feat(pi): 3 new Pi Dev extensions — read-tweet, auto-context, task-lifecycle tools
CI / build (push) Has been cancelled

This commit is contained in:
Lap Tran
2026-06-26 22:40:49 -04:00
parent c2355954b9
commit 287cfeff7c
7 changed files with 452 additions and 11 deletions
+141
View File
@@ -0,0 +1,141 @@
import type { ExtensionAPI, ExtensionContext, BeforeAgentStartEventResult } from "@earendil-works/pi-coding-agent";
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
import { basename, join } from "path";
import { homedir } from "os";
const AUTOMATON_HOME = join(homedir(), ".automaton");
const STALE_MINUTES = 30;
interface TaskInfo {
name: string;
phase: string;
mtime: Date;
}
function getTasks(autoDir: string): TaskInfo[] {
const tasksDir = join(autoDir, ".automaton", "tasks");
if (!existsSync(tasksDir)) return [];
try {
const entries = readdirSync(tasksDir);
const tasks: TaskInfo[] = [];
for (const entry of entries) {
const stateFile = join(tasksDir, entry, ".state");
if (existsSync(stateFile)) {
try {
const phase = readFileSync(stateFile, "utf-8").trim();
if (!phase) continue;
const stat = statSync(stateFile);
tasks.push({ name: entry, phase, mtime: stat.mtime });
} catch {
// skip unreadable
}
}
}
tasks.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
return tasks;
} catch {
return [];
}
}
function getLoopInfo(autoDir: string): string[] {
const loopsDir = join(autoDir, ".automaton", "loops");
if (!existsSync(loopsDir)) return [];
try {
const entries = readdirSync(loopsDir);
const lines: string[] = [];
for (const entry of entries) {
const stateFile = join(loopsDir, entry, ".state.loop");
if (existsSync(stateFile)) {
try {
const content = readFileSync(stateFile, "utf-8").trim();
const state = JSON.parse(content);
const taskRef = state.current_task ? `, active task: ${state.current_task}` : "";
lines.push(`Loop "${entry}": ${state.status || "unknown"}${taskRef}`);
} catch {
// skip unparseable
}
}
}
return lines;
} catch {
return [];
}
}
export default function (pi: ExtensionAPI) {
pi.on("before_agent_start", async (_event, ctx): Promise<BeforeAgentStartEventResult | undefined> => {
const cwd = ctx.cwd;
const inFramework = cwd === AUTOMATON_HOME || cwd.startsWith(AUTOMATON_HOME + "/");
let baseDir: string | null = null;
let scopeLabel: string;
if (inFramework) {
baseDir = AUTOMATON_HOME;
scopeLabel = "framework (~/.automaton/)";
} else {
const projectAuto = join(cwd, ".automaton");
if (existsSync(projectAuto)) {
baseDir = cwd;
const projectName = basename(cwd) || "project";
scopeLabel = `project (${projectName}/.automaton/)`;
} else {
return; // not in automaton context
}
}
const tasks = getTasks(baseDir);
const editTasks = tasks.filter((t) => t.phase === "implement" || t.phase === "doc_review");
const currentTask = editTasks.length > 0 ? editTasks[0] : tasks.length > 0 ? tasks[0] : null;
const lines: string[] = [];
lines.push(`Automaton scope: ${scopeLabel}`);
lines.push("");
lines.push("How Automaton works:");
lines.push("- Automaton enforces a task phase state machine. File edits are ONLY allowed when a task is in 'implement' or 'doc_review' phase.");
lines.push("- When the user asks you to build, fix, or change something, you MUST first create a task (use automaton_create_task) and transition it to 'implement' before you can edit any files.");
lines.push("- Without a task in 'implement', the automaton-guard-pi extension will BLOCK all file edits.");
lines.push(`- Available tools: automaton_create_task (create+transition), automaton_transition (change phase), automaton_status (check state).`);
lines.push("");
if (currentTask) {
lines.push(`State: Active task "${currentTask.name}" is in "${currentTask.phase}" phase.`);
if (currentTask.phase === "implement" || currentTask.phase === "doc_review") {
lines.push(`You CAN edit files under this task.`);
const ageMinutes = Math.round((Date.now() - currentTask.mtime.getTime()) / 60000);
if (ageMinutes > STALE_MINUTES) {
lines.push(
`Note: task has been in ${currentTask.phase} for ${ageMinutes} min and is considered stale. ` +
`Run automaton_transition (or touch via status.py) if still active.`,
);
}
} else {
lines.push(`File edits are BLOCKED. Call automaton_transition to move it to 'implement' before editing.`);
}
} else {
lines.push(`State: No active tasks. When the user makes a work request, call automaton_create_task first.`);
}
const byPhase = new Map<string, number>();
for (const t of tasks) {
byPhase.set(t.phase, (byPhase.get(t.phase) || 0) + 1);
}
const summary = Array.from(byPhase.entries())
.sort((a, b) => b[1] - a[1])
.map(([p, c]) => `${p} (${c})`)
.join(", ");
lines.push(`All tasks: ${summary || "none"}`);
const loopInfo = getLoopInfo(baseDir);
for (const l of loopInfo) {
lines.push(l);
}
return {
systemPrompt: `${_event.systemPrompt}\n\n## Automaton Context\n\n${lines.join("\n")}`,
};
});
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "pi-automaton-context",
"version": "1.0.0",
"description": "Auto-injects Automaton framework context into Pi Dev system prompt — no more manual copy-paste of pi-automaton.sh output",
"main": "index.ts",
"type": "module",
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
}
+171
View File
@@ -0,0 +1,171 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { execSync } from "child_process";
import { existsSync, readFileSync, readdirSync } from "fs";
import { join } from "path";
import { homedir } from "os";
const STATUS_SCRIPT = join(homedir(), ".automaton", "scripts", "status.py");
function resolveProjectDir(cwd: string): string | null {
const inFramework = cwd === join(homedir(), ".automaton") || cwd.startsWith(join(homedir(), ".automaton") + "/");
if (inFramework) return homedir() + "/.automaton";
if (existsSync(join(cwd, ".automaton"))) return cwd;
return null;
}
function getTasks(projectDir: string) {
const tasksDir = join(projectDir, ".automaton", "tasks");
if (!existsSync(tasksDir)) return [];
const tasks: { name: string; phase: string }[] = [];
for (const entry of readdirSync(tasksDir)) {
const stateFile = join(tasksDir, entry, ".state");
if (existsSync(stateFile)) {
try {
const phase = readFileSync(stateFile, "utf-8").trim();
if (phase) tasks.push({ name: entry, phase });
} catch {}
}
}
tasks.sort((a, b) => a.name.localeCompare(b.name));
return tasks;
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "automaton_create_task",
label: "Create Automaton Task",
description:
"Create a new automaton task and transition it to the specified phase. " +
"Use this when the user asks you to do work that requires file edits — you need a task in 'implement' or 'doc_review' phase before you can edit files.",
promptSnippet: "Create automaton tasks for work management",
promptGuidelines: [
"When the user asks you to build, implement, fix, or change something, first call automaton_create_task to create a task and transition it to 'implement' phase",
"Only after the task is in 'implement' can you edit files — the guard will block edits otherwise",
"Use a descriptive task name based on what the user wants (e.g., 'add-login-page', 'fix-api-timeout')",
"Default phase is 'implement' — omit phase for most cases",
],
parameters: Type.Object({
name: Type.String({ minLength: 1, description: "Task name (kebab-case, e.g. 'add-login-page')" }),
phase: Type.Optional(Type.String({ default: "implement", description: "Phase to transition to after creation" })),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { name, phase = "implement" } = params;
const projectDir = resolveProjectDir(process.cwd());
if (!projectDir) {
return {
content: [{ type: "text", text: "Not in an automaton-managed project. No .automaton/ directory found." }],
details: {},
};
}
try {
const createOut = execSync(
`python3 ${STATUS_SCRIPT} --create-task ${JSON.stringify(name)} --project ${JSON.stringify(projectDir)}`,
{ encoding: "utf-8", timeout: 10000 },
);
const transitionOut = execSync(
`python3 ${STATUS_SCRIPT} --transition ${phase} --task ${JSON.stringify(name)} --project ${JSON.stringify(projectDir)}`,
{ encoding: "utf-8", timeout: 10000 },
);
return {
content: [{ type: "text", text: `Created task "${name}" and transitioned to "${phase}".` }],
details: { task: name, phase },
};
} catch (e: any) {
return {
content: [{
type: "text",
text: `Failed to create task: ${e.stderr || e.message || String(e)}`,
}],
details: { error: e.stderr || e.message, task: name },
};
}
},
});
pi.registerTool({
name: "automaton_transition",
label: "Transition Automaton Task",
description:
"Transition an existing automaton task to a new phase. " +
"Valid phases: research, decomposition, design, implement, test_design, testing, doc_review, complete. " +
"Use this to move a task forward (e.g. from research to implement).",
promptSnippet: "Transition automaton tasks between phases",
parameters: Type.Object({
task: Type.String({ minLength: 1, description: "Task name" }),
phase: Type.String({ minLength: 1, description: "Target phase" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { task, phase } = params;
const projectDir = resolveProjectDir(process.cwd());
if (!projectDir) {
return {
content: [{ type: "text", text: "Not in an automaton-managed project." }],
details: {},
};
}
try {
execSync(
`python3 ${STATUS_SCRIPT} --transition ${phase} --task ${JSON.stringify(task)} --project ${JSON.stringify(projectDir)}`,
{ encoding: "utf-8", timeout: 10000 },
);
return {
content: [{ type: "text", text: `Task "${task}" transitioned to "${phase}".` }],
details: { task, phase },
};
} catch (e: any) {
return {
content: [{
type: "text",
text: `Failed to transition task: ${e.stderr || e.message || String(e)}`,
}],
details: { error: e.stderr || e.message, task, phase },
};
}
},
});
pi.registerTool({
name: "automaton_status",
label: "Automaton Status",
description:
"Show current automaton project status — all tasks, their phases, and any running loops. " +
"Call this to check what state things are in before deciding what to do.",
promptSnippet: "Check automaton project status",
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
const projectDir = resolveProjectDir(process.cwd());
if (!projectDir) {
return {
content: [{ type: "text", text: "Not in an automaton-managed project." }],
details: {},
};
}
const tasks = getTasks(projectDir);
const editTasks = tasks.filter((t) => t.phase === "implement" || t.phase === "doc_review");
const currentTask = editTasks.length > 0 ? editTasks[0] : tasks.length > 0 ? tasks[0] : null;
const lines: string[] = [];
lines.push(`Project: ${projectDir.split("/").pop()}`);
if (currentTask) {
lines.push(`Current: "${currentTask.name}" (${currentTask.phase})`);
} else {
lines.push("No tasks. Create one with automaton_create_task.");
}
lines.push("");
for (const t of tasks) {
const marker = currentTask && t.name === currentTask.name ? ">" : " ";
lines.push(`${marker} ${t.name.padEnd(35)} ${t.phase}`);
}
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { tasks: tasks.length, current: currentTask?.name || null },
};
},
});
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "pi-automaton-tools",
"version": "1.0.0",
"description": "Registers automaton_create_task and automaton_transition tools so the agent can manage task lifecycle automatically",
"main": "index.ts",
"type": "module",
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
}
+91
View File
@@ -0,0 +1,91 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "@sinclair/typebox";
const BIRD_BIN = "/opt/homebrew/bin/bird";
const TweetParams = Type.Object({
url: Type.String({ minLength: 1, description: "Tweet URL or numeric ID" }),
mode: Type.Optional(
Type.Union([
Type.Literal("read"),
Type.Literal("thread"),
Type.Literal("replies"),
]),
),
});
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "read_tweet",
label: "Read Tweet",
description:
"Read an X/Twitter tweet or thread using the local bird CLI. " +
"Returns tweet author, text, media, metrics, and timestamps. " +
"Use this instead of webfetch for x.com/twitter.com URLs.",
promptSnippet: "Read X/Twitter tweets with bird CLI",
promptGuidelines: [
"Use read_tweet when the user shares an x.com or twitter.com URL and asks what it says",
"Use mode='thread' for full conversation threads",
"Use mode='replies' to fetch replies to a tweet",
"If fetch fails with auth errors, ask the user to sign in to x.com in their browser and retry",
],
parameters: TweetParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const { url, mode = "read" } = params;
try {
const { execSync } = await import("child_process");
const cmd = `${BIRD_BIN} ${mode} ${JSON.stringify(url)} --json`;
const stdout = execSync(cmd, { encoding: "utf-8", timeout: 15000 });
const parsed = JSON.parse(stdout);
const author = parsed.author?.name || parsed.author?.screen_name || "unknown";
const text = parsed.text || parsed.content || "";
const createdAt = parsed.created_at || "";
const retweetCount = parsed.metrics?.retweet_count ?? parsed.metrics?.retweets ?? 0;
const likeCount = parsed.metrics?.like_count ?? parsed.metrics?.likes ?? 0;
const replyCount = parsed.metrics?.reply_count ?? parsed.metrics?.replies ?? 0;
const mediaCount = parsed.media?.length ?? 0;
const threadCount = parsed.thread?.tweets?.length ?? 0;
const repliesCount = parsed.replies?.length ?? 0;
const lines: string[] = [];
lines.push(`Author: ${author}`);
if (createdAt) lines.push(`Posted: ${createdAt}`);
lines.push("");
lines.push(text);
if (retweetCount || likeCount || replyCount) {
lines.push("");
lines.push(`Retweets: ${retweetCount} Likes: ${likeCount} Replies: ${replyCount}`);
}
if (mediaCount) lines.push(`Media: ${mediaCount} attachment(s)`);
if (threadCount) lines.push(`Thread: ${threadCount} tweets total`);
if (repliesCount) lines.push(`Replies fetched: ${repliesCount}`);
return {
content: [
{ type: "text", text: lines.join("\n") },
{ type: "text", text: `\n--- raw ---\n${JSON.stringify(parsed, null, 2)}` },
],
details: {
author,
text: text.slice(0, 500),
url,
mode,
tweetCount: threadCount || repliesCount || 1,
},
};
} catch (e: any) {
const errMsg = e.stderr || e.message || String(e);
const hint = errMsg.includes("auth")
? " Sign in to x.com in your browser and retry."
: "";
return {
content: [{ type: "text", text: `Failed to fetch tweet: ${errMsg}${hint}` }],
details: { error: errMsg, url, mode },
};
}
},
});
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "pi-read-tweet",
"version": "1.0.0",
"description": "Read X/Twitter tweets using the local `bird` CLI — registered as a read_tweet tool for Pi Dev",
"main": "index.ts",
"type": "module",
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
}
}
+19 -11
View File
@@ -54,18 +54,26 @@ else
echo "OpenCode: not detected (no ~/.config/opencode/opencode.json or .jsonc)" echo "OpenCode: not detected (no ~/.config/opencode/opencode.json or .jsonc)"
fi fi
# Pi Dev guard # Pi Dev extensions
PI_SOURCE="$FRAMEWORK_DIR/plugins/automaton-guard-pi" PI_EXTENSIONS=(
"$FRAMEWORK_DIR/plugins/automaton-guard-pi"
"$FRAMEWORK_DIR/plugins/pi-read-tweet"
"$FRAMEWORK_DIR/plugins/pi-automaton-context"
"$FRAMEWORK_DIR/plugins/pi-automaton-tools"
)
if command -v pi &>/dev/null; then if command -v pi &>/dev/null; then
INSTALLED=$(pi list 2>/dev/null | grep -c "automaton-guard-pi" || true) for ext in "${PI_EXTENSIONS[@]}"; do
if [ "$INSTALLED" -gt 0 ]; then ext_name=$(basename "$ext")
echo "Pi Dev: already installed" INSTALLED=$(pi list 2>/dev/null | grep -c "$ext_name" || true)
else if [ "$INSTALLED" -gt 0 ]; then
echo "Pi Dev: installing guard extension..." echo "Pi Dev: $ext_name already installed"
pi install "$PI_SOURCE" 2>&1 | sed 's/^/ /' else
INSTALLED_PI=true echo "Pi Dev: installing $ext_name..."
echo "Pi Dev: installed" pi install "$ext" 2>&1 | sed 's/^/ /'
fi INSTALLED_PI=true
echo "Pi Dev: $ext_name installed"
fi
done
# Offer pi-automaton startup wrapper # Offer pi-automaton startup wrapper
PI_WRAPPER="$FRAMEWORK_DIR/scripts/pi-automaton.sh" PI_WRAPPER="$FRAMEWORK_DIR/scripts/pi-automaton.sh"
if [ -f "$PI_WRAPPER" ]; then if [ -f "$PI_WRAPPER" ]; then