Files
dotfiles/pi/.pi/agent/extensions/subagent-pane-reminder.ts
2026-08-08 13:27:14 +02:00

115 lines
3.9 KiB
TypeScript

/**
* Subagent pane lifecycle reminder.
*
* When the parent agent dispatches to a `pane: true` agent via `subagent`,
* this extension floats a cleanup reminder to the parent only:
*
* - immediately: appended to the `subagent` tool result of that call;
* - persistently: a one-line system-prompt note on later turns until the
* parent calls `stop_subagent` for that agent (or the session ends).
*
* Nothing is injected for bg agents (`pane: false`), for `delegate_subagent`
* (pane targets are rejected there anyway), for `/agents` commands, or in
* sessions that never spawn a pane agent. Other sessions and workflows stay
* untouched.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";
import * as path from "node:path";
import { homedir } from "node:os";
interface SubagentInput {
agent?: unknown;
tasks?: Array<{ agent?: unknown }>;
chain?: Array<{ agent?: unknown }>;
}
const USER_AGENT_DIRS: string[] = [
path.join(homedir(), ".pi", "agent", "agents"),
path.join(homedir(), ".claude", "agents"),
];
function projectAgentDirs(cwd: string): string[] {
const dirs: string[] = [];
let current = path.resolve(cwd);
for (;;) {
dirs.push(path.join(current, ".pi", "agents"), path.join(current, ".claude", "agents"));
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return dirs;
}
function hasPaneFrontmatter(text: string): boolean {
const block = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
return block ? /^\s*pane\s*:\s*(?:true|yes|on)\s*$/im.test(block[1]) : false;
}
function isPaneAgent(name: string, cwd: string): boolean {
for (const dir of [...USER_AGENT_DIRS, ...projectAgentDirs(cwd)]) {
for (const ext of [".md", ".markdown"]) {
try {
if (hasPaneFrontmatter(fs.readFileSync(path.join(dir, `${name}${ext}`), "utf8"))) return true;
} catch {
// not present in this directory
}
}
}
return false;
}
function agentNamesFrom(input: SubagentInput): string[] {
const names = new Set<string>();
const add = (value: unknown) => {
if (typeof value === "string" && value.trim()) names.add(value.trim());
};
add(input.agent);
for (const item of input.tasks ?? []) add(item.agent);
for (const item of input.chain ?? []) add(item.agent);
return [...names];
}
function reminderText(names: string[]): string {
const quoted = names.map((name) => `\`${name}\``).join(", ");
return (
`\n\nPane cleanup: you spawned persistent pane agent(s) ${quoted}. ` +
`After you collect the result, call \`stop_subagent\` with the agent name to kill its tmux pane. ` +
`Skip only if you plan a follow-up (steer or another subagent call to the same agent); stop it at the end of the workflow. ` +
`Stopping preserves the session file, so a later \`subagent\` call resumes the agent's memory.`
);
}
export default function (pi: ExtensionAPI) {
// Agent names whose panes are still alive this session.
const pending = new Set<string>();
pi.on("tool_call", (event) => {
if (event.toolName === "stop_subagent") {
const agent = (event.input as { agent?: unknown } | undefined)?.agent;
if (typeof agent === "string") pending.delete(agent);
}
});
pi.on("tool_result", (event, ctx) => {
if (event.toolName !== "subagent" || event.isError) return;
const input = (event.input as SubagentInput | undefined) ?? {};
const paneAgents = agentNamesFrom(input).filter((name) => isPaneAgent(name, ctx.cwd));
if (paneAgents.length === 0) return;
for (const name of paneAgents) pending.add(name);
return {
content: [...(event.content ?? []), { type: "text" as const, text: reminderText(paneAgents) }],
};
});
pi.on("before_agent_start", (event) => {
if (pending.size === 0) return;
const note =
"\n\nPane cleanup (pending): " +
`[${[...pending].join(", ")}]. ` +
"After collecting their results, call stop_subagent for each (unless a follow-up is planned).";
return { systemPrompt: event.systemPrompt + note };
});
}