pi-subagents 0.34.0 → 0.35.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 (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -0,0 +1,230 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
6
+ import type { AsyncStatus, Details, SubagentState, ToolBudgetConfig, TurnBudgetConfig } from "../../shared/types.ts";
7
+ import { readStatus } from "../../shared/utils.ts";
8
+ import { consumeSteerAcks, deliverInterruptRequest, requestAsyncSteer } from "../background/control-channel.ts";
9
+ import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
10
+ import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
11
+ import { actionResultFromSteeringStatus, claimSteeringRecovery, remainingSteeringRecoveryLimits, updateSteeringTarget, waitForSteeringAction } from "../background/steering.ts";
12
+
13
+ export async function steerAsyncRun(input: {
14
+ state: SubagentState;
15
+ runId: string;
16
+ message: string;
17
+ index?: number;
18
+ kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
19
+ location: { asyncDir: string | null };
20
+ signal?: AbortSignal;
21
+ ackTimeoutMs?: number;
22
+ recoveryTimeoutMs?: number;
23
+ onRequestQueued?: (requestPath: string) => void;
24
+ onBeforeRecoveryClaim?: (requestId: string, committedAt: number) => void;
25
+ onRecoveryCommitted?: (requestId: string, committedAt: number) => void;
26
+ recover?: (limits: { timeoutMs?: number; absoluteDeadlineAt?: number; turnBudget?: TurnBudgetConfig; toolBudget?: ToolBudgetConfig }) => Promise<AgentToolResult<Details>>;
27
+ }): Promise<AgentToolResult<Details>> {
28
+ if (!input.location.asyncDir) {
29
+ return {
30
+ content: [{ type: "text", text: `Async run '${input.runId}' has no live run directory to steer.` }],
31
+ isError: true,
32
+ details: { mode: "management", results: [] },
33
+ };
34
+ }
35
+ const asyncDir = input.location.asyncDir;
36
+ const status = reconcileAsyncRun(asyncDir, { kill: input.kill }).status;
37
+ if (input.state.currentSessionId && status?.sessionId !== input.state.currentSessionId) {
38
+ return {
39
+ content: [{ type: "text", text: `Async run '${input.runId}' was not found in the active session.` }],
40
+ isError: true,
41
+ details: { mode: "management", results: [] },
42
+ };
43
+ }
44
+ if (!status || (status.state !== "running" && status.state !== "queued")) {
45
+ return {
46
+ content: [{ type: "text", text: `Async run '${input.runId}' is not running or queued and cannot be steered.` }],
47
+ isError: true,
48
+ details: { mode: "management", results: [] },
49
+ };
50
+ }
51
+ const steps = status.steps ?? [];
52
+ if (input.index !== undefined) {
53
+ if (input.index < 0 || input.index >= steps.length) {
54
+ return {
55
+ content: [{ type: "text", text: `Async run '${status.runId}' has ${steps.length} children. Index ${input.index} is out of range.` }],
56
+ isError: true,
57
+ details: { mode: "management", results: [] },
58
+ };
59
+ }
60
+ const targetStep = steps[input.index];
61
+ if (targetStep && targetStep.status !== "running" && targetStep.status !== "pending") {
62
+ return {
63
+ content: [{ type: "text", text: `Async run '${status.runId}' child ${input.index} is ${targetStep.status} and cannot be steered.` }],
64
+ isError: true,
65
+ details: { mode: "management", results: [] },
66
+ };
67
+ }
68
+ } else {
69
+ const running = steps.filter((step) => step.status === "running");
70
+ if (running.length === 0 && steps.length > 1) {
71
+ return {
72
+ content: [{ type: "text", text: `Async run '${status.runId}' has no running child yet. Provide index to steer a queued child.` }],
73
+ isError: true,
74
+ details: { mode: "management", results: [] },
75
+ };
76
+ }
77
+ }
78
+ const runningIndexes = steps
79
+ .map((step, index) => step.status === "running" ? index : undefined)
80
+ .filter((index): index is number => index !== undefined);
81
+ const effectiveTargetIndex = input.index ?? (status.mode === "single" && runningIndexes.length === 0 && steps[0]?.status === "pending" ? 0 : undefined);
82
+ const targetIndexes = effectiveTargetIndex !== undefined ? [effectiveTargetIndex] : runningIndexes;
83
+ if (targetIndexes.length === 0) {
84
+ return {
85
+ content: [{ type: "text", text: `Async run '${status.runId}' has no running child to steer.` }],
86
+ isError: true,
87
+ details: { mode: "management", results: [] },
88
+ };
89
+ }
90
+ const requestId = randomUUID();
91
+ let requestPath: string;
92
+ try {
93
+ requestPath = requestAsyncSteer(asyncDir, {
94
+ message: input.message,
95
+ ...(effectiveTargetIndex !== undefined ? { targetIndex: effectiveTargetIndex } : { targetIndexes }),
96
+ source: "steer-action",
97
+ id: requestId,
98
+ });
99
+ } catch (error) {
100
+ return {
101
+ content: [{ type: "text", text: `Failed to queue steering for async run ${status.runId}: ${error instanceof Error ? error.message : String(error)}` }],
102
+ isError: true,
103
+ details: { mode: "management", results: [] },
104
+ };
105
+ }
106
+ input.onRequestQueued?.(requestPath);
107
+ const tracked = input.state.asyncJobs.get(status.runId);
108
+ if (tracked) tracked.updatedAt = Date.now();
109
+ const targets = targetIndexes.map((index) => ({ index, state: status.steps?.[index]?.status === "pending" ? "scheduled" as const : "pending" as const }));
110
+ if (targets.every((target) => target.state === "scheduled")) {
111
+ const scheduled = { requestId, state: "scheduled" as const, sourceRunId: status.runId, targets };
112
+ return { content: [{ type: "text", text: `Steering scheduled for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: scheduled } };
113
+ }
114
+ const waited = await waitForSteeringAction({ asyncDir, sourceRunId: status.runId, requestId, timeoutMs: input.ackTimeoutMs ?? 3_000, signal: input.signal });
115
+ const result = waited ?? { requestId, state: "pending" as const, sourceRunId: status.runId, targets };
116
+ if (input.signal?.aborted) {
117
+ return { content: [{ type: "text", text: `Steering pending for async run ${status.runId} (request ${requestId}); caller aborted before recovery.` }], details: { mode: "management", results: [], steering: result } };
118
+ }
119
+ const finalStatus = readStatus(asyncDir);
120
+ const finalResult = finalStatus?.steering ? actionResultFromSteeringStatus(finalStatus.steering, status.runId, requestId) : undefined;
121
+ if (finalResult?.state === "delivered") {
122
+ return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: finalResult } };
123
+ }
124
+ const running = (finalStatus?.steps ?? status.steps).filter((step) => step.status === "running");
125
+ const recoveryAllowed = status.mode === "single" && status.isNested !== true && running.length === 1 && Boolean(finalStatus?.steering) && (input.index === undefined || input.index === 0);
126
+ if (recoveryAllowed && finalResult?.state !== "scheduled" && input.recover) {
127
+ const appendSteeringNotice = (state: "failed" | "recovered", message: string): void => {
128
+ try {
129
+ fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({ type: "subagent.steering.notice", ts: Date.now(), runId: status.runId, requestId, state, message, ...(status.sessionId ? { currentSessionId: status.sessionId } : {}) })}\n`);
130
+ } catch {
131
+ // The action result and status remain authoritative if diagnostic notification persistence fails.
132
+ }
133
+ };
134
+ try {
135
+ const latest = readStatus(asyncDir);
136
+ const latestResult = latest?.steering ? actionResultFromSteeringStatus(latest.steering, status.runId, requestId) : undefined;
137
+ if (latestResult?.state === "delivered") return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: latestResult } };
138
+ const committedAt = Date.now();
139
+ input.onBeforeRecoveryClaim?.(requestId, committedAt);
140
+ const { claimPath, markerPath } = claimSteeringRecovery(asyncDir, { requestId, sourceRunId: status.runId, committedAt });
141
+ input.onRecoveryCommitted?.(requestId, committedAt);
142
+ const preCommitStatus = readStatus(asyncDir);
143
+ const preCommitResult = preCommitStatus?.steering ? actionResultFromSteeringStatus(preCommitStatus.steering, status.runId, requestId) : undefined;
144
+ if (preCommitResult?.state === "delivered" && preCommitResult.targets.every((target) => target.deliveredAt !== undefined && target.deliveredAt <= committedAt)) {
145
+ fs.rmSync(markerPath, { force: true });
146
+ fs.rmSync(claimPath, { force: true });
147
+ return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
148
+ }
149
+ try {
150
+ deliverInterruptRequest({ asyncDir, pid: latest?.pid ?? status.pid, kill: input.kill, source: "steering-recovery" });
151
+ } catch (error) {
152
+ fs.rmSync(markerPath, { force: true });
153
+ fs.rmSync(claimPath, { force: true });
154
+ throw new Error(`Failed to commit steering recovery interrupt: ${error instanceof Error ? error.message : String(error)}`);
155
+ }
156
+ const pauseDeadline = Date.now() + (input.recoveryTimeoutMs ?? 15_000);
157
+ let paused: AsyncStatus | null = null;
158
+ while (Date.now() <= pauseDeadline) {
159
+ if (input.signal?.aborted) break;
160
+ const candidate = readStatus(asyncDir);
161
+ if (candidate?.state === "paused" && candidate.endedAt !== undefined) { paused = candidate; break; }
162
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
163
+ }
164
+ if (!paused) throw new Error("Source run did not reach confirmed paused state within 15 seconds; no replacement was launched and the recovery claim remains committed to prevent a delayed duplicate.");
165
+ let lateAckRecorded = false;
166
+ for (const ack of consumeSteerAcks(asyncDir)) {
167
+ if (!paused.steering?.recent.some((request) => request.id === ack.requestId && request.targets.some((target) => target.index === ack.index))) continue;
168
+ const state = ack.state === "delivered" ? "late" : "failed";
169
+ const reason = ack.state === "delivered" ? "acknowledged after recovery commit" : ack.message;
170
+ updateSteeringTarget(paused.steering, ack.requestId, ack.index, state, ack.ts, { reason });
171
+ const stepSteering = paused.steps?.[ack.index]?.steering;
172
+ if (stepSteering) updateSteeringTarget(stepSteering, ack.requestId, ack.index, state, ack.ts, { reason });
173
+ lateAckRecorded = true;
174
+ try {
175
+ fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({ type: ack.state === "delivered" ? "subagent.steer.delivered" : "subagent.steer.failed", ts: ack.ts, runId: status.runId, requestId: ack.requestId, index: ack.index, late: true, message: ack.message })}\n`);
176
+ } catch {
177
+ // Status remains authoritative when diagnostic event persistence fails.
178
+ }
179
+ }
180
+ if (lateAckRecorded) writeAtomicJson(path.join(asyncDir, "status.json"), paused);
181
+ let recoveryTarget;
182
+ try {
183
+ recoveryTarget = resolveAsyncResumeTarget(
184
+ { id: status.runId },
185
+ { kill: input.kill },
186
+ { sessionId: input.state.currentSessionId ?? undefined },
187
+ );
188
+ } catch (error) {
189
+ throw new Error(`Source run remains paused and cannot be revived safely: ${error instanceof Error ? error.message : String(error)}`);
190
+ }
191
+ if (recoveryTarget.kind !== "revive" || !recoveryTarget.sessionFile) throw new Error("Source run has no valid persisted child session; it remains paused and requires a new run.");
192
+ if (!recoveryTarget.recoveryDescriptor) throw new Error("Source run has no private recovery descriptor; it remains paused and is not safely resumable.");
193
+ const limits = remainingSteeringRecoveryLimits(recoveryTarget.recoveryDescriptor, paused);
194
+ const revived = await input.recover(limits);
195
+ if (revived.isError || !revived.details.asyncId) throw new Error(revived.content[0]?.type === "text" ? revived.content[0].text : "Replacement launch failed; source run remains paused.");
196
+ const sourceStatus = readStatus(asyncDir);
197
+ const targetIndex = input.index ?? status.steps.findIndex((step) => step.status === "running");
198
+ if (sourceStatus?.state === "paused" && sourceStatus.steering && targetIndex >= 0) {
199
+ updateSteeringTarget(sourceStatus.steering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
200
+ const stepSteering = sourceStatus.steps?.[targetIndex]?.steering;
201
+ if (stepSteering) updateSteeringTarget(stepSteering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
202
+ writeAtomicJson(path.join(asyncDir, "status.json"), sourceStatus);
203
+ }
204
+ const recovered = sourceStatus?.steering ? actionResultFromSteeringStatus(sourceStatus.steering, status.runId, requestId, revived.details.asyncId) : undefined;
205
+ appendSteeringNotice("recovered", `Steering recovered for run ${status.runId}; replacement ${revived.details.asyncId} launched.`);
206
+ return { content: [{ type: "text", text: `Steering recovered for async run ${status.runId}; replacement ${revived.details.asyncId} launched after the source paused.` }], details: { mode: "management", results: [], steering: recovered ?? { requestId, state: "recovered", sourceRunId: status.runId, replacementRunId: revived.details.asyncId, targets: [{ index: input.index ?? 0, state: "recovered" }] } } };
207
+ } catch (error) {
208
+ const reason = error instanceof Error ? error.message : String(error);
209
+ const failedStatus = readStatus(asyncDir);
210
+ const targetIndex = input.index ?? status.steps.findIndex((step) => step.status === "running");
211
+ if (failedStatus && failedStatus.state !== "running" && failedStatus.state !== "queued" && failedStatus.steering && targetIndex >= 0) {
212
+ updateSteeringTarget(failedStatus.steering, requestId, targetIndex, "failed", Date.now(), { reason });
213
+ const stepSteering = failedStatus.steps?.[targetIndex]?.steering;
214
+ if (stepSteering) updateSteeringTarget(stepSteering, requestId, targetIndex, "failed", Date.now(), { reason });
215
+ failedStatus.activityState = "needs_attention";
216
+ writeAtomicJson(path.join(asyncDir, "status.json"), failedStatus);
217
+ }
218
+ const failed = failedStatus?.steering ? actionResultFromSteeringStatus(failedStatus.steering, status.runId, requestId) : undefined;
219
+ appendSteeringNotice("failed", `Steering failed for run ${status.runId}: ${reason}`);
220
+ return { content: [{ type: "text", text: `Steering failed for async run ${status.runId} (request ${requestId}): ${reason}` }], isError: true, details: { mode: "management", results: [], steering: failed ?? { requestId, state: "failed", sourceRunId: status.runId, targets: [{ index: input.index ?? 0, state: "failed", reason }] } } };
221
+ }
222
+ }
223
+ const stateText = result.state === "delivered" ? "delivered" : result.state === "scheduled" ? "scheduled" : result.state === "recovered" ? "recovered" : result.state === "failed" ? "failed" : result.state === "partial" ? "partial" : "pending";
224
+ const isError = result.state === "failed" || result.state === "partial";
225
+ return {
226
+ content: [{ type: "text", text: `Steering ${stateText} for async run ${status.runId} (request ${requestId}).` }],
227
+ ...(isError ? { isError: true } : {}),
228
+ details: { mode: "management", results: [], steering: result },
229
+ };
230
+ }
@@ -1004,6 +1004,7 @@ export class ChainClarifyComponent implements Component {
1004
1004
  "medium": "Moderate reasoning",
1005
1005
  "high": "Deep reasoning",
1006
1006
  "xhigh": "Maximum reasoning (ultrathink)",
1007
+ "max": "Maximum available reasoning",
1007
1008
  };
1008
1009
 
1009
1010
  const levels = this.getAvailableThinkingLevels(this.editingStep!);
@@ -1102,18 +1103,29 @@ export class ChainClarifyComponent implements Component {
1102
1103
  return lines;
1103
1104
  }
1104
1105
 
1105
- private getFooterText(): string {
1106
- const bgLabel = this.runInBackground ? '[b]g:ON' : '[b]g';
1106
+ private getShortcutHelpText(): string {
1107
+ const th = this.theme;
1108
+ const shortcut = (key: string, label: string) =>
1109
+ th.fg("accent", `[${key}]`) + th.fg("dim", label);
1110
+ const backgroundState = this.runInBackground ? "ON" : "OFF";
1111
+ const background = shortcut("b", `Background:${backgroundState}`);
1112
+
1107
1113
  switch (this.mode) {
1108
1114
  case 'single':
1109
- return ` [Enter] Run • [Esc] Cancel • e m t w s ${bgLabel} `;
1115
+ return ` ${shortcut("e", "Task")} ${shortcut("m", "Model")} ${shortcut("t", "Think")} ${shortcut("w", "Output")} ${shortcut("s", "Skills")} ${background}`;
1110
1116
  case 'parallel':
1111
- return ` [Enter] Run • [Esc] Cancel • e m t s ${bgLabel} • ↑↓ Nav `;
1117
+ return ` ${shortcut("e", "Task")} ${shortcut("m", "Model")} ${shortcut("t", "Think")} ${shortcut("s", "Skills")} ${background}`;
1112
1118
  case 'chain':
1113
- return ` [Enter] Run • [Esc] Cancel • e m t w r p s ${bgLabel} • ↑↓ Nav `;
1119
+ return ` ${shortcut("e", "Task")} ${shortcut("m", "Model")} ${shortcut("t", "Think")} ${shortcut("w", "Output")} ${shortcut("r", "Reads")} ${shortcut("p", "Prog")} ${shortcut("s", "Skills")} ${background}`;
1114
1120
  }
1115
1121
  }
1116
1122
 
1123
+ private getFooterText(): string {
1124
+ return this.mode === 'single'
1125
+ ? " [Enter] Run • [Esc] Cancel "
1126
+ : " [Enter] Run • [Esc] Cancel • [↑↓] Navigate ";
1127
+ }
1128
+
1117
1129
  private appendNotice(lines: string[]): void {
1118
1130
  if (!this.noticeMessage) return;
1119
1131
  const color = this.noticeMessage.type === "error" ? "error" : "success";
@@ -1162,7 +1174,7 @@ export class ChainClarifyComponent implements Component {
1162
1174
  const skillsLabel = th.fg("dim", "skills: ");
1163
1175
  lines.push(this.row(` ${skillsLabel}${truncateToWidth(skillsValue, innerW - 14)}`));
1164
1176
 
1165
- lines.push(this.row(""));
1177
+ lines.push(this.row(this.getShortcutHelpText()));
1166
1178
 
1167
1179
  this.appendNotice(lines);
1168
1180
  lines.push(this.renderFooter(this.getFooterText()));
@@ -1216,6 +1228,8 @@ export class ChainClarifyComponent implements Component {
1216
1228
  lines.push(this.row(""));
1217
1229
  }
1218
1230
 
1231
+ if (this.agentConfigs.length > 0) lines.pop();
1232
+ lines.push(this.row(this.getShortcutHelpText()));
1219
1233
  this.appendNotice(lines);
1220
1234
  lines.push(this.renderFooter(this.getFooterText()));
1221
1235
 
@@ -1319,6 +1333,8 @@ export class ChainClarifyComponent implements Component {
1319
1333
  lines.push(this.row(""));
1320
1334
  }
1321
1335
 
1336
+ if (this.agentConfigs.length > 0) lines.pop();
1337
+ lines.push(this.row(this.getShortcutHelpText()));
1322
1338
  this.appendNotice(lines);
1323
1339
  lines.push(this.renderFooter(this.getFooterText()));
1324
1340
 
@@ -63,9 +63,9 @@ import {
63
63
  MAX_CONCURRENCY,
64
64
  resolveChildMaxSubagentDepth,
65
65
  } from "../../shared/types.ts";
66
- import { resolveSubagentModelOverride } from "../shared/model-fallback.ts";
66
+ import { resolveEffectiveSubagentModel } from "../shared/model-fallback.ts";
67
67
  import type { ModelScopeConfig } from "../shared/model-scope.ts";
68
- import { validateFileOnlyOutputMode } from "../shared/single-output.ts";
68
+ import { injectSingleOutputInstruction, validateFileOnlyOutputMode } from "../shared/single-output.ts";
69
69
  import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
70
70
  import { ChainOutputValidationError, outputEntryFromResult, resolveOutputReferences, validateChainOutputBindings } from "../shared/chain-outputs.ts";
71
71
  import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
@@ -88,7 +88,7 @@ interface ChainExecutionDetailsInput {
88
88
  outputs?: ChainOutputMap;
89
89
  currentFlatIndex?: number;
90
90
  dynamicChildren?: Record<number, Array<{ agent: string; label?: string; flatIndex: number; itemKey: string; outputName?: string; structured?: boolean; error?: string }>>;
91
- dynamicGroupStatuses?: Record<number, { status: "pending" | "running" | "completed" | "failed" | "paused" | "detached"; error?: string; acceptance?: SingleResult["acceptance"] }>;
91
+ dynamicGroupStatuses?: Record<number, { status: "pending" | "running" | "completed" | "failed" | "paused" | "stopped" | "detached"; error?: string; acceptance?: SingleResult["acceptance"] }>;
92
92
  }
93
93
 
94
94
  interface ParallelChainRunInput {
@@ -109,8 +109,8 @@ interface ParallelChainRunInput {
109
109
  globalTaskIndex: number;
110
110
  sessionDirForIndex: (idx?: number) => string | undefined;
111
111
  sessionFileForIndex?: (idx?: number) => string | undefined;
112
- sessionFileForTask?: (agentName: string, idx?: number) => string | undefined;
113
- thinkingOverrideForTask?: (agentName: string, idx?: number) => AgentConfig["thinking"] | undefined;
112
+ sessionFileForTask?: (agentName: string, idx?: number, modelOverride?: string) => string | undefined;
113
+ thinkingOverrideForTask?: (agentName: string, idx?: number, modelOverride?: string) => AgentConfig["thinking"] | undefined;
114
114
  shareEnabled: boolean;
115
115
  artifactConfig: ArtifactConfig;
116
116
  artifactsDir: string;
@@ -152,6 +152,7 @@ interface ParallelChainRunInput {
152
152
  toolBudget?: ResolvedToolBudget;
153
153
  configToolBudget?: ToolBudgetConfig;
154
154
  globalSemaphore?: Semaphore;
155
+ dynamic?: boolean;
155
156
  }
156
157
 
157
158
  function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details {
@@ -230,6 +231,21 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
230
231
  const concurrency = input.step.concurrency ?? MAX_CONCURRENCY;
231
232
  const failFast = input.step.failFast ?? false;
232
233
  let aborted = false;
234
+ const effectiveModels = input.step.parallel.map((task) => {
235
+ const taskAgentConfig = input.agents.find((agent) => agent.name === task.agent);
236
+ return resolveEffectiveSubagentModel(
237
+ task.model,
238
+ taskAgentConfig?.model,
239
+ input.ctx.model,
240
+ input.availableModels,
241
+ input.ctx.model?.provider,
242
+ { scope: input.modelScope },
243
+ );
244
+ });
245
+ for (let taskIndex = 0; taskIndex < input.step.parallel.length; taskIndex++) {
246
+ const task = input.step.parallel[taskIndex]!;
247
+ input.sessionFileForTask?.(task.agent, input.globalTaskIndex + taskIndex, effectiveModels[taskIndex]);
248
+ }
233
249
 
234
250
  const parallelResults = await mapConcurrent(
235
251
  input.step.parallel,
@@ -248,9 +264,10 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
248
264
 
249
265
  const taskTemplate = input.parallelTemplates[taskIndex] ?? "{previous}";
250
266
  const behavior = suppressProgressForReadOnlyTask(input.parallelBehaviors[taskIndex]!, taskTemplate, input.originalTask);
267
+ const taskAgentConfig = input.agents.find((agent) => agent.name === task.agent);
251
268
  const templateHasPrevious = taskTemplate.includes("{previous}");
252
269
  const { prefix, suffix } = buildChainInstructions(
253
- behavior,
270
+ { ...behavior, output: false },
254
271
  input.chainDir,
255
272
  false,
256
273
  templateHasPrevious ? undefined : input.prev,
@@ -263,14 +280,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
263
280
  const cleanTask = taskStr;
264
281
  taskStr = prefix + taskStr + suffix;
265
282
 
266
- const taskAgentConfig = input.agents.find((agent) => agent.name === task.agent);
267
- const effectiveModel = resolveSubagentModelOverride(
268
- task.model ?? taskAgentConfig?.model,
269
- input.ctx.model,
270
- input.availableModels,
271
- input.ctx.model?.provider,
272
- { scope: input.modelScope, source: task.model ? "explicit" : "inherited" },
273
- );
283
+ const effectiveModel = effectiveModels[taskIndex];
274
284
  const maxSubagentDepth = resolveChildMaxSubagentDepth(input.maxSubagentDepth, taskAgentConfig?.maxSubagentDepth);
275
285
  const toolBudget = resolveChainToolBudget({ stepBudget: task.toolBudget, runBudget: input.toolBudget, agentBudget: taskAgentConfig?.toolBudget, configBudget: input.configToolBudget });
276
286
  if (toolBudget.error) throw new Error(toolBudget.error);
@@ -282,6 +292,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
282
292
  const outputPath = typeof behavior.output === "string"
283
293
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(input.chainDir, behavior.output))
284
294
  : undefined;
295
+ taskStr = injectSingleOutputInstruction(taskStr, outputPath, taskAgentConfig);
285
296
  const interruptController = new AbortController();
286
297
  if (input.foregroundControl) {
287
298
  input.foregroundControl.currentAgent = task.agent;
@@ -310,9 +321,9 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
310
321
  runId: input.runId,
311
322
  index: input.globalTaskIndex + taskIndex,
312
323
  sessionDir: input.sessionDirForIndex(input.globalTaskIndex + taskIndex),
313
- sessionFile: input.sessionFileForTask?.(task.agent, input.globalTaskIndex + taskIndex)
324
+ sessionFile: input.sessionFileForTask?.(task.agent, input.globalTaskIndex + taskIndex, effectiveModel)
314
325
  ?? input.sessionFileForIndex?.(input.globalTaskIndex + taskIndex),
315
- thinkingOverride: input.thinkingOverrideForTask?.(task.agent, input.globalTaskIndex + taskIndex),
326
+ thinkingOverride: input.thinkingOverrideForTask?.(task.agent, input.globalTaskIndex + taskIndex, effectiveModel),
316
327
  share: input.shareEnabled,
317
328
  artifactsDir: input.artifactConfig.enabled ? input.artifactsDir : undefined,
318
329
  artifactConfig: input.artifactConfig,
@@ -331,7 +342,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
331
342
  skills: behavior.skills === false ? [] : behavior.skills,
332
343
  structuredOutput: structuredRuntime,
333
344
  acceptance: task.acceptance,
334
- acceptanceContext: { mode: "chain" },
345
+ acceptanceContext: { mode: "chain", dynamic: input.dynamic && task.acceptance === undefined },
335
346
  timeoutMs: input.timeoutMs,
336
347
  deadlineAt: input.deadlineAt,
337
348
  turnBudget: input.turnBudget,
@@ -412,8 +423,8 @@ interface ChainExecutionParams {
412
423
  shareEnabled: boolean;
413
424
  sessionDirForIndex: (idx?: number) => string | undefined;
414
425
  sessionFileForIndex?: (idx?: number) => string | undefined;
415
- sessionFileForTask?: (agentName: string, idx?: number) => string | undefined;
416
- thinkingOverrideForTask?: (agentName: string, idx?: number) => AgentConfig["thinking"] | undefined;
426
+ sessionFileForTask?: (agentName: string, idx?: number, modelOverride?: string) => string | undefined;
427
+ thinkingOverrideForTask?: (agentName: string, idx?: number, modelOverride?: string) => AgentConfig["thinking"] | undefined;
417
428
  artifactsDir: string;
418
429
  artifactConfig: ArtifactConfig;
419
430
  includeProgress?: boolean;
@@ -627,7 +638,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
627
638
  return {
628
639
  ...step,
629
640
  task: result.templates[i]!,
630
- ...(override?.model ? { model: override.model } : {}),
641
+ ...(override?.model !== undefined ? { model: override.model } : {}),
631
642
  ...(override?.output !== undefined ? { output: override.output } : {}),
632
643
  ...("outputMode" in step && step.outputMode !== undefined ? { outputMode: step.outputMode } : {}),
633
644
  ...(override?.reads !== undefined ? { reads: override.reads } : {}),
@@ -768,7 +779,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
768
779
  const detached = detachedIndexInStep >= 0 ? parallelResults[detachedIndexInStep] : undefined;
769
780
  if (detached) {
770
781
  return {
771
- content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
782
+ content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${detached.agent}). Reply to the supervisor request first, then wait with subagent_wait({ id: "${runId}" }). Use subagent({ action: "status", id: "${runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.` }],
772
783
  details: buildChainExecutionDetails(makeDetailsInput({
773
784
  currentStepIndex: stepIndex,
774
785
  currentFlatIndex: globalTaskIndex - step.parallel.length + detachedIndexInStep,
@@ -869,7 +880,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
869
880
  const effectiveGroupAcceptance = resolveEffectiveAcceptance({
870
881
  explicit: step.acceptance,
871
882
  agentName: step.parallel.agent,
872
- task: step.parallel.task ?? originalTask,
883
+ acceptanceRole: agents.find((agent) => agent.name === step.parallel.agent)?.acceptanceRole,
884
+ task: (step.parallel.task ?? originalTask ?? "").replace(/\{task\}/g, originalTask ?? ""),
873
885
  mode: "chain",
874
886
  dynamicGroup: true,
875
887
  });
@@ -964,6 +976,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
964
976
  toolBudget: params.toolBudget,
965
977
  configToolBudget: params.configToolBudget,
966
978
  globalSemaphore,
979
+ dynamic: true,
967
980
  });
968
981
  globalTaskIndex = dynamicStartIndex + reservedDynamicItems;
969
982
 
@@ -988,7 +1001,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
988
1001
  const detached = detachedIndexInStep >= 0 ? parallelResults[detachedIndexInStep] : undefined;
989
1002
  if (detached) {
990
1003
  return {
991
- content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
1004
+ content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${detached.agent}). Reply to the supervisor request first, then wait with subagent_wait({ id: "${runId}" }). Use subagent({ action: "status", id: "${runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.` }],
992
1005
  details: buildChainExecutionDetails(makeDetailsInput({
993
1006
  currentStepIndex: stepIndex,
994
1007
  currentFlatIndex: dynamicStartIndex + detachedIndexInStep,
@@ -1034,7 +1047,10 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1034
1047
  const effectiveGroupAcceptance = resolveEffectiveAcceptance({
1035
1048
  explicit: step.acceptance,
1036
1049
  agentName: step.parallel.agent,
1037
- task: step.parallel.task ?? originalTask,
1050
+ acceptanceRole: agents.find((agent) => agent.name === step.parallel.agent)?.acceptanceRole,
1051
+ task: materialized.parallel
1052
+ .map((task) => (task.task ?? originalTask ?? "").replace(/\{task\}/g, originalTask ?? ""))
1053
+ .join("\n"),
1038
1054
  mode: "chain",
1039
1055
  dynamicGroup: true,
1040
1056
  });
@@ -1048,7 +1064,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1048
1064
  cwd: cwd ?? ctx.cwd,
1049
1065
  });
1050
1066
  dynamicGroupStatuses[stepIndex].acceptance = groupAcceptance;
1051
- const groupAcceptanceFailure = acceptanceFailureMessage(groupAcceptance);
1067
+ const groupAcceptanceFailure = effectiveGroupAcceptance.explicit ? acceptanceFailureMessage(groupAcceptance) : undefined;
1052
1068
  if (groupAcceptanceFailure) {
1053
1069
  dynamicGroupStatuses[stepIndex] = { status: "failed", error: groupAcceptanceFailure, acceptance: groupAcceptance };
1054
1070
  return buildChainExecutionErrorResult(groupAcceptanceFailure, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length }));
@@ -1096,7 +1112,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1096
1112
 
1097
1113
  const templateHasPrevious = stepTemplate.includes("{previous}");
1098
1114
  const { prefix, suffix } = buildChainInstructions(
1099
- behavior,
1115
+ { ...behavior, output: false },
1100
1116
  chainDir,
1101
1117
  isFirstProgress,
1102
1118
  templateHasPrevious ? undefined : prev,
@@ -1110,17 +1126,19 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1110
1126
  stepTask = prefix + stepTask + suffix;
1111
1127
 
1112
1128
  const explicitStepModel = tuiOverride?.model ?? seqStep.model;
1113
- const effectiveModel = resolveSubagentModelOverride(
1114
- explicitStepModel ?? agentConfig.model,
1129
+ const effectiveModel = resolveEffectiveSubagentModel(
1130
+ explicitStepModel,
1131
+ agentConfig.model,
1115
1132
  ctx.model,
1116
1133
  availableModels,
1117
1134
  ctx.model?.provider,
1118
- { scope: modelScope, source: explicitStepModel ? "explicit" : "inherited" },
1135
+ { scope: modelScope },
1119
1136
  );
1120
1137
 
1121
1138
  const outputPath = typeof behavior.output === "string"
1122
1139
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
1123
1140
  : undefined;
1141
+ stepTask = injectSingleOutputInstruction(stepTask, outputPath, agentConfig);
1124
1142
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Chain step ${stepIndex + 1} (${seqStep.agent})`);
1125
1143
  if (validationError) {
1126
1144
  return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
@@ -1172,9 +1190,9 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1172
1190
  runId,
1173
1191
  index: childIndex,
1174
1192
  sessionDir: sessionDirForIndex(childIndex),
1175
- sessionFile: sessionFileForTask?.(seqStep.agent, childIndex)
1193
+ sessionFile: sessionFileForTask?.(seqStep.agent, childIndex, effectiveModel)
1176
1194
  ?? sessionFileForIndex?.(childIndex),
1177
- thinkingOverride: thinkingOverrideForTask?.(seqStep.agent, childIndex),
1195
+ thinkingOverride: thinkingOverrideForTask?.(seqStep.agent, childIndex, effectiveModel),
1178
1196
  share: shareEnabled,
1179
1197
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1180
1198
  artifactConfig,
@@ -1264,7 +1282,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1264
1282
  }
1265
1283
  if (r.detached) {
1266
1284
  return {
1267
- content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${r.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
1285
+ content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${r.agent}). Reply to the supervisor request first, then wait with subagent_wait({ id: "${runId}" }). Use subagent({ action: "status", id: "${runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.` }],
1268
1286
  details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: childIndex })),
1269
1287
  };
1270
1288
  }