pi-herdr-agents 0.0.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.
Files changed (49) hide show
  1. package/AGENTS.md +116 -0
  2. package/CONTEXT.md +159 -0
  3. package/LICENSE +21 -0
  4. package/README.md +874 -0
  5. package/RELEASING.md +139 -0
  6. package/agents/adversarial-reviewer.md +80 -0
  7. package/agents/claude-reviewer.md +23 -0
  8. package/agents/planner.md +539 -0
  9. package/agents/poteto.md +32 -0
  10. package/agents/reviewer.md +164 -0
  11. package/agents/scout.md +106 -0
  12. package/agents/visual-tester.md +224 -0
  13. package/agents/worker.md +132 -0
  14. package/config.json.example +8 -0
  15. package/docs/README.md +42 -0
  16. package/docs/adr/0001-btw-ephemeral-side-questions.md +142 -0
  17. package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +265 -0
  18. package/docs/adr/0003-installable-role-packs.md +135 -0
  19. package/docs/adr/0004-require-active-user-approval-for-workflow-execution.md +17 -0
  20. package/docs/adr/0005-parent-owns-workflow-script-authority.md +17 -0
  21. package/docs/adr/0006-limit-v1-execution-effects-to-isolated-worktrees.md +18 -0
  22. package/docs/adr/0007-require-fresh-review-for-workflow-scripts.md +19 -0
  23. package/docs/orchestrated-review-workflow-plan.md +479 -0
  24. package/docs/research/pdw-architecture-assessment.md +525 -0
  25. package/docs/research/pi-workflows-sol-advisor.md +255 -0
  26. package/docs/research/worktree-subagent-orchestration.md +317 -0
  27. package/docs/worktree-subagents.md +196 -0
  28. package/examples/role-pack/extension.ts +18 -0
  29. package/examples/role-pack/package.json +16 -0
  30. package/examples/role-pack/roles/example-reviewer.md +12 -0
  31. package/package.json +58 -0
  32. package/pi-extension/subagents/activity.ts +511 -0
  33. package/pi-extension/subagents/completion.ts +177 -0
  34. package/pi-extension/subagents/herdr.ts +541 -0
  35. package/pi-extension/subagents/index.ts +4730 -0
  36. package/pi-extension/subagents/lifecycle.ts +477 -0
  37. package/pi-extension/subagents/model-config.ts +95 -0
  38. package/pi-extension/subagents/plan-skill.md +262 -0
  39. package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
  40. package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
  41. package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
  42. package/pi-extension/subagents/runtime-routing.ts +313 -0
  43. package/pi-extension/subagents/session.ts +216 -0
  44. package/pi-extension/subagents/status.ts +513 -0
  45. package/pi-extension/subagents/subagent-done.ts +326 -0
  46. package/pi-extension/subagents/terminal.ts +163 -0
  47. package/pi-extension/subagents/workflow-worker.js +56 -0
  48. package/pi-extension/subagents/workflow.ts +1210 -0
  49. package/skills/orchestrate/SKILL.md +184 -0
@@ -0,0 +1,4730 @@
1
+ import type {
2
+ AgentToolResult,
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { keyHint } from "@earendil-works/pi-coding-agent";
7
+ import { Type, type Static } from "@sinclair/typebox";
8
+ import {
9
+ Box,
10
+ Text,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ } from "@earendil-works/pi-tui";
14
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { execFileSync } from "node:child_process";
17
+ import {
18
+ readdirSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ writeFileSync,
22
+ existsSync,
23
+ mkdirSync,
24
+ copyFileSync,
25
+ unlinkSync,
26
+ rmSync,
27
+ renameSync,
28
+ statSync,
29
+ } from "node:fs";
30
+ import { homedir } from "node:os";
31
+ import {
32
+ isTerminalAvailable,
33
+ terminalSetupHint,
34
+ createSubagentPane,
35
+ createSubagentWorktree,
36
+ runScriptInPane,
37
+ closePane,
38
+ interruptPane,
39
+ shellQuote,
40
+ readPane,
41
+ readPaneAsync,
42
+ inspectPane,
43
+ getPaneProcessInfo,
44
+ waitForPaneAbsence,
45
+ waitForProcessesExit,
46
+ } from "./terminal.ts";
47
+ import { waitForCompletion } from "./completion.ts";
48
+ import {
49
+ buildAuthenticatedModelCatalog,
50
+ resolveRuntimePlan,
51
+ wrapPiModelRegistry,
52
+ THINKING_LEVELS,
53
+ isThinkingLevel,
54
+ type ResolvedRuntimePlan,
55
+ type ThinkingLevel,
56
+ } from "./runtime-routing.ts";
57
+ import { loadModelConfig, resolveModelDefault } from "./model-config.ts";
58
+ import {
59
+ beginWorkflowCancellation,
60
+ cancelTerminationResult,
61
+ claimWorkflowTerminal,
62
+ createWorkflowJournal,
63
+ createWorkflowReaderCheckout,
64
+ createWorkflowTerminalGate,
65
+ disposeWorkflowReaderCheckout,
66
+ executeWorkflow,
67
+ formatApprovalPacket,
68
+ prepareWorkflow,
69
+ recoverWorkflowStartup,
70
+ sameWorkflowCandidate,
71
+ validateWorkflowApproval,
72
+ type PendingWorkflow,
73
+ type WorkflowReaderCheckout,
74
+ type WorkflowRole,
75
+ type WorkflowTerminalGate,
76
+ type WorkflowTerminalOutcome,
77
+ } from "./workflow.ts";
78
+
79
+ import {
80
+ findLastAssistantMessage,
81
+ findObservedSessionRuntime,
82
+ getNewEntries,
83
+ seedSubagentSessionFile,
84
+ createBtwSessionSnapshot,
85
+ } from "./session.ts";
86
+ import {
87
+ type SubagentStatusState,
88
+ capStatusLines,
89
+ formatElapsedDuration,
90
+ formatStatusAggregate,
91
+ normalizeStatusName,
92
+ loadStatusConfig,
93
+ } from "./status.ts";
94
+ import {
95
+ getSubagentActivityFile,
96
+ readSubagentActivityFile,
97
+ type ActivityReadResult,
98
+ type SubagentActivityState,
99
+ } from "./activity.ts";
100
+ import {
101
+ createLifecycle,
102
+ formatLifecycleTransitionLine,
103
+ lifecycleTransition,
104
+ markCompleted,
105
+ markCompletionDetected,
106
+ markDelivery,
107
+ markFailed,
108
+ markInterruptRequested,
109
+ markProcessRunning,
110
+ observeActivity,
111
+ observePaneInspection,
112
+ projectLifecycle,
113
+ type LifecycleProjection,
114
+ type SubagentLifecycle,
115
+ type PaneInspection,
116
+ } from "./lifecycle.ts";
117
+
118
+ /** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */
119
+ const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url));
120
+
121
+ // Survive /reload: replace presentation timers while keeping active completion
122
+ // watchers and their registry alive. Old module closures continue watching the
123
+ // children; the reloaded module adopts the shared registry for status/interrupts.
124
+ const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval");
125
+ const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval");
126
+ const RUNTIME_KEY = Symbol.for("pi-subagents/runtime");
127
+
128
+ const BTW_BOUNDARY = `You are answering an ephemeral BTW side question.
129
+ Treat inherited conversation history only as reference context. Do not resume or complete an
130
+ earlier task. Answer only the question after this boundary. Do not modify the workspace unless
131
+ that side question explicitly requests a mutation.
132
+
133
+ BTW question:
134
+ `;
135
+
136
+ interface BtwChild {
137
+ surface: string;
138
+ sessionFile: string;
139
+ launchScriptFile: string;
140
+ }
141
+
142
+ function getFirstText(
143
+ content: readonly { type: string; text?: string }[],
144
+ ): string {
145
+ const first = content[0];
146
+ return first?.type === "text" ? (first.text ?? "") : "";
147
+ }
148
+
149
+ {
150
+ const prevInterval = (globalThis as any)[WIDGET_INTERVAL_KEY];
151
+ if (prevInterval) {
152
+ clearInterval(prevInterval);
153
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
154
+ }
155
+ const prevStatusInterval = (globalThis as any)[STATUS_INTERVAL_KEY];
156
+ if (prevStatusInterval) {
157
+ clearInterval(prevStatusInterval);
158
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
159
+ }
160
+ }
161
+
162
+ function buildSubagentRoutingGuidelines(catalog?: string): string[] {
163
+ return [
164
+ "For subagent model and thinking selection, inherit the parent runtime by omitting both fields unless the task warrants an override.",
165
+ "For subagent tasks, prefer changing thinking before changing models: minimal/low for bounded mechanical work, medium for ordinary implementation or review, and high+ for architecture, concurrency, security, or hard diagnosis.",
166
+ "When overriding a subagent model, use an exact authenticated provider/model-id from the live catalog below. Do not invent aliases or fuzzy names.",
167
+ catalog ??
168
+ "Authenticated subagent model catalog becomes available after session start.",
169
+ ];
170
+ }
171
+
172
+ const subagentRoutingGuidelines = buildSubagentRoutingGuidelines();
173
+
174
+ const ThinkingLevelSchema = Type.Union(
175
+ THINKING_LEVELS.map((level) => Type.Literal(level)),
176
+ {
177
+ description:
178
+ "Pi thinking level. Omit to inherit the parent level. Prefer changing thinking before changing models: minimal/low for bounded mechanical work, medium for ordinary implementation or review, high+ for architecture, concurrency, security, or hard diagnosis.",
179
+ },
180
+ );
181
+
182
+ const SubagentParams = Type.Object({
183
+ name: Type.String({ description: "Display name for the subagent" }),
184
+ task: Type.String({ description: "Task/prompt for the sub-agent" }),
185
+ agent: Type.Optional(
186
+ Type.String({
187
+ description:
188
+ "Agent name to load defaults from (e.g. 'worker', 'scout', 'reviewer'). Discovery precedence is project .pi/agents, global ~/.pi/agent/agents, then package-bundled agents.",
189
+ }),
190
+ ),
191
+ systemPrompt: Type.Optional(
192
+ Type.String({
193
+ description:
194
+ "Role/system-prompt text for a bare spawn. For a Claude CLI agent it overrides the definition body; named Pi agents keep their definition body.",
195
+ }),
196
+ ),
197
+ model: Type.Optional(
198
+ Type.String({
199
+ description:
200
+ "Exact authenticated provider/model-id. Omit to inherit the parent model. Select another model only when task capability, speed, cost, modality, or context requirements warrant it.",
201
+ }),
202
+ ),
203
+ thinking: Type.Optional(ThinkingLevelSchema),
204
+ skills: Type.Optional(
205
+ Type.String({
206
+ description: "Comma-separated skills (overrides agent default)",
207
+ }),
208
+ ),
209
+ tools: Type.Optional(
210
+ Type.String({
211
+ description: "Comma-separated tools (overrides agent default)",
212
+ }),
213
+ ),
214
+ cwd: Type.Optional(
215
+ Type.String({
216
+ description:
217
+ "Working directory for the sub-agent. Without worktree, the agent starts in this folder. With worktree, this selects the source Git repository and the agent starts at the created worktree root.",
218
+ }),
219
+ ),
220
+ worktree: Type.Optional(
221
+ Type.Object({
222
+ branch: Type.String({
223
+ minLength: 1,
224
+ description:
225
+ "New branch name for an isolated Herdr-managed Git worktree",
226
+ }),
227
+ base: Type.Optional(
228
+ Type.String({
229
+ description:
230
+ "Git revision to branch from. Defaults to the source checkout's committed HEAD.",
231
+ }),
232
+ ),
233
+ }),
234
+ ),
235
+ fork: Type.Optional(
236
+ Type.Boolean({
237
+ description:
238
+ "Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.",
239
+ }),
240
+ ),
241
+ interactive: Type.Optional(
242
+ Type.Boolean({
243
+ description:
244
+ "Mark the subagent as interactive (long-running, user drives the conversation in its own pane). When true, the main session is not woken by status transitions (stalled/recovered) for this subagent. If omitted, falls back to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit` (agents that auto-exit are autonomous and get stall pings; agents that don't are interactive and stay quiet).",
245
+ }),
246
+ ),
247
+ resumeSessionId: Type.Optional(
248
+ Type.String({
249
+ description:
250
+ "Resume a previous Claude Code session by its ID. Loads the conversation history and continues where it left off. The session ID is returned in details of every claude tool call. Use this to retry cancelled runs or ask follow-up questions.",
251
+ }),
252
+ ),
253
+ });
254
+
255
+ type SubagentSessionMode = "standalone" | "lineage-only" | "fork";
256
+
257
+ interface AgentDefaults {
258
+ model?: string;
259
+ tools?: string;
260
+ skills?: string;
261
+ thinking?: ThinkingLevel;
262
+ denyTools?: string;
263
+ spawning?: boolean;
264
+ autoExit?: boolean;
265
+ interactive?: boolean;
266
+ systemPromptMode?: "append" | "replace";
267
+ sessionMode?: SubagentSessionMode;
268
+ cwd?: string;
269
+ cli?: string;
270
+ cliModel?: string;
271
+ body?: string;
272
+ disableModelInvocation?: boolean;
273
+ }
274
+
275
+ type AgentSource = "package" | "global" | "project";
276
+
277
+ interface AgentDefinition extends AgentDefaults {
278
+ name: string;
279
+ description?: string;
280
+ disableModelInvocation: boolean;
281
+ }
282
+
283
+ interface ListedAgentDefinition extends AgentDefinition {
284
+ source: AgentSource;
285
+ path: string;
286
+ provider?: string;
287
+ providerVersion?: string;
288
+ }
289
+
290
+ interface AgentDiagnostic {
291
+ code: string;
292
+ message: string;
293
+ path?: string;
294
+ agentName?: string;
295
+ provider?: string;
296
+ }
297
+
298
+ interface AgentCatalog {
299
+ agents: ListedAgentDefinition[];
300
+ diagnostics: AgentDiagnostic[];
301
+ }
302
+
303
+ const ROLE_PACK_DISCOVERY_EVENT = "pi-herdr-subagents:roles:discover:v1";
304
+
305
+ /** Tools that are gated by `spawning: false` */
306
+ const SPAWNING_TOOLS = new Set([
307
+ "subagent",
308
+ "subagent_interrupt",
309
+ "subagents_list",
310
+ "subagent_resume",
311
+ ]);
312
+
313
+ /**
314
+ * Resolve the effective set of denied tool names from agent defaults.
315
+ * `spawning: false` expands to all SPAWNING_TOOLS.
316
+ * `deny-tools` adds individual tool names on top.
317
+ */
318
+ function resolveDenyTools(agentDefs: AgentDefaults | null): Set<string> {
319
+ const denied = new Set<string>();
320
+ if (!agentDefs) return denied;
321
+
322
+ // spawning: false → deny all spawning tools
323
+ if (agentDefs.spawning === false) {
324
+ for (const t of SPAWNING_TOOLS) denied.add(t);
325
+ }
326
+
327
+ // deny-tools: explicit list
328
+ if (agentDefs.denyTools) {
329
+ for (const t of agentDefs.denyTools
330
+ .split(",")
331
+ .map((s) => s.trim())
332
+ .filter(Boolean)) {
333
+ denied.add(t);
334
+ }
335
+ }
336
+
337
+ return denied;
338
+ }
339
+
340
+ /** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */
341
+ function getAgentConfigDir(): string {
342
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
343
+ }
344
+
345
+ function getBundledAgentsDir(): string {
346
+ return join(SUBAGENTS_DIR, "../../agents");
347
+ }
348
+
349
+ function getFrontmatterValue(
350
+ frontmatter: string,
351
+ key: string,
352
+ ): string | undefined {
353
+ const prefix = `${key}:`;
354
+ const line = frontmatter
355
+ .split("\n")
356
+ .find((candidate) => candidate.startsWith(prefix));
357
+ return line?.slice(prefix.length).trim() || undefined;
358
+ }
359
+
360
+ function parseOptionalBoolean(value: string | undefined): boolean | undefined {
361
+ return value != null ? value === "true" : undefined;
362
+ }
363
+
364
+ function parseSessionMode(
365
+ value: string | undefined,
366
+ ): SubagentSessionMode | undefined {
367
+ if (value === "standalone" || value === "lineage-only" || value === "fork") {
368
+ return value;
369
+ }
370
+ return undefined;
371
+ }
372
+
373
+ function parseAgentDefinition(
374
+ content: string,
375
+ fallbackName: string,
376
+ ): AgentDefinition | null {
377
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
378
+ if (!match) return null;
379
+
380
+ const frontmatter = match[1];
381
+ const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim();
382
+ const systemPromptMode = getFrontmatterValue(frontmatter, "system-prompt");
383
+ const thinking = getFrontmatterValue(frontmatter, "thinking");
384
+
385
+ return {
386
+ name: getFrontmatterValue(frontmatter, "name") ?? fallbackName,
387
+ description: getFrontmatterValue(frontmatter, "description"),
388
+ model: getFrontmatterValue(frontmatter, "model"),
389
+ tools: getFrontmatterValue(frontmatter, "tools"),
390
+ systemPromptMode:
391
+ systemPromptMode === "replace"
392
+ ? "replace"
393
+ : systemPromptMode === "append"
394
+ ? "append"
395
+ : undefined,
396
+ skills:
397
+ getFrontmatterValue(frontmatter, "skills") ??
398
+ getFrontmatterValue(frontmatter, "skill"),
399
+ thinking: thinking && isThinkingLevel(thinking) ? thinking : undefined,
400
+ denyTools: getFrontmatterValue(frontmatter, "deny-tools"),
401
+ spawning: parseOptionalBoolean(
402
+ getFrontmatterValue(frontmatter, "spawning"),
403
+ ),
404
+ autoExit: parseOptionalBoolean(
405
+ getFrontmatterValue(frontmatter, "auto-exit"),
406
+ ),
407
+ interactive: parseOptionalBoolean(
408
+ getFrontmatterValue(frontmatter, "interactive"),
409
+ ),
410
+ sessionMode: parseSessionMode(
411
+ getFrontmatterValue(frontmatter, "session-mode"),
412
+ ),
413
+ cwd: getFrontmatterValue(frontmatter, "cwd"),
414
+ cli: getFrontmatterValue(frontmatter, "cli"),
415
+ cliModel: getFrontmatterValue(frontmatter, "cli-model"),
416
+ body: body || undefined,
417
+ disableModelInvocation:
418
+ getFrontmatterValue(
419
+ frontmatter,
420
+ "disable-model-invocation",
421
+ )?.toLowerCase() === "true",
422
+ };
423
+ }
424
+
425
+ function listMarkdownFiles(path: string): string[] {
426
+ const stat = statSync(path);
427
+ if (stat.isFile()) return path.endsWith(".md") ? [path] : [];
428
+ if (!stat.isDirectory()) return [];
429
+ return readdirSync(path)
430
+ .filter((entry) => entry.endsWith(".md"))
431
+ .sort((left, right) => left.localeCompare(right))
432
+ .map((entry) => join(path, entry));
433
+ }
434
+
435
+ function findPackageMetadata(path: string): {
436
+ provider?: string;
437
+ providerVersion?: string;
438
+ } {
439
+ let current = statSync(path).isDirectory() ? path : dirname(path);
440
+ while (true) {
441
+ const packagePath = join(current, "package.json");
442
+ if (existsSync(packagePath)) {
443
+ try {
444
+ const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
445
+ return {
446
+ provider: typeof pkg.name === "string" ? pkg.name : undefined,
447
+ providerVersion:
448
+ typeof pkg.version === "string" ? pkg.version : undefined,
449
+ };
450
+ } catch {
451
+ return {};
452
+ }
453
+ }
454
+ const parent = dirname(current);
455
+ if (parent === current) return {};
456
+ current = parent;
457
+ }
458
+ }
459
+
460
+ function discoverRolePackPaths(pi?: Pick<ExtensionAPI, "events">): {
461
+ paths: string[];
462
+ diagnostics: AgentDiagnostic[];
463
+ } {
464
+ const paths = new Set<string>();
465
+ const diagnostics: AgentDiagnostic[] = [];
466
+ if (!pi?.events) return { paths: [], diagnostics };
467
+
468
+ try {
469
+ pi.events.emit(ROLE_PACK_DISCOVERY_EVENT, {
470
+ apiVersion: 1,
471
+ register(path: unknown) {
472
+ if (typeof path !== "string" || !isAbsolute(path)) {
473
+ diagnostics.push({
474
+ code: "invalid-role-pack-path",
475
+ message:
476
+ "Role packs must register an absolute file or directory path.",
477
+ });
478
+ return;
479
+ }
480
+ paths.add(resolve(path));
481
+ },
482
+ });
483
+ } catch (error) {
484
+ diagnostics.push({
485
+ code: "role-pack-discovery-failed",
486
+ message: `Role-pack discovery failed: ${error instanceof Error ? error.message : String(error)}`,
487
+ });
488
+ }
489
+
490
+ return { paths: [...paths], diagnostics };
491
+ }
492
+
493
+ function discoverAgentCatalog(pi?: Pick<ExtensionAPI, "events">): AgentCatalog {
494
+ const agents = new Map<string, ListedAgentDefinition>();
495
+ const diagnostics: AgentDiagnostic[] = [];
496
+
497
+ const addDirectory = (path: string, source: AgentSource) => {
498
+ if (!existsSync(path)) return;
499
+ for (const filePath of listMarkdownFiles(path)) {
500
+ const parsed = parseAgentDefinition(
501
+ readFileSync(filePath, "utf8"),
502
+ basename(filePath, ".md"),
503
+ );
504
+ if (parsed)
505
+ agents.set(parsed.name, { ...parsed, source, path: filePath });
506
+ }
507
+ };
508
+
509
+ addDirectory(getBundledAgentsDir(), "package");
510
+
511
+ const discovered = discoverRolePackPaths(pi);
512
+ diagnostics.push(...discovered.diagnostics);
513
+ const contributed = new Map<string, ListedAgentDefinition[]>();
514
+ for (const registeredPath of discovered.paths) {
515
+ if (!existsSync(registeredPath)) {
516
+ diagnostics.push({
517
+ code: "missing-role-pack-path",
518
+ message: `Registered role-pack path does not exist: ${registeredPath}`,
519
+ path: registeredPath,
520
+ });
521
+ continue;
522
+ }
523
+
524
+ let metadata: ReturnType<typeof findPackageMetadata>;
525
+ let roleFiles: string[];
526
+ try {
527
+ metadata = findPackageMetadata(registeredPath);
528
+ roleFiles = listMarkdownFiles(registeredPath);
529
+ } catch (error) {
530
+ diagnostics.push({
531
+ code: "unreadable-role-pack-path",
532
+ message: `Cannot read registered role-pack path ${registeredPath}: ${error instanceof Error ? error.message : String(error)}`,
533
+ path: registeredPath,
534
+ });
535
+ continue;
536
+ }
537
+ if (roleFiles.length === 0 && statSync(registeredPath).isFile()) {
538
+ diagnostics.push({
539
+ code: "invalid-role-pack-file",
540
+ message: `Registered role-pack file must use the .md extension: ${registeredPath}`,
541
+ path: registeredPath,
542
+ provider: metadata.provider,
543
+ });
544
+ continue;
545
+ }
546
+
547
+ for (const filePath of roleFiles) {
548
+ const fallbackName = basename(filePath, ".md");
549
+ let content: string;
550
+ try {
551
+ content = readFileSync(filePath, "utf8");
552
+ } catch (error) {
553
+ diagnostics.push({
554
+ code: "unreadable-role-definition",
555
+ message: `Cannot read role definition ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
556
+ path: filePath,
557
+ agentName: fallbackName,
558
+ provider: metadata.provider,
559
+ });
560
+ continue;
561
+ }
562
+ const parsed = parseAgentDefinition(content, fallbackName);
563
+ if (!parsed) {
564
+ diagnostics.push({
565
+ code: "invalid-role-definition",
566
+ message: `Role definition must start with frontmatter: ${filePath}`,
567
+ path: filePath,
568
+ agentName: fallbackName,
569
+ provider: metadata.provider,
570
+ });
571
+ continue;
572
+ }
573
+ if (parsed.name !== fallbackName) {
574
+ diagnostics.push({
575
+ code: "role-name-mismatch",
576
+ message: `Role name "${parsed.name}" must match filename "${fallbackName}" in ${filePath}`,
577
+ path: filePath,
578
+ agentName: fallbackName,
579
+ provider: metadata.provider,
580
+ });
581
+ continue;
582
+ }
583
+ if (!parsed.description) {
584
+ diagnostics.push({
585
+ code: "missing-role-description",
586
+ message: `Role "${parsed.name}" must declare a description in ${filePath}`,
587
+ path: filePath,
588
+ agentName: parsed.name,
589
+ provider: metadata.provider,
590
+ });
591
+ continue;
592
+ }
593
+ const definitions = contributed.get(parsed.name) ?? [];
594
+ definitions.push({
595
+ ...parsed,
596
+ source: "package",
597
+ path: filePath,
598
+ ...metadata,
599
+ });
600
+ contributed.set(parsed.name, definitions);
601
+ }
602
+ }
603
+
604
+ for (const [name, definitions] of contributed) {
605
+ if (agents.has(name)) {
606
+ diagnostics.push({
607
+ code: "bundled-role-collision",
608
+ message: `Role pack cannot replace bundled role "${name}"; use a global or project override instead.`,
609
+ agentName: name,
610
+ });
611
+ continue;
612
+ }
613
+ if (definitions.length > 1) {
614
+ const providers = definitions
615
+ .map((definition) => definition.provider ?? definition.path)
616
+ .sort((left, right) => left.localeCompare(right))
617
+ .join(", ");
618
+ diagnostics.push({
619
+ code: "duplicate-package-role",
620
+ message: `Role "${name}" is contributed by multiple role packs: ${providers}`,
621
+ agentName: name,
622
+ });
623
+ continue;
624
+ }
625
+ agents.set(name, definitions[0]);
626
+ }
627
+
628
+ addDirectory(join(getAgentConfigDir(), "agents"), "global");
629
+ addDirectory(join(process.cwd(), ".pi", "agents"), "project");
630
+
631
+ return { agents: [...agents.values()], diagnostics };
632
+ }
633
+
634
+ function discoverAgentDefinitions(
635
+ pi?: Pick<ExtensionAPI, "events">,
636
+ ): ListedAgentDefinition[] {
637
+ return discoverAgentCatalog(pi).agents;
638
+ }
639
+
640
+ function workflowRoles(catalog: AgentCatalog): WorkflowRole[] {
641
+ return catalog.agents.map((agent) => ({
642
+ name: agent.name,
643
+ source: agent.source,
644
+ path: agent.path,
645
+ body: agent.body,
646
+ model: agent.model,
647
+ thinking: agent.thinking,
648
+ tools: agent.tools,
649
+ skills: agent.skills,
650
+ denyTools: agent.denyTools,
651
+ spawning: agent.spawning,
652
+ autoExit: agent.autoExit,
653
+ interactive: agent.interactive,
654
+ sessionMode: agent.sessionMode,
655
+ cwd: agent.cwd,
656
+ disableModelInvocation: agent.disableModelInvocation,
657
+ cli: agent.cli,
658
+ }));
659
+ }
660
+
661
+ function formatAgentSource(agent: ListedAgentDefinition): string {
662
+ return agent.source === "package" && agent.provider
663
+ ? `package:${agent.provider}`
664
+ : agent.source;
665
+ }
666
+
667
+ function formatVisibleAgentDefinitions(
668
+ agents: ListedAgentDefinition[],
669
+ ): string[] {
670
+ return agents
671
+ .filter((agent) => !agent.disableModelInvocation)
672
+ .map((agent) => {
673
+ const badge = ` (${formatAgentSource(agent)})`;
674
+ const desc = agent.description ? ` — ${agent.description}` : "";
675
+ const model = agent.model ? ` [${agent.model}]` : "";
676
+ return `• ${agent.name}${badge}${model}${desc}`;
677
+ });
678
+ }
679
+
680
+ function formatAgentDiagnostics(diagnostics: AgentDiagnostic[]): string[] {
681
+ return diagnostics.map((diagnostic) => `! ${diagnostic.message}`);
682
+ }
683
+
684
+ function resolveSubagentPaths(
685
+ params: Static<typeof SubagentParams>,
686
+ agentDefs: AgentDefaults | null,
687
+ ): {
688
+ effectiveCwd: string | null;
689
+ localAgentDir: string | null;
690
+ effectiveAgentDir: string;
691
+ } {
692
+ const rawCwd = params.cwd ?? agentDefs?.cwd ?? null;
693
+ const cwdIsFromAgent = !params.cwd && agentDefs?.cwd != null;
694
+ const cwdBase = cwdIsFromAgent ? getAgentConfigDir() : process.cwd();
695
+ const effectiveCwd = rawCwd
696
+ ? rawCwd.startsWith("/")
697
+ ? rawCwd
698
+ : join(cwdBase, rawCwd)
699
+ : null;
700
+ const localAgentDir = effectiveCwd
701
+ ? join(effectiveCwd, ".pi", "agent")
702
+ : null;
703
+ const effectiveAgentDir =
704
+ localAgentDir && existsSync(localAgentDir)
705
+ ? localAgentDir
706
+ : getAgentConfigDir();
707
+ return { effectiveCwd, localAgentDir, effectiveAgentDir };
708
+ }
709
+
710
+ function getDefaultSessionDirFor(cwd: string, agentDir: string): string {
711
+ const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
712
+ const sessionDir = join(agentDir, "sessions", safePath);
713
+ if (!existsSync(sessionDir)) {
714
+ mkdirSync(sessionDir, { recursive: true });
715
+ }
716
+ return sessionDir;
717
+ }
718
+
719
+ function resolveEffectiveSessionMode(
720
+ params: Static<typeof SubagentParams>,
721
+ agentDefs: AgentDefaults | null,
722
+ ): SubagentSessionMode {
723
+ if (params.fork) return "fork";
724
+ return agentDefs?.sessionMode ?? "standalone";
725
+ }
726
+
727
+ function resolveLaunchBehavior(
728
+ params: Static<typeof SubagentParams>,
729
+ agentDefs: AgentDefaults | null,
730
+ ): {
731
+ sessionMode: SubagentSessionMode;
732
+ seededSessionMode: "lineage-only" | "fork" | null;
733
+ inheritsConversationContext: boolean;
734
+ taskDelivery: "direct" | "artifact";
735
+ } {
736
+ const sessionMode = resolveEffectiveSessionMode(params, agentDefs);
737
+ const inheritsConversationContext = sessionMode === "fork";
738
+ return {
739
+ sessionMode,
740
+ seededSessionMode: sessionMode === "standalone" ? null : sessionMode,
741
+ inheritsConversationContext,
742
+ taskDelivery: inheritsConversationContext ? "direct" : "artifact",
743
+ };
744
+ }
745
+
746
+ /**
747
+ * Decide whether a subagent is interactive (user-driven, long-running).
748
+ *
749
+ * Resolution order:
750
+ * 1. Explicit `interactive` tool parameter wins.
751
+ * 2. Explicit `interactive` frontmatter field on the agent.
752
+ * 3. Default: the inverse of `auto-exit`. Agents that auto-exit are
753
+ * autonomous (scout, worker, reviewer) and the parent session should be
754
+ * woken on stall/recovery transitions. Agents that don't auto-exit are
755
+ * driven by the user in their own pane (planner, iterate/fork) and
756
+ * stall pings are noise.
757
+ *
758
+ * When no agent defs exist at all (bare `subagent({ name, task })` call,
759
+ * typical for `/iterate` with `fork: true`), `autoExit` is undefined and the
760
+ * subagent is treated as interactive — matching the intent of iterate.
761
+ */
762
+ function resolveEffectiveAutoExit(
763
+ params: Static<typeof SubagentParams>,
764
+ agentDefs: AgentDefaults | null,
765
+ ): boolean {
766
+ // Named agents preserve their declared behavior. Bare tool calls are
767
+ // autonomous by default, including full-context forks: `fork` controls
768
+ // context inheritance, not whether the child should remain open. Interactive
769
+ // flows such as /iterate opt out explicitly with `interactive: true`.
770
+ if (agentDefs) return agentDefs.autoExit ?? false;
771
+ return params.interactive !== true;
772
+ }
773
+
774
+ function resolveEffectiveInteractive(
775
+ params: Static<typeof SubagentParams>,
776
+ agentDefs: AgentDefaults | null,
777
+ ): boolean {
778
+ if (params.interactive != null) return params.interactive;
779
+ if (agentDefs?.interactive != null) return agentDefs.interactive;
780
+ return !resolveEffectiveAutoExit(params, agentDefs);
781
+ }
782
+
783
+ function loadAgentDefaults(
784
+ agentName: string,
785
+ pi?: Pick<ExtensionAPI, "events">,
786
+ ): AgentDefaults | null {
787
+ return (
788
+ discoverAgentCatalog(pi).agents.find((agent) => agent.name === agentName) ??
789
+ null
790
+ );
791
+ }
792
+
793
+ function formatElapsed(seconds: number): string {
794
+ if (seconds < 60) return `${seconds}s`;
795
+ const m = Math.floor(seconds / 60);
796
+ const s = seconds % 60;
797
+ return `${m}m ${s}s`;
798
+ }
799
+
800
+ /**
801
+ * Wait long enough for a freshly created pane to finish shell startup.
802
+ *
803
+ * Some environments do extra shell-init work before the prompt is ready
804
+ * (for example direnv/devenv), so the delay is configurable for users who hit
805
+ * dropped commands. Keep the historical default at 500ms.
806
+ */
807
+ function captureWorkspaceBaseline(cwd: string): Set<string> | undefined {
808
+ try {
809
+ const output = execFileSync(
810
+ "git",
811
+ ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
812
+ { cwd, encoding: "utf8" },
813
+ );
814
+ return new Set(
815
+ output
816
+ .split("\0")
817
+ .filter(Boolean)
818
+ .map((entry) => entry.slice(3)),
819
+ );
820
+ } catch {
821
+ return undefined;
822
+ }
823
+ }
824
+
825
+ function guardClaudeWorkspace(running: RunningSubagent): string | undefined {
826
+ if (!running.workspaceBaseline || !running.workspaceCwd) return;
827
+
828
+ try {
829
+ const output = execFileSync(
830
+ "git",
831
+ ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
832
+ { cwd: running.workspaceCwd, encoding: "utf8" },
833
+ );
834
+ const changed = output
835
+ .split("\0")
836
+ .filter(Boolean)
837
+ .map((entry) => ({
838
+ status: entry.slice(0, 2),
839
+ path: entry.slice(3),
840
+ }));
841
+ const newPaths = changed.filter(
842
+ ({ path }) => !running.workspaceBaseline!.has(path),
843
+ );
844
+ const cleaned: string[] = [];
845
+
846
+ for (const { status, path } of newPaths) {
847
+ if (path.startsWith(".reviews/")) continue;
848
+ if (status === "??") {
849
+ rmSync(`${running.workspaceCwd}/${path}`, {
850
+ recursive: true,
851
+ force: true,
852
+ });
853
+ } else {
854
+ execFileSync("git", ["restore", "--staged", "--worktree", "--", path], {
855
+ cwd: running.workspaceCwd,
856
+ stdio: "ignore",
857
+ });
858
+ }
859
+ cleaned.push(path);
860
+ }
861
+
862
+ return cleaned.length > 0
863
+ ? `Claude workspace guard reverted newly introduced paths: ${cleaned.join(", ")}`
864
+ : undefined;
865
+ } catch (error: any) {
866
+ return `Claude workspace guard failed: ${error?.message ?? String(error)}`;
867
+ }
868
+ }
869
+
870
+ function getShellReadyDelayMs(): number {
871
+ const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
872
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
873
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 500;
874
+ }
875
+
876
+ function muxUnavailableResult() {
877
+ return {
878
+ content: [
879
+ {
880
+ type: "text" as const,
881
+ text: `Subagents require herdr. ${terminalSetupHint()}`,
882
+ },
883
+ ],
884
+ details: { error: "herdr not available" },
885
+ };
886
+ }
887
+
888
+ /**
889
+ * Build the internal artifact directory path for the current session.
890
+ * Used by the subagents extension to stash task files, system prompts, and
891
+ * launch scripts for sub-agents. Path convention:
892
+ * <sessionDir>/artifacts/<session-id>/
893
+ */
894
+ function getArtifactDir(sessionDir: string, sessionId: string): string {
895
+ return join(sessionDir, "artifacts", sessionId);
896
+ }
897
+
898
+ function resolveGitCommit(cwd: string, ref: string): string {
899
+ return execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
900
+ cwd,
901
+ encoding: "utf8",
902
+ }).trim();
903
+ }
904
+
905
+ function writeWorktreeManifest(
906
+ path: string,
907
+ value: Record<string, unknown>,
908
+ ): void {
909
+ mkdirSync(dirname(path), { recursive: true });
910
+ let existing: Record<string, unknown> = {};
911
+ if (existsSync(path)) {
912
+ try {
913
+ existing = JSON.parse(readFileSync(path, "utf8"));
914
+ } catch {
915
+ // Replace malformed legacy state with a fresh manifest below.
916
+ existing = {};
917
+ }
918
+ }
919
+ const tempPath = `${path}.tmp`;
920
+ writeFileSync(
921
+ tempPath,
922
+ `${JSON.stringify(
923
+ {
924
+ ...existing,
925
+ ...value,
926
+ version: 1,
927
+ kind: "worktree-run",
928
+ owner: "pi-herdr-subagents",
929
+ updatedAt: Date.now(),
930
+ },
931
+ null,
932
+ 2,
933
+ )}\n`,
934
+ );
935
+ renameSync(tempPath, path);
936
+ }
937
+
938
+ function gitPathList(cwd: string, args: string[]): string[] {
939
+ return execFileSync("git", args, { cwd, encoding: "utf8" })
940
+ .split("\0")
941
+ .filter(Boolean);
942
+ }
943
+
944
+ function captureWorktreeHandoff(worktree: WorktreeLaunch): WorktreeHandoff {
945
+ try {
946
+ const headSha = resolveGitCommit(worktree.path, "HEAD");
947
+ const status = execFileSync(
948
+ "git",
949
+ ["status", "--porcelain=v1", "--untracked-files=all", "-z"],
950
+ { cwd: worktree.path, encoding: "utf8" },
951
+ );
952
+ const untrackedFiles = gitPathList(worktree.path, [
953
+ "ls-files",
954
+ "--others",
955
+ "--exclude-standard",
956
+ "-z",
957
+ ]);
958
+ const conflictedFiles = gitPathList(worktree.path, [
959
+ "diff",
960
+ "--name-only",
961
+ "--diff-filter=U",
962
+ "-z",
963
+ ]);
964
+ const changedFiles = new Set([
965
+ ...gitPathList(worktree.path, [
966
+ "diff",
967
+ "--name-only",
968
+ "-z",
969
+ `${worktree.baseSha}...HEAD`,
970
+ ]),
971
+ ...gitPathList(worktree.path, ["diff", "--name-only", "-z"]),
972
+ ...gitPathList(worktree.path, ["diff", "--cached", "--name-only", "-z"]),
973
+ ...untrackedFiles,
974
+ ]);
975
+ const commitsAhead = Number.parseInt(
976
+ execFileSync(
977
+ "git",
978
+ ["rev-list", "--count", `${worktree.baseSha}..HEAD`],
979
+ {
980
+ cwd: worktree.path,
981
+ encoding: "utf8",
982
+ },
983
+ ).trim(),
984
+ 10,
985
+ );
986
+ return {
987
+ ...worktree,
988
+ headSha,
989
+ commitsAhead: Number.isFinite(commitsAhead) ? commitsAhead : 0,
990
+ clean: status.length === 0,
991
+ conflicted: conflictedFiles.length > 0,
992
+ changedFiles: [...changedFiles].sort(),
993
+ untrackedFiles: untrackedFiles.sort(),
994
+ };
995
+ } catch (error: any) {
996
+ return {
997
+ ...worktree,
998
+ headSha: null,
999
+ commitsAhead: null,
1000
+ clean: null,
1001
+ conflicted: null,
1002
+ changedFiles: null,
1003
+ untrackedFiles: null,
1004
+ gitError: error?.message ?? String(error),
1005
+ };
1006
+ }
1007
+ }
1008
+
1009
+ function persistWorktreeResult(
1010
+ worktree: WorktreeLaunch,
1011
+ state: "running" | "ready_for_review" | "failed" | "needs_help",
1012
+ handoff?: WorktreeHandoff,
1013
+ ): void {
1014
+ writeWorktreeManifest(worktree.manifestFile, {
1015
+ state,
1016
+ ...worktree,
1017
+ ...handoff,
1018
+ });
1019
+ }
1020
+
1021
+ function shouldRetainSubagentSurface(
1022
+ running: Pick<RunningSubagent, "worktree"> | { worktree?: unknown },
1023
+ ): boolean {
1024
+ return !!running.worktree;
1025
+ }
1026
+
1027
+ function runSubagentScript(
1028
+ surface: string,
1029
+ command: string,
1030
+ options: Parameters<typeof runScriptInPane>[2],
1031
+ worktree?: WorktreeLaunch,
1032
+ run: typeof runScriptInPane = runScriptInPane,
1033
+ ): string {
1034
+ if (worktree) persistWorktreeResult(worktree, "running");
1035
+ try {
1036
+ return run(surface, command, options);
1037
+ } catch (error: any) {
1038
+ if (!worktree) throw error;
1039
+ const handoff = captureWorktreeHandoff(worktree);
1040
+ try {
1041
+ persistWorktreeResult(worktree, "failed", handoff);
1042
+ } catch {
1043
+ // The launch error remains authoritative when manifest persistence fails.
1044
+ }
1045
+ throw new Error(
1046
+ `Failed to launch subagent; worktree retained at ${worktree.path} ` +
1047
+ `(workspace ${worktree.workspaceId}): ${error?.message ?? String(error)}`,
1048
+ );
1049
+ }
1050
+ }
1051
+
1052
+ function finalizeSubagentSurface(
1053
+ running: RunningSubagent,
1054
+ state: "ready_for_review" | "failed" | "needs_help",
1055
+ ignoreCloseError = false,
1056
+ ): WorktreeHandoff | undefined {
1057
+ if (running.worktree) {
1058
+ let handoff = captureWorktreeHandoff(running.worktree);
1059
+ try {
1060
+ persistWorktreeResult(running.worktree, state, handoff);
1061
+ } catch (error: any) {
1062
+ handoff = {
1063
+ ...handoff,
1064
+ gitError: [
1065
+ handoff.gitError,
1066
+ `Manifest update failed: ${error?.message ?? String(error)}`,
1067
+ ]
1068
+ .filter(Boolean)
1069
+ .join("; "),
1070
+ };
1071
+ }
1072
+ return handoff;
1073
+ }
1074
+
1075
+ try {
1076
+ closePane(running.surface);
1077
+ } catch (error) {
1078
+ if (!ignoreCloseError) throw error;
1079
+ }
1080
+ return undefined;
1081
+ }
1082
+
1083
+ const statusConfig = loadStatusConfig();
1084
+ const modelConfig = loadModelConfig();
1085
+
1086
+ const MAX_RESULT_PRESENTATION_CHARS = 16_000;
1087
+ const MAX_SESSION_REFERENCE_CHARS = 10_000;
1088
+ const RESULT_CONTINUATION_PROMPT =
1089
+ "Parent action: Continue the parent task using this result; do not return an empty response.";
1090
+ const RESULT_UI_CONTEXT =
1091
+ "Subagent completion follows in the next user message.";
1092
+
1093
+ function abbreviateMiddle(
1094
+ value: string,
1095
+ maxChars: number,
1096
+ marker: string,
1097
+ ): string {
1098
+ if (value.length <= maxChars) return value;
1099
+
1100
+ const retainedChars = maxChars - marker.length;
1101
+ const headChars = Math.ceil(retainedChars / 2);
1102
+ const tailChars = Math.floor(retainedChars / 2);
1103
+ return (
1104
+ value.slice(0, headChars) +
1105
+ marker +
1106
+ (tailChars ? value.slice(-tailChars) : "")
1107
+ );
1108
+ }
1109
+
1110
+ function boundResultPresentation(body: string, sessionRef: string): string {
1111
+ const boundedSessionRef = abbreviateMiddle(
1112
+ sessionRef,
1113
+ MAX_SESSION_REFERENCE_CHARS,
1114
+ "\n[... session reference abbreviated ...]\n",
1115
+ );
1116
+ if (body.length + boundedSessionRef.length <= MAX_RESULT_PRESENTATION_CHARS) {
1117
+ return body + boundedSessionRef;
1118
+ }
1119
+
1120
+ const marker = boundedSessionRef
1121
+ ? "\n\n[... result abbreviated; full output remains in the child session below ...]\n\n"
1122
+ : "\n\n[... result abbreviated ...]\n\n";
1123
+ const retainedChars =
1124
+ MAX_RESULT_PRESENTATION_CHARS - marker.length - boundedSessionRef.length;
1125
+ return (
1126
+ abbreviateMiddle(body, retainedChars + marker.length, marker) +
1127
+ boundedSessionRef
1128
+ );
1129
+ }
1130
+
1131
+ function formatSessionReference(sessionFile?: string): string {
1132
+ return sessionFile
1133
+ ? `\n\nSession: ${sessionFile}\nResume: pi --session ${sessionFile}`
1134
+ : "";
1135
+ }
1136
+
1137
+ function resolveUnexpectedErrorPresentation(
1138
+ prefix: string,
1139
+ error: unknown,
1140
+ sessionFile?: string,
1141
+ ): string {
1142
+ const message = error instanceof Error ? error.message : String(error);
1143
+ return boundResultPresentation(
1144
+ `${prefix}: ${message}`,
1145
+ formatSessionReference(sessionFile),
1146
+ );
1147
+ }
1148
+
1149
+ function sendSubagentResult(
1150
+ api: Pick<ExtensionAPI, "sendMessage" | "sendUserMessage">,
1151
+ content: string,
1152
+ details: Record<string, unknown>,
1153
+ ): void {
1154
+ const resultContent = boundResultPresentation(content, "");
1155
+ const promptContent = boundResultPresentation(
1156
+ `${resultContent}\n\n${RESULT_CONTINUATION_PROMPT}`,
1157
+ "",
1158
+ );
1159
+ api.sendMessage(
1160
+ {
1161
+ customType: "subagent_result",
1162
+ content: RESULT_UI_CONTEXT,
1163
+ display: true,
1164
+ details: { ...details, resultContent },
1165
+ },
1166
+ { triggerTurn: false, deliverAs: "steer" },
1167
+ );
1168
+ api.sendUserMessage(promptContent, { deliverAs: "steer" });
1169
+ }
1170
+
1171
+ function formatWorktreeHandoff(worktree: WorktreeHandoff): string {
1172
+ const state = worktree.gitError
1173
+ ? "inspection unknown"
1174
+ : worktree.conflicted
1175
+ ? "conflicted"
1176
+ : worktree.clean
1177
+ ? "clean"
1178
+ : "dirty";
1179
+ const ahead =
1180
+ worktree.commitsAhead == null
1181
+ ? "commits ahead unknown"
1182
+ : `${worktree.commitsAhead} commit${worktree.commitsAhead === 1 ? "" : "s"} ahead`;
1183
+ const lines = [
1184
+ "Worktree result retained for review:",
1185
+ `Worktree: ${worktree.path}`,
1186
+ `Workspace: ${worktree.workspaceId}`,
1187
+ `Branch: ${worktree.branch}`,
1188
+ `Base/head: ${worktree.baseSha} -> ${worktree.headSha ?? "unknown"}`,
1189
+ `State: ${state} · ${ahead}`,
1190
+ ];
1191
+ if (worktree.changedFiles?.length)
1192
+ lines.push(`Changed: ${worktree.changedFiles.join(", ")}`);
1193
+ if (worktree.untrackedFiles?.length)
1194
+ lines.push(`Untracked: ${worktree.untrackedFiles.join(", ")}`);
1195
+ if (worktree.gitError)
1196
+ lines.push(`Git inspection warning: ${worktree.gitError}`);
1197
+ return lines.join("\n");
1198
+ }
1199
+
1200
+ function resolveResultPresentation(
1201
+ result: Pick<
1202
+ SubagentResult,
1203
+ | "exitCode"
1204
+ | "elapsed"
1205
+ | "summary"
1206
+ | "sessionFile"
1207
+ | "errorMessage"
1208
+ | "worktree"
1209
+ >,
1210
+ name: string,
1211
+ runtimeMismatch?: string,
1212
+ ): string {
1213
+ const sessionRef = formatSessionReference(result.sessionFile);
1214
+ let body: string;
1215
+
1216
+ if (result.errorMessage) {
1217
+ // Auto-retry exhausted or other agent-loop error. The subagent did not
1218
+ // produce a usable result — surface the underlying provider/network
1219
+ // failure so the orchestrator can decide whether to retry, resume, or
1220
+ // change approach instead of silently treating the run as completed.
1221
+ body =
1222
+ `Sub-agent "${name}" failed after ${formatElapsed(result.elapsed)} ` +
1223
+ `(provider/agent error — auto-retry exhausted).\n\n` +
1224
+ `Error: ${result.errorMessage}\n\n` +
1225
+ `The subagent did not produce a result. You can retry by spawning a new ` +
1226
+ `subagent or resume the session with subagent_resume.`;
1227
+ } else {
1228
+ body =
1229
+ result.exitCode !== 0
1230
+ ? `Sub-agent "${name}" failed (exit code ${result.exitCode}).\n\n${result.summary}`
1231
+ : `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}`;
1232
+ }
1233
+
1234
+ if (result.worktree) body += `\n\n${formatWorktreeHandoff(result.worktree)}`;
1235
+ const runtimeWarning = runtimeMismatch
1236
+ ? `\n\nRuntime warning: ${runtimeMismatch}`
1237
+ : "";
1238
+ return boundResultPresentation(body, sessionRef + runtimeWarning);
1239
+ }
1240
+
1241
+ interface WorktreeLaunch {
1242
+ path: string;
1243
+ workspaceId: string;
1244
+ paneId: string;
1245
+ branch: string;
1246
+ baseRef: string;
1247
+ baseSha: string;
1248
+ manifestFile: string;
1249
+ sessionFile?: string;
1250
+ }
1251
+
1252
+ interface WorktreeHandoff extends WorktreeLaunch {
1253
+ headSha: string | null;
1254
+ commitsAhead: number | null;
1255
+ clean: boolean | null;
1256
+ conflicted: boolean | null;
1257
+ changedFiles: string[] | null;
1258
+ untrackedFiles: string[] | null;
1259
+ gitError?: string;
1260
+ }
1261
+
1262
+ /**
1263
+ * Result from running a single subagent.
1264
+ */
1265
+ interface SubagentResult {
1266
+ name: string;
1267
+ task: string;
1268
+ summary: string;
1269
+ sessionFile?: string;
1270
+ claudeSessionId?: string;
1271
+ exitCode: number;
1272
+ elapsed: number;
1273
+ error?: string;
1274
+ /** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
1275
+ errorMessage?: string;
1276
+ ping?: { name: string; message: string };
1277
+ worktree?: WorktreeHandoff;
1278
+ }
1279
+
1280
+ /**
1281
+ * State for a launched (but not yet completed) subagent.
1282
+ */
1283
+ interface RunningSubagent {
1284
+ id: string;
1285
+ name: string;
1286
+ task: string;
1287
+ agent?: string;
1288
+ surface: string;
1289
+ startTime: number;
1290
+ sessionFile: string;
1291
+ launchScriptFile?: string;
1292
+ activityFile?: string;
1293
+ activity?: SubagentActivityState;
1294
+ activityRead?: {
1295
+ ok: boolean;
1296
+ reason?: "missing" | "invalid" | "wrong-id";
1297
+ error?: string;
1298
+ };
1299
+ abortController?: AbortController;
1300
+ cli?: string;
1301
+ sentinelFile?: string;
1302
+ /**
1303
+ * Optional legacy status snapshot retained only for hydrating pre-lifecycle
1304
+ * runtime entries after /reload. Live observation uses `lifecycle` only.
1305
+ */
1306
+ statusState?: SubagentStatusState;
1307
+ lifecycle: SubagentLifecycle;
1308
+ /** Last projected kind used to detect stalled/recovered transitions. */
1309
+ lastProjectedKind?: LifecycleProjection["kind"];
1310
+ /**
1311
+ * When true, status transitions (stalled/recovered) do not wake the parent
1312
+ * session via a steer message. The widget still updates locally. Used for
1313
+ * long-running agents where the user drives the conversation in the
1314
+ * subagent's pane (e.g. planner).
1315
+ */
1316
+ interactive: boolean;
1317
+ /** Parent-resolved model/thinking selection and provenance. */
1318
+ runtimePlan: ResolvedRuntimePlan | undefined;
1319
+ /** Baseline used to clean up newly introduced Claude workspace changes. */
1320
+ workspaceBaseline?: Set<string>;
1321
+ workspaceCwd?: string;
1322
+ worktree?: WorktreeLaunch;
1323
+ }
1324
+
1325
+ interface WorkflowChildHandle {
1326
+ controller: AbortController;
1327
+ surface?: string;
1328
+ }
1329
+
1330
+ interface WorkflowOwner {
1331
+ runId: string;
1332
+ candidate: PendingWorkflow;
1333
+ children: Map<string, WorkflowChildHandle>;
1334
+ controller: AbortController;
1335
+ worker?: { terminate(): Promise<number> };
1336
+ gate: WorkflowTerminalGate;
1337
+ checkout?: string;
1338
+ journal?: ReturnType<typeof createWorkflowJournal>;
1339
+ cancelPromise?: Promise<WorkflowTerminalOutcome>;
1340
+ }
1341
+
1342
+ interface WorkflowCancelHooks {
1343
+ getProcessInfo?: typeof getPaneProcessInfo;
1344
+ closeSurface?: typeof closePane;
1345
+ waitAbsence?: typeof waitForPaneAbsence;
1346
+ waitExit?: typeof waitForProcessesExit;
1347
+ }
1348
+
1349
+ interface SubagentRuntime {
1350
+ runningSubagents: Map<string, RunningSubagent>;
1351
+ pendingWorkflow?: PendingWorkflow;
1352
+ activeWorkflow?: WorkflowOwner;
1353
+ workflowOutcomes: Map<string, WorkflowTerminalOutcome>;
1354
+ workflowStartupScanned: boolean;
1355
+ workflowCancelHooks?: WorkflowCancelHooks;
1356
+ pi?: ExtensionAPI;
1357
+ latestCtx?: ExtensionContext;
1358
+ modelCatalog?: string;
1359
+ }
1360
+
1361
+ function createSubagentRuntime(): SubagentRuntime {
1362
+ return {
1363
+ runningSubagents: new Map<string, RunningSubagent>(),
1364
+ workflowOutcomes: new Map<string, WorkflowTerminalOutcome>(),
1365
+ workflowStartupScanned: false,
1366
+ };
1367
+ }
1368
+
1369
+ /** Runtime state preserved across /reload. */
1370
+ const runtime: SubagentRuntime =
1371
+ (globalThis as any)[RUNTIME_KEY] ??
1372
+ ((globalThis as any)[RUNTIME_KEY] = createSubagentRuntime());
1373
+ if (!runtime.workflowOutcomes) {
1374
+ runtime.workflowOutcomes = new Map<string, WorkflowTerminalOutcome>();
1375
+ }
1376
+ if (runtime.workflowStartupScanned === undefined) {
1377
+ runtime.workflowStartupScanned = false;
1378
+ }
1379
+ const runningSubagents = runtime.runningSubagents;
1380
+
1381
+ export function shouldPreserveSubagentsOnShutdown(reason: unknown): boolean {
1382
+ return (
1383
+ reason === "reload" ||
1384
+ reason === "new" ||
1385
+ reason === "resume" ||
1386
+ reason === "fork"
1387
+ );
1388
+ }
1389
+
1390
+ export function cleanupSubagentsForShutdown(
1391
+ reason: unknown,
1392
+ agents: Map<string, Pick<RunningSubagent, "abortController" | "lifecycle">>,
1393
+ ): void {
1394
+ if (shouldPreserveSubagentsOnShutdown(reason)) return;
1395
+
1396
+ for (const agent of agents.values()) {
1397
+ if (agent.lifecycle) {
1398
+ agent.lifecycle = markDelivery(agent.lifecycle, "suppressed");
1399
+ }
1400
+ agent.abortController?.abort();
1401
+ }
1402
+ agents.clear();
1403
+ }
1404
+
1405
+ export function shouldDeliverSubagentCompletion(
1406
+ running: Pick<RunningSubagent, "lifecycle">,
1407
+ ): boolean {
1408
+ // Authoritative gate: only pending deliveries may be sent.
1409
+ // Missing lifecycle (pre-migration fixtures) defaults to pending/true.
1410
+ return (running.lifecycle?.delivery ?? "pending") === "pending";
1411
+ }
1412
+
1413
+ export function selectCompletionApi<T>(previous: T, current: T | undefined): T {
1414
+ return current ?? previous;
1415
+ }
1416
+
1417
+ // ── Widget management ──
1418
+
1419
+ /** Interval timer for widget re-renders. */
1420
+ let widgetInterval: ReturnType<typeof setInterval> | null = null;
1421
+
1422
+ /** Interval timer for status transition checks. */
1423
+ let statusInterval: ReturnType<typeof setInterval> | null = null;
1424
+
1425
+ function formatElapsedMMSS(startTime: number, endTime = Date.now()): string {
1426
+ const seconds = Math.floor((endTime - startTime) / 1000);
1427
+ const m = Math.floor(seconds / 60);
1428
+ const s = seconds % 60;
1429
+ return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
1430
+ }
1431
+
1432
+ const ACTIVE_ACCENT = "\x1b[38;2;77;163;255m";
1433
+ const OPEN_ACCENT = "\x1b[38;2;214;158;46m";
1434
+ const RST = "\x1b[0m";
1435
+
1436
+ /**
1437
+ * Build a bordered content line: │left right│
1438
+ * Left content is truncated if needed, right is preserved, padded to fill width.
1439
+ */
1440
+ function borderLine(
1441
+ left: string,
1442
+ right: string,
1443
+ width: number,
1444
+ accent = ACTIVE_ACCENT,
1445
+ ): string {
1446
+ if (width <= 0) return "";
1447
+ if (width === 1) return `${accent}│${RST}`;
1448
+
1449
+ // width = total visible chars for the whole line including │ and │
1450
+ const contentWidth = Math.max(0, width - 2); // space inside the two │ chars
1451
+ const rightVis = visibleWidth(right);
1452
+
1453
+ // If the status chunk alone is too wide, prefer preserving it in compact form
1454
+ // rather than overflowing the terminal.
1455
+ if (rightVis >= contentWidth) {
1456
+ const truncRight = truncateToWidth(right, contentWidth);
1457
+ const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight));
1458
+ return `${accent}│${RST}${truncRight}${" ".repeat(rightPad)}${accent}│${RST}`;
1459
+ }
1460
+
1461
+ const maxLeft = Math.max(0, contentWidth - rightVis);
1462
+ const truncLeft = truncateToWidth(left, maxLeft);
1463
+ const leftVis = visibleWidth(truncLeft);
1464
+ const pad = Math.max(0, contentWidth - leftVis - rightVis);
1465
+ return `${accent}│${RST}${truncLeft}${" ".repeat(pad)}${right}${accent}│${RST}`;
1466
+ }
1467
+
1468
+ /**
1469
+ * Build the bordered top line: ╭─ Title ──── info ─╮
1470
+ * All chars are accounted for within `width`.
1471
+ */
1472
+ function borderTop(
1473
+ title: string,
1474
+ info: string,
1475
+ width: number,
1476
+ accent = ACTIVE_ACCENT,
1477
+ ): string {
1478
+ if (width <= 0) return "";
1479
+ if (width === 1) return `${accent}╭${RST}`;
1480
+
1481
+ // ╭─ Title ───...─── info ─╮
1482
+ // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify
1483
+ const inner = Math.max(0, width - 2); // inside ╭ and ╮
1484
+ const titlePart = `─ ${title} `;
1485
+ const infoPart = ` ${info} ─`;
1486
+ const fillLen = Math.max(0, inner - titlePart.length - infoPart.length);
1487
+ const fill = "─".repeat(fillLen);
1488
+ const content = `${titlePart}${fill}${infoPart}`
1489
+ .slice(0, inner)
1490
+ .padEnd(inner, "─");
1491
+ return `${accent}╭${content}╮${RST}`;
1492
+ }
1493
+
1494
+ /**
1495
+ * Build the bordered bottom line: ╰──────────────────╯
1496
+ */
1497
+ function borderBottom(width: number, accent = ACTIVE_ACCENT): string {
1498
+ if (width <= 0) return "";
1499
+ if (width === 1) return `${accent}╰${RST}`;
1500
+
1501
+ const inner = Math.max(0, width - 2);
1502
+ return `${accent}╰${"─".repeat(inner)}╯${RST}`;
1503
+ }
1504
+
1505
+ function formatLifecycleWidgetLabel(
1506
+ projection: ReturnType<typeof projectLifecycle>,
1507
+ now: number,
1508
+ ): string {
1509
+ const duration =
1510
+ projection.stateDurationSince == null
1511
+ ? ""
1512
+ : ` ${formatElapsedDuration(now - projection.stateDurationSince)}`;
1513
+ if (projection.kind === "active")
1514
+ return projection.label
1515
+ ? ` active · ${projection.label}${duration} `
1516
+ : ` active${duration} `;
1517
+ if (projection.kind === "blocked") return ` blocked${duration} `;
1518
+ if (projection.kind === "running") return " running… ";
1519
+ if (projection.kind === "waiting") return ` waiting${duration} `;
1520
+ if (projection.kind === "interrupted") return ` interrupted${duration} `;
1521
+ if (projection.kind === "stalled") return ` stalled${duration} `;
1522
+ // completed/failed exist as lifecycle projections for delivery bookkeeping,
1523
+ // but the row is removed immediately after result delivery — so the only
1524
+ // visible terminal handoff label is finalizing.
1525
+ if (
1526
+ projection.kind === "finalizing" ||
1527
+ projection.kind === "completed" ||
1528
+ projection.kind === "failed"
1529
+ ) {
1530
+ return " finalizing… ";
1531
+ }
1532
+ return " starting… ";
1533
+ }
1534
+
1535
+ function renderSubagentWidgetLines(
1536
+ agents: RunningSubagent[],
1537
+ width: number,
1538
+ ): string[] {
1539
+ const now = Date.now();
1540
+ const rendered = agents.map((agent) => ({
1541
+ agent,
1542
+ projection: projectLifecycle(ensureLifecycle(agent), now),
1543
+ }));
1544
+ const activeCount = rendered.filter(
1545
+ ({ projection }) =>
1546
+ projection.kind === "active" ||
1547
+ projection.kind === "starting" ||
1548
+ projection.kind === "running" ||
1549
+ projection.kind === "blocked",
1550
+ ).length;
1551
+ const openCount = agents.length - activeCount;
1552
+ const info =
1553
+ activeCount > 0
1554
+ ? openCount > 0
1555
+ ? `${activeCount} active · ${openCount} open`
1556
+ : `${activeCount} active`
1557
+ : `${openCount} open`;
1558
+ const accent = activeCount > 0 ? ACTIVE_ACCENT : OPEN_ACCENT;
1559
+
1560
+ const lines: string[] = [borderTop("Subagents", info, width, accent)];
1561
+
1562
+ for (const { agent, projection } of rendered) {
1563
+ const elapsed = formatElapsedMMSS(
1564
+ agent.startTime,
1565
+ projection.runtimeEndedAt ?? now,
1566
+ );
1567
+ const agentTag = agent.agent ? ` (${agent.agent})` : "";
1568
+ const left = ` ${elapsed} ${agent.name}${agentTag} `;
1569
+ const runtimeTag = agent.runtimePlan
1570
+ ? `${agent.runtimePlan.modelId}|${agent.runtimePlan.thinking} · `
1571
+ : "";
1572
+ const right = statusConfig.enabled
1573
+ ? ` ${runtimeTag}${formatLifecycleWidgetLabel(projection, now).trim()} `
1574
+ : agent.cli === "claude"
1575
+ ? ` ${runtimeTag}running… `
1576
+ : ` ${runtimeTag}starting… `;
1577
+
1578
+ lines.push(borderLine(left, right, width, accent));
1579
+ }
1580
+
1581
+ lines.push(borderBottom(width, accent));
1582
+ return lines;
1583
+ }
1584
+
1585
+ function updateWidget() {
1586
+ const latestCtx = runtime.latestCtx;
1587
+ if (!latestCtx?.hasUI) return;
1588
+
1589
+ if (runningSubagents.size === 0) {
1590
+ latestCtx.ui.setWidget("subagent-status", undefined);
1591
+ if (widgetInterval) {
1592
+ clearInterval(widgetInterval);
1593
+ widgetInterval = null;
1594
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
1595
+ }
1596
+ return;
1597
+ }
1598
+
1599
+ latestCtx.ui.setWidget(
1600
+ "subagent-status",
1601
+ (_tui: any, _theme: any) => {
1602
+ return {
1603
+ invalidate() {},
1604
+ render(width: number) {
1605
+ return renderSubagentWidgetLines(
1606
+ Array.from(runningSubagents.values()),
1607
+ width,
1608
+ );
1609
+ },
1610
+ };
1611
+ },
1612
+ { placement: "aboveEditor" },
1613
+ );
1614
+ }
1615
+
1616
+ /**
1617
+ * Build the positional prompt args for a Pi CLI subagent launch.
1618
+ *
1619
+ * In artifact-backed launches (lineage-only, standalone), Pi's buildInitialMessage()
1620
+ * concatenates @file content with messages[0] into one initial prompt. That breaks
1621
+ * /skill: expansion because the message no longer starts with "/skill:". Only
1622
+ * messages[1..] are sent as separate follow-up prompts where /skill: is recognized.
1623
+ *
1624
+ * When there are skill prompts AND artifact-backed delivery, we prepend an empty
1625
+ * first positional message so that /skill: args land in messages[1..] and arrive
1626
+ * as standalone prompts in the child session.
1627
+ */
1628
+ const SUBAGENT_CONTROL_TOOLS = ["caller_ping", "subagent_done"] as const;
1629
+
1630
+ /**
1631
+ * Build the child --tools allowlist.
1632
+ *
1633
+ * Pi 0.70+ applies --tools to built-in, extension, and custom tools. If a
1634
+ * subagent definition restricts tools to e.g. "read,bash,write", the child
1635
+ * control tools from subagent-done.ts would otherwise be hidden, leaving a
1636
+ * manually resumed or user-touched subagent unable to call subagent_done.
1637
+ */
1638
+ function buildSubagentToolAllowlist(effectiveTools?: string): string | null {
1639
+ const requested = (effectiveTools ?? "")
1640
+ .split(",")
1641
+ .map((tool) => tool.trim())
1642
+ .filter(Boolean);
1643
+
1644
+ if (requested.length === 0) return null;
1645
+
1646
+ const allow = new Set(requested);
1647
+ for (const tool of SUBAGENT_CONTROL_TOOLS) {
1648
+ allow.add(tool);
1649
+ }
1650
+
1651
+ return [...allow].join(",");
1652
+ }
1653
+
1654
+ function buildPiPromptArgs(params: {
1655
+ effectiveSkills?: string;
1656
+ taskDelivery: "direct" | "artifact";
1657
+ taskArg: string;
1658
+ }): string[] {
1659
+ const skillPrompts = (params.effectiveSkills ?? "")
1660
+ .split(",")
1661
+ .map((s) => s.trim())
1662
+ .filter(Boolean)
1663
+ .map((skill) => `/skill:${skill}`);
1664
+
1665
+ const needsSeparator =
1666
+ params.taskDelivery === "artifact" && skillPrompts.length > 0;
1667
+
1668
+ return [...(needsSeparator ? [""] : []), ...skillPrompts, params.taskArg];
1669
+ }
1670
+
1671
+ function ensureLifecycle(running: RunningSubagent): SubagentLifecycle {
1672
+ if (running.lifecycle) return running.lifecycle;
1673
+ let lifecycle = createLifecycle(running.startTime);
1674
+ // Claude agents have no activity snapshots; treat confirmed launch as running.
1675
+ if (running.cli === "claude") {
1676
+ lifecycle = markProcessRunning(lifecycle, running.startTime);
1677
+ running.lifecycle = lifecycle;
1678
+ return lifecycle;
1679
+ }
1680
+ const state = running.statusState;
1681
+ if (
1682
+ state?.activityLabel === "interrupted" &&
1683
+ state.localOverrideAtMs != null
1684
+ ) {
1685
+ lifecycle = markInterruptRequested(lifecycle, state.localOverrideAtMs);
1686
+ } else if (state?.phase === "done") {
1687
+ // Legacy activity "done" means the turn ended, not that completion
1688
+ // evidence was recorded. Hydrate as Herdr-style waiting and let the
1689
+ // preserved watcher consume sidecar/sentinel evidence.
1690
+ const observedAt = state.lastActivityAtMs ?? running.startTime;
1691
+ lifecycle = observePaneInspection(
1692
+ lifecycle,
1693
+ { kind: "present", observedAt, agentStatus: "done" },
1694
+ observedAt,
1695
+ );
1696
+ } else if (
1697
+ state?.phase === "active" ||
1698
+ state?.phase === "waiting" ||
1699
+ state?.phase === "starting"
1700
+ ) {
1701
+ lifecycle = observeActivity(
1702
+ lifecycle,
1703
+ {
1704
+ ok: true,
1705
+ activity: {
1706
+ version: 1,
1707
+ runningChildId: running.id,
1708
+ createdAt: running.startTime,
1709
+ updatedAt: state.lastActivityAtMs ?? running.startTime,
1710
+ sequence: state.lastActivitySequence ?? 0,
1711
+ latestEvent:
1712
+ state.latestEvent === "agent_end" ? "agent_end" : "agent_start",
1713
+ phase: state.phase,
1714
+ agentActive: state.phase === "active",
1715
+ turnActive: state.phase === "active",
1716
+ providerActive: false,
1717
+ toolActive: state.activeScope === "tool",
1718
+ ...(state.activeScope
1719
+ ? { activeScope: state.activeScope as any }
1720
+ : {}),
1721
+ ...(state.activeSinceMs != null
1722
+ ? { activeSince: state.activeSinceMs }
1723
+ : {}),
1724
+ ...(state.waitingSinceMs != null
1725
+ ? { waitingSince: state.waitingSinceMs }
1726
+ : {}),
1727
+ ...(state.activityLabel && state.activeScope === "tool"
1728
+ ? { toolName: state.activityLabel }
1729
+ : {}),
1730
+ },
1731
+ },
1732
+ state.lastActivityAtMs ?? running.startTime,
1733
+ );
1734
+ } else if (state?.source === "claude" || running.startTime) {
1735
+ // Pre-lifecycle Pi agents without a known phase still get a running process.
1736
+ lifecycle = markProcessRunning(lifecycle, running.startTime);
1737
+ }
1738
+ running.lifecycle = lifecycle;
1739
+ return lifecycle;
1740
+ }
1741
+
1742
+ function observeRunningSubagent(
1743
+ running: RunningSubagent,
1744
+ observedAt = Date.now(),
1745
+ ) {
1746
+ ensureLifecycle(running);
1747
+ if (running.cli === "claude") return;
1748
+
1749
+ const activityFile = running.activityFile;
1750
+ const read: ActivityReadResult = activityFile
1751
+ ? readSubagentActivityFile(activityFile, running.id)
1752
+ : { ok: false, reason: "missing" };
1753
+
1754
+ running.activityRead = read.ok
1755
+ ? { ok: true }
1756
+ : { ok: false, reason: read.reason, error: read.error };
1757
+
1758
+ if (read.ok) running.activity = read.activity;
1759
+ running.lifecycle = observeActivity(
1760
+ ensureLifecycle(running),
1761
+ read,
1762
+ observedAt,
1763
+ );
1764
+ }
1765
+
1766
+ function resolveInterruptTarget(params: {
1767
+ id?: string;
1768
+ name?: string;
1769
+ }): { running: RunningSubagent } | { error: string } {
1770
+ const requestedId = params.id?.trim();
1771
+ if (requestedId) {
1772
+ const running = runningSubagents.get(requestedId);
1773
+ return running
1774
+ ? { running }
1775
+ : { error: `No running subagent with id "${requestedId}".` };
1776
+ }
1777
+
1778
+ const requestedName = params.name?.trim();
1779
+ if (!requestedName) {
1780
+ return { error: "Provide a running subagent id or exact display name." };
1781
+ }
1782
+
1783
+ const matches = Array.from(runningSubagents.values()).filter(
1784
+ (running) => running.name === requestedName,
1785
+ );
1786
+ if (matches.length === 1) return { running: matches[0] };
1787
+ if (matches.length === 0) {
1788
+ return { error: `No running subagent named "${requestedName}".` };
1789
+ }
1790
+
1791
+ const candidates = matches
1792
+ .map((running) => `${running.name} [${running.id}]`)
1793
+ .join(", ");
1794
+ return {
1795
+ error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}`,
1796
+ };
1797
+ }
1798
+
1799
+ function requestSubagentInterrupt(
1800
+ running: RunningSubagent,
1801
+ interruptPaneKey: (surface: string) => void = interruptPane,
1802
+ ): { ok: true } | { error: string } {
1803
+ try {
1804
+ interruptPaneKey(running.surface);
1805
+ return { ok: true };
1806
+ } catch (error: any) {
1807
+ return {
1808
+ error:
1809
+ `Failed to send Escape to subagent "${running.name}" via herdr: ` +
1810
+ `${error?.message ?? String(error)}`,
1811
+ };
1812
+ }
1813
+ }
1814
+
1815
+ interface SubagentInterruptDetails {
1816
+ error?: string;
1817
+ id?: string;
1818
+ name?: string;
1819
+ status?: "interrupt_requested";
1820
+ }
1821
+
1822
+ function handleSubagentInterrupt(
1823
+ params: { id?: string; name?: string },
1824
+ interruptPaneKey: (surface: string) => void = interruptPane,
1825
+ ): AgentToolResult<SubagentInterruptDetails> {
1826
+ const resolved = resolveInterruptTarget(params);
1827
+ if ("error" in resolved) {
1828
+ return {
1829
+ content: [{ type: "text" as const, text: resolved.error }],
1830
+ details: { error: resolved.error },
1831
+ };
1832
+ }
1833
+
1834
+ const running = resolved.running;
1835
+ if (running.cli === "claude") {
1836
+ return {
1837
+ content: [
1838
+ {
1839
+ type: "text" as const,
1840
+ text: "Turn-only Escape interrupt is currently supported only for Pi-backed subagents. Claude-backed semantics have not been verified yet.",
1841
+ },
1842
+ ],
1843
+ details: {
1844
+ error: "claude interrupt unsupported",
1845
+ id: running.id,
1846
+ name: running.name,
1847
+ },
1848
+ };
1849
+ }
1850
+
1851
+ const now = Date.now();
1852
+ observeRunningSubagent(running, now);
1853
+
1854
+ const interruption = requestSubagentInterrupt(running, interruptPaneKey);
1855
+ if ("error" in interruption) {
1856
+ return {
1857
+ content: [{ type: "text" as const, text: interruption.error }],
1858
+ details: {
1859
+ error: interruption.error,
1860
+ id: running.id,
1861
+ name: running.name,
1862
+ },
1863
+ };
1864
+ }
1865
+
1866
+ running.lifecycle = markInterruptRequested(ensureLifecycle(running), now);
1867
+ updateWidget();
1868
+
1869
+ return {
1870
+ content: [
1871
+ {
1872
+ type: "text" as const,
1873
+ text: `Interrupt requested for subagent "${running.name}".`,
1874
+ },
1875
+ ],
1876
+ details: {
1877
+ id: running.id,
1878
+ name: running.name,
1879
+ status: "interrupt_requested",
1880
+ },
1881
+ };
1882
+ }
1883
+
1884
+ function startStatusRefresh(pi: ExtensionAPI) {
1885
+ if (!statusConfig.enabled || statusInterval) return;
1886
+
1887
+ statusInterval = setInterval(() => {
1888
+ if (runningSubagents.size === 0) {
1889
+ if (statusInterval) {
1890
+ clearInterval(statusInterval);
1891
+ statusInterval = null;
1892
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
1893
+ }
1894
+ return;
1895
+ }
1896
+
1897
+ const transitionLines: string[] = [];
1898
+ const now = Date.now();
1899
+ let shouldRefreshWidget = false;
1900
+
1901
+ for (const running of runningSubagents.values()) {
1902
+ // Dual-writes lifecycle + statusState for reload hydration; steers use lifecycle only.
1903
+ observeRunningSubagent(running, now);
1904
+ const projection = projectLifecycle(ensureLifecycle(running), now);
1905
+ const transition = lifecycleTransition(
1906
+ running.lastProjectedKind,
1907
+ projection.kind,
1908
+ );
1909
+ if (running.lastProjectedKind !== projection.kind) {
1910
+ shouldRefreshWidget = true;
1911
+ }
1912
+ running.lastProjectedKind = projection.kind;
1913
+
1914
+ // Interactive subagents (long-running, user-driven) intentionally don't
1915
+ // wake the parent session on stalled/recovered transitions — the user is
1916
+ // working in the subagent's pane, and a steer message here would burn an
1917
+ // orchestrator turn on a no-op "still waiting" ping. Widget still updates.
1918
+ if (transition && !running.interactive) {
1919
+ transitionLines.push(
1920
+ formatLifecycleTransitionLine(
1921
+ normalizeStatusName(running.name),
1922
+ projection,
1923
+ transition,
1924
+ now,
1925
+ running.startTime,
1926
+ formatElapsedDuration,
1927
+ ),
1928
+ );
1929
+ }
1930
+ }
1931
+
1932
+ if (shouldRefreshWidget) updateWidget();
1933
+
1934
+ if (transitionLines.length > 0) {
1935
+ const capped = capStatusLines(transitionLines, statusConfig.lineLimit);
1936
+ pi.sendMessage(
1937
+ {
1938
+ customType: "subagent_status",
1939
+ content: formatStatusAggregate(
1940
+ transitionLines,
1941
+ statusConfig.lineLimit,
1942
+ ),
1943
+ display: true,
1944
+ details: { lines: capped.visibleLines, overflow: capped.overflow },
1945
+ },
1946
+ { triggerTurn: true, deliverAs: "steer" },
1947
+ );
1948
+ }
1949
+ }, 1000);
1950
+
1951
+ (globalThis as any)[STATUS_INTERVAL_KEY] = statusInterval;
1952
+ }
1953
+
1954
+ function resolveResumeLaunchBehavior(params: { autoExit?: boolean }): {
1955
+ autoExit: boolean;
1956
+ interactive: boolean;
1957
+ } {
1958
+ const autoExit = params.autoExit ?? true;
1959
+ return { autoExit, interactive: !autoExit };
1960
+ }
1961
+
1962
+ function buildBtwLaunchCommand(params: {
1963
+ cwd: string;
1964
+ sessionFile: string;
1965
+ question: string;
1966
+ model: string;
1967
+ thinking: string;
1968
+ agentDir?: string;
1969
+ }): string {
1970
+ const parts = [
1971
+ "pi",
1972
+ "--session",
1973
+ shellQuote(params.sessionFile),
1974
+ "--no-extensions",
1975
+ "--model",
1976
+ shellQuote(params.model),
1977
+ "--thinking",
1978
+ shellQuote(params.thinking),
1979
+ shellQuote(BTW_BOUNDARY + params.question),
1980
+ ];
1981
+ const envPrefix = params.agentDir
1982
+ ? `PI_CODING_AGENT_DIR=${shellQuote(params.agentDir)} `
1983
+ : "";
1984
+ return `cd ${shellQuote(params.cwd)} && ${envPrefix}${parts.join(" ")}`;
1985
+ }
1986
+
1987
+ function buildWorkflowChildCommand(params: {
1988
+ checkout: string;
1989
+ sessionFile: string;
1990
+ id: string;
1991
+ name: string;
1992
+ model: string;
1993
+ thinking: ThinkingLevel;
1994
+ tools: string[];
1995
+ rolePrompt?: string;
1996
+ task: string;
1997
+ }): string {
1998
+ const parts = [
1999
+ "pi",
2000
+ "--no-extensions",
2001
+ "--no-skills",
2002
+ "--no-prompt-templates",
2003
+ "--no-context-files",
2004
+ "--no-approve",
2005
+ "--session",
2006
+ shellQuote(params.sessionFile),
2007
+ "-e",
2008
+ shellQuote(join(SUBAGENTS_DIR, "subagent-done.ts")),
2009
+ "--model",
2010
+ shellQuote(params.model),
2011
+ "--thinking",
2012
+ shellQuote(params.thinking),
2013
+ "--tools",
2014
+ shellQuote(params.tools.join(",")),
2015
+ ];
2016
+ if (params.rolePrompt)
2017
+ parts.push("--system-prompt", shellQuote(params.rolePrompt));
2018
+ parts.push(shellQuote(params.task));
2019
+ const denied =
2020
+ "caller_ping,subagent_done,subagent,subagent_interrupt,subagent_resume,subagents_list,herdr_workflow";
2021
+ const env = [
2022
+ `PI_DENY_TOOLS=${shellQuote(denied)}`,
2023
+ `PI_SUBAGENT_AUTO_EXIT=1`,
2024
+ `PI_SUBAGENT_NAME=${shellQuote(params.name)}`,
2025
+ `PI_SUBAGENT_ID=${shellQuote(params.id)}`,
2026
+ `PI_SUBAGENT_SESSION=${shellQuote(params.sessionFile)}`,
2027
+ // Inherit the parent agent dir so workflow children resolve the same
2028
+ // deterministic/test provider configuration as the approving parent.
2029
+ ...(process.env.PI_CODING_AGENT_DIR
2030
+ ? [`PI_CODING_AGENT_DIR=${shellQuote(process.env.PI_CODING_AGENT_DIR)}`]
2031
+ : []),
2032
+ ].join(" ");
2033
+ return `cd ${shellQuote(params.checkout)} && ${env} ${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
2034
+ }
2035
+
2036
+ export const __test__ = {
2037
+ borderLine,
2038
+ getShellReadyDelayMs,
2039
+ renderSubagentWidgetLines,
2040
+ loadAgentDefaults,
2041
+ discoverAgentDefinitions,
2042
+ resolveEffectiveSessionMode,
2043
+ resolveLaunchBehavior,
2044
+ resolveEffectiveAutoExit,
2045
+ resolveEffectiveInteractive,
2046
+ buildSubagentToolAllowlist,
2047
+ buildPiPromptArgs,
2048
+ buildBtwLaunchCommand,
2049
+ buildWorkflowChildCommand,
2050
+ observeRunningSubagent,
2051
+ resolveDenyTools,
2052
+ resolveInterruptTarget,
2053
+ requestSubagentInterrupt,
2054
+ handleSubagentInterrupt,
2055
+ resolveResultPresentation,
2056
+ resolveUnexpectedErrorPresentation,
2057
+ sendSubagentResult,
2058
+ resolveResumeLaunchBehavior,
2059
+ shouldRetainSubagentSurface,
2060
+ captureWorktreeHandoff,
2061
+ runSubagentScript,
2062
+ writeWorktreeManifest,
2063
+ runningSubagents,
2064
+ formatElapsed,
2065
+ setWorkflowCancelHooks(hooks: WorkflowCancelHooks | undefined) {
2066
+ runtime.workflowCancelHooks = hooks;
2067
+ },
2068
+ getActiveWorkflow() {
2069
+ return runtime.activeWorkflow;
2070
+ },
2071
+ };
2072
+
2073
+ function startWidgetRefresh() {
2074
+ if (widgetInterval) return;
2075
+ updateWidget(); // immediate first render
2076
+ widgetInterval = setInterval(() => {
2077
+ updateWidget();
2078
+ }, 1000);
2079
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = widgetInterval;
2080
+ }
2081
+
2082
+ /**
2083
+ * Launch a subagent: creates the herdr pane, builds the command, and
2084
+ * sends it. Returns a RunningSubagent — does NOT poll.
2085
+ *
2086
+ * Call watchSubagent() on the returned object to observe completion.
2087
+ */
2088
+ async function launchSubagent(
2089
+ params: typeof SubagentParams.static,
2090
+ ctx: {
2091
+ sessionManager: {
2092
+ getSessionFile(): string | null | undefined;
2093
+ getSessionId(): string;
2094
+ getSessionDir(): string;
2095
+ };
2096
+ cwd: string;
2097
+ model?: { provider: string; id: string };
2098
+ modelRegistry: {
2099
+ find(provider: string, modelId: string): any;
2100
+ getAvailable?: () => any[];
2101
+ getAll?: () => any[];
2102
+ hasConfiguredAuth?: (model: any) => boolean;
2103
+ };
2104
+ },
2105
+ parentThinking: ThinkingLevel,
2106
+ options?: { surface?: string },
2107
+ ): Promise<RunningSubagent> {
2108
+ const startTime = Date.now();
2109
+ const id = Math.random().toString(16).slice(2, 10);
2110
+
2111
+ const agentDefs = params.agent
2112
+ ? loadAgentDefaults(params.agent, runtime.pi)
2113
+ : null;
2114
+ if (params.agent && !agentDefs) {
2115
+ const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find(
2116
+ (candidate) => candidate.agentName === params.agent,
2117
+ );
2118
+ throw new Error(
2119
+ diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
2120
+ );
2121
+ }
2122
+ if (!ctx.model)
2123
+ throw new Error("Subagent launch requires a resolved parent model");
2124
+ const runtimePlan = resolveRuntimePlan(
2125
+ { model: params.model, thinking: params.thinking },
2126
+ {
2127
+ model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
2128
+ thinking: agentDefs?.thinking,
2129
+ },
2130
+ {
2131
+ provider: ctx.model.provider,
2132
+ modelId: ctx.model.id,
2133
+ thinking: parentThinking,
2134
+ },
2135
+ wrapPiModelRegistry(ctx.modelRegistry),
2136
+ );
2137
+ const effectiveModel = runtimePlan.model;
2138
+ const effectiveTools = params.tools ?? agentDefs?.tools;
2139
+ const effectiveSkills = params.skills ?? agentDefs?.skills;
2140
+ const effectiveThinking = runtimePlan.thinking;
2141
+ const effectiveAutoExit = resolveEffectiveAutoExit(params, agentDefs);
2142
+ const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs);
2143
+
2144
+ if (
2145
+ agentDefs?.cli === "claude" &&
2146
+ (runtimePlan.thinkingSource !== "parent" ||
2147
+ runtimePlan.thinking !== parentThinking)
2148
+ ) {
2149
+ throw new Error(
2150
+ "Thinking-level overrides are not supported for Claude CLI subagents; omit thinking or use a Pi-backed agent.",
2151
+ );
2152
+ }
2153
+
2154
+ const sessionFile = ctx.sessionManager.getSessionFile();
2155
+ if (!sessionFile) throw new Error("No session file");
2156
+ const sessionId = ctx.sessionManager.getSessionId();
2157
+ const artifactDir = getArtifactDir(
2158
+ ctx.sessionManager.getSessionDir(),
2159
+ sessionId,
2160
+ );
2161
+
2162
+ const resolvedPaths = resolveSubagentPaths(params, agentDefs);
2163
+ let localAgentDir = resolvedPaths.localAgentDir;
2164
+ let effectiveAgentDir = resolvedPaths.effectiveAgentDir;
2165
+ const sourceCwd = resolvedPaths.effectiveCwd ?? ctx.cwd;
2166
+ let targetCwdForSession = sourceCwd;
2167
+ let worktree: WorktreeLaunch | undefined;
2168
+ let surface: string;
2169
+
2170
+ if (params.worktree) {
2171
+ if (options?.surface)
2172
+ throw new Error("A worktree subagent cannot use a pre-created pane");
2173
+ const baseRef = params.worktree.base ?? "HEAD";
2174
+ const baseSha = resolveGitCommit(sourceCwd, baseRef);
2175
+ const manifestFile = join(artifactDir, "worktree-runs", `${id}.json`);
2176
+ writeWorktreeManifest(manifestFile, {
2177
+ state: "provisioning",
2178
+ id,
2179
+ name: params.name,
2180
+ sourceCwd,
2181
+ branch: params.worktree.branch,
2182
+ baseRef,
2183
+ baseSha,
2184
+ createdAt: startTime,
2185
+ });
2186
+
2187
+ let created: ReturnType<typeof createSubagentWorktree>;
2188
+ try {
2189
+ created = createSubagentWorktree(
2190
+ params.name,
2191
+ sourceCwd,
2192
+ params.worktree.branch,
2193
+ baseSha,
2194
+ );
2195
+ } catch (error: any) {
2196
+ writeWorktreeManifest(manifestFile, {
2197
+ state: "failed",
2198
+ id,
2199
+ name: params.name,
2200
+ sourceCwd,
2201
+ branch: params.worktree.branch,
2202
+ baseRef,
2203
+ baseSha,
2204
+ createdAt: startTime,
2205
+ error: error?.message ?? String(error),
2206
+ });
2207
+ throw error;
2208
+ }
2209
+
2210
+ worktree = {
2211
+ path: created.path,
2212
+ workspaceId: created.workspaceId,
2213
+ paneId: created.paneId,
2214
+ branch: created.branch,
2215
+ baseRef,
2216
+ baseSha,
2217
+ manifestFile,
2218
+ };
2219
+ writeWorktreeManifest(manifestFile, {
2220
+ state: "provisioned",
2221
+ id,
2222
+ name: params.name,
2223
+ sourceCwd,
2224
+ createdAt: startTime,
2225
+ ...worktree,
2226
+ });
2227
+ targetCwdForSession = created.path;
2228
+ surface = created.paneId;
2229
+
2230
+ const isolatedAgentDir = join(created.path, ".pi", "agent");
2231
+ if (existsSync(isolatedAgentDir)) {
2232
+ localAgentDir = isolatedAgentDir;
2233
+ effectiveAgentDir = isolatedAgentDir;
2234
+ } else {
2235
+ localAgentDir = null;
2236
+ }
2237
+ } else {
2238
+ surface = options?.surface ?? createSubagentPane(params.name);
2239
+ }
2240
+
2241
+ const sessionDir = getDefaultSessionDirFor(
2242
+ targetCwdForSession,
2243
+ effectiveAgentDir,
2244
+ );
2245
+ const workspaceBaseline =
2246
+ agentDefs?.cli === "claude" && !worktree
2247
+ ? captureWorkspaceBaseline(targetCwdForSession)
2248
+ : undefined;
2249
+
2250
+ // Generate a deterministic session file path for this subagent.
2251
+ // This eliminates race conditions when multiple agents launch simultaneously —
2252
+ // each agent knows exactly which file is theirs.
2253
+ const timestamp =
2254
+ new Date().toISOString().replace(/[:.]/g, "-").slice(0, 23) + "Z";
2255
+ const uuid = [
2256
+ id,
2257
+ Math.random().toString(16).slice(2, 10),
2258
+ Math.random().toString(16).slice(2, 10),
2259
+ Math.random().toString(16).slice(2, 6),
2260
+ ].join("-");
2261
+ const subagentSessionFile = join(sessionDir, `${timestamp}_${uuid}.jsonl`);
2262
+ if (worktree) {
2263
+ worktree.sessionFile = subagentSessionFile;
2264
+ writeWorktreeManifest(worktree.manifestFile, {
2265
+ sessionFile: subagentSessionFile,
2266
+ });
2267
+ }
2268
+
2269
+ // Use pre-created surface (parallel mode) or create a new one.
2270
+ // For new surfaces, pause briefly so the shell is ready before sending the command.
2271
+ if (!options?.surface) {
2272
+ await new Promise<void>((resolve) =>
2273
+ setTimeout(resolve, getShellReadyDelayMs()),
2274
+ );
2275
+ }
2276
+
2277
+ const launchBehavior = resolveLaunchBehavior(params, agentDefs);
2278
+
2279
+ if (launchBehavior.seededSessionMode) {
2280
+ seedSubagentSessionFile({
2281
+ mode: launchBehavior.seededSessionMode,
2282
+ parentSessionFile: sessionFile,
2283
+ childSessionFile: subagentSessionFile,
2284
+ childCwd: targetCwdForSession,
2285
+ });
2286
+ }
2287
+
2288
+ const activityFile = getSubagentActivityFile(artifactDir, id);
2289
+ mkdirSync(dirname(activityFile), { recursive: true });
2290
+ const { inheritsConversationContext } = launchBehavior;
2291
+
2292
+ // Build the task message
2293
+ // Only full-context fork mode inherits prior conversation state.
2294
+ // Blank-session modes need the wrapper instructions and artifact-backed handoff.
2295
+ const modeHint = effectiveAutoExit
2296
+ ? "Complete your task autonomously."
2297
+ : "Complete your task. When finished, call the subagent_done tool. The user can interact with you at any time.";
2298
+ const summaryInstruction = effectiveAutoExit
2299
+ ? "Your FINAL assistant message should summarize what you accomplished."
2300
+ : "Your FINAL assistant message (before calling subagent_done or before the user exits) should summarize what you accomplished.";
2301
+ const denySet = resolveDenyTools(agentDefs);
2302
+ const identity = agentDefs?.body ?? params.systemPrompt ?? null;
2303
+ const systemPromptMode = agentDefs?.systemPromptMode;
2304
+ const identityInSystemPrompt = systemPromptMode && identity;
2305
+ const roleBlock =
2306
+ identity && !identityInSystemPrompt ? `\n\n${identity}` : "";
2307
+ const fullTask = inheritsConversationContext
2308
+ ? params.task
2309
+ : `${roleBlock}\n\n${modeHint}\n\n${params.task}\n\n${summaryInstruction}`;
2310
+ // ── Claude Code CLI path ──
2311
+ if (agentDefs?.cli === "claude") {
2312
+ const sentinelFile = `/tmp/pi-claude-${id}-done`;
2313
+ const pluginDir = join(SUBAGENTS_DIR, "plugin");
2314
+
2315
+ const cmdParts: string[] = [];
2316
+ cmdParts.push(`PI_CLAUDE_SENTINEL=${shellQuote(sentinelFile)}`);
2317
+ cmdParts.push("claude");
2318
+ cmdParts.push("--dangerously-skip-permissions");
2319
+
2320
+ if (existsSync(pluginDir)) {
2321
+ cmdParts.push("--plugin-dir", shellQuote(pluginDir));
2322
+ }
2323
+
2324
+ const cliModel = agentDefs.cliModel ?? effectiveModel;
2325
+ if (cliModel) {
2326
+ cmdParts.push("--model", shellQuote(cliModel));
2327
+ }
2328
+
2329
+ const sp = params.systemPrompt ?? agentDefs.body;
2330
+ if (sp) {
2331
+ cmdParts.push("--append-system-prompt", shellQuote(sp));
2332
+ }
2333
+
2334
+ if (params.resumeSessionId) {
2335
+ cmdParts.push("--resume", shellQuote(params.resumeSessionId));
2336
+ }
2337
+
2338
+ // Always pass the task as the prompt — even for resumed sessions,
2339
+ // the caller's task is the follow-up instruction.
2340
+ cmdParts.push(shellQuote(params.task));
2341
+
2342
+ const cdPrefix = `cd ${shellQuote(targetCwdForSession)} && `;
2343
+ const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
2344
+
2345
+ const launchScriptName = `${
2346
+ (params.name || "subagent")
2347
+ .toLowerCase()
2348
+ .replace(/[^a-z0-9\s-]/g, "")
2349
+ .replace(/\s+/g, "-")
2350
+ .replace(/-+/g, "-")
2351
+ .replace(/^-|-$/g, "") || "subagent"
2352
+ }-${id}.sh`;
2353
+ const launchScriptFile = join(
2354
+ artifactDir,
2355
+ "subagent-scripts",
2356
+ launchScriptName,
2357
+ );
2358
+
2359
+ runSubagentScript(
2360
+ surface,
2361
+ command,
2362
+ {
2363
+ scriptPath: launchScriptFile,
2364
+ scriptPreamble: [
2365
+ `# Claude Code subagent launch script for ${params.name}`,
2366
+ `# Generated: ${new Date().toISOString()}`,
2367
+ `# Surface: ${surface}`,
2368
+ ].join("\n"),
2369
+ },
2370
+ worktree,
2371
+ );
2372
+
2373
+ const running: RunningSubagent = {
2374
+ id,
2375
+ name: params.name,
2376
+ task: params.task,
2377
+ agent: params.agent,
2378
+ surface,
2379
+ startTime,
2380
+ sessionFile: subagentSessionFile,
2381
+ launchScriptFile,
2382
+ cli: "claude",
2383
+ sentinelFile,
2384
+ interactive: effectiveInteractive,
2385
+ runtimePlan,
2386
+ workspaceBaseline,
2387
+ workspaceCwd: targetCwdForSession,
2388
+ worktree,
2389
+ lifecycle: markProcessRunning(createLifecycle(startTime), Date.now()),
2390
+ };
2391
+
2392
+ runningSubagents.set(id, running);
2393
+ return running;
2394
+ }
2395
+
2396
+ // ── Pi CLI path ──
2397
+
2398
+ // Build pi command
2399
+ const parts: string[] = ["pi"];
2400
+ parts.push("--session", shellQuote(subagentSessionFile));
2401
+
2402
+ const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
2403
+ parts.push("-e", shellQuote(subagentDonePath));
2404
+
2405
+ if (effectiveModel) {
2406
+ parts.push("--model", shellQuote(effectiveModel));
2407
+ }
2408
+ if (effectiveThinking) {
2409
+ parts.push("--thinking", shellQuote(effectiveThinking));
2410
+ }
2411
+
2412
+ // Pass agent body as system prompt via file to avoid shell escaping issues
2413
+ // with multiline content. Pi's --append-system-prompt and --system-prompt
2414
+ // auto-detect file paths and read their contents.
2415
+ if (identityInSystemPrompt && identity) {
2416
+ const flag =
2417
+ systemPromptMode === "replace"
2418
+ ? "--system-prompt"
2419
+ : "--append-system-prompt";
2420
+ const spTimestamp = new Date()
2421
+ .toISOString()
2422
+ .replace(/[:.]/g, "-")
2423
+ .slice(0, 19);
2424
+ const spSafeName = params.name
2425
+ .toLowerCase()
2426
+ .replace(/[^a-z0-9\s-]/g, "")
2427
+ .replace(/\s+/g, "-")
2428
+ .replace(/-+/g, "-")
2429
+ .replace(/^-|-$/g, "");
2430
+ const syspromptPath = join(
2431
+ artifactDir,
2432
+ `context/${spSafeName || "subagent"}-sysprompt-${spTimestamp}.md`,
2433
+ );
2434
+ mkdirSync(dirname(syspromptPath), { recursive: true });
2435
+ writeFileSync(syspromptPath, identity, "utf8");
2436
+ parts.push(flag, shellQuote(syspromptPath));
2437
+ }
2438
+
2439
+ const toolAllowlist = buildSubagentToolAllowlist(effectiveTools);
2440
+ if (toolAllowlist) {
2441
+ parts.push("--tools", shellQuote(toolAllowlist));
2442
+ }
2443
+
2444
+ // Build env prefix: denied tools + subagent identity + config dir propagation
2445
+ const envParts: string[] = [];
2446
+
2447
+ // If the target cwd has its own .pi/agent/, use that as the config root.
2448
+ // Otherwise propagate the current/global agent dir.
2449
+ if (localAgentDir && existsSync(localAgentDir)) {
2450
+ envParts.push(`PI_CODING_AGENT_DIR=${shellQuote(localAgentDir)}`);
2451
+ } else if (process.env.PI_CODING_AGENT_DIR) {
2452
+ envParts.push(
2453
+ `PI_CODING_AGENT_DIR=${shellQuote(process.env.PI_CODING_AGENT_DIR)}`,
2454
+ );
2455
+ }
2456
+
2457
+ if (denySet.size > 0) {
2458
+ envParts.push(`PI_DENY_TOOLS=${shellQuote([...denySet].join(","))}`);
2459
+ }
2460
+ envParts.push(`PI_SUBAGENT_NAME=${shellQuote(params.name)}`);
2461
+ if (params.agent) {
2462
+ envParts.push(`PI_SUBAGENT_AGENT=${shellQuote(params.agent)}`);
2463
+ }
2464
+ if (effectiveAutoExit) {
2465
+ envParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
2466
+ }
2467
+ envParts.push(`PI_SUBAGENT_SESSION=${shellQuote(subagentSessionFile)}`);
2468
+ envParts.push(`PI_SUBAGENT_ID=${shellQuote(id)}`);
2469
+ envParts.push(`PI_SUBAGENT_ACTIVITY_FILE=${shellQuote(activityFile)}`);
2470
+ envParts.push(`PI_SUBAGENT_SURFACE=${shellQuote(surface)}`);
2471
+ const envPrefix = envParts.join(" ") + " ";
2472
+
2473
+ // Pass task and skill prompts to the sub-agent.
2474
+ // Only full-context fork mode gets a direct task argument because it already
2475
+ // inherits the parent conversation. Blank-session modes use artifact-backed
2476
+ // handoff so the wrapper instructions arrive as the initial user message.
2477
+ let taskArg: string;
2478
+ if (launchBehavior.taskDelivery === "direct") {
2479
+ taskArg = fullTask;
2480
+ } else {
2481
+ const timestamp = new Date()
2482
+ .toISOString()
2483
+ .replace(/[:.]/g, "-")
2484
+ .slice(0, 19);
2485
+ const safeName = params.name
2486
+ .toLowerCase()
2487
+ .replace(/[^a-z0-9\s-]/g, "") // strip everything except alphanumeric, spaces, hyphens
2488
+ .replace(/\s+/g, "-") // spaces to hyphens
2489
+ .replace(/-+/g, "-") // collapse multiple hyphens
2490
+ .replace(/^-|-$/g, ""); // trim leading/trailing hyphens
2491
+ const artifactName = `context/${safeName || "subagent"}-${timestamp}.md`;
2492
+ const artifactPath = join(artifactDir, artifactName);
2493
+ mkdirSync(dirname(artifactPath), { recursive: true });
2494
+ writeFileSync(artifactPath, fullTask, "utf8");
2495
+ taskArg = `@${artifactPath}`;
2496
+ }
2497
+
2498
+ for (const promptArg of buildPiPromptArgs({
2499
+ effectiveSkills,
2500
+ taskDelivery: launchBehavior.taskDelivery,
2501
+ taskArg,
2502
+ })) {
2503
+ parts.push(shellQuote(promptArg));
2504
+ }
2505
+
2506
+ // Session placement, PI_CODING_AGENT_DIR, and command cwd must agree.
2507
+ const cdPrefix = `cd ${shellQuote(targetCwdForSession)} && `;
2508
+
2509
+ const piCommand = cdPrefix + envPrefix + parts.join(" ");
2510
+ const command = `${piCommand}; echo '__SUBAGENT_DONE_'$?'__'`;
2511
+ const launchScriptName = `${
2512
+ (params.name || "subagent")
2513
+ .toLowerCase()
2514
+ .replace(/[^a-z0-9\s-]/g, "")
2515
+ .replace(/\s+/g, "-")
2516
+ .replace(/-+/g, "-")
2517
+ .replace(/^-|-$/g, "") || "subagent"
2518
+ }-${id}.sh`;
2519
+ const launchScriptFile = join(
2520
+ artifactDir,
2521
+ "subagent-scripts",
2522
+ launchScriptName,
2523
+ );
2524
+ runSubagentScript(
2525
+ surface,
2526
+ command,
2527
+ {
2528
+ scriptPath: launchScriptFile,
2529
+ scriptPreamble: [
2530
+ `# Subagent launch script for ${params.name}`,
2531
+ `# Generated: ${new Date().toISOString()}`,
2532
+ `# Session: ${subagentSessionFile}`,
2533
+ `# Surface: ${surface}`,
2534
+ ].join("\n"),
2535
+ },
2536
+ worktree,
2537
+ );
2538
+
2539
+ const running: RunningSubagent = {
2540
+ id,
2541
+ name: params.name,
2542
+ task: params.task,
2543
+ agent: params.agent,
2544
+ surface,
2545
+ startTime,
2546
+ sessionFile: subagentSessionFile,
2547
+ launchScriptFile,
2548
+ activityFile,
2549
+ interactive: effectiveInteractive,
2550
+ runtimePlan,
2551
+ worktree,
2552
+ lifecycle: createLifecycle(startTime),
2553
+ };
2554
+
2555
+ runningSubagents.set(id, running);
2556
+ return running;
2557
+ }
2558
+
2559
+ /**
2560
+ * Watch a launched subagent until it exits. Polls for completion, extracts
2561
+ * the summary from the session file, and closes ordinary panes. Worktree
2562
+ * workspaces are retained for parent review.
2563
+ */
2564
+ const CLAUDE_SESSIONS_DIR = join(
2565
+ process.env.HOME ?? "/tmp",
2566
+ ".pi",
2567
+ "agent",
2568
+ "sessions",
2569
+ "claude-code",
2570
+ );
2571
+
2572
+ function copyClaudeSession(sentinelFile: string): string | null {
2573
+ try {
2574
+ const transcriptFile = sentinelFile + ".transcript";
2575
+ if (!existsSync(transcriptFile)) return null;
2576
+ const transcriptPath = readFileSync(transcriptFile, "utf-8").trim();
2577
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
2578
+ mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
2579
+ const filename =
2580
+ transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`;
2581
+ const dest = join(CLAUDE_SESSIONS_DIR, filename);
2582
+ copyFileSync(transcriptPath, dest);
2583
+ return filename;
2584
+ } catch {
2585
+ return null;
2586
+ }
2587
+ }
2588
+
2589
+ async function watchSubagent(
2590
+ running: RunningSubagent,
2591
+ signal: AbortSignal,
2592
+ ): Promise<SubagentResult> {
2593
+ const { name, task, surface, startTime, sessionFile } = running;
2594
+
2595
+ try {
2596
+ const result = await waitForCompletion(signal, {
2597
+ intervalMs: 1000,
2598
+ sessionFile,
2599
+ sentinelFile: running.sentinelFile,
2600
+ readTerminalTail: () => readPaneAsync(surface, 5),
2601
+ inspectPane: async () => inspectPane(surface),
2602
+ onPaneInspection: (inspection: PaneInspection, observedAt: number) => {
2603
+ ensureLifecycle(running);
2604
+ running.lifecycle = observePaneInspection(
2605
+ running.lifecycle,
2606
+ inspection,
2607
+ observedAt,
2608
+ );
2609
+ updateWidget();
2610
+ },
2611
+ onTick() {
2612
+ observeRunningSubagent(running);
2613
+ },
2614
+ });
2615
+
2616
+ const detectedAt = Date.now();
2617
+ running.lifecycle = markCompletionDetected(
2618
+ running.lifecycle,
2619
+ result,
2620
+ detectedAt,
2621
+ );
2622
+ updateWidget();
2623
+ const elapsed = Math.floor((detectedAt - startTime) / 1000);
2624
+
2625
+ if (running.cli === "claude") {
2626
+ // Claude Code result extraction
2627
+ const guardMessage = guardClaudeWorkspace(running);
2628
+ let summary = "";
2629
+
2630
+ if (running.sentinelFile) {
2631
+ try {
2632
+ summary = readFileSync(running.sentinelFile, "utf-8").trim();
2633
+ } catch {}
2634
+ }
2635
+
2636
+ if (!summary) {
2637
+ summary = readPane(surface, 200)
2638
+ .replace(/__SUBAGENT_DONE_\d+__/, "")
2639
+ .trimEnd();
2640
+ }
2641
+
2642
+ if (!summary) {
2643
+ summary =
2644
+ result.exitCode !== 0
2645
+ ? `Claude Code exited with code ${result.exitCode}`
2646
+ : "Claude Code exited without output";
2647
+ }
2648
+ if (guardMessage) summary += `\n\n${guardMessage}`;
2649
+
2650
+ // Copy Claude session transcript
2651
+ let sessionId: string | null = null;
2652
+ if (running.sentinelFile) {
2653
+ sessionId = copyClaudeSession(running.sentinelFile);
2654
+ try {
2655
+ unlinkSync(running.sentinelFile);
2656
+ } catch {}
2657
+ try {
2658
+ unlinkSync(running.sentinelFile + ".transcript");
2659
+ } catch {}
2660
+ }
2661
+
2662
+ const worktreeHandoff = finalizeSubagentSurface(
2663
+ running,
2664
+ result.exitCode === 0 ? "ready_for_review" : "failed",
2665
+ );
2666
+ running.lifecycle =
2667
+ result.exitCode === 0
2668
+ ? markCompleted(running.lifecycle, Date.now())
2669
+ : markFailed(
2670
+ running.lifecycle,
2671
+ result.errorMessage ?? summary,
2672
+ Date.now(),
2673
+ result.exitCode,
2674
+ );
2675
+
2676
+ return {
2677
+ name,
2678
+ task,
2679
+ summary,
2680
+ exitCode: result.exitCode,
2681
+ elapsed,
2682
+ ...(sessionId ? { claudeSessionId: sessionId } : {}),
2683
+ ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
2684
+ };
2685
+ }
2686
+
2687
+ // Pi subagent result extraction
2688
+ let summary: string;
2689
+ if (existsSync(sessionFile)) {
2690
+ const allEntries = getNewEntries(sessionFile, 0);
2691
+ const observed = findObservedSessionRuntime(allEntries);
2692
+ if (running.runtimePlan && observed.provider && observed.modelId) {
2693
+ const observedModel = `${observed.provider}/${observed.modelId}`;
2694
+ const observedThinking =
2695
+ observed.thinking === "off" ||
2696
+ observed.thinking === "minimal" ||
2697
+ observed.thinking === "low" ||
2698
+ observed.thinking === "medium" ||
2699
+ observed.thinking === "high" ||
2700
+ observed.thinking === "xhigh" ||
2701
+ observed.thinking === "max"
2702
+ ? observed.thinking
2703
+ : undefined;
2704
+ const mismatch =
2705
+ observedModel !== running.runtimePlan.model
2706
+ ? `Resolved model ${running.runtimePlan.model} but child reported ${observedModel}`
2707
+ : undefined;
2708
+ running.runtimePlan = {
2709
+ ...running.runtimePlan,
2710
+ ...(observedThinking ? { thinking: observedThinking } : {}),
2711
+ observed: {
2712
+ model: observedModel,
2713
+ ...(observedThinking ? { thinking: observedThinking } : {}),
2714
+ },
2715
+ ...(mismatch ? { runtimeMismatch: mismatch } : {}),
2716
+ };
2717
+ }
2718
+ summary =
2719
+ findLastAssistantMessage(allEntries) ??
2720
+ (result.errorMessage
2721
+ ? `Subagent error: ${result.errorMessage}`
2722
+ : result.exitCode !== 0
2723
+ ? `Sub-agent exited with code ${result.exitCode}`
2724
+ : "Sub-agent exited without output");
2725
+ } else {
2726
+ summary = result.errorMessage
2727
+ ? `Subagent error: ${result.errorMessage}`
2728
+ : result.exitCode !== 0
2729
+ ? `Sub-agent exited with code ${result.exitCode}`
2730
+ : "Sub-agent exited without output";
2731
+ }
2732
+
2733
+ const worktreeHandoff = finalizeSubagentSurface(
2734
+ running,
2735
+ result.ping
2736
+ ? "needs_help"
2737
+ : result.exitCode === 0
2738
+ ? "ready_for_review"
2739
+ : "failed",
2740
+ );
2741
+ running.lifecycle =
2742
+ result.exitCode === 0
2743
+ ? markCompleted(running.lifecycle, Date.now())
2744
+ : markFailed(
2745
+ running.lifecycle,
2746
+ result.errorMessage ?? summary,
2747
+ Date.now(),
2748
+ result.exitCode,
2749
+ );
2750
+
2751
+ return {
2752
+ name,
2753
+ task,
2754
+ summary,
2755
+ sessionFile,
2756
+ exitCode: result.exitCode,
2757
+ elapsed,
2758
+ ping: result.ping,
2759
+ ...(result.errorMessage ? { errorMessage: result.errorMessage } : {}),
2760
+ ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
2761
+ };
2762
+ } catch (err: any) {
2763
+ const guardMessage =
2764
+ running.cli === "claude" ? guardClaudeWorkspace(running) : undefined;
2765
+ const worktreeHandoff = finalizeSubagentSurface(running, "failed", true);
2766
+ running.lifecycle = markFailed(
2767
+ running.lifecycle,
2768
+ signal.aborted ? "Subagent cancelled." : (err?.message ?? String(err)),
2769
+ Date.now(),
2770
+ 1,
2771
+ );
2772
+ updateWidget();
2773
+
2774
+ if (signal.aborted) {
2775
+ return {
2776
+ name,
2777
+ task,
2778
+ summary: guardMessage
2779
+ ? `Subagent cancelled.\n\n${guardMessage}`
2780
+ : "Subagent cancelled.",
2781
+ exitCode: 1,
2782
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
2783
+ error: "cancelled",
2784
+ sessionFile,
2785
+ ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
2786
+ };
2787
+ }
2788
+ return {
2789
+ name,
2790
+ task,
2791
+ summary: guardMessage
2792
+ ? `Subagent error: ${err?.message ?? String(err)}\n\n${guardMessage}`
2793
+ : `Subagent error: ${err?.message ?? String(err)}`,
2794
+ exitCode: 1,
2795
+ elapsed: Math.floor((Date.now() - startTime) / 1000),
2796
+ error: err?.message ?? String(err),
2797
+ ...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
2798
+ };
2799
+ }
2800
+ }
2801
+
2802
+ export default function subagentsExtension(pi: ExtensionAPI) {
2803
+ runtime.pi = pi;
2804
+ let btwChild: BtwChild | undefined;
2805
+
2806
+ const closeBtw = async (): Promise<boolean> => {
2807
+ const child = btwChild;
2808
+ if (!child) return false;
2809
+
2810
+ let paneMissing = false;
2811
+ try {
2812
+ paneMissing = (await inspectPane(child.surface)).kind === "missing";
2813
+ } catch {
2814
+ // Best effort: try closing the pane directly when inspection is unavailable.
2815
+ }
2816
+
2817
+ if (!paneMissing) {
2818
+ try {
2819
+ interruptPane(child.surface);
2820
+ } catch {
2821
+ // Escape is best effort; pane close is authoritative for this MVP.
2822
+ }
2823
+ closePane(child.surface);
2824
+ }
2825
+
2826
+ btwChild = undefined;
2827
+ for (const file of [child.sessionFile, child.launchScriptFile]) {
2828
+ try {
2829
+ rmSync(file, { force: true });
2830
+ } catch {
2831
+ // Ephemeral artifact cleanup is best effort.
2832
+ }
2833
+ }
2834
+ return true;
2835
+ };
2836
+
2837
+ // Capture the UI context for widget updates and restore presentation for
2838
+ // subagents whose watchers survived a reload.
2839
+ pi.on("session_start", (_event, ctx) => {
2840
+ runtime.latestCtx = ctx;
2841
+ if (!runtime.workflowStartupScanned) {
2842
+ runtime.workflowStartupScanned = true;
2843
+ recoverWorkflowStartup(
2844
+ ctx.cwd,
2845
+ runtime.activeWorkflow
2846
+ ? new Set([runtime.activeWorkflow.runId])
2847
+ : new Set(),
2848
+ );
2849
+ }
2850
+ const pendingSession = runtime.pendingWorkflow?.parentSession;
2851
+ if (
2852
+ pendingSession &&
2853
+ (ctx.sessionManager.getSessionId() !== pendingSession.id ||
2854
+ ctx.sessionManager.getSessionFile() !== pendingSession.file)
2855
+ ) {
2856
+ runtime.pendingWorkflow = undefined;
2857
+ }
2858
+ runtime.modelCatalog = buildAuthenticatedModelCatalog(
2859
+ wrapPiModelRegistry(ctx.modelRegistry),
2860
+ );
2861
+ const refreshedGuidelines = buildSubagentRoutingGuidelines(
2862
+ runtime.modelCatalog,
2863
+ );
2864
+ subagentRoutingGuidelines.splice(
2865
+ 0,
2866
+ subagentRoutingGuidelines.length,
2867
+ ...refreshedGuidelines,
2868
+ );
2869
+ if (runningSubagents.size > 0) {
2870
+ startWidgetRefresh();
2871
+ startStatusRefresh(pi);
2872
+ updateWidget();
2873
+ }
2874
+ });
2875
+
2876
+ // Clean up on session shutdown
2877
+ pi.on("session_shutdown", async (event, _ctx) => {
2878
+ if (widgetInterval) {
2879
+ clearInterval(widgetInterval);
2880
+ widgetInterval = null;
2881
+ (globalThis as any)[WIDGET_INTERVAL_KEY] = null;
2882
+ }
2883
+ if (statusInterval) {
2884
+ clearInterval(statusInterval);
2885
+ statusInterval = null;
2886
+ (globalThis as any)[STATUS_INTERVAL_KEY] = null;
2887
+ }
2888
+
2889
+ const shutdownReason = (event as any).reason;
2890
+ cleanupSubagentsForShutdown(shutdownReason, runningSubagents);
2891
+ if (
2892
+ shutdownReason === "new" ||
2893
+ shutdownReason === "resume" ||
2894
+ shutdownReason === "fork"
2895
+ ) {
2896
+ runtime.pendingWorkflow = undefined;
2897
+ }
2898
+ try {
2899
+ await closeBtw();
2900
+ } catch {
2901
+ // Best effort during parent shutdown; the Herdr pane remains recoverable.
2902
+ }
2903
+ });
2904
+
2905
+ // Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter)
2906
+ const deniedTools = new Set(
2907
+ (process.env.PI_SUBAGENT_ID ? (process.env.PI_DENY_TOOLS ?? "") : "")
2908
+ .split(",")
2909
+ .map((s) => s.trim())
2910
+ .filter(Boolean),
2911
+ );
2912
+
2913
+ const shouldRegister = (name: string) => !deniedTools.has(name);
2914
+ const prepareCandidate = (
2915
+ ctx: ExtensionContext,
2916
+ path: string,
2917
+ parentSession: PendingWorkflow["parentSession"],
2918
+ roles = workflowRoles(discoverAgentCatalog(pi)),
2919
+ ) =>
2920
+ prepareWorkflow({
2921
+ cwd: ctx.cwd,
2922
+ path,
2923
+ roles,
2924
+ modelRegistry: wrapPiModelRegistry(ctx.modelRegistry),
2925
+ parentSession,
2926
+ });
2927
+ const workflowFailure = (
2928
+ code: string,
2929
+ message: string,
2930
+ retryable = false,
2931
+ ) => ({
2932
+ ok: false,
2933
+ code,
2934
+ message,
2935
+ retryable,
2936
+ });
2937
+ const runWorkflowAgent = async (
2938
+ owner: WorkflowOwner,
2939
+ candidate: PendingWorkflow,
2940
+ checkout: string,
2941
+ journal: ReturnType<typeof createWorkflowJournal>,
2942
+ roles: WorkflowRole[],
2943
+ prompt: string,
2944
+ options: unknown,
2945
+ ) => {
2946
+ if (owner.controller.signal.aborted || owner.gate.phase !== "running") {
2947
+ return workflowFailure("cancelled", "Workflow cancelled.");
2948
+ }
2949
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
2950
+ return workflowFailure(
2951
+ "workflow_agent_options",
2952
+ "Workflow agent options must contain kind: review and one declared role.",
2953
+ );
2954
+ }
2955
+ const entries = Object.entries(options as Record<string, unknown>);
2956
+ const { kind, role: roleName } = options as {
2957
+ kind?: unknown;
2958
+ role?: unknown;
2959
+ };
2960
+ if (
2961
+ entries.length !== 2 ||
2962
+ kind !== "review" ||
2963
+ typeof roleName !== "string"
2964
+ ) {
2965
+ return workflowFailure(
2966
+ "workflow_agent_options",
2967
+ "Workflow agent options must contain only kind: review and one declared role.",
2968
+ );
2969
+ }
2970
+ const policy = candidate.rolePolicies.find(
2971
+ (value) => value.role === roleName,
2972
+ );
2973
+ const role = roles.find((value) => value.name === roleName);
2974
+ if (
2975
+ !policy ||
2976
+ !role ||
2977
+ role.cli ||
2978
+ role.disableModelInvocation ||
2979
+ policy.tools.length === 0
2980
+ ) {
2981
+ return workflowFailure(
2982
+ "policy_error",
2983
+ `Workflow role ${JSON.stringify(roleName)} is unavailable.`,
2984
+ );
2985
+ }
2986
+ const id = `workflow-${candidate.runId}-${Math.random().toString(16).slice(2, 10)}`;
2987
+ const sessionFile = join(
2988
+ dirname(candidate.path),
2989
+ "sessions",
2990
+ `${id}.jsonl`,
2991
+ );
2992
+ let surface: string | undefined;
2993
+ let launched = false;
2994
+ const childController = new AbortController();
2995
+ const onOwnerAbort = () => childController.abort();
2996
+ if (owner.controller.signal.aborted) childController.abort();
2997
+ else
2998
+ owner.controller.signal.addEventListener("abort", onOwnerAbort, {
2999
+ once: true,
3000
+ });
3001
+ try {
3002
+ if (childController.signal.aborted)
3003
+ return workflowFailure("cancelled", "Workflow cancelled.");
3004
+ mkdirSync(dirname(sessionFile), { recursive: true });
3005
+ surface = createSubagentPane(`${candidate.runId}: ${roleName}`);
3006
+ owner.children.set(id, { controller: childController, surface });
3007
+ await new Promise<void>((done) =>
3008
+ setTimeout(done, getShellReadyDelayMs()),
3009
+ );
3010
+ if (childController.signal.aborted)
3011
+ return workflowFailure("cancelled", "Workflow cancelled.");
3012
+ const command = buildWorkflowChildCommand({
3013
+ checkout,
3014
+ sessionFile,
3015
+ id,
3016
+ name: roleName,
3017
+ model: policy.model,
3018
+ thinking: policy.thinking,
3019
+ tools: policy.tools,
3020
+ rolePrompt: role.body,
3021
+ task: prompt,
3022
+ });
3023
+ journal.append("agent_started", {
3024
+ id,
3025
+ role: roleName,
3026
+ sessionFile,
3027
+ tools: policy.tools,
3028
+ });
3029
+ runScriptInPane(surface, command, {
3030
+ scriptPath: join(dirname(candidate.path), "launch", `${id}.sh`),
3031
+ });
3032
+ launched = true;
3033
+ const watched = await watchSubagent(
3034
+ {
3035
+ id,
3036
+ name: roleName,
3037
+ task: prompt,
3038
+ surface,
3039
+ startTime: Date.now(),
3040
+ sessionFile,
3041
+ interactive: false,
3042
+ runtimePlan: undefined,
3043
+ lifecycle: createLifecycle(Date.now()),
3044
+ },
3045
+ childController.signal,
3046
+ );
3047
+ surface = undefined;
3048
+ if (childController.signal.aborted || watched.error === "cancelled") {
3049
+ return workflowFailure("cancelled", "Workflow cancelled.");
3050
+ }
3051
+ const childEntries = existsSync(sessionFile)
3052
+ ? getNewEntries(sessionFile, 0)
3053
+ : [];
3054
+ const summary = findLastAssistantMessage(childEntries);
3055
+ if (watched.exitCode !== 0 || watched.errorMessage || !summary) {
3056
+ return workflowFailure(
3057
+ "child_error",
3058
+ watched.errorMessage ??
3059
+ summary ??
3060
+ `Workflow child exited with code ${watched.exitCode}`,
3061
+ );
3062
+ }
3063
+ const observed = findObservedSessionRuntime(childEntries);
3064
+ const observedModel =
3065
+ observed.provider && observed.modelId
3066
+ ? `${observed.provider}/${observed.modelId}`
3067
+ : undefined;
3068
+ if (
3069
+ observedModel !== policy.model ||
3070
+ observed.thinking !== policy.thinking
3071
+ ) {
3072
+ return workflowFailure(
3073
+ "workflow_runtime_mismatch",
3074
+ "Workflow child did not report the approved provider/model and thinking.",
3075
+ );
3076
+ }
3077
+ return { ok: true, value: summary, sessionFile };
3078
+ } catch (error) {
3079
+ if (childController.signal.aborted)
3080
+ return workflowFailure("cancelled", "Workflow cancelled.");
3081
+ const message = error instanceof Error ? error.message : String(error);
3082
+ return workflowFailure(
3083
+ launched ? "child_error" : "launch_error",
3084
+ message,
3085
+ );
3086
+ } finally {
3087
+ owner.controller.signal.removeEventListener("abort", onOwnerAbort);
3088
+ owner.children.delete(id);
3089
+ if (surface) {
3090
+ try {
3091
+ closePane(surface);
3092
+ } catch (error) {
3093
+ journal.append("pane_close_failed", {
3094
+ surface,
3095
+ error: error instanceof Error ? error.message : String(error),
3096
+ });
3097
+ }
3098
+ }
3099
+ }
3100
+ };
3101
+ const deliverWorkflowOutcome = (
3102
+ candidate: PendingWorkflow,
3103
+ journal: ReturnType<typeof createWorkflowJournal>,
3104
+ outcome: WorkflowTerminalOutcome,
3105
+ checkoutResult?: WorkflowReaderCheckout,
3106
+ ) => {
3107
+ const envelope = {
3108
+ runId: candidate.runId,
3109
+ state: outcome.state,
3110
+ ...(outcome.result !== undefined ? { result: outcome.result } : {}),
3111
+ ...(outcome.error ? { error: outcome.error } : {}),
3112
+ ...(checkoutResult ? { checkout: checkoutResult } : {}),
3113
+ };
3114
+ const terminalEventId = journal.append(outcome.state, { envelope });
3115
+ const content =
3116
+ outcome.state === "cancelled"
3117
+ ? `Workflow ${candidate.runId} cancelled.\n\nJournal: ${journal.path}`
3118
+ : `Workflow ${candidate.runId} ${outcome.state}.\n\nResult:\n${JSON.stringify(envelope)}\n\nJournal: ${journal.path}`;
3119
+ try {
3120
+ selectCompletionApi(pi, runtime.pi).sendMessage(
3121
+ {
3122
+ customType: "herdr_workflow_result",
3123
+ content,
3124
+ display: true,
3125
+ details: { ...envelope, journal: journal.path },
3126
+ },
3127
+ { triggerTurn: true, deliverAs: "steer" },
3128
+ );
3129
+ journal.append("delivery", {
3130
+ terminalEventId,
3131
+ state: outcome.state,
3132
+ targetSession: candidate.parentSession.file,
3133
+ status: "sent",
3134
+ });
3135
+ } catch {
3136
+ journal.append("delivery", {
3137
+ terminalEventId,
3138
+ state: outcome.state,
3139
+ targetSession: candidate.parentSession.file,
3140
+ status: "failed",
3141
+ });
3142
+ }
3143
+ return outcome;
3144
+ };
3145
+ const finalizeWorkflow = (
3146
+ owner: WorkflowOwner,
3147
+ outcome: WorkflowTerminalOutcome,
3148
+ checkoutResult?: WorkflowReaderCheckout,
3149
+ ) => {
3150
+ if (!claimWorkflowTerminal(owner.gate, outcome)) {
3151
+ return (
3152
+ owner.gate.outcome ??
3153
+ runtime.workflowOutcomes.get(owner.runId) ??
3154
+ outcome
3155
+ );
3156
+ }
3157
+ runtime.workflowOutcomes.set(owner.runId, outcome);
3158
+ const journal = owner.journal;
3159
+ if (journal)
3160
+ deliverWorkflowOutcome(owner.candidate, journal, outcome, checkoutResult);
3161
+ if (runtime.activeWorkflow?.runId === owner.runId)
3162
+ runtime.activeWorkflow = undefined;
3163
+ return outcome;
3164
+ };
3165
+ const cancelWorkflow = async (
3166
+ owner: WorkflowOwner,
3167
+ options: WorkflowCancelHooks = {},
3168
+ ): Promise<WorkflowTerminalOutcome> => {
3169
+ if (owner.cancelPromise) return owner.cancelPromise;
3170
+
3171
+ // Claim the gate first. Only the claimer creates cancelPromise, and it is
3172
+ // assigned before any await so concurrent callers await the real outcome.
3173
+ const begin = beginWorkflowCancellation(owner.gate);
3174
+ if (!begin.claimed) {
3175
+ if (begin.outcome) return begin.outcome;
3176
+ while (!owner.cancelPromise && owner.gate.phase === "cancelling") {
3177
+ await new Promise((resolve) => setImmediate(resolve));
3178
+ }
3179
+ if (owner.cancelPromise) return owner.cancelPromise;
3180
+ const previous =
3181
+ owner.gate.outcome ?? runtime.workflowOutcomes.get(owner.runId);
3182
+ if (previous) return previous;
3183
+ return {
3184
+ state: "failed" as const,
3185
+ error: {
3186
+ code: "cancel_termination_failed",
3187
+ message:
3188
+ "Workflow cancellation lost its in-flight waiter without a terminal outcome.",
3189
+ },
3190
+ };
3191
+ }
3192
+
3193
+ // Publish the waiter immediately so concurrent cancel callers never invent success.
3194
+ let settle!: (outcome: WorkflowTerminalOutcome) => void;
3195
+ const deferred = new Promise<WorkflowTerminalOutcome>((resolve) => {
3196
+ settle = resolve;
3197
+ });
3198
+ owner.cancelPromise = deferred;
3199
+
3200
+ const hooks = { ...runtime.workflowCancelHooks, ...options };
3201
+ const getProcessInfo = hooks.getProcessInfo ?? getPaneProcessInfo;
3202
+ const closeSurface = hooks.closeSurface ?? closePane;
3203
+ const waitAbsence = hooks.waitAbsence ?? waitForPaneAbsence;
3204
+ const waitExit = hooks.waitExit ?? waitForProcessesExit;
3205
+ void (async () => {
3206
+ try {
3207
+ owner.controller.abort();
3208
+ const children = [...owner.children.values()];
3209
+ const captured: Array<{
3210
+ surface?: string;
3211
+ pids: number[];
3212
+ identityUnconfirmed: boolean;
3213
+ }> = [];
3214
+ for (const child of children) {
3215
+ child.controller.abort();
3216
+ const pids: number[] = [];
3217
+ let identityUnconfirmed = false;
3218
+ if (child.surface) {
3219
+ try {
3220
+ const info = getProcessInfo(child.surface);
3221
+ pids.push(...info.pids);
3222
+ owner.journal?.append("cancel_process_info", {
3223
+ surface: child.surface,
3224
+ pids: info.pids,
3225
+ shellPid: info.shellPid,
3226
+ foregroundProcessGroupId: info.foregroundProcessGroupId,
3227
+ });
3228
+ // Active panes with no waitable PIDs lack exit proof.
3229
+ if (info.pids.length === 0) identityUnconfirmed = true;
3230
+ } catch (error) {
3231
+ identityUnconfirmed = true;
3232
+ owner.journal?.append("cancel_process_info_failed", {
3233
+ surface: child.surface,
3234
+ error: error instanceof Error ? error.message : String(error),
3235
+ });
3236
+ }
3237
+ }
3238
+ captured.push({
3239
+ surface: child.surface,
3240
+ pids,
3241
+ identityUnconfirmed,
3242
+ });
3243
+ }
3244
+ for (const child of captured) {
3245
+ if (!child.surface) continue;
3246
+ try {
3247
+ closeSurface(child.surface);
3248
+ } catch (error) {
3249
+ owner.journal?.append("pane_close_failed", {
3250
+ surface: child.surface,
3251
+ error: error instanceof Error ? error.message : String(error),
3252
+ });
3253
+ }
3254
+ }
3255
+ const surviving: number[] = [];
3256
+ let identityUnconfirmed = false;
3257
+ for (const child of captured) {
3258
+ if (child.identityUnconfirmed) identityUnconfirmed = true;
3259
+ if (child.surface) {
3260
+ const gone = await waitAbsence(child.surface, {
3261
+ timeoutMs: 5_000,
3262
+ intervalMs: 50,
3263
+ });
3264
+ if (!gone) {
3265
+ // Pane still present after close: treat as unconfirmed termination.
3266
+ identityUnconfirmed = true;
3267
+ owner.journal?.append("cancel_pane_still_present", {
3268
+ surface: child.surface,
3269
+ });
3270
+ }
3271
+ }
3272
+ if (child.pids.length > 0) {
3273
+ surviving.push(
3274
+ ...(await waitExit(child.pids, {
3275
+ timeoutMs: 5_000,
3276
+ intervalMs: 50,
3277
+ })),
3278
+ );
3279
+ }
3280
+ }
3281
+ const uniqueSurvivors = [...new Set(surviving)];
3282
+ const termination = cancelTerminationResult(
3283
+ uniqueSurvivors,
3284
+ owner.checkout,
3285
+ { identityUnconfirmed },
3286
+ );
3287
+ let checkoutResult: WorkflowReaderCheckout | undefined =
3288
+ termination.checkout;
3289
+ if (termination.retainCheckout) {
3290
+ if (owner.checkout) {
3291
+ owner.journal?.append("reader_checkout_retained", {
3292
+ path: owner.checkout,
3293
+ reason: "cancel_termination_failed",
3294
+ survivingPids: uniqueSurvivors,
3295
+ identityUnconfirmed,
3296
+ });
3297
+ }
3298
+ settle(finalizeWorkflow(owner, termination.outcome, checkoutResult));
3299
+ return;
3300
+ }
3301
+ if (owner.checkout && owner.journal) {
3302
+ checkoutResult = disposeWorkflowReaderCheckout(
3303
+ owner.candidate,
3304
+ owner.checkout,
3305
+ owner.journal,
3306
+ );
3307
+ owner.checkout = undefined;
3308
+ }
3309
+ settle(finalizeWorkflow(owner, termination.outcome, checkoutResult));
3310
+ } catch (error) {
3311
+ settle(
3312
+ finalizeWorkflow(owner, {
3313
+ state: "failed",
3314
+ error: {
3315
+ code: "cancel_termination_failed",
3316
+ message: error instanceof Error ? error.message : String(error),
3317
+ },
3318
+ }),
3319
+ );
3320
+ }
3321
+ })();
3322
+ return deferred;
3323
+ };
3324
+ const deliverWorkflow = async (
3325
+ owner: WorkflowOwner,
3326
+ candidate: PendingWorkflow,
3327
+ journal: ReturnType<typeof createWorkflowJournal>,
3328
+ roles: WorkflowRole[],
3329
+ ) => {
3330
+ owner.journal = journal;
3331
+ journal.append("started");
3332
+ let execution: WorkflowTerminalOutcome;
3333
+ try {
3334
+ owner.checkout = createWorkflowReaderCheckout(candidate, journal);
3335
+ execution = await executeWorkflow(candidate, {
3336
+ signal: owner.controller.signal,
3337
+ onWorker: (worker) => {
3338
+ owner.worker = worker;
3339
+ },
3340
+ onLog: (message) => journal.append("workflow_log", { message }),
3341
+ onAgent: async (prompt, options) => {
3342
+ const result = await runWorkflowAgent(
3343
+ owner,
3344
+ candidate,
3345
+ owner.checkout!,
3346
+ journal,
3347
+ roles,
3348
+ prompt,
3349
+ options,
3350
+ );
3351
+ // Cancel may already have written the terminal + delivery; do not
3352
+ // append late agent results after the journal has terminalized.
3353
+ if (owner.gate.phase === "running") {
3354
+ journal.append("agent_result", { result });
3355
+ }
3356
+ return result;
3357
+ },
3358
+ });
3359
+ } catch (error) {
3360
+ execution = {
3361
+ state: "failed",
3362
+ error: {
3363
+ code: "workflow_runner_error",
3364
+ message: error instanceof Error ? error.message : String(error),
3365
+ },
3366
+ };
3367
+ }
3368
+ if (owner.gate.phase === "cancelling" || owner.gate.phase === "terminal") {
3369
+ if (owner.cancelPromise) await owner.cancelPromise;
3370
+ return;
3371
+ }
3372
+ let checkoutResult: WorkflowReaderCheckout | undefined;
3373
+ if (owner.checkout) {
3374
+ checkoutResult = disposeWorkflowReaderCheckout(
3375
+ candidate,
3376
+ owner.checkout,
3377
+ journal,
3378
+ );
3379
+ owner.checkout = undefined;
3380
+ }
3381
+ finalizeWorkflow(owner, execution, checkoutResult);
3382
+ };
3383
+
3384
+ // Workflow control is parent-only. Workflow children must not be able to
3385
+ // prepare a revision or acquire approval for any later execution slice.
3386
+ if (!process.env.PI_SUBAGENT_ID)
3387
+ pi.registerTool({
3388
+ name: "herdr_workflow",
3389
+ label: "Herdr Workflow",
3390
+ description:
3391
+ "Prepare, start, or cancel one exact project-local workflow. Preparation validates and compiles the script without evaluating it. Start requires the matching user approval. Cancel stops queued and active children under the process-global terminal gate.",
3392
+ parameters: Type.Object({
3393
+ action: Type.Union([
3394
+ Type.Literal("prepare"),
3395
+ Type.Literal("start"),
3396
+ Type.Literal("cancel"),
3397
+ ]),
3398
+ path: Type.Optional(Type.String()),
3399
+ runId: Type.Optional(Type.String()),
3400
+ }),
3401
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3402
+ if (params.action === "prepare") {
3403
+ if (runtime.activeWorkflow) {
3404
+ return {
3405
+ content: [
3406
+ {
3407
+ type: "text",
3408
+ text: "Error: a workflow is already active in this Pi process.",
3409
+ },
3410
+ ],
3411
+ details: { error: "workflow_active" },
3412
+ };
3413
+ }
3414
+ if (!isTerminalAvailable()) return muxUnavailableResult();
3415
+ if (!params.path) {
3416
+ return {
3417
+ content: [
3418
+ {
3419
+ type: "text",
3420
+ text: "Error: workflow preparation requires path.",
3421
+ },
3422
+ ],
3423
+ details: { error: "workflow_path_required" },
3424
+ };
3425
+ }
3426
+ const sessionFile = ctx.sessionManager.getSessionFile();
3427
+ const leafId = ctx.sessionManager.getLeafId();
3428
+ if (!sessionFile || !leafId) {
3429
+ return {
3430
+ content: [
3431
+ {
3432
+ type: "text",
3433
+ text: "Error: start pi with a persistent session before preparing a workflow.",
3434
+ },
3435
+ ],
3436
+ details: { error: "workflow_persistent_session_required" },
3437
+ };
3438
+ }
3439
+ runtime.pendingWorkflow = undefined;
3440
+ try {
3441
+ const candidate = prepareCandidate(ctx, params.path, {
3442
+ id: ctx.sessionManager.getSessionId(),
3443
+ file: sessionFile,
3444
+ prepareLeafId: leafId,
3445
+ });
3446
+ runtime.pendingWorkflow = candidate;
3447
+ return {
3448
+ content: [
3449
+ { type: "text", text: formatApprovalPacket(candidate) },
3450
+ ],
3451
+ details: {
3452
+ runId: candidate.runId,
3453
+ scriptHash: candidate.scriptHash,
3454
+ repository: candidate.repository,
3455
+ baseSha: candidate.baseSha,
3456
+ sources: candidate.sources,
3457
+ rolePolicies: candidate.rolePolicies,
3458
+ },
3459
+ };
3460
+ } catch (error) {
3461
+ return {
3462
+ content: [
3463
+ {
3464
+ type: "text",
3465
+ text: `Workflow preparation failed: ${error instanceof Error ? error.message : String(error)}`,
3466
+ },
3467
+ ],
3468
+ details: { error: "workflow_prepare_failed" },
3469
+ };
3470
+ }
3471
+ }
3472
+ if (params.action === "start") {
3473
+ const candidate = runtime.pendingWorkflow;
3474
+ if (
3475
+ !candidate ||
3476
+ params.runId !== candidate.runId ||
3477
+ runtime.activeWorkflow
3478
+ ) {
3479
+ return {
3480
+ content: [
3481
+ {
3482
+ type: "text",
3483
+ text: "Error: no matching pending workflow can be started.",
3484
+ },
3485
+ ],
3486
+ details: { error: "workflow_start_rejected" },
3487
+ };
3488
+ }
3489
+ try {
3490
+ const sessionFile = ctx.sessionManager.getSessionFile();
3491
+ if (!sessionFile)
3492
+ throw new Error("No persistent parent session is available");
3493
+ const approval = validateWorkflowApproval(candidate, {
3494
+ sessionId: ctx.sessionManager.getSessionId(),
3495
+ sessionFile,
3496
+ branch: ctx.sessionManager.getBranch(),
3497
+ });
3498
+ const approvedRoles = workflowRoles(discoverAgentCatalog(pi));
3499
+ const revalidated = prepareCandidate(
3500
+ ctx,
3501
+ candidate.path,
3502
+ candidate.parentSession,
3503
+ approvedRoles,
3504
+ );
3505
+ if (!sameWorkflowCandidate(candidate, revalidated)) {
3506
+ throw new Error("Workflow candidate changed after preparation");
3507
+ }
3508
+ const journal = createWorkflowJournal(candidate, approval);
3509
+ runtime.pendingWorkflow = undefined;
3510
+ runtime.workflowOutcomes.delete(candidate.runId);
3511
+ const owner: WorkflowOwner = {
3512
+ runId: candidate.runId,
3513
+ candidate,
3514
+ children: new Map(),
3515
+ controller: new AbortController(),
3516
+ gate: createWorkflowTerminalGate(),
3517
+ journal,
3518
+ };
3519
+ runtime.activeWorkflow = owner;
3520
+ void deliverWorkflow(owner, candidate, journal, approvedRoles);
3521
+ return {
3522
+ content: [
3523
+ {
3524
+ type: "text",
3525
+ text: `Workflow ${candidate.runId} started in the background.`,
3526
+ },
3527
+ ],
3528
+ details: {
3529
+ runId: candidate.runId,
3530
+ journal: journal.path,
3531
+ status: "started",
3532
+ },
3533
+ };
3534
+ } catch (error) {
3535
+ return {
3536
+ content: [
3537
+ {
3538
+ type: "text",
3539
+ text: `Workflow start failed: ${error instanceof Error ? error.message : String(error)}`,
3540
+ },
3541
+ ],
3542
+ details: { error: "workflow_start_failed" },
3543
+ };
3544
+ }
3545
+ }
3546
+ if (params.action === "cancel") {
3547
+ if (!params.runId) {
3548
+ return {
3549
+ content: [
3550
+ {
3551
+ type: "text",
3552
+ text: "Error: workflow cancellation requires runId.",
3553
+ },
3554
+ ],
3555
+ details: { error: "workflow_run_id_required" },
3556
+ };
3557
+ }
3558
+ const owner = runtime.activeWorkflow;
3559
+ if (!owner || owner.runId !== params.runId) {
3560
+ const previous = runtime.workflowOutcomes.get(params.runId);
3561
+ if (previous) {
3562
+ return {
3563
+ content: [
3564
+ {
3565
+ type: "text",
3566
+ text: `Workflow ${params.runId} already ended as ${previous.state}.`,
3567
+ },
3568
+ ],
3569
+ details: {
3570
+ runId: params.runId,
3571
+ status: previous.state,
3572
+ outcome: previous,
3573
+ },
3574
+ };
3575
+ }
3576
+ return {
3577
+ content: [
3578
+ {
3579
+ type: "text",
3580
+ text: "Error: no matching active workflow can be cancelled.",
3581
+ },
3582
+ ],
3583
+ details: { error: "workflow_cancel_rejected" },
3584
+ };
3585
+ }
3586
+ try {
3587
+ const root = realpathSync(
3588
+ execFileSync(
3589
+ "git",
3590
+ ["-C", ctx.cwd, "rev-parse", "--show-toplevel"],
3591
+ {
3592
+ encoding: "utf8",
3593
+ },
3594
+ ).trim(),
3595
+ );
3596
+ const commonDir = realpathSync(
3597
+ execFileSync(
3598
+ "git",
3599
+ [
3600
+ "-C",
3601
+ ctx.cwd,
3602
+ "rev-parse",
3603
+ "--path-format=absolute",
3604
+ "--git-common-dir",
3605
+ ],
3606
+ { encoding: "utf8" },
3607
+ ).trim(),
3608
+ );
3609
+ if (
3610
+ root !== owner.candidate.repository.root ||
3611
+ commonDir !== owner.candidate.repository.commonDir
3612
+ ) {
3613
+ return {
3614
+ content: [
3615
+ {
3616
+ type: "text",
3617
+ text: "Error: workflow cancellation must use the approved repository identity.",
3618
+ },
3619
+ ],
3620
+ details: { error: "workflow_cancel_identity_mismatch" },
3621
+ };
3622
+ }
3623
+ } catch (error) {
3624
+ return {
3625
+ content: [
3626
+ {
3627
+ type: "text",
3628
+ text: `Workflow cancellation failed: ${error instanceof Error ? error.message : String(error)}`,
3629
+ },
3630
+ ],
3631
+ details: { error: "workflow_cancel_identity_failed" },
3632
+ };
3633
+ }
3634
+ const outcome = await cancelWorkflow(owner);
3635
+ return {
3636
+ content: [
3637
+ {
3638
+ type: "text",
3639
+ text:
3640
+ outcome.state === "cancelled"
3641
+ ? `Workflow ${owner.runId} cancelled.`
3642
+ : `Workflow ${owner.runId} ended as ${outcome.state}${outcome.error ? `: ${outcome.error.message}` : "."}`,
3643
+ },
3644
+ ],
3645
+ details: { runId: owner.runId, status: outcome.state, outcome },
3646
+ };
3647
+ }
3648
+ return {
3649
+ content: [
3650
+ { type: "text", text: "Error: unsupported workflow action." },
3651
+ ],
3652
+ details: { error: "workflow_action_unavailable" },
3653
+ };
3654
+ },
3655
+ });
3656
+
3657
+ // ── subagent tool ──
3658
+ if (shouldRegister("subagent"))
3659
+ pi.registerTool({
3660
+ name: "subagent",
3661
+ label: "Subagent",
3662
+ description:
3663
+ "Spawn a sub-agent in a dedicated terminal herdr pane, or in an isolated Herdr-managed Git worktree when worktree is provided. " +
3664
+ "Use unique worktree branches for independent writing tasks; use ordinary panes for read-only tasks. The worktree base is committed state, so uncommitted parent changes are not copied. " +
3665
+ "Worktree runs retain their workspace after completion for parent review; they are not pushed, merged, or removed automatically. " +
3666
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
3667
+ "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
3668
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
3669
+ "DO NOT fabricate, assume, or summarize results after calling this tool. " +
3670
+ "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
3671
+ promptSnippet:
3672
+ "Spawn a sub-agent in a dedicated terminal herdr pane, or in an isolated Herdr-managed Git worktree when worktree is provided. " +
3673
+ "Use unique worktree branches for independent writing tasks; use ordinary panes for read-only tasks. The worktree base is committed state, so uncommitted parent changes are not copied. " +
3674
+ "Worktree runs retain their workspace after completion for parent review; they are not pushed, merged, or removed automatically. " +
3675
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
3676
+ "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
3677
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " +
3678
+ "DO NOT fabricate, assume, or summarize results after calling this tool. " +
3679
+ "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.",
3680
+ promptGuidelines: subagentRoutingGuidelines,
3681
+ parameters: SubagentParams,
3682
+
3683
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
3684
+ // Prevent self-spawning (e.g. planner spawning another planner)
3685
+ const currentAgent = process.env.PI_SUBAGENT_AGENT;
3686
+ if (params.agent && currentAgent && params.agent === currentAgent) {
3687
+ return {
3688
+ content: [
3689
+ {
3690
+ type: "text",
3691
+ text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`,
3692
+ },
3693
+ ],
3694
+ details: { error: "self-spawn blocked" },
3695
+ };
3696
+ }
3697
+
3698
+ // Validate prerequisites
3699
+ if (!isTerminalAvailable()) {
3700
+ return muxUnavailableResult();
3701
+ }
3702
+
3703
+ if (!ctx.sessionManager.getSessionFile()) {
3704
+ return {
3705
+ content: [
3706
+ {
3707
+ type: "text",
3708
+ text: "Error: no session file. Start pi with a persistent session to use subagents.",
3709
+ },
3710
+ ],
3711
+ details: { error: "no session file" },
3712
+ };
3713
+ }
3714
+
3715
+ // Launch the subagent (creates pane, sends command)
3716
+ const parentThinking = pi.getThinkingLevel();
3717
+ if (
3718
+ parentThinking !== "off" &&
3719
+ parentThinking !== "minimal" &&
3720
+ parentThinking !== "low" &&
3721
+ parentThinking !== "medium" &&
3722
+ parentThinking !== "high" &&
3723
+ parentThinking !== "xhigh" &&
3724
+ parentThinking !== "max"
3725
+ ) {
3726
+ throw new Error(
3727
+ `Unsupported parent thinking level: ${parentThinking}`,
3728
+ );
3729
+ }
3730
+ const running = await launchSubagent(params, ctx, parentThinking);
3731
+
3732
+ // Create a separate AbortController for the watcher
3733
+ // (the tool's signal completes when we return)
3734
+ const watcherAbort = new AbortController();
3735
+ running.abortController = watcherAbort;
3736
+
3737
+ // Start widget refresh and status supervision when the first agent launches
3738
+ startWidgetRefresh();
3739
+ startStatusRefresh(pi);
3740
+
3741
+ // Fire-and-forget: start watching in background
3742
+ watchSubagent(running, watcherAbort.signal)
3743
+ .then((result) => {
3744
+ if (!shouldDeliverSubagentCompletion(running)) {
3745
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
3746
+ runningSubagents.delete(running.id);
3747
+ updateWidget();
3748
+ return;
3749
+ }
3750
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
3751
+ runningSubagents.delete(running.id);
3752
+ updateWidget();
3753
+ const completionApi = selectCompletionApi(pi, runtime.pi);
3754
+
3755
+ if (result.ping) {
3756
+ // Subagent is requesting help — steer a ping message with session path for resume
3757
+ const worktreeRef = result.worktree
3758
+ ? `\n\n${formatWorktreeHandoff(result.worktree)}`
3759
+ : "";
3760
+ const sessionRef = `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`;
3761
+ completionApi.sendMessage(
3762
+ {
3763
+ customType: "subagent_ping",
3764
+ content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${worktreeRef}${sessionRef}`,
3765
+ display: true,
3766
+ details: {
3767
+ name: result.ping.name,
3768
+ message: result.ping.message,
3769
+ agent: running.agent,
3770
+ sessionFile: result.sessionFile,
3771
+ ...(result.worktree ? { worktree: result.worktree } : {}),
3772
+ },
3773
+ },
3774
+ { triggerTurn: true, deliverAs: "steer" },
3775
+ );
3776
+ return;
3777
+ }
3778
+
3779
+ const presentation = resolveResultPresentation(
3780
+ result,
3781
+ running.name,
3782
+ running.runtimePlan?.runtimeMismatch,
3783
+ );
3784
+
3785
+ sendSubagentResult(completionApi, presentation, {
3786
+ name: running.name,
3787
+ task: running.task,
3788
+ agent: running.agent,
3789
+ exitCode: result.exitCode,
3790
+ elapsed: result.elapsed,
3791
+ sessionFile: result.sessionFile,
3792
+ ...(result.errorMessage
3793
+ ? { errorMessage: result.errorMessage }
3794
+ : {}),
3795
+ ...(result.claudeSessionId
3796
+ ? { claudeSessionId: result.claudeSessionId }
3797
+ : {}),
3798
+ ...(result.worktree ? { worktree: result.worktree } : {}),
3799
+ ...(running.runtimePlan
3800
+ ? { runtimePlan: running.runtimePlan }
3801
+ : {}),
3802
+ });
3803
+ })
3804
+ .catch((err) => {
3805
+ if (!shouldDeliverSubagentCompletion(running)) {
3806
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
3807
+ runningSubagents.delete(running.id);
3808
+ updateWidget();
3809
+ return;
3810
+ }
3811
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
3812
+ runningSubagents.delete(running.id);
3813
+ updateWidget();
3814
+ sendSubagentResult(
3815
+ selectCompletionApi(pi, runtime.pi),
3816
+ resolveUnexpectedErrorPresentation(
3817
+ `Sub-agent "${running.name}" error`,
3818
+ err,
3819
+ running.sessionFile,
3820
+ ),
3821
+ {
3822
+ name: running.name,
3823
+ task: running.task,
3824
+ error: err?.message,
3825
+ sessionFile: running.sessionFile,
3826
+ ...(running.worktree ? { worktree: running.worktree } : {}),
3827
+ },
3828
+ );
3829
+ });
3830
+
3831
+ // Return immediately
3832
+ return {
3833
+ content: [
3834
+ {
3835
+ type: "text",
3836
+ text:
3837
+ `Sub-agent "${params.name}" launched and is now running in the background` +
3838
+ (running.worktree
3839
+ ? ` in worktree ${running.worktree.path} on branch ${running.worktree.branch}. `
3840
+ : ". ") +
3841
+ `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` +
3842
+ `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` +
3843
+ `Until then, move on to other work or tell the user you're waiting.`,
3844
+ },
3845
+ ],
3846
+ details: {
3847
+ id: running.id,
3848
+ name: params.name,
3849
+ task: params.task,
3850
+ agent: params.agent,
3851
+ sessionFile: running.sessionFile,
3852
+ launchScriptFile: running.launchScriptFile,
3853
+ model: running.runtimePlan?.model,
3854
+ thinking: running.runtimePlan?.thinking,
3855
+ runtimePlan: running.runtimePlan,
3856
+ ...(running.worktree ? { worktree: running.worktree } : {}),
3857
+ status: "started",
3858
+ },
3859
+ };
3860
+ },
3861
+
3862
+ renderCall(args, theme) {
3863
+ const partialArgs = args as Record<string, unknown>;
3864
+ const name =
3865
+ typeof partialArgs.name === "string" && partialArgs.name
3866
+ ? partialArgs.name
3867
+ : "(unnamed)";
3868
+ const task =
3869
+ typeof partialArgs.task === "string" ? partialArgs.task : "";
3870
+ const agent =
3871
+ typeof partialArgs.agent === "string" && partialArgs.agent
3872
+ ? theme.fg("dim", ` (${partialArgs.agent})`)
3873
+ : "";
3874
+ const cwdHint =
3875
+ typeof partialArgs.cwd === "string" && partialArgs.cwd
3876
+ ? theme.fg("dim", ` in ${partialArgs.cwd}`)
3877
+ : "";
3878
+ const worktree = partialArgs.worktree as
3879
+ | { branch?: unknown }
3880
+ | undefined;
3881
+ const worktreeHint =
3882
+ typeof worktree?.branch === "string"
3883
+ ? theme.fg("dim", ` on ${worktree.branch} (worktree)`)
3884
+ : "";
3885
+ let text =
3886
+ "▸ " +
3887
+ theme.fg("toolTitle", theme.bold(name)) +
3888
+ agent +
3889
+ cwdHint +
3890
+ worktreeHint;
3891
+
3892
+ // Show a one-line task preview. renderCall is called repeatedly as the
3893
+ // LLM generates tool arguments, so args.task grows token by token.
3894
+ // We keep it compact here — Ctrl+O on renderResult expands the full content.
3895
+ if (task) {
3896
+ const firstLine =
3897
+ task.split("\n").find((l: string) => l.trim()) ?? "";
3898
+ const preview =
3899
+ firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine;
3900
+ if (preview) {
3901
+ text += "\n" + theme.fg("toolOutput", preview);
3902
+ }
3903
+ const totalLines = task.split("\n").length;
3904
+ if (totalLines > 1) {
3905
+ text += theme.fg("muted", ` (${totalLines} lines)`);
3906
+ }
3907
+ }
3908
+
3909
+ return new Text(text, 0, 0);
3910
+ },
3911
+
3912
+ renderResult(result, _opts, theme) {
3913
+ const details = result.details as any;
3914
+ const name = details?.name ?? "(unnamed)";
3915
+
3916
+ // "Started" result — tool returned immediately
3917
+ if (details?.status === "started") {
3918
+ const runtime = details?.model
3919
+ ? ` — ${details.model}${details.thinking ? ` · ${details.thinking}` : ""}`
3920
+ : " — started";
3921
+ const worktree = details?.worktree?.branch
3922
+ ? ` · ${details.worktree.branch}`
3923
+ : "";
3924
+ return new Text(
3925
+ theme.fg("accent", "▸") +
3926
+ " " +
3927
+ theme.fg("toolTitle", theme.bold(name)) +
3928
+ theme.fg("dim", runtime + worktree),
3929
+ 0,
3930
+ 0,
3931
+ );
3932
+ }
3933
+
3934
+ // Fallback (shouldn't happen)
3935
+ return new Text(theme.fg("dim", getFirstText(result.content)), 0, 0);
3936
+ },
3937
+ });
3938
+
3939
+ // ── subagent_interrupt tool ──
3940
+ if (shouldRegister("subagent_interrupt"))
3941
+ pi.registerTool({
3942
+ name: "subagent_interrupt",
3943
+ label: "Interrupt Subagent",
3944
+ description:
3945
+ "Send Escape to the active turn of a currently running Pi-backed subagent. " +
3946
+ "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " +
3947
+ "and does not emit a subagent_result solely because of this request.",
3948
+ promptSnippet:
3949
+ "Send Escape to the active turn of a currently running Pi-backed subagent. " +
3950
+ "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " +
3951
+ "and does not emit a subagent_result solely because of this request.",
3952
+ parameters: Type.Object({
3953
+ id: Type.Optional(
3954
+ Type.String({ description: "Exact running subagent id" }),
3955
+ ),
3956
+ name: Type.Optional(
3957
+ Type.String({ description: "Exact running subagent display name" }),
3958
+ ),
3959
+ }),
3960
+
3961
+ async execute(_toolCallId, params) {
3962
+ return handleSubagentInterrupt(params);
3963
+ },
3964
+
3965
+ renderCall(args, theme) {
3966
+ const target = args.id ? `${args.id}` : (args.name ?? "(unknown)");
3967
+ return new Text(
3968
+ theme.fg("accent", "▸") +
3969
+ " " +
3970
+ theme.fg("toolTitle", theme.bold(target)) +
3971
+ theme.fg("dim", " — interrupt turn"),
3972
+ 0,
3973
+ 0,
3974
+ );
3975
+ },
3976
+
3977
+ renderResult(result, _opts, theme) {
3978
+ const details = result.details as any;
3979
+ if (details?.status === "interrupt_requested") {
3980
+ return new Text(
3981
+ theme.fg("accent", "▸") +
3982
+ " " +
3983
+ theme.fg(
3984
+ "toolTitle",
3985
+ theme.bold(details.name ?? details.id ?? "subagent"),
3986
+ ) +
3987
+ theme.fg("dim", " — interrupt requested"),
3988
+ 0,
3989
+ 0,
3990
+ );
3991
+ }
3992
+
3993
+ return new Text(theme.fg("dim", getFirstText(result.content)), 0, 0);
3994
+ },
3995
+ });
3996
+
3997
+ // ── subagents_list tool ──
3998
+ if (shouldRegister("subagents_list"))
3999
+ pi.registerTool({
4000
+ name: "subagents_list",
4001
+ label: "List Subagents",
4002
+ description:
4003
+ "List all available package, global, and project subagent definitions. " +
4004
+ "Project agents override global definitions, which override package definitions.",
4005
+ promptSnippet:
4006
+ "List all available package, global, and project subagent definitions. " +
4007
+ "Project agents override global definitions, which override package definitions.",
4008
+ parameters: Type.Object({}),
4009
+
4010
+ async execute() {
4011
+ const catalog = discoverAgentCatalog(pi);
4012
+ const list = catalog.agents.filter(
4013
+ (agent) => !agent.disableModelInvocation,
4014
+ );
4015
+ const lines = [
4016
+ ...formatVisibleAgentDefinitions(list),
4017
+ ...formatAgentDiagnostics(catalog.diagnostics),
4018
+ ];
4019
+
4020
+ return {
4021
+ content: [
4022
+ {
4023
+ type: "text",
4024
+ text: lines.join("\n") || "No subagent definitions found.",
4025
+ },
4026
+ ],
4027
+ details: { agents: list, diagnostics: catalog.diagnostics },
4028
+ };
4029
+ },
4030
+
4031
+ renderResult(result, _opts, theme) {
4032
+ const details = result.details as any;
4033
+ const agents = details?.agents ?? [];
4034
+ const diagnostics = details?.diagnostics ?? [];
4035
+ if (agents.length === 0 && diagnostics.length === 0) {
4036
+ return new Text(
4037
+ theme.fg("dim", "No subagent definitions found."),
4038
+ 0,
4039
+ 0,
4040
+ );
4041
+ }
4042
+ const lines = agents.map((a: any) => {
4043
+ const source =
4044
+ a.source === "package" && a.provider
4045
+ ? `package:${a.provider}`
4046
+ : a.source;
4047
+ const badge = theme.fg("accent", ` (${source})`);
4048
+ const desc = a.description
4049
+ ? theme.fg("dim", ` — ${a.description}`)
4050
+ : "";
4051
+ const model = a.model ? theme.fg("dim", ` [${a.model}]`) : "";
4052
+ return ` ${theme.fg("toolTitle", theme.bold(a.name))}${badge}${model}${desc}`;
4053
+ });
4054
+ for (const diagnostic of diagnostics) {
4055
+ lines.push(theme.fg("warning", ` ! ${diagnostic.message}`));
4056
+ }
4057
+ return new Text(lines.join("\n"), 0, 0);
4058
+ },
4059
+ });
4060
+
4061
+ // ── subagent_resume tool ──
4062
+ if (shouldRegister("subagent_resume"))
4063
+ pi.registerTool({
4064
+ name: "subagent_resume",
4065
+ label: "Resume Subagent",
4066
+ description:
4067
+ "Resume a previous Pi-backed sub-agent session in a new herdr pane. " +
4068
+ "This does not reattach a retained managed worktree; continue worktree-bound follow-up in its existing workspace. " +
4069
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
4070
+ "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
4071
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " +
4072
+ "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " +
4073
+ "Use when a sub-agent was cancelled or needs follow-up work.",
4074
+ promptSnippet:
4075
+ "Resume a previous Pi-backed sub-agent session in a new herdr pane. " +
4076
+ "This does not reattach a retained managed worktree; continue worktree-bound follow-up in its existing workspace. " +
4077
+ "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " +
4078
+ "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " +
4079
+ "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " +
4080
+ "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " +
4081
+ "Use when a sub-agent was cancelled or needs follow-up work.",
4082
+ parameters: Type.Object({
4083
+ sessionPath: Type.String({
4084
+ description: "Path to the session .jsonl file to resume",
4085
+ }),
4086
+ name: Type.Optional(
4087
+ Type.String({
4088
+ description: "Display name for the terminal tab. Default: 'Resume'",
4089
+ }),
4090
+ ),
4091
+ message: Type.Optional(
4092
+ Type.String({
4093
+ description:
4094
+ "Optional message to send after resuming (e.g. follow-up instructions)",
4095
+ }),
4096
+ ),
4097
+ autoExit: Type.Optional(
4098
+ Type.Boolean({
4099
+ description:
4100
+ "Whether the resumed session should automatically exit after completing its response. Defaults to true for autonomous follow-up work; set false for interactive resumed sessions.",
4101
+ }),
4102
+ ),
4103
+ }),
4104
+
4105
+ renderCall(args, theme) {
4106
+ const name = args.name ?? "Resume";
4107
+ const text =
4108
+ "▸ " +
4109
+ theme.fg("toolTitle", theme.bold(name)) +
4110
+ theme.fg("dim", " — resuming session");
4111
+ return new Text(text, 0, 0);
4112
+ },
4113
+
4114
+ renderResult(result, _opts, theme) {
4115
+ const details = result.details as any;
4116
+ const name = details?.name ?? "Resume";
4117
+
4118
+ if (details?.status === "started") {
4119
+ return new Text(
4120
+ theme.fg("accent", "▸") +
4121
+ " " +
4122
+ theme.fg("toolTitle", theme.bold(name)) +
4123
+ theme.fg("dim", " — resumed"),
4124
+ 0,
4125
+ 0,
4126
+ );
4127
+ }
4128
+
4129
+ // Fallback
4130
+ return new Text(theme.fg("dim", getFirstText(result.content)), 0, 0);
4131
+ },
4132
+
4133
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
4134
+ const name = params.name ?? "Resume";
4135
+ const { autoExit, interactive } = resolveResumeLaunchBehavior(params);
4136
+ const startTime = Date.now();
4137
+ const id = Math.random().toString(16).slice(2, 10);
4138
+
4139
+ if (!isTerminalAvailable()) {
4140
+ return muxUnavailableResult();
4141
+ }
4142
+
4143
+ if (!existsSync(params.sessionPath)) {
4144
+ return {
4145
+ content: [
4146
+ {
4147
+ type: "text",
4148
+ text: `Error: session file not found: ${params.sessionPath}`,
4149
+ },
4150
+ ],
4151
+ details: { error: "session not found" },
4152
+ };
4153
+ }
4154
+
4155
+ // Record entry count before resuming so we can extract new messages
4156
+ const entryCountBefore = getNewEntries(params.sessionPath, 0).length;
4157
+
4158
+ const surface = createSubagentPane(name);
4159
+ await new Promise<void>((resolve) =>
4160
+ setTimeout(resolve, getShellReadyDelayMs()),
4161
+ );
4162
+
4163
+ // Build pi resume command
4164
+ const parts = ["pi", "--session", shellQuote(params.sessionPath)];
4165
+
4166
+ // Load subagent-done extension so the agent can self-terminate if needed
4167
+ const subagentDonePath = join(SUBAGENTS_DIR, "subagent-done.ts");
4168
+ parts.push("-e", shellQuote(subagentDonePath));
4169
+
4170
+ const sessionId = ctx.sessionManager.getSessionId();
4171
+ const artifactDir = getArtifactDir(
4172
+ ctx.sessionManager.getSessionDir(),
4173
+ sessionId,
4174
+ );
4175
+ const activityFile = getSubagentActivityFile(artifactDir, id);
4176
+ mkdirSync(dirname(activityFile), { recursive: true });
4177
+
4178
+ let resumeMsgFile: string | undefined;
4179
+ if (params.message) {
4180
+ const msgTimestamp = new Date()
4181
+ .toISOString()
4182
+ .replace(/[:.]/g, "-")
4183
+ .slice(0, 19);
4184
+ resumeMsgFile = join(
4185
+ artifactDir,
4186
+ "subagent-resume",
4187
+ `${
4188
+ name
4189
+ .toLowerCase()
4190
+ .replace(/[^a-z0-9\s-]/g, "")
4191
+ .replace(/\s+/g, "-")
4192
+ .replace(/-+/g, "-")
4193
+ .replace(/^-|-$/g, "") || "resume"
4194
+ }-${msgTimestamp}.md`,
4195
+ );
4196
+ mkdirSync(dirname(resumeMsgFile), { recursive: true });
4197
+ writeFileSync(resumeMsgFile, params.message, "utf8");
4198
+ parts.push(shellQuote(`@${resumeMsgFile}`));
4199
+ }
4200
+
4201
+ // Build env prefix — propagate PI_CODING_AGENT_DIR for config isolation
4202
+ const resumeEnvParts: string[] = [];
4203
+ if (process.env.PI_CODING_AGENT_DIR) {
4204
+ resumeEnvParts.push(
4205
+ `PI_CODING_AGENT_DIR=${shellQuote(process.env.PI_CODING_AGENT_DIR)}`,
4206
+ );
4207
+ }
4208
+ resumeEnvParts.push(`PI_SUBAGENT_NAME=${shellQuote(name)}`);
4209
+ resumeEnvParts.push(
4210
+ `PI_SUBAGENT_SESSION=${shellQuote(params.sessionPath)}`,
4211
+ );
4212
+ resumeEnvParts.push(`PI_SUBAGENT_ID=${shellQuote(id)}`);
4213
+ resumeEnvParts.push(
4214
+ `PI_SUBAGENT_ACTIVITY_FILE=${shellQuote(activityFile)}`,
4215
+ );
4216
+ if (autoExit) {
4217
+ resumeEnvParts.push(`PI_SUBAGENT_AUTO_EXIT=1`);
4218
+ }
4219
+ const resumeEnvPrefix = resumeEnvParts.join(" ") + " ";
4220
+
4221
+ const command = `${resumeEnvPrefix}${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
4222
+ const launchScriptFile = join(
4223
+ artifactDir,
4224
+ "subagent-scripts",
4225
+ `${
4226
+ name
4227
+ .toLowerCase()
4228
+ .replace(/[^a-z0-9\s-]/g, "")
4229
+ .replace(/\s+/g, "-")
4230
+ .replace(/-+/g, "-")
4231
+ .replace(/^-|-$/g, "") || "resume"
4232
+ }-resume-${Date.now()}.sh`,
4233
+ );
4234
+ runScriptInPane(surface, command, {
4235
+ scriptPath: launchScriptFile,
4236
+ scriptPreamble: [
4237
+ `# Subagent resume script for ${name}`,
4238
+ `# Generated: ${new Date().toISOString()}`,
4239
+ `# Session: ${params.sessionPath}`,
4240
+ `# Surface: ${surface}`,
4241
+ ...(resumeMsgFile
4242
+ ? [`# Resume message file: ${resumeMsgFile}`]
4243
+ : []),
4244
+ ].join("\n"),
4245
+ });
4246
+
4247
+ // Register as a running subagent for widget tracking
4248
+ const running: RunningSubagent = {
4249
+ id,
4250
+ name,
4251
+ task: params.message ?? "resumed session",
4252
+ surface,
4253
+ startTime,
4254
+ sessionFile: params.sessionPath,
4255
+ launchScriptFile,
4256
+ activityFile,
4257
+ interactive,
4258
+ runtimePlan: undefined,
4259
+ lifecycle: createLifecycle(startTime),
4260
+ };
4261
+ runningSubagents.set(id, running);
4262
+ startWidgetRefresh();
4263
+ startStatusRefresh(pi);
4264
+
4265
+ // Fire-and-forget watcher
4266
+ const watcherAbort = new AbortController();
4267
+ running.abortController = watcherAbort;
4268
+
4269
+ watchSubagent(running, watcherAbort.signal)
4270
+ .then((result) => {
4271
+ if (!shouldDeliverSubagentCompletion(running)) {
4272
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
4273
+ runningSubagents.delete(running.id);
4274
+ updateWidget();
4275
+ return;
4276
+ }
4277
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
4278
+ runningSubagents.delete(running.id);
4279
+ updateWidget();
4280
+ const completionApi = selectCompletionApi(pi, runtime.pi);
4281
+
4282
+ if (result.ping) {
4283
+ const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`;
4284
+ completionApi.sendMessage(
4285
+ {
4286
+ customType: "subagent_ping",
4287
+ content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`,
4288
+ display: true,
4289
+ details: {
4290
+ name: result.ping.name,
4291
+ message: result.ping.message,
4292
+ sessionFile: params.sessionPath,
4293
+ },
4294
+ },
4295
+ { triggerTurn: true, deliverAs: "steer" },
4296
+ );
4297
+ return;
4298
+ }
4299
+
4300
+ const allEntries = getNewEntries(
4301
+ params.sessionPath,
4302
+ entryCountBefore,
4303
+ );
4304
+ const summary =
4305
+ findLastAssistantMessage(allEntries) ??
4306
+ (result.errorMessage
4307
+ ? `Subagent error: ${result.errorMessage}`
4308
+ : result.exitCode !== 0
4309
+ ? `Resumed session exited with code ${result.exitCode}`
4310
+ : "Resumed session exited without new output");
4311
+ const presentation = resolveResultPresentation(
4312
+ { ...result, summary, sessionFile: params.sessionPath },
4313
+ name,
4314
+ running.runtimePlan?.runtimeMismatch,
4315
+ );
4316
+
4317
+ sendSubagentResult(completionApi, presentation, {
4318
+ name,
4319
+ task: params.message ?? "resumed session",
4320
+ exitCode: result.exitCode,
4321
+ elapsed: result.elapsed,
4322
+ sessionFile: params.sessionPath,
4323
+ ...(result.errorMessage
4324
+ ? { errorMessage: result.errorMessage }
4325
+ : {}),
4326
+ ...(running.runtimePlan
4327
+ ? { runtimePlan: running.runtimePlan }
4328
+ : {}),
4329
+ });
4330
+ })
4331
+ .catch((err) => {
4332
+ if (!shouldDeliverSubagentCompletion(running)) {
4333
+ running.lifecycle = markDelivery(running.lifecycle, "suppressed");
4334
+ runningSubagents.delete(running.id);
4335
+ updateWidget();
4336
+ return;
4337
+ }
4338
+ running.lifecycle = markDelivery(running.lifecycle, "delivered");
4339
+ runningSubagents.delete(running.id);
4340
+ updateWidget();
4341
+ sendSubagentResult(
4342
+ selectCompletionApi(pi, runtime.pi),
4343
+ resolveUnexpectedErrorPresentation(
4344
+ "Resume error",
4345
+ err,
4346
+ params.sessionPath,
4347
+ ),
4348
+ { name, error: err?.message, sessionFile: params.sessionPath },
4349
+ );
4350
+ });
4351
+
4352
+ return {
4353
+ content: [{ type: "text", text: `Session "${name}" resumed.` }],
4354
+ details: {
4355
+ id,
4356
+ name,
4357
+ sessionPath: params.sessionPath,
4358
+ launchScriptFile,
4359
+ status: "started",
4360
+ },
4361
+ };
4362
+ },
4363
+ });
4364
+
4365
+ pi.registerCommand("btw", {
4366
+ description:
4367
+ "Open an ephemeral side-question session in a background Herdr tab",
4368
+ handler: async (args, ctx) => {
4369
+ const question = args.trim();
4370
+ if (!question) {
4371
+ ctx.ui.notify("Usage: /btw <question>", "warning");
4372
+ return;
4373
+ }
4374
+ if (!isTerminalAvailable()) {
4375
+ ctx.ui.notify(terminalSetupHint(), "error");
4376
+ return;
4377
+ }
4378
+
4379
+ let sessionFile: string | undefined;
4380
+ let surface: string | undefined;
4381
+ let launchScriptFile: string | undefined;
4382
+ try {
4383
+ await ctx.waitForIdle();
4384
+ if (btwChild) await closeBtw();
4385
+
4386
+ const parentSessionFile = ctx.sessionManager.getSessionFile();
4387
+ const leafId = ctx.sessionManager.getLeafId();
4388
+ if (!parentSessionFile || !leafId) {
4389
+ throw new Error("No completed session context is available for BTW");
4390
+ }
4391
+ if (!ctx.model) throw new Error("No parent model is selected");
4392
+
4393
+ sessionFile = createBtwSessionSnapshot(parentSessionFile, leafId);
4394
+ surface = createSubagentPane("BTW");
4395
+ await new Promise<void>((resolve) =>
4396
+ setTimeout(resolve, getShellReadyDelayMs()),
4397
+ );
4398
+
4399
+ const artifactDir = getArtifactDir(
4400
+ ctx.sessionManager.getSessionDir(),
4401
+ ctx.sessionManager.getSessionId(),
4402
+ );
4403
+ launchScriptFile = join(
4404
+ artifactDir,
4405
+ "subagent-scripts",
4406
+ `btw-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
4407
+ );
4408
+ const command = buildBtwLaunchCommand({
4409
+ cwd: ctx.cwd,
4410
+ sessionFile,
4411
+ question,
4412
+ model: `${ctx.model.provider}/${ctx.model.id}`,
4413
+ thinking: pi.getThinkingLevel(),
4414
+ agentDir: process.env.PI_CODING_AGENT_DIR,
4415
+ });
4416
+ runScriptInPane(surface, command, {
4417
+ scriptPath: launchScriptFile,
4418
+ scriptPreamble: [
4419
+ "# BTW side-question session",
4420
+ `# Session: ${sessionFile}`,
4421
+ `# Generated: ${new Date().toISOString()}`,
4422
+ ].join("\n"),
4423
+ });
4424
+ btwChild = { surface, sessionFile, launchScriptFile };
4425
+ ctx.ui.notify("BTW opened in a background Herdr tab.", "info");
4426
+ } catch (error) {
4427
+ if (surface) {
4428
+ try {
4429
+ closePane(surface);
4430
+ } catch {
4431
+ // Leave the pane for manual recovery if launch cleanup fails.
4432
+ }
4433
+ }
4434
+ for (const file of [sessionFile, launchScriptFile]) {
4435
+ if (!file) continue;
4436
+ try {
4437
+ rmSync(file, { force: true });
4438
+ } catch {
4439
+ // Best effort.
4440
+ }
4441
+ }
4442
+ ctx.ui.notify(
4443
+ `BTW failed: ${error instanceof Error ? error.message : String(error)}`,
4444
+ "error",
4445
+ );
4446
+ }
4447
+ },
4448
+ });
4449
+
4450
+ pi.registerCommand("btw-close", {
4451
+ description: "Close the current BTW side-question session",
4452
+ handler: async (_args, ctx) => {
4453
+ try {
4454
+ if (!(await closeBtw())) {
4455
+ ctx.ui.notify("No BTW session is open.", "info");
4456
+ return;
4457
+ }
4458
+ ctx.ui.notify("BTW session closed.", "info");
4459
+ } catch (error) {
4460
+ ctx.ui.notify(
4461
+ `Could not close BTW session: ${error instanceof Error ? error.message : String(error)}`,
4462
+ "warning",
4463
+ );
4464
+ }
4465
+ },
4466
+ });
4467
+
4468
+ // /iterate command — fork the session into a subagent
4469
+ pi.registerCommand("iterate", {
4470
+ description:
4471
+ "Fork session into a subagent for focused work (bugfixes, iteration)",
4472
+ handler: async (args, _ctx) => {
4473
+ const task = args.trim() || "";
4474
+ const toolCall = task
4475
+ ? `Use subagent to fork an interactive session. fork: true, interactive: true, name: "Iterate", task: ${JSON.stringify(task)}`
4476
+ : `Use subagent to fork an interactive session. fork: true, interactive: true, name: "Iterate", task: "The user wants to do some hands-on work. Help them with whatever they need."`;
4477
+ pi.sendUserMessage(toolCall);
4478
+ },
4479
+ });
4480
+
4481
+ // /subagent command — spawn a subagent by name, or list available agents
4482
+ pi.registerCommand("subagent", {
4483
+ description:
4484
+ "Spawn a subagent: /subagent <agent> <task>; list agents: /subagent list",
4485
+ handler: async (args, ctx) => {
4486
+ const trimmed = args.trim();
4487
+ if (trimmed === "list") {
4488
+ const catalog = discoverAgentCatalog(pi);
4489
+ const lines = [
4490
+ ...formatVisibleAgentDefinitions(catalog.agents),
4491
+ ...formatAgentDiagnostics(catalog.diagnostics),
4492
+ ];
4493
+ ctx.ui.notify(
4494
+ lines.join("\n") || "No subagent definitions found.",
4495
+ "info",
4496
+ );
4497
+ return;
4498
+ }
4499
+ if (!trimmed) {
4500
+ ctx.ui.notify(
4501
+ "Usage: /subagent <agent> [task] | /subagent list",
4502
+ "warning",
4503
+ );
4504
+ return;
4505
+ }
4506
+
4507
+ const spaceIdx = trimmed.indexOf(" ");
4508
+ const agentName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
4509
+ const task = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim();
4510
+
4511
+ const catalog = discoverAgentCatalog(pi);
4512
+ const defs = catalog.agents.find((agent) => agent.name === agentName);
4513
+ if (!defs) {
4514
+ const diagnostic = catalog.diagnostics.find(
4515
+ (candidate) => candidate.agentName === agentName,
4516
+ );
4517
+ ctx.ui.notify(
4518
+ diagnostic?.message ?? `Agent "${agentName}" not found.`,
4519
+ "error",
4520
+ );
4521
+ return;
4522
+ }
4523
+
4524
+ const taskText =
4525
+ task || `You are the ${agentName} agent. Wait for instructions.`;
4526
+ const displayName = agentName[0].toUpperCase() + agentName.slice(1);
4527
+ const toolCall = `Use subagent with agent: "${agentName}", name: "${displayName}", task: ${JSON.stringify(taskText)}`;
4528
+ pi.sendUserMessage(toolCall);
4529
+ },
4530
+ });
4531
+
4532
+ // ── subagent_result message renderer ──
4533
+ pi.registerMessageRenderer("subagent_result", (message, options, theme) => {
4534
+ const details = message.details as any;
4535
+ if (!details) return undefined;
4536
+
4537
+ return {
4538
+ invalidate() {},
4539
+ render(width: number): string[] {
4540
+ const name = details.name ?? "subagent";
4541
+ const exitCode = details.exitCode ?? 0;
4542
+ const errorMessage =
4543
+ typeof details.errorMessage === "string" ? details.errorMessage : "";
4544
+ const failed = exitCode !== 0 || !!errorMessage;
4545
+ const elapsed =
4546
+ details.elapsed != null ? formatElapsed(details.elapsed) : "?";
4547
+ const bgFn = failed
4548
+ ? (text: string) => theme.bg("toolErrorBg", text)
4549
+ : (text: string) => theme.bg("toolSuccessBg", text);
4550
+ const icon = failed ? theme.fg("error", "✗") : theme.fg("success", "✓");
4551
+ const status = errorMessage
4552
+ ? "failed (provider/agent error)"
4553
+ : failed
4554
+ ? `failed (exit ${exitCode})`
4555
+ : "completed";
4556
+ const agentTag = details.agent
4557
+ ? theme.fg("dim", ` (${details.agent})`)
4558
+ : "";
4559
+
4560
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`;
4561
+ const rawContent =
4562
+ typeof details.resultContent === "string"
4563
+ ? details.resultContent
4564
+ : typeof message.content === "string"
4565
+ ? message.content
4566
+ : "";
4567
+
4568
+ // Clean summary (remove session ref and leading label for display)
4569
+ const summary = rawContent
4570
+ .replace(/\n\nSession: .+\nResume: .+$/, "")
4571
+ .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "")
4572
+ .replace(
4573
+ `Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`,
4574
+ "",
4575
+ )
4576
+ .replace(
4577
+ new RegExp(
4578
+ `^Sub-agent "${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" failed after ${elapsed} \\(provider/agent error — auto-retry exhausted\\)\\.\\n\\n`,
4579
+ ),
4580
+ "",
4581
+ );
4582
+
4583
+ // Build content for the box
4584
+ const contentLines = [header];
4585
+
4586
+ if (options.expanded) {
4587
+ // Full view: complete summary + session info
4588
+ if (summary) {
4589
+ for (const line of summary.split("\n")) {
4590
+ contentLines.push(line.slice(0, width - 6));
4591
+ }
4592
+ }
4593
+ if (details.sessionFile) {
4594
+ contentLines.push("");
4595
+ contentLines.push(
4596
+ theme.fg("dim", `Session: ${details.sessionFile}`),
4597
+ );
4598
+ contentLines.push(
4599
+ theme.fg("dim", `Resume: pi --session ${details.sessionFile}`),
4600
+ );
4601
+ }
4602
+ } else {
4603
+ // Collapsed: preview + expand hint
4604
+ if (summary) {
4605
+ const previewLines = summary.split("\n").slice(0, 5);
4606
+ for (const line of previewLines) {
4607
+ contentLines.push(theme.fg("dim", line.slice(0, width - 6)));
4608
+ }
4609
+ const totalLines = summary.split("\n").length;
4610
+ if (totalLines > 5) {
4611
+ contentLines.push(
4612
+ theme.fg("muted", `… ${totalLines - 5} more lines`),
4613
+ );
4614
+ }
4615
+ }
4616
+ contentLines.push(
4617
+ theme.fg("muted", keyHint("app.tools.expand", "to expand")),
4618
+ );
4619
+ }
4620
+
4621
+ // Render via Box for background + padding, with blank line above for separation
4622
+ const box = new Box(1, 1, bgFn);
4623
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
4624
+ return ["", ...box.render(width)];
4625
+ },
4626
+ };
4627
+ });
4628
+
4629
+ // ── subagent_status message renderer ──
4630
+ pi.registerMessageRenderer("subagent_status", (message, options, theme) => {
4631
+ const details = message.details as any;
4632
+ const lines = Array.isArray(details?.lines) ? details.lines : [];
4633
+ const overflow =
4634
+ typeof details?.overflow === "number" ? details.overflow : 0;
4635
+ if (lines.length === 0 && overflow === 0) return undefined;
4636
+
4637
+ return {
4638
+ invalidate() {},
4639
+ render(width: number): string[] {
4640
+ const lineWidth = Math.max(0, width - 6);
4641
+ const contentLines = [
4642
+ `${theme.fg("accent", "•")} ${theme.fg("toolTitle", theme.bold("Subagent status"))}`,
4643
+ ...lines.map((line: string) =>
4644
+ theme.fg("dim", truncateToWidth(line, lineWidth)),
4645
+ ),
4646
+ ];
4647
+
4648
+ if (overflow > 0) {
4649
+ contentLines.push(theme.fg("muted", `+${overflow} more running.`));
4650
+ }
4651
+ if (!options.expanded) {
4652
+ contentLines.push(
4653
+ theme.fg("muted", keyHint("app.tools.expand", "to expand")),
4654
+ );
4655
+ }
4656
+
4657
+ const box = new Box(1, 1, (text: string) =>
4658
+ theme.bg("customMessageBg", text),
4659
+ );
4660
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
4661
+ return ["", ...box.render(width)];
4662
+ },
4663
+ };
4664
+ });
4665
+
4666
+ // ── subagent_ping message renderer ──
4667
+ pi.registerMessageRenderer("subagent_ping", (message, options, theme) => {
4668
+ const details = message.details as any;
4669
+ if (!details) return undefined;
4670
+
4671
+ return {
4672
+ invalidate() {},
4673
+ render(width: number): string[] {
4674
+ const name = details.name ?? "subagent";
4675
+ const agentTag = details.agent
4676
+ ? theme.fg("dim", ` (${details.agent})`)
4677
+ : "";
4678
+ const bgFn = (text: string) => theme.bg("toolSuccessBg", text);
4679
+
4680
+ const icon = theme.fg("accent", "?");
4681
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— needs help")}`;
4682
+
4683
+ const contentLines = [header];
4684
+
4685
+ if (options.expanded) {
4686
+ contentLines.push("");
4687
+ contentLines.push(details.message ?? "");
4688
+ if (details.sessionFile) {
4689
+ contentLines.push("");
4690
+ contentLines.push(
4691
+ theme.fg("dim", `Session: ${details.sessionFile}`),
4692
+ );
4693
+ }
4694
+ } else {
4695
+ const preview = (details.message ?? "")
4696
+ .split("\n")[0]
4697
+ .slice(0, width - 10);
4698
+ contentLines.push(theme.fg("dim", preview));
4699
+ contentLines.push(
4700
+ theme.fg("muted", keyHint("app.tools.expand", "to expand")),
4701
+ );
4702
+ }
4703
+
4704
+ const box = new Box(1, 1, bgFn);
4705
+ box.addChild(new Text(contentLines.join("\n"), 0, 0));
4706
+ return ["", ...box.render(width)];
4707
+ },
4708
+ };
4709
+ });
4710
+
4711
+ // /plan command — start the full planning workflow
4712
+ pi.registerCommand("plan", {
4713
+ description: "Start a planning session: /plan <what to build>",
4714
+ handler: async (args, ctx) => {
4715
+ const task = args.trim();
4716
+ if (!task) {
4717
+ ctx.ui.notify("Usage: /plan <what to build>", "warning");
4718
+ return;
4719
+ }
4720
+
4721
+ // Load the plan skill from the subagents extension directory
4722
+ const planSkillPath = join(SUBAGENTS_DIR, "plan-skill.md");
4723
+ let content = readFileSync(planSkillPath, "utf8");
4724
+ content = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
4725
+ pi.sendUserMessage(
4726
+ `<skill name="plan" location="${planSkillPath}">\n${content.trim()}\n</skill>\n\n${task}`,
4727
+ );
4728
+ },
4729
+ });
4730
+ }