@oh-my-pi/pi-coding-agent 17.0.3 → 17.0.5

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 (120) hide show
  1. package/CHANGELOG.md +88 -35
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/mnemopi/state.d.ts +32 -9
  17. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  18. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  19. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  20. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  21. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  22. package/dist/types/modes/interactive-mode.d.ts +2 -0
  23. package/dist/types/modes/print-mode.d.ts +4 -0
  24. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -0
  27. package/dist/types/sdk.d.ts +2 -0
  28. package/dist/types/session/agent-session.d.ts +19 -1
  29. package/dist/types/session/messages.d.ts +6 -0
  30. package/dist/types/task/types.d.ts +6 -6
  31. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  32. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  33. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  35. package/dist/types/tools/gh.d.ts +5 -3
  36. package/dist/types/tools/hub/index.d.ts +1 -1
  37. package/dist/types/tools/hub/jobs.d.ts +1 -0
  38. package/dist/types/tools/hub/types.d.ts +2 -0
  39. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  40. package/dist/types/utils/git.d.ts +33 -0
  41. package/dist/types/web/search/providers/codex.d.ts +1 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +150 -0
  44. package/src/advisor/advise-tool.ts +4 -0
  45. package/src/advisor/runtime.ts +38 -14
  46. package/src/async/job-manager.ts +3 -0
  47. package/src/cli/bench-cli.ts +8 -2
  48. package/src/cli/dry-balance-cli.ts +1 -0
  49. package/src/config/model-registry.ts +89 -8
  50. package/src/config/model-resolver.ts +78 -14
  51. package/src/config/models-config-schema.ts +1 -0
  52. package/src/config/settings.ts +3 -1
  53. package/src/dap/client.ts +168 -1
  54. package/src/dap/config.ts +51 -1
  55. package/src/dap/session.ts +575 -234
  56. package/src/dap/types.ts +6 -5
  57. package/src/discovery/agents.ts +2 -2
  58. package/src/extensibility/extensions/runner.ts +6 -4
  59. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  60. package/src/launch/broker.ts +34 -31
  61. package/src/launch/client.ts +7 -1
  62. package/src/launch/spawn-options.test.ts +31 -0
  63. package/src/launch/spawn-options.ts +17 -0
  64. package/src/lsp/types.ts +5 -1
  65. package/src/main.ts +17 -4
  66. package/src/mcp/transports/stdio.test.ts +9 -1
  67. package/src/mnemopi/backend.ts +1 -1
  68. package/src/mnemopi/embed-worker.ts +8 -7
  69. package/src/mnemopi/state.ts +45 -18
  70. package/src/modes/components/ask-dialog.ts +137 -73
  71. package/src/modes/components/status-line/component.ts +2 -2
  72. package/src/modes/components/status-line/segments.ts +20 -3
  73. package/src/modes/components/status-line/types.ts +3 -1
  74. package/src/modes/components/tool-execution.ts +5 -0
  75. package/src/modes/components/transcript-container.ts +18 -111
  76. package/src/modes/components/tree-selector.ts +10 -4
  77. package/src/modes/controllers/event-controller.ts +1 -2
  78. package/src/modes/controllers/input-controller.ts +1 -1
  79. package/src/modes/controllers/selector-controller.ts +29 -8
  80. package/src/modes/interactive-mode.ts +40 -8
  81. package/src/modes/noninteractive-dispose.test.ts +12 -1
  82. package/src/modes/print-mode.ts +21 -9
  83. package/src/modes/rpc/rpc-input.ts +38 -0
  84. package/src/modes/rpc/rpc-mode.ts +7 -2
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  86. package/src/modes/types.ts +2 -0
  87. package/src/modes/utils/ui-helpers.ts +3 -2
  88. package/src/prompts/tools/browser.md +3 -2
  89. package/src/prompts/tools/debug.md +2 -7
  90. package/src/prompts/tools/github.md +6 -1
  91. package/src/prompts/tools/hub.md +4 -2
  92. package/src/prompts/tools/task-async-contract.md +1 -0
  93. package/src/prompts/tools/task.md +9 -2
  94. package/src/sdk.ts +38 -6
  95. package/src/session/agent-session.ts +395 -162
  96. package/src/session/messages.test.ts +91 -0
  97. package/src/session/messages.ts +248 -110
  98. package/src/session/session-loader.ts +31 -3
  99. package/src/slash-commands/builtin-registry.ts +1 -0
  100. package/src/stt/recorder.ts +68 -55
  101. package/src/task/executor.ts +59 -33
  102. package/src/task/index.ts +46 -9
  103. package/src/task/types.ts +11 -8
  104. package/src/task/worktree.ts +10 -0
  105. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  106. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  107. package/src/tools/browser/cmux/socket-client.ts +139 -3
  108. package/src/tools/browser/run-cancellation.ts +4 -0
  109. package/src/tools/browser/tab-protocol.ts +2 -0
  110. package/src/tools/browser/tab-supervisor.ts +21 -11
  111. package/src/tools/browser/tab-worker.ts +199 -33
  112. package/src/tools/debug.ts +3 -0
  113. package/src/tools/gh.ts +40 -2
  114. package/src/tools/hub/index.ts +4 -1
  115. package/src/tools/hub/jobs.ts +42 -1
  116. package/src/tools/hub/types.ts +2 -0
  117. package/src/tools/tool-timeouts.ts +1 -1
  118. package/src/utils/git.ts +237 -0
  119. package/src/web/search/index.ts +9 -5
  120. package/src/web/search/providers/codex.ts +195 -99
@@ -11,6 +11,8 @@ export interface RecordingHandle {
11
11
  }
12
12
 
13
13
  const isWindows = process.platform === "win32";
14
+ const linuxFFmpegFormats = new Map<string, "pulse" | "alsa">();
15
+ const ffmpegCaptureFlags = ["-hide_banner", "-loglevel", "error", "-nostats"];
14
16
 
15
17
  /**
16
18
  * Returns available recording tools in priority order.
@@ -36,6 +38,36 @@ async function detectWindowsAudioDevice(bin: string): Promise<string> {
36
38
  return audioDevices[0];
37
39
  }
38
40
 
41
+ async function ffmpegInputArgs(bin: string): Promise<string[]> {
42
+ if (isWindows) {
43
+ return ["-f", "dshow", "-i", `audio=${await detectWindowsAudioDevice(bin)}`];
44
+ }
45
+ if (process.platform === "darwin") {
46
+ return ["-f", "avfoundation", "-i", ":default"];
47
+ }
48
+
49
+ let format = linuxFFmpegFormats.get(bin);
50
+ if (!format) {
51
+ const result = await $`${bin} -hide_banner -demuxers`.quiet().nothrow();
52
+ if (result.exitCode !== 0) {
53
+ const stderr = result.stderr.toString().trim();
54
+ throw new Error(
55
+ `Could not inspect ffmpeg input formats (code ${result.exitCode}): ${stderr || "(no output)"}`,
56
+ );
57
+ }
58
+ const demuxers = result.stdout.toString();
59
+ if (/^\s*D\s+(?:d\s+)?pulse(?:\s|$)/m.test(demuxers)) {
60
+ format = "pulse";
61
+ } else if (/^\s*D\s+(?:d\s+)?alsa(?:\s|$)/m.test(demuxers)) {
62
+ format = "alsa";
63
+ } else {
64
+ throw new Error("ffmpeg supports neither PulseAudio nor ALSA input on Linux");
65
+ }
66
+ linuxFFmpegFormats.set(bin, format);
67
+ }
68
+ return ["-f", format, "-i", "default"];
69
+ }
70
+
39
71
  // ── Recording implementations ──────────────────────────────────────
40
72
 
41
73
  async function startSoxRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
@@ -44,7 +76,7 @@ async function startSoxRecording(bin: string, outputPath: string): Promise<Recor
44
76
 
45
77
  const proc = Bun.spawn([bin, ...inputArgs, "-r", "16000", "-c", "1", "-b", "16", "-t", "wav", outputPath], {
46
78
  stdout: "pipe",
47
- stderr: "ignore",
79
+ stderr: "pipe",
48
80
  });
49
81
  await verifyProcessAlive(proc, "sox");
50
82
  return {
@@ -56,48 +88,24 @@ async function startSoxRecording(bin: string, outputPath: string): Promise<Recor
56
88
  }
57
89
 
58
90
  async function startFFmpegRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
59
- let args: string[];
60
- if (isWindows) {
61
- const device = await detectWindowsAudioDevice(bin);
62
- args = [
63
- bin,
64
- "-f",
65
- "dshow",
66
- "-i",
67
- `audio=${device}`,
68
- "-ar",
69
- "16000",
70
- "-ac",
71
- "1",
72
- "-sample_fmt",
73
- "s16",
74
- "-y",
75
- outputPath,
76
- ];
77
- } else if (process.platform === "darwin") {
78
- args = [
79
- bin,
80
- "-f",
81
- "avfoundation",
82
- "-i",
83
- ":default",
84
- "-ar",
85
- "16000",
86
- "-ac",
87
- "1",
88
- "-sample_fmt",
89
- "s16",
90
- "-y",
91
- outputPath,
92
- ];
93
- } else {
94
- args = [bin, "-f", "pulse", "-i", "default", "-ar", "16000", "-ac", "1", "-sample_fmt", "s16", "-y", outputPath];
95
- }
91
+ const args = [
92
+ bin,
93
+ ...ffmpegCaptureFlags,
94
+ ...(await ffmpegInputArgs(bin)),
95
+ "-ar",
96
+ "16000",
97
+ "-ac",
98
+ "1",
99
+ "-sample_fmt",
100
+ "s16",
101
+ "-y",
102
+ outputPath,
103
+ ];
96
104
 
97
105
  const proc = Bun.spawn(args, {
98
106
  stdin: "pipe",
99
107
  stdout: "pipe",
100
- stderr: "ignore",
108
+ stderr: "pipe",
101
109
  });
102
110
  await verifyProcessAlive(proc, "ffmpeg");
103
111
 
@@ -119,7 +127,7 @@ async function startFFmpegRecording(bin: string, outputPath: string): Promise<Re
119
127
  async function startArecordRecording(bin: string, outputPath: string): Promise<RecordingHandle> {
120
128
  const proc = Bun.spawn([bin, "-f", "S16_LE", "-r", "16000", "-c", "1", outputPath], {
121
129
  stdout: "pipe",
122
- stderr: "ignore",
130
+ stderr: "pipe",
123
131
  });
124
132
  await verifyProcessAlive(proc, "arecord");
125
133
  return {
@@ -260,20 +268,19 @@ async function startPowerShellRecording(outputPath: string): Promise<RecordingHa
260
268
 
261
269
  // ── Health check ───────────────────────────────────────────────────
262
270
 
263
- type RecorderProcess = Subprocess<"ignore" | "pipe", "pipe", "ignore">;
271
+ type RecorderProcess = Subprocess<"ignore" | "pipe", "pipe", "pipe">;
264
272
 
265
273
  async function verifyProcessAlive(proc: RecorderProcess, tool: string): Promise<void> {
266
274
  await Bun.sleep(300);
267
275
 
268
276
  const exited = await Promise.race([proc.exited.then(code => code), Bun.sleep(0).then(() => "running" as const)]);
269
-
270
- if (exited !== "running") {
271
- let stderr = "";
272
- if (proc.stderr && typeof proc.stderr !== "number") {
273
- stderr = await new Response(proc.stderr as ReadableStream).text();
274
- }
275
- throw new Error(`${tool} exited immediately (code ${exited}): ${stderr.trim() || "(no output)"}`);
277
+ if (exited === "running") {
278
+ void proc.stderr.pipeTo(new WritableStream<Uint8Array>()).catch(() => {});
279
+ return;
276
280
  }
281
+
282
+ const stderr = await new Response(proc.stderr).text();
283
+ throw new Error(`${tool} exited immediately (code ${exited}): ${stderr.trim() || "(no output)"}`);
277
284
  }
278
285
 
279
286
  // ── Public API ─────────────────────────────────────────────────────
@@ -415,12 +422,18 @@ async function streamingRecorderArgs(recorder: ResolvedRecorder): Promise<string
415
422
  case "arecord":
416
423
  return [bin, "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw", "-"];
417
424
  case "ffmpeg": {
418
- const input = isWindows
419
- ? ["-f", "dshow", "-i", `audio=${await detectWindowsAudioDevice(bin)}`]
420
- : process.platform === "darwin"
421
- ? ["-f", "avfoundation", "-i", ":default"]
422
- : ["-f", "pulse", "-i", "default"];
423
- return [bin, ...input, "-ar", "16000", "-ac", "1", "-f", "s16le", "pipe:1"];
425
+ return [
426
+ bin,
427
+ ...ffmpegCaptureFlags,
428
+ ...(await ffmpegInputArgs(bin)),
429
+ "-ar",
430
+ "16000",
431
+ "-ac",
432
+ "1",
433
+ "-f",
434
+ "s16le",
435
+ "pipe:1",
436
+ ];
424
437
  }
425
438
  case "powershell":
426
439
  throw new Error("PowerShell recorder cannot stream PCM to a pipe");
@@ -439,7 +452,7 @@ async function startStreamingRecordingWithRecorder(
439
452
  ): Promise<StreamingRecordingHandle> {
440
453
  const args = await streamingRecorderArgs(recorder);
441
454
  logger.debug("Starting streaming audio recording", { tool: recorder.tool, bin: recorder.bin });
442
- const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "ignore" });
455
+ const proc = Bun.spawn(args, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
443
456
 
444
457
  // Read s16le bytes off stdout, carrying any trailing odd byte across chunk
445
458
  // boundaries so a sample is never split. Runs until the process closes stdout.
@@ -15,6 +15,7 @@ import {
15
15
  formatModelSelectorValue,
16
16
  formatModelStringWithRouting,
17
17
  resolveAgentPrewalkPattern,
18
+ resolveConfiguredModelPatterns,
18
19
  resolveModelOverride,
19
20
  resolveModelOverrideWithAuthFallback,
20
21
  } from "../config/model-resolver";
@@ -161,22 +162,44 @@ function resolveSubagentRetryFallbackCandidates(
161
162
  return candidates;
162
163
  }
163
164
 
165
+ function resolveSubagentDefaultRetryFallbackChain(settings: Settings): string[] | undefined {
166
+ const fallbackChain = settings.get("retry.fallbackChains")?.default;
167
+ if (
168
+ !Array.isArray(fallbackChain) ||
169
+ fallbackChain.length === 0 ||
170
+ !fallbackChain.every(entry => typeof entry === "string")
171
+ ) {
172
+ return undefined;
173
+ }
174
+ return fallbackChain;
175
+ }
176
+
164
177
  function installSubagentRetryFallbackChain(args: {
165
178
  settings: Settings;
166
179
  id: string;
167
180
  candidates: SubagentRetryFallbackCandidate[];
181
+ defaultFallbackChain: string[] | undefined;
168
182
  model: Model<Api> | undefined;
169
183
  authFallbackUsed: boolean;
170
184
  }): string | undefined {
171
- const { settings, id, candidates, model, authFallbackUsed } = args;
172
- if (!model || authFallbackUsed || candidates.length <= 1) return undefined;
185
+ const { settings, id, candidates, defaultFallbackChain, model, authFallbackUsed } = args;
186
+ if (!model || authFallbackUsed || candidates.length === 0) return undefined;
173
187
 
174
188
  const selectedIndex = candidates.findIndex(
175
189
  candidate => candidate.model.provider === model.provider && candidate.model.id === model.id,
176
190
  );
177
191
  if (selectedIndex < 0) return undefined;
178
192
  const fallbackSelectors = candidates.slice(selectedIndex + 1).map(candidate => candidate.selector);
179
- if (fallbackSelectors.length === 0) return undefined;
193
+ const existingFallbackChains = settings.get("retry.fallbackChains");
194
+ // A single explicit model may reuse a configured default chain, but never an implicit parent fallback.
195
+ const fallbackChain = fallbackSelectors.length > 0 ? fallbackSelectors : defaultFallbackChain;
196
+ if (
197
+ !Array.isArray(fallbackChain) ||
198
+ fallbackChain.length === 0 ||
199
+ !fallbackChain.every(entry => typeof entry === "string")
200
+ ) {
201
+ return undefined;
202
+ }
180
203
 
181
204
  const role = `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`;
182
205
  const modelRoles: Record<string, string> = {};
@@ -189,10 +212,10 @@ function installSubagentRetryFallbackChain(args: {
189
212
  }
190
213
  modelRoles[role] = candidates[selectedIndex].selector;
191
214
  settings.override("modelRoles", modelRoles);
215
+ // Insert the task-specific role first so another role assigned to the same model cannot capture fallback routing.
192
216
  const fallbackChains: Record<string, string[]> = {
193
- [role]: fallbackSelectors,
217
+ [role]: fallbackChain,
194
218
  };
195
- const existingFallbackChains = settings.get("retry.fallbackChains");
196
219
  for (const existingRole in existingFallbackChains) {
197
220
  if (existingRole !== role) {
198
221
  fallbackChains[existingRole] = existingFallbackChains[existingRole];
@@ -914,7 +937,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
914
937
  const finalOutputChunks: string[] = [];
915
938
  const RECENT_OUTPUT_TAIL_BYTES = 8 * 1024;
916
939
  let recentOutputTail = "";
917
- let tailLastLineRepresentable = false;
940
+ let recentOutputDirty = false;
918
941
  let resolved = false;
919
942
  let abortSent = false;
920
943
  let abortReason: AbortReason | undefined;
@@ -1054,7 +1077,22 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1054
1077
  let lastProgressEmitMs = 0;
1055
1078
  let progressTimeoutId: NodeJS.Timeout | null = null;
1056
1079
 
1080
+ // Recompute progress.recentOutput from the capped tail. Deferred: text_delta
1081
+ // appends only extend the tail and mark it dirty; the (up to 8KB) split/filter
1082
+ // runs synchronously here, immediately before the ONLY places the progress
1083
+ // object is snapshotted ({...progress} for onProgress and the eventBus
1084
+ // progress channel, both inside emitProgressNow — including the
1085
+ // scheduleProgress(flush) finalize/error/cancel paths). Observers therefore
1086
+ // always see exact state; no staleness beyond the existing 150ms coalescing.
1087
+ const refreshRecentOutput = () => {
1088
+ if (!recentOutputDirty) return;
1089
+ recentOutputDirty = false;
1090
+ const filtered = recentOutputTail.split("\n").filter(line => line.trim());
1091
+ progress.recentOutput = filtered.slice(-8).reverse();
1092
+ };
1093
+
1057
1094
  const emitProgressNow = () => {
1095
+ refreshRecentOutput();
1058
1096
  progress.durationMs = Date.now() - startTime;
1059
1097
  onProgress?.({ ...progress });
1060
1098
  const activityGist =
@@ -1137,36 +1175,16 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1137
1175
  return message.usage;
1138
1176
  };
1139
1177
 
1140
- const updateRecentOutputLines = () => {
1141
- const lines = recentOutputTail.split("\n");
1142
- const filtered = lines.filter(line => line.trim());
1143
- progress.recentOutput = filtered.slice(-8).reverse();
1144
- // The tail's last raw segment (after its final newline) is "represented"
1145
- // in recentOutput only when it trims non-empty — an empty/whitespace-only
1146
- // trailing segment is filtered out, so recentOutput[0] is then the line
1147
- // before it, not the tail's true last line.
1148
- tailLastLineRepresentable = lines[lines.length - 1].trim().length > 0;
1149
- };
1150
-
1151
1178
  const appendRecentOutputTail = (text: string) => {
1152
1179
  if (!text) return;
1153
1180
  recentOutputTail += text;
1154
- const truncated = recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES;
1155
- if (truncated) {
1181
+ if (recentOutputTail.length > RECENT_OUTPUT_TAIL_BYTES) {
1156
1182
  recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
1157
1183
  }
1158
- // Fast path: a token without a newline only extends the current last line.
1159
- // This runs on every text_delta token (hundreds/thousands per second while
1160
- // streaming), so skip re-splitting the whole (up to 8KB) tail unless the line
1161
- // structure actually changed. Requires no truncation AND the tail's last line
1162
- // already represented (trims non-empty) — otherwise boundaries shift and a
1163
- // full recompute is required. Appending to a non-empty line keeps it non-empty,
1164
- // so the flag stays valid across consecutive fast-path tokens.
1165
- if (truncated || text.includes("\n") || !tailLastLineRepresentable || progress.recentOutput.length === 0) {
1166
- updateRecentOutputLines();
1167
- } else {
1168
- progress.recentOutput = [progress.recentOutput[0] + text, ...progress.recentOutput.slice(1)];
1169
- }
1184
+ // O(chunk) hot path: this runs on every text_delta token (hundreds/
1185
+ // thousands per second while streaming). Line reconstruction is deferred
1186
+ // to refreshRecentOutput() at the emit boundary.
1187
+ recentOutputDirty = true;
1170
1188
  };
1171
1189
 
1172
1190
  const replaceRecentOutputFromContent = (content: unknown[]) => {
@@ -1181,12 +1199,12 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1181
1199
  recentOutputTail = recentOutputTail.slice(-RECENT_OUTPUT_TAIL_BYTES);
1182
1200
  }
1183
1201
  }
1184
- updateRecentOutputLines();
1202
+ recentOutputDirty = true;
1185
1203
  };
1186
1204
 
1187
1205
  const resetRecentOutput = () => {
1188
1206
  recentOutputTail = "";
1189
- tailLastLineRepresentable = false;
1207
+ recentOutputDirty = false;
1190
1208
  progress.recentOutput = [];
1191
1209
  };
1192
1210
 
@@ -2319,6 +2337,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2319
2337
  }
2320
2338
  checkAbort();
2321
2339
 
2340
+ const configuredModelPatterns = resolveConfiguredModelPatterns(modelPatterns, settings);
2341
+ const defaultRetryFallbackChain =
2342
+ configuredModelPatterns.length === 1
2343
+ ? resolveSubagentDefaultRetryFallbackChain(subagentSettings)
2344
+ : undefined;
2322
2345
  const {
2323
2346
  model,
2324
2347
  thinkingLevel: resolvedThinkingLevel,
@@ -2352,6 +2375,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2352
2375
  settings: subagentSettings,
2353
2376
  id,
2354
2377
  candidates: resolveSubagentRetryFallbackCandidates(modelPatterns, modelRegistry, settings),
2378
+ defaultFallbackChain: defaultRetryFallbackChain,
2355
2379
  model,
2356
2380
  authFallbackUsed,
2357
2381
  });
@@ -2479,6 +2503,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2479
2503
  model || modelOverride === undefined ? undefined : options.parentActiveModelPattern,
2480
2504
  modelPatternFallbackRole:
2481
2505
  model || modelOverride === undefined ? undefined : `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`,
2506
+ modelPatternDefaultFallbackChain:
2507
+ model || modelOverride === undefined ? undefined : defaultRetryFallbackChain,
2482
2508
  thinkingLevel: effectiveThinkingLevel,
2483
2509
  toolNames,
2484
2510
  outputSchema,
package/src/task/index.ts CHANGED
@@ -21,6 +21,7 @@ import type { ToolSession } from "..";
21
21
  import type { Theme } from "../modes/theme/theme";
22
22
  import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
23
23
  import taskDescriptionTemplate from "../prompts/tools/task.md" with { type: "text" };
24
+ import taskAsyncContractTemplate from "../prompts/tools/task-async-contract.md" with { type: "text" };
24
25
  import taskSummaryTemplate from "../prompts/tools/task-summary.md" with { type: "text" };
25
26
  import { truncateForPrompt } from "../tools/approval";
26
27
  import { isIrcEnabled } from "../tools/hub";
@@ -894,14 +895,16 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
894
895
  failedSchedules.length > 0
895
896
  ? ` Failed to schedule ${failedSchedules.length} spawn${failedSchedules.length === 1 ? "" : "s"}: ${failedSchedules.join("; ")}.`
896
897
  : "";
897
- const coordinationHint =
898
+ const coordinationHint = [
898
899
  started.length === 1
899
900
  ? ircEnabled
900
901
  ? `DM \`${started[0].agentId}\` via \`hub\` send to coordinate while it runs; use \`hub\` only to inspect (\`jobs\`), wait, or cancel a stuck task.`
901
902
  : `Use \`hub\` to inspect (\`jobs\`), wait, or cancel a stuck task.`
902
903
  : ircEnabled
903
904
  ? `DM these ids via \`hub\` send to coordinate while they run; use \`hub\` only to inspect (\`jobs\`), wait, or cancel a stuck task.`
904
- : `Use \`hub\` to inspect (\`jobs\`), wait, or cancel a stuck task by id.`;
905
+ : `Use \`hub\` to inspect (\`jobs\`), wait, or cancel a stuck task by id.`,
906
+ taskAsyncContractTemplate.trim(),
907
+ ].join("\n");
905
908
 
906
909
  if (syncSpawns.length === 0) {
907
910
  if (spawns.length === 1) {
@@ -915,7 +918,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
915
918
  content: [
916
919
  {
917
920
  type: "text",
918
- text: `Spawned agent \`${agentId}\` (job \`${jobId}\`). The result will be delivered when it yields. ${coordinationHint}`,
921
+ text: `Spawned agent \`${agentId}\` (job \`${jobId}\`). Its result auto-delivers on yield unless a settled \`hub jobs\`/\`wait\` snapshot consumes it first. ${coordinationHint}`,
919
922
  },
920
923
  ],
921
924
  details: buildAsyncDetails(),
@@ -932,7 +935,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
932
935
  content: [
933
936
  {
934
937
  type: "text",
935
- text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${startedListing}\n${coordinationHint}`,
938
+ text: `Spawned ${started.length} background agents using ${agentLabel}.${scheduleFailureSummary} Each result auto-delivers on yield unless a settled \`hub jobs\`/\`wait\` snapshot consumes it first.\n${startedListing}\n${coordinationHint}`,
936
939
  },
937
940
  ],
938
941
  details: buildAsyncDetails(),
@@ -997,7 +1000,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
997
1000
 
998
1001
  const spawnedSummary =
999
1002
  started.length > 0
1000
- ? `Spawned ${started.length} background agent${started.length === 1 ? "" : "s"}.${scheduleFailureSummary} Each result will be delivered when that agent yields.\n${started.map(({ agentId, jobId }) => `- \`${agentId}\` (job \`${jobId}\`)`).join("\n")}\n${coordinationHint}`
1003
+ ? `Spawned ${started.length} background agent${started.length === 1 ? "" : "s"}.${scheduleFailureSummary} Each result auto-delivers on yield unless a settled \`hub jobs\`/\`wait\` snapshot consumes it first.\n${started.map(({ agentId, jobId }) => `- \`${agentId}\` (job \`${jobId}\`)`).join("\n")}\n${coordinationHint}`
1001
1004
  : scheduleFailureSummary.trim();
1002
1005
  const text = [merged.contentParts.join("\n\n"), spawnedSummary]
1003
1006
  .filter(section => section.trim().length > 0)
@@ -1078,12 +1081,41 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1078
1081
  try {
1079
1082
  markRunning();
1080
1083
  progress.status = "running";
1081
- await reportProgress(`Running background task ${agentId}...`);
1084
+ await reportProgress(
1085
+ `Running background task ${agentId}...`,
1086
+ buildDetails() as unknown as Record<string, unknown>,
1087
+ );
1088
+ const forwardSyncProgress: AgentToolUpdateCallback<TaskToolDetails> = async update => {
1089
+ const nextProgress = update.details?.progress?.[0];
1090
+ if (nextProgress) {
1091
+ // The job body owns status and identity (id/index/agent);
1092
+ // copy only the live metrics the subagent streams so the
1093
+ // polling row reflects the resolved model, reasoning level,
1094
+ // and running counters without reverting the "running"
1095
+ // status back to the subagent's initial "pending" snapshot.
1096
+ progress.resolvedModel = nextProgress.resolvedModel;
1097
+ progress.tokens = nextProgress.tokens;
1098
+ progress.requests = nextProgress.requests;
1099
+ progress.contextTokens = nextProgress.contextTokens;
1100
+ progress.contextWindow = nextProgress.contextWindow;
1101
+ progress.cost = nextProgress.cost;
1102
+ progress.toolCount = nextProgress.toolCount;
1103
+ progress.currentTool = nextProgress.currentTool;
1104
+ progress.lastIntent = nextProgress.lastIntent;
1105
+ progress.recentTools = nextProgress.recentTools.slice();
1106
+ progress.recentOutput = nextProgress.recentOutput.slice();
1107
+ progress.retryState = nextProgress.retryState;
1108
+ progress.retryFailure = nextProgress.retryFailure;
1109
+ }
1110
+ const updateText =
1111
+ update.content.find(part => part.type === "text")?.text ?? `Running background task ${agentId}...`;
1112
+ await reportProgress(updateText, buildDetails() as unknown as Record<string, unknown>);
1113
+ };
1082
1114
  const result = await this.#executeSync(
1083
1115
  toolCallId,
1084
1116
  spawnParams,
1085
1117
  runSignal,
1086
- undefined,
1118
+ forwardSyncProgress,
1087
1119
  agentId,
1088
1120
  progress.index,
1089
1121
  true,
@@ -1104,11 +1136,16 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1104
1136
  progress.extractedToolData = singleResult?.extractedToolData;
1105
1137
  progress.retryFailure = singleResult?.retryFailure;
1106
1138
  progress.retryState = undefined;
1139
+ if (singleResult?.resolvedModel) {
1140
+ progress.resolvedModel = singleResult.resolvedModel;
1141
+ } else {
1142
+ delete progress.resolvedModel;
1143
+ }
1107
1144
  onSettled?.(resultFailed);
1108
1145
  const statusText = resultFailed
1109
1146
  ? `Background task ${agentId} failed.`
1110
1147
  : `Background task ${agentId} complete.`;
1111
- await reportProgress(statusText);
1148
+ await reportProgress(statusText, buildDetails() as unknown as Record<string, unknown>);
1112
1149
  const deliveryText = `${finalText}${buildFollowUpHint(singleResult?.aborted === true)}`;
1113
1150
  if (resultFailed) {
1114
1151
  // Mark the job itself failed; the failed agent stays interrogable.
@@ -1123,7 +1160,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1123
1160
  progress.durationMs = Math.max(0, Date.now() - startedAt);
1124
1161
  onSettled?.(true);
1125
1162
  const statusText = `Background task ${agentId} failed.`;
1126
- await reportProgress(statusText);
1163
+ await reportProgress(statusText, buildDetails() as unknown as Record<string, unknown>);
1127
1164
  const message = error instanceof Error ? error.message : String(error);
1128
1165
  const hint = AgentRegistry.global().get(agentId) ? buildFollowUpHint(false) : "";
1129
1166
  throw new TaskJobError(`${message}${hint}`);
package/src/task/types.ts CHANGED
@@ -106,11 +106,14 @@ export interface SubagentLifecyclePayload {
106
106
  /** Display cap for a normalized one-line label (roster line, registry `displayName`, prompt field). */
107
107
  export const LABEL_MAX = 80;
108
108
 
109
+ // Keep this explicit: ArkType serializes `unknown` as a boolean subschema, which llama.cpp grammars reject.
110
+ const outputSchemaInputSchema = type("object | boolean | string | null");
111
+
109
112
  export const taskItemSchema = type({
110
113
  "name?": "string",
111
114
  agent: "string = 'task'",
112
115
  task: "string",
113
- "outputSchema?": "unknown",
116
+ "outputSchema?": outputSchemaInputSchema,
114
117
  "schemaMode?": '"permissive" | "strict"',
115
118
  "+": "delete",
116
119
  });
@@ -118,7 +121,7 @@ const taskItemSchemaIsolated = type({
118
121
  "name?": "string",
119
122
  agent: "string = 'task'",
120
123
  task: "string",
121
- "outputSchema?": "unknown",
124
+ "outputSchema?": outputSchemaInputSchema,
122
125
  "schemaMode?": '"permissive" | "strict"',
123
126
  "isolated?": "boolean",
124
127
  "+": "delete",
@@ -144,7 +147,7 @@ export const taskSchema = type({
144
147
  "name?": "string",
145
148
  agent: "string = 'task'",
146
149
  task: "string",
147
- "outputSchema?": "unknown",
150
+ "outputSchema?": outputSchemaInputSchema,
148
151
  "schemaMode?": '"permissive" | "strict"',
149
152
  "isolated?": "boolean",
150
153
  "+": "delete",
@@ -153,7 +156,7 @@ const taskSchemaNoIsolation = type({
153
156
  "name?": "string",
154
157
  agent: "string = 'task'",
155
158
  task: "string",
156
- "outputSchema?": "unknown",
159
+ "outputSchema?": outputSchemaInputSchema,
157
160
  "schemaMode?": '"permissive" | "strict"',
158
161
  "+": "delete",
159
162
  });
@@ -197,7 +200,7 @@ function createTaskSchema(options: {
197
200
  "name?": "string",
198
201
  agent,
199
202
  task: "string",
200
- "outputSchema?": "unknown",
203
+ "outputSchema?": outputSchemaInputSchema,
201
204
  "schemaMode?": '"permissive" | "strict"',
202
205
  "isolated?": "boolean",
203
206
  "+": "delete",
@@ -212,7 +215,7 @@ function createTaskSchema(options: {
212
215
  "name?": "string",
213
216
  agent,
214
217
  task: "string",
215
- "outputSchema?": "unknown",
218
+ "outputSchema?": outputSchemaInputSchema,
216
219
  "schemaMode?": '"permissive" | "strict"',
217
220
  "+": "delete",
218
221
  });
@@ -227,7 +230,7 @@ function createTaskSchema(options: {
227
230
  "name?": "string",
228
231
  agent,
229
232
  task: "string",
230
- "outputSchema?": "unknown",
233
+ "outputSchema?": outputSchemaInputSchema,
231
234
  "schemaMode?": '"permissive" | "strict"',
232
235
  "isolated?": "boolean",
233
236
  "+": "delete",
@@ -237,7 +240,7 @@ function createTaskSchema(options: {
237
240
  "name?": "string",
238
241
  agent,
239
242
  task: "string",
240
- "outputSchema?": "unknown",
243
+ "outputSchema?": outputSchemaInputSchema,
241
244
  "schemaMode?": '"permissive" | "strict"',
242
245
  "+": "delete",
243
246
  });
@@ -414,6 +414,8 @@ export async function ensureIsolation(
414
414
  preferred?: IsoBackendKind,
415
415
  ): Promise<IsolationHandle> {
416
416
  const repoRoot = await getRepoRoot(baseCwd);
417
+ const repository = await git.repo.resolve(repoRoot);
418
+ const sourceCommonDir = repository?.commonDir ?? path.join(repoRoot, ".git");
417
419
  const baseDir = getWorktreeDir(getTaskIsolationSegment(repoRoot, id));
418
420
  const mergedDir = path.join(baseDir, TASK_ISOLATION_MOUNT_DIR);
419
421
  const resolution = natives.isoResolve(preferred ?? null);
@@ -424,6 +426,14 @@ export async function ensureIsolation(
424
426
  await fs.rm(baseDir, { recursive: true, force: true });
425
427
  try {
426
428
  await natives.isoStart(candidate, repoRoot, mergedDir);
429
+ // Sever the isolation's git metadata from the source checkout. Copy
430
+ // backends duplicate `repoRoot`'s `.git` verbatim — a linked-worktree
431
+ // pointer file (or the rcopy `git worktree add` registration) leaves
432
+ // the isolation sharing the source's HEAD/index/ref namespace, so a
433
+ // task's git operations would mutate the parent checkout and stack
434
+ // parallel task branches. Detaching gives each isolation a private,
435
+ // frozen repo that still borrows the source object DB via alternates.
436
+ await git.detachGitDir(mergedDir, sourceCommonDir);
427
437
  return {
428
438
  mergedDir,
429
439
  backend: candidate,
@@ -1,4 +1,5 @@
1
1
  import type { ElementHandle, JSHandle, Page } from "puppeteer-core";
2
+ import { ToolError } from "../../tool-errors";
2
3
  import ariaBundle from "./aria-snapshot.bundle.txt" with { type: "text" };
3
4
  // `aria-snapshot.bundle.txt` is a generated, committed artifact: Playwright's
4
5
  // injected ARIA-snapshot sources (pinned, Apache-2.0) bundled to a CJS module.
@@ -69,15 +70,41 @@ export async function resolveAriaRefHandle(page: Page, ref: string): Promise<Ele
69
70
  const ARIA_REF_PREFIXES = ["aria-ref=", "aria-ref/", "ariaref/"];
70
71
 
71
72
  /**
72
- * Recognize the explicit `[ref=eN]` selector forms and return the bare ref id,
73
- * else null. Accepts `aria-ref=e5` (Playwright-MCP style), `aria-ref/e5`, and
74
- * `ariaref/e5` — lets `tab.click("aria-ref=e5")` etc. act on snapshot refs. A
75
- * bare `e5` is intentionally NOT a ref selector: the cmux backend already uses
76
- * bare `eN`/`@eN` for its own observe ids, so requiring the prefix keeps action
77
- * selectors meaning the same thing on both backends. (`tab.ref("e5")` still
78
- * accepts a bare id directly.)
73
+ * Guard the selector funnels: `tab.click`/`type`/`fill`/`waitFor*`/`scrollIntoView`
74
+ * take string selectors only, but user `run` code routinely passes the ElementHandle
75
+ * from `tab.id(n)`/`tab.ref(...)` (or an un-awaited Promise of one) straight in.
76
+ * Without this the value reaches `.trim()`/`.startsWith()` and throws the opaque,
77
+ * minified `A.trim is not a function` instead of a recovery-naming ToolError.
78
+ */
79
+ export function assertSelectorString(selector: unknown): asserts selector is string {
80
+ if (typeof selector === "string") return;
81
+ let kind: string;
82
+ if (selector !== null && typeof selector === "object") {
83
+ kind =
84
+ "then" in selector && typeof selector.then === "function" ? "a Promise (missing await?)" : "an ElementHandle";
85
+ } else {
86
+ kind = `a ${typeof selector}`;
87
+ }
88
+ throw new ToolError(
89
+ `Browser selector must be a string; got ${kind}. ` +
90
+ "tab.click/type/fill/waitFor take string selectors only — " +
91
+ 'call the handle method directly (e.g. (await tab.id(n)).click()) or pass a string like "aria-ref=eN".',
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Recognize a snapshot-ref selector and return the bare ref id, else null.
97
+ * Accepts `aria-ref=e5` (Playwright-MCP style), `aria-ref/e5`, `ariaref/e5`,
98
+ * and bare `e5`/`@e5`: agents copy ids straight out of the snapshot YAML
99
+ * (`[ref=e5]`), so `tab.click("e5")` must act on the ref instead of falling
100
+ * through to a CSS tag selector that can never match. Bare ids are safe to
101
+ * claim here — an eN tag name is not real HTML, and the tab-worker backend's
102
+ * observe ids are numeric (`tab.id(7)`), so refs are its only eN namespace.
103
+ * (The cmux backend parses selectors itself and routes bare `eN` to its own
104
+ * observe ids; either way `eN` means "the id from the last page dump".)
79
105
  */
80
106
  export function parseAriaRefSelector(selector: string): string | null {
107
+ assertSelectorString(selector);
81
108
  const trimmed = selector.trim();
82
109
  for (const prefix of ARIA_REF_PREFIXES) {
83
110
  if (trimmed.startsWith(prefix)) {
@@ -85,7 +112,8 @@ export function parseAriaRefSelector(selector: string): string | null {
85
112
  return /^e\d+$/.test(id) ? id : null;
86
113
  }
87
114
  }
88
- return null;
115
+ const bare = /^@?(e\d+)$/.exec(trimmed);
116
+ return bare ? bare[1]! : null;
89
117
  }
90
118
 
91
119
  /**