@pi-agents/orchid 0.1.0-beta.0

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 (163) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/LICENSE +21 -0
  3. package/README.md +246 -0
  4. package/agents/AGENTS-MANIFEST.md +42 -0
  5. package/agents/brain.md +42 -0
  6. package/agents/context-builder.md +46 -0
  7. package/agents/delegate.md +12 -0
  8. package/agents/dev-1.md +42 -0
  9. package/agents/oracle.md +73 -0
  10. package/agents/planner.md +55 -0
  11. package/agents/researcher.md +52 -0
  12. package/agents/reviewer.md +79 -0
  13. package/agents/scout.md +50 -0
  14. package/agents/tester.md +45 -0
  15. package/agents/worker.md +55 -0
  16. package/extensions/ralph.ts +1 -0
  17. package/extensions/reviewer-extension.ts +125 -0
  18. package/extensions/task-orchestrator.ts +28 -0
  19. package/package.json +63 -0
  20. package/prompts/gather-context-and-clarify.md +13 -0
  21. package/prompts/parallel-cleanup.md +59 -0
  22. package/prompts/parallel-context-build.md +53 -0
  23. package/prompts/parallel-handoff-plan.md +59 -0
  24. package/prompts/parallel-research.md +50 -0
  25. package/prompts/parallel-review.md +54 -0
  26. package/prompts/review-loop.md +41 -0
  27. package/skills/orchid/SKILL.md +214 -0
  28. package/skills/orchid/orchid-cleanup/SKILL.md +122 -0
  29. package/skills/orchid/orchid-converge/SKILL.md +124 -0
  30. package/skills/orchid/orchid-decompose/SKILL.md +201 -0
  31. package/skills/orchid/orchid-doctor/SKILL.md +162 -0
  32. package/skills/orchid/orchid-investigate/SKILL.md +102 -0
  33. package/skills/orchid/orchid-launch/SKILL.md +147 -0
  34. package/skills/ralph/SKILL.md +73 -0
  35. package/skills/subagents/pi-subagents/SKILL.md +813 -0
  36. package/src/index.ts +7 -0
  37. package/src/orchestrator/abort.ts +534 -0
  38. package/src/orchestrator/agent-bridge-extension.ts +1020 -0
  39. package/src/orchestrator/agent-host.ts +954 -0
  40. package/src/orchestrator/cleanup.ts +776 -0
  41. package/src/orchestrator/config-loader.ts +1412 -0
  42. package/src/orchestrator/config-schema.ts +690 -0
  43. package/src/orchestrator/config.ts +81 -0
  44. package/src/orchestrator/context-window.ts +66 -0
  45. package/src/orchestrator/diagnostic-reports.ts +475 -0
  46. package/src/orchestrator/diagnostics.ts +394 -0
  47. package/src/orchestrator/discovery.ts +1833 -0
  48. package/src/orchestrator/engine-worker.ts +415 -0
  49. package/src/orchestrator/engine.ts +5940 -0
  50. package/src/orchestrator/execution.ts +3104 -0
  51. package/src/orchestrator/extension.ts +5934 -0
  52. package/src/orchestrator/formatting.ts +785 -0
  53. package/src/orchestrator/git.ts +88 -0
  54. package/src/orchestrator/index.ts +28 -0
  55. package/src/orchestrator/lane-runner.ts +1787 -0
  56. package/src/orchestrator/mailbox.ts +780 -0
  57. package/src/orchestrator/merge.ts +3414 -0
  58. package/src/orchestrator/messages.ts +1062 -0
  59. package/src/orchestrator/migrations.ts +278 -0
  60. package/src/orchestrator/naming.ts +117 -0
  61. package/src/orchestrator/path-resolver.ts +275 -0
  62. package/src/orchestrator/persistence.ts +2625 -0
  63. package/src/orchestrator/process-registry.ts +452 -0
  64. package/src/orchestrator/quality-gate.ts +1085 -0
  65. package/src/orchestrator/resume.ts +3488 -0
  66. package/src/orchestrator/sessions.ts +57 -0
  67. package/src/orchestrator/settings-loader.ts +136 -0
  68. package/src/orchestrator/settings-tui.ts +2208 -0
  69. package/src/orchestrator/sidecar-telemetry.ts +267 -0
  70. package/src/orchestrator/supervisor.ts +4548 -0
  71. package/src/orchestrator/task-executor-core.ts +675 -0
  72. package/src/orchestrator/tmux-compat.ts +37 -0
  73. package/src/orchestrator/tool-allowlist-constants.ts +37 -0
  74. package/src/orchestrator/types.ts +4465 -0
  75. package/src/orchestrator/verification.ts +547 -0
  76. package/src/orchestrator/waves.ts +1564 -0
  77. package/src/orchestrator/workspace.ts +707 -0
  78. package/src/orchestrator/worktree.ts +2725 -0
  79. package/src/ralph/index.ts +825 -0
  80. package/src/subagents/agents/agent-management.ts +648 -0
  81. package/src/subagents/agents/agent-scope.ts +6 -0
  82. package/src/subagents/agents/agent-selection.ts +23 -0
  83. package/src/subagents/agents/agent-serializer.ts +86 -0
  84. package/src/subagents/agents/agents.ts +832 -0
  85. package/src/subagents/agents/chain-serializer.ts +137 -0
  86. package/src/subagents/agents/frontmatter.ts +29 -0
  87. package/src/subagents/agents/identity.ts +30 -0
  88. package/src/subagents/agents/skills.ts +632 -0
  89. package/src/subagents/extension/config.ts +16 -0
  90. package/src/subagents/extension/control-notices.ts +92 -0
  91. package/src/subagents/extension/doctor.ts +199 -0
  92. package/src/subagents/extension/fanout-child.ts +170 -0
  93. package/src/subagents/extension/index.ts +573 -0
  94. package/src/subagents/extension/schemas.ts +168 -0
  95. package/src/subagents/intercom/intercom-bridge.ts +379 -0
  96. package/src/subagents/intercom/result-intercom.ts +377 -0
  97. package/src/subagents/runs/background/async-execution.ts +712 -0
  98. package/src/subagents/runs/background/async-job-tracker.ts +310 -0
  99. package/src/subagents/runs/background/async-resume.ts +345 -0
  100. package/src/subagents/runs/background/async-status.ts +325 -0
  101. package/src/subagents/runs/background/completion-dedupe.ts +63 -0
  102. package/src/subagents/runs/background/notify.ts +108 -0
  103. package/src/subagents/runs/background/parallel-groups.ts +45 -0
  104. package/src/subagents/runs/background/result-watcher.ts +307 -0
  105. package/src/subagents/runs/background/run-id-resolver.ts +83 -0
  106. package/src/subagents/runs/background/run-status.ts +269 -0
  107. package/src/subagents/runs/background/stale-run-reconciler.ts +336 -0
  108. package/src/subagents/runs/background/subagent-runner.ts +1808 -0
  109. package/src/subagents/runs/background/top-level-async.ts +13 -0
  110. package/src/subagents/runs/foreground/chain-clarify.ts +1333 -0
  111. package/src/subagents/runs/foreground/chain-execution.ts +938 -0
  112. package/src/subagents/runs/foreground/execution.ts +918 -0
  113. package/src/subagents/runs/foreground/subagent-executor.ts +2527 -0
  114. package/src/subagents/runs/shared/completion-guard.ts +147 -0
  115. package/src/subagents/runs/shared/long-running-guard.ts +175 -0
  116. package/src/subagents/runs/shared/mcp-direct-tool-allowlist.ts +365 -0
  117. package/src/subagents/runs/shared/model-fallback.ts +103 -0
  118. package/src/subagents/runs/shared/nested-events.ts +819 -0
  119. package/src/subagents/runs/shared/nested-path.ts +52 -0
  120. package/src/subagents/runs/shared/nested-render.ts +115 -0
  121. package/src/subagents/runs/shared/parallel-utils.ts +109 -0
  122. package/src/subagents/runs/shared/pi-args.ts +220 -0
  123. package/src/subagents/runs/shared/pi-spawn.ts +115 -0
  124. package/src/subagents/runs/shared/run-history.ts +60 -0
  125. package/src/subagents/runs/shared/single-output.ts +164 -0
  126. package/src/subagents/runs/shared/subagent-control.ts +226 -0
  127. package/src/subagents/runs/shared/subagent-prompt-runtime.ts +170 -0
  128. package/src/subagents/runs/shared/worktree.ts +577 -0
  129. package/src/subagents/shared/artifacts.ts +98 -0
  130. package/src/subagents/shared/atomic-json.ts +16 -0
  131. package/src/subagents/shared/file-coalescer.ts +40 -0
  132. package/src/subagents/shared/fork-context.ts +76 -0
  133. package/src/subagents/shared/formatters.ts +133 -0
  134. package/src/subagents/shared/jsonl-writer.ts +81 -0
  135. package/src/subagents/shared/model-info.ts +78 -0
  136. package/src/subagents/shared/post-exit-stdio-guard.ts +85 -0
  137. package/src/subagents/shared/session-identity.ts +10 -0
  138. package/src/subagents/shared/session-tokens.ts +44 -0
  139. package/src/subagents/shared/settings.ts +397 -0
  140. package/src/subagents/shared/status-format.ts +49 -0
  141. package/src/subagents/shared/types.ts +822 -0
  142. package/src/subagents/shared/utils.ts +450 -0
  143. package/src/subagents/slash/prompt-template-bridge.ts +397 -0
  144. package/src/subagents/slash/slash-bridge.ts +174 -0
  145. package/src/subagents/slash/slash-commands.ts +528 -0
  146. package/src/subagents/slash/slash-live-state.ts +292 -0
  147. package/src/subagents/tui/render-helpers.ts +80 -0
  148. package/src/subagents/tui/render.ts +1358 -0
  149. package/templates/agents/local/supervisor.md +33 -0
  150. package/templates/agents/local/task-merger.md +27 -0
  151. package/templates/agents/local/task-reviewer.md +30 -0
  152. package/templates/agents/local/task-worker.md +34 -0
  153. package/templates/agents/supervisor-routing.md +92 -0
  154. package/templates/agents/supervisor.md +229 -0
  155. package/templates/agents/task-merger.md +214 -0
  156. package/templates/agents/task-reviewer.md +260 -0
  157. package/templates/agents/task-worker-segment.md +44 -0
  158. package/templates/agents/task-worker.md +557 -0
  159. package/templates/tasks/CONTEXT.md +30 -0
  160. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -0
  161. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -0
  162. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -0
  163. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -0
@@ -0,0 +1,1808 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import type { Message } from "@earendil-works/pi-ai";
6
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
+ import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
8
+ import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
9
+ import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
10
+ import {
11
+ type ActivityState,
12
+ type ArtifactConfig,
13
+ type ArtifactPaths,
14
+ type AsyncParallelGroupStatus,
15
+ type AsyncStatus,
16
+ type ModelAttempt,
17
+ type NestedRouteInfo,
18
+ type ResolvedControlConfig,
19
+ type SubagentRunMode,
20
+ type Usage,
21
+ DEFAULT_MAX_OUTPUT,
22
+ type MaxOutputConfig,
23
+ truncateOutput,
24
+ getSubagentDepthEnv,
25
+ } from "../../shared/types.ts";
26
+ import {
27
+ DEFAULT_CONTROL_CONFIG,
28
+ buildControlEvent,
29
+ deriveActivityState,
30
+ claimControlNotification,
31
+ formatControlIntercomMessage,
32
+ formatControlNoticeMessage,
33
+ } from "../shared/subagent-control.ts";
34
+ import {
35
+ type RunnerSubagentStep as SubagentStep,
36
+ type RunnerStep,
37
+ isParallelGroup,
38
+ flattenSteps,
39
+ mapConcurrent,
40
+ aggregateParallelOutputs,
41
+ MAX_PARALLEL_CONCURRENCY,
42
+ } from "../shared/parallel-utils.ts";
43
+ import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
44
+ import { nestedSummaryFromAsyncStatus, writeNestedEvent } from "../shared/nested-events.ts";
45
+ import { formatModelAttemptNote, isRetryableModelFailure } from "../shared/model-fallback.ts";
46
+ import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
47
+ import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "../../shared/utils.ts";
48
+ import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
49
+ import {
50
+ createMutatingFailureState,
51
+ didMutatingToolFail,
52
+ isMutatingTool,
53
+ nextLongRunningTrigger,
54
+ recordMutatingFailure,
55
+ resetMutatingFailureState,
56
+ resolveCurrentPath,
57
+ shouldEscalateMutatingFailures,
58
+ summarizeRecentMutatingFailures,
59
+ } from "../shared/long-running-guard.ts";
60
+ import { parseSessionTokens } from "../../shared/session-tokens.ts";
61
+ import type { TokenUsage } from "../../shared/types.ts";
62
+ import {
63
+ cleanupWorktrees,
64
+ createWorktrees,
65
+ diffWorktrees,
66
+ findWorktreeTaskCwdConflict,
67
+ formatWorktreeDiffSummary,
68
+ formatWorktreeTaskCwdConflict,
69
+ type WorktreeSetup,
70
+ } from "../shared/worktree.ts";
71
+ import { resolveEffectiveThinking } from "../../shared/model-info.ts";
72
+ import { writeInitialProgressFile } from "../../shared/settings.ts";
73
+
74
+ interface SubagentRunConfig {
75
+ id: string;
76
+ steps: RunnerStep[];
77
+ resultPath: string;
78
+ cwd: string;
79
+ placeholder: string;
80
+ taskIndex?: number;
81
+ totalTasks?: number;
82
+ maxOutput?: MaxOutputConfig;
83
+ artifactsDir?: string;
84
+ artifactConfig?: Partial<ArtifactConfig>;
85
+ share?: boolean;
86
+ sessionDir?: string;
87
+ asyncDir: string;
88
+ sessionId?: string | null;
89
+ piPackageRoot?: string;
90
+ piArgv1?: string;
91
+ worktreeSetupHook?: string;
92
+ worktreeSetupHookTimeoutMs?: number;
93
+ controlConfig?: ResolvedControlConfig;
94
+ controlIntercomTarget?: string;
95
+ childIntercomTargets?: Array<string | undefined>;
96
+ resultMode?: SubagentRunMode;
97
+ nestedRoute?: NestedRouteInfo;
98
+ nestedSelf?: { parentRunId: string; parentStepIndex?: number; depth: number; path?: Array<{ runId: string; stepIndex?: number; agent?: string }> };
99
+ }
100
+
101
+ interface StepResult {
102
+ agent: string;
103
+ output: string;
104
+ error?: string;
105
+ success: boolean;
106
+ skipped?: boolean;
107
+ sessionFile?: string;
108
+ intercomTarget?: string;
109
+ model?: string;
110
+ attemptedModels?: string[];
111
+ modelAttempts?: ModelAttempt[];
112
+ artifactPaths?: ArtifactPaths;
113
+ truncated?: boolean;
114
+ }
115
+
116
+ const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
117
+
118
+ function findLatestSessionFile(sessionDir: string): string | null {
119
+ try {
120
+ const files = fs
121
+ .readdirSync(sessionDir)
122
+ .filter((f) => f.endsWith(".jsonl"))
123
+ .map((f) => path.join(sessionDir, f));
124
+ if (files.length === 0) return null;
125
+ files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
126
+ return files[0] ?? null;
127
+ } catch {
128
+ // Session lookup is optional metadata.
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function emptyUsage(): Usage {
134
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
135
+ }
136
+
137
+ function tokenUsageFromAttempts(attempts: ModelAttempt[] | undefined): TokenUsage | null {
138
+ if (!attempts || attempts.length === 0) return null;
139
+ let input = 0;
140
+ let output = 0;
141
+ for (const attempt of attempts) {
142
+ input += attempt.usage?.input ?? 0;
143
+ output += attempt.usage?.output ?? 0;
144
+ }
145
+ const total = input + output;
146
+ return total > 0 ? { input, output, total } : null;
147
+ }
148
+
149
+ function appendRecentStepOutput(step: RunnerStatusStep, lines: string[]): void {
150
+ const nonEmpty = lines.filter((line) => line.trim());
151
+ if (nonEmpty.length === 0) return;
152
+ step.recentOutput ??= [];
153
+ step.recentOutput.push(...nonEmpty);
154
+ if (step.recentOutput.length > 50) {
155
+ step.recentOutput.splice(0, step.recentOutput.length - 50);
156
+ }
157
+ }
158
+
159
+ function resetStepLiveDetail(step: RunnerStatusStep): void {
160
+ step.currentTool = undefined;
161
+ step.currentToolArgs = undefined;
162
+ step.currentToolStartedAt = undefined;
163
+ step.currentPath = undefined;
164
+ step.recentTools = [];
165
+ step.recentOutput = [];
166
+ }
167
+
168
+ interface ChildEventContext {
169
+ eventsPath: string;
170
+ runId: string;
171
+ stepIndex: number;
172
+ agent: string;
173
+ }
174
+
175
+ interface ChildUsage {
176
+ input?: number;
177
+ inputTokens?: number;
178
+ output?: number;
179
+ outputTokens?: number;
180
+ cacheRead?: number;
181
+ cacheWrite?: number;
182
+ cost?: { total?: number };
183
+ }
184
+
185
+ type ChildMessage = Message & {
186
+ model?: string;
187
+ errorMessage?: string;
188
+ usage?: ChildUsage;
189
+ };
190
+
191
+ interface ChildEvent {
192
+ type?: string;
193
+ message?: ChildMessage;
194
+ toolName?: string;
195
+ args?: Record<string, unknown>;
196
+ }
197
+
198
+ interface RunPiStreamingResult {
199
+ stderr: string;
200
+ exitCode: number | null;
201
+ messages: Message[];
202
+ usage: Usage;
203
+ model?: string;
204
+ error?: string;
205
+ finalOutput: string;
206
+ interrupted?: boolean;
207
+ observedMutationAttempt?: boolean;
208
+ }
209
+
210
+ function runPiStreaming(
211
+ args: string[],
212
+ cwd: string,
213
+ outputFile: string,
214
+ env?: Record<string, string | undefined>,
215
+ piPackageRoot?: string,
216
+ piArgv1?: string,
217
+ maxSubagentDepth?: number,
218
+ childEventContext?: ChildEventContext,
219
+ registerInterrupt?: (interrupt: (() => void) | undefined) => void,
220
+ onChildEvent?: (event: ChildEvent) => void,
221
+ ): Promise<RunPiStreamingResult> {
222
+ return new Promise((resolve) => {
223
+ const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
224
+ const spawnEnv = { ...process.env, ...(env ?? {}), ...getSubagentDepthEnv(maxSubagentDepth) };
225
+ const spawnSpec = getPiSpawnCommand(args, {
226
+ ...(piPackageRoot ? { piPackageRoot } : {}),
227
+ ...(piArgv1 ? { argv1: piArgv1 } : {}),
228
+ });
229
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
230
+ cwd,
231
+ stdio: ["ignore", "pipe", "pipe"],
232
+ env: spawnEnv,
233
+ windowsHide: true,
234
+ });
235
+ let stderr = "";
236
+ let stdoutBuf = "";
237
+ let stderrBuf = "";
238
+ const messages: Message[] = [];
239
+ const usage = emptyUsage();
240
+ let model: string | undefined;
241
+ let error: string | undefined;
242
+ let assistantError: string | undefined;
243
+ let interrupted = false;
244
+ let observedMutationAttempt = false;
245
+ const rawStdoutLines: string[] = [];
246
+
247
+ const writeOutputLine = (line: string) => {
248
+ if (!line.trim()) return;
249
+ outputStream.write(`${line}\n`);
250
+ };
251
+
252
+ const writeOutputText = (text: string) => {
253
+ for (const line of text.split("\n")) {
254
+ writeOutputLine(line);
255
+ }
256
+ };
257
+
258
+ const appendChildEvent = (event: Record<string, unknown>) => {
259
+ if (!childEventContext) return;
260
+ appendJsonl(childEventContext.eventsPath, JSON.stringify({
261
+ ...event,
262
+ subagentSource: "child",
263
+ subagentRunId: childEventContext.runId,
264
+ subagentStepIndex: childEventContext.stepIndex,
265
+ subagentAgent: childEventContext.agent,
266
+ observedAt: Date.now(),
267
+ }));
268
+ };
269
+
270
+ const appendChildLine = (type: "subagent.child.stdout" | "subagent.child.stderr", line: string) => {
271
+ appendChildEvent({ type, line });
272
+ };
273
+
274
+ const processStdoutLine = (line: string) => {
275
+ if (!line.trim()) return;
276
+ let event: ChildEvent;
277
+ try {
278
+ event = JSON.parse(line) as ChildEvent;
279
+ } catch {
280
+ rawStdoutLines.push(line);
281
+ writeOutputLine(line);
282
+ appendChildLine("subagent.child.stdout", line);
283
+ return;
284
+ }
285
+
286
+ appendChildEvent(event);
287
+ onChildEvent?.(event);
288
+
289
+ if (event.type === "tool_execution_start" && event.toolName) {
290
+ observedMutationAttempt = observedMutationAttempt || isMutatingTool(event.toolName, event.args);
291
+ const toolArgs = extractToolArgsPreview(event.args ?? {});
292
+ writeOutputLine(toolArgs ? `${event.toolName}: ${toolArgs}` : event.toolName);
293
+ return;
294
+ }
295
+
296
+ if ((event.type === "message_end" || event.type === "tool_result_end") && event.message) {
297
+ messages.push(event.message);
298
+ const text = extractTextFromContent(event.message.content);
299
+ if (text) writeOutputText(text);
300
+
301
+ if (event.type !== "message_end" || event.message.role !== "assistant") return;
302
+ if (event.message.model) model = event.message.model;
303
+ if (event.message.errorMessage) assistantError = event.message.errorMessage;
304
+ const eventUsage = event.message.usage;
305
+ if (eventUsage) {
306
+ usage.turns++;
307
+ usage.input += eventUsage.input ?? eventUsage.inputTokens ?? 0;
308
+ usage.output += eventUsage.output ?? eventUsage.outputTokens ?? 0;
309
+ usage.cacheRead += eventUsage.cacheRead ?? 0;
310
+ usage.cacheWrite += eventUsage.cacheWrite ?? 0;
311
+ usage.cost += eventUsage.cost?.total ?? 0;
312
+ }
313
+ const stopReason = (event.message as { stopReason?: string }).stopReason;
314
+ const hasToolCall = Array.isArray(event.message.content)
315
+ && event.message.content.some((part) => (part as { type?: string }).type === "toolCall");
316
+ if (stopReason === "stop" && !hasToolCall) {
317
+ if (!event.message.errorMessage && extractTextFromContent(event.message.content).trim()) assistantError = undefined;
318
+ cleanTerminalAssistantStopReceived ||= !event.message.errorMessage;
319
+ startFinalDrain();
320
+ }
321
+ }
322
+ };
323
+
324
+ const processStderrText = (text: string) => {
325
+ stderr += text;
326
+ stderrBuf += text;
327
+ outputStream.write(text);
328
+ if (!childEventContext) return;
329
+ const lines = stderrBuf.split("\n");
330
+ stderrBuf = lines.pop() || "";
331
+ for (const line of lines) {
332
+ if (!line.trim()) continue;
333
+ appendChildLine("subagent.child.stderr", line);
334
+ }
335
+ };
336
+
337
+ // Guard both cases that can leave the parent waiting on `close` forever:
338
+ // a lingering stdio holder after `exit`, or a child that never exits.
339
+ const FINAL_STOP_GRACE_MS = 1000;
340
+ const HARD_KILL_MS = 3000;
341
+ let childExited = false;
342
+ let forcedTerminationSignal = false;
343
+ let cleanTerminalAssistantStopReceived = false;
344
+ let finalDrainTimer: NodeJS.Timeout | undefined;
345
+ let finalHardKillTimer: NodeJS.Timeout | undefined;
346
+ let settled = false;
347
+ const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
348
+ child.stdout.on("data", (chunk: Buffer) => {
349
+ const text = chunk.toString();
350
+ stdoutBuf += text;
351
+ const lines = stdoutBuf.split("\n");
352
+ stdoutBuf = lines.pop() || "";
353
+ for (const line of lines) processStdoutLine(line);
354
+ });
355
+
356
+ child.stderr.on("data", (chunk: Buffer) => {
357
+ processStderrText(chunk.toString());
358
+ });
359
+ registerInterrupt?.(() => {
360
+ if (settled) return;
361
+ interrupted = true;
362
+ if (!error) error = "Interrupted. Waiting for explicit next action.";
363
+ trySignalChild(child, "SIGINT");
364
+ setTimeout(() => {
365
+ if (!settled) trySignalChild(child, "SIGTERM");
366
+ }, 1000).unref?.();
367
+ });
368
+ const clearDrainTimers = () => {
369
+ if (finalDrainTimer) {
370
+ clearTimeout(finalDrainTimer);
371
+ finalDrainTimer = undefined;
372
+ }
373
+ if (finalHardKillTimer) {
374
+ clearTimeout(finalHardKillTimer);
375
+ finalHardKillTimer = undefined;
376
+ }
377
+ };
378
+ function startFinalDrain(): void {
379
+ if (childExited || finalDrainTimer || settled) return;
380
+ finalDrainTimer = setTimeout(() => {
381
+ if (settled) return;
382
+ const termSent = trySignalChild(child, "SIGTERM");
383
+ if (!termSent) return;
384
+ forcedTerminationSignal = true;
385
+ if (!cleanTerminalAssistantStopReceived && !error && !assistantError) {
386
+ error = `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its final message. Forcing termination.`;
387
+ }
388
+ finalHardKillTimer = setTimeout(() => {
389
+ if (settled) return;
390
+ forcedTerminationSignal = trySignalChild(child, "SIGKILL") || forcedTerminationSignal;
391
+ }, HARD_KILL_MS);
392
+ finalHardKillTimer.unref?.();
393
+ }, FINAL_STOP_GRACE_MS);
394
+ finalDrainTimer.unref?.();
395
+ }
396
+ child.on("exit", () => {
397
+ childExited = true;
398
+ clearDrainTimers();
399
+ });
400
+ child.on("close", (exitCode, signal) => {
401
+ settled = true;
402
+ registerInterrupt?.(undefined);
403
+ clearDrainTimers();
404
+ clearStdioGuard();
405
+ if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
406
+ if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
407
+ outputStream.end();
408
+ const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
409
+ const finalError = error ?? assistantError;
410
+ const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !finalError;
411
+ resolve({
412
+ stderr,
413
+ exitCode: interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
414
+ messages,
415
+ usage,
416
+ model,
417
+ error: interrupted || forcedDrainAfterFinalSuccess ? undefined : finalError,
418
+ finalOutput,
419
+ interrupted,
420
+ observedMutationAttempt,
421
+ });
422
+ });
423
+
424
+ child.on("error", (spawnError) => {
425
+ settled = true;
426
+ registerInterrupt?.(undefined);
427
+ clearDrainTimers();
428
+ clearStdioGuard();
429
+ outputStream.end();
430
+ const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
431
+ const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
432
+ resolve({ stderr, exitCode: 1, messages, usage, model, error: error ?? assistantError ?? spawnErrorMessage, finalOutput, observedMutationAttempt });
433
+ });
434
+ });
435
+ }
436
+
437
+ function resolvePiPackageRootFallback(): string {
438
+ const root = resolveInstalledPiPackageRoot();
439
+ if (root) return root;
440
+ throw new Error(`Could not resolve ${PI_CODING_AGENT_PACKAGE} package root`);
441
+ }
442
+
443
+ async function exportSessionHtml(sessionFile: string, outputDir: string, piPackageRoot?: string): Promise<string> {
444
+ const pkgRoot = piPackageRoot ?? resolvePiPackageRootFallback();
445
+ const exportModulePath = path.join(pkgRoot, "dist", "core", "export-html", "index.js");
446
+ const moduleUrl = pathToFileURL(exportModulePath).href;
447
+ const mod = await import(moduleUrl);
448
+ const exportFromFile = (mod as { exportFromFile?: (inputPath: string, options?: { outputPath?: string }) => string })
449
+ .exportFromFile;
450
+ if (typeof exportFromFile !== "function") {
451
+ throw new Error("exportFromFile not available");
452
+ }
453
+ const outputPath = path.join(outputDir, `${path.basename(sessionFile, ".jsonl")}.html`);
454
+ return exportFromFile(sessionFile, { outputPath });
455
+ }
456
+
457
+ function createShareLink(htmlPath: string): { shareUrl: string; gistUrl: string } | { error: string } {
458
+ try {
459
+ const auth = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" });
460
+ if (auth.status !== 0) {
461
+ return { error: "GitHub CLI is not logged in. Run 'gh auth login' first." };
462
+ }
463
+ } catch {
464
+ return { error: "GitHub CLI (gh) is not installed." };
465
+ }
466
+
467
+ try {
468
+ const result = spawnSync("gh", ["gist", "create", htmlPath], { encoding: "utf-8" });
469
+ if (result.status !== 0) {
470
+ const err = (result.stderr || "").trim() || "Failed to create gist.";
471
+ return { error: err };
472
+ }
473
+ const gistUrl = (result.stdout || "").trim();
474
+ const gistId = gistUrl.split("/").pop();
475
+ if (!gistId) return { error: "Failed to parse gist ID." };
476
+ const shareUrl = `https://shittycodingagent.ai/session/?${gistId}`;
477
+ return { shareUrl, gistUrl };
478
+ } catch (err) {
479
+ return { error: String(err) };
480
+ }
481
+ }
482
+
483
+ function formatDuration(ms: number): string {
484
+ if (ms < 1000) return `${ms}ms`;
485
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
486
+ const minutes = Math.floor(ms / 60000);
487
+ const seconds = Math.floor((ms % 60000) / 1000);
488
+ return `${minutes}m${seconds}s`;
489
+ }
490
+
491
+ function writeRunLog(
492
+ logPath: string,
493
+ input: {
494
+ id: string;
495
+ mode: SubagentRunMode;
496
+ cwd: string;
497
+ startedAt: number;
498
+ endedAt: number;
499
+ steps: Array<{
500
+ agent: string;
501
+ status: string;
502
+ durationMs?: number;
503
+ }>;
504
+ summary: string;
505
+ truncated: boolean;
506
+ artifactsDir?: string;
507
+ sessionFile?: string;
508
+ shareUrl?: string;
509
+ shareError?: string;
510
+ },
511
+ ): void {
512
+ const lines: string[] = [];
513
+ lines.push(`# Subagent run ${input.id}`);
514
+ lines.push("");
515
+ lines.push(`- **Mode:** ${input.mode}`);
516
+ lines.push(`- **CWD:** ${input.cwd}`);
517
+ lines.push(`- **Started:** ${new Date(input.startedAt).toISOString()}`);
518
+ lines.push(`- **Ended:** ${new Date(input.endedAt).toISOString()}`);
519
+ lines.push(`- **Duration:** ${formatDuration(input.endedAt - input.startedAt)}`);
520
+ if (input.sessionFile) lines.push(`- **Session:** ${input.sessionFile}`);
521
+ if (input.shareUrl) lines.push(`- **Share:** ${input.shareUrl}`);
522
+ if (input.shareError) lines.push(`- **Share error:** ${input.shareError}`);
523
+ if (input.artifactsDir) lines.push(`- **Artifacts:** ${input.artifactsDir}`);
524
+ lines.push("");
525
+ lines.push("## Steps");
526
+ lines.push("| Step | Agent | Status | Duration |");
527
+ lines.push("| --- | --- | --- | --- |");
528
+ input.steps.forEach((step, i) => {
529
+ const duration = step.durationMs !== undefined ? formatDuration(step.durationMs) : "-";
530
+ lines.push(`| ${i + 1} | ${step.agent} | ${step.status} | ${duration} |`);
531
+ });
532
+ lines.push("");
533
+ lines.push("## Summary");
534
+ if (input.truncated) {
535
+ lines.push("_Output truncated_");
536
+ lines.push("");
537
+ }
538
+ lines.push(input.summary.trim() || "(no output)");
539
+ lines.push("");
540
+ fs.writeFileSync(logPath, lines.join("\n"), "utf-8");
541
+ }
542
+
543
+ /** Context for running a single step */
544
+ interface SingleStepContext {
545
+ previousOutput: string;
546
+ placeholder: string;
547
+ cwd: string;
548
+ sessionEnabled: boolean;
549
+ sessionDir?: string;
550
+ artifactsDir?: string;
551
+ artifactConfig?: Partial<ArtifactConfig>;
552
+ id: string;
553
+ flatIndex: number;
554
+ flatStepCount: number;
555
+ outputFile: string;
556
+ piPackageRoot?: string;
557
+ piArgv1?: string;
558
+ registerInterrupt?: (interrupt: (() => void) | undefined) => void;
559
+ childIntercomTarget?: string;
560
+ orchestratorIntercomTarget?: string;
561
+ nestedRoute?: NestedRouteInfo;
562
+ onAttemptStart?: (attempt: { model?: string; thinking?: string }) => void;
563
+ onChildEvent?: (event: ChildEvent) => void;
564
+ }
565
+
566
+ /** Run a single pi agent step, returning output and metadata */
567
+ async function runSingleStep(
568
+ step: SubagentStep,
569
+ ctx: SingleStepContext,
570
+ ): Promise<{
571
+ agent: string;
572
+ output: string;
573
+ exitCode: number | null;
574
+ error?: string;
575
+ model?: string;
576
+ attemptedModels?: string[];
577
+ modelAttempts?: ModelAttempt[];
578
+ artifactPaths?: ArtifactPaths;
579
+ interrupted?: boolean;
580
+ sessionFile?: string;
581
+ intercomTarget?: string;
582
+ completionGuardTriggered?: boolean;
583
+ }> {
584
+ const placeholderRegex = new RegExp(ctx.placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g");
585
+ const task = step.task.replace(placeholderRegex, () => ctx.previousOutput);
586
+ const sessionEnabled = Boolean(step.sessionFile) || ctx.sessionEnabled;
587
+ const sessionDir = step.sessionFile ? undefined : ctx.sessionDir;
588
+
589
+ let artifactPaths: ArtifactPaths | undefined;
590
+ if (ctx.artifactsDir && ctx.artifactConfig?.enabled !== false) {
591
+ const index = ctx.flatStepCount > 1 ? ctx.flatIndex : undefined;
592
+ artifactPaths = getArtifactPaths(ctx.artifactsDir, ctx.id, step.agent, index);
593
+ fs.mkdirSync(ctx.artifactsDir, { recursive: true });
594
+ if (ctx.artifactConfig?.includeInput !== false) {
595
+ fs.writeFileSync(artifactPaths.inputPath, `# Task for ${step.agent}\n\n${task}`, "utf-8");
596
+ }
597
+ }
598
+
599
+ const candidates = step.modelCandidates && step.modelCandidates.length > 0
600
+ ? step.modelCandidates
601
+ : step.model
602
+ ? [step.model]
603
+ : [undefined];
604
+ const attemptedModels: string[] = [];
605
+ const modelAttempts: ModelAttempt[] = [];
606
+ const attemptNotes: string[] = [];
607
+ const eventsPath = path.join(path.dirname(ctx.outputFile), "events.jsonl");
608
+ let finalResult: RunPiStreamingResult | undefined;
609
+ let finalOutputSnapshot: SingleOutputSnapshot | undefined;
610
+ let completionGuardTriggeredFinal = false;
611
+
612
+ for (let index = 0; index < candidates.length; index++) {
613
+ const candidate = candidates[index];
614
+ ctx.onAttemptStart?.({ model: candidate, thinking: resolveEffectiveThinking(candidate, step.thinking) });
615
+ const outputSnapshot = captureSingleOutputSnapshot(step.outputPath);
616
+ const { args, env, tempDir } = buildPiArgs({
617
+ baseArgs: ["--mode", "json", "-p"],
618
+ task,
619
+ sessionEnabled,
620
+ sessionDir,
621
+ sessionFile: step.sessionFile,
622
+ model: candidate,
623
+ inheritProjectContext: step.inheritProjectContext,
624
+ inheritSkills: step.inheritSkills,
625
+ tools: step.tools,
626
+ extensions: step.extensions,
627
+ systemPrompt: step.systemPrompt,
628
+ systemPromptMode: step.systemPromptMode,
629
+ mcpDirectTools: step.mcpDirectTools,
630
+ cwd: step.cwd ?? ctx.cwd,
631
+ promptFileStem: step.agent,
632
+ intercomSessionName: ctx.childIntercomTarget,
633
+ orchestratorIntercomTarget: ctx.orchestratorIntercomTarget,
634
+ runId: ctx.id,
635
+ childAgentName: step.agent,
636
+ childIndex: ctx.flatIndex,
637
+ parentEventSink: ctx.nestedRoute?.eventSink,
638
+ parentControlInbox: ctx.nestedRoute?.controlInbox,
639
+ parentRootRunId: ctx.nestedRoute?.rootRunId,
640
+ parentCapabilityToken: ctx.nestedRoute?.capabilityToken,
641
+ });
642
+ const run = await runPiStreaming(
643
+ args,
644
+ step.cwd ?? ctx.cwd,
645
+ ctx.outputFile,
646
+ env,
647
+ ctx.piPackageRoot,
648
+ ctx.piArgv1,
649
+ step.maxSubagentDepth,
650
+ { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
651
+ ctx.registerInterrupt,
652
+ ctx.onChildEvent,
653
+ );
654
+ cleanupTempDir(tempDir);
655
+
656
+ const hiddenError = run.exitCode === 0 && !run.error ? detectSubagentError(run.messages) : null;
657
+ const completionGuard = run.exitCode === 0 && !run.error && !hiddenError?.hasError && step.completionGuard !== false
658
+ ? evaluateCompletionMutationGuard({
659
+ agent: step.agent,
660
+ task,
661
+ messages: run.messages,
662
+ tools: step.tools,
663
+ mcpDirectTools: step.mcpDirectTools,
664
+ })
665
+ : undefined;
666
+ const completionGuardTriggered = completionGuard?.triggered === true && !run.observedMutationAttempt;
667
+ const completionGuardError = completionGuardTriggered
668
+ ? "Subagent completed without making edits for an implementation task.\nIt appears to have returned planning or scratchpad output instead of applying changes."
669
+ : undefined;
670
+ const effectiveExitCode = completionGuardTriggered
671
+ ? 1
672
+ : hiddenError?.hasError
673
+ ? (hiddenError.exitCode ?? 1)
674
+ : run.error && run.exitCode === 0
675
+ ? 1
676
+ : run.exitCode;
677
+ const error = completionGuardError
678
+ ?? (hiddenError?.hasError
679
+ ? hiddenError.details
680
+ ? `${hiddenError.errorType} failed (exit ${effectiveExitCode}): ${hiddenError.details}`
681
+ : `${hiddenError.errorType} failed with exit code ${effectiveExitCode}`
682
+ : run.error || (run.exitCode !== 0 && run.stderr.trim() ? run.stderr.trim() : undefined));
683
+ const attempt: ModelAttempt = {
684
+ model: candidate ?? run.model ?? step.model ?? "default",
685
+ success: effectiveExitCode === 0 && !error,
686
+ exitCode: effectiveExitCode,
687
+ error,
688
+ usage: run.usage,
689
+ };
690
+ modelAttempts.push(attempt);
691
+ if (candidate) attemptedModels.push(candidate);
692
+ completionGuardTriggeredFinal = completionGuardTriggered;
693
+ finalOutputSnapshot = outputSnapshot;
694
+ finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error };
695
+ if (attempt.success || completionGuardTriggered) break;
696
+ if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
697
+ attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
698
+ }
699
+
700
+ const rawOutput = finalResult?.finalOutput ?? "";
701
+ const resolvedOutput = step.outputPath && finalResult?.exitCode === 0
702
+ ? resolveSingleOutput(step.outputPath, rawOutput, finalOutputSnapshot)
703
+ : { fullOutput: rawOutput };
704
+ const output = resolvedOutput.fullOutput;
705
+ const outputReference = resolvedOutput.savedPath ? formatSavedOutputReference(resolvedOutput.savedPath, output) : undefined;
706
+ let outputForSummary = output;
707
+ if (attemptNotes.length > 0) {
708
+ outputForSummary = `${attemptNotes.join("\n")}\n\n${outputForSummary}`.trim();
709
+ }
710
+ const finalizedOutput = finalizeSingleOutput({
711
+ fullOutput: outputForSummary,
712
+ outputPath: step.outputPath,
713
+ outputMode: step.outputMode,
714
+ exitCode: finalResult?.exitCode ?? 1,
715
+ savedPath: resolvedOutput.savedPath,
716
+ outputReference,
717
+ saveError: resolvedOutput.saveError,
718
+ });
719
+ outputForSummary = finalizedOutput.displayOutput;
720
+
721
+ if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
722
+ if (ctx.artifactConfig?.includeOutput !== false) {
723
+ fs.writeFileSync(artifactPaths.outputPath, output, "utf-8");
724
+ }
725
+ if (ctx.artifactConfig?.includeMetadata !== false) {
726
+ fs.writeFileSync(
727
+ artifactPaths.metadataPath,
728
+ JSON.stringify({
729
+ runId: ctx.id,
730
+ agent: step.agent,
731
+ task,
732
+ exitCode: finalResult?.exitCode,
733
+ model: finalResult?.model,
734
+ attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
735
+ modelAttempts,
736
+ skills: step.skills,
737
+ timestamp: Date.now(),
738
+ }, null, 2),
739
+ "utf-8",
740
+ );
741
+ }
742
+ }
743
+
744
+ return {
745
+ agent: step.agent,
746
+ output: outputForSummary,
747
+ exitCode: finalResult?.exitCode ?? 1,
748
+ error: finalResult?.error,
749
+ sessionFile: step.sessionFile,
750
+ intercomTarget: ctx.childIntercomTarget,
751
+ model: finalResult?.model,
752
+ attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
753
+ modelAttempts,
754
+ artifactPaths,
755
+ interrupted: finalResult?.interrupted,
756
+ completionGuardTriggered: completionGuardTriggeredFinal,
757
+ };
758
+ }
759
+
760
+ type RunnerStatusStep = NonNullable<AsyncStatus["steps"]>[number] & {
761
+ exitCode?: number | null;
762
+ };
763
+
764
+ type RunnerStatusPayload = Omit<AsyncStatus, "steps" | "parallelGroups" | "pid" | "cwd" | "currentStep" | "chainStepCount" | "lastUpdate"> & {
765
+ pid: number;
766
+ cwd: string;
767
+ currentStep: number;
768
+ chainStepCount: number;
769
+ parallelGroups: AsyncParallelGroupStatus[];
770
+ steps: RunnerStatusStep[];
771
+ lastUpdate: number;
772
+ artifactsDir?: string;
773
+ shareUrl?: string;
774
+ gistUrl?: string;
775
+ shareError?: string;
776
+ error?: string;
777
+ };
778
+
779
+ function markParallelGroupSetupFailure(input: {
780
+ statusPayload: RunnerStatusPayload;
781
+ results: StepResult[];
782
+ group: Extract<RunnerStep, { parallel: SubagentStep[] }>;
783
+ groupStartFlatIndex: number;
784
+ setupError: string;
785
+ failedAt: number;
786
+ statusPath: string;
787
+ eventsPath: string;
788
+ asyncDir: string;
789
+ runId: string;
790
+ stepIndex: number;
791
+ }): void {
792
+ for (let taskIndex = 0; taskIndex < input.group.parallel.length; taskIndex++) {
793
+ const flatTaskIndex = input.groupStartFlatIndex + taskIndex;
794
+ input.statusPayload.steps[flatTaskIndex].status = "failed";
795
+ input.statusPayload.steps[flatTaskIndex].startedAt = input.failedAt;
796
+ input.statusPayload.steps[flatTaskIndex].endedAt = input.failedAt;
797
+ input.statusPayload.steps[flatTaskIndex].durationMs = 0;
798
+ input.statusPayload.steps[flatTaskIndex].exitCode = 1;
799
+ input.results.push({ agent: input.group.parallel[taskIndex].agent, output: input.setupError, success: false, sessionFile: input.group.parallel[taskIndex].sessionFile });
800
+ }
801
+ input.statusPayload.currentStep = input.groupStartFlatIndex;
802
+ input.statusPayload.lastUpdate = input.failedAt;
803
+ input.statusPayload.outputFile = path.join(input.asyncDir, `output-${input.groupStartFlatIndex}.log`);
804
+ writeAtomicJson(input.statusPath, input.statusPayload);
805
+ appendJsonl(input.eventsPath, JSON.stringify({
806
+ type: "subagent.parallel.completed",
807
+ ts: input.failedAt,
808
+ runId: input.runId,
809
+ stepIndex: input.stepIndex,
810
+ success: false,
811
+ }));
812
+ }
813
+
814
+ function markParallelGroupRunning(input: {
815
+ statusPayload: RunnerStatusPayload;
816
+ group: Extract<RunnerStep, { parallel: SubagentStep[] }>;
817
+ groupStartFlatIndex: number;
818
+ groupStartTime: number;
819
+ statusPath: string;
820
+ eventsPath: string;
821
+ asyncDir: string;
822
+ runId: string;
823
+ stepIndex: number;
824
+ }): void {
825
+ for (let taskIndex = 0; taskIndex < input.group.parallel.length; taskIndex++) {
826
+ const flatTaskIndex = input.groupStartFlatIndex + taskIndex;
827
+ input.statusPayload.steps[flatTaskIndex].status = "pending";
828
+ input.statusPayload.steps[flatTaskIndex].startedAt = undefined;
829
+ input.statusPayload.steps[flatTaskIndex].endedAt = undefined;
830
+ input.statusPayload.steps[flatTaskIndex].durationMs = undefined;
831
+ input.statusPayload.steps[flatTaskIndex].lastActivityAt = undefined;
832
+ input.statusPayload.steps[flatTaskIndex].activityState = undefined;
833
+ input.statusPayload.steps[flatTaskIndex].error = undefined;
834
+ }
835
+ input.statusPayload.currentStep = input.groupStartFlatIndex;
836
+ input.statusPayload.activityState = undefined;
837
+ input.statusPayload.lastActivityAt = input.groupStartTime;
838
+ input.statusPayload.lastUpdate = input.groupStartTime;
839
+ input.statusPayload.outputFile = path.join(input.asyncDir, `output-${input.groupStartFlatIndex}.log`);
840
+ writeAtomicJson(input.statusPath, input.statusPayload);
841
+ appendJsonl(input.eventsPath, JSON.stringify({
842
+ type: "subagent.parallel.started",
843
+ ts: input.groupStartTime,
844
+ runId: input.runId,
845
+ stepIndex: input.stepIndex,
846
+ agents: input.group.parallel.map((task) => task.agent),
847
+ count: input.group.parallel.length,
848
+ }));
849
+ }
850
+
851
+ function prepareParallelTaskRun(
852
+ task: SubagentStep,
853
+ cwd: string,
854
+ worktreeSetup: WorktreeSetup | undefined,
855
+ taskIndex: number,
856
+ ): { taskForRun: SubagentStep; taskCwd: string } {
857
+ if (!worktreeSetup) return { taskForRun: task, taskCwd: cwd };
858
+ return {
859
+ taskForRun: { ...task, cwd: undefined },
860
+ taskCwd: worktreeSetup.worktrees[taskIndex]!.agentCwd,
861
+ };
862
+ }
863
+
864
+ function appendParallelWorktreeSummary(
865
+ previousOutput: string,
866
+ worktreeSetup: WorktreeSetup | undefined,
867
+ asyncDir: string,
868
+ stepIndex: number,
869
+ group: Extract<RunnerStep, { parallel: SubagentStep[] }>,
870
+ ): string {
871
+ if (!worktreeSetup) return previousOutput;
872
+ const diffsDir = path.join(asyncDir, "worktree-diffs", `step-${stepIndex}`);
873
+ const diffs = diffWorktrees(worktreeSetup, group.parallel.map((task) => task.agent), diffsDir);
874
+ const diffSummary = formatWorktreeDiffSummary(diffs);
875
+ if (!diffSummary) return previousOutput;
876
+ return `${previousOutput}\n\n${diffSummary}`;
877
+ }
878
+
879
+ function ensureParallelProgressFile(cwd: string, group: Extract<RunnerStep, { parallel: SubagentStep[] }>): void {
880
+ const progressPath = path.join(cwd, "progress.md");
881
+ if (!group.parallel.some((task) => task.task.includes(`Update progress at: ${progressPath}`))) return;
882
+ writeInitialProgressFile(cwd);
883
+ }
884
+
885
+ async function runSubagent(config: SubagentRunConfig): Promise<void> {
886
+ const { id, steps, resultPath, cwd, placeholder, taskIndex, totalTasks, maxOutput, artifactsDir, artifactConfig } =
887
+ config;
888
+ let previousOutput = "";
889
+ const results: StepResult[] = [];
890
+ const overallStartTime = Date.now();
891
+ const shareEnabled = config.share === true;
892
+ const asyncDir = config.asyncDir;
893
+ const statusPath = path.join(asyncDir, "status.json");
894
+ const eventsPath = path.join(asyncDir, "events.jsonl");
895
+ const logPath = path.join(asyncDir, `subagent-log-${id}.md`);
896
+ const controlConfig = config.controlConfig ?? DEFAULT_CONTROL_CONFIG;
897
+ let activeChildInterrupt: (() => void) | undefined;
898
+ let interrupted = false;
899
+ let currentActivityState: ActivityState | undefined;
900
+ let activityTimer: NodeJS.Timeout | undefined;
901
+ let previousCumulativeTokens: TokenUsage = { input: 0, output: 0, total: 0 };
902
+ let latestSessionFile: string | undefined;
903
+
904
+ const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
905
+ let flatStepCount = 0;
906
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
907
+ const step = steps[stepIndex]!;
908
+ if (isParallelGroup(step)) {
909
+ parallelGroups.push({ start: flatStepCount, count: step.parallel.length, stepIndex });
910
+ flatStepCount += step.parallel.length;
911
+ } else {
912
+ flatStepCount++;
913
+ }
914
+ }
915
+ const flatSteps = flattenSteps(steps);
916
+ const sessionEnabled = Boolean(config.sessionDir)
917
+ || shareEnabled
918
+ || flatSteps.some((step) => Boolean(step.sessionFile));
919
+ const statusPayload: RunnerStatusPayload = {
920
+ runId: id,
921
+ ...(config.sessionId ? { sessionId: config.sessionId } : {}),
922
+ mode: config.resultMode ?? (flatSteps.length > 1 ? "chain" : "single"),
923
+ state: "running",
924
+ lastActivityAt: overallStartTime,
925
+ startedAt: overallStartTime,
926
+ lastUpdate: overallStartTime,
927
+ pid: process.pid,
928
+ cwd,
929
+ currentStep: 0,
930
+ chainStepCount: steps.length,
931
+ parallelGroups,
932
+ steps: flatSteps.map((step) => ({
933
+ agent: step.agent,
934
+ status: "pending",
935
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
936
+ skills: step.skills,
937
+ model: step.model,
938
+ thinking: step.thinking,
939
+ attemptedModels: step.modelCandidates && step.modelCandidates.length > 0 ? step.modelCandidates : step.model ? [step.model] : undefined,
940
+ recentTools: [],
941
+ recentOutput: [],
942
+ })),
943
+ artifactsDir,
944
+ sessionDir: config.sessionDir,
945
+ outputFile: path.join(asyncDir, "output-0.log"),
946
+ };
947
+
948
+ fs.mkdirSync(asyncDir, { recursive: true });
949
+ writeAtomicJson(statusPath, statusPayload);
950
+ const emitNestedSelfEvent = (type: "subagent.nested.updated" | "subagent.nested.completed"): void => {
951
+ if (!config.nestedRoute || !config.nestedSelf) return;
952
+ try {
953
+ writeNestedEvent(config.nestedRoute, {
954
+ type,
955
+ ts: Date.now(),
956
+ parentRunId: config.nestedSelf.parentRunId,
957
+ parentStepIndex: config.nestedSelf.parentStepIndex,
958
+ child: nestedSummaryFromAsyncStatus(statusPayload, asyncDir, {
959
+ id,
960
+ parentRunId: config.nestedSelf.parentRunId,
961
+ parentStepIndex: config.nestedSelf.parentStepIndex,
962
+ depth: config.nestedSelf.depth,
963
+ path: config.nestedSelf.path,
964
+ mode: statusPayload.mode,
965
+ ts: Date.now(),
966
+ }),
967
+ });
968
+ } catch (error) {
969
+ console.error("Failed to emit nested async status event:", error);
970
+ }
971
+ };
972
+ const writeStatusPayload = (): void => {
973
+ writeAtomicJson(statusPath, statusPayload);
974
+ emitNestedSelfEvent(statusPayload.state === "running" || statusPayload.state === "queued" ? "subagent.nested.updated" : "subagent.nested.completed");
975
+ };
976
+
977
+ const stepOutputActivityAt = (index: number): number => {
978
+ const step = statusPayload.steps[index];
979
+ let lastActivityAt = step?.lastActivityAt ?? step?.startedAt ?? overallStartTime;
980
+ const outputPath = path.join(asyncDir, `output-${index}.log`);
981
+ try {
982
+ lastActivityAt = Math.max(lastActivityAt, fs.statSync(outputPath).mtimeMs);
983
+ } catch (error) {
984
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
985
+ console.error(`Failed to inspect async output file '${outputPath}':`, error);
986
+ }
987
+ }
988
+ return lastActivityAt;
989
+ };
990
+ const emittedControlEventKeys = new Set<string>();
991
+ const activeLongRunningSteps = new Set<number>();
992
+ const mutatingFailureStates = flatSteps.map(() => createMutatingFailureState());
993
+ const pendingToolResults: Array<{ tool: string; path?: string; mutates: boolean; startedAt?: number } | undefined> = [];
994
+ const mutatingFailureWindowMs = 5 * 60_000;
995
+ const appendControlEvent = (event: ReturnType<typeof buildControlEvent>) => {
996
+ if (!controlConfig.enabled) return;
997
+ const childIntercomTarget = config.childIntercomTargets?.[event.index ?? statusPayload.currentStep];
998
+ const channels = event.type === "active_long_running"
999
+ ? controlConfig.notifyChannels.filter((channel) => channel !== "intercom")
1000
+ : controlConfig.notifyChannels;
1001
+ if (channels.length === 0 || !claimControlNotification(controlConfig, event, emittedControlEventKeys, childIntercomTarget)) return;
1002
+ appendJsonl(eventsPath, JSON.stringify({
1003
+ type: "subagent.control",
1004
+ event,
1005
+ channels,
1006
+ childIntercomTarget,
1007
+ noticeText: formatControlNoticeMessage(event, childIntercomTarget),
1008
+ ...(config.controlIntercomTarget && channels.includes("intercom") ? {
1009
+ intercom: {
1010
+ to: config.controlIntercomTarget,
1011
+ message: formatControlIntercomMessage(event, childIntercomTarget),
1012
+ },
1013
+ } : {}),
1014
+ }));
1015
+ };
1016
+ const syncTopLevelCurrentTool = (): void => {
1017
+ const activeStep = statusPayload.steps
1018
+ .filter((step) => step.status === "running" && typeof step.currentTool === "string" && step.currentTool.length > 0)
1019
+ .sort((left, right) => (right.currentToolStartedAt ?? 0) - (left.currentToolStartedAt ?? 0))[0];
1020
+ statusPayload.currentTool = activeStep?.currentTool;
1021
+ statusPayload.currentToolStartedAt = activeStep?.currentToolStartedAt;
1022
+ statusPayload.currentPath = activeStep?.currentPath;
1023
+ };
1024
+ const maybeEmitActiveLongRunning = (flatIndex: number, now: number): boolean => {
1025
+ if (!controlConfig.enabled || activeLongRunningSteps.has(flatIndex)) return false;
1026
+ const step = statusPayload.steps[flatIndex];
1027
+ if (!step || step.status !== "running" || step.activityState === "needs_attention") return false;
1028
+ const reason = nextLongRunningTrigger(controlConfig, {
1029
+ startedAt: step.startedAt ?? overallStartTime,
1030
+ now,
1031
+ turns: step.turnCount ?? 0,
1032
+ tokens: step.tokens?.total ?? 0,
1033
+ });
1034
+ if (!reason) return false;
1035
+ activeLongRunningSteps.add(flatIndex);
1036
+ const previous = step.activityState;
1037
+ step.activityState = "active_long_running";
1038
+ statusPayload.activityState = statusPayload.activityState === "needs_attention" ? "needs_attention" : "active_long_running";
1039
+ const event = buildControlEvent({
1040
+ type: "active_long_running",
1041
+ from: previous,
1042
+ to: "active_long_running",
1043
+ runId: id,
1044
+ agent: step.agent,
1045
+ index: flatIndex,
1046
+ ts: now,
1047
+ message: `${step.agent} is still active but long-running`,
1048
+ reason,
1049
+ turns: step.turnCount,
1050
+ tokens: step.tokens?.total,
1051
+ toolCount: step.toolCount,
1052
+ currentTool: step.currentTool,
1053
+ currentToolDurationMs: step.currentToolStartedAt ? Math.max(0, now - step.currentToolStartedAt) : undefined,
1054
+ currentPath: step.currentPath,
1055
+ elapsedMs: now - (step.startedAt ?? overallStartTime),
1056
+ });
1057
+ appendControlEvent(event);
1058
+ return true;
1059
+ };
1060
+ const updateStepModel = (flatIndex: number, model: string | undefined, thinking: string | undefined, now = Date.now()): void => {
1061
+ const step = statusPayload.steps[flatIndex];
1062
+ if (!step) return;
1063
+ step.model = model;
1064
+ step.thinking = thinking;
1065
+ statusPayload.lastUpdate = now;
1066
+ writeStatusPayload();
1067
+ };
1068
+ const updateStepFromChildEvent = (flatIndex: number, event: ChildEvent): void => {
1069
+ const step = statusPayload.steps[flatIndex];
1070
+ if (!step) return;
1071
+ const now = Date.now();
1072
+ statusPayload.currentStep = flatIndex;
1073
+ if (event.type === "tool_execution_start" && event.toolName) {
1074
+ const mutates = isMutatingTool(event.toolName, event.args);
1075
+ const currentPath = resolveCurrentPath(event.toolName, event.args);
1076
+ step.toolCount = (step.toolCount ?? 0) + 1;
1077
+ step.currentTool = event.toolName;
1078
+ step.currentToolArgs = extractToolArgsPreview(event.args ?? {});
1079
+ step.currentToolStartedAt = now;
1080
+ step.currentPath = currentPath;
1081
+ pendingToolResults[flatIndex] = { tool: event.toolName, path: currentPath, mutates, startedAt: now };
1082
+ statusPayload.toolCount = (statusPayload.toolCount ?? 0) + 1;
1083
+ syncTopLevelCurrentTool();
1084
+ } else if (event.type === "tool_execution_end") {
1085
+ if (step.currentTool) {
1086
+ step.recentTools ??= [];
1087
+ step.recentTools.push({ tool: step.currentTool, args: step.currentToolArgs || "", endMs: now });
1088
+ }
1089
+ step.currentTool = undefined;
1090
+ step.currentToolArgs = undefined;
1091
+ step.currentToolStartedAt = undefined;
1092
+ step.currentPath = undefined;
1093
+ syncTopLevelCurrentTool();
1094
+ } else if (event.type === "tool_result_end" && event.message) {
1095
+ const toolSnapshot = pendingToolResults[flatIndex];
1096
+ pendingToolResults[flatIndex] = undefined;
1097
+ const resultText = extractTextFromContent(event.message.content);
1098
+ appendRecentStepOutput(step, resultText.split("\n").slice(-10));
1099
+ if (toolSnapshot?.mutates && didMutatingToolFail(resultText)) {
1100
+ const state = mutatingFailureStates[flatIndex]!;
1101
+ recordMutatingFailure(state, {
1102
+ tool: toolSnapshot.tool,
1103
+ path: toolSnapshot.path,
1104
+ error: resultText.split("\n").find((line) => line.trim())?.trim().slice(0, 180) ?? "mutating tool failed",
1105
+ ts: now,
1106
+ }, mutatingFailureWindowMs);
1107
+ if (controlConfig.enabled && shouldEscalateMutatingFailures(state, controlConfig.failedToolAttemptsBeforeAttention) && step.activityState !== "needs_attention") {
1108
+ const previous = step.activityState;
1109
+ step.activityState = "needs_attention";
1110
+ statusPayload.activityState = "needs_attention";
1111
+ appendControlEvent(buildControlEvent({
1112
+ type: "needs_attention",
1113
+ from: previous,
1114
+ to: "needs_attention",
1115
+ runId: id,
1116
+ agent: step.agent,
1117
+ index: flatIndex,
1118
+ ts: now,
1119
+ message: `${step.agent} needs attention after repeated mutating tool failures`,
1120
+ reason: "tool_failures",
1121
+ turns: step.turnCount,
1122
+ tokens: step.tokens?.total,
1123
+ toolCount: step.toolCount,
1124
+ currentTool: toolSnapshot.tool,
1125
+ currentToolDurationMs: toolSnapshot.startedAt ? Math.max(0, now - toolSnapshot.startedAt) : undefined,
1126
+ currentPath: toolSnapshot.path,
1127
+ recentFailureSummary: summarizeRecentMutatingFailures(state),
1128
+ }));
1129
+ }
1130
+ } else if (toolSnapshot?.mutates) {
1131
+ resetMutatingFailureState(mutatingFailureStates[flatIndex]!);
1132
+ }
1133
+ } else if (event.type === "message_end" && event.message?.role === "assistant") {
1134
+ appendRecentStepOutput(step, extractTextFromContent(event.message.content).split("\n").slice(-10));
1135
+ step.turnCount = (step.turnCount ?? 0) + 1;
1136
+ const usage = event.message.usage;
1137
+ if (usage) {
1138
+ const input = usage.input ?? usage.inputTokens ?? 0;
1139
+ const output = usage.output ?? usage.outputTokens ?? 0;
1140
+ const previousInput = step.tokens?.input ?? 0;
1141
+ const previousOutput = step.tokens?.output ?? 0;
1142
+ step.tokens = { input: previousInput + input, output: previousOutput + output, total: previousInput + previousOutput + input + output };
1143
+ const totalInput = statusPayload.totalTokens?.input ?? 0;
1144
+ const totalOutput = statusPayload.totalTokens?.output ?? 0;
1145
+ statusPayload.totalTokens = { input: totalInput + input, output: totalOutput + output, total: totalInput + totalOutput + input + output };
1146
+ }
1147
+ statusPayload.turnCount = Math.max(statusPayload.turnCount ?? 0, step.turnCount);
1148
+ }
1149
+ syncTopLevelCurrentTool();
1150
+ step.lastActivityAt = now;
1151
+ statusPayload.lastActivityAt = now;
1152
+ statusPayload.lastUpdate = now;
1153
+ maybeEmitActiveLongRunning(flatIndex, now);
1154
+ writeStatusPayload();
1155
+ };
1156
+ const updateRunnerActivityState = (now: number): boolean => {
1157
+ if (!controlConfig.enabled) return false;
1158
+ let changed = false;
1159
+ let runLastActivityAt = statusPayload.lastActivityAt ?? overallStartTime;
1160
+ for (let index = 0; index < statusPayload.steps.length; index++) {
1161
+ const step = statusPayload.steps[index]!;
1162
+ if (step.status !== "running") continue;
1163
+ const lastActivityAt = stepOutputActivityAt(index);
1164
+ runLastActivityAt = Math.max(runLastActivityAt, lastActivityAt);
1165
+ if (step.lastActivityAt !== lastActivityAt) {
1166
+ step.lastActivityAt = lastActivityAt;
1167
+ changed = true;
1168
+ }
1169
+ const idleState = deriveActivityState({
1170
+ config: controlConfig,
1171
+ startedAt: step.startedAt ?? overallStartTime,
1172
+ lastActivityAt,
1173
+ now,
1174
+ });
1175
+ if (idleState === "needs_attention") {
1176
+ const previous = step.activityState;
1177
+ step.activityState = "needs_attention";
1178
+ if (previous !== "needs_attention") {
1179
+ appendControlEvent(buildControlEvent({
1180
+ from: previous,
1181
+ to: "needs_attention",
1182
+ runId: id,
1183
+ agent: step.agent,
1184
+ index,
1185
+ ts: now,
1186
+ lastActivityAt,
1187
+ }));
1188
+ changed = true;
1189
+ }
1190
+ } else if (maybeEmitActiveLongRunning(index, now)) {
1191
+ changed = true;
1192
+ }
1193
+ }
1194
+ if (statusPayload.lastActivityAt !== runLastActivityAt) {
1195
+ statusPayload.lastActivityAt = runLastActivityAt;
1196
+ changed = true;
1197
+ }
1198
+ const nextRunState = statusPayload.steps.some((step) => step.activityState === "needs_attention")
1199
+ ? "needs_attention"
1200
+ : statusPayload.steps.some((step) => step.activityState === "active_long_running")
1201
+ ? "active_long_running"
1202
+ : undefined;
1203
+ if (nextRunState !== currentActivityState) {
1204
+ currentActivityState = nextRunState;
1205
+ statusPayload.activityState = nextRunState;
1206
+ changed = true;
1207
+ }
1208
+ statusPayload.lastUpdate = now;
1209
+ if (changed) writeStatusPayload();
1210
+ return changed;
1211
+ };
1212
+ if (controlConfig.enabled) {
1213
+ activityTimer = setInterval(() => {
1214
+ if (statusPayload.state !== "running") return;
1215
+ const now = Date.now();
1216
+ updateRunnerActivityState(now);
1217
+ }, 1000);
1218
+ activityTimer.unref?.();
1219
+ }
1220
+
1221
+ const interruptRunner = () => {
1222
+ if (interrupted || statusPayload.state !== "running") return;
1223
+ interrupted = true;
1224
+ const now = Date.now();
1225
+ statusPayload.state = "paused";
1226
+ currentActivityState = undefined;
1227
+ statusPayload.activityState = undefined;
1228
+ statusPayload.lastUpdate = now;
1229
+ for (const step of statusPayload.steps) {
1230
+ if (step.status === "running") {
1231
+ step.status = "paused";
1232
+ step.activityState = undefined;
1233
+ step.endedAt = now;
1234
+ step.durationMs = step.startedAt ? now - step.startedAt : undefined;
1235
+ step.lastActivityAt = now;
1236
+ }
1237
+ }
1238
+ writeStatusPayload();
1239
+ appendJsonl(eventsPath, JSON.stringify({
1240
+ type: "subagent.run.paused",
1241
+ ts: now,
1242
+ runId: id,
1243
+ }));
1244
+ activeChildInterrupt?.();
1245
+ };
1246
+ process.on(ASYNC_INTERRUPT_SIGNAL, interruptRunner);
1247
+ appendJsonl(
1248
+ eventsPath,
1249
+ JSON.stringify({
1250
+ type: "subagent.run.started",
1251
+ ts: overallStartTime,
1252
+ runId: id,
1253
+ mode: statusPayload.mode,
1254
+ cwd,
1255
+ pid: process.pid,
1256
+ }),
1257
+ );
1258
+
1259
+ let flatIndex = 0;
1260
+
1261
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
1262
+ if (interrupted) break;
1263
+ const step = steps[stepIndex];
1264
+
1265
+ if (isParallelGroup(step)) {
1266
+ const group = step;
1267
+ const concurrency = group.concurrency ?? MAX_PARALLEL_CONCURRENCY;
1268
+ const failFast = group.failFast ?? false;
1269
+ const groupStartFlatIndex = flatIndex;
1270
+ let aborted = false;
1271
+ let worktreeSetup: WorktreeSetup | undefined;
1272
+ if (group.worktree) {
1273
+ const worktreeTaskCwdConflict = findWorktreeTaskCwdConflict(group.parallel, cwd);
1274
+ if (worktreeTaskCwdConflict) {
1275
+ const failedAt = Date.now();
1276
+ markParallelGroupSetupFailure({
1277
+ statusPayload,
1278
+ results,
1279
+ group,
1280
+ groupStartFlatIndex,
1281
+ setupError: formatWorktreeTaskCwdConflict(worktreeTaskCwdConflict, cwd),
1282
+ failedAt,
1283
+ statusPath,
1284
+ eventsPath,
1285
+ asyncDir,
1286
+ runId: id,
1287
+ stepIndex,
1288
+ });
1289
+ flatIndex += group.parallel.length;
1290
+ break;
1291
+ }
1292
+ try {
1293
+ worktreeSetup = createWorktrees(cwd, `${id}-s${stepIndex}`, group.parallel.length, {
1294
+ agents: group.parallel.map((task) => task.agent),
1295
+ setupHook: config.worktreeSetupHook
1296
+ ? { hookPath: config.worktreeSetupHook, timeoutMs: config.worktreeSetupHookTimeoutMs }
1297
+ : undefined,
1298
+ });
1299
+ } catch (error) {
1300
+ const setupError = error instanceof Error ? error.message : String(error);
1301
+ const failedAt = Date.now();
1302
+ markParallelGroupSetupFailure({
1303
+ statusPayload,
1304
+ results,
1305
+ group,
1306
+ groupStartFlatIndex,
1307
+ setupError,
1308
+ failedAt,
1309
+ statusPath,
1310
+ eventsPath,
1311
+ asyncDir,
1312
+ runId: id,
1313
+ stepIndex,
1314
+ });
1315
+ flatIndex += group.parallel.length;
1316
+ break;
1317
+ }
1318
+ }
1319
+
1320
+ try {
1321
+ if (group.worktree) ensureParallelProgressFile(cwd, group);
1322
+ const groupStartTime = Date.now();
1323
+ markParallelGroupRunning({
1324
+ statusPayload,
1325
+ group,
1326
+ groupStartFlatIndex,
1327
+ groupStartTime,
1328
+ statusPath,
1329
+ eventsPath,
1330
+ asyncDir,
1331
+ runId: id,
1332
+ stepIndex,
1333
+ });
1334
+ const parallelResults = await mapConcurrent(
1335
+ group.parallel,
1336
+ concurrency,
1337
+ async (task, taskIdx) => {
1338
+ const fi = groupStartFlatIndex + taskIdx;
1339
+ if (aborted && failFast) {
1340
+ const skippedAt = Date.now();
1341
+ statusPayload.steps[fi].status = "failed";
1342
+ statusPayload.steps[fi].error = "Skipped due to fail-fast";
1343
+ statusPayload.steps[fi].startedAt = skippedAt;
1344
+ statusPayload.steps[fi].endedAt = skippedAt;
1345
+ statusPayload.steps[fi].durationMs = 0;
1346
+ statusPayload.steps[fi].exitCode = -1;
1347
+ statusPayload.steps[fi].activityState = undefined;
1348
+ statusPayload.lastUpdate = skippedAt;
1349
+ writeStatusPayload();
1350
+ appendJsonl(eventsPath, JSON.stringify({
1351
+ type: "subagent.step.failed", ts: skippedAt, runId: id, stepIndex: fi, agent: task.agent, exitCode: -1, durationMs: 0,
1352
+ }));
1353
+ return { agent: task.agent, output: "(skipped — fail-fast)", exitCode: -1 as number | null, skipped: true };
1354
+ }
1355
+
1356
+ const taskStartTime = Date.now();
1357
+ statusPayload.currentStep = fi;
1358
+ statusPayload.steps[fi].status = "running";
1359
+ statusPayload.steps[fi].error = undefined;
1360
+ statusPayload.steps[fi].activityState = undefined;
1361
+ resetStepLiveDetail(statusPayload.steps[fi]);
1362
+ statusPayload.steps[fi].startedAt = taskStartTime;
1363
+ statusPayload.steps[fi].endedAt = undefined;
1364
+ statusPayload.steps[fi].durationMs = undefined;
1365
+ statusPayload.steps[fi].lastActivityAt = taskStartTime;
1366
+ statusPayload.outputFile = path.join(asyncDir, `output-${fi}.log`);
1367
+ statusPayload.lastActivityAt = taskStartTime;
1368
+ statusPayload.lastUpdate = taskStartTime;
1369
+ writeStatusPayload();
1370
+
1371
+ appendJsonl(eventsPath, JSON.stringify({
1372
+ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent,
1373
+ }));
1374
+
1375
+ const taskSessionDir = config.sessionDir
1376
+ ? path.join(config.sessionDir, `parallel-${taskIdx}`)
1377
+ : undefined;
1378
+ const { taskForRun, taskCwd } = prepareParallelTaskRun(task, cwd, worktreeSetup, taskIdx);
1379
+
1380
+ const singleResult = await runSingleStep(taskForRun, {
1381
+ previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
1382
+ sessionDir: taskSessionDir,
1383
+ artifactsDir, artifactConfig, id,
1384
+ flatIndex: fi, flatStepCount: flatSteps.length,
1385
+ outputFile: path.join(asyncDir, `output-${fi}.log`),
1386
+ piPackageRoot: config.piPackageRoot,
1387
+ piArgv1: config.piArgv1,
1388
+ childIntercomTarget: config.childIntercomTargets?.[fi],
1389
+ orchestratorIntercomTarget: config.controlIntercomTarget,
1390
+ nestedRoute: config.nestedRoute,
1391
+ registerInterrupt: (interrupt) => {
1392
+ activeChildInterrupt = interrupt;
1393
+ },
1394
+ onAttemptStart: (attempt) => updateStepModel(fi, attempt.model, attempt.thinking),
1395
+ onChildEvent: (event) => updateStepFromChildEvent(fi, event),
1396
+ });
1397
+ if (task.sessionFile) {
1398
+ latestSessionFile = task.sessionFile;
1399
+ }
1400
+
1401
+ const taskEndTime = Date.now();
1402
+ const taskDuration = taskEndTime - taskStartTime;
1403
+
1404
+ statusPayload.steps[fi].status = singleResult.exitCode === 0 ? "complete" : "failed";
1405
+ statusPayload.steps[fi].endedAt = taskEndTime;
1406
+ statusPayload.steps[fi].durationMs = taskDuration;
1407
+ statusPayload.steps[fi].exitCode = singleResult.exitCode;
1408
+ statusPayload.steps[fi].model = singleResult.model;
1409
+ statusPayload.steps[fi].thinking = resolveEffectiveThinking(singleResult.model, statusPayload.steps[fi].thinking);
1410
+ statusPayload.steps[fi].attemptedModels = singleResult.attemptedModels;
1411
+ statusPayload.steps[fi].modelAttempts = singleResult.modelAttempts;
1412
+ statusPayload.steps[fi].error = singleResult.error;
1413
+ statusPayload.lastUpdate = taskEndTime;
1414
+ writeStatusPayload();
1415
+
1416
+ appendJsonl(eventsPath, JSON.stringify({
1417
+ type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1418
+ ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
1419
+ exitCode: singleResult.exitCode, durationMs: taskDuration,
1420
+ }));
1421
+ if (singleResult.completionGuardTriggered) {
1422
+ const event = buildControlEvent({
1423
+ from: statusPayload.steps[fi].activityState,
1424
+ to: "needs_attention",
1425
+ runId: id,
1426
+ agent: task.agent,
1427
+ index: fi,
1428
+ ts: taskEndTime,
1429
+ message: `${task.agent} completed without making edits for an implementation task`,
1430
+ reason: "completion_guard",
1431
+ });
1432
+ appendControlEvent(event);
1433
+ }
1434
+
1435
+ if (singleResult.exitCode !== 0 && failFast) aborted = true;
1436
+ return { ...singleResult, skipped: false };
1437
+ },
1438
+ );
1439
+
1440
+ flatIndex += group.parallel.length;
1441
+
1442
+ for (let t = 0; t < group.parallel.length; t++) {
1443
+ const fi = groupStartFlatIndex + t;
1444
+ const sessionTokens = config.sessionDir
1445
+ ? parseSessionTokens(path.join(config.sessionDir, `parallel-${t}`))
1446
+ : null;
1447
+ const taskTokens = sessionTokens ?? tokenUsageFromAttempts(parallelResults[t]?.modelAttempts);
1448
+ if (!taskTokens) continue;
1449
+ statusPayload.steps[fi].tokens = taskTokens;
1450
+ previousCumulativeTokens = {
1451
+ input: previousCumulativeTokens.input + taskTokens.input,
1452
+ output: previousCumulativeTokens.output + taskTokens.output,
1453
+ total: previousCumulativeTokens.total + taskTokens.total,
1454
+ };
1455
+ }
1456
+ statusPayload.totalTokens = { ...previousCumulativeTokens };
1457
+ statusPayload.lastUpdate = Date.now();
1458
+ writeStatusPayload();
1459
+
1460
+ for (const pr of parallelResults) {
1461
+ results.push({
1462
+ agent: pr.agent,
1463
+ output: pr.output,
1464
+ error: pr.error,
1465
+ success: pr.exitCode === 0,
1466
+ skipped: pr.skipped,
1467
+ sessionFile: pr.sessionFile,
1468
+ intercomTarget: pr.intercomTarget,
1469
+ model: pr.model,
1470
+ attemptedModels: pr.attemptedModels,
1471
+ modelAttempts: pr.modelAttempts,
1472
+ artifactPaths: pr.artifactPaths,
1473
+ });
1474
+ }
1475
+
1476
+ previousOutput = aggregateParallelOutputs(
1477
+ parallelResults.map((r) => ({
1478
+ agent: r.agent,
1479
+ output: r.output,
1480
+ exitCode: r.exitCode,
1481
+ error: r.error,
1482
+ model: r.model,
1483
+ attemptedModels: r.attemptedModels,
1484
+ })),
1485
+ );
1486
+ previousOutput = appendParallelWorktreeSummary(previousOutput, worktreeSetup, asyncDir, stepIndex, group);
1487
+
1488
+ appendJsonl(eventsPath, JSON.stringify({
1489
+ type: "subagent.parallel.completed",
1490
+ ts: Date.now(),
1491
+ runId: id,
1492
+ stepIndex,
1493
+ success: parallelResults.every((r) => r.exitCode === 0 || r.exitCode === -1),
1494
+ }));
1495
+
1496
+ if (parallelResults.some((r) => r.exitCode !== 0 && r.exitCode !== -1)) {
1497
+ break;
1498
+ }
1499
+ } finally {
1500
+ if (worktreeSetup) cleanupWorktrees(worktreeSetup);
1501
+ }
1502
+ } else {
1503
+ const seqStep = step as SubagentStep;
1504
+ const stepStartTime = Date.now();
1505
+ statusPayload.currentStep = flatIndex;
1506
+ statusPayload.steps[flatIndex].status = "running";
1507
+ statusPayload.steps[flatIndex].activityState = undefined;
1508
+ statusPayload.activityState = undefined;
1509
+ resetStepLiveDetail(statusPayload.steps[flatIndex]);
1510
+ statusPayload.steps[flatIndex].skills = seqStep.skills;
1511
+ statusPayload.steps[flatIndex].startedAt = stepStartTime;
1512
+ statusPayload.steps[flatIndex].lastActivityAt = stepStartTime;
1513
+ statusPayload.lastActivityAt = stepStartTime;
1514
+ statusPayload.lastUpdate = stepStartTime;
1515
+ statusPayload.outputFile = path.join(asyncDir, `output-${flatIndex}.log`);
1516
+ writeStatusPayload();
1517
+
1518
+ appendJsonl(eventsPath, JSON.stringify({
1519
+ type: "subagent.step.started",
1520
+ ts: stepStartTime,
1521
+ runId: id,
1522
+ stepIndex: flatIndex,
1523
+ agent: seqStep.agent,
1524
+ }));
1525
+
1526
+ const singleResult = await runSingleStep(seqStep, {
1527
+ previousOutput, placeholder, cwd, sessionEnabled,
1528
+ sessionDir: config.sessionDir,
1529
+ artifactsDir, artifactConfig, id,
1530
+ flatIndex, flatStepCount: flatSteps.length,
1531
+ outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
1532
+ piPackageRoot: config.piPackageRoot,
1533
+ piArgv1: config.piArgv1,
1534
+ childIntercomTarget: config.childIntercomTargets?.[flatIndex],
1535
+ orchestratorIntercomTarget: config.controlIntercomTarget,
1536
+ nestedRoute: config.nestedRoute,
1537
+ registerInterrupt: (interrupt) => {
1538
+ activeChildInterrupt = interrupt;
1539
+ },
1540
+ onAttemptStart: (attempt) => updateStepModel(flatIndex, attempt.model, attempt.thinking),
1541
+ onChildEvent: (event) => updateStepFromChildEvent(flatIndex, event),
1542
+ });
1543
+ if (seqStep.sessionFile) {
1544
+ latestSessionFile = seqStep.sessionFile;
1545
+ }
1546
+
1547
+ previousOutput = singleResult.output;
1548
+ results.push({
1549
+ agent: singleResult.agent,
1550
+ output: singleResult.output,
1551
+ error: singleResult.error,
1552
+ success: singleResult.exitCode === 0,
1553
+ sessionFile: singleResult.sessionFile,
1554
+ intercomTarget: singleResult.intercomTarget,
1555
+ model: singleResult.model,
1556
+ attemptedModels: singleResult.attemptedModels,
1557
+ modelAttempts: singleResult.modelAttempts,
1558
+ artifactPaths: singleResult.artifactPaths,
1559
+ });
1560
+
1561
+ const cumulativeTokens = config.sessionDir ? parseSessionTokens(config.sessionDir) : null;
1562
+ let stepTokens: TokenUsage | null = cumulativeTokens
1563
+ ? {
1564
+ input: cumulativeTokens.input - previousCumulativeTokens.input,
1565
+ output: cumulativeTokens.output - previousCumulativeTokens.output,
1566
+ total: cumulativeTokens.total - previousCumulativeTokens.total,
1567
+ }
1568
+ : null;
1569
+ if (cumulativeTokens) {
1570
+ previousCumulativeTokens = cumulativeTokens;
1571
+ } else {
1572
+ stepTokens = tokenUsageFromAttempts(singleResult.modelAttempts);
1573
+ if (stepTokens) {
1574
+ previousCumulativeTokens = {
1575
+ input: previousCumulativeTokens.input + stepTokens.input,
1576
+ output: previousCumulativeTokens.output + stepTokens.output,
1577
+ total: previousCumulativeTokens.total + stepTokens.total,
1578
+ };
1579
+ }
1580
+ }
1581
+
1582
+ const stepEndTime = Date.now();
1583
+ statusPayload.steps[flatIndex].status = singleResult.exitCode === 0 ? "complete" : "failed";
1584
+ statusPayload.steps[flatIndex].endedAt = stepEndTime;
1585
+ statusPayload.steps[flatIndex].durationMs = stepEndTime - stepStartTime;
1586
+ statusPayload.steps[flatIndex].exitCode = singleResult.exitCode;
1587
+ statusPayload.steps[flatIndex].model = singleResult.model;
1588
+ statusPayload.steps[flatIndex].thinking = resolveEffectiveThinking(singleResult.model, statusPayload.steps[flatIndex].thinking);
1589
+ statusPayload.steps[flatIndex].attemptedModels = singleResult.attemptedModels;
1590
+ statusPayload.steps[flatIndex].modelAttempts = singleResult.modelAttempts;
1591
+ statusPayload.steps[flatIndex].error = singleResult.error;
1592
+ if (stepTokens) {
1593
+ statusPayload.steps[flatIndex].tokens = stepTokens;
1594
+ statusPayload.totalTokens = { ...previousCumulativeTokens };
1595
+ }
1596
+ statusPayload.lastUpdate = stepEndTime;
1597
+ writeStatusPayload();
1598
+
1599
+ appendJsonl(eventsPath, JSON.stringify({
1600
+ type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1601
+ ts: stepEndTime,
1602
+ runId: id,
1603
+ stepIndex: flatIndex,
1604
+ agent: seqStep.agent,
1605
+ exitCode: singleResult.exitCode,
1606
+ durationMs: stepEndTime - stepStartTime,
1607
+ tokens: stepTokens,
1608
+ }));
1609
+ if (singleResult.completionGuardTriggered) {
1610
+ const event = buildControlEvent({
1611
+ from: statusPayload.steps[flatIndex].activityState,
1612
+ to: "needs_attention",
1613
+ runId: id,
1614
+ agent: seqStep.agent,
1615
+ index: flatIndex,
1616
+ ts: stepEndTime,
1617
+ message: `${seqStep.agent} completed without making edits for an implementation task`,
1618
+ reason: "completion_guard",
1619
+ });
1620
+ appendControlEvent(event);
1621
+ }
1622
+
1623
+ flatIndex++;
1624
+ if (singleResult.exitCode !== 0) {
1625
+ break;
1626
+ }
1627
+ }
1628
+ }
1629
+
1630
+ let summary = results.map((r) => `${r.agent}:\n${r.output}`).join("\n\n");
1631
+ let truncated = false;
1632
+
1633
+ if (maxOutput) {
1634
+ const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput };
1635
+ const lastArtifactPath = results[results.length - 1]?.artifactPaths?.outputPath;
1636
+ const truncResult = truncateOutput(summary, config, lastArtifactPath);
1637
+ if (truncResult.truncated) {
1638
+ summary = truncResult.text;
1639
+ truncated = true;
1640
+ }
1641
+ }
1642
+
1643
+ const resultMode = config.resultMode ?? statusPayload.mode;
1644
+ const agentName = flatSteps.length === 1
1645
+ ? flatSteps[0].agent
1646
+ : resultMode === "parallel"
1647
+ ? `parallel:${flatSteps.map((s) => s.agent).join("+")}`
1648
+ : `chain:${flatSteps.map((s) => s.agent).join("->")}`;
1649
+ let sessionFile: string | undefined;
1650
+ let shareUrl: string | undefined;
1651
+ let gistUrl: string | undefined;
1652
+ let shareError: string | undefined;
1653
+
1654
+ if (shareEnabled) {
1655
+ sessionFile = config.sessionDir
1656
+ ? (findLatestSessionFile(config.sessionDir) ?? undefined)
1657
+ : undefined;
1658
+ if (!sessionFile && latestSessionFile) {
1659
+ sessionFile = latestSessionFile;
1660
+ }
1661
+ if (sessionFile) {
1662
+ try {
1663
+ const exportDir = config.sessionDir ?? path.dirname(sessionFile);
1664
+ const htmlPath = await exportSessionHtml(sessionFile, exportDir, config.piPackageRoot);
1665
+ const share = createShareLink(htmlPath);
1666
+ if ("error" in share) shareError = share.error;
1667
+ else {
1668
+ shareUrl = share.shareUrl;
1669
+ gistUrl = share.gistUrl;
1670
+ }
1671
+ } catch (err) {
1672
+ shareError = String(err);
1673
+ }
1674
+ } else {
1675
+ shareError = "Session file not found.";
1676
+ }
1677
+ }
1678
+
1679
+ if (activityTimer) {
1680
+ clearInterval(activityTimer);
1681
+ activityTimer = undefined;
1682
+ }
1683
+ const effectiveSessionFile = sessionFile ?? latestSessionFile;
1684
+ const runEndedAt = Date.now();
1685
+ statusPayload.state = interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed";
1686
+ statusPayload.activityState = undefined;
1687
+ statusPayload.endedAt = runEndedAt;
1688
+ statusPayload.lastUpdate = runEndedAt;
1689
+ statusPayload.sessionFile = effectiveSessionFile;
1690
+ statusPayload.shareUrl = shareUrl;
1691
+ statusPayload.gistUrl = gistUrl;
1692
+ statusPayload.shareError = shareError;
1693
+ if (statusPayload.state === "failed") {
1694
+ const failedStep = statusPayload.steps.find((s) => s.status === "failed");
1695
+ if (failedStep?.agent) {
1696
+ statusPayload.error = `Step failed: ${failedStep.agent}`;
1697
+ }
1698
+ }
1699
+ writeStatusPayload();
1700
+ appendJsonl(
1701
+ eventsPath,
1702
+ JSON.stringify({
1703
+ type: "subagent.run.completed",
1704
+ ts: runEndedAt,
1705
+ runId: id,
1706
+ status: statusPayload.state,
1707
+ durationMs: runEndedAt - overallStartTime,
1708
+ }),
1709
+ );
1710
+ writeRunLog(logPath, {
1711
+ id,
1712
+ mode: statusPayload.mode,
1713
+ cwd,
1714
+ startedAt: overallStartTime,
1715
+ endedAt: runEndedAt,
1716
+ steps: statusPayload.steps.map((step) => ({
1717
+ agent: step.agent,
1718
+ status: step.status,
1719
+ durationMs: step.durationMs,
1720
+ })),
1721
+ summary,
1722
+ truncated,
1723
+ artifactsDir,
1724
+ sessionFile: effectiveSessionFile,
1725
+ shareUrl,
1726
+ shareError,
1727
+ });
1728
+
1729
+ try {
1730
+ writeAtomicJson(resultPath, {
1731
+ id,
1732
+ agent: agentName,
1733
+ mode: resultMode,
1734
+ success: !interrupted && results.every((r) => r.success),
1735
+ state: interrupted ? "paused" : results.every((r) => r.success) ? "complete" : "failed",
1736
+ summary: interrupted ? "Paused after interrupt. Waiting for explicit next action." : summary,
1737
+ results: results.map((r) => ({
1738
+ agent: r.agent,
1739
+ output: r.output,
1740
+ error: r.error,
1741
+ success: r.success,
1742
+ skipped: r.skipped || undefined,
1743
+ sessionFile: r.sessionFile,
1744
+ intercomTarget: r.intercomTarget,
1745
+ model: r.model,
1746
+ attemptedModels: r.attemptedModels,
1747
+ modelAttempts: r.modelAttempts,
1748
+ artifactPaths: r.artifactPaths,
1749
+ truncated: r.truncated,
1750
+ })),
1751
+ exitCode: interrupted || results.every((r) => r.success) ? 0 : 1,
1752
+ timestamp: runEndedAt,
1753
+ durationMs: runEndedAt - overallStartTime,
1754
+ truncated,
1755
+ artifactsDir,
1756
+ cwd,
1757
+ asyncDir,
1758
+ sessionId: config.sessionId,
1759
+ sessionFile: effectiveSessionFile,
1760
+ intercomTarget: config.controlIntercomTarget,
1761
+ shareUrl,
1762
+ gistUrl,
1763
+ shareError,
1764
+ ...(taskIndex !== undefined && { taskIndex }),
1765
+ ...(totalTasks !== undefined && { totalTasks }),
1766
+ });
1767
+ } catch (err) {
1768
+ console.error(`Failed to write result file ${resultPath}:`, err);
1769
+ }
1770
+ }
1771
+
1772
+ const configArg = process.argv[2];
1773
+ if (configArg) {
1774
+ try {
1775
+ const configJson = fs.readFileSync(configArg, "utf-8");
1776
+ const config = JSON.parse(configJson) as SubagentRunConfig;
1777
+ try {
1778
+ fs.unlinkSync(configArg);
1779
+ } catch {
1780
+ // Temp config cleanup is best effort.
1781
+ }
1782
+ runSubagent(config).catch((runErr) => {
1783
+ console.error("Subagent runner error:", runErr);
1784
+ process.exit(1);
1785
+ });
1786
+ } catch (err) {
1787
+ console.error("Subagent runner error:", err);
1788
+ process.exit(1);
1789
+ }
1790
+ } else {
1791
+ let input = "";
1792
+ process.stdin.setEncoding("utf-8");
1793
+ process.stdin.on("data", (chunk) => {
1794
+ input += chunk;
1795
+ });
1796
+ process.stdin.on("end", () => {
1797
+ try {
1798
+ const config = JSON.parse(input) as SubagentRunConfig;
1799
+ runSubagent(config).catch((runErr) => {
1800
+ console.error("Subagent runner error:", runErr);
1801
+ process.exit(1);
1802
+ });
1803
+ } catch (err) {
1804
+ console.error("Subagent runner error:", err);
1805
+ process.exit(1);
1806
+ }
1807
+ });
1808
+ }