@ferris1225/pi-subagents 4.2.7 → 4.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dispatch.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * completion ownership live in thread-lifecycle.ts.
7
7
  */
8
8
 
9
- import { StringEnum } from "@earendil-works/pi-ai";
9
+ import { StringEnum, type Usage } from "@earendil-works/pi-ai";
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import { Text } from "@earendil-works/pi-tui";
12
12
  import { join, resolve } from "node:path";
@@ -19,6 +19,8 @@ import {
19
19
  formatToolActivity,
20
20
  monitor,
21
21
  statusIcon,
22
+ statusLabel,
23
+ sumUsage,
22
24
  type RunView,
23
25
  type RunWaitReason,
24
26
  } from "./monitor.ts";
@@ -32,6 +34,7 @@ import {
32
34
  type SingleResult,
33
35
  type SubagentDetails,
34
36
  type SubagentLiveEvent,
37
+ type UsageStats,
35
38
  } from "./spawn.ts";
36
39
  import {
37
40
  createBackgroundDispatcher,
@@ -116,6 +119,31 @@ export function defaultIsolationMode(
116
119
  return mode === "parallel" && writeCapable ? "worktree" : "shared";
117
120
  }
118
121
 
122
+ /** Map the child's own usage tally onto pi's tool-result `Usage`, so sub-agent
123
+ * token spend lands in the parent's footer, /session, and RPC session totals
124
+ * instead of being invisible. Only the total cost is known here: a child
125
+ * reports one cost number, not a per-bucket split. */
126
+ function toToolUsage(stats: UsageStats): Usage {
127
+ return {
128
+ input: stats.input,
129
+ output: stats.output,
130
+ cacheRead: stats.cacheRead,
131
+ cacheWrite: stats.cacheWrite,
132
+ totalTokens: stats.input + stats.output + stats.cacheRead + stats.cacheWrite,
133
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: stats.cost },
134
+ };
135
+ }
136
+
137
+ /** Usage of the runs awaited in-turn. Omitted entirely in the background path:
138
+ * those children have not finished when the tool returns, so any number there
139
+ * would be a fabrication. */
140
+ function toolUsage(runtime: SubagentRuntime, runIds: number[]): { usage?: Usage } {
141
+ const parts = runIds
142
+ .map((id) => runtime.settledRuns.get(id)?.usage)
143
+ .filter((usage): usage is UsageStats => usage !== undefined);
144
+ return parts.length > 0 ? { usage: toToolUsage(sumUsage(parts)) } : {};
145
+ }
146
+
119
147
  /** In-turn wait behind dispatch `wait: true` — the escape hatch for one-shot
120
148
  * `pi -p` parents that exit at end of turn: hold the call until every run it
121
149
  * started settles, then hand back their result blocks. Interactive sessions
@@ -130,6 +158,7 @@ export async function awaitRunResults(
130
158
  signal: AbortSignal | undefined,
131
159
  maxResultLines: number,
132
160
  fallbackCwd: string,
161
+ onProgress?: (text: string) => void,
133
162
  ): Promise<string> {
134
163
  const waitForRun = (runId: number): Promise<{ result?: SingleResult; note?: string }> => {
135
164
  const already = runtime.settledRuns.get(runId);
@@ -189,12 +218,38 @@ export async function awaitRunResults(
189
218
  else signal?.addEventListener("abort", onAbort, { once: true });
190
219
  });
191
220
  };
192
- const outcomes = await Promise.all(runIds.map(waitForRun));
193
- return outcomes.map((outcome) =>
194
- outcome.result
195
- ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) })
196
- : (outcome.note ?? "(no outcome)"),
197
- ).join("\n\n");
221
+ // One shared subscription drives the progress line: each waiter already
222
+ // subscribes for its own settlement, and the tool card wants a single
223
+ // rolled-up line rather than one per run.
224
+ let lastProgress: string | undefined;
225
+ const emitProgress = onProgress
226
+ ? (): void => {
227
+ const parts = runIds.map((id) => {
228
+ const settled = runtime.settledRuns.get(id);
229
+ if (settled) return `#${id} ${isFailedResult(settled) ? "failed" : "done"}`;
230
+ const live = monitor.findRun(id);
231
+ return live ? `#${id} ${statusLabel(live.status)}` : `#${id} …`;
232
+ });
233
+ const text = `Waiting in-turn on ${runIds.length} run${runIds.length === 1 ? "" : "s"} · ${parts.join(", ")}`;
234
+ // The monitor notifies on every usage and activity change; this line
235
+ // names only statuses, so most notifications leave it identical.
236
+ if (text === lastProgress) return;
237
+ lastProgress = text;
238
+ onProgress(text);
239
+ }
240
+ : undefined;
241
+ const progressUnsub = emitProgress ? monitor.subscribe(emitProgress) : undefined;
242
+ emitProgress?.();
243
+ try {
244
+ const outcomes = await Promise.all(runIds.map(waitForRun));
245
+ return outcomes.map((outcome) =>
246
+ outcome.result
247
+ ? formatCompletionBlock(outcome.result, maxResultLines, { resultRoot: projectResultsRoot(runtime.configPath, outcome.result.projectCwd ?? fallbackCwd) })
248
+ : (outcome.note ?? "(no outcome)"),
249
+ ).join("\n\n");
250
+ } finally {
251
+ progressUnsub?.();
252
+ }
198
253
  }
199
254
 
200
255
  export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime): void {
@@ -203,16 +258,22 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
203
258
  // refreshes the fallback context, config, and agent catalog it resolves.
204
259
  const environmentRef: { current: DispatchEnvironment | undefined } = { current: undefined };
205
260
 
206
- // Finished runs leave the active monitor immediately. Their final findings
207
- // are sent as a custom message that starts a follow-up turn.
261
+ // Terminal rows stay in the monitor until the next beginTurn so the footer
262
+ // can count them beside siblings that are still live. The widget ignores
263
+ // them. A second finishRun for the same endedAt is a no-op; a resume
264
+ // clears endedAt, so the next settlement notifies again.
265
+ const publishedEndedAt = new Map<number, number>();
208
266
  const finishRun = (
209
267
  runId: number,
210
268
  status: "done" | "failed",
211
269
  opts?: { silent?: boolean },
212
270
  ): void => {
271
+ const run = monitor.findRun(runId);
272
+ if (!run) return;
213
273
  monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
214
- const run = monitor.removeRun(runId);
215
- if (!run) return; // already finished stay idempotent
274
+ const endedAt = monitor.findRun(runId)?.endedAt;
275
+ if (endedAt !== undefined && publishedEndedAt.get(runId) === endedAt) return;
276
+ if (endedAt !== undefined) publishedEndedAt.set(runId, endedAt);
216
277
  if (opts?.silent || !runtime.sessionActive) return;
217
278
  const icon = status === "done" ? "✓" : "✗";
218
279
  environmentRef.current?.ctx.ui.notify(`${icon} #${run.id} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
@@ -332,7 +393,13 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
332
393
  "Dispatch isolated background agents for recon, implementation, cleanup, docs sync, or result merging; never blocks your turn, and completions wake you automatically.",
333
394
  parameters: SubagentParams,
334
395
 
335
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
396
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
397
+ // `wait: true` holds this call for minutes and would otherwise show a
398
+ // blank card; the background path returns at once and has nothing to
399
+ // stream. Frames carry the final details shape because renderResult
400
+ // falls back to "(no output)" without it.
401
+ const makeProgress = (details: SubagentDetails): ((text: string) => void) | undefined =>
402
+ onUpdate ? (text: string): void => onUpdate({ content: [{ type: "text", text }], details }) : undefined;
336
403
  // Run ids are allocated below; restore raises the allocator above every
337
404
  // id a durable record still owns, so a dispatch racing it could hand a
338
405
  // fresh run the id of a parked thread and overwrite its record.
@@ -438,7 +505,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
438
505
  const startedIds = startedRuns
439
506
  .map((result) => result.runId)
440
507
  .filter((id): id is number => id !== undefined);
441
- const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd);
508
+ const blocks = await awaitRunResults(runtime, startedIds, signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("parallel", true)(results)));
442
509
  const text = [
443
510
  `Started ${started} subagent${started === 1 ? "" : "s"} (${startedRefs.join(", ")}) and waited in-turn.`,
444
511
  ...(failureLines.length > 0
@@ -450,6 +517,7 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
450
517
  return {
451
518
  content: [{ type: "text", text }],
452
519
  details: makeDetails("parallel", true)(results),
520
+ ...toolUsage(runtime, startedIds),
453
521
  };
454
522
  }
455
523
  const text = [
@@ -483,10 +551,11 @@ export function registerSubagentTool(pi: ExtensionAPI, runtime: SubagentRuntime)
483
551
  }
484
552
  const runRef = result.runId === undefined ? result.agent : `#${result.runId} ${result.agent}`;
485
553
  if (params.wait && result.runId !== undefined) {
486
- const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd);
554
+ const blocks = await awaitRunResults(runtime, [result.runId], signal, config.maxResultLines, ctx.cwd, makeProgress(makeDetails("single", true)([result])));
487
555
  return {
488
556
  content: [{ type: "text", text: `Started ${runRef} and waited in-turn.\n\n${blocks}` }],
489
557
  details: makeDetails("single", true)([result]),
558
+ ...toolUsage(runtime, [result.runId]),
490
559
  };
491
560
  }
492
561
  return {
package/src/format.ts CHANGED
@@ -1,165 +1,183 @@
1
- /**
2
- * Pure formatting/result helpers shared by the subagent tool and completion
3
- * delivery: usage rendering, completion blocks, synthetic result constructors,
4
- * and run-id matching.
5
- */
6
-
7
- import type { AgentConfig } from "./agents.ts";
8
- import { formatTaskSummary } from "./monitor.ts";
9
- import { emptyUsage } from "./rpc-run.ts";
10
- import {
11
- getResultOutput,
12
- isFailedResult,
13
- truncateResultOutput,
14
- writeResultArtifact,
15
- type SingleResult,
16
- type UsageStats,
17
- } from "./spawn.ts";
18
-
19
- export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
20
- return {
21
- agent: agent.name,
22
- task,
23
- exitCode: -1,
24
- messages: [],
25
- stderr: "",
26
- usage: emptyUsage(),
27
- model: agent.model,
28
- ...(thinking ? { thinking } : {}),
29
- };
30
- }
31
-
32
- export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
33
- return {
34
- agent: agentName,
35
- task,
36
- exitCode: 1,
37
- messages: [],
38
- stderr: errorMessage,
39
- usage: emptyUsage(),
40
- errorMessage,
41
- dispatchFailed: true,
42
- };
43
- }
44
-
45
- /** Failed result for a background task that crashed with an exception (spawn
46
- * infra, delivery API, ...) instead of returning a normal result. */
47
- export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
48
- const errorMessage = error instanceof Error ? error.message : String(error);
49
- return {
50
- ...queuedResult(agent, task, thinking),
51
- exitCode: 1,
52
- stderr: errorMessage,
53
- stopReason: "error",
54
- errorMessage,
55
- dispatchFailed: true,
56
- };
57
- }
58
-
59
- function formatTokens(count: number): string {
60
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
61
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
62
- return String(count);
63
- }
64
-
65
- export function formatUsage(usage: UsageStats): string {
66
- const parts: string[] = [];
67
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
68
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
69
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
70
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
71
- if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
72
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
73
- return parts.join(" ");
74
- }
75
-
76
- export interface CompletionFormatOptions {
77
- /** Project-scoped directory the full result is written to when the output
78
- * is truncated: <projectRoot>/results. */
79
- resultRoot?: string;
80
- }
81
-
82
- export function formatCompletionBlock(
83
- result: SingleResult,
84
- maxResultLines: number,
85
- options: CompletionFormatOptions = {},
86
- ): string {
87
- const failed = isFailedResult(result);
88
- const failedTools = result.failedTools ?? [];
89
- const status = failed ? "failed" : "completed";
90
- const usage = formatUsage(result.usage);
91
- const output = getResultOutput(result);
92
- const { text, truncated } = truncateResultOutput(output, maxResultLines);const fallbackNote = result.modelFallbackFrom
93
- ? ` (selected model ${result.modelFallbackFrom} failed → main ${result.model ?? "dynamic default"})`
94
- : "";
95
- const startupRetryNote = result.startupRetries
96
- ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
97
- : "";
98
- const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
99
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
100
- if (result.isolation === "worktree") {
101
- const isolation =
102
- result.integrationStatus === "integrated"
103
- ? "worktree · changes integrated into the original working tree"
104
- : result.integrationStatus === "no_changes"
105
- ? "worktree · no changes; temporary worktree removed"
106
- : result.integrationStatus === "retained"
107
- ? result.integrationApplied
108
- ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
109
- : "worktree · integration failed; recovery artifacts retained"
110
- : "worktree · isolated";
111
- lines.push(`Isolation: ${isolation}`);
112
- if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
113
- if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
114
- if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
115
- lines.push("");
116
- }
117
- lines.push(text);
118
- // Failed-tool diagnostics ride along only when the run itself failed: they
119
- // explain the failure, while on a successful run a transient failed call
120
- // (no-match grep, rejected edit) is noise the agent already worked around.
121
- if (failed && failedTools.length > 0) {
122
- lines.push(
123
- "",
124
- `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
125
- ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
126
- );
127
- }
128
- if (truncated) {
129
- // The full text lives on disk so the main agent can read it on demand.
130
- const artifact = options.resultRoot
131
- ? writeResultArtifact(output, result.agent, options.resultRoot)
132
- : "(result root unavailable)";
133
- lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${artifact})`);
134
- }
135
- return lines.join("\n");
136
- }
137
-
138
- /** Instruction appended to a model-level failure: the sub-agent's provider never
139
- * produced usable output (or the run stalled), so the task is handed back to the
140
- * main window instead of being left as a dead failure. When the run preserved a
141
- * session with earlier work (and the run id is known), steer the main agent to
142
- * RESUME it in-context once a model is available, instead of re-dispatching
143
- * fresh (which would re-scan everything). */
144
- export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
145
- const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
146
- const detail = result.errorMessage?.trim();
147
- const cause = detail
148
- ? `its model/provider call failed (${detail})`
149
- : "its model was unavailable or failed (or the run stalled)";
150
- const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
151
- const recovery = sessionPreserved
152
- ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
153
- : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
154
- return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
155
- }
156
-
157
- /** Resolve a run-id request to actual ids: an exact numeric match always wins
158
- * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
159
- * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
160
- * from returning or, for subagent_stop, acting on a whole prefix family. */
161
- export function matchRunIds(ids: number[], requested: string): number[] {
162
- const exact = ids.filter((id) => String(id) === requested);
163
- if (exact.length > 0) return exact;
164
- return ids.filter((id) => String(id).startsWith(requested));
165
- }
1
+ /**
2
+ * Pure formatting/result helpers shared by the subagent tool and completion
3
+ * delivery: usage rendering, completion blocks, synthetic result constructors,
4
+ * and run-id matching.
5
+ */
6
+
7
+ import type { AgentConfig } from "./agents.ts";
8
+ import { runLabel, shrinkRunLabel } from "./monitor.ts";
9
+ import { emptyUsage } from "./rpc-run.ts";
10
+ import {
11
+ RESULT_LINE_MAX,
12
+ getResultOutput,
13
+ isFailedResult,
14
+ truncateResultOutput,
15
+ writeResultArtifact,
16
+ type SingleResult,
17
+ type UsageStats,
18
+ } from "./spawn.ts";
19
+
20
+ export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
21
+ return {
22
+ agent: agent.name,
23
+ task,
24
+ exitCode: -1,
25
+ messages: [],
26
+ stderr: "",
27
+ usage: emptyUsage(),
28
+ model: agent.model,
29
+ ...(thinking ? { thinking } : {}),
30
+ };
31
+ }
32
+
33
+ export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
34
+ return {
35
+ agent: agentName,
36
+ task,
37
+ exitCode: 1,
38
+ messages: [],
39
+ stderr: errorMessage,
40
+ usage: emptyUsage(),
41
+ errorMessage,
42
+ dispatchFailed: true,
43
+ };
44
+ }
45
+
46
+ /** Failed result for a background task that crashed with an exception (spawn
47
+ * infra, delivery API, ...) instead of returning a normal result. */
48
+ export function dispatchFailedResult(agent: AgentConfig, task: string, error: unknown, thinking?: string): SingleResult {
49
+ const errorMessage = error instanceof Error ? error.message : String(error);
50
+ return {
51
+ ...queuedResult(agent, task, thinking),
52
+ exitCode: 1,
53
+ stderr: errorMessage,
54
+ stopReason: "error",
55
+ errorMessage,
56
+ dispatchFailed: true,
57
+ };
58
+ }
59
+
60
+ function formatTokens(count: number): string {
61
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
62
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
63
+ return String(count);
64
+ }
65
+
66
+ export function formatUsage(usage: UsageStats): string {
67
+ const parts: string[] = [];
68
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
69
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
70
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
71
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
72
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
73
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
74
+ return parts.join(" ");
75
+ }
76
+
77
+ export interface CompletionFormatOptions {
78
+ /** Project-scoped directory the full result is written to when the output
79
+ * is truncated: <projectRoot>/results. */
80
+ resultRoot?: string;
81
+ }
82
+
83
+ /** Display width of the task-derived label folded into the completion heading:
84
+ * tight enough to stay a hint rather than a second copy of the task. */
85
+ const COMPLETION_LABEL_MAX = 24;
86
+
87
+ export function formatCompletionBlock(
88
+ result: SingleResult,
89
+ maxResultLines: number,
90
+ options: CompletionFormatOptions = {},
91
+ ): string {
92
+ const failed = isFailedResult(result);
93
+ const failedTools = result.failedTools ?? [];
94
+ const status = failed ? "failed" : "completed";
95
+ const usage = formatUsage(result.usage);
96
+ const output = getResultOutput(result);
97
+ const { text, truncated, shownLines, totalLines, widthClipped } = truncateResultOutput(output, maxResultLines);
98
+ const fallbackNote = result.modelFallbackFrom
99
+ ? ` (selected model ${result.modelFallbackFrom} failed main ${result.model ?? "dynamic default"})`
100
+ : "";
101
+ const startupRetryNote = result.startupRetries
102
+ ? ` (recovered after ${result.startupRetries} startup retr${result.startupRetries === 1 ? "y" : "ies"} — concurrent pi startup race)`
103
+ : "";
104
+ const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
105
+ // A short task-derived label, not the task itself: the parent authored the
106
+ // task and still has it in context, but a wide fan-out of same-agent runs
107
+ // needs more than a run id to tell completions apart.
108
+ const label = shrinkRunLabel(runLabel(result.task), COMPLETION_LABEL_MAX);
109
+ const lines = [
110
+ `### [${result.agent}${label ? `·${label}` : ""}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${runNote}`,
111
+ "",
112
+ ];
113
+ if (result.isolation === "worktree") {
114
+ const isolation =
115
+ result.integrationStatus === "integrated"
116
+ ? "worktree · changes integrated into the original working tree"
117
+ : result.integrationStatus === "no_changes"
118
+ ? "worktree · no changes; temporary worktree removed"
119
+ : result.integrationStatus === "retained"
120
+ ? result.integrationApplied
121
+ ? "worktree · changes applied, but cleanup failed; recovery artifacts retained"
122
+ : "worktree · integration failed; recovery artifacts retained"
123
+ : "worktree · isolated";
124
+ lines.push(`Isolation: ${isolation}`);
125
+ if (result.integrationWorktreePath) lines.push(`Retained worktree: ${result.integrationWorktreePath}`);
126
+ if (result.integrationPatchPath) lines.push(`Retained patch: ${result.integrationPatchPath}`);
127
+ if (result.integrationError) lines.push(`Integration error: ${result.integrationError}`);
128
+ lines.push("");
129
+ }
130
+ lines.push(text);
131
+ // Failed-tool diagnostics ride along only when the run itself failed: they
132
+ // explain the failure, while on a successful run a transient failed call
133
+ // (no-match grep, rejected edit) is noise the agent already worked around.
134
+ if (failed && failedTools.length > 0) {
135
+ lines.push(
136
+ "",
137
+ `⚠ ${failedTools.length} failed tool call${failedTools.length === 1 ? "" : "s"}:`,
138
+ ...failedTools.map((tool) => `- ${tool.toolName}: ${tool.error.trim() || "(no output)"}`),
139
+ );
140
+ }
141
+ if (truncated) {
142
+ // The full text lives on disk so the main agent can read it on demand.
143
+ const artifact = options.resultRoot
144
+ ? writeResultArtifact(output, result.agent, options.resultRoot)
145
+ : "(result root unavailable)";
146
+ // State the real loss and condition the read: handing the parent both a
147
+ // summary and a full-text entrance invites the same content twice.
148
+ const lineLoss = shownLines < totalLines ? `${shownLines} of ${totalLines} lines shown` : `${shownLines} line${shownLines === 1 ? "" : "s"} shown`;
149
+ const widthLoss = widthClipped ? `clipped to ${RESULT_LINE_MAX} characters` : undefined;
150
+ const loss = widthLoss ? `${lineLoss}, ${widthLoss}` : lineLoss;
151
+ lines.push("", `(${loss}; full result ${artifact} — read only if these are insufficient)`);
152
+ }
153
+ return lines.join("\n");
154
+ }
155
+
156
+ /** Instruction appended to a model-level failure: the sub-agent's provider never
157
+ * produced usable output (or the run stalled), so the task is handed back to the
158
+ * main window instead of being left as a dead failure. When the run preserved a
159
+ * session with earlier work (and the run id is known), steer the main agent to
160
+ * RESUME it in-context once a model is available, instead of re-dispatching
161
+ * fresh (which would re-scan everything). */
162
+ export function modelLevelTakeoverNote(result: SingleResult, opts?: { runId?: number }): string {
163
+ const retry = result.modelFallbackFrom ? ", and the current main model also failed" : "";
164
+ const detail = result.errorMessage?.trim();
165
+ const cause = detail
166
+ ? `its model/provider call failed (${detail})`
167
+ : "its model was unavailable or failed (or the run stalled)";
168
+ const sessionPreserved = Boolean(result.sessionDir && result.sessionId) && opts?.runId !== undefined;
169
+ const recovery = sessionPreserved
170
+ ? ` The sub-agent's earlier work in this run is preserved. Once a model is available again, call subagent_control with { action: "resume", id: ${opts!.runId} } to CONTINUE it in-context (it keeps the same run id and does not re-scan), or execute the task in the main window with your own tools.`
171
+ : ` Please execute this task in the main window with your own tools; do not re-dispatch it as a sub-agent.`;
172
+ return `The sub-agent could not complete this task: ${cause}${retry}.${recovery}`;
173
+ }
174
+
175
+ /** Resolve a run-id request to actual ids: an exact numeric match always wins
176
+ * (so "1" never fans out to 10, 11, …); only when no exact match exists does a
177
+ * prefix match run, as a convenience for partial ids. Keeps single-digit lookups
178
+ * from returning — or, for subagent_stop, acting on — a whole prefix family. */
179
+ export function matchRunIds(ids: number[], requested: string): number[] {
180
+ const exact = ids.filter((id) => String(id) === requested);
181
+ if (exact.length > 0) return exact;
182
+ return ids.filter((id) => String(id).startsWith(requested));
183
+ }