ported over subagents, minor changes to functionality, refined ui

This commit is contained in:
Amos
2026-06-27 00:19:44 +03:00
parent c6c00f89a7
commit c47a653336
17 changed files with 958 additions and 605 deletions
+240 -116
View File
@@ -24,8 +24,6 @@ import {
getMuxBackend,
sendEscape,
shellEscape,
renameCurrentTab,
renameWorkspace,
readScreen,
} from "./cmux.ts";
@@ -33,6 +31,8 @@ import {
findLastAssistantMessage,
getNewEntries,
seedSubagentSessionFile,
summarizeSessionStats,
type SessionStats,
} from "./session.ts";
import {
type StatusSnapshot,
@@ -136,8 +136,13 @@ interface AgentDefaults {
tools?: string;
skills?: string;
thinking?: string;
denyTools?: string;
spawning?: boolean;
/**
* If set (non-empty), this agent is granted the full subagent spawning
* toolset and may only spawn the listed agents. Presence of this field —
* not the `tools` list — is what grants spawning. Enforced in the child via
* the PI_SUBAGENT_ALLOWED env var.
*/
subagentAgents?: string[];
autoExit?: boolean;
interactive?: boolean;
systemPromptMode?: "append" | "replace";
@@ -160,46 +165,62 @@ interface ListedAgentDefinition extends AgentDefinition {
source: AgentSource;
}
/** Tools that are gated by `spawning: false` */
const SPAWNING_TOOLS = new Set([
/**
* The full subagent lifecycle/spawning toolset registered by this extension.
* An agent is granted these (and this extension is loaded into its child
* process) only when its frontmatter declares a non-empty `subagent_agents`.
*/
const SPAWNING_TOOLS = [
"subagent",
"subagent_interrupt",
"subagents_list",
"subagent_resume",
]);
] as const;
/**
* Resolve the effective set of denied tool names from agent defaults.
* `spawning: false` expands to all SPAWNING_TOOLS.
* `deny-tools` adds individual tool names on top.
*/
function resolveDenyTools(agentDefs: AgentDefaults | null): Set<string> {
const denied = new Set<string>();
if (!agentDefs) return denied;
// spawning: false → deny all spawning tools
if (agentDefs.spawning === false) {
for (const t of SPAWNING_TOOLS) denied.add(t);
}
// deny-tools: explicit list
if (agentDefs.denyTools) {
for (const t of agentDefs.denyTools
.split(",")
.map((s) => s.trim())
.filter(Boolean)) {
denied.add(t);
}
}
return denied;
}
/** Built-in tools pi provides natively — no extension needs to be loaded. */
const BUILTIN_TOOLS = new Set(["read", "write", "edit", "bash", "grep", "find", "ls"]);
/** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */
function getAgentConfigDir(): string {
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
}
/**
* Map a custom (non-built-in) tool name to the pi-extension file that
* registers it. Used to build the child's `--extension` whitelist after
* `--no-extensions` disables global discovery. Returns undefined for built-in
* tools and for unknown names (which simply won't be granted).
*/
function getToolExtensionPath(tool: string): string | undefined {
if (BUILTIN_TOOLS.has(tool)) return undefined;
// The four spawning tools are registered by THIS extension.
if ((SPAWNING_TOOLS as readonly string[]).includes(tool)) {
return fileURLToPath(import.meta.url);
}
const extBase = join(getAgentConfigDir(), "extensions");
const map: Record<string, string> = {
web_search: join(extBase, "web-search", "index.ts"),
web_fetch: join(extBase, "web-fetch", "index.ts"),
video_extract: join(extBase, "video-extract", "index.ts"),
youtube_search: join(extBase, "youtube-search", "index.ts"),
google_image_search: join(extBase, "google-image-search", "index.ts"),
safe_bash: join(SUBAGENTS_DIR, "tools", "safe-bash.ts"),
};
return map[tool];
}
/**
* When this process was spawned as a restricted subagent, the parent pins the
* set of agents it may itself spawn via PI_SUBAGENT_ALLOWED. `null` means no
* restriction (top-level session, or an unrestricted child).
*/
const SUBAGENT_ALLOWLIST: Set<string> | null = (() => {
const raw = process.env.PI_SUBAGENT_ALLOWED;
if (!raw) return null;
const list = raw.split(",").map((s) => s.trim()).filter(Boolean);
return list.length > 0 ? new Set(list) : null;
})();
function getBundledAgentsDir(): string {
return join(SUBAGENTS_DIR, "../../agents");
}
@@ -213,6 +234,13 @@ function parseOptionalBoolean(value: string | undefined): boolean | undefined {
return value != null ? value === "true" : undefined;
}
/** Parse a comma-separated frontmatter value into a trimmed list (or undefined). */
function parseCommaList(value: string | undefined): string[] | undefined {
if (value == null) return undefined;
const list = value.split(",").map((s) => s.trim()).filter(Boolean);
return list.length > 0 ? list : undefined;
}
function parseSessionMode(value: string | undefined): SubagentSessionMode | undefined {
if (value === "standalone" || value === "lineage-only" || value === "fork") {
return value;
@@ -241,8 +269,7 @@ function parseAgentDefinition(content: string, fallbackName: string): AgentDefin
: undefined,
skills: getFrontmatterValue(frontmatter, "skill") ?? getFrontmatterValue(frontmatter, "skills"),
thinking: getFrontmatterValue(frontmatter, "thinking"),
denyTools: getFrontmatterValue(frontmatter, "deny-tools"),
spawning: parseOptionalBoolean(getFrontmatterValue(frontmatter, "spawning")),
subagentAgents: parseCommaList(getFrontmatterValue(frontmatter, "subagent_agents")),
autoExit: parseOptionalBoolean(getFrontmatterValue(frontmatter, "auto-exit")),
interactive: parseOptionalBoolean(getFrontmatterValue(frontmatter, "interactive")),
sessionMode: parseSessionMode(getFrontmatterValue(frontmatter, "session-mode")),
@@ -274,7 +301,10 @@ function discoverAgentDefinitions(): ListedAgentDefinition[] {
}
}
return [...agents.values()];
// When this process is itself a restricted subagent, only expose the agents
// it is permitted to spawn (PI_SUBAGENT_ALLOWED). Top-level sessions see all.
const all = [...agents.values()];
return SUBAGENT_ALLOWLIST ? all.filter((a) => SUBAGENT_ALLOWLIST.has(a.name)) : all;
}
function resolveSubagentPaths(
@@ -380,6 +410,71 @@ function formatElapsed(seconds: number): string {
return `${m}m ${s}s`;
}
/** Compact token count: 850, 3.2k, 45k. */
function formatTokens(n: number): string {
return n < 1000 ? String(n) : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`;
}
/**
* Known context-window sizes by model id substring, used for the context-usage
* gauge. Unknown models fall back to a window-less "Nk ctx" label.
*/
function contextWindowFor(model: string | null | undefined): number | undefined {
if (!model) return undefined;
const m = model.toLowerCase();
if (m.includes("claude")) return 200_000;
if (m.includes("gpt-4.1") || m.includes("gpt-4o")) return 128_000;
if (m.includes("gemini")) return 1_000_000;
return undefined;
}
/** Context-usage gauge: "18.0%/200k" when window known, else "37k ctx". */
function formatContextUsage(tokens: number, contextWindow: number | undefined): string {
if (!contextWindow) return `${formatTokens(tokens)} ctx`;
const pct = (tokens / contextWindow) * 100;
const maxStr =
contextWindow >= 1_000_000
? `${(contextWindow / 1_000_000).toFixed(1)}M`
: `${Math.round(contextWindow / 1000)}k`;
return `${pct.toFixed(1)}%/${maxStr}`;
}
/**
* Build the dim usage line for a completed subagent, mirroring the format of
* the in-process subagents extension: "↑in ↓out R… W… $cost · ctx".
* `theme.fg` is applied by the caller; this returns plain segments joined.
*/
function formatUsageSegments(stats: SessionStats): string[] {
const segs: string[] = [];
if (stats.inputTokens) segs.push(`↑${formatTokens(stats.inputTokens)}`);
if (stats.outputTokens) segs.push(`↓${formatTokens(stats.outputTokens)}`);
if (stats.cacheReadTokens) segs.push(`R${formatTokens(stats.cacheReadTokens)}`);
if (stats.cacheWriteTokens) segs.push(`W${formatTokens(stats.cacheWriteTokens)}`);
if (stats.cost) segs.push(`$${stats.cost.toFixed(3)}`);
return segs;
}
/** ANSI colors for widget status icons (raw, since the widget bypasses theme). */
const ICON_GREEN = "\x1b[38;2;126;186;103m";
const ICON_YELLOW = "\x1b[38;2;214;181;94m";
const ICON_RED = "\x1b[38;2;224;108;117m";
const ICON_DIM = "\x1b[38;2;128;128;128m";
/** Map a live status kind to a colored single-char icon for the widget. */
function widgetIcon(kind: StatusSnapshot["kind"]): string {
switch (kind) {
case "active":
case "running":
return `${ICON_YELLOW}⟳${RST}`;
case "stalled":
return `${ICON_RED}⟳${RST}`;
case "waiting":
case "starting":
default:
return `${ICON_DIM}○${RST}`;
}
}
/**
* Wait long enough for a freshly created pane to finish shell startup.
*
@@ -481,6 +576,8 @@ interface SubagentResult {
/** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
errorMessage?: string;
ping?: { name: string; message: string };
/** Aggregate usage/model/tool stats parsed from the completed session file. */
stats?: SessionStats;
}
/**
@@ -606,8 +703,9 @@ function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): st
for (const agent of agents) {
const elapsed = formatElapsedMMSS(agent.startTime);
const agentTag = agent.agent ? ` (${agent.agent})` : "";
const left = ` ${elapsed} ${agent.name}${agentTag} `;
const snapshot = classifyStatus(agent.statusState, Date.now());
const icon = widgetIcon(snapshot.kind);
const left = ` ${icon} ${elapsed} ${agent.name}${agentTag} `;
const right = statusConfig.enabled
? formatWidgetRightLabel(snapshot)
: agent.cli === "claude"
@@ -670,15 +768,25 @@ const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const;
* control tools from subagent-done.ts would otherwise be hidden, leaving a
* manually resumed or user-touched subagent unable to call subagent_done.
*/
function buildSubagentToolAllowlist(effectiveTools?: string): string | null {
function buildSubagentToolAllowlist(
effectiveTools?: string,
opts?: { grantSpawning?: boolean },
): string | null {
const requested = (effectiveTools ?? "")
.split(",")
.map((tool) => tool.trim())
.filter(Boolean);
if (requested.length === 0) return null;
const grantSpawning = opts?.grantSpawning ?? false;
// No explicit tool restriction and no spawning grant → don't pass --tools at
// all (the child keeps its default toolset).
if (requested.length === 0 && !grantSpawning) return null;
const allow = new Set(requested);
if (grantSpawning) {
for (const tool of SPAWNING_TOOLS) allow.add(tool);
}
for (const tool of SUBAGENT_CONTROL_TOOLS) {
allow.add(tool);
}
@@ -905,7 +1013,7 @@ export const __test__ = {
buildPiPromptArgs,
formatWidgetRightLabel,
observeRunningSubagent,
resolveDenyTools,
getToolExtensionPath,
resolveInterruptTarget,
requestSubagentInterrupt,
handleSubagentInterrupt,
@@ -913,6 +1021,11 @@ export const __test__ = {
resolveResumeLaunchBehavior,
runningSubagents,
formatElapsed,
formatTokens,
formatContextUsage,
contextWindowFor,
formatUsageSegments,
widgetIcon,
};
function startWidgetRefresh() {
@@ -998,7 +1111,9 @@ async function launchSubagent(
const summaryInstruction = agentDefs?.autoExit
? "Your FINAL assistant message should summarize what you accomplished."
: "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished.";
const denySet = resolveDenyTools(agentDefs);
// An agent with a non-empty subagent_agents list is granted the spawning
// toolset and may only spawn the listed agents (enforced via PI_SUBAGENT_ALLOWED).
const grantSpawning = !!(agentDefs?.subagentAgents && agentDefs.subagentAgents.length > 0);
const identity = agentDefs?.body ?? params.systemPrompt ?? null;
const systemPromptMode = agentDefs?.systemPromptMode;
const identityInSystemPrompt = systemPromptMode && identity;
@@ -1111,12 +1226,28 @@ async function launchSubagent(
parts.push(flag, shellEscape(syspromptPath));
}
const toolAllowlist = buildSubagentToolAllowlist(effectiveTools);
// Default-deny model: when an agent restricts its tools (or is granted the
// spawning toolset), we disable global extension discovery and re-enable only
// the extensions backing the whitelisted tools. Bare/fork spawns with no tool
// restriction keep their full default toolset and all global extensions.
const toolAllowlist = buildSubagentToolAllowlist(effectiveTools, { grantSpawning });
if (toolAllowlist) {
parts.push("--no-extensions");
parts.push("--tools", shellEscape(toolAllowlist));
const extPaths = new Set<string>();
for (const tool of toolAllowlist.split(",")) {
const extPath = getToolExtensionPath(tool);
// Skip extensions that can't be resolved on disk so a missing optional
// extension never breaks the launch (the tool simply won't be available).
if (extPath && existsSync(extPath)) extPaths.add(extPath);
}
for (const extPath of extPaths) {
parts.push("-e", shellEscape(extPath));
}
}
// Build env prefix: denied tools + subagent identity + config dir propagation
// Build env prefix: subagent identity + config dir propagation + spawn allowlist
const envParts: string[] = [];
// If the target cwd has its own .pi/agent/, use that as the config root.
@@ -1127,8 +1258,8 @@ async function launchSubagent(
envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
}
if (denySet.size > 0) {
envParts.push(`PI_DENY_TOOLS=${shellEscape([...denySet].join(","))}`);
if (grantSpawning && agentDefs?.subagentAgents) {
envParts.push(`PI_SUBAGENT_ALLOWED=${shellEscape(agentDefs.subagentAgents.join(","))}`);
}
envParts.push(`PI_SUBAGENT_NAME=${shellEscape(params.name)}`);
if (params.agent) {
@@ -1316,6 +1447,8 @@ async function watchSubagent(
: "Sub-agent exited without output";
}
const stats = existsSync(sessionFile) ? summarizeSessionStats(sessionFile) : null;
closeSurface(surface);
runningSubagents.delete(running.id);
@@ -1328,6 +1461,7 @@ async function watchSubagent(
elapsed,
ping: result.ping,
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
...(stats ? { stats } : {}),
};
} catch (err: any) {
try {
@@ -1383,19 +1517,13 @@ export default function subagentsExtension(pi: ExtensionAPI) {
runningSubagents.clear();
});
// Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter)
const deniedTools = new Set(
(process.env.PI_DENY_TOOLS ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
const shouldRegister = (name: string) => !deniedTools.has(name);
// The spawning tools are always registered here. Whether a child process can
// actually see/use them is governed by the parent's `--tools` allowlist and
// by which extensions are loaded into the child (default-deny --no-extensions
// + explicit -e). See launchSubagent().
// ── subagent tool ──
if (shouldRegister("subagent"))
pi.registerTool({
pi.registerTool({
name: "subagent",
label: "Subagent",
description:
@@ -1429,6 +1557,22 @@ export default function subagentsExtension(pi: ExtensionAPI) {
};
}
// Enforce the spawn allowlist when this process is itself a restricted
// subagent (e.g. a worker may only spawn scout/researcher).
if (params.agent && SUBAGENT_ALLOWLIST && !SUBAGENT_ALLOWLIST.has(params.agent)) {
return {
content: [
{
type: "text",
text:
`You may not spawn the "${params.agent}" agent. ` +
`You are only permitted to spawn: ${[...SUBAGENT_ALLOWLIST].join(", ")}.`,
},
],
details: { error: "agent not in allowlist" },
};
}
// Validate prerequisites
if (!isMuxAvailable()) {
return muxUnavailableResult();
@@ -1499,6 +1643,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
sessionFile: result.sessionFile,
...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
...(result.claudeSessionId ? { claudeSessionId: result.claudeSessionId } : {}),
...(result.stats ? { stats: result.stats } : {}),
},
},
{ triggerTurn: true, deliverAs: "steer" },
@@ -1552,7 +1697,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
? theme.fg("dim", ` in ${partialArgs.cwd}`)
: "";
let text =
"▸ " +
"○ " +
theme.fg("toolTitle", theme.bold(name)) +
agent +
cwdHint;
@@ -1582,7 +1727,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
// "Started" result — tool returned immediately
if (details?.status === "started") {
return new Text(
theme.fg("accent", "▸") +
theme.fg("accent", "⟳") +
" " +
theme.fg("toolTitle", theme.bold(name)) +
theme.fg("dim", " — started"),
@@ -1598,8 +1743,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
});
// ── subagent_interrupt tool ──
if (shouldRegister("subagent_interrupt"))
pi.registerTool({
pi.registerTool({
name: "subagent_interrupt",
label: "Interrupt Subagent",
description:
@@ -1622,7 +1766,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
renderCall(args, theme) {
const target = args.id ? `${args.id}` : args.name ?? "(unknown)";
return new Text(
theme.fg("accent", "▸") +
theme.fg("accent", "○") +
" " +
theme.fg("toolTitle", theme.bold(target)) +
theme.fg("dim", " — interrupt turn"),
@@ -1635,7 +1779,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
const details = result.details as any;
if (details?.status === "interrupt_requested") {
return new Text(
theme.fg("accent", "▸") +
theme.fg("accent", "✗") +
" " +
theme.fg("toolTitle", theme.bold(details.name ?? details.id ?? "subagent")) +
theme.fg("dim", " — interrupt requested"),
@@ -1650,8 +1794,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
});
// ── subagents_list tool ──
if (shouldRegister("subagents_list"))
pi.registerTool({
pi.registerTool({
name: "subagents_list",
label: "List Subagents",
description:
@@ -1706,8 +1849,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
// ── subagent_resume tool ──
if (shouldRegister("subagent_resume"))
pi.registerTool({
pi.registerTool({
name: "subagent_resume",
label: "Resume Subagent",
description:
@@ -1745,7 +1887,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
renderCall(args, theme) {
const name = args.name ?? "Resume";
const text =
"▸ " +
"○ " +
theme.fg("toolTitle", theme.bold(name)) +
theme.fg("dim", " — resuming session");
return new Text(text, 0, 0);
@@ -1757,7 +1899,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
if (details?.status === "started") {
return new Text(
theme.fg("accent", "▸") +
theme.fg("accent", "⟳") +
" " +
theme.fg("toolTitle", theme.bold(name)) +
theme.fg("dim", " — resumed"),
@@ -1963,18 +2105,6 @@ export default function subagentsExtension(pi: ExtensionAPI) {
},
});
// /iterate command — fork the session into a subagent
pi.registerCommand("iterate", {
description: "Fork session into a subagent for focused work (bugfixes, iteration)",
handler: async (args, _ctx) => {
const task = args.trim() || "";
const toolCall = task
? `Use subagent to fork a session. fork: true, name: "Iterate", task: ${JSON.stringify(task)}`
: `Use subagent to fork a session. fork: true, name: "Iterate", task: "The user wants to do some hands-on work. Help them with whatever they need."`;
pi.sendUserMessage(toolCall);
},
});
// /subagent command — spawn a subagent by name
pi.registerCommand("subagent", {
description: "Spawn a subagent: /subagent <agent> <task>",
@@ -2020,17 +2150,40 @@ export default function subagentsExtension(pi: ExtensionAPI) {
const bgFn = failed
? (text: string) => theme.bg("toolErrorBg", text)
: (text: string) => theme.bg("toolSuccessBg", text);
const stats = (details.stats ?? null) as SessionStats | null;
const icon = failed
? theme.fg("error", "✗")
: theme.fg("success", "✓");
const status = errorMessage
? "failed (provider/agent error)"
: failed
? `failed (exit ${exitCode})`
: "completed";
const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : "";
const modelTag = stats?.model ? theme.fg("dim", ` (${stats.model})`) : "";
const titleSegment = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag}${modelTag} ${theme.fg("dim", "—")} `;
// Success: icon already conveys "completed", so show "N tools · duration"
// like the in-process extension. Failure: surface the failure reason.
let header: string;
if (failed) {
const reason = errorMessage ? "failed (provider/agent error)" : `failed (exit ${exitCode})`;
header = `${titleSegment}${theme.fg("error", reason)} ${theme.fg("dim", `· ${elapsed}`)}`;
} else {
const toolPart = stats ? `${stats.toolCount} tools · ${elapsed}` : elapsed;
header = `${titleSegment}${theme.fg("dim", toolPart)}`;
}
// Usage line: ↑in ↓out R… W… $cost · context-gauge (color-coded by %).
let usageLine: string | null = null;
if (stats) {
const segs = formatUsageSegments(stats).map((s) => theme.fg("dim", s));
if (stats.contextTokens > 0) {
const window = contextWindowFor(stats.model);
const ctxStr = formatContextUsage(stats.contextTokens, window);
const pct = window ? (stats.contextTokens / window) * 100 : 0;
const coloredCtx =
pct > 90 ? theme.fg("error", ctxStr) : pct > 70 ? theme.fg("warning", ctxStr) : theme.fg("dim", ctxStr);
segs.push(coloredCtx);
}
if (segs.length > 0) usageLine = segs.join(theme.fg("dim", " "));
}
const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`;
const rawContent = typeof message.content === "string" ? message.content : "";
// Clean summary (remove session ref and leading label for display)
@@ -2047,6 +2200,7 @@ export default function subagentsExtension(pi: ExtensionAPI) {
// Build content for the box
const contentLines = [header];
if (usageLine) contentLines.push(usageLine);
if (options.expanded) {
// Full view: complete summary + session info
@@ -2148,35 +2302,5 @@ export default function subagentsExtension(pi: ExtensionAPI) {
};
});
// /plan command — start the full planning workflow
pi.registerCommand("plan", {
description: "Start a planning session: /plan <what to build>",
handler: async (args, ctx) => {
const task = args.trim();
if (!task) {
ctx.ui.notify("Usage: /plan <what to build>", "warning");
return;
}
// Rename workspace and tab to show this is a planning session
if (isMuxAvailable()) {
try {
const label = task.length > 40 ? task.slice(0, 40) + "..." : task;
renameWorkspace(`🎯 ${label}`);
renameCurrentTab(`🎯 Plan: ${label}`);
} catch {
// non-critical -- do not block the plan
}
}
// Load the plan skill from the subagents extension directory
const planSkillPath = join(SUBAGENTS_DIR, "plan-skill.md");
let content = readFileSync(planSkillPath, "utf8");
content = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
pi.sendUserMessage(
`<skill name="plan" location="${planSkillPath}">\n${content.trim()}\n</skill>\n\n${task}`,
);
},
});
}
// test
-203
View File
@@ -1,203 +0,0 @@
---
name: plan
description: >
Planning workflow. Runs a pre-flight scout, then spawns the planner agent
which clarifies WHAT to build and figures out HOW, with the ability to
spawn its own scouts/researchers mid-session. Use when asked to "plan",
"brainstorm", "I want to build X", or "let's design". Requires the
subagents extension and a supported multiplexer (cmux/tmux/zellij).
---
# Plan
A planning workflow. A scout maps the relevant codebase, then an interactive planner clarifies intent + requirements and designs the technical approach, producing a `plan.md` and todos.
**Announce at start:** "Let me take a quick look, then I'll send a scout to map the codebase before we start the planning session."
---
## The Flow
```
Phase 1: Quick Assessment (main session — 30s orientation)
↓
Phase 2: Scout (autonomous — codebase context)
↓
Phase 3: Spawn Planner Agent (interactive — clarifies WHAT, plans HOW, creates todos)
↓
(Planner may spawn its own scouts/researchers mid-session as needed)
↓
Phase 4: Review Plan & Todos (main session)
↓
Phase 5: Execute Todos (workers — receive plan + scout context)
↓
Phase 6: Review
```
---
## Phase 1: Quick Assessment
Quick orientation — just enough to give the scout a focused mission:
```bash
ls -la
find . -type f -name "*.ts" | head -20 # or relevant extension
cat package.json 2>/dev/null | head -30
```
Spend ~30 seconds. Tech stack, project shape, and the area relevant to the user's request. This tells you what to ask the scout to focus on.
---
## Artifact Paths
For a planning run, pick a short `<name>` (e.g. `auth-redesign`) and use a shared directory under `.pi/plans/YYYY-MM-DD-<name>/` for every deliverable. Pass explicit paths in each subagent's task and read them back with the plain `read` tool when a subagent finishes.
Standard filenames:
- `.pi/plans/YYYY-MM-DD-<name>/scout-context.md`
- `.pi/plans/YYYY-MM-DD-<name>/plan.md`
- `.pi/plans/YYYY-MM-DD-<name>/review.md` (optional, for reviewer output)
---
## Phase 2: Scout
**Always spawn a scout before the planner.** The scout's context feeds into the planning session — it lets the planner skip re-asking questions whose answers live in the code, and gives it a solid base to design from.
```typescript
subagent({
name: "🔍 Scout",
agent: "scout",
task: `Analyze the codebase for [user's request area]. Map file structure, key modules, patterns, conventions, and existing code related to [feature area]. Focus on what a planner would need to understand before designing this feature.
Save your findings to: .pi/plans/YYYY-MM-DD-<name>/scout-context.md`,
});
```
**Wait for the scout to finish.** Read the scout's context file with the `read` tool — you'll pass it to the planner.
The planner can spawn **additional** scouts or researchers mid-session if it hits a factual gap. That's expected — don't try to pre-scout every possible area.
---
## Phase 3: Spawn Planner Agent
Spawn the interactive planner with the scout's context and the user's request. The planner handles everything from here: clarifying intent, compact requirements engineering, ISC, approach exploration, design validation, premortem, plan artifact, and todos.
```typescript
subagent({
name: "💬 Planner",
agent: "planner",
interactive: true,
task: `Plan: [what the user wants to build]
Scout context:
[paste scout findings here — file structure, conventions, patterns, relevant code]
Save the final plan to: .pi/plans/YYYY-MM-DD-<name>/plan.md
Create todos tagged with: <name>`,
});
```
**The user works with the planner.** It will clarify requirements lightly (1-2 rounds of questions, not a deep spec session), propose approaches, validate the design, run a premortem, write the plan, and create todos with mandatory code examples.
When done, the user presses Ctrl+D and the plan + todos are returned to the main session.
### The planner may spawn its own specialists
During the session, the planner can spawn:
- **`scout`** — when a design decision depends on existing code it hasn't read
- **`researcher`** — when a decision depends on external facts (library tradeoffs, best practices, API behaviors)
These are internal to the planning session. You'll see them in the multiplexer but don't need to intervene.
### Optional: extra scout after planning
If the planner significantly changed scope (new subsystems, areas the original scout didn't cover), spawn another scout targeting the new areas before workers start:
```typescript
subagent({
name: "🔍 Scout (updated scope)",
agent: "scout",
task: "The plan changed scope. Gather context for [new areas]. Read the plan at [plan path]. Focus on [specific files/modules the planner identified that weren't in the original scout].",
});
```
Fold the new context into the worker tasks.
---
## Phase 4: Review Plan & Todos
Once the planner closes, read the plan and list todos:
```typescript
todo({ action: "list" });
```
Review with the user:
> "Here's what the planner produced: [brief summary]. Ready to execute, or anything to adjust?"
---
## Phase 5: Execute Todos
Spawn workers sequentially. Each worker gets the plan path and scout context:
```typescript
// Workers execute todos sequentially — one at a time
subagent({
name: "🔨 Worker 1/N",
agent: "worker",
task: "Implement TODO-xxxx. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary from Phase 2, plus any re-scout from Phase 3]",
});
// Check result, then next todo
subagent({
name: "🔨 Worker 2/N",
agent: "worker",
task: "Implement TODO-yyyy. Mark the todo as done. Plan: [plan path]\n\nScout context: [paste scout summary]",
});
```
**Always run workers sequentially in the same git repo** — parallel workers will conflict on commits.
---
## Phase 6: Review
After all todos are complete:
```typescript
subagent({
name: "Reviewer",
agent: "reviewer",
interactive: false,
task: "Review the recent changes. Plan: [plan path]",
});
```
Triage findings:
- **P0** — Real bugs, security issues → fix now
- **P1** — Genuine traps, maintenance dangers → fix before merging
- **P2** — Minor issues → fix if quick, note otherwise
- **P3** — Nits → skip
Create todos for P0/P1, run workers to fix, re-review only if fixes were substantial.
---
## ⚠️ Completion Checklist
Before reporting done:
1. ✅ Scout ran before the planner?
2. ✅ Scout context was passed to the planner?
3. ✅ All worker todos closed?
4. ✅ Every todo has a polished commit (using the `commit` skill)?
5. ✅ Reviewer has run?
6. ✅ Reviewer findings triaged and addressed?
+74
View File
@@ -178,3 +178,77 @@ export function mergeNewEntries(
}
return entries;
}
export interface SessionStats {
model: string | null;
toolCount: number;
/** Cumulative token usage across all assistant turns. */
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
/** Current context size: the last assistant turn's totalTokens. */
contextTokens: number;
/** Cumulative cost in USD across all assistant turns. */
cost: number;
}
/**
* Parse a completed subagent session JSONL into aggregate stats for display:
* model, tool-call count, cumulative token usage + cost, and current context
* size. Cumulative usage fields are summed across every assistant turn; the
* context size is taken from the last assistant turn's `totalTokens` (the live
* context window occupancy). Returns null if the file can't be read.
*/
export function summarizeSessionStats(sessionFile: string): SessionStats | null {
let entries: SessionEntry[];
try {
entries = readEntries(sessionFile);
} catch {
return null;
}
const stats: SessionStats = {
model: null,
toolCount: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
contextTokens: 0,
cost: 0,
};
for (const entry of entries) {
if (entry.type === "model_change") {
const modelId = (entry as { modelId?: unknown }).modelId;
if (typeof modelId === "string" && modelId) stats.model = modelId;
continue;
}
if (entry.type !== "message") continue;
const msg = (entry as MessageEntry).message;
if (msg.role !== "assistant") continue;
const model = (msg as { model?: unknown }).model;
if (typeof model === "string" && model) stats.model = model;
for (const block of msg.content) {
if (block.type === "toolCall") stats.toolCount++;
}
const usage = (msg as { usage?: Record<string, unknown> }).usage;
if (usage && typeof usage === "object") {
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
stats.inputTokens += num(usage.input);
stats.outputTokens += num(usage.output);
stats.cacheReadTokens += num(usage.cacheRead);
stats.cacheWriteTokens += num(usage.cacheWrite);
const total = num(usage.totalTokens);
if (total > 0) stats.contextTokens = total;
const cost = usage.cost;
if (cost && typeof cost === "object") stats.cost += num((cost as Record<string, unknown>).total);
}
}
return stats;
}
+5 -5
View File
@@ -1,6 +1,6 @@
/**
* Extension loaded into sub-agents.
* - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J)
* - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+Alt+O)
* - Provides a `subagent_done` tool for autonomous agents to self-terminate
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -102,7 +102,7 @@ export default function (pi: ExtensionAPI) {
if (expanded) {
// Expanded: full tool list + denied
const countInfo = theme.fg("dim", ` — ${toolNames.length} available`);
const hint = theme.fg("muted", " (Ctrl+J to collapse)");
const hint = theme.fg("muted", " (Ctrl+Alt+O to collapse)");
const toolList = toolNames
.map((name: string) => theme.fg("dim", name))
@@ -129,7 +129,7 @@ export default function (pi: ExtensionAPI) {
denied.length > 0
? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`)
: "";
const hint = theme.fg("muted", " (Ctrl+J to expand)");
const hint = theme.fg("muted", " (Ctrl+Alt+O to expand)");
const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0);
box.addChild(content);
@@ -256,8 +256,8 @@ export default function (pi: ExtensionAPI) {
recorder.sessionShutdown((event as any).reason);
});
// Toggle expand/collapse with Ctrl+J
pi.registerShortcut("ctrl+j", {
// Toggle expand/collapse with Ctrl+Alt+O
pi.registerShortcut("ctrl+alt+o", {
description: "Toggle subagent tools widget",
handler: (ctx) => {
expanded = !expanded;
+63
View File
@@ -0,0 +1,63 @@
/**
* Safe bash extension for the worker subagent.
* Wraps the built-in bash tool with dangerous command blocking.
*
* Loaded into a child pi process via `--extension` when an agent's `tools`
* frontmatter lists `safe_bash`. See CUSTOM_TOOL_EXTENSIONS in ../index.ts.
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { createBashTool } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
const DANGEROUS_PATTERNS = [
/\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
/\bsudo\b/,
/\bmkfs\b/,
/\bdd\s+if=/,
/:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
/>\s*\/dev\/[sh]d[a-z]/,
/\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
/\bchown\s+(-[a-zA-Z]+\s+)?root/,
/\bcurl\s.*\|\s*(ba)?sh/,
/\bwget\s.*\|\s*(ba)?sh/,
/\bshutdown\b/,
/\breboot\b/,
/\binit\s+0\b/,
/\bkill\s+-9\s+1\b/,
/\bkillall\b/,
];
function isDangerous(command: string): string | null {
const normalized = command.replace(/\\\n/g, " ");
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(normalized)) {
return `Command blocked by safe_bash: matches dangerous pattern ${pattern}`;
}
}
return null;
}
export default function (pi: ExtensionAPI) {
const bashTool = createBashTool(process.cwd());
pi.registerTool({
name: "safe_bash",
label: "Safe Bash",
description:
"Execute a bash command. Blocks dangerous commands (rm -rf /, sudo, mkfs, etc.).",
parameters: Type.Object({
command: Type.String({ description: "Bash command to execute" }),
timeout: Type.Optional(
Type.Number({ description: "Timeout in seconds (optional)" }),
),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
const danger = isDangerous(params.command);
if (danger) {
throw new Error(danger);
}
return bashTool.execute(toolCallId, params, signal, onUpdate);
},
});
}