@johnnygreco/pizza-pi 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +191 -0
- package/README.md +82 -0
- package/extensions/context.ts +578 -0
- package/extensions/control.ts +1782 -0
- package/extensions/loop.ts +454 -0
- package/extensions/pizza-ui.ts +93 -0
- package/extensions/todos.ts +2066 -0
- package/node_modules/pi-interactive-subagents/.pi/settings.json +13 -0
- package/node_modules/pi-interactive-subagents/.pi/skills/release/SKILL.md +133 -0
- package/node_modules/pi-interactive-subagents/LICENSE +21 -0
- package/node_modules/pi-interactive-subagents/README.md +362 -0
- package/node_modules/pi-interactive-subagents/agents/planner.md +270 -0
- package/node_modules/pi-interactive-subagents/agents/reviewer.md +153 -0
- package/node_modules/pi-interactive-subagents/agents/scout.md +103 -0
- package/node_modules/pi-interactive-subagents/agents/spec.md +339 -0
- package/node_modules/pi-interactive-subagents/agents/visual-tester.md +202 -0
- package/node_modules/pi-interactive-subagents/agents/worker.md +104 -0
- package/node_modules/pi-interactive-subagents/package.json +34 -0
- package/node_modules/pi-interactive-subagents/pi-extension/session-artifacts/index.ts +252 -0
- package/node_modules/pi-interactive-subagents/pi-extension/subagents/cmux.ts +647 -0
- package/node_modules/pi-interactive-subagents/pi-extension/subagents/index.ts +1343 -0
- package/node_modules/pi-interactive-subagents/pi-extension/subagents/plan-skill.md +225 -0
- package/node_modules/pi-interactive-subagents/pi-extension/subagents/session.ts +124 -0
- package/node_modules/pi-interactive-subagents/pi-extension/subagents/subagent-done.ts +166 -0
- package/package.json +62 -0
- package/prompts/.gitkeep +0 -0
- package/skills/.gitkeep +0 -0
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { keyHint } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Type } from "@sinclair/typebox";
|
|
4
|
+
import { Box, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
readdirSync,
|
|
8
|
+
statSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { homedir, tmpdir } from "node:os";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
isMuxAvailable,
|
|
18
|
+
muxSetupHint,
|
|
19
|
+
createSurface,
|
|
20
|
+
sendCommand,
|
|
21
|
+
pollForExit,
|
|
22
|
+
closeSurface,
|
|
23
|
+
shellEscape,
|
|
24
|
+
exitStatusVar,
|
|
25
|
+
renameCurrentTab,
|
|
26
|
+
renameWorkspace,
|
|
27
|
+
} from "./cmux.ts";
|
|
28
|
+
import { getNewEntries, findLastAssistantMessage } from "./session.ts";
|
|
29
|
+
|
|
30
|
+
const SubagentParams = Type.Object({
|
|
31
|
+
name: Type.String({ description: "Display name for the subagent" }),
|
|
32
|
+
task: Type.String({ description: "Task/prompt for the sub-agent" }),
|
|
33
|
+
agent: Type.Optional(
|
|
34
|
+
Type.String({
|
|
35
|
+
description:
|
|
36
|
+
"Agent name to load defaults from (e.g. 'worker', 'scout', 'reviewer'). Reads ~/.pi/agent/agents/<name>.md for model, tools, skills.",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
systemPrompt: Type.Optional(
|
|
40
|
+
Type.String({ description: "Appended to system prompt (role instructions)" }),
|
|
41
|
+
),
|
|
42
|
+
model: Type.Optional(Type.String({ description: "Model override (overrides agent default)" })),
|
|
43
|
+
skills: Type.Optional(
|
|
44
|
+
Type.String({ description: "Comma-separated skills (overrides agent default)" }),
|
|
45
|
+
),
|
|
46
|
+
tools: Type.Optional(
|
|
47
|
+
Type.String({ description: "Comma-separated tools (overrides agent default)" }),
|
|
48
|
+
),
|
|
49
|
+
cwd: Type.Optional(
|
|
50
|
+
Type.String({
|
|
51
|
+
description:
|
|
52
|
+
"Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.",
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
fork: Type.Optional(
|
|
56
|
+
Type.Boolean({
|
|
57
|
+
description:
|
|
58
|
+
"Fork the current session — sub-agent gets full conversation context. Use for iterate/bugfix patterns.",
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
interface AgentDefaults {
|
|
64
|
+
model?: string;
|
|
65
|
+
tools?: string;
|
|
66
|
+
skills?: string;
|
|
67
|
+
thinking?: string;
|
|
68
|
+
denyTools?: string;
|
|
69
|
+
spawning?: boolean;
|
|
70
|
+
autoExit?: boolean;
|
|
71
|
+
systemPromptMode?: "append" | "replace";
|
|
72
|
+
cwd?: string;
|
|
73
|
+
body?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Tools that are gated by `spawning: false` */
|
|
77
|
+
const SPAWNING_TOOLS = new Set(["subagent", "subagents_list", "subagent_resume"]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the effective set of denied tool names from agent defaults.
|
|
81
|
+
* `spawning: false` expands to all SPAWNING_TOOLS.
|
|
82
|
+
* `deny-tools` adds individual tool names on top.
|
|
83
|
+
*/
|
|
84
|
+
function resolveDenyTools(agentDefs: AgentDefaults | null): Set<string> {
|
|
85
|
+
const denied = new Set<string>();
|
|
86
|
+
if (!agentDefs) return denied;
|
|
87
|
+
|
|
88
|
+
// spawning: false → deny all spawning tools
|
|
89
|
+
if (agentDefs.spawning === false) {
|
|
90
|
+
for (const t of SPAWNING_TOOLS) denied.add(t);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// deny-tools: explicit list
|
|
94
|
+
if (agentDefs.denyTools) {
|
|
95
|
+
for (const t of agentDefs.denyTools
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((s) => s.trim())
|
|
98
|
+
.filter(Boolean)) {
|
|
99
|
+
denied.add(t);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return denied;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */
|
|
107
|
+
function getAgentConfigDir(): string {
|
|
108
|
+
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function loadAgentDefaults(agentName: string): AgentDefaults | null {
|
|
112
|
+
const configDir = getAgentConfigDir();
|
|
113
|
+
const paths = [
|
|
114
|
+
join(process.cwd(), ".pi", "agents", `${agentName}.md`),
|
|
115
|
+
join(configDir, "agents", `${agentName}.md`),
|
|
116
|
+
join(dirname(new URL(import.meta.url).pathname), "../../agents", `${agentName}.md`),
|
|
117
|
+
];
|
|
118
|
+
for (const p of paths) {
|
|
119
|
+
if (!existsSync(p)) continue;
|
|
120
|
+
const content = readFileSync(p, "utf8");
|
|
121
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
122
|
+
if (!match) continue;
|
|
123
|
+
const frontmatter = match[1];
|
|
124
|
+
const get = (key: string) => {
|
|
125
|
+
const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
126
|
+
return m ? m[1].trim() : undefined;
|
|
127
|
+
};
|
|
128
|
+
// Extract body (everything after frontmatter)
|
|
129
|
+
const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
|
|
130
|
+
const spawningRaw = get("spawning");
|
|
131
|
+
const autoExitRaw = get("auto-exit");
|
|
132
|
+
const spm = get("system-prompt");
|
|
133
|
+
return {
|
|
134
|
+
model: get("model"),
|
|
135
|
+
tools: get("tools"),
|
|
136
|
+
systemPromptMode: spm === "replace" ? "replace" : spm === "append" ? "append" : undefined,
|
|
137
|
+
skills: get("skill") ?? get("skills"),
|
|
138
|
+
thinking: get("thinking"),
|
|
139
|
+
denyTools: get("deny-tools"),
|
|
140
|
+
spawning: spawningRaw != null ? spawningRaw === "true" : undefined,
|
|
141
|
+
autoExit: autoExitRaw != null ? autoExitRaw === "true" : undefined,
|
|
142
|
+
cwd: get("cwd"),
|
|
143
|
+
body: body || undefined,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatElapsed(seconds: number): string {
|
|
150
|
+
if (seconds < 60) return `${seconds}s`;
|
|
151
|
+
const m = Math.floor(seconds / 60);
|
|
152
|
+
const s = seconds % 60;
|
|
153
|
+
return `${m}m ${s}s`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function muxUnavailableResult(kind: "subagents" | "tab-title" = "subagents") {
|
|
157
|
+
if (kind === "tab-title") {
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{ type: "text" as const, text: `Terminal multiplexer not available. ${muxSetupHint()}` },
|
|
161
|
+
],
|
|
162
|
+
details: { error: "mux not available" },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
content: [
|
|
168
|
+
{
|
|
169
|
+
type: "text" as const,
|
|
170
|
+
text: `Subagents require a supported terminal multiplexer. ${muxSetupHint()}`,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
details: { error: "mux not available" },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build the artifact directory path for the current session.
|
|
179
|
+
* Same convention as the write_artifact tool:
|
|
180
|
+
* <sessionDir>/artifacts/<session-id>/
|
|
181
|
+
*/
|
|
182
|
+
function getArtifactDir(sessionDir: string, sessionId: string): string {
|
|
183
|
+
return join(sessionDir, "artifacts", sessionId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatBytes(bytes: number): string {
|
|
187
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
188
|
+
const kb = bytes / 1024;
|
|
189
|
+
if (kb < 1024) return `${kb.toFixed(1)}KB`;
|
|
190
|
+
const mb = kb / 1024;
|
|
191
|
+
return `${mb.toFixed(1)}MB`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Try to find and measure a specific session file, or discover
|
|
196
|
+
* the right one from new files in the session directory.
|
|
197
|
+
*
|
|
198
|
+
* When `trackedFile` is provided, measures that file directly.
|
|
199
|
+
* Otherwise scans for new files not in `existingFiles` or `excludeFiles`.
|
|
200
|
+
*
|
|
201
|
+
* Returns { file, entries, bytes } — `file` is the path that was measured,
|
|
202
|
+
* so callers can lock onto it for subsequent calls.
|
|
203
|
+
*/
|
|
204
|
+
/**
|
|
205
|
+
* Result from running a single subagent.
|
|
206
|
+
*/
|
|
207
|
+
interface SubagentResult {
|
|
208
|
+
name: string;
|
|
209
|
+
task: string;
|
|
210
|
+
summary: string;
|
|
211
|
+
sessionFile?: string;
|
|
212
|
+
exitCode: number;
|
|
213
|
+
elapsed: number;
|
|
214
|
+
error?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* State for a launched (but not yet completed) subagent.
|
|
219
|
+
*/
|
|
220
|
+
interface RunningSubagent {
|
|
221
|
+
id: string;
|
|
222
|
+
name: string;
|
|
223
|
+
task: string;
|
|
224
|
+
agent?: string;
|
|
225
|
+
surface: string;
|
|
226
|
+
startTime: number;
|
|
227
|
+
sessionFile: string;
|
|
228
|
+
entries?: number;
|
|
229
|
+
bytes?: number;
|
|
230
|
+
abortController?: AbortController;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** All currently running subagents, keyed by id. */
|
|
234
|
+
const runningSubagents = new Map<string, RunningSubagent>();
|
|
235
|
+
|
|
236
|
+
// ── Widget management ──
|
|
237
|
+
|
|
238
|
+
/** Latest ExtensionContext from session_start, used for widget updates. */
|
|
239
|
+
let latestCtx: ExtensionContext | null = null;
|
|
240
|
+
|
|
241
|
+
/** Interval timer for widget re-renders. */
|
|
242
|
+
let widgetInterval: ReturnType<typeof setInterval> | null = null;
|
|
243
|
+
|
|
244
|
+
function formatElapsedMMSS(startTime: number): string {
|
|
245
|
+
const seconds = Math.floor((Date.now() - startTime) / 1000);
|
|
246
|
+
const m = Math.floor(seconds / 60);
|
|
247
|
+
const s = seconds % 60;
|
|
248
|
+
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const ACCENT = "\x1b[38;2;77;163;255m";
|
|
252
|
+
const RST = "\x1b[0m";
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build a bordered content line: │left right│
|
|
256
|
+
* Left content is truncated if needed, right is preserved, padded to fill width.
|
|
257
|
+
*/
|
|
258
|
+
function borderLine(left: string, right: string, width: number): string {
|
|
259
|
+
if (width <= 0) return "";
|
|
260
|
+
if (width === 1) return `${ACCENT}│${RST}`;
|
|
261
|
+
|
|
262
|
+
// width = total visible chars for the whole line including │ and │
|
|
263
|
+
const contentWidth = Math.max(0, width - 2); // space inside the two │ chars
|
|
264
|
+
const rightVis = visibleWidth(right);
|
|
265
|
+
|
|
266
|
+
// If the status chunk alone is too wide, prefer preserving it in compact form
|
|
267
|
+
// rather than overflowing the terminal.
|
|
268
|
+
if (rightVis >= contentWidth) {
|
|
269
|
+
const truncRight = truncateToWidth(right, contentWidth);
|
|
270
|
+
const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
|
|
271
|
+
return `${ACCENT}│${RST}${truncRight}${" ".repeat(rightPad)}${ACCENT}│${RST}`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const maxLeft = Math.max(0, contentWidth - rightVis);
|
|
275
|
+
const truncLeft = truncateToWidth(left, maxLeft);
|
|
276
|
+
const leftVis = visibleWidth(truncLeft);
|
|
277
|
+
const pad = Math.max(0, contentWidth - leftVis - rightVis);
|
|
278
|
+
return `${ACCENT}│${RST}${truncLeft}${" ".repeat(pad)}${right}${ACCENT}│${RST}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Build the bordered top line: ╭─ Title ──── info ─╮
|
|
283
|
+
* All chars are accounted for within `width`.
|
|
284
|
+
*/
|
|
285
|
+
function borderTop(title: string, info: string, width: number): string {
|
|
286
|
+
if (width <= 0) return "";
|
|
287
|
+
if (width === 1) return `${ACCENT}╭${RST}`;
|
|
288
|
+
|
|
289
|
+
// ╭─ Title ───...─── info ─╮
|
|
290
|
+
// overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify
|
|
291
|
+
const inner = Math.max(0, width - 2); // inside ╭ and ╮
|
|
292
|
+
const titlePart = `─ ${title} `;
|
|
293
|
+
const infoPart = ` ${info} ─`;
|
|
294
|
+
const fillLen = Math.max(0, inner - titlePart.length - infoPart.length);
|
|
295
|
+
const fill = "─".repeat(fillLen);
|
|
296
|
+
const content = `${titlePart}${fill}${infoPart}`.slice(0, inner).padEnd(inner, "─");
|
|
297
|
+
return `${ACCENT}╭${content}╮${RST}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Build the bordered bottom line: ╰──────────────────╯
|
|
302
|
+
*/
|
|
303
|
+
function borderBottom(width: number): string {
|
|
304
|
+
if (width <= 0) return "";
|
|
305
|
+
if (width === 1) return `${ACCENT}╰${RST}`;
|
|
306
|
+
|
|
307
|
+
const inner = Math.max(0, width - 2);
|
|
308
|
+
return `${ACCENT}╰${"─".repeat(inner)}╯${RST}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderSubagentWidgetLines(agents: RunningSubagent[], width: number): string[] {
|
|
312
|
+
const count = agents.length;
|
|
313
|
+
const title = "Subagents";
|
|
314
|
+
const info = `${count} running`;
|
|
315
|
+
|
|
316
|
+
const lines: string[] = [borderTop(title, info, width)];
|
|
317
|
+
|
|
318
|
+
for (const agent of agents) {
|
|
319
|
+
const elapsed = formatElapsedMMSS(agent.startTime);
|
|
320
|
+
const agentTag = agent.agent ? ` (${agent.agent})` : "";
|
|
321
|
+
const left = ` ${elapsed} ${agent.name}${agentTag} `;
|
|
322
|
+
const right =
|
|
323
|
+
agent.entries != null && agent.bytes != null
|
|
324
|
+
? ` ${agent.entries} msgs (${formatBytes(agent.bytes)}) `
|
|
325
|
+
: " starting… ";
|
|
326
|
+
|
|
327
|
+
lines.push(borderLine(left, right, width));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
lines.push(borderBottom(width));
|
|
331
|
+
return lines;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function updateWidget() {
|
|
335
|
+
if (!latestCtx?.hasUI) return;
|
|
336
|
+
|
|
337
|
+
if (runningSubagents.size === 0) {
|
|
338
|
+
latestCtx.ui.setWidget("subagent-status", undefined);
|
|
339
|
+
if (widgetInterval) {
|
|
340
|
+
clearInterval(widgetInterval);
|
|
341
|
+
widgetInterval = null;
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
latestCtx.ui.setWidget(
|
|
347
|
+
"subagent-status",
|
|
348
|
+
(_tui: any, _theme: any) => {
|
|
349
|
+
return {
|
|
350
|
+
invalidate() {},
|
|
351
|
+
render(width: number) {
|
|
352
|
+
return renderSubagentWidgetLines(Array.from(runningSubagents.values()), width);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
{ placement: "aboveEditor" },
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export const __test__ = {
|
|
361
|
+
borderLine,
|
|
362
|
+
renderSubagentWidgetLines,
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
function startWidgetRefresh() {
|
|
366
|
+
if (widgetInterval) return;
|
|
367
|
+
updateWidget(); // immediate first render
|
|
368
|
+
widgetInterval = setInterval(() => {
|
|
369
|
+
updateWidget();
|
|
370
|
+
}, 1000);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Launch a subagent: creates the multiplexer pane, builds the command, and
|
|
375
|
+
* sends it. Returns a RunningSubagent — does NOT poll.
|
|
376
|
+
*
|
|
377
|
+
* Call watchSubagent() on the returned object to observe completion.
|
|
378
|
+
*/
|
|
379
|
+
async function launchSubagent(
|
|
380
|
+
params: typeof SubagentParams.static,
|
|
381
|
+
ctx: { sessionManager: { getSessionFile(): string | null; getSessionId(): string; getSessionDir(): string }; cwd: string },
|
|
382
|
+
options?: { surface?: string },
|
|
383
|
+
): Promise<RunningSubagent> {
|
|
384
|
+
const startTime = Date.now();
|
|
385
|
+
const id = Math.random().toString(16).slice(2, 10);
|
|
386
|
+
|
|
387
|
+
const agentDefs = params.agent ? loadAgentDefaults(params.agent) : null;
|
|
388
|
+
const effectiveModel = params.model ?? agentDefs?.model;
|
|
389
|
+
const effectiveTools = params.tools ?? agentDefs?.tools;
|
|
390
|
+
const effectiveSkills = params.skills ?? agentDefs?.skills;
|
|
391
|
+
const effectiveThinking = agentDefs?.thinking;
|
|
392
|
+
|
|
393
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
394
|
+
if (!sessionFile) throw new Error("No session file");
|
|
395
|
+
|
|
396
|
+
const sessionDir = dirname(sessionFile);
|
|
397
|
+
|
|
398
|
+
// Generate a deterministic session file path for this subagent.
|
|
399
|
+
// This eliminates race conditions when multiple agents launch simultaneously —
|
|
400
|
+
// each agent knows exactly which file is theirs.
|
|
401
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z";
|
|
402
|
+
const uuid = [
|
|
403
|
+
id,
|
|
404
|
+
Math.random().toString(16).slice(2, 10),
|
|
405
|
+
Math.random().toString(16).slice(2, 10),
|
|
406
|
+
Math.random().toString(16).slice(2, 6),
|
|
407
|
+
].join("-");
|
|
408
|
+
const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
|
|
409
|
+
|
|
410
|
+
// Use pre-created surface (parallel mode) or create a new one.
|
|
411
|
+
// For new surfaces, pause briefly so the shell is ready before sending the command.
|
|
412
|
+
const surfacePreCreated = !!options?.surface;
|
|
413
|
+
const surface = options?.surface ?? createSurface(params.name);
|
|
414
|
+
if (!surfacePreCreated) {
|
|
415
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Build the task message
|
|
419
|
+
// When forking, the sub-agent already has the full conversation context.
|
|
420
|
+
// Only send the user's task as a clean message — no wrapper instructions
|
|
421
|
+
// that would confuse the agent into thinking it needs to restart.
|
|
422
|
+
const modeHint = agentDefs?.autoExit
|
|
423
|
+
? "Complete your task autonomously."
|
|
424
|
+
: "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time.";
|
|
425
|
+
const summaryInstruction = agentDefs?.autoExit
|
|
426
|
+
? "Your FINAL assistant message should summarize what you accomplished."
|
|
427
|
+
: "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished.";
|
|
428
|
+
const denySet = resolveDenyTools(agentDefs);
|
|
429
|
+
const agentType = params.agent ?? params.name;
|
|
430
|
+
const tabTitleInstruction = denySet.has("set_tab_title")
|
|
431
|
+
? ""
|
|
432
|
+
: `As your FIRST action, set the tab title using set_tab_title. ` +
|
|
433
|
+
`The title MUST start with [${agentType}] followed by a short description of your current task. ` +
|
|
434
|
+
`Example: "[${agentType}] Analyzing auth module". Keep it concise.`;
|
|
435
|
+
// Determine where the agent identity goes: system prompt or user message
|
|
436
|
+
const identity = agentDefs?.body ?? params.systemPrompt ?? null;
|
|
437
|
+
const systemPromptMode = agentDefs?.systemPromptMode;
|
|
438
|
+
const identityInSystemPrompt = systemPromptMode && identity;
|
|
439
|
+
const roleBlock = identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
|
|
440
|
+
const fullTask = params.fork
|
|
441
|
+
? params.task
|
|
442
|
+
: `${roleBlock}\n\n${modeHint}\n\n${tabTitleInstruction}\n\n${params.task}\n\n${summaryInstruction}`;
|
|
443
|
+
|
|
444
|
+
// Build pi command
|
|
445
|
+
const parts: string[] = ["pi"];
|
|
446
|
+
parts.push("--session", shellEscape(subagentSessionFile));
|
|
447
|
+
|
|
448
|
+
// For fork mode, build the forked session file directly at subagentSessionFile.
|
|
449
|
+
// We write a new session header + cleaned entries (excluding the meta-message
|
|
450
|
+
// that triggered this fork). The sub-agent launches with just --session.
|
|
451
|
+
if (params.fork) {
|
|
452
|
+
const raw = readFileSync(sessionFile, "utf8");
|
|
453
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
454
|
+
|
|
455
|
+
// Walk backwards to find the last user message (the meta-instruction)
|
|
456
|
+
// and truncate everything from there onwards
|
|
457
|
+
let truncateAt = lines.length;
|
|
458
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
459
|
+
try {
|
|
460
|
+
const entry = JSON.parse(lines[i]);
|
|
461
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
462
|
+
truncateAt = i;
|
|
463
|
+
break;
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Separate header from content entries
|
|
469
|
+
const cleanLines = lines.slice(0, truncateAt);
|
|
470
|
+
const contentLines = cleanLines.filter((l) => {
|
|
471
|
+
try {
|
|
472
|
+
return JSON.parse(l).type !== "session";
|
|
473
|
+
} catch {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Write new session header + cleaned entries to the subagent session file
|
|
479
|
+
const newHeader = JSON.stringify({
|
|
480
|
+
type: "session",
|
|
481
|
+
version: 3,
|
|
482
|
+
id: randomUUID(),
|
|
483
|
+
timestamp: new Date().toISOString(),
|
|
484
|
+
cwd: process.cwd(),
|
|
485
|
+
parentSession: sessionFile,
|
|
486
|
+
});
|
|
487
|
+
mkdirSync(dirname(subagentSessionFile), { recursive: true });
|
|
488
|
+
writeFileSync(
|
|
489
|
+
subagentSessionFile,
|
|
490
|
+
newHeader + "\n" + contentLines.join("\n") + "\n",
|
|
491
|
+
"utf8",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const subagentDonePath = join(dirname(new URL(import.meta.url).pathname), "subagent-done.ts");
|
|
496
|
+
parts.push("-e", shellEscape(subagentDonePath));
|
|
497
|
+
|
|
498
|
+
if (effectiveModel) {
|
|
499
|
+
const model = effectiveThinking ? `${effectiveModel}:${effectiveThinking}` : effectiveModel;
|
|
500
|
+
parts.push("--model", shellEscape(model));
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Pass agent body as system prompt via file to avoid shell escaping issues
|
|
504
|
+
// with multiline content. Pi's --append-system-prompt and --system-prompt
|
|
505
|
+
// auto-detect file paths and read their contents.
|
|
506
|
+
if (identityInSystemPrompt && identity) {
|
|
507
|
+
const flag = systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt";
|
|
508
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
509
|
+
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
|
510
|
+
const spTimestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
511
|
+
const spSafeName = (params.name ?? "subagent")
|
|
512
|
+
.toLowerCase()
|
|
513
|
+
.replace(/[^a-z0-9\s-]/g, "")
|
|
514
|
+
.replace(/\s+/g, "-")
|
|
515
|
+
.replace(/-+/g, "-")
|
|
516
|
+
.replace(/^-|-$/g, "");
|
|
517
|
+
const syspromptPath = join(artifactDir, `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`);
|
|
518
|
+
mkdirSync(dirname(syspromptPath), { recursive: true });
|
|
519
|
+
writeFileSync(syspromptPath, identity, "utf8");
|
|
520
|
+
parts.push(flag, shellEscape(syspromptPath));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (effectiveTools) {
|
|
524
|
+
const BUILTIN_TOOLS = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
|
|
525
|
+
const builtins = effectiveTools
|
|
526
|
+
.split(",")
|
|
527
|
+
.map((t) => t.trim())
|
|
528
|
+
.filter((t) => BUILTIN_TOOLS.has(t));
|
|
529
|
+
if (builtins.length > 0) {
|
|
530
|
+
parts.push("--tools", shellEscape(builtins.join(",")));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (effectiveSkills) {
|
|
535
|
+
for (const skill of effectiveSkills
|
|
536
|
+
.split(",")
|
|
537
|
+
.map((s) => s.trim())
|
|
538
|
+
.filter(Boolean)) {
|
|
539
|
+
parts.push(shellEscape(`/skill:${skill}`));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Build env prefix: denied tools + subagent identity + config dir propagation
|
|
544
|
+
const envParts: string[] = [];
|
|
545
|
+
|
|
546
|
+
// Resolve PI_CODING_AGENT_DIR: if cwd is set and the target has its own
|
|
547
|
+
// .pi/agent/, use that as the config root — this gives the sub-agent full
|
|
548
|
+
// config isolation (its own extensions, skills, models, auth). Otherwise
|
|
549
|
+
// propagate the parent's PI_CODING_AGENT_DIR.
|
|
550
|
+
const rawCwdForEnv = params.cwd ?? agentDefs?.cwd ?? null;
|
|
551
|
+
const cwdIsFromAgentForEnv = !params.cwd && agentDefs?.cwd != null;
|
|
552
|
+
const cwdBaseForEnv = cwdIsFromAgentForEnv ? getAgentConfigDir() : process.cwd();
|
|
553
|
+
const resolvedCwdForEnv = rawCwdForEnv
|
|
554
|
+
? rawCwdForEnv.startsWith("/")
|
|
555
|
+
? rawCwdForEnv
|
|
556
|
+
: join(cwdBaseForEnv, rawCwdForEnv)
|
|
557
|
+
: null;
|
|
558
|
+
const localAgentDir = resolvedCwdForEnv ? join(resolvedCwdForEnv, ".pi", "agent") : null;
|
|
559
|
+
if (localAgentDir && existsSync(localAgentDir)) {
|
|
560
|
+
envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(localAgentDir)}`);
|
|
561
|
+
} else if (process.env.PI_CODING_AGENT_DIR) {
|
|
562
|
+
envParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (denySet.size > 0) {
|
|
566
|
+
envParts.push(`PI_DENY_TOOLS=${shellEscape([...denySet].join(","))}`);
|
|
567
|
+
}
|
|
568
|
+
envParts.push(`PI_SUBAGENT_NAME=${shellEscape(params.name)}`);
|
|
569
|
+
if (params.agent) {
|
|
570
|
+
envParts.push(`PI_SUBAGENT_AGENT=${shellEscape(params.agent)}`);
|
|
571
|
+
}
|
|
572
|
+
if (agentDefs?.autoExit) {
|
|
573
|
+
envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
|
|
574
|
+
}
|
|
575
|
+
const envPrefix = envParts.join(" ") + " ";
|
|
576
|
+
|
|
577
|
+
// Pass task to the sub-agent.
|
|
578
|
+
// For fork mode, pass as a plain quoted argument — the forked session already
|
|
579
|
+
// has the full conversation context, so the message arrives as if the user typed it.
|
|
580
|
+
// For non-fork mode, write to an artifact file and pass via @file to handle
|
|
581
|
+
// long task descriptions with role/instructions safely.
|
|
582
|
+
if (params.fork) {
|
|
583
|
+
parts.push(shellEscape(fullTask));
|
|
584
|
+
} else {
|
|
585
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
586
|
+
const artifactDir = getArtifactDir(ctx.sessionManager.getSessionDir(), sessionId);
|
|
587
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
588
|
+
const safeName = params.name
|
|
589
|
+
.toLowerCase()
|
|
590
|
+
.replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens
|
|
591
|
+
.replace(/\s+/g, "-") // spaces to hyphens
|
|
592
|
+
.replace(/-+/g, "-") // collapse multiple hyphens
|
|
593
|
+
.replace(/^-|-$/g, ""); // trim leading/trailing hyphens
|
|
594
|
+
const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`;
|
|
595
|
+
const artifactPath = join(artifactDir, artifactName);
|
|
596
|
+
mkdirSync(dirname(artifactPath), { recursive: true });
|
|
597
|
+
writeFileSync(artifactPath, fullTask, "utf8");
|
|
598
|
+
parts.push(`@${artifactPath}`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Resolve cwd — param overrides agent default, supports absolute and relative paths.
|
|
602
|
+
// For agent-default cwd (from the .md definition), resolve relative to the config dir
|
|
603
|
+
// where the agent was discovered — not process.cwd(). This allows agents to find their
|
|
604
|
+
// role folders when PI_CODING_AGENT_DIR points to a different directory than cwd.
|
|
605
|
+
const rawCwd = params.cwd ?? agentDefs?.cwd ?? null;
|
|
606
|
+
const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null;
|
|
607
|
+
const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd();
|
|
608
|
+
const effectiveCwd = rawCwd
|
|
609
|
+
? rawCwd.startsWith("/")
|
|
610
|
+
? rawCwd
|
|
611
|
+
: join(cwdBase, rawCwd)
|
|
612
|
+
: null;
|
|
613
|
+
const cdPrefix = effectiveCwd ? `cd ${shellEscape(effectiveCwd)} && ` : "";
|
|
614
|
+
|
|
615
|
+
const piCommand = cdPrefix + envPrefix + parts.join(" ");
|
|
616
|
+
const command = `${piCommand}; echo '__SUBAGENT_DONE_'${exitStatusVar()}'__'`;
|
|
617
|
+
sendCommand(surface, command);
|
|
618
|
+
|
|
619
|
+
const running: RunningSubagent = {
|
|
620
|
+
id,
|
|
621
|
+
name: params.name,
|
|
622
|
+
task: params.task,
|
|
623
|
+
agent: params.agent,
|
|
624
|
+
surface,
|
|
625
|
+
startTime,
|
|
626
|
+
sessionFile: subagentSessionFile,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
runningSubagents.set(id, running);
|
|
630
|
+
return running;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Watch a launched subagent until it exits. Polls for completion, extracts
|
|
635
|
+
* the summary from the session file, cleans up the surface,
|
|
636
|
+
* and removes the entry from runningSubagents.
|
|
637
|
+
*/
|
|
638
|
+
async function watchSubagent(
|
|
639
|
+
running: RunningSubagent,
|
|
640
|
+
signal: AbortSignal,
|
|
641
|
+
): Promise<SubagentResult> {
|
|
642
|
+
const { name, task, surface, startTime, sessionFile } = running;
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
const exitCode = await pollForExit(surface, signal, {
|
|
646
|
+
interval: 1000,
|
|
647
|
+
onTick() {
|
|
648
|
+
// Update entries/bytes for widget display
|
|
649
|
+
try {
|
|
650
|
+
if (existsSync(sessionFile)) {
|
|
651
|
+
const stat = statSync(sessionFile);
|
|
652
|
+
const raw = readFileSync(sessionFile, "utf8");
|
|
653
|
+
running.entries = raw.split("\n").filter((l) => l.trim()).length;
|
|
654
|
+
running.bytes = stat.size;
|
|
655
|
+
}
|
|
656
|
+
} catch {}
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
661
|
+
|
|
662
|
+
// Extract summary from the known session file
|
|
663
|
+
let summary: string;
|
|
664
|
+
if (existsSync(sessionFile)) {
|
|
665
|
+
const allEntries = getNewEntries(sessionFile, 0);
|
|
666
|
+
summary =
|
|
667
|
+
findLastAssistantMessage(allEntries) ??
|
|
668
|
+
(exitCode !== 0
|
|
669
|
+
? `Sub-agent exited with code ${exitCode}`
|
|
670
|
+
: "Sub-agent exited without output");
|
|
671
|
+
} else {
|
|
672
|
+
summary =
|
|
673
|
+
exitCode !== 0
|
|
674
|
+
? `Sub-agent exited with code ${exitCode}`
|
|
675
|
+
: "Sub-agent exited without output";
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
closeSurface(surface);
|
|
679
|
+
runningSubagents.delete(running.id);
|
|
680
|
+
|
|
681
|
+
return { name, task, summary, sessionFile, exitCode, elapsed };
|
|
682
|
+
} catch (err: any) {
|
|
683
|
+
try {
|
|
684
|
+
closeSurface(surface);
|
|
685
|
+
} catch {}
|
|
686
|
+
runningSubagents.delete(running.id);
|
|
687
|
+
|
|
688
|
+
if (signal.aborted) {
|
|
689
|
+
return {
|
|
690
|
+
name,
|
|
691
|
+
task,
|
|
692
|
+
summary: "Subagent cancelled.",
|
|
693
|
+
exitCode: 1,
|
|
694
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
695
|
+
error: "cancelled",
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
name,
|
|
700
|
+
task,
|
|
701
|
+
summary: `Subagent error: ${err?.message ?? String(err)}`,
|
|
702
|
+
exitCode: 1,
|
|
703
|
+
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
|
704
|
+
error: err?.message ?? String(err),
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
710
|
+
// Capture the UI context for widget updates
|
|
711
|
+
pi.on("session_start", (_event, ctx) => {
|
|
712
|
+
latestCtx = ctx;
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
// Clean up on session shutdown
|
|
716
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
717
|
+
if (widgetInterval) {
|
|
718
|
+
clearInterval(widgetInterval);
|
|
719
|
+
widgetInterval = null;
|
|
720
|
+
}
|
|
721
|
+
for (const [_id, agent] of runningSubagents) {
|
|
722
|
+
agent.abortController?.abort();
|
|
723
|
+
}
|
|
724
|
+
runningSubagents.clear();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter)
|
|
728
|
+
const deniedTools = new Set(
|
|
729
|
+
(process.env.PI_DENY_TOOLS ?? "")
|
|
730
|
+
.split(",")
|
|
731
|
+
.map((s) => s.trim())
|
|
732
|
+
.filter(Boolean),
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
const shouldRegister = (name: string) => !deniedTools.has(name);
|
|
736
|
+
|
|
737
|
+
// ── subagent tool ──
|
|
738
|
+
if (shouldRegister("subagent"))
|
|
739
|
+
pi.registerTool({
|
|
740
|
+
name: "subagent",
|
|
741
|
+
label: "Subagent",
|
|
742
|
+
description:
|
|
743
|
+
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
|
744
|
+
"IMPORTANT: This tool returns IMMEDIATELY — the sub-agent runs asynchronously in the background. " +
|
|
745
|
+
"You will NOT have results when this tool returns. Results are delivered later via a steer message. " +
|
|
746
|
+
"Do NOT fabricate, assume, or summarize results after calling this tool. " +
|
|
747
|
+
"Either wait for the steer message or move on to other work.",
|
|
748
|
+
promptSnippet:
|
|
749
|
+
"Spawn a sub-agent in a dedicated terminal multiplexer pane. " +
|
|
750
|
+
"IMPORTANT: This tool returns IMMEDIATELY — the sub-agent runs asynchronously in the background. " +
|
|
751
|
+
"You will NOT have results when this tool returns. Results are delivered later via a steer message. " +
|
|
752
|
+
"Do NOT fabricate, assume, or summarize results after calling this tool. " +
|
|
753
|
+
"Either wait for the steer message or move on to other work.",
|
|
754
|
+
parameters: SubagentParams,
|
|
755
|
+
|
|
756
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
757
|
+
// Prevent self-spawning (e.g. planner spawning another planner)
|
|
758
|
+
const currentAgent = process.env.PI_SUBAGENT_AGENT;
|
|
759
|
+
if (params.agent && currentAgent && params.agent === currentAgent) {
|
|
760
|
+
return {
|
|
761
|
+
content: [
|
|
762
|
+
{
|
|
763
|
+
type: "text",
|
|
764
|
+
text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`,
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
details: { error: "self-spawn blocked" },
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Validate prerequisites
|
|
772
|
+
if (!isMuxAvailable()) {
|
|
773
|
+
return muxUnavailableResult("subagents");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (!ctx.sessionManager.getSessionFile()) {
|
|
777
|
+
return {
|
|
778
|
+
content: [
|
|
779
|
+
{
|
|
780
|
+
type: "text",
|
|
781
|
+
text: "Error: no session file. Start pi with a persistent session to use subagents.",
|
|
782
|
+
},
|
|
783
|
+
],
|
|
784
|
+
details: { error: "no session file" },
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Launch the subagent (creates pane, sends command)
|
|
789
|
+
const running = await launchSubagent(params, ctx);
|
|
790
|
+
|
|
791
|
+
// Create a separate AbortController for the watcher
|
|
792
|
+
// (the tool's signal completes when we return)
|
|
793
|
+
const watcherAbort = new AbortController();
|
|
794
|
+
running.abortController = watcherAbort;
|
|
795
|
+
|
|
796
|
+
// Start widget refresh when first agent launches
|
|
797
|
+
startWidgetRefresh();
|
|
798
|
+
|
|
799
|
+
// Fire-and-forget: start watching in background
|
|
800
|
+
watchSubagent(running, watcherAbort.signal)
|
|
801
|
+
.then((result) => {
|
|
802
|
+
updateWidget(); // reflect removal from Map immediately
|
|
803
|
+
const sessionRef = result.sessionFile
|
|
804
|
+
? `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`
|
|
805
|
+
: "";
|
|
806
|
+
const content =
|
|
807
|
+
result.exitCode !== 0
|
|
808
|
+
? `Sub-agent "${running.name}" failed (exit code ${result.exitCode}).\n\n${result.summary}${sessionRef}`
|
|
809
|
+
: `Sub-agent "${running.name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}${sessionRef}`;
|
|
810
|
+
|
|
811
|
+
pi.sendMessage(
|
|
812
|
+
{
|
|
813
|
+
customType: "subagent_result",
|
|
814
|
+
content,
|
|
815
|
+
display: true,
|
|
816
|
+
details: {
|
|
817
|
+
name: running.name,
|
|
818
|
+
task: running.task,
|
|
819
|
+
agent: running.agent,
|
|
820
|
+
exitCode: result.exitCode,
|
|
821
|
+
elapsed: result.elapsed,
|
|
822
|
+
sessionFile: result.sessionFile,
|
|
823
|
+
},
|
|
824
|
+
},
|
|
825
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
826
|
+
);
|
|
827
|
+
})
|
|
828
|
+
.catch((err) => {
|
|
829
|
+
updateWidget();
|
|
830
|
+
pi.sendMessage(
|
|
831
|
+
{
|
|
832
|
+
customType: "subagent_result",
|
|
833
|
+
content: `Sub-agent "${running.name}" error: ${err?.message ?? String(err)}`,
|
|
834
|
+
display: true,
|
|
835
|
+
details: { name: running.name, task: running.task, error: err?.message },
|
|
836
|
+
},
|
|
837
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
838
|
+
);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// Return immediately
|
|
842
|
+
return {
|
|
843
|
+
content: [
|
|
844
|
+
{
|
|
845
|
+
type: "text",
|
|
846
|
+
text:
|
|
847
|
+
`Sub-agent "${params.name}" launched and is now running in the background. ` +
|
|
848
|
+
`Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
|
|
849
|
+
`The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
|
|
850
|
+
`Until then, move on to other work or tell the user you're waiting.`,
|
|
851
|
+
},
|
|
852
|
+
],
|
|
853
|
+
details: {
|
|
854
|
+
id: running.id,
|
|
855
|
+
name: params.name,
|
|
856
|
+
task: params.task,
|
|
857
|
+
agent: params.agent,
|
|
858
|
+
sessionFile: running.sessionFile,
|
|
859
|
+
status: "started",
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
},
|
|
863
|
+
|
|
864
|
+
renderCall(args, theme) {
|
|
865
|
+
const agent = args.agent ? theme.fg("dim", ` (${args.agent})`) : "";
|
|
866
|
+
const cwdHint = args.cwd ? theme.fg("dim", ` in ${args.cwd}`) : "";
|
|
867
|
+
let text =
|
|
868
|
+
"▸ " + theme.fg("toolTitle", theme.bold(args.name ?? "(unnamed)")) + agent + cwdHint;
|
|
869
|
+
|
|
870
|
+
// Show a one-line task preview. renderCall is called repeatedly as the
|
|
871
|
+
// LLM generates tool arguments, so args.task grows token by token.
|
|
872
|
+
// We keep it compact here — Ctrl+O on renderResult expands the full content.
|
|
873
|
+
const task = args.task ?? "";
|
|
874
|
+
if (task) {
|
|
875
|
+
const firstLine = task.split("\n").find((l: string) => l.trim()) ?? "";
|
|
876
|
+
const preview = firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine;
|
|
877
|
+
if (preview) {
|
|
878
|
+
text += "\n" + theme.fg("toolOutput", preview);
|
|
879
|
+
}
|
|
880
|
+
const totalLines = task.split("\n").length;
|
|
881
|
+
if (totalLines > 1) {
|
|
882
|
+
text += theme.fg("muted", ` (${totalLines} lines)`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
return new Text(text, 0, 0);
|
|
887
|
+
},
|
|
888
|
+
|
|
889
|
+
renderResult(result, _opts, theme) {
|
|
890
|
+
const details = result.details as any;
|
|
891
|
+
const name = details?.name ?? "(unnamed)";
|
|
892
|
+
|
|
893
|
+
// "Started" result — tool returned immediately
|
|
894
|
+
if (details?.status === "started") {
|
|
895
|
+
return new Text(
|
|
896
|
+
theme.fg("accent", "▸") +
|
|
897
|
+
" " +
|
|
898
|
+
theme.fg("toolTitle", theme.bold(name)) +
|
|
899
|
+
theme.fg("dim", " — started"),
|
|
900
|
+
0,
|
|
901
|
+
0,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Fallback (shouldn't happen)
|
|
906
|
+
const text = typeof result.content?.[0]?.text === "string" ? result.content[0].text : "";
|
|
907
|
+
return new Text(theme.fg("dim", text), 0, 0);
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// ── subagents_list tool ──
|
|
912
|
+
if (shouldRegister("subagents_list"))
|
|
913
|
+
pi.registerTool({
|
|
914
|
+
name: "subagents_list",
|
|
915
|
+
label: "List Subagents",
|
|
916
|
+
description:
|
|
917
|
+
"List all available subagent definitions. " +
|
|
918
|
+
"Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " +
|
|
919
|
+
"Project-local agents override global ones with the same name.",
|
|
920
|
+
promptSnippet:
|
|
921
|
+
"List all available subagent definitions. " +
|
|
922
|
+
"Scans project-local .pi/agents/ and global ~/.pi/agent/agents/. " +
|
|
923
|
+
"Project-local agents override global ones with the same name.",
|
|
924
|
+
parameters: Type.Object({}),
|
|
925
|
+
|
|
926
|
+
async execute() {
|
|
927
|
+
const agents = new Map<
|
|
928
|
+
string,
|
|
929
|
+
{ name: string; description?: string; model?: string; source: string }
|
|
930
|
+
>();
|
|
931
|
+
|
|
932
|
+
const dirs = [
|
|
933
|
+
{
|
|
934
|
+
path: join(dirname(new URL(import.meta.url).pathname), "../../agents"),
|
|
935
|
+
source: "package",
|
|
936
|
+
},
|
|
937
|
+
{ path: join(getAgentConfigDir(), "agents"), source: "global" },
|
|
938
|
+
{ path: join(process.cwd(), ".pi", "agents"), source: "project" },
|
|
939
|
+
];
|
|
940
|
+
|
|
941
|
+
for (const { path: dir, source } of dirs) {
|
|
942
|
+
if (!existsSync(dir)) continue;
|
|
943
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
|
|
944
|
+
const content = readFileSync(join(dir, file), "utf8");
|
|
945
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
946
|
+
if (!match) continue;
|
|
947
|
+
const frontmatter = match[1];
|
|
948
|
+
const get = (key: string) => {
|
|
949
|
+
const m = frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
|
|
950
|
+
return m ? m[1].trim() : undefined;
|
|
951
|
+
};
|
|
952
|
+
const name = get("name") ?? file.replace(/\.md$/, "");
|
|
953
|
+
agents.set(name, {
|
|
954
|
+
name,
|
|
955
|
+
description: get("description"),
|
|
956
|
+
model: get("model"),
|
|
957
|
+
source,
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
if (agents.size === 0) {
|
|
963
|
+
return {
|
|
964
|
+
content: [{ type: "text", text: "No subagent definitions found." }],
|
|
965
|
+
details: { agents: [] },
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const list = [...agents.values()];
|
|
970
|
+
const lines = list.map((a) => {
|
|
971
|
+
const badge = a.source === "project" ? " (project)" : "";
|
|
972
|
+
const desc = a.description ? ` — ${a.description}` : "";
|
|
973
|
+
const model = a.model ? ` [${a.model}]` : "";
|
|
974
|
+
return `• ${a.name}${badge}${model}${desc}`;
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
return {
|
|
978
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
979
|
+
details: { agents: list },
|
|
980
|
+
};
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
renderResult(result, _opts, theme) {
|
|
984
|
+
const details = result.details as any;
|
|
985
|
+
const agents = details?.agents ?? [];
|
|
986
|
+
if (agents.length === 0) {
|
|
987
|
+
return new Text(theme.fg("dim", "No subagent definitions found."), 0, 0);
|
|
988
|
+
}
|
|
989
|
+
const lines = agents.map((a: any) => {
|
|
990
|
+
const badge = a.source === "project" ? theme.fg("accent", " (project)") : "";
|
|
991
|
+
const desc = a.description ? theme.fg("dim", ` — ${a.description}`) : "";
|
|
992
|
+
const model = a.model ? theme.fg("dim", ` [${a.model}]`) : "";
|
|
993
|
+
return ` ${theme.fg("toolTitle", theme.bold(a.name))}${badge}${model}${desc}`;
|
|
994
|
+
});
|
|
995
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// ── set_tab_title tool ──
|
|
1000
|
+
if (shouldRegister("set_tab_title"))
|
|
1001
|
+
pi.registerTool({
|
|
1002
|
+
name: "set_tab_title",
|
|
1003
|
+
label: "Set Tab Title",
|
|
1004
|
+
description:
|
|
1005
|
+
"Update the current tab/window and workspace/session title. Use to show progress during multi-phase workflows " +
|
|
1006
|
+
"(e.g. planning, executing todos, reviewing). Keep titles short and informative.",
|
|
1007
|
+
promptSnippet:
|
|
1008
|
+
"Update the current tab/window and workspace/session title. Use to show progress during multi-phase workflows " +
|
|
1009
|
+
"(e.g. planning, executing todos, reviewing). Keep titles short and informative.",
|
|
1010
|
+
parameters: Type.Object({
|
|
1011
|
+
title: Type.String({
|
|
1012
|
+
description: "New tab title (also applied to workspace/session when supported)",
|
|
1013
|
+
}),
|
|
1014
|
+
}),
|
|
1015
|
+
|
|
1016
|
+
async execute(_toolCallId, params) {
|
|
1017
|
+
if (!isMuxAvailable()) {
|
|
1018
|
+
return muxUnavailableResult("tab-title");
|
|
1019
|
+
}
|
|
1020
|
+
try {
|
|
1021
|
+
renameCurrentTab(params.title);
|
|
1022
|
+
renameWorkspace(params.title);
|
|
1023
|
+
return {
|
|
1024
|
+
content: [{ type: "text", text: `Title set to: ${params.title}` }],
|
|
1025
|
+
details: { title: params.title },
|
|
1026
|
+
};
|
|
1027
|
+
} catch (err: any) {
|
|
1028
|
+
return {
|
|
1029
|
+
content: [{ type: "text", text: `Failed to set title: ${err?.message}` }],
|
|
1030
|
+
details: { error: err?.message },
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// ── subagent_resume tool ──
|
|
1037
|
+
if (shouldRegister("subagent_resume"))
|
|
1038
|
+
pi.registerTool({
|
|
1039
|
+
name: "subagent_resume",
|
|
1040
|
+
label: "Resume Subagent",
|
|
1041
|
+
description:
|
|
1042
|
+
"Resume a previous sub-agent session in a new multiplexer pane. " +
|
|
1043
|
+
"IMPORTANT: Returns IMMEDIATELY — the resumed session runs asynchronously in the background. " +
|
|
1044
|
+
"Results are delivered later via a steer message. Do NOT fabricate or assume results. " +
|
|
1045
|
+
"Use when a sub-agent was cancelled or needs follow-up work.",
|
|
1046
|
+
promptSnippet:
|
|
1047
|
+
"Resume a previous sub-agent session in a new multiplexer pane. " +
|
|
1048
|
+
"IMPORTANT: Returns IMMEDIATELY — the resumed session runs asynchronously in the background. " +
|
|
1049
|
+
"Results are delivered later via a steer message. Do NOT fabricate or assume results. " +
|
|
1050
|
+
"Use when a sub-agent was cancelled or needs follow-up work.",
|
|
1051
|
+
parameters: Type.Object({
|
|
1052
|
+
sessionPath: Type.String({ description: "Path to the session .jsonl file to resume" }),
|
|
1053
|
+
name: Type.Optional(
|
|
1054
|
+
Type.String({ description: "Display name for the terminal tab. Default: 'Resume'" }),
|
|
1055
|
+
),
|
|
1056
|
+
message: Type.Optional(
|
|
1057
|
+
Type.String({
|
|
1058
|
+
description: "Optional message to send after resuming (e.g. follow-up instructions)",
|
|
1059
|
+
}),
|
|
1060
|
+
),
|
|
1061
|
+
}),
|
|
1062
|
+
|
|
1063
|
+
renderCall(args, theme) {
|
|
1064
|
+
const name = args.name ?? "Resume";
|
|
1065
|
+
const text =
|
|
1066
|
+
"▸ " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", " — resuming session");
|
|
1067
|
+
return new Text(text, 0, 0);
|
|
1068
|
+
},
|
|
1069
|
+
|
|
1070
|
+
renderResult(result, _opts, theme) {
|
|
1071
|
+
const details = result.details as any;
|
|
1072
|
+
const name = details?.name ?? "Resume";
|
|
1073
|
+
|
|
1074
|
+
if (details?.status === "started") {
|
|
1075
|
+
return new Text(
|
|
1076
|
+
theme.fg("accent", "▸") +
|
|
1077
|
+
" " +
|
|
1078
|
+
theme.fg("toolTitle", theme.bold(name)) +
|
|
1079
|
+
theme.fg("dim", " — resumed"),
|
|
1080
|
+
0,
|
|
1081
|
+
0,
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Fallback
|
|
1086
|
+
const text = typeof result.content?.[0]?.text === "string" ? result.content[0].text : "";
|
|
1087
|
+
return new Text(theme.fg("dim", text), 0, 0);
|
|
1088
|
+
},
|
|
1089
|
+
|
|
1090
|
+
async execute(_toolCallId, params, _signal, _onUpdate) {
|
|
1091
|
+
const name = params.name ?? "Resume";
|
|
1092
|
+
const startTime = Date.now();
|
|
1093
|
+
|
|
1094
|
+
if (!isMuxAvailable()) {
|
|
1095
|
+
return muxUnavailableResult("subagents");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (!existsSync(params.sessionPath)) {
|
|
1099
|
+
return {
|
|
1100
|
+
content: [
|
|
1101
|
+
{ type: "text", text: `Error: session file not found: ${params.sessionPath}` },
|
|
1102
|
+
],
|
|
1103
|
+
details: { error: "session not found" },
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Record entry count before resuming so we can extract new messages
|
|
1108
|
+
const entryCountBefore = getNewEntries(params.sessionPath, 0).length;
|
|
1109
|
+
|
|
1110
|
+
const surface = createSurface(name);
|
|
1111
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 500));
|
|
1112
|
+
|
|
1113
|
+
// Build pi resume command
|
|
1114
|
+
const parts = ["pi", "--session", shellEscape(params.sessionPath)];
|
|
1115
|
+
|
|
1116
|
+
// Load subagent-done extension so the agent can self-terminate if needed
|
|
1117
|
+
const subagentDonePath = join(
|
|
1118
|
+
dirname(new URL(import.meta.url).pathname),
|
|
1119
|
+
"subagent-done.ts",
|
|
1120
|
+
);
|
|
1121
|
+
parts.push("-e", shellEscape(subagentDonePath));
|
|
1122
|
+
|
|
1123
|
+
let cleanupMsgFile: string | undefined;
|
|
1124
|
+
if (params.message) {
|
|
1125
|
+
const msgFile = join(tmpdir(), `subagent-resume-${Date.now()}.md`);
|
|
1126
|
+
writeFileSync(msgFile, params.message, "utf8");
|
|
1127
|
+
cleanupMsgFile = msgFile;
|
|
1128
|
+
parts.push(`@${msgFile}`);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Build env prefix — propagate PI_CODING_AGENT_DIR for config isolation
|
|
1132
|
+
const resumeEnvParts: string[] = [];
|
|
1133
|
+
if (process.env.PI_CODING_AGENT_DIR) {
|
|
1134
|
+
resumeEnvParts.push(`PI_CODING_AGENT_DIR=${shellEscape(process.env.PI_CODING_AGENT_DIR)}`);
|
|
1135
|
+
}
|
|
1136
|
+
const resumeEnvPrefix = resumeEnvParts.length > 0 ? resumeEnvParts.join(" ") + " " : "";
|
|
1137
|
+
|
|
1138
|
+
const command = `${resumeEnvPrefix}${parts.join(" ")}${cleanupMsgFile ? `; rm -f ${shellEscape(cleanupMsgFile)}` : ""}; echo '__SUBAGENT_DONE_'${exitStatusVar()}'__'`;
|
|
1139
|
+
sendCommand(surface, command);
|
|
1140
|
+
|
|
1141
|
+
// Register as a running subagent for widget tracking
|
|
1142
|
+
const id = Math.random().toString(16).slice(2, 10);
|
|
1143
|
+
const running: RunningSubagent = {
|
|
1144
|
+
id,
|
|
1145
|
+
name,
|
|
1146
|
+
task: params.message ?? "resumed session",
|
|
1147
|
+
surface,
|
|
1148
|
+
startTime,
|
|
1149
|
+
sessionFile: params.sessionPath,
|
|
1150
|
+
};
|
|
1151
|
+
runningSubagents.set(id, running);
|
|
1152
|
+
startWidgetRefresh();
|
|
1153
|
+
|
|
1154
|
+
// Fire-and-forget watcher
|
|
1155
|
+
const watcherAbort = new AbortController();
|
|
1156
|
+
running.abortController = watcherAbort;
|
|
1157
|
+
|
|
1158
|
+
watchSubagent(running, watcherAbort.signal)
|
|
1159
|
+
.then((result) => {
|
|
1160
|
+
updateWidget();
|
|
1161
|
+
const allEntries = getNewEntries(params.sessionPath, entryCountBefore);
|
|
1162
|
+
const summary =
|
|
1163
|
+
findLastAssistantMessage(allEntries) ??
|
|
1164
|
+
(result.exitCode !== 0
|
|
1165
|
+
? `Resumed session exited with code ${result.exitCode}`
|
|
1166
|
+
: "Resumed session exited without new output");
|
|
1167
|
+
const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`;
|
|
1168
|
+
|
|
1169
|
+
pi.sendMessage(
|
|
1170
|
+
{
|
|
1171
|
+
customType: "subagent_result",
|
|
1172
|
+
content: `${summary}${sessionRef}`,
|
|
1173
|
+
display: true,
|
|
1174
|
+
details: {
|
|
1175
|
+
name,
|
|
1176
|
+
task: params.message ?? "resumed session",
|
|
1177
|
+
exitCode: result.exitCode,
|
|
1178
|
+
elapsed: result.elapsed,
|
|
1179
|
+
sessionFile: params.sessionPath,
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1183
|
+
);
|
|
1184
|
+
})
|
|
1185
|
+
.catch((err) => {
|
|
1186
|
+
updateWidget();
|
|
1187
|
+
pi.sendMessage(
|
|
1188
|
+
{
|
|
1189
|
+
customType: "subagent_result",
|
|
1190
|
+
content: `Resume error: ${err?.message ?? String(err)}`,
|
|
1191
|
+
display: true,
|
|
1192
|
+
details: { name, error: err?.message },
|
|
1193
|
+
},
|
|
1194
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
1195
|
+
);
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
return {
|
|
1199
|
+
content: [{ type: "text", text: `Session "${name}" resumed.` }],
|
|
1200
|
+
details: { id, name, sessionPath: params.sessionPath, status: "started" },
|
|
1201
|
+
};
|
|
1202
|
+
},
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
// /iterate command — fork the session into a subagent
|
|
1206
|
+
pi.registerCommand("iterate", {
|
|
1207
|
+
description: "Fork session into a subagent for focused work (bugfixes, iteration)",
|
|
1208
|
+
handler: async (args, _ctx) => {
|
|
1209
|
+
const task = args?.trim() || "";
|
|
1210
|
+
const toolCall = task
|
|
1211
|
+
? `Use subagent to fork a session. fork: true, name: "Iterate", task: ${JSON.stringify(task)}`
|
|
1212
|
+
: `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."`;
|
|
1213
|
+
pi.sendUserMessage(toolCall);
|
|
1214
|
+
},
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
// /subagent command — spawn a subagent by name
|
|
1218
|
+
pi.registerCommand("subagent", {
|
|
1219
|
+
description: "Spawn a subagent: /subagent <agent> <task>",
|
|
1220
|
+
handler: async (args, ctx) => {
|
|
1221
|
+
const trimmed = (args ?? "").trim();
|
|
1222
|
+
if (!trimmed) {
|
|
1223
|
+
ctx.ui.notify("Usage: /subagent <agent> [task]", "warning");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
1228
|
+
const agentName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
|
|
1229
|
+
const task = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
|
|
1230
|
+
|
|
1231
|
+
const defs = loadAgentDefaults(agentName);
|
|
1232
|
+
if (!defs) {
|
|
1233
|
+
ctx.ui.notify(
|
|
1234
|
+
`Agent "${agentName}" not found in ~/.pi/agent/agents/ or .pi/agents/`,
|
|
1235
|
+
"error",
|
|
1236
|
+
);
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const taskText = task || `You are the ${agentName} agent. Wait for instructions.`;
|
|
1241
|
+
const displayName = agentName[0].toUpperCase() + agentName.slice(1);
|
|
1242
|
+
const toolCall = `Use subagent with agent: "${agentName}", name: "${displayName}", task: ${JSON.stringify(taskText)}`;
|
|
1243
|
+
pi.sendUserMessage(toolCall);
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
// ── subagent_result message renderer ──
|
|
1248
|
+
pi.registerMessageRenderer("subagent_result", (message, options, theme) => {
|
|
1249
|
+
const details = message.details as any;
|
|
1250
|
+
if (!details) return undefined;
|
|
1251
|
+
|
|
1252
|
+
return {
|
|
1253
|
+
render(width: number): string[] {
|
|
1254
|
+
const name = details.name ?? "subagent";
|
|
1255
|
+
const exitCode = details.exitCode ?? 0;
|
|
1256
|
+
const elapsed = details.elapsed != null ? formatElapsed(details.elapsed) : "?";
|
|
1257
|
+
const bgFn =
|
|
1258
|
+
exitCode === 0
|
|
1259
|
+
? (text: string) => theme.bg("toolSuccessBg", text)
|
|
1260
|
+
: (text: string) => theme.bg("toolErrorBg", text);
|
|
1261
|
+
const icon = exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
1262
|
+
const status = exitCode === 0 ? "completed" : `failed (exit ${exitCode})`;
|
|
1263
|
+
const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : "";
|
|
1264
|
+
|
|
1265
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`;
|
|
1266
|
+
const rawContent = typeof message.content === "string" ? message.content : "";
|
|
1267
|
+
|
|
1268
|
+
// Clean summary (remove session ref and leading label for display)
|
|
1269
|
+
const summary = rawContent
|
|
1270
|
+
.replace(/\n\nSession: .+\nResume: .+$/, "")
|
|
1271
|
+
.replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "")
|
|
1272
|
+
.replace(`Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "");
|
|
1273
|
+
|
|
1274
|
+
// Build content for the box
|
|
1275
|
+
const contentLines = [header];
|
|
1276
|
+
|
|
1277
|
+
if (options.expanded) {
|
|
1278
|
+
// Full view: complete summary + session info
|
|
1279
|
+
if (summary) {
|
|
1280
|
+
for (const line of summary.split("\n")) {
|
|
1281
|
+
contentLines.push(line.slice(0, width - 6));
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (details.sessionFile) {
|
|
1285
|
+
contentLines.push("");
|
|
1286
|
+
contentLines.push(theme.fg("dim", `Session: ${details.sessionFile}`));
|
|
1287
|
+
contentLines.push(theme.fg("dim", `Resume: pi --session ${details.sessionFile}`));
|
|
1288
|
+
}
|
|
1289
|
+
} else {
|
|
1290
|
+
// Collapsed: preview + expand hint
|
|
1291
|
+
if (summary) {
|
|
1292
|
+
const previewLines = summary.split("\n").slice(0, 5);
|
|
1293
|
+
for (const line of previewLines) {
|
|
1294
|
+
contentLines.push(theme.fg("dim", line.slice(0, width - 6)));
|
|
1295
|
+
}
|
|
1296
|
+
const totalLines = summary.split("\n").length;
|
|
1297
|
+
if (totalLines > 5) {
|
|
1298
|
+
contentLines.push(theme.fg("muted", `… ${totalLines - 5} more lines`));
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
contentLines.push(theme.fg("muted", keyHint("app.tools.expand", "to expand")));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Render via Box for background + padding, with blank line above for separation
|
|
1305
|
+
const box = new Box(1, 1, bgFn);
|
|
1306
|
+
box.addChild(new Text(contentLines.join("\n"), 0, 0));
|
|
1307
|
+
return ["", ...box.render(width)];
|
|
1308
|
+
},
|
|
1309
|
+
};
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
// /plan command — start the full planning workflow
|
|
1313
|
+
pi.registerCommand("plan", {
|
|
1314
|
+
description: "Start a planning session: /plan <what to build>",
|
|
1315
|
+
handler: async (args, ctx) => {
|
|
1316
|
+
const task = (args ?? "").trim();
|
|
1317
|
+
if (!task) {
|
|
1318
|
+
ctx.ui.notify("Usage: /plan <what to build>", "warning");
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Rename workspace and tab to show this is a planning session
|
|
1323
|
+
if (isMuxAvailable()) {
|
|
1324
|
+
try {
|
|
1325
|
+
const label = task.length > 40 ? task.slice(0, 40) + "..." : task;
|
|
1326
|
+
renameWorkspace(`🎯 ${label}`);
|
|
1327
|
+
renameCurrentTab(`🎯 Plan: ${label}`);
|
|
1328
|
+
} catch {
|
|
1329
|
+
// non-critical -- do not block the plan
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Load the plan skill from the subagents extension directory
|
|
1334
|
+
const planSkillPath = join(dirname(new URL(import.meta.url).pathname), "plan-skill.md");
|
|
1335
|
+
let content = readFileSync(planSkillPath, "utf8");
|
|
1336
|
+
content = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
|
|
1337
|
+
pi.sendUserMessage(
|
|
1338
|
+
`<skill name="plan" location="${planSkillPath}">\n${content.trim()}\n</skill>\n\n${task}`,
|
|
1339
|
+
);
|
|
1340
|
+
},
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
// test
|