ported over subagents, minor changes to functionality, refined ui
This commit is contained in:
+240
-116
@@ -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
|
||||
|
||||
@@ -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?
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user