atom-agent 1.4.0 → 1.5.1

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 (68) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +221 -224
  3. package/dist/App.js +922 -341
  4. package/dist/adapters.js +502 -21
  5. package/dist/agent/goal-evaluator.js +3 -0
  6. package/dist/agent/loop.js +250 -434
  7. package/dist/agent/tool-pipeline.js +398 -0
  8. package/dist/agent/turn-events.js +12 -0
  9. package/dist/cli.js +57 -8
  10. package/dist/compact.js +72 -8
  11. package/dist/config.js +19 -0
  12. package/dist/context-manager.js +6 -2
  13. package/dist/extensions.js +6 -0
  14. package/dist/file-diffs.js +108 -0
  15. package/dist/kilo.js +1 -1
  16. package/dist/local-discovery.js +2 -2
  17. package/dist/media.js +276 -0
  18. package/dist/overflow.js +140 -0
  19. package/dist/policy.js +8 -0
  20. package/dist/providers.js +11 -3
  21. package/dist/scheduler.js +38 -9
  22. package/dist/session-revert.js +125 -0
  23. package/dist/sessions.js +101 -0
  24. package/dist/snapshots.js +69 -0
  25. package/dist/system.js +2 -89
  26. package/dist/telemetry.js +79 -5
  27. package/dist/todos.js +241 -0
  28. package/dist/tools/filesystem.js +102 -22
  29. package/dist/tools/registry.js +184 -45
  30. package/dist/tools/ripgrep.js +7 -6
  31. package/dist/tools/search.js +172 -17
  32. package/dist/tools/shared.js +6 -0
  33. package/dist/tools.js +7 -39
  34. package/dist/ui/diff-panel.js +1 -1
  35. package/dist/ui/diff-view.js +13 -5
  36. package/dist/ui/diff.js +67 -0
  37. package/dist/ui/errors.js +20 -6
  38. package/dist/ui/input.js +24 -20
  39. package/dist/ui/live-tail.js +36 -1
  40. package/dist/ui/markdown.js +9 -4
  41. package/dist/ui/modals.js +7 -5
  42. package/dist/ui/paint-scheduler.js +120 -0
  43. package/dist/ui/palette.js +4 -2
  44. package/dist/ui/pickers.js +4 -1
  45. package/dist/ui/side-by-side.js +81 -22
  46. package/dist/ui/status-bar.js +63 -8
  47. package/dist/ui/stream-store.js +7 -0
  48. package/dist/ui/theme.js +23 -1
  49. package/dist/ui/todo-panel.js +5 -2
  50. package/dist/ui/tool-inspector.js +33 -4
  51. package/dist/ui/transcript.js +8 -5
  52. package/dist/web/events.js +93 -0
  53. package/dist/web/runtime.js +790 -0
  54. package/dist/web/server.js +570 -0
  55. package/dist/web/ui/app.js +1925 -0
  56. package/dist/web/ui/index.html +135 -0
  57. package/dist/web/ui/styles.css +515 -0
  58. package/dist/zen.js +532 -34
  59. package/documentation/cli.md +5 -5
  60. package/documentation/configuration.md +11 -6
  61. package/documentation/development.md +4 -3
  62. package/documentation/goals.md +1 -1
  63. package/documentation/index.md +4 -4
  64. package/documentation/providers.md +2 -3
  65. package/documentation/skills.md +3 -3
  66. package/documentation/tools.md +8 -3
  67. package/documentation/troubleshooting.md +1 -1
  68. package/package.json +3 -2
@@ -22,42 +22,19 @@
22
22
  // zen.ts; runAgenticLoopForProvider wraps this loop from there).
23
23
  import { loadAtomConfig } from "../config.js";
24
24
  import { historyChars } from "../context-manager.js";
25
- import { planBatches } from "../scheduler.js";
26
- import { describeToolCall, executeTool, invalidCall, needsApproval, toolNames, validateAskQuestionArgs, validateToolArgs, } from "../tools.js";
27
- import { afterToolInterceptors, applyAfterInterceptors, applyBeforeInterceptors, beforeToolInterceptors, blockedToolResult, } from "../tools/intercept.js";
25
+ import { captureSchedulerSnapshot, planBatches } from "../scheduler.js";
26
+ import { describeToolCall, executeTool, invalidCall } from "../tools.js";
27
+ import { applyCommitPatches, invalidJsonArgsResult, isCancelError, LoopCancelledError, parseToolArguments, planToolCall, runPlannedToolCall, runSerialToolPipeline, throwIfCancelled, } from "./tool-pipeline.js";
28
28
  import { getReadCacheStats } from "../tools/read-cache.js";
29
29
  import { emptyGoalProgress, GOAL_STALL_REPEATS, goalFollowUp, goalPausedNotice, goalReportAck, goalReportOutsideError, goalReportRejectedNotice, goalStallNudge, goalStallReached, goalVerdictNotice, noteGoalProgress, recentTurnsForJudge, resetGoalStall, sameGoalDisposition, updateGoalDisposition, validateUpdateGoalArgs, } from "../goal.js";
30
30
  import { bashExitCode, evaluateTurnEnd, isCodePath, isVerificationCommand, todoCompletionGate, verificationGate, } from "./gates.js";
31
31
  import { errorStreakFollowUp, ErrorStreakTracker, repetitionFollowUp, RepetitionGuard, repetitionStopNotice, } from "./loop-guard.js";
32
- import { normalizeChatResult, normalizeToolResult, toolSignature } from "./normalize.js";
33
- // Whole-turn cancellation: thrown when the user cancels (Ctrl+C) mid-loop.
34
- // The App catches it, rolls the partial turn back (same splice contract as
35
- // POST failure), renders one dim `(cancelled)` line, and returns to a clean
36
- // input state. Never retried, never a tool result.
37
- export class LoopCancelledError extends Error {
38
- constructor() {
39
- super("(cancelled)");
40
- this.name = "LoopCancelledError";
41
- }
42
- }
43
- export function isCancelError(e) {
44
- if (e instanceof LoopCancelledError)
45
- return true;
46
- if (e instanceof Error && e.name === "LoopCancelledError")
47
- return true;
48
- // fetch abort surfaces as DOMException AbortError (or Error with that name
49
- // in mocks). Treat any AbortError as a cancellation, never a retry.
50
- if (e instanceof Error && e.name === "AbortError")
51
- return true;
52
- if (typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError") {
53
- return true;
54
- }
55
- return false;
56
- }
57
- export function throwIfCancelled(signal) {
58
- if (signal?.aborted)
59
- throw new LoopCancelledError();
60
- }
32
+ import { normalizeChatResult, toolSignature } from "./normalize.js";
33
+ import { emitTurnEvent } from "./turn-events.js";
34
+ // Compat re-exports: the cancel primitives and the tool-timeout helpers now
35
+ // live in the pipeline module (./tool-pipeline.js); loop.js re-exports them
36
+ // so existing `from "./agent/loop.js"` importers (via zen) keep working.
37
+ export { DEFAULT_TOOL_TIMEOUT_MS, executeWithTimeout, isCancelError, LoopCancelledError, resolveToolTimeoutMs, throwIfCancelled, } from "./tool-pipeline.js";
61
38
  // Tool-round budget for one agentic turn (env → atom.json → unlimited).
62
39
  // No default cap: the turn runs until the model ends it, a gate stops it,
63
40
  // or the user cancels. An explicit `opts.maxSteps` (or env/file value) still
@@ -92,19 +69,6 @@ export function emptyResponseFollowUp(attempt) {
92
69
  `the turn cannot end on silence. Continue with tool calls toward the goal, ` +
93
70
  `or answer in text. If there is genuinely nothing to do, end by saying so explicitly.)`);
94
71
  }
95
- // Per-tool outer timeout (ms): undefined → default 60s (enabled); explicit
96
- // <=0/NaN → disabled (direct await, zero overhead). Clamped 1s–120s when
97
- // enabled so a stuck executor can never hang the turn past the bash ceiling.
98
- export const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
99
- export function resolveToolTimeoutMs(raw) {
100
- if (raw === undefined)
101
- return DEFAULT_TOOL_TIMEOUT_MS;
102
- if (typeof raw !== "number" || !Number.isFinite(raw))
103
- return DEFAULT_TOOL_TIMEOUT_MS;
104
- if (raw <= 0)
105
- return null;
106
- return Math.min(Math.max(Math.floor(raw), 1000), 120_000);
107
- }
108
72
  // Total tool-call budget per turn (no default cap; explicit opts value only,
109
73
  // min 1). Previously defaulted to 200 as a parallel-batch explosion guard.
110
74
  // Deprecated alias kept for import compatibility; the loop no longer uses it.
@@ -114,100 +78,16 @@ export function resolveMaxTotalToolCalls(raw) {
114
78
  return Number.POSITIVE_INFINITY;
115
79
  return Math.max(1, Math.floor(raw));
116
80
  }
117
- // Race one execution against the outer timeout. Timeout resolves to an
118
- // `Error:` result (the model adapts); the underlying promise is left to
119
- // settle executors own their own cleanup.
120
- //
121
- // Cancellation is DELIBERATELY not raced here: the pinned contract is that
122
- // an in-flight tool runs to completion and its result IS recorded, with the
123
- // cancel stopping the turn before the next batch/POST (see the
124
- // throwIfCancelled checks between batches and before each POST). Racing
125
- // abort against the execution would drop the in-flight result and break
126
- // assistant/tool pairing guarantees the tests pin. A hung tool + cancel
127
- // therefore waits for the timeout (≤60s), commits the timeout error, then
128
- // the next boundary check throws LoopCancelledError.
129
- export async function executeWithTimeout(execute, name, parsed, timeoutMs, signal) {
130
- // No new executions after a cancel: refuse to start when already aborted.
131
- if (signal?.aborted)
132
- throw new LoopCancelledError();
133
- if (timeoutMs === null) {
134
- return execute(name, parsed);
135
- }
136
- let timer = null;
137
- try {
138
- const execP = execute(name, parsed);
139
- const timeoutP = new Promise((_resolve, reject) => {
140
- timer = setTimeout(() => {
141
- const err = new Error(`timeout after ${timeoutMs}ms`);
142
- err.code = "ToolTimeout";
143
- reject(err);
144
- }, timeoutMs);
145
- });
146
- try {
147
- return await Promise.race([execP, timeoutP]);
148
- }
149
- catch (e) {
150
- if (isCancelError(e))
151
- throw e;
152
- if (e?.code === "ToolTimeout" || e?.message?.startsWith("timeout after ")) {
153
- return `Error: ${name} timed out after ${timeoutMs}ms — retry with a narrower scope or smaller input.`;
154
- }
155
- throw e;
156
- }
157
- }
158
- finally {
159
- if (timer)
160
- clearTimeout(timer);
161
- }
162
- }
163
- // Permission-gate resolution shared by the serial path and the parallel
164
- // pre-pass: returns the hook's decision, or null when no approval applies
165
- // (no hook, or a tool that never needs it). A "no" (or a throwing hook)
166
- // denies without executing; cancellation always propagates.
167
- async function resolveApproval(name, parsed, opts) {
168
- if (!opts?.approve || !needsApproval(name))
169
- return null;
170
- try {
171
- const decision = await opts.approve(name, parsed);
172
- // Abort that lands as a resolved denial still cancels the whole turn.
173
- throwIfCancelled(opts?.signal);
174
- return decision;
175
- }
176
- catch (e) {
177
- // Whole-turn cancellation must propagate (Ctrl+C cancels the turn,
178
- // not just deny one call). Anything else is a denial.
179
- if (isCancelError(e) || opts?.signal?.aborted)
180
- throw new LoopCancelledError();
181
- return "no";
182
- }
183
- }
184
- // Extension tool-call interception (ticket 03): global pre/post hooks
185
- // registered via ExtensionAPI.onBeforeToolCall/onAfterToolCall. Both
186
- // helpers snapshot the live handler list and never throw — a throwing
187
- // before handler fails closed (blocked outcome), a throwing after handler
188
- // fails open (original content). Cancellation checks stay where they are;
189
- // hooks are in-process policy, not executions, so they run even when the
190
- // signal is armed and the existing boundaries still stop the turn.
191
- async function runBeforeIntercept(name, parsed) {
192
- try {
193
- return await applyBeforeInterceptors(beforeToolInterceptors(), name, parsed);
194
- }
195
- catch {
196
- return { args: parsed, blocked: blockedToolResult(name, "(unknown)", "interception failed") };
197
- }
198
- }
199
- async function runAfterIntercept(name, parsed, result, isError) {
200
- try {
201
- return await applyAfterInterceptors(afterToolInterceptors(), { name, args: parsed, result, isError });
202
- }
203
- catch {
204
- return { content: result, isError };
205
- }
206
- }
81
+ // Per-call pipeline stages (hook validate approve execute
82
+ // post-hook) live in ./tool-pipeline.js the serial driver calls
83
+ // runSerialToolPipeline, the parallel pre-pass plans each member via
84
+ // planToolCall and members execute via runPlannedToolCall (the same
85
+ // plan/run pair, so the paths share every stage by construction).
207
86
  // Execute one parsed tool call through interception + validation +
208
- // permission + ask_question gates. Model mistakes (unknown name, invalid
209
- // args) return repairs-oriented results WITHOUT executing; cancellations
210
- // propagate as LoopCancelledError (never a result, never retried).
87
+ // permission + the registry-intercepted stage. Model mistakes (unknown
88
+ // name, invalid args) return repairs-oriented results WITHOUT executing;
89
+ // cancellations propagate as LoopCancelledError (never a result, never
90
+ // retried).
211
91
  // Everything else returns the result string plus the effective (post-
212
92
  // rewrite) args the commit funnel must use for gates, labels, and pairing:
213
93
  // - Hook-vs-approval ordering: pre-hooks run BEFORE validation and
@@ -217,143 +97,12 @@ async function runAfterIntercept(name, parsed, result, isError) {
217
97
  // re-validate before execution, so a hook can never smuggle unvalidated
218
98
  // args into an executor.
219
99
  // - Unknown names never reach hooks (model mistake — nothing would run).
220
- // update_goal is the one exemption (ticket 03): a loop-intercepted tool
221
- // like ask_question, resolved without an executor via onUpdateGoal.
100
+ // The registry roster (toolNames/isInterceptedTool) is the only name
101
+ // authority the loop holds no per-tool branch.
222
102
  // - ask_question never needs approval; without an askUser hook it resolves
223
103
  // to "Error: ask_question has no UI hook".
224
104
  // - update_goal never needs approval either; without the per-turn recorder
225
105
  // (only runLoopWithChat supplies it) it resolves to the outside-turn error.
226
- // - write/edit/bash consult the approve hook when one is provided (or a
227
- // pre-resolved batch decision); a "no" resolves to
228
- // "Error: denied by user: <tool>" (final, no retry/rollback). Without a
229
- // hook every tool executes immediately.
230
- async function runOneTool(call, parsed, opts, execute, preDecision, onUpdateGoal) {
231
- const name = call?.function?.name ?? "(unknown)";
232
- // update_goal bypasses the unknown-name gate (ticket 03): a
233
- // loop-intercepted tool like ask_question — the registry owns builtins
234
- // only, so the loop exempts it by name here; runOneToolWithArgs below
235
- // validates it and resolves it without an executor (never needs approval).
236
- if (name !== "update_goal" && !toolNames().includes(name)) {
237
- return { result: `Error: unknown tool "${name}". Available: ${toolNames().join(", ")}`, args: parsed };
238
- }
239
- const pre = await runBeforeIntercept(name, parsed);
240
- if (pre.blocked !== null) {
241
- return { result: pre.blocked, args: pre.args };
242
- }
243
- return runOneToolWithArgs(call, pre.args, opts, execute, preDecision, onUpdateGoal);
244
- }
245
- // Inner execution after pre-interception: validate (rewritten) args, then
246
- // permission + ask_question/update_goal gates, then the executor. Shared by
247
- // the serial path (via runOneTool) and the parallel batch (via the pre-pass
248
- // below, which resolves pre-hooks serially so prompts never run concurrently).
249
- async function runOneToolWithArgs(call, parsed, opts, execute, preDecision, onUpdateGoal) {
250
- const name = call?.function?.name ?? "(unknown)";
251
- // Argument validation BEFORE approval/execution: model mistake, never runs.
252
- // Pre-hook rewrites arrive here already applied, so they re-validate on
253
- // exactly what would execute — invalid rewrites never reach an executor.
254
- const detail = validateToolArgs(name, parsed);
255
- if (detail) {
256
- return { result: invalidCall(detail), args: parsed };
257
- }
258
- if (name === "ask_question") {
259
- throwIfCancelled(opts?.signal);
260
- // If the signal aborts during the modal, runAskQuestion rejects with
261
- // LoopCancelledError (no result). If it resolves just as the signal
262
- // aborts, return the result — the loop records it, then stops before
263
- // the next POST (no new POSTs, pairing stays valid until rollback).
264
- return { result: await runAskQuestion(parsed, opts?.askUser, opts?.signal), args: parsed };
265
- }
266
- if (name === "update_goal") {
267
- throwIfCancelled(opts?.signal);
268
- // Goal-disposition report (ticket 03): ask_question-shaped — validated,
269
- // never needs approval, resolved without an executor (the per-turn slot
270
- // recorder owns the record; execute never sees this name, so plan-mode
271
- // and approval policy are untouched). Without the recorder (never from
272
- // runLoopWithChat — both call sites thread it) the call is outside any
273
- // goal turn by construction.
274
- if (!onUpdateGoal)
275
- return { result: goalReportOutsideError(), args: parsed };
276
- return { result: onUpdateGoal(parsed), args: parsed };
277
- }
278
- const decision = preDecision ?? (await resolveApproval(name, parsed, opts));
279
- if (decision === "no") {
280
- return { result: `Error: denied by user: ${name}`, args: parsed };
281
- }
282
- // "once"/"always" run this call (the caller caches the always-allowed set
283
- // session-wide so later calls skip the prompt).
284
- // No new executions after a cancel: stop after the current tool finishes.
285
- // The current tool (if already running) is awaited to completion and its
286
- // result IS recorded — the loop then stops before the next tool/POST, so
287
- // assistant/tool pairing stays valid until the caller rolls back.
288
- throwIfCancelled(opts?.signal);
289
- const timeoutMs = resolveToolTimeoutMs(opts?.toolTimeoutMs);
290
- const doNormalize = opts?.normalizeResults !== false;
291
- try {
292
- const raw = await executeWithTimeout(execute, name, parsed, timeoutMs, opts?.signal);
293
- if (doNormalize)
294
- return { result: normalizeToolResult(raw), args: parsed };
295
- return { result: typeof raw === "string" ? raw : normalizeToolResult(raw), args: parsed };
296
- }
297
- catch (e) {
298
- if (isCancelError(e) || opts?.signal?.aborted)
299
- throw new LoopCancelledError();
300
- throw e;
301
- }
302
- }
303
- // After-tool-call result hook (issue 06): the single rewrite seam between
304
- // execution and commit. Absent hook (or null/undefined/void/non-object
305
- // return) → the original result, byte-identical. A string return replaces
306
- // content (error flag kept); an object may replace content, override
307
- // isError, or veto the commit. Hook failures degrade to the original — the
308
- // turn never breaks. Telemetry keeps the pre-hook execution result; only the
309
- // committed history/transcript/activity sees the rewrite.
310
- async function applyToolResultHook(hook, name, parsed, result, isError) {
311
- if (!hook)
312
- return { content: result, isError, veto: false };
313
- try {
314
- const decision = await hook({ name, args: parsed, result, isError });
315
- if (decision === null || decision === undefined)
316
- return { content: result, isError, veto: false };
317
- if (typeof decision === "string")
318
- return { content: decision, isError, veto: false };
319
- if (typeof decision === "object") {
320
- if (decision.veto === true)
321
- return { content: result, isError, veto: true };
322
- const content = typeof decision.content === "string" ? decision.content : result;
323
- const nextIsError = typeof decision.isError === "boolean" ? decision.isError : isError;
324
- return { content, isError: nextIsError, veto: false };
325
- }
326
- return { content: result, isError, veto: false };
327
- }
328
- catch {
329
- return { content: result, isError, veto: false };
330
- }
331
- }
332
- async function runAskQuestion(parsed, askUser, signal) {
333
- const invalid = validateAskQuestionArgs(parsed);
334
- if (invalid)
335
- return invalid;
336
- if (!askUser)
337
- return "Error: ask_question has no UI hook";
338
- const q = parsed;
339
- const allowCustom = q.allowCustom === true;
340
- try {
341
- const answer = await askUser(q.question, q.options, allowCustom);
342
- if (typeof answer === "string" && answer.startsWith("Error:"))
343
- return answer;
344
- return JSON.stringify({ answer });
345
- }
346
- catch (e) {
347
- // Whole-turn cancellation (Ctrl+C) propagates — it is NOT the Esc
348
- // question-cancel result below.
349
- if (isCancelError(e) || signal?.aborted)
350
- throw new LoopCancelledError();
351
- const msg = e instanceof Error ? e.message : String(e);
352
- if (/cancel/i.test(msg))
353
- return "Error: question cancelled by user";
354
- return `Error: ${msg}`;
355
- }
356
- }
357
106
  // Shared agentic-loop core: the SINGLE loop implementation backing both
358
107
  // runAgenticLoop and runAgenticLoopForProvider (same tool/rollback contract).
359
108
  // Sequencing: each assistant message's tool_calls block is partitioned by
@@ -376,6 +125,11 @@ export async function runLoopWithChat(chatFn, history, opts) {
376
125
  // Every hook call below is guarded, so telemetry can never break the turn;
377
126
  // absent → a few Date.now() reads per call, negligible and identical.
378
127
  const telemetry = opts?.telemetry;
128
+ // Ordered turn-event sink (see src/agent/turn-events.ts): additive-only
129
+ // observer beside the callbacks below. Every emission goes through
130
+ // emitTurnEvent (guarded, never throws); absent → no reporting, and the
131
+ // pass-through wrappers below collapse to the original callbacks.
132
+ const sink = opts?.turnEvents;
379
133
  const telemetryIso = (ms) => {
380
134
  try {
381
135
  return new Date(ms).toISOString();
@@ -409,6 +163,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
409
163
  // observer errors never break the loop
410
164
  }
411
165
  };
166
+ // TurnEvents phase mirror: reports the same phase + detail the loop just
167
+ // sent to onPhase. Always called AFTER the callback block, so callback side
168
+ // effects keep their exact order; guarded, so the sink never breaks the turn.
169
+ const reportPhase = (phase, detail) => {
170
+ emitTurnEvent(sink, (s) => s.onPhase?.(phase, detail));
171
+ };
412
172
  // Explicit verification state (no transcript parsing — the gate reads
413
173
  // these, never model prose):
414
174
  // - filesWritten: any write/edit executed (legacy compat signal).
@@ -551,6 +311,21 @@ export async function runLoopWithChat(chatFn, history, opts) {
551
311
  bottleneck = { name, durationMs: Math.floor(durationMs) };
552
312
  }
553
313
  };
314
+ // v2 phase tracking: model time is measured alongside tool time so a 289s
315
+ // streaming stall is never hidden behind a 94ms glob again.
316
+ let slowestModel = null;
317
+ let modelTotalMs = 0;
318
+ let toolTotalMs = 0;
319
+ let truncationNotices = 0;
320
+ const noteModel = (id, durationMs) => {
321
+ if (!Number.isFinite(durationMs) || durationMs < 0)
322
+ return;
323
+ const floored = Math.floor(durationMs);
324
+ modelTotalMs += floored;
325
+ if (!slowestModel || floored > slowestModel.durationMs) {
326
+ slowestModel = { id, durationMs: floored };
327
+ }
328
+ };
554
329
  const finishStats = () => {
555
330
  try {
556
331
  let endChars = startChars;
@@ -577,6 +352,11 @@ export async function runLoopWithChat(chatFn, history, opts) {
577
352
  durationMs: Math.max(0, Date.now() - turnStartMs),
578
353
  bottleneck,
579
354
  contextGrowthChars: endChars - startChars,
355
+ slowestModel,
356
+ modelTotalMs,
357
+ toolTotalMs,
358
+ dominantPhase: modelTotalMs >= toolTotalMs ? "model" : "tool",
359
+ truncationNotices,
580
360
  };
581
361
  opts?.onLoopStats?.(stats);
582
362
  }
@@ -599,12 +379,37 @@ export async function runLoopWithChat(chatFn, history, opts) {
599
379
  let msg;
600
380
  const modelStart = Date.now();
601
381
  try {
382
+ // TurnEvents pass-through wrappers: each original callback runs first,
383
+ // exactly as before (a throwing callback still propagates to chatFn),
384
+ // then the same fact is reported to the sink (guarded, never throws).
385
+ // onToolDelta/onWarning have no sink kinds and pass through untouched.
386
+ // When the sink is absent the wrapper still calls the original — and a
387
+ // field with neither callback nor sink stays undefined, so chatFn sees
388
+ // the same shape it always did.
389
+ const sinkToken = sink?.onToken !== undefined;
390
+ const sinkPhase = sink?.onPhase !== undefined;
391
+ const sinkThinking = sink?.onThinking !== undefined;
602
392
  msg = await chatFn(history, {
603
- onToken: opts?.onToken,
604
- onPhase: opts?.onPhase,
393
+ onToken: opts?.onToken !== undefined || sinkToken
394
+ ? (text) => {
395
+ opts?.onToken?.(text);
396
+ emitTurnEvent(sink, (s) => s.onToken?.(text));
397
+ }
398
+ : undefined,
399
+ onPhase: opts?.onPhase !== undefined || sinkPhase
400
+ ? (phase, detail) => {
401
+ opts?.onPhase?.(phase, detail);
402
+ emitTurnEvent(sink, (s) => s.onPhase?.(phase, detail));
403
+ }
404
+ : undefined,
605
405
  onToolDelta: opts?.onToolDelta,
606
406
  onWarning: opts?.onWarning,
607
- onThinking: opts?.onThinking,
407
+ onThinking: opts?.onThinking !== undefined || sinkThinking
408
+ ? (thinking) => {
409
+ opts?.onThinking?.(thinking);
410
+ emitTurnEvent(sink, (s) => s.onThinking?.(thinking));
411
+ }
412
+ : undefined,
608
413
  sleep: opts?.sleep,
609
414
  reasoningEffort: opts?.reasoningEffort,
610
415
  signal,
@@ -614,11 +419,17 @@ export async function runLoopWithChat(chatFn, history, opts) {
614
419
  // A failed POST still records its model call (with the error) so the
615
420
  // trace shows what was attempted — the caller still rolls back.
616
421
  const modelEnd = Date.now();
422
+ const failedMs = Math.max(0, modelEnd - modelStart);
423
+ noteModel(`model-step-${step}`, failedMs);
424
+ if (typeof e?.message === "string" && e.message.startsWith("Truncated stream")) {
425
+ truncationNotices += 1;
426
+ }
427
+ failures += 1;
617
428
  reportModelCall({
618
429
  step,
619
430
  startedAt: telemetryIso(modelStart),
620
431
  endedAt: telemetryIso(modelEnd),
621
- durationMs: Math.max(0, modelEnd - modelStart),
432
+ durationMs: failedMs,
622
433
  usageReported: false,
623
434
  toolCallCount: 0,
624
435
  finishReason: "error",
@@ -631,7 +442,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
631
442
  // wins; every other failure throws exactly as before.
632
443
  if (isEmptyReplyError(e) && emptyRounds < MAX_EMPTY_ROUNDS) {
633
444
  emptyRounds += 1;
634
- failures += 1;
445
+ // failures already counted once above for this failed POST.
635
446
  history.push({ role: "assistant", content: "" });
636
447
  history.push({ role: "user", content: emptyResponseFollowUp(emptyRounds) });
637
448
  continue;
@@ -698,11 +509,15 @@ export async function runLoopWithChat(chatFn, history, opts) {
698
509
  // actually carried it (usageReported) — never synthesized here.
699
510
  const modelEnd = Date.now();
700
511
  const callsCount = (msg.tool_calls ?? []).length;
512
+ const okMs = Math.max(0, modelEnd - modelStart);
513
+ noteModel(`model-step-${step}`, okMs);
514
+ if (msg.truncated === true)
515
+ truncationNotices += 1;
701
516
  reportModelCall({
702
517
  step,
703
518
  startedAt: telemetryIso(modelStart),
704
519
  endedAt: telemetryIso(modelEnd),
705
- durationMs: Math.max(0, modelEnd - modelStart),
520
+ durationMs: okMs,
706
521
  usage: msg.usage,
707
522
  usageReported: msg.usage !== undefined,
708
523
  reasoningLabel: msg.reasoning,
@@ -796,6 +611,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
796
611
  catch {
797
612
  // ignore
798
613
  }
614
+ reportPhase("done");
799
615
  return outcome.finalText;
800
616
  }
801
617
  if (judgeError !== null || verdict === null) {
@@ -810,6 +626,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
810
626
  catch {
811
627
  // ignore
812
628
  }
629
+ reportPhase("done");
813
630
  return outcome.finalText;
814
631
  }
815
632
  disposition = verdict;
@@ -864,6 +681,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
864
681
  catch {
865
682
  // ignore
866
683
  }
684
+ reportPhase("done");
867
685
  return outcome.finalText;
868
686
  }
869
687
  // A guard-style continue: not a turn end, so no goal-turn
@@ -888,6 +706,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
888
706
  catch {
889
707
  // ignore
890
708
  }
709
+ reportPhase("done");
891
710
  return final;
892
711
  }
893
712
  if (disposition !== null &&
@@ -937,6 +756,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
937
756
  catch {
938
757
  // ignore
939
758
  }
759
+ reportPhase("done");
940
760
  return outcome.finalText;
941
761
  }
942
762
  noteGoalTurn();
@@ -967,6 +787,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
967
787
  catch {
968
788
  // ignore
969
789
  }
790
+ reportPhase("done");
970
791
  return outcome.finalText;
971
792
  }
972
793
  if (step >= maxSteps) {
@@ -991,6 +812,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
991
812
  catch {
992
813
  // ignore
993
814
  }
815
+ reportPhase("done");
994
816
  return notice;
995
817
  }
996
818
  // Total tool-call budget (explicit `opts.maxTotalToolCalls` only;
@@ -1016,28 +838,36 @@ export async function runLoopWithChat(chatFn, history, opts) {
1016
838
  catch {
1017
839
  // ignore
1018
840
  }
841
+ reportPhase("done");
1019
842
  return notice;
1020
843
  }
1021
844
  history.push({ role: "assistant", content: msg.content ?? null, tool_calls: calls });
1022
- // Commit helper shared by the serial and parallel paths: Task 7
845
+ // Commit funnel shared by the serial and parallel paths: Task 7
1023
846
  // bookkeeping + one ordered transcript entry per call. Only successful
1024
847
  // executions count — denials, validation errors, and unknown tools (all
1025
848
  // `Error:` results) never ran, so they neither arm nor clear the gate.
849
+ //
850
+ // The funnel returns the commit receipt (see ToolCallReceipt in
851
+ // ./tool-pipeline.js): the effective args plus the final post-patch
852
+ // result. The serial driver reports telemetry from this receipt AFTER
853
+ // the commit, so telemetry, history, and activity all observe the same
854
+ // values. The parallel path keeps its pre-commit telemetry reporting
855
+ // untouched (ticket 05 owns that path) and ignores the return.
1026
856
  const commitToolResult = async (name, parsed, call, result, durationMs) => {
1027
857
  const baseIsError = typeof result === "string" && result.startsWith("Error");
1028
858
  // Extension post-hooks observe the raw commit candidate first (every
1029
859
  // committed result: executions, blocks, denials, validation errors);
1030
860
  // the caller's onToolResult hook runs last on the patched version.
1031
861
  // Patches apply per call in commit order, so tool_call_id re-pairing
1032
- // and ordering are untouched; throwing patchers fail open above.
1033
- const after = await runAfterIntercept(name, parsed, result, baseIsError);
1034
- const hooked = await applyToolResultHook(opts?.onToolResult, name, parsed, after.content, after.isError);
862
+ // and ordering are untouched; throwing patchers fail open inside the
863
+ // pipeline module.
864
+ const patched = await applyCommitPatches(name, parsed, result, baseIsError, opts?.onToolResult);
1035
865
  // Veto: skip the commit entirely — no counters, no gates, no history,
1036
866
  // no activity. The turn continues; pairing risk is the hook author's.
1037
- if (hooked.veto)
1038
- return false;
1039
- const finalResult = hooked.content;
1040
- const isError = hooked.isError;
867
+ if (patched.veto)
868
+ return { committed: false, result: patched.content, isError: patched.isError };
869
+ const finalResult = patched.content;
870
+ const isError = patched.isError;
1041
871
  toolCalls += 1;
1042
872
  if (isError)
1043
873
  failures += 1;
@@ -1051,8 +881,11 @@ export async function runLoopWithChat(chatFn, history, opts) {
1051
881
  catch {
1052
882
  // observer errors never break the loop
1053
883
  }
1054
- if (typeof durationMs === "number")
884
+ if (typeof durationMs === "number") {
1055
885
  noteBottleneck(name, durationMs);
886
+ if (Number.isFinite(durationMs) && durationMs >= 0)
887
+ toolTotalMs += Math.floor(durationMs);
888
+ }
1056
889
  if (!isError && (name === "write" || name === "edit")) {
1057
890
  filesWritten = true;
1058
891
  verifiedAfterWrite = false;
@@ -1089,7 +922,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
1089
922
  catch {
1090
923
  // ignore observer errors
1091
924
  }
1092
- return true;
925
+ // TurnEvents: the commit funnel is the single commit-order point — every
926
+ // committed result reports its stable identity here, right after the
927
+ // activity callback. Vetoed results return above with neither activity
928
+ // nor sink event, exactly matching the callbacks.
929
+ emitTurnEvent(sink, (s) => s.onToolFinished?.({ toolCallId: call?.id ?? "", name, isError }));
930
+ return { committed: true, result: finalResult, isError };
1093
931
  };
1094
932
  // Length-truncated response (the output limit cut the tool arguments
1095
933
  // off): NOTHING executes — every carried call commits a repair-oriented
@@ -1099,6 +937,8 @@ export async function runLoopWithChat(chatFn, history, opts) {
1099
937
  // step/total-call budgets above keep bounding runaway retries.
1100
938
  // Truncated-without-calls never reaches here (handled by the turn-end
1101
939
  // gates above, exactly as before).
940
+ // NOTE: truncationNotices is counted at the POST (success) and stream-death
941
+ // sites above — not here — so each truncated response counts exactly once.
1102
942
  if (msg.truncated === true) {
1103
943
  for (let i = 0; i < calls.length; i++) {
1104
944
  const call = calls[i];
@@ -1107,40 +947,55 @@ export async function runLoopWithChat(chatFn, history, opts) {
1107
947
  `nothing was executed. Re-issue the call with complete arguments ` +
1108
948
  `(narrow the scope or split into smaller calls if it keeps truncating).`;
1109
949
  const toolAt = Date.now();
950
+ // Truncated arguments are often not valid JSON (cut mid-string) —
951
+ // fall back to {} for bookkeeping (an error result never arms the
952
+ // verification gate, so this only shapes the activity label).
953
+ const parsed = parseToolArguments(call?.function?.arguments) ?? {};
954
+ // TurnEvents: truncated calls never ran, so no phase fired — report
955
+ // the start here so every finished keeps a preceding started.
956
+ emitTurnEvent(sink, (s) => s.onToolStarted?.({ toolCallId: call?.id ?? "", name, index: i }));
957
+ // Commit first, then report telemetry from the receipt: one receipt
958
+ // (effective args + final post-patch result) for telemetry, history,
959
+ // and activity alike.
960
+ const commit = await commitToolResult(name, parsed, call, result);
961
+ const receipt = {
962
+ toolCallId: call?.id ?? "",
963
+ name,
964
+ args: parsed,
965
+ result: commit.result,
966
+ isError: commit.isError,
967
+ durationMs: 0,
968
+ committed: commit.committed,
969
+ decision: "truncated",
970
+ };
1110
971
  reportToolCall({
1111
972
  step,
1112
- toolCallId: call?.id ?? "",
973
+ toolCallId: receipt.toolCallId,
1113
974
  name,
1114
975
  startedAt: telemetryIso(toolAt),
1115
976
  endedAt: telemetryIso(toolAt),
1116
977
  durationMs: 0,
1117
- argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
1118
- result,
978
+ argsJson: telemetryArgsJson(receipt.args),
979
+ result: receipt.result,
1119
980
  batchIndex: i,
1120
981
  batchSize: calls.length,
1121
982
  });
1122
- // Truncated arguments are often not valid JSON (cut mid-string) —
1123
- // fall back to {} for bookkeeping (an error result never arms the
1124
- // verification gate, so this only shapes the activity label).
1125
- let parsed;
1126
- try {
1127
- const raw = call?.function?.arguments ?? "{}";
1128
- const v = JSON.parse(typeof raw === "string" ? raw : "{}");
1129
- parsed = typeof v === "object" && v !== null ? v : {};
1130
- }
1131
- catch {
1132
- parsed = {};
1133
- }
1134
- await commitToolResult(name, parsed, call, result);
1135
983
  }
1136
984
  continue;
1137
985
  }
1138
- for (const batch of planBatches(calls)) {
986
+ // Static registry snapshot (ticket 05): captured once per tool_calls
987
+ // block at plan time and threaded through planning, so a mid-turn
988
+ // extension registration cannot reshape an already-planned batch.
989
+ const registrySnapshot = captureSchedulerSnapshot();
990
+ for (const batch of planBatches(calls, registrySnapshot)) {
1139
991
  // No new executions after a cancel: the current tool (if any) already
1140
992
  // finished; stop before starting the next batch.
1141
993
  throwIfCancelled(signal);
1142
994
  if (batch.length === 1) {
1143
- // Serial path: byte-identical to the pre-05 loop body.
995
+ // Serial path: one call through the pipeline module
996
+ // (hook → validate → approve → execute), committed through the
997
+ // shared funnel, with telemetry reported from the commit receipt —
998
+ // one receipt for telemetry, history, and activity alike.
1144
999
  const call = batch[0].call;
1145
1000
  const name = call?.function?.name ?? "(unknown)";
1146
1001
  try {
@@ -1149,42 +1004,53 @@ export async function runLoopWithChat(chatFn, history, opts) {
1149
1004
  catch {
1150
1005
  // ignore
1151
1006
  }
1007
+ // TurnEvents: phase mirror + start of this tool transition, keyed by
1008
+ // the stable tool_call_id — never the display label.
1009
+ reportPhase("tool", name);
1010
+ emitTurnEvent(sink, (s) => s.onToolStarted?.({ toolCallId: call?.id ?? "", name, index: calls.indexOf(call) }));
1152
1011
  const toolStart = Date.now();
1153
- let parsed;
1154
- try {
1155
- const raw = call?.function?.arguments ?? "{}";
1156
- const v = JSON.parse(typeof raw === "string" ? raw : "{}");
1157
- parsed = typeof v === "object" && v !== null ? v : {};
1158
- }
1159
- catch {
1160
- parsed = {};
1161
- const result = `Error: invalid call: invalid JSON arguments for tool "${name}" (arguments must be valid JSON). Fix the arguments and retry.`;
1012
+ // One serial telemetry report from a commit receipt (effective args
1013
+ // + final post-patch result). Thrown executions never commit, so
1014
+ // they keep their attempt-shaped report in the catch below.
1015
+ const reportSerialReceipt = (receipt, toolEndMs) => {
1016
+ reportToolCall({
1017
+ step,
1018
+ toolCallId: receipt.toolCallId,
1019
+ name: receipt.name,
1020
+ startedAt: telemetryIso(toolStart),
1021
+ endedAt: telemetryIso(toolEndMs),
1022
+ durationMs: Math.max(0, toolEndMs - toolStart),
1023
+ argsJson: telemetryArgsJson(receipt.args),
1024
+ result: receipt.result,
1025
+ batchIndex: 0,
1026
+ batchSize: 1,
1027
+ });
1028
+ };
1029
+ const unparsed = parseToolArguments(call?.function?.arguments);
1030
+ if (unparsed === null) {
1162
1031
  // Invalid JSON never executes — route through the commit funnel so
1163
1032
  // the result hook still sees every committed result. Bookkeeping
1164
1033
  // (counters, error streak, bottleneck, history, activity) is
1165
1034
  // identical to the inline block this replaced; only the committed
1166
1035
  // content may differ when a hook rewrites it.
1036
+ const parsed = {};
1037
+ const result = invalidJsonArgsResult(name);
1167
1038
  repGuard.note(toolSignature(name, parsed), name);
1168
1039
  const toolEnd = Date.now();
1169
- reportToolCall({
1170
- step,
1040
+ const commit = await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
1041
+ reportSerialReceipt({
1171
1042
  toolCallId: call?.id ?? "",
1172
1043
  name,
1173
- startedAt: telemetryIso(toolStart),
1174
- endedAt: telemetryIso(toolEnd),
1044
+ args: parsed,
1045
+ result: commit.result,
1046
+ isError: commit.isError,
1175
1047
  durationMs: Math.max(0, toolEnd - toolStart),
1176
- argsJson: telemetryArgsJson(call?.function?.arguments ?? "{}"),
1177
- result,
1178
- batchIndex: 0,
1179
- batchSize: 1,
1180
- });
1181
- await commitToolResult(name, parsed, call, result, Math.max(0, toolEnd - toolStart));
1048
+ committed: commit.committed,
1049
+ decision: "invalid-json",
1050
+ }, toolEnd);
1182
1051
  continue;
1183
1052
  }
1184
- let result;
1185
- // Effective (post-rewrite) args: what validated, approved, and ran —
1186
- // telemetry and the commit below must see these, not the originals.
1187
- let effectiveArgs = parsed;
1053
+ const parsed = unparsed;
1188
1054
  // Repetition guard (opt-in via maxRepeatedCalls; unset = track-only):
1189
1055
  // a repeated signature skips execution and yields a guidance error;
1190
1056
  // exhausted nudges stop hard.
@@ -1192,37 +1058,21 @@ export async function runLoopWithChat(chatFn, history, opts) {
1192
1058
  const repNote = repGuard.note(repSig, name);
1193
1059
  if (repNote.intervened) {
1194
1060
  const toolEndRep = Date.now();
1195
- if (repGuard.consumeNudge()) {
1196
- const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
1197
- reportToolCall({
1198
- step,
1199
- toolCallId: call?.id ?? "",
1200
- name,
1201
- startedAt: telemetryIso(toolStart),
1202
- endedAt: telemetryIso(toolEndRep),
1203
- durationMs: 0,
1204
- argsJson: telemetryArgsJson(parsed),
1205
- result: guarded,
1206
- batchIndex: 0,
1207
- batchSize: 1,
1208
- });
1209
- await commitToolResult(name, parsed, call, guarded, 0);
1210
- continue;
1211
- }
1061
+ const hasNudge = repGuard.consumeNudge();
1212
1062
  const guarded = `Error: invalid call: ${repetitionFollowUp(repSig, repNote.consecutive)} Fix the approach and retry.`;
1213
- reportToolCall({
1214
- step,
1063
+ const commit = await commitToolResult(name, parsed, call, guarded, 0);
1064
+ reportSerialReceipt({
1215
1065
  toolCallId: call?.id ?? "",
1216
1066
  name,
1217
- startedAt: telemetryIso(toolStart),
1218
- endedAt: telemetryIso(toolEndRep),
1067
+ args: parsed,
1068
+ result: commit.result,
1069
+ isError: commit.isError,
1219
1070
  durationMs: 0,
1220
- argsJson: telemetryArgsJson(parsed),
1221
- result: guarded,
1222
- batchIndex: 0,
1223
- batchSize: 1,
1224
- });
1225
- await commitToolResult(name, parsed, call, guarded, 0);
1071
+ committed: commit.committed,
1072
+ decision: "repetition-guard",
1073
+ }, toolEndRep);
1074
+ if (hasNudge)
1075
+ continue;
1226
1076
  const stopBase = msg.content ?? "";
1227
1077
  const stopNotice = `${stopBase}${stopBase ? "\n" : ""}${repetitionStopNotice(repSig, repNote.consecutive)}`;
1228
1078
  history.push({ role: "assistant", content: stopNotice });
@@ -1232,12 +1082,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
1232
1082
  catch {
1233
1083
  // ignore
1234
1084
  }
1085
+ reportPhase("done");
1235
1086
  return stopNotice;
1236
1087
  }
1088
+ let outcome;
1237
1089
  try {
1238
- const one = await runOneTool(call, parsed, opts, execute, undefined, recordGoalReport);
1239
- result = one.result;
1240
- effectiveArgs = one.args;
1090
+ outcome = await runSerialToolPipeline(call, parsed, opts, execute, recordGoalReport);
1241
1091
  }
1242
1092
  catch (e) {
1243
1093
  // A cancelled/throwing tool still records its attempt (with the
@@ -1267,22 +1117,18 @@ export async function runLoopWithChat(chatFn, history, opts) {
1267
1117
  throw new LoopCancelledError();
1268
1118
  throw e;
1269
1119
  }
1270
- {
1271
- const toolEnd = Date.now();
1272
- reportToolCall({
1273
- step,
1274
- toolCallId: call?.id ?? "",
1275
- name,
1276
- startedAt: telemetryIso(toolStart),
1277
- endedAt: telemetryIso(toolEnd),
1278
- durationMs: Math.max(0, toolEnd - toolStart),
1279
- argsJson: telemetryArgsJson(effectiveArgs),
1280
- result,
1281
- batchIndex: 0,
1282
- batchSize: 1,
1283
- });
1284
- }
1285
- await commitToolResult(name, effectiveArgs, call, result, Math.max(0, Date.now() - toolStart));
1120
+ const toolEnd = Date.now();
1121
+ const commit = await commitToolResult(name, outcome.args, call, outcome.result, Math.max(0, Date.now() - toolStart));
1122
+ reportSerialReceipt({
1123
+ toolCallId: call?.id ?? "",
1124
+ name,
1125
+ args: outcome.args,
1126
+ result: commit.result,
1127
+ isError: commit.isError,
1128
+ durationMs: Math.max(0, toolEnd - toolStart),
1129
+ committed: commit.committed,
1130
+ decision: outcome.decision,
1131
+ }, toolEnd);
1286
1132
  continue;
1287
1133
  }
1288
1134
  // Parallel batch: every member is pre-validated parallel-safe (see
@@ -1300,6 +1146,14 @@ export async function runLoopWithChat(chatFn, history, opts) {
1300
1146
  catch {
1301
1147
  // ignore
1302
1148
  }
1149
+ // TurnEvents: phase mirror + start per member, in call order, keyed
1150
+ // by the stable tool_call_id — never the display label.
1151
+ reportPhase("tool", member.call?.function?.name ?? "(unknown)");
1152
+ emitTurnEvent(sink, (s) => s.onToolStarted?.({
1153
+ toolCallId: member.call?.id ?? "",
1154
+ name: member.call?.function?.name ?? "(unknown)",
1155
+ index: calls.indexOf(member.call),
1156
+ }));
1303
1157
  }
1304
1158
  let results;
1305
1159
  const memberDurations = new Array(batch.length).fill(0);
@@ -1314,6 +1168,14 @@ export async function runLoopWithChat(chatFn, history, opts) {
1314
1168
  repHardStop = { sig: note.signature, consecutive: note.consecutive };
1315
1169
  }
1316
1170
  }
1171
+ // Serial planning pre-pass (in call order, skipping repetition-guarded
1172
+ // members exactly as the serial path would): each member plans through
1173
+ // the shared planToolCall runner — unknown-name gate, pre-hooks,
1174
+ // re-validation, then the approval decision — so rewrites reach the
1175
+ // approval prompt, a block skips approval entirely, and prompts still
1176
+ // resolve before any member executes (concurrent writes never prompt
1177
+ // at once). Cancel between prompts aborts the batch with nothing
1178
+ // executed.
1317
1179
  const memberPlans = new Map();
1318
1180
  const preDecisions = new Map();
1319
1181
  for (let i = 0; i < batch.length; i++) {
@@ -1322,24 +1184,10 @@ export async function runLoopWithChat(chatFn, history, opts) {
1322
1184
  continue;
1323
1185
  const member = batch[i];
1324
1186
  const memberName = member.call?.function?.name ?? "(unknown)";
1325
- if (!toolNames().includes(memberName)) {
1326
- memberPlans.set(i, { args: member.parsed, blocked: null, invalid: null });
1327
- continue;
1328
- }
1329
- const pre = await runBeforeIntercept(memberName, member.parsed);
1330
- if (pre.blocked !== null) {
1331
- memberPlans.set(i, { args: pre.args, blocked: pre.blocked, invalid: null });
1332
- continue;
1333
- }
1334
- const invalid = validateToolArgs(memberName, pre.args);
1335
- if (invalid) {
1336
- memberPlans.set(i, { args: pre.args, blocked: null, invalid });
1337
- continue;
1338
- }
1339
- memberPlans.set(i, { args: pre.args, blocked: null, invalid: null });
1340
- const decision = await resolveApproval(memberName, pre.args, opts);
1341
- if (decision !== null)
1342
- preDecisions.set(i, decision);
1187
+ const planned = await planToolCall(memberName, member.parsed, opts);
1188
+ memberPlans.set(i, planned.plan);
1189
+ if (planned.preDecision !== null)
1190
+ preDecisions.set(i, planned.preDecision);
1343
1191
  }
1344
1192
  try {
1345
1193
  // Each member is timed individually (concurrent wall-clock per call,
@@ -1368,46 +1216,13 @@ export async function runLoopWithChat(chatFn, history, opts) {
1368
1216
  return guarded;
1369
1217
  }
1370
1218
  try {
1371
- // Pre-resolved members (blocked/invalid) never reach the
1372
- // executor: their inline results commit in call order below.
1219
+ // Planned members run the shared runner concurrently: inline
1220
+ // results (unknown/blocked/invalid/denied) never reach the
1221
+ // executor; executions share validate → approve → execute with
1222
+ // the serial path by construction. The pre-pass guarantees a
1223
+ // plan for every non-guarded member (guarded ones return above).
1373
1224
  const plan = memberPlans.get(index);
1374
- if (plan?.blocked !== null && plan?.blocked !== undefined) {
1375
- const blocked = plan.blocked;
1376
- const memberEnd = Date.now();
1377
- memberDurations[index] = Math.max(0, memberEnd - memberStart);
1378
- reportToolCall({
1379
- step,
1380
- toolCallId: member.call?.id ?? "",
1381
- name: member.call?.function?.name ?? "(unknown)",
1382
- startedAt: telemetryIso(memberStart),
1383
- endedAt: telemetryIso(memberEnd),
1384
- durationMs: Math.max(0, memberEnd - memberStart),
1385
- argsJson: telemetryArgsJson(plan.args),
1386
- result: blocked,
1387
- batchIndex: index,
1388
- batchSize: batch.length,
1389
- });
1390
- return blocked;
1391
- }
1392
- if (plan?.invalid) {
1393
- const bad = invalidCall(plan.invalid);
1394
- const memberEnd = Date.now();
1395
- memberDurations[index] = Math.max(0, memberEnd - memberStart);
1396
- reportToolCall({
1397
- step,
1398
- toolCallId: member.call?.id ?? "",
1399
- name: member.call?.function?.name ?? "(unknown)",
1400
- startedAt: telemetryIso(memberStart),
1401
- endedAt: telemetryIso(memberEnd),
1402
- durationMs: Math.max(0, memberEnd - memberStart),
1403
- argsJson: telemetryArgsJson(plan.args),
1404
- result: bad,
1405
- batchIndex: index,
1406
- batchSize: batch.length,
1407
- });
1408
- return bad;
1409
- }
1410
- const r = await runOneToolWithArgs(member.call, plan?.args ?? member.parsed, opts, execute, preDecisions.get(index) ?? null, recordGoalReport);
1225
+ const outcome = await runPlannedToolCall(member.call, plan, preDecisions.get(index) ?? null, opts, execute, recordGoalReport);
1411
1226
  const memberEnd = Date.now();
1412
1227
  memberDurations[index] = Math.max(0, memberEnd - memberStart);
1413
1228
  reportToolCall({
@@ -1417,12 +1232,12 @@ export async function runLoopWithChat(chatFn, history, opts) {
1417
1232
  startedAt: telemetryIso(memberStart),
1418
1233
  endedAt: telemetryIso(memberEnd),
1419
1234
  durationMs: Math.max(0, memberEnd - memberStart),
1420
- argsJson: telemetryArgsJson(plan?.args ?? member.parsed),
1421
- result: r.result,
1235
+ argsJson: telemetryArgsJson(outcome.args),
1236
+ result: outcome.result,
1422
1237
  batchIndex: index,
1423
1238
  batchSize: batch.length,
1424
1239
  });
1425
- return r.result;
1240
+ return outcome.result;
1426
1241
  }
1427
1242
  catch (e) {
1428
1243
  const memberEnd = Date.now();
@@ -1468,6 +1283,7 @@ export async function runLoopWithChat(chatFn, history, opts) {
1468
1283
  catch {
1469
1284
  // ignore
1470
1285
  }
1286
+ reportPhase("done");
1471
1287
  return stopNotice;
1472
1288
  }
1473
1289
  }