@mono-agent/agent-runtime 0.20.14 → 0.21.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 (82) hide show
  1. package/ARCHITECTURE.md +50 -11
  2. package/MIGRATION.md +30 -7
  3. package/README.md +219 -35
  4. package/package.json +9 -4
  5. package/src/agent/tool-bloat.js +145 -9
  6. package/src/agent/tools/agent-tool.js +104 -5
  7. package/src/agent/tools/bash.js +10 -2
  8. package/src/agent/tools/codex-subscription-search.js +122 -28
  9. package/src/agent/tools/exec.js +10 -2
  10. package/src/agent/tools/monitor.js +11 -2
  11. package/src/agent/tools/pi-bridge.js +33 -14
  12. package/src/agent/tools/shared/monitors.js +22 -3
  13. package/src/agent/tools/shared/path-resolver.js +25 -6
  14. package/src/agent/tools/shared/process-jobs.js +6 -1
  15. package/src/agent/tools/shared/process-runner.js +3 -1
  16. package/src/agent/tools/shared/tool-context.js +8 -0
  17. package/src/agent/tools/web-access-interstitial.js +70 -0
  18. package/src/agent/tools/web-browser-render.js +83 -58
  19. package/src/agent/tools/web-controller.js +112 -21
  20. package/src/agent/tools/web-document-extractor.js +379 -0
  21. package/src/agent/tools/web-fetch.js +271 -243
  22. package/src/agent/tools/web-request.js +65 -0
  23. package/src/agent/tools/web-search-output.js +165 -0
  24. package/src/agent/tools/web-search-state.js +75 -0
  25. package/src/agent/tools/web-search.js +532 -71
  26. package/src/ai/failure.js +3 -3
  27. package/src/ai/index.js +1 -0
  28. package/src/ai/observer.js +8 -0
  29. package/src/ai/pi-interop.js +156 -0
  30. package/src/ai/provider-check.js +131 -0
  31. package/src/ai/providers/pi-native/compaction-driver.js +45 -21
  32. package/src/ai/providers/pi-native/compaction-summary.js +140 -0
  33. package/src/ai/providers/pi-native/harness-adapter.js +40 -2
  34. package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
  35. package/src/ai/providers/pi-native/provider-attribution.js +102 -0
  36. package/src/ai/providers/pi-native/result-builder.js +28 -4
  37. package/src/ai/providers/pi-native/session-lifecycle.js +167 -24
  38. package/src/ai/providers/pi-native/stream-subscriber.js +30 -2
  39. package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
  40. package/src/ai/providers/pi-native/turn-runner.js +245 -13
  41. package/src/ai/providers/pi-native.js +159 -40
  42. package/src/ai/runtime/live-input-events.js +250 -54
  43. package/src/ai/runtime/router.js +30 -11
  44. package/src/ai/tool-lifecycle.js +32 -18
  45. package/src/ai/types.js +26 -5
  46. package/src/runtime.js +24 -5
  47. package/types/agent/tool-bloat.d.ts +1 -1
  48. package/types/agent/tools/agent-tool.d.ts +4 -1
  49. package/types/agent/tools/bash.d.ts +5 -3
  50. package/types/agent/tools/codex-subscription-search.d.ts +6 -2
  51. package/types/agent/tools/exec.d.ts +5 -3
  52. package/types/agent/tools/monitor.d.ts +5 -2
  53. package/types/agent/tools/pi-bridge.d.ts +6 -4
  54. package/types/agent/tools/shared/monitors.d.ts +17 -2
  55. package/types/agent/tools/shared/process-jobs.d.ts +5 -1
  56. package/types/agent/tools/shared/process-runner.d.ts +3 -2
  57. package/types/agent/tools/shared/tool-context.d.ts +2 -0
  58. package/types/agent/tools/web-access-interstitial.d.ts +23 -0
  59. package/types/agent/tools/web-browser-render.d.ts +4 -1
  60. package/types/agent/tools/web-controller.d.ts +4 -2
  61. package/types/agent/tools/web-document-extractor.d.ts +27 -0
  62. package/types/agent/tools/web-fetch.d.ts +19 -24
  63. package/types/agent/tools/web-request.d.ts +20 -0
  64. package/types/agent/tools/web-search-output.d.ts +31 -0
  65. package/types/agent/tools/web-search-state.d.ts +21 -0
  66. package/types/agent/tools/web-search.d.ts +10 -45
  67. package/types/ai/index.d.ts +1 -0
  68. package/types/ai/observer.d.ts +6 -0
  69. package/types/ai/pi-interop.d.ts +61 -0
  70. package/types/ai/provider-check.d.ts +53 -0
  71. package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
  72. package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
  73. package/types/ai/providers/pi-native/harness-adapter.d.ts +3 -1
  74. package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
  75. package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
  76. package/types/ai/providers/pi-native/result-builder.d.ts +11 -1
  77. package/types/ai/providers/pi-native/session-lifecycle.d.ts +23 -5
  78. package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
  79. package/types/ai/providers/pi-native/turn-runner.d.ts +36 -5
  80. package/types/ai/runtime/live-input-events.d.ts +32 -8
  81. package/types/ai/tool-lifecycle.d.ts +4 -3
  82. package/types/ai/types.d.ts +140 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.20.14",
3
+ "version": "0.21.0",
4
4
  "description": "Agent runtime backed by the Pi SDK provider catalog",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
@@ -100,22 +100,27 @@
100
100
  "LICENSE"
101
101
  ],
102
102
  "engines": {
103
- "node": ">=22.19.0"
103
+ "node": ">=24.15.0"
104
104
  },
105
105
  "dependencies": {
106
- "@earendil-works/pi-agent-core": "0.85.0",
107
- "@earendil-works/pi-ai": "0.85.0",
106
+ "@earendil-works/pi-agent-core": "0.85.1",
107
+ "@earendil-works/pi-ai": "0.85.1",
108
108
  "@modelcontextprotocol/sdk": "^1.29.0",
109
109
  "@mozilla/readability": "0.6.0",
110
110
  "@vscode/ripgrep": "1.18.0",
111
111
  "bmp-ts": "1.0.9",
112
112
  "cross-spawn": "^7.0.6",
113
113
  "defuddle": "0.19.2",
114
+ "fast-xml-parser": "5.7.3",
114
115
  "linkedom": "0.18.13",
115
116
  "sharp": "0.35.3",
117
+ "turndown": "7.2.4",
116
118
  "unpdf": "1.8.0",
117
119
  "zod": "^4.3.6"
118
120
  },
121
+ "devDependencies": {
122
+ "@types/turndown": "5.0.6"
123
+ },
119
124
  "scripts": {
120
125
  "build": "tsc -p tsconfig.types.json",
121
126
  "typecheck": "tsc -p tsconfig.types.json --noEmit",
@@ -6,9 +6,9 @@
6
6
  // still cite the artifact.
7
7
  //
8
8
  // Persistence is delegated to the host via a `persistArtifact({ filename, buffer,
9
- // toolName, toolUseId }) -> path | null` callback. Host output sinks
10
- // in src/core/tool-artifacts.js writes to {runArtifactDir}/tool-output/<file>.
11
- // Hosts that don't care can pass null and the truncated payload is dropped.
9
+ // toolName, toolUseId }) -> path | null` callback. The app binds that callback
10
+ // per run and stores payloads under its configured tool-output artifact root.
11
+ // Hosts that don't provide a sink still receive bounded retained text.
12
12
 
13
13
  export const MAX_TOOL_RESULT_BYTES = 262144;
14
14
 
@@ -22,6 +22,11 @@ export const DEFAULT_TOOL_BLOAT_CONFIG = Object.freeze({
22
22
  binaryBloatTools: BINARY_BLOAT_TOOLS,
23
23
  });
24
24
 
25
+ const RETAINED_UNTRUSTED_BEGIN = "[BEGIN RETAINED UNTRUSTED TOOL RESULT]";
26
+ const RETAINED_UNTRUSTED_END = "[END RETAINED UNTRUSTED TOOL RESULT]";
27
+ const RETAINED_MIDDLE_NOTICE = "[Omitted middle may contain additional source content; retained tail is not the source ending.]";
28
+ const RETAINED_OMISSION_RE = /\[\.\.\. (\d+) source bytes omitted \.\.\.\]/gu;
29
+
25
30
  function blockBytes(block) {
26
31
  if (!block || typeof block !== "object") return 0;
27
32
  if (block.type === "text") return Buffer.byteLength(String(block.text || ""), "utf8");
@@ -74,17 +79,144 @@ function persistBlock(toolName, block, idx, idTag, persistArtifact) {
74
79
  }
75
80
  }
76
81
 
77
- function summaryText(toolName, originalBytes, maxBytes, savedPaths) {
82
+ function controlFree(value) {
83
+ return String(value || "").replace(/[\u0000-\u001f\u007f]+/gu, " ").trim();
84
+ }
85
+
86
+ function persistenceSummary(savedPaths, includePath = true) {
87
+ if (savedPaths.length === 0) return "persistence unavailable";
88
+ if (!includePath) return `saved_files=${savedPaths.length}`;
89
+ if (savedPaths.length === 1) return `saved_to=${controlFree(savedPaths[0])}`;
90
+ return `saved_to=${controlFree(savedPaths[0])}; saved_files=${savedPaths.length}`;
91
+ }
92
+
93
+ function summaryText(toolName, originalBytes, retainedBytes, maxBytes, savedPaths, includePath = true) {
78
94
  const parts = [
79
95
  `[truncated tool_result: ${originalBytes} bytes exceeded ${maxBytes} byte cap`,
80
- `tool=${toolName}`,
96
+ `retained=${retainedBytes} bytes`,
97
+ `tool=${controlFree(toolName)}`,
81
98
  ];
82
- if (savedPaths.length === 1) parts.push(`saved_to=${savedPaths[0]}`);
83
- else if (savedPaths.length > 1) parts.push(`saved_to=[${savedPaths.length} files]`);
84
- else parts.push("persistence unavailable");
99
+ parts.push(persistenceSummary(savedPaths, includePath));
85
100
  return `${parts.join("; ")}]`;
86
101
  }
87
102
 
103
+ function capSummary(text, maxBytes) {
104
+ for (const candidate of [text, "[truncated tool_result]", "[truncated]", "[]"]) {
105
+ if (Buffer.byteLength(candidate, "utf8") <= maxBytes) return candidate;
106
+ }
107
+ return "";
108
+ }
109
+
110
+ function summaryOnly(toolName, originalBytes, maxBytes, savedPaths) {
111
+ const parts = [
112
+ `[truncated tool_result: ${originalBytes} bytes exceeded ${maxBytes} byte cap`,
113
+ `tool=${controlFree(toolName)}`,
114
+ ];
115
+ parts.push(persistenceSummary(savedPaths));
116
+ return `${parts.join("; ")}]`;
117
+ }
118
+
119
+ function neutralizeRetainedFrames(value) {
120
+ return wellFormedText(value)
121
+ .replaceAll(RETAINED_UNTRUSTED_BEGIN, `(BEGIN RETAINED UNTRUSTED TOOL RESULT)`)
122
+ .replaceAll(RETAINED_UNTRUSTED_END, `(END RETAINED UNTRUSTED TOOL RESULT)`)
123
+ .replaceAll(RETAINED_MIDDLE_NOTICE, `(Omitted middle may contain additional source content; retained tail is not the source ending.)`)
124
+ .replace(RETAINED_OMISSION_RE, `(... $1 source bytes omitted ...)`);
125
+ }
126
+
127
+ function wellFormedText(value) {
128
+ let out = "";
129
+ for (const point of String(value || "")) {
130
+ const unit = point.charCodeAt(0);
131
+ out += point.length === 1 && unit >= 0xd800 && unit <= 0xdfff ? "\uFFFD" : point;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ function utf8Head(segments, maxBytes) {
137
+ if (maxBytes <= 0) return { text: "", sourceBytes: 0 };
138
+ let bytes = 0;
139
+ let out = "";
140
+ let sourceBytes = 0;
141
+ outer: for (const segment of segments) {
142
+ for (const point of segment.text) {
143
+ const size = Buffer.byteLength(point, "utf8");
144
+ if (bytes + size > maxBytes) break outer;
145
+ out += point;
146
+ bytes += size;
147
+ if (segment.source) sourceBytes += size;
148
+ }
149
+ }
150
+ return { text: out, sourceBytes };
151
+ }
152
+
153
+ function utf8Tail(segments, maxBytes) {
154
+ if (maxBytes <= 0) return { text: "", sourceBytes: 0 };
155
+ let bytes = 0;
156
+ const out = [];
157
+ let sourceBytes = 0;
158
+ outer: for (const segment of [...segments].reverse()) {
159
+ for (const point of [...segment.text].reverse()) {
160
+ const size = Buffer.byteLength(point, "utf8");
161
+ if (bytes + size > maxBytes) break outer;
162
+ out.push(point);
163
+ bytes += size;
164
+ if (segment.source) sourceBytes += size;
165
+ }
166
+ }
167
+ return { text: out.reverse().join(""), sourceBytes };
168
+ }
169
+
170
+ function retainedTextPayload(toolName, blocks, originalBytes, maxBytes, savedPaths) {
171
+ const segments = blocks.flatMap((block, index) => [
172
+ ...(index === 0 ? [] : [{ text: "\n\n", source: false }]),
173
+ { text: neutralizeRetainedFrames(block.text), source: true },
174
+ ]);
175
+ const maximumDigits = String(originalBytes).length;
176
+ const retainedPlaceholder = "9".repeat(maximumDigits);
177
+ const omittedPlaceholder = "9".repeat(maximumDigits);
178
+ let summary = summaryText(toolName, originalBytes, Number(retainedPlaceholder), maxBytes, savedPaths, true);
179
+ const fixedBody = [
180
+ summary,
181
+ RETAINED_UNTRUSTED_BEGIN,
182
+ "",
183
+ `[... ${omittedPlaceholder} source bytes omitted ...]`,
184
+ RETAINED_MIDDLE_NOTICE,
185
+ "",
186
+ RETAINED_UNTRUSTED_END,
187
+ ].join("\n");
188
+ if (Buffer.byteLength(fixedBody, "utf8") > maxBytes) {
189
+ summary = summaryText(toolName, originalBytes, 0, maxBytes, savedPaths, false);
190
+ return capSummary(summary, maxBytes);
191
+ }
192
+
193
+ const sourceBudget = maxBytes - Buffer.byteLength(fixedBody, "utf8");
194
+ const head = utf8Head(segments, Math.floor(sourceBudget * 0.6));
195
+ const tail = utf8Tail(segments, sourceBudget - Buffer.byteLength(head.text, "utf8"));
196
+ const retainedBytes = head.sourceBytes + tail.sourceBytes;
197
+ const omittedBytes = Math.max(0, originalBytes - retainedBytes);
198
+ summary = summaryText(toolName, originalBytes, retainedBytes, maxBytes, savedPaths, true);
199
+ const rendered = [
200
+ summary,
201
+ RETAINED_UNTRUSTED_BEGIN,
202
+ head.text,
203
+ `[... ${omittedBytes} source bytes omitted ...]`,
204
+ RETAINED_MIDDLE_NOTICE,
205
+ tail.text,
206
+ RETAINED_UNTRUSTED_END,
207
+ ].join("\n");
208
+ if (Buffer.byteLength(rendered, "utf8") <= maxBytes) return rendered;
209
+
210
+ // A long saved path can make the exact-path summary larger than the reserved
211
+ // placeholder. Preserve the trust frame and source slices by falling back to
212
+ // the honest saved-file count, never by returning a partial path.
213
+ const countSummary = summaryText(toolName, originalBytes, retainedBytes, maxBytes, savedPaths, false);
214
+ const countRendered = [countSummary, ...rendered.split("\n").slice(1)].join("\n");
215
+ return Buffer.byteLength(countRendered, "utf8") <= maxBytes
216
+ ? countRendered
217
+ : capSummary(countSummary, maxBytes);
218
+ }
219
+
88
220
  export function summarisePayload(toolName, contentBlocks, persistArtifact, options = {}) {
89
221
  const {
90
222
  maxBytes = MAX_TOOL_RESULT_BYTES,
@@ -109,8 +241,12 @@ export function summarisePayload(toolName, contentBlocks, persistArtifact, optio
109
241
  const path = persistBlock(toolName, block, idx, idTag, persistArtifact);
110
242
  if (path) savedPaths.push(path);
111
243
  });
244
+ const textOnly = blocks.length > 0 && blocks.every((block) => block?.type === "text");
245
+ const rewrittenText = textOnly && otherBytes > maxBytes && imageBytes === 0
246
+ ? retainedTextPayload(toolName, blocks, originalBytes, maxBytes, savedPaths)
247
+ : summaryOnly(toolName, originalBytes, maxBytes, savedPaths);
112
248
  return {
113
- rewrittenBlocks: [{ type: "text", text: summaryText(toolName, originalBytes, maxBytes, savedPaths) }],
249
+ rewrittenBlocks: [{ type: "text", text: rewrittenText }],
114
250
  savedPaths,
115
251
  originalBytes,
116
252
  truncated: true,
@@ -203,7 +203,7 @@ function positiveInt(value, fallback) {
203
203
  * Build the `Agent` tool, or null when subagents are unavailable for this run.
204
204
  *
205
205
  * @param {RuntimeSubagentsOptions|null|undefined} subagents
206
- * @param {{model?: *, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, toolEnvironment?: *, onEvent?: (event: *) => void}} [context]
206
+ * @param {{model?: *, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, toolEnvironment?: *, webSearchConfig?: *, webRequestCoordinator?: *, webFetchConfig?: *, onEvent?: (event: *) => void}} [context]
207
207
  * @returns {*|null}
208
208
  */
209
209
  export function createAgentTool(subagents, context = {}) {
@@ -410,6 +410,9 @@ export function createAgentTool(subagents, context = {}) {
410
410
  ...(context.skills === undefined ? {} : { skills: context.skills }),
411
411
  ...(context.skillsRoot === undefined ? {} : { skillsRoot: context.skillsRoot }),
412
412
  ...(context.toolEnvironment === undefined ? {} : { toolEnvironment: context.toolEnvironment }),
413
+ ...(context.webSearchConfig === undefined ? {} : { webSearchConfig: context.webSearchConfig }),
414
+ ...(context.webRequestCoordinator === undefined ? {} : { webRequestCoordinator: context.webRequestCoordinator }),
415
+ ...(context.webFetchConfig === undefined ? {} : { webFetchConfig: context.webFetchConfig }),
413
416
  abortSignal: controller.signal,
414
417
  maxTurns,
415
418
  callId: toolCallId,
@@ -447,7 +450,7 @@ export function createAgentTool(subagents, context = {}) {
447
450
 
448
451
  const durationMs = Date.now() - startedAt;
449
452
  const outcome = classifyOutcome({ result, thrown, timedOut, abandoned });
450
- collector.finished({ status: outcome.status, durationMs });
453
+ collector.finished({ status: outcome.status, durationMs, result });
451
454
  // Each result is individually capped, but the parent's context sees the
452
455
  // SUM. The description encourages parallel calls, so twenty valid results
453
456
  // would otherwise land ~480KB in one batch. Later calls get whatever
@@ -598,6 +601,22 @@ function normalizeProfile(definition) {
598
601
 
599
602
  /** Hard cap on any single forwarded payload, so the operator wire's binary-search reducer never has to run. */
600
603
  const WIRE_CONTENT_MAX_CHARS = 2_000;
604
+ const ROUTE_HISTORY_MAX_ENTRIES = 32;
605
+
606
+ function boundedRouteString(value, max = 256) {
607
+ return typeof value === "string" && value.length > 0 ? value.slice(0, max) : undefined;
608
+ }
609
+
610
+ function boundedRouteIndex(value) {
611
+ return Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
612
+ }
613
+
614
+ function appendRouteEntry(entries, entry, routeState) {
615
+ entries.push(entry);
616
+ if (entries.length <= ROUTE_HISTORY_MAX_ENTRIES) return;
617
+ entries.splice(1, entries.length - ROUTE_HISTORY_MAX_ENTRIES);
618
+ routeState.truncated = true;
619
+ }
601
620
 
602
621
  /**
603
622
  * Translates the child's raw provider events into (a) a bounded per-tool-call
@@ -624,6 +643,7 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit,
624
643
  /** What the child reported spending, so the parent run can own it. */
625
644
  const usage = emptyUsage();
626
645
  const subagent = { id: callId, name: profileName, callIndex, ...(label === undefined ? {} : { label }) };
646
+ const routeState = { requested: {}, attempted: undefined, transitions: [], retries: [], truncated: false };
627
647
 
628
648
  /** @param {*} event */
629
649
  const publish = (event) => {
@@ -646,11 +666,32 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit,
646
666
  arguments: { name: profileName, ...(label === undefined ? {} : { description: label }) },
647
667
  });
648
668
  },
649
- /** @param {{status: string, durationMs: number}} outcome */
650
- finished({ status, durationMs }) {
669
+ /** @param {{status: string, durationMs: number, result?: *}} outcome */
670
+ finished({ status, durationMs, result }) {
651
671
  // Before the bookend, so the run's own usage report can already include
652
672
  // it, and so an abandoned child still hands over whatever it spent.
653
673
  recordUsage?.(usage);
674
+ const executed = status === "ok" && result && typeof result === "object"
675
+ ? {
676
+ ...(boundedRouteString(result.model) === undefined ? {} : { model: boundedRouteString(result.model) }),
677
+ ...(boundedRouteString(result.effort, 64) === undefined ? {} : { effort: boundedRouteString(result.effort, 64) }),
678
+ ...(boundedRouteString(result.effectiveEffort, 64) === undefined ? {} : { effectiveEffort: boundedRouteString(result.effectiveEffort, 64) }),
679
+ }
680
+ : undefined;
681
+ const requestedModel = routeState.requested.model;
682
+ const fallback = routeState.transitions.length > 0
683
+ || (executed?.model !== undefined && requestedModel !== undefined && executed.model !== requestedModel);
684
+ const attribution = requestedModel === undefined && routeState.attempted === undefined && executed === undefined
685
+ ? undefined
686
+ : {
687
+ requested: routeState.requested,
688
+ ...(routeState.attempted === undefined ? {} : { attempted: routeState.attempted }),
689
+ ...(executed === undefined ? {} : { executed }),
690
+ disposition: requestedModel === undefined ? "unknown" : fallback ? "fallback" : "requested",
691
+ transitions: routeState.transitions,
692
+ retries: routeState.retries,
693
+ ...(routeState.truncated ? { truncated: true } : {}),
694
+ };
654
695
  publish({
655
696
  phase: "agent_completed",
656
697
  id: `agent:${callId}`,
@@ -658,7 +699,13 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit,
658
699
  // The one place the child's price is knowable per delegation: operator
659
700
  // surfaces show it on the row so an expensive one is identifiable, not
660
701
  // just visible in the run total it disappears into.
661
- ...(usage.costUsd > 0 ? { subagent: { ...subagent, costUsd: usage.costUsd } } : {}),
702
+ ...((usage.costUsd > 0 || attribution !== undefined)
703
+ ? { subagent: {
704
+ ...subagent,
705
+ ...(usage.costUsd > 0 ? { costUsd: usage.costUsd } : {}),
706
+ ...(attribution === undefined ? {} : { attribution }),
707
+ } }
708
+ : {}),
662
709
  isError: status !== "ok",
663
710
  executionMs: durationMs,
664
711
  content: `${status} · ${done.length} tool call${done.length === 1 ? "" : "s"}`,
@@ -681,6 +728,58 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit,
681
728
  /** @param {*} event */
682
729
  observe(event) {
683
730
  const type = event?.type;
731
+ if (type === "provider_execution_config") {
732
+ const model = boundedRouteString(event.model);
733
+ const effort = boundedRouteString(event.effort, 64);
734
+ const effectiveEffort = boundedRouteString(event.effectiveEffort, 64);
735
+ routeState.attempted = {
736
+ ...(model === undefined ? {} : { model }),
737
+ ...(effort === undefined ? {} : { effort }),
738
+ ...(effectiveEffort === undefined ? {} : { effectiveEffort }),
739
+ };
740
+ if (routeState.requested.model === undefined && routeState.transitions.length === 0) {
741
+ routeState.requested = {
742
+ ...(model === undefined ? {} : { model }),
743
+ ...(effort === undefined ? {} : { effort }),
744
+ };
745
+ }
746
+ return;
747
+ }
748
+ if (type === "provider_failover_started") {
749
+ const from = boundedRouteString(event.from);
750
+ const to = boundedRouteString(event.to);
751
+ if (from !== undefined && to !== undefined) {
752
+ if (routeState.requested.model === undefined) routeState.requested = { model: from };
753
+ const attemptIndex = boundedRouteIndex(event.attemptIndex);
754
+ const reason = boundedRouteString(event.reason, 128);
755
+ appendRouteEntry(routeState.transitions, {
756
+ from,
757
+ to,
758
+ ...(attemptIndex === undefined ? {} : { attemptIndex }),
759
+ ...(reason === undefined ? {} : { reason }),
760
+ }, routeState);
761
+ routeState.attempted = { model: to };
762
+ }
763
+ return;
764
+ }
765
+ if (type === "provider_retry_started") {
766
+ const model = boundedRouteString(event.model);
767
+ const retryIndex = boundedRouteIndex(event.retryIndex);
768
+ const attempts = boundedRouteIndex(event.attempts);
769
+ const reason = boundedRouteString(event.reason, 128);
770
+ appendRouteEntry(routeState.retries, {
771
+ ...(model === undefined ? {} : { model }),
772
+ ...(retryIndex === undefined ? {} : { retryIndex }),
773
+ ...(attempts === undefined ? {} : { attempts }),
774
+ ...(reason === undefined ? {} : { reason }),
775
+ }, routeState);
776
+ return;
777
+ }
778
+ if (type === "provider_failover_completed") {
779
+ const model = boundedRouteString(event.model);
780
+ if (model !== undefined) routeState.attempted = { ...(routeState.attempted || {}), model };
781
+ return;
782
+ }
684
783
  // Providers legitimately forward multiple blocks in one message; the
685
784
  // Claude bridges do routinely. Inspecting only content[0] dropped every
686
785
  // tool call after the first.
@@ -72,7 +72,7 @@ export function normalizeBackgroundBashTimeoutMs(value) {
72
72
  /**
73
73
  * Compatibility wrapper retained for direct callers and tests.
74
74
  *
75
- * @param {{command: string, description?: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string, background?: boolean}} params
75
+ * @param {{command: string, description?: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string, background?: boolean, wake_on_completion?: boolean}} params
76
76
  * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: import("./shared/process-jobs.js").ProcessJobsController}} [options]
77
77
  */
78
78
  export async function bashToolImpl(params, options = {}) {
@@ -82,7 +82,7 @@ export async function bashToolImpl(params, options = {}) {
82
82
  /**
83
83
  * Structured Bash execution used by the Pi bridge.
84
84
  *
85
- * @param {{command: string, description?: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string, background?: boolean}} params
85
+ * @param {{command: string, description?: string, timeout?: number, timeout_ms?: number, max_output_chars?: number, workdir?: string, background?: boolean, wake_on_completion?: boolean}} params
86
86
  * @param {{signal?: AbortSignal, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, processJobsController?: import("./shared/process-jobs.js").ProcessJobsController}} [options]
87
87
  */
88
88
  export async function bashToolRun(
@@ -94,6 +94,7 @@ export async function bashToolRun(
94
94
  max_output_chars,
95
95
  workdir,
96
96
  background,
97
+ wake_on_completion,
97
98
  },
98
99
  {
99
100
  signal,
@@ -104,6 +105,12 @@ export async function bashToolRun(
104
105
  } = {},
105
106
  ) {
106
107
  const startedAt = Date.now();
108
+ if (wake_on_completion !== undefined && (background !== true || typeof wake_on_completion !== "boolean")) {
109
+ return failed("Error: wake_on_completion requires background=true and a boolean value.", "process_job_invalid", startedAt);
110
+ }
111
+ if (background === true && !processJobsController) {
112
+ return failed("Error: Background process jobs are unavailable for this request.", "background_unsupported", startedAt);
113
+ }
107
114
  if (typeof command !== "string") {
108
115
  return failed("Error: Bash command must be a string.", "invalid_command", startedAt);
109
116
  }
@@ -158,6 +165,7 @@ export async function bashToolRun(
158
165
  prepared,
159
166
  summary: `Bash command (${command.length} characters; content redacted)`,
160
167
  description,
168
+ wakeOnCompletion: wake_on_completion,
161
169
  timeoutMs: requestedTimeoutMs,
162
170
  maxOutputChars: max_output_chars === undefined ? undefined : maxChars,
163
171
  startedAt,