@ocis/myagent-cli 0.2.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 (91) hide show
  1. package/README.md +357 -0
  2. package/dist/agent/context.d.ts +33 -0
  3. package/dist/agent/context.js +169 -0
  4. package/dist/agent/modes.d.ts +21 -0
  5. package/dist/agent/modes.js +84 -0
  6. package/dist/agent/prompt-builder.d.ts +9 -0
  7. package/dist/agent/prompt-builder.js +31 -0
  8. package/dist/agent/sessions.d.ts +38 -0
  9. package/dist/agent/sessions.js +130 -0
  10. package/dist/agent/todo.d.ts +18 -0
  11. package/dist/agent/todo.js +61 -0
  12. package/dist/agent/turn.d.ts +309 -0
  13. package/dist/agent/turn.js +1253 -0
  14. package/dist/approval/policy.d.ts +81 -0
  15. package/dist/approval/policy.js +157 -0
  16. package/dist/config.d.ts +49 -0
  17. package/dist/config.js +156 -0
  18. package/dist/git/status.d.ts +89 -0
  19. package/dist/git/status.js +226 -0
  20. package/dist/headless.d.ts +72 -0
  21. package/dist/headless.js +330 -0
  22. package/dist/index.d.ts +60 -0
  23. package/dist/index.js +511 -0
  24. package/dist/protocol/client.d.ts +123 -0
  25. package/dist/protocol/client.js +250 -0
  26. package/dist/protocol/sse-frames.d.ts +6 -0
  27. package/dist/protocol/sse-frames.js +75 -0
  28. package/dist/protocol/types.d.ts +200 -0
  29. package/dist/protocol/types.js +8 -0
  30. package/dist/runtime.d.ts +38 -0
  31. package/dist/runtime.js +166 -0
  32. package/dist/sanitize.d.ts +1 -0
  33. package/dist/sanitize.js +21 -0
  34. package/dist/skills/discovery.d.ts +24 -0
  35. package/dist/skills/discovery.js +109 -0
  36. package/dist/tools/binary.d.ts +2 -0
  37. package/dist/tools/binary.js +22 -0
  38. package/dist/tools/diff.d.ts +1 -0
  39. package/dist/tools/diff.js +49 -0
  40. package/dist/tools/find.d.ts +2 -0
  41. package/dist/tools/find.js +61 -0
  42. package/dist/tools/fs.d.ts +2 -0
  43. package/dist/tools/fs.js +276 -0
  44. package/dist/tools/glob.d.ts +6 -0
  45. package/dist/tools/glob.js +131 -0
  46. package/dist/tools/grep.d.ts +3 -0
  47. package/dist/tools/grep.js +228 -0
  48. package/dist/tools/paths.d.ts +27 -0
  49. package/dist/tools/paths.js +124 -0
  50. package/dist/tools/registry.d.ts +13 -0
  51. package/dist/tools/registry.js +38 -0
  52. package/dist/tools/shell.d.ts +2 -0
  53. package/dist/tools/shell.js +136 -0
  54. package/dist/tools/skills.d.ts +2 -0
  55. package/dist/tools/skills.js +36 -0
  56. package/dist/tools/todo.d.ts +2 -0
  57. package/dist/tools/todo.js +43 -0
  58. package/dist/tools/transfer.d.ts +2 -0
  59. package/dist/tools/transfer.js +145 -0
  60. package/dist/tools/truncate.d.ts +12 -0
  61. package/dist/tools/truncate.js +46 -0
  62. package/dist/tools/types.d.ts +85 -0
  63. package/dist/tools/types.js +63 -0
  64. package/dist/ui/app.d.ts +39 -0
  65. package/dist/ui/app.js +1061 -0
  66. package/dist/ui/colors.d.ts +100 -0
  67. package/dist/ui/colors.js +169 -0
  68. package/dist/ui/components.d.ts +267 -0
  69. package/dist/ui/components.js +811 -0
  70. package/dist/ui/diff.d.ts +37 -0
  71. package/dist/ui/diff.js +143 -0
  72. package/dist/ui/format.d.ts +28 -0
  73. package/dist/ui/format.js +76 -0
  74. package/dist/ui/help.d.ts +6 -0
  75. package/dist/ui/help.js +45 -0
  76. package/dist/ui/highlight.d.ts +20 -0
  77. package/dist/ui/highlight.js +210 -0
  78. package/dist/ui/logo.d.ts +24 -0
  79. package/dist/ui/logo.js +106 -0
  80. package/dist/ui/model-list.d.ts +10 -0
  81. package/dist/ui/model-list.js +33 -0
  82. package/dist/ui/quit-confirm.d.ts +8 -0
  83. package/dist/ui/quit-confirm.js +40 -0
  84. package/dist/ui/select-popup.d.ts +30 -0
  85. package/dist/ui/select-popup.js +54 -0
  86. package/dist/ui/theme.d.ts +4 -0
  87. package/dist/ui/theme.js +41 -0
  88. package/dist/ui/tool-view.d.ts +20 -0
  89. package/dist/ui/tool-view.js +326 -0
  90. package/package.json +44 -0
  91. package/skills/git-commit/SKILL.md +27 -0
@@ -0,0 +1,1253 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The turn loop over the AG-UI run line.
3
+ //
4
+ // One turn = one run stream (POST /v1/runs): AG-UI events arrive verbatim and
5
+ // the stream STAYS OPEN through client-tool waits, so the whole loop —
6
+ // content, tool calls, approval waits, result submission, continuation — is
7
+ // driven by events on a single connection. `CUSTOM_TOOL_CALL` events collected
8
+ // in one tick form an execution round; `sendRunMessages` delivers the results
9
+ // (user/system messages steer, tool-role messages resume) and the agent's
10
+ // continuation keeps streaming here.
11
+ //
12
+ // Release detection is state-based, never timing-based: a call is released
13
+ // when a TOOL_CALL_RESULT arrives for an id still awaiting submission — the
14
+ // server dropped it (isError) or another client answered it — or when a run
15
+ // segment ends with calls still pending. Our own submissions are removed from
16
+ // that pending set BEFORE the request goes out, so their echoes can never be
17
+ // misread.
18
+ //
19
+ // The session events channel is a fallback only: when the run stream drops
20
+ // with calls still owed, the runner reconciles over REST and subscribes to the
21
+ // channel while it finishes the round (no request stream is attached then).
22
+ // ---------------------------------------------------------------------------
23
+ import { IntegrationApiError } from "../protocol/client.js";
24
+ import { isMutatingCall } from "../tools/types.js";
25
+ import { toolRequestOutsideWorkspace } from "../tools/paths.js";
26
+ import { DEFAULT_MODE, modeMarker } from "./modes.js";
27
+ /** Error `code` the integration API uses for a submission whose call was released. */
28
+ const TOOL_CALL_EXPIRED_CODE = "tool_call_expired";
29
+ /** Error `code` for a steer whose run already ended (recover as a new turn). */
30
+ const NO_ACTIVE_RUN_CODE = "no_active_run";
31
+ /** Delay between session-event-channel reconnect attempts (fallback watcher). */
32
+ const EVENT_RECONNECT_DELAY_MS = 5_000;
33
+ /** Delay before the single retry of a transiently failed tool-result submission. */
34
+ const SUBMIT_RETRY_DELAY_MS = 1_000;
35
+ /**
36
+ * Reconnect attempts per recovery. Bounded on purpose: a reconnect wakes a
37
+ * paused sandbox (integration traffic auto-wakes), so an unbounded loop would
38
+ * fight the platform's idle pause. The loop also stops as soon as the
39
+ * outstanding call is known to be released.
40
+ */
41
+ const MAX_EVENT_RECONNECTS = 12;
42
+ /**
43
+ * Canonical form of a tool call's raw JSON arguments for retry matching:
44
+ * parse + stable key order, so `{"a":1,"b":2}` and `{"b":2,"a":1}` compare
45
+ * equal. Unparseable input falls back to a trimmed string.
46
+ */
47
+ function canonicalToolArgs(raw) {
48
+ try {
49
+ return JSON.stringify(sortJson(JSON.parse(raw)));
50
+ }
51
+ catch {
52
+ return raw.trim();
53
+ }
54
+ }
55
+ function sortJson(value) {
56
+ if (Array.isArray(value))
57
+ return value.map(sortJson);
58
+ if (value && typeof value === "object") {
59
+ const out = {};
60
+ for (const key of Object.keys(value).sort()) {
61
+ out[key] = sortJson(value[key]);
62
+ }
63
+ return out;
64
+ }
65
+ return value;
66
+ }
67
+ /**
68
+ * Find a pending call the model re-issued for the same action (same tool name
69
+ * and canonically-equal arguments) — the retry that follows an abandoned call.
70
+ * Adopting it lets the already-executed result be delivered as a native tool
71
+ * result on the same run, without executing the side effect twice.
72
+ */
73
+ function findAdoptableCall(call, pending) {
74
+ const wanted = canonicalToolArgs(call.arguments);
75
+ return pending.find((p) => p.name === call.name && canonicalToolArgs(p.arguments) === wanted);
76
+ }
77
+ /** True when an error means "the call was released before the result arrived". */
78
+ function isToolCallExpired(err) {
79
+ return err instanceof IntegrationApiError && err.code === TOOL_CALL_EXPIRED_CODE;
80
+ }
81
+ /** True when a steer raced the end of its run — the message must be re-sent as a new turn. */
82
+ function isNoActiveRun(err) {
83
+ return err instanceof IntegrationApiError && err.code === NO_ACTIVE_RUN_CODE;
84
+ }
85
+ /** Expired call ids reported by a pre-stream rejection (`details.expiredIds`). */
86
+ function readExpiredIds(err) {
87
+ if (!(err instanceof IntegrationApiError))
88
+ return [];
89
+ const ids = err.details?.expiredIds;
90
+ return Array.isArray(ids) ? ids.filter((id) => typeof id === "string") : [];
91
+ }
92
+ /** True for event-channel failures retrying cannot fix (bad key, deleted session). */
93
+ export function isPermanentEventChannelError(err) {
94
+ return err instanceof IntegrationApiError && (err.status === 401 || err.status === 404);
95
+ }
96
+ /** Map the tool-execution output shape onto the AG-UI input message shape. */
97
+ function toAguiToolResult(output) {
98
+ return {
99
+ role: "tool",
100
+ toolCallId: output.tool_call_id,
101
+ content: output.output,
102
+ ...(output.isError ? { isError: true } : {}),
103
+ ...(output.images?.length ? { images: output.images } : {}),
104
+ };
105
+ }
106
+ /** Drop the nulls `executeOne` returns for cancelled calls. */
107
+ const isToolOutput = (output) => output !== null;
108
+ export class AgentRunner {
109
+ opts;
110
+ threadId;
111
+ mode = DEFAULT_MODE;
112
+ /** undefined = omit thinkingLevel (provider default). */
113
+ thinkingLevel;
114
+ /**
115
+ * The model the user picked for the next new thread (null = client-choice
116
+ * default). Sent only on a new thread's first run: the server pins the
117
+ * thread's model on its first run, and a later request naming a different
118
+ * model on the same thread is rejected with 400.
119
+ */
120
+ selectedModel = null;
121
+ /**
122
+ * Effective model of the current/resumed thread, as reported by the server.
123
+ * Display only — selection goes through `selectedModel`.
124
+ */
125
+ model = null;
126
+ usage = null;
127
+ state = "idle";
128
+ abort = null;
129
+ /**
130
+ * True from stop() until the next run starts — the cancelled-approval
131
+ * wording keys off it (the AbortController is nulled by run()'s finally
132
+ * before an in-flight round resumes, so it cannot be used there).
133
+ */
134
+ stopped = false;
135
+ followUps = [];
136
+ /**
137
+ * Chain of fire-and-forget follow-up turns (user follow-ups).
138
+ * `waitForIdle()` awaits it so headless runs don't exit before a queued
139
+ * turn has been delivered.
140
+ */
141
+ followUpChain = Promise.resolve();
142
+ /**
143
+ * Tool calls already reported as released — populated from the run stream's
144
+ * `TOOL_CALL_RESULT(isError)` echoes, `RUN_FINISHED` with calls still
145
+ * pending, and the fallback event channel. Also the client-side idempotency
146
+ * filter: a replayed CUSTOM_TOOL_CALL for an abandoned call is never
147
+ * executed again.
148
+ */
149
+ abandonedToolCalls = new Set();
150
+ /**
151
+ * Every CUSTOM_TOOL_CALL id received this session. The replay guard: an id
152
+ * seen once is never enqueued again, whatever the delivery path (run stream,
153
+ * event-channel replay) — a duplicate would execute a side-effecting tool
154
+ * twice. Cumulative like abandonedToolCalls: ids are unique per call, so
155
+ * there is nothing to reset.
156
+ */
157
+ seenToolCallIds = new Set();
158
+ /**
159
+ * Unix ms of a server-reported interruption of the previous run (restart /
160
+ * idle scale-down) — wording only: released calls are explained as an
161
+ * interruption instead of an ordinary no-response release.
162
+ */
163
+ lastRunInterruptedAt = null;
164
+ /**
165
+ * Calls received from the agent but not yet submitted: the release filter.
166
+ * An id is removed BEFORE its submission request goes out, so the server's
167
+ * echo of our own result can never be misread as a release.
168
+ */
169
+ pendingSubmission = new Set();
170
+ /**
171
+ * Steers acked but not yet injected into the run, keyed by the persisted
172
+ * message id (from INPUT_ACCEPTED). The ack only means "persisted + queued
173
+ * for injection" — the float stays up until QUEUED_MESSAGE_DELIVERED (the
174
+ * agent actually put the message into the conversation) releases it. The
175
+ * turn-end sweep releases whatever is left: unconsumed steers are re-issued
176
+ * as a fresh prompt by the manager, and the message is in the session
177
+ * either way, so the float must not linger.
178
+ */
179
+ pendingSteerDeliveries = new Map();
180
+ /**
181
+ * Tool rounds scheduled or executing (debounce included). The turn drains
182
+ * them before it settles, so a round is never raced by the followUps drain.
183
+ */
184
+ roundsInFlight = 0;
185
+ roundsDrained = [];
186
+ /**
187
+ * True once the current run segment ended (RUN_FINISHED/RUN_ERROR) — a
188
+ * stream that closes without this observed a drop, not a settle.
189
+ */
190
+ runSettled = false;
191
+ /**
192
+ * True while a channel-loss notice is outstanding (cleared when events flow
193
+ * again) — keeps "lost the event channel" to one notice per outage.
194
+ */
195
+ eventChannelDown = false;
196
+ /** Fallback event-channel subscription (reconcile window only). */
197
+ eventsAbort = null;
198
+ /**
199
+ * Mode last announced to the server. null = unknown (new/resumed thread), so
200
+ * the next message carries `[mode: x]`. Kept in sync by the send paths.
201
+ */
202
+ announcedMode = null;
203
+ constructor(opts) {
204
+ this.opts = opts;
205
+ this.threadId = null;
206
+ }
207
+ get busy() {
208
+ return this.state !== "idle";
209
+ }
210
+ setMode(mode) {
211
+ this.mode = mode;
212
+ }
213
+ /** Forget the announced mode (new thread or resume) — re-announce on next send. */
214
+ resetModeAnnouncement() {
215
+ this.announcedMode = null;
216
+ }
217
+ setThinkingLevel(level) {
218
+ this.thinkingLevel = level;
219
+ }
220
+ /** Select the model for the next new thread (null = server default). */
221
+ setModel(model) {
222
+ this.selectedModel = model;
223
+ }
224
+ /** Advertised tools are mode-independent: a stable schema keeps the prompt cache warm. */
225
+ toolDefs() {
226
+ return this.opts.registry.allDefinitions();
227
+ }
228
+ /** Tool definitions in AG-UI protocol shape (name/description/parameters). */
229
+ aguiToolDefs() {
230
+ return this.toolDefs().map((def) => ({
231
+ name: def.function.name,
232
+ description: def.function.description,
233
+ parameters: def.function.parameters,
234
+ }));
235
+ }
236
+ /**
237
+ * The user text to send, prefixed with `[mode: x]` when the mode changed
238
+ * since the last send (or was never announced). Also returns the mode being
239
+ * announced so the caller can record it after a successful send. The marker
240
+ * is the only mode signal the model gets — the system prompt describes both
241
+ * modes statically.
242
+ */
243
+ announcedText(text) {
244
+ const mode = this.mode;
245
+ const message = this.announcedMode === mode ? text : `${modeMarker(mode)}\n\n${text}`;
246
+ return { message, mode };
247
+ }
248
+ toolContext(signal) {
249
+ return {
250
+ workspace: this.opts.workspace,
251
+ allowOutside: this.opts.allowOutside,
252
+ skillDirs: this.opts.skillDirs,
253
+ signal,
254
+ todos: this.opts.todos,
255
+ client: this.opts.client,
256
+ };
257
+ }
258
+ // -------------------------------------------------------------------------
259
+ // Sending
260
+ // -------------------------------------------------------------------------
261
+ /**
262
+ * Compose a send's input messages: the per-request system block (when one is
263
+ * built) plus the user message with its `[mode: x]` marker applied. Shared by
264
+ * the steer and run paths so their mode-announcement semantics cannot drift.
265
+ */
266
+ async composeInput(text) {
267
+ const system = await this.opts.buildSystemPrompt();
268
+ const { message, mode } = this.announcedText(text);
269
+ return {
270
+ messages: [
271
+ ...(system ? [{ role: "system", content: system }] : []),
272
+ { role: "user", content: message },
273
+ ],
274
+ mode,
275
+ };
276
+ }
277
+ /**
278
+ * Send a user message. While a run is active:
279
+ * - default: steered into the in-flight run (POST .../messages)
280
+ * - `followUp`: queued locally and delivered after the run settles
281
+ */
282
+ async send(text, opts = {}) {
283
+ const cb = this.opts.callbacks;
284
+ if (this.state !== "idle") {
285
+ // Not part of the conversation yet — float it above the prompt until the
286
+ // agent actually injects it (QUEUED_MESSAGE_DELIVERED → onSteerAccepted)
287
+ // or it is delivered as a fresh turn (onUserMessage releases the float).
288
+ cb.onSteerQueued?.(text);
289
+ if (opts.followUp || !this.threadId) {
290
+ // No thread id yet (the first stream hasn't reported one) — a steer
291
+ // would have nowhere to go, so queue it instead of dropping it.
292
+ this.followUps.push(text);
293
+ cb.onNotice(opts.followUp ? "Queued as a follow-up." : "Queued (session still starting).", "info");
294
+ return;
295
+ }
296
+ const { messages, mode } = await this.composeInput(text);
297
+ try {
298
+ const messageId = await this.opts.client.sendRunMessages(this.threadId, messages, { tools: this.aguiToolDefs() });
299
+ this.announcedMode = mode;
300
+ if (messageId) {
301
+ // The ack only means persisted + queued for injection — hold the
302
+ // float until the agent consumes it (QUEUED_MESSAGE_DELIVERED).
303
+ this.pendingSteerDeliveries.set(messageId, text);
304
+ }
305
+ else {
306
+ // No id to correlate with (older server) — release at the ack so
307
+ // the float can never get stuck.
308
+ cb.onSteerAccepted?.(text);
309
+ }
310
+ }
311
+ catch (err) {
312
+ if (isNoActiveRun(err)) {
313
+ // The run ended between the user's send and this request. Nothing
314
+ // was persisted server-side (the server rolls the race-window row
315
+ // back) — deliver it as a fresh turn instead of leaving it stranded.
316
+ // The float stays: run()'s onUserMessage releases it when the
317
+ // follow-up turn actually starts.
318
+ this.followUps.push(text);
319
+ cb.onNotice("The run had already ended — sending as a new turn.", "warn");
320
+ return;
321
+ }
322
+ // Undelivered — the float stays up (the message never entered the
323
+ // conversation); the notice says why.
324
+ cb.onNotice(`Could not steer: ${err instanceof Error ? err.message : String(err)}`, "error");
325
+ }
326
+ return;
327
+ }
328
+ await this.run(text);
329
+ }
330
+ /** Force-stop the run (used by Esc and /stop). */
331
+ async stop() {
332
+ if (this.state === "idle")
333
+ return;
334
+ this.state = "stopping";
335
+ this.stopped = true;
336
+ this.opts.callbacks.onRunState("stopping");
337
+ // A pending approval must not outlive the run — the tool never runs.
338
+ this.opts.approval.cancelPending();
339
+ this.abort?.abort();
340
+ if (this.threadId) {
341
+ await this.opts.client.stopSession(this.threadId).catch(() => { });
342
+ }
343
+ }
344
+ /** Manually compact the remote session history. */
345
+ async compact() {
346
+ if (!this.threadId)
347
+ throw new Error("No active session to compact.");
348
+ await this.opts.client.compactSession(this.threadId);
349
+ await this.refreshSession();
350
+ }
351
+ /** Refresh model/usage/thinking metadata from the server. */
352
+ async refreshSession(includeHistory = false) {
353
+ if (!this.threadId)
354
+ return null;
355
+ try {
356
+ const detail = await this.opts.client.getSession(this.threadId, { history: includeHistory });
357
+ this.model = detail.model ?? this.model;
358
+ if (detail.usage)
359
+ this.usage = usageFromSession(detail.usage);
360
+ this.opts.callbacks.onSession(detail);
361
+ return detail;
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ }
367
+ // -------------------------------------------------------------------------
368
+ // The run
369
+ // -------------------------------------------------------------------------
370
+ async run(text) {
371
+ const cb = this.opts.callbacks;
372
+ const abort = new AbortController();
373
+ this.abort = abort;
374
+ this.stopped = false;
375
+ this.state = "running";
376
+ // Wording state is per turn — an older turn's interruption is stale news.
377
+ this.lastRunInterruptedAt = null;
378
+ this.runSettled = false;
379
+ cb.onRunState("running");
380
+ try {
381
+ cb.onUserMessage(text, false);
382
+ const { messages, mode } = await this.composeInput(text);
383
+ const request = {
384
+ ...(this.threadId ? { threadId: this.threadId } : {}),
385
+ messages,
386
+ tools: this.aguiToolDefs(),
387
+ ...(this.thinkingLevel !== undefined ? { thinkingLevel: this.thinkingLevel } : {}),
388
+ ...(!this.threadId && this.selectedModel ? { model: this.selectedModel } : {}),
389
+ };
390
+ const result = await this.streamRun(request, cb, abort.signal);
391
+ this.announcedMode = mode;
392
+ // A stream that ends without a terminal run event (network error, or a
393
+ // silent close) dropped before the run settled.
394
+ const dropped = Boolean(result.error) || !this.runSettled;
395
+ // The stream is gone but a round is still owed results: the fallback
396
+ // channel is the release watcher for this window (no run stream attached).
397
+ if (dropped && this.roundsInFlight > 0) {
398
+ this.startEventSubscription();
399
+ }
400
+ // Let in-flight tool rounds finish (execution, submission) before
401
+ // judging the turn's outcome — a round may still be waiting on an
402
+ // approval even though the run already ended.
403
+ await this.drainRounds(abort.signal);
404
+ if (abort.signal.aborted) {
405
+ cb.onNotice("Stopped.", "warn");
406
+ return;
407
+ }
408
+ if (dropped) {
409
+ // Recover against the durable session.
410
+ await this.reconcile(cb, abort.signal);
411
+ return;
412
+ }
413
+ // The run settled cleanly; any call still awaiting submission was released.
414
+ if (this.pendingSubmission.size > 0)
415
+ this.markAbandoned([...this.pendingSubmission.keys()]);
416
+ }
417
+ catch (err) {
418
+ if (err instanceof Error && err.name === "AbortError") {
419
+ cb.onNotice("Stopped.", "warn");
420
+ }
421
+ else {
422
+ cb.onNotice(err instanceof Error ? err.message : String(err), "error");
423
+ }
424
+ }
425
+ finally {
426
+ this.stopEventSubscription();
427
+ this.pendingSubmission.clear();
428
+ // Catch-all: any steer still floating when the turn ends can no longer
429
+ // be confirmed by this connection (settled, dropped, stopped, errored).
430
+ // The message is persisted in the session either way, so release it
431
+ // rather than leaving the float hanging.
432
+ this.releasePendingSteers();
433
+ this.abort = null;
434
+ this.state = "idle";
435
+ cb.onRunState("idle");
436
+ await this.refreshSession();
437
+ const next = this.followUps.shift();
438
+ if (next && !abort.signal.aborted) {
439
+ this.followUpChain = this.followUpChain.then(() => this.run(next));
440
+ }
441
+ }
442
+ }
443
+ /**
444
+ * Wait for every in-flight tool round (or an abort, so /stop never hangs on
445
+ * a pending approval).
446
+ */
447
+ async drainRounds(signal) {
448
+ const aborted = new Promise((resolve) => {
449
+ if (signal.aborted)
450
+ return resolve();
451
+ signal.addEventListener("abort", () => resolve(), { once: true });
452
+ });
453
+ while (this.roundsInFlight > 0 && !signal.aborted) {
454
+ await Promise.race([
455
+ new Promise((resolve) => this.roundsDrained.push(resolve)),
456
+ aborted,
457
+ ]);
458
+ }
459
+ }
460
+ /**
461
+ * Open the run stream and dispatch its events until it closes. Returns when
462
+ * the stream ended; `runSettled` distinguishes a clean settle from a drop.
463
+ */
464
+ async streamRun(request, cb,
465
+ /**
466
+ * This run's signal, captured by every round it starts. `this.abort` is
467
+ * nulled by run()'s finally, so a round that outlives the run (stopped
468
+ * while an approval was pending — drainRounds returns on abort) would
469
+ * otherwise read "not aborted" and submit results into the next run's
470
+ * bookkeeping.
471
+ */
472
+ signal) {
473
+ // Parallel custom tool calls arrive in one tick — debounce them into a
474
+ // single execution round (the same batching the server applies to the
475
+ // OpenAI surface's tool_calls frame).
476
+ let batch = [];
477
+ let flushTimer = null;
478
+ const runBatch = () => {
479
+ const calls = batch;
480
+ batch = [];
481
+ void this.executeAndSubmit(calls, cb, signal)
482
+ .catch((err) => {
483
+ cb.onNotice(`Tool round failed: ${err instanceof Error ? err.message : String(err)}`, "error");
484
+ })
485
+ .finally(() => {
486
+ this.roundsInFlight--;
487
+ if (this.roundsInFlight === 0) {
488
+ for (const resolve of this.roundsDrained.splice(0))
489
+ resolve();
490
+ }
491
+ });
492
+ };
493
+ const scheduleFlush = () => {
494
+ if (flushTimer)
495
+ return;
496
+ this.roundsInFlight++;
497
+ flushTimer = setTimeout(() => {
498
+ flushTimer = null;
499
+ runBatch();
500
+ }, 0);
501
+ };
502
+ // Server (non-registry) tool bookkeeping for the informational rendering.
503
+ const serverToolIds = new Set();
504
+ const serverToolArgs = new Map();
505
+ const toolState = {
506
+ serverToolIds,
507
+ serverToolArgs,
508
+ enqueue: (call) => { batch.push(call); scheduleFlush(); },
509
+ };
510
+ const result = await this.opts.client.startRun(request, {
511
+ signal,
512
+ onThreadId: (id) => this.latchThread(id, cb),
513
+ onEvent: (event) => this.handleRunEvent(event, cb, toolState),
514
+ });
515
+ // The stream can close before the debounce tick fires (a run may end in
516
+ // the same tick as its tool call): flush the batch now instead of dropping
517
+ // it — the round must still execute and deliver its results.
518
+ if (flushTimer) {
519
+ clearTimeout(flushTimer);
520
+ flushTimer = null;
521
+ runBatch();
522
+ }
523
+ return result;
524
+ }
525
+ /**
526
+ * One AG-UI event from the run stream. Terminal events drive release
527
+ * detection and the settle flag; `CUSTOM_TOOL_CALL` events feed the
528
+ * debounced execution round.
529
+ */
530
+ handleRunEvent(event, cb, toolState) {
531
+ switch (event.type) {
532
+ case "TEXT_MESSAGE_CONTENT":
533
+ cb.onContent(String(event.delta ?? ""), typeof event.messageId === "string" ? event.messageId : undefined);
534
+ break;
535
+ case "THINKING_CONTENT":
536
+ cb.onReasoning(String(event.delta ?? ""));
537
+ break;
538
+ case "RUN_USAGE":
539
+ this.applyUsage(event, cb);
540
+ break;
541
+ case "TOOL_CALL_START": {
542
+ const id = String(event.toolCallId ?? "");
543
+ const name = String(event.toolName ?? "");
544
+ // Client tools are driven by CUSTOM_TOOL_CALL — only server tools
545
+ // render as informational activity.
546
+ if (this.opts.registry.get(name))
547
+ break;
548
+ toolState.serverToolIds.add(id);
549
+ cb.onServerTool({ id, name, phase: "start" });
550
+ break;
551
+ }
552
+ case "TOOL_CALL_ARGS": {
553
+ const id = String(event.toolCallId ?? "");
554
+ if (!toolState.serverToolIds.has(id))
555
+ break;
556
+ toolState.serverToolArgs.set(id, (toolState.serverToolArgs.get(id) ?? "") + String(event.delta ?? ""));
557
+ break;
558
+ }
559
+ case "TOOL_CALL_RESULT": {
560
+ const id = String(event.toolCallId ?? "");
561
+ const name = String(event.toolName ?? "");
562
+ if (toolState.serverToolIds.has(id) || !this.opts.registry.get(name)) {
563
+ cb.onServerTool({
564
+ id, name, phase: "result",
565
+ arguments: toolState.serverToolArgs.get(id) ?? "",
566
+ result: event.result,
567
+ isError: event.isError === true,
568
+ });
569
+ toolState.serverToolIds.delete(id);
570
+ toolState.serverToolArgs.delete(id);
571
+ break;
572
+ }
573
+ // A client-tool result for a call we have NOT submitted yet means the
574
+ // server released it (isError) or another client answered it — either
575
+ // way the call is consumed server-side and submitting would be
576
+ // rejected as expired. Our own submissions leave `pendingSubmission`
577
+ // before their request goes out, so their echoes never land here.
578
+ if (this.pendingSubmission.has(id))
579
+ this.markAbandoned([id]);
580
+ break;
581
+ }
582
+ case "CUSTOM_TOOL_CALL": {
583
+ const id = String(event.toolCallId ?? "");
584
+ // Replay guard: never execute a tool twice. An id already seen this
585
+ // session — answered, abandoned, or still in flight (batched /
586
+ // executing / submitted) — is a replayed event, not a new call.
587
+ if (this.abandonedToolCalls.has(id) || this.seenToolCallIds.has(id))
588
+ break;
589
+ this.seenToolCallIds.add(id);
590
+ toolState.enqueue({
591
+ id,
592
+ name: String(event.toolName ?? ""),
593
+ arguments: String(event.arguments ?? "{}"),
594
+ });
595
+ break;
596
+ }
597
+ case "QUEUED_MESSAGE_DELIVERED": {
598
+ // The agent injected a steered message into the run — it is part of
599
+ // the conversation now: release its float into the transcript.
600
+ const id = String(event.messageId ?? "");
601
+ if (id)
602
+ this.releaseSteer(id);
603
+ break;
604
+ }
605
+ case "RUN_FINISHED": {
606
+ this.runSettled = true;
607
+ this.releasePending();
608
+ this.releasePendingSteers();
609
+ break;
610
+ }
611
+ case "RUN_ERROR": {
612
+ this.runSettled = true;
613
+ this.releasePending();
614
+ this.releasePendingSteers();
615
+ cb.onNotice(String(event.message ?? "The agent run failed."), "error", typeof event.code === "string" ? event.code : undefined);
616
+ break;
617
+ }
618
+ default:
619
+ // FILE_CHANGED, STEP_*, MESSAGES_SNAPSHOT (never emitted to
620
+ // integration listeners), … — not rendered (parity).
621
+ break;
622
+ }
623
+ }
624
+ applyUsage(event, cb) {
625
+ const u = event.usage;
626
+ if (!u)
627
+ return;
628
+ this.usage = {
629
+ input: u.input ?? 0,
630
+ output: u.output ?? 0,
631
+ cacheRead: u.cacheRead ?? 0,
632
+ cacheWrite: u.cacheWrite ?? 0,
633
+ totalTokens: u.totalTokens ?? 0,
634
+ turns: u.turns ?? 0,
635
+ ...(u.cost ? { cost: u.cost } : {}),
636
+ contextTokens: Number(event.contextTokens ?? 0),
637
+ contextWindow: Number(event.contextWindow ?? 0),
638
+ };
639
+ cb.onUsage(this.usage);
640
+ }
641
+ /** Report every call still awaiting submission as released (run ended). */
642
+ releasePending() {
643
+ if (this.pendingSubmission.size === 0)
644
+ return;
645
+ this.markAbandoned([...this.pendingSubmission.keys()]);
646
+ }
647
+ /** Release the float for a steer the agent actually injected (swap to a
648
+ * transcript bubble). Unknown ids (another client's steer) are ignored. */
649
+ releaseSteer(messageId) {
650
+ const text = this.pendingSteerDeliveries.get(messageId);
651
+ if (text === undefined)
652
+ return;
653
+ this.pendingSteerDeliveries.delete(messageId);
654
+ this.opts.callbacks.onSteerAccepted?.(text);
655
+ }
656
+ /** Release every float still pending — the turn ended before the agent
657
+ * injected the message (settled, dropped, stopped, errored). The message
658
+ * is persisted in the session either way, so the float must not linger. */
659
+ releasePendingSteers() {
660
+ if (this.pendingSteerDeliveries.size === 0)
661
+ return;
662
+ const texts = [...this.pendingSteerDeliveries.values()];
663
+ this.pendingSteerDeliveries.clear();
664
+ for (const text of texts)
665
+ this.opts.callbacks.onSteerAccepted?.(text);
666
+ }
667
+ /**
668
+ * Execute a round's client tool calls (approval + plan-mode refusal) and
669
+ * deliver the results. Runs off the event dispatcher: the run stream stays
670
+ * attached throughout, so release/continuation events keep flowing while a
671
+ * human decides.
672
+ */
673
+ async executeAndSubmit(calls, cb, signal) {
674
+ if (calls.length === 0)
675
+ return;
676
+ for (const call of calls)
677
+ this.pendingSubmission.add(call.id);
678
+ const outputs = await this.executeToolCalls(calls, signal);
679
+ if (signal.aborted)
680
+ return;
681
+ await this.submitRound(calls, outputs, cb, signal);
682
+ }
683
+ /**
684
+ * Resolve once the runner is idle and every queued follow-up turn has been
685
+ * delivered. Headless runs await this before exiting so a queued turn is
686
+ * never cut off by process exit.
687
+ */
688
+ async waitForIdle() {
689
+ let chain;
690
+ do {
691
+ chain = this.followUpChain;
692
+ await chain;
693
+ } while (chain !== this.followUpChain);
694
+ }
695
+ /**
696
+ * Deliver a round's tool results, reconciling against the server's truth
697
+ * first when it advertises `toolCallExpiry`:
698
+ *
699
+ * - still pending (exact id) → submit as-is (native tool result, same run);
700
+ * - the model retried the same action (same name + canonical args) → adopt
701
+ * the retry's id and submit the already-executed result (no double side
702
+ * effect);
703
+ * - other pending calls the client never executed → run them through the
704
+ * normal approval+execution path and submit their results too;
705
+ * - outputs with no matching pending call → the server already released
706
+ * the call; the result is discarded (the agent moved on when the call
707
+ * was released — a follow-up message would only confuse it).
708
+ *
709
+ * A submission that still races a release (pre-stream 400 or the
710
+ * INPUT_REJECTED frame) re-reconciles once.
711
+ */
712
+ async submitRound(calls, outputs, cb, signal) {
713
+ let remaining = outputs;
714
+ const released = [];
715
+ // Separate budgets for the two recoveries: an expiry re-reconcile and a
716
+ // transient-delivery retry are different failures, and a shared bound let
717
+ // the compound case (transient blip, then an expiry race on the retry)
718
+ // exit the loop before the re-reconcile ran — discarding results for ids
719
+ // the server still held pending (all-or-nothing rejection).
720
+ let transientRetriesLeft = 1;
721
+ let expiryReconcilesLeft = 2;
722
+ while (remaining.length > 0) {
723
+ // No thread to submit to (unreachable in practice — the run stream
724
+ // latches the id before any event): the post-loop pass discards them.
725
+ if (!this.threadId)
726
+ break;
727
+ const reconciled = await this.reconcileRound(calls, remaining, cb, signal);
728
+ remaining = reconciled.live;
729
+ // Released results leave the pending set: they are discarded, not
730
+ // submitted — keeping them pending would double-announce the release
731
+ // after the round.
732
+ for (const output of reconciled.released) {
733
+ released.push(output);
734
+ this.pendingSubmission.delete(output.tool_call_id);
735
+ }
736
+ if (remaining.length === 0)
737
+ break;
738
+ // Optimistic removal: the server echoes our own TOOL_CALL_RESULT events
739
+ // on the run stream, and they must never be read as a release.
740
+ for (const output of remaining)
741
+ this.pendingSubmission.delete(output.tool_call_id);
742
+ try {
743
+ // Tool-result submissions carry no `tools` — the server ignores the
744
+ // field on the resume path (the paused run keeps the tools it started
745
+ // with), so sending them is pure payload.
746
+ await this.opts.client.sendRunMessages(this.threadId, remaining.map(toAguiToolResult), {
747
+ signal,
748
+ });
749
+ return;
750
+ }
751
+ catch (err) {
752
+ if (isToolCallExpired(err)) {
753
+ // Released between the reconcile and the submission — re-reconcile;
754
+ // the reported ids are the ones the server already dropped (forced:
755
+ // the ids left the pending set when their request went out). The
756
+ // next pass separates them from siblings that are still pending:
757
+ // those must be re-submitted, not dropped with the expired ones.
758
+ const expiredIds = readExpiredIds(err);
759
+ if (expiredIds.length > 0)
760
+ this.markAbandoned(expiredIds, true);
761
+ if (expiryReconcilesLeft-- > 0)
762
+ continue;
763
+ // The server keeps refusing these ids despite the reconcile — as
764
+ // far as it is concerned they are released; discard like the
765
+ // post-loop pass would.
766
+ break;
767
+ }
768
+ // Aborted: no retry or fallback — the stop path releases the calls.
769
+ if (signal.aborted) {
770
+ for (const output of remaining)
771
+ this.pendingSubmission.add(output.tool_call_id);
772
+ throw err;
773
+ }
774
+ // A non-expired failure (network blip, 5xx) must not silently drop
775
+ // executed results — the agent is still waiting on the call. Retry
776
+ // once after a short delay; the loop's reconcile re-validates the
777
+ // pending set before the retry.
778
+ if (transientRetriesLeft-- > 0) {
779
+ await new Promise((resolve) => setTimeout(resolve, SUBMIT_RETRY_DELAY_MS));
780
+ continue;
781
+ }
782
+ // Persistent failure: the results can no longer be delivered as tool
783
+ // results, but leaving the call unanswered would hang the run until
784
+ // the server's 30-min lease releases it. Unblock the agent with an
785
+ // error result — the tool DID run, its outcome is unknown.
786
+ const undeliverable = remaining.map((output) => ({
787
+ ...output,
788
+ output: "Error: the client could not deliver this tool's result (delivery failed twice). " +
789
+ "The tool DID execute on the user's machine, but its output is unavailable. " +
790
+ "Treat the outcome as unknown and proceed accordingly.",
791
+ isError: true,
792
+ }));
793
+ try {
794
+ await this.opts.client.sendRunMessages(this.threadId, undeliverable.map(toAguiToolResult), {
795
+ signal,
796
+ });
797
+ cb.onNotice(`Failed to deliver ${remaining.length} tool result(s) — the agent was told their outcome is unknown.`, "error");
798
+ return;
799
+ }
800
+ catch {
801
+ // Even the error result failed (the connection is gone): restore
802
+ // the ids so the release detection stays consistent (a later
803
+ // TOOL_CALL_RESULT / RUN_FINISHED can still mark them), then
804
+ // surface the original failure — the dropped-stream reconcile
805
+ // path takes over from here.
806
+ for (const output of remaining)
807
+ this.pendingSubmission.add(output.tool_call_id);
808
+ throw err;
809
+ }
810
+ }
811
+ }
812
+ // Results that never became submittable (no thread, or released on every
813
+ // reconcile attempt): the server already answered those calls — discard.
814
+ released.push(...remaining);
815
+ this.discardReleasedResults(released, cb);
816
+ }
817
+ /**
818
+ * Compare a round's outputs against the server's pending list. Returns the
819
+ * outputs that can still be delivered (exact ids + adopted retries + newly
820
+ * executed pending calls) and the ones whose calls the server already
821
+ * released (discarded by the caller).
822
+ */
823
+ async reconcileRound(calls, outputs, cb, signal) {
824
+ if (!this.threadId)
825
+ return { live: outputs, released: [] };
826
+ let detail = null;
827
+ try {
828
+ // Also the wake trigger when the sandbox was paused (integration traffic
829
+ // auto-wakes; the client retries 503/504 at the transport layer).
830
+ detail = await this.opts.client.getSession(this.threadId);
831
+ }
832
+ catch {
833
+ // Can't verify — submit as-is and let the typed error path recover.
834
+ return { live: outputs, released: [] };
835
+ }
836
+ if (detail.last_run_interrupted_at)
837
+ this.lastRunInterruptedAt = detail.last_run_interrupted_at;
838
+ const pending = detail.pending_tool_calls ?? [];
839
+ const unmatched = new Map(pending.map((p) => [p.id, p]));
840
+ const live = [];
841
+ const released = [];
842
+ for (const output of outputs) {
843
+ if (unmatched.has(output.tool_call_id)) {
844
+ live.push(output);
845
+ unmatched.delete(output.tool_call_id);
846
+ continue;
847
+ }
848
+ const call = calls.find((c) => c.id === output.tool_call_id);
849
+ const adopted = call ? findAdoptableCall(call, [...unmatched.values()]) : undefined;
850
+ if (adopted) {
851
+ live.push({ ...output, tool_call_id: adopted.id });
852
+ unmatched.delete(adopted.id);
853
+ cb.onNotice(`Re-attached the result of ${call?.name ?? "a tool call"} to the agent's retry.`, "info");
854
+ continue;
855
+ }
856
+ released.push(output);
857
+ }
858
+ // Pending calls this client never executed (e.g. the model retried with
859
+ // different arguments): execute them through the normal approval path so
860
+ // the run isn't left waiting on a call nobody will answer.
861
+ if (unmatched.size > 0) {
862
+ const extraCalls = [...unmatched.values()].map((p) => ({
863
+ id: p.id,
864
+ name: p.name,
865
+ arguments: p.arguments,
866
+ }));
867
+ // Register before execution (same as event-driven calls via
868
+ // executeAndSubmit) so a release arriving mid-execution is surfaced by
869
+ // the release filter instead of only surfacing at submission time.
870
+ for (const call of extraCalls)
871
+ this.pendingSubmission.add(call.id);
872
+ live.push(...await this.executeToolCalls(extraCalls, signal));
873
+ }
874
+ return { live, released };
875
+ }
876
+ /**
877
+ * Discard results whose calls the server already released. The agent
878
+ * answered those calls when they were released — delivering the result now
879
+ * (as a follow-up message) would only confuse it. The release itself was
880
+ * usually already surfaced (run stream / channel); only announce the
881
+ * discard when this is the first the user hears of it.
882
+ */
883
+ discardReleasedResults(released, cb) {
884
+ if (released.length === 0)
885
+ return;
886
+ if (!released.every((output) => this.abandonedToolCalls.has(output.tool_call_id))) {
887
+ cb.onNotice(`The server had released ${released.length} tool call(s) (${this.releaseReason()}); the result(s) were discarded.`, "warn");
888
+ }
889
+ }
890
+ /** Human reason for a released call, preferring a known server restart. */
891
+ releaseReason() {
892
+ return this.lastRunInterruptedAt
893
+ ? "the run was interrupted by a server restart"
894
+ : "no response in time";
895
+ }
896
+ /**
897
+ * Mark calls as released and tell the user once. Only calls this turn is
898
+ * actually waiting on are surfaced (a result for an older call arriving on
899
+ * the stream/channel is not actionable) — `force` is for ids the server
900
+ * itself reported as expired, which have already left the pending set.
901
+ */
902
+ markAbandoned(toolCallIds, force = false) {
903
+ const newly = toolCallIds.filter((id) => (force || this.pendingSubmission.has(id)) && !this.abandonedToolCalls.has(id));
904
+ for (const id of newly)
905
+ this.abandonedToolCalls.add(id);
906
+ if (newly.length === 0)
907
+ return;
908
+ // A released call must never run — cancel an approval waiting on it.
909
+ this.opts.approval.cancelPending(newly);
910
+ this.opts.callbacks.onNotice(`The server released ${newly.length} tool call(s) (${this.releaseReason()}).`, "warn");
911
+ }
912
+ latchThread(threadId, cb) {
913
+ if (threadId && threadId !== this.threadId) {
914
+ this.threadId = threadId;
915
+ cb.onThread(threadId);
916
+ }
917
+ }
918
+ // -------------------------------------------------------------------------
919
+ // Fallback event channel (reconcile window only — no run stream attached)
920
+ // -------------------------------------------------------------------------
921
+ /** Open the session event subscription for the recovery window (capability-gated). */
922
+ startEventSubscription() {
923
+ if (!this.threadId)
924
+ return;
925
+ this.stopEventSubscription();
926
+ this.eventChannelDown = false;
927
+ const abort = new AbortController();
928
+ this.eventsAbort = abort;
929
+ void this.runEventSubscription(abort.signal);
930
+ }
931
+ stopEventSubscription() {
932
+ this.eventsAbort?.abort();
933
+ this.eventsAbort = null;
934
+ }
935
+ /**
936
+ * Subscribe → reconnect loop. Bounded (see MAX_EVENT_RECONNECTS) and stopped
937
+ * as soon as the outstanding call is known to be released, so a paused
938
+ * sandbox is woken at most a few times and never kept awake by the channel.
939
+ *
940
+ * Loss and exhaustion are surfaced (once each, state change only): a long
941
+ * approval wait can outlive the sandbox, and the user staring at the
942
+ * approval card deserves to know the channel went away.
943
+ */
944
+ async runEventSubscription(signal) {
945
+ for (let attempt = 0; attempt < MAX_EVENT_RECONNECTS && !signal.aborted; attempt++) {
946
+ try {
947
+ await this.opts.client.openSessionEvents(this.threadId, {
948
+ signal,
949
+ onEvent: (event) => this.handleSessionEvent(event),
950
+ });
951
+ }
952
+ catch (err) {
953
+ if (signal.aborted || (err instanceof Error && err.name === "AbortError"))
954
+ return;
955
+ // Retrying cannot fix a bad key or a deleted session — and every retry
956
+ // wakes a paused sandbox, so stop instead of looping. Say so: the
957
+ // approval wait just lost its safety net.
958
+ if (isPermanentEventChannelError(err)) {
959
+ this.opts.callbacks.onNotice("Session event channel unavailable — a release while you decide will only surface when the result is submitted.", "warn");
960
+ return;
961
+ }
962
+ // Network drop / wake 503 — retry after a delay.
963
+ }
964
+ if (signal.aborted)
965
+ return;
966
+ // Nothing left to watch: stop instead of re-waking a paused sandbox.
967
+ if (this.pendingSubmission.size === 0)
968
+ return;
969
+ // The channel ended while a result is still owed — announce the outage
970
+ // once, not per attempt.
971
+ if (!this.eventChannelDown) {
972
+ this.eventChannelDown = true;
973
+ this.opts.callbacks.onNotice("Lost the session event channel — retrying (the sandbox may be waking)…", "warn");
974
+ }
975
+ if (attempt === MAX_EVENT_RECONNECTS - 1) {
976
+ this.opts.callbacks.onNotice("Stopped watching the session — a release while you decide will only surface when the result is submitted.", "warn");
977
+ return;
978
+ }
979
+ await new Promise((resolve) => setTimeout(resolve, this.opts.eventReconnectDelayMs ?? EVENT_RECONNECT_DELAY_MS));
980
+ }
981
+ }
982
+ /**
983
+ * Handle one fallback-channel event. The channel is subscribed only when no
984
+ * run stream is attached (the recovery window), so an idle snapshot or a
985
+ * terminal run event means the calls still awaiting submission were
986
+ * released. Echoes of our own submissions are already out of
987
+ * `pendingSubmission` and can never be read as releases.
988
+ */
989
+ handleSessionEvent(event) {
990
+ if (this.eventChannelDown) {
991
+ this.eventChannelDown = false;
992
+ this.opts.callbacks.onNotice("Session event channel reconnected.", "info");
993
+ }
994
+ if (event.type === "MESSAGES_SNAPSHOT") {
995
+ if (typeof event.lastRunInterruptedAt === "number")
996
+ this.lastRunInterruptedAt = event.lastRunInterruptedAt;
997
+ // The channel's initial snapshot always carries a status; an explicit
998
+ // non-running status with results still owed means the run is gone.
999
+ if (typeof event.status === "string" && event.status !== "running" && this.pendingSubmission.size > 0) {
1000
+ this.markAbandoned([...this.pendingSubmission.keys()]);
1001
+ this.stopEventSubscription();
1002
+ }
1003
+ return;
1004
+ }
1005
+ if (event.type === "QUEUED_MESSAGE_DELIVERED") {
1006
+ // Delivery can land while the run stream is down — the fallback channel
1007
+ // replays it (same release as the run-stream handler).
1008
+ const id = typeof event.messageId === "string" ? event.messageId : "";
1009
+ if (id)
1010
+ this.releaseSteer(id);
1011
+ return;
1012
+ }
1013
+ if (event.type === "RUN_FINISHED" || event.type === "RUN_ERROR") {
1014
+ this.releasePendingSteers();
1015
+ if (this.pendingSubmission.size > 0) {
1016
+ this.markAbandoned([...this.pendingSubmission.keys()]);
1017
+ this.stopEventSubscription();
1018
+ }
1019
+ return;
1020
+ }
1021
+ // Any result for a still-pending id is a release: the server dropped the
1022
+ // call (isError) or another client answered it. Same state-based rule as
1023
+ // the run stream — our own echoes can't be pending here (ids leave the
1024
+ // set before their submission request goes out).
1025
+ if (event.type === "TOOL_CALL_RESULT") {
1026
+ const id = typeof event.toolCallId === "string" ? event.toolCallId : "";
1027
+ if (this.pendingSubmission.has(id))
1028
+ this.markAbandoned([id]);
1029
+ }
1030
+ }
1031
+ // -------------------------------------------------------------------------
1032
+ // Tool execution
1033
+ // -------------------------------------------------------------------------
1034
+ parseCall(call) {
1035
+ const raw = call.arguments?.trim() || "{}";
1036
+ try {
1037
+ const parsed = JSON.parse(raw);
1038
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1039
+ return { call, args: {}, parseError: "arguments must be a JSON object" };
1040
+ }
1041
+ return { call, args: parsed };
1042
+ }
1043
+ catch (err) {
1044
+ return { call, args: {}, parseError: err instanceof Error ? err.message : String(err) };
1045
+ }
1046
+ }
1047
+ /** Read-only calls run concurrently; mutating and unknown calls serialize. */
1048
+ async executeToolCalls(calls, signal) {
1049
+ const outputs = [];
1050
+ let index = 0;
1051
+ while (index < calls.length && !signal.aborted) {
1052
+ // Batch consecutive read-only calls; anything mutating (per-call) or
1053
+ // unknown serializes for approval and ordering.
1054
+ const batch = [];
1055
+ while (index < calls.length) {
1056
+ const candidate = this.parseCall(calls[index]);
1057
+ if (isMutatingCall(this.opts.registry.get(candidate.call.name), candidate.args))
1058
+ break;
1059
+ batch.push(candidate);
1060
+ index++;
1061
+ }
1062
+ if (batch.length > 0) {
1063
+ const results = await Promise.all(batch.map((parsed) => this.executeOne(parsed, signal)));
1064
+ outputs.push(...results.filter(isToolOutput));
1065
+ continue;
1066
+ }
1067
+ const result = await this.executeOne(this.parseCall(calls[index]), signal);
1068
+ if (result !== null)
1069
+ outputs.push(result);
1070
+ index++;
1071
+ }
1072
+ return outputs;
1073
+ }
1074
+ /**
1075
+ * Execute one client tool call. Returns null when the call was cancelled
1076
+ * (server released it, or the run was stopped while the user decided): the
1077
+ * tool never ran and nothing may be submitted for it.
1078
+ */
1079
+ async executeOne(parsed, signal) {
1080
+ const cb = this.opts.callbacks;
1081
+ const { call, args } = parsed;
1082
+ if (parsed.parseError) {
1083
+ const output = `Error: invalid JSON arguments (${parsed.parseError}).`;
1084
+ cb.onToolStart(call, `${call.name} (invalid args)`);
1085
+ cb.onToolEnd(call, output, true);
1086
+ return { tool_call_id: call.id, output, isError: true };
1087
+ }
1088
+ const handler = this.opts.registry.get(call.name);
1089
+ if (!handler) {
1090
+ const output = `Error: unknown tool "${call.name}". Available: ${this.opts.registry.all().map((t) => t.definition.function.name).join(", ")}.`;
1091
+ cb.onToolStart(call, call.name);
1092
+ cb.onToolEnd(call, output, true);
1093
+ return { tool_call_id: call.id, output, isError: true };
1094
+ }
1095
+ let summary;
1096
+ try {
1097
+ summary = handler.summarize(args);
1098
+ }
1099
+ catch {
1100
+ summary = call.name;
1101
+ }
1102
+ cb.onToolStart(call, summary);
1103
+ try {
1104
+ // Mode is enforced here rather than by hiding tools: the advertised tool
1105
+ // set stays constant (prompt-cache stable) and plan mode refuses any
1106
+ // mutating call with a model-readable error. Checked before approval —
1107
+ // plan mode is not an ASK/AUTO question. Mutation is per-call for tools
1108
+ // whose direction decides it (transfer get vs put/list).
1109
+ const mutating = isMutatingCall(handler, args);
1110
+ if (mutating && this.mode === "plan") {
1111
+ const output = `Error: cannot use ${call.name} in plan mode — it changes your machine. Switch to Coding mode (Tab) and retry.`;
1112
+ cb.onToolEnd(call, output, true);
1113
+ return { tool_call_id: call.id, output, isError: true };
1114
+ }
1115
+ // Outside-workspace calls are asked about even in AUTO: a pre-resolve
1116
+ // check (symlink-aware) routes them through approval before the tool runs.
1117
+ // In-workspace read-only calls skip approval entirely (the policy would
1118
+ // wave them through anyway), which also keeps bookkeeping tools like
1119
+ // local_update_todo usable in plan mode.
1120
+ const outside = !this.opts.allowOutside
1121
+ && await toolRequestOutsideWorkspace(call.name, args, this.opts.workspace);
1122
+ if (mutating || outside) {
1123
+ const outcome = await this.opts.approval.approve({
1124
+ tool: call.name, summary, outsideWorkspace: outside, callId: call.id, mutating,
1125
+ });
1126
+ if (outcome === "cancelled") {
1127
+ // The server released the call (or the run was stopped) while the
1128
+ // user was deciding — the tool must not run, and there is nothing
1129
+ // to submit: the server already answered the call.
1130
+ this.pendingSubmission.delete(call.id);
1131
+ const reason = this.stopped ? "the run was stopped" : "the server released this call";
1132
+ cb.onToolEnd(call, `Cancelled — ${reason}.`, true);
1133
+ return null;
1134
+ }
1135
+ if (outcome !== "approved") {
1136
+ const output = outcome === "timeout"
1137
+ ? this.opts.approval.timeoutMessage
1138
+ : this.opts.approval.deniedMessage;
1139
+ cb.onToolEnd(call, output, true);
1140
+ return { tool_call_id: call.id, output, isError: true };
1141
+ }
1142
+ }
1143
+ // An approved outside call runs with the confinement check lifted for
1144
+ // this invocation only.
1145
+ const ctx = outside ? { ...this.toolContext(signal), allowOutside: true } : this.toolContext(signal);
1146
+ const output = await handler.run(args, ctx);
1147
+ cb.onToolEnd(call, output, false);
1148
+ return { tool_call_id: call.id, output };
1149
+ }
1150
+ catch (err) {
1151
+ const output = `Error: ${err instanceof Error ? err.message : String(err)}`;
1152
+ cb.onToolEnd(call, output, true);
1153
+ return { tool_call_id: call.id, output, isError: true };
1154
+ }
1155
+ }
1156
+ // -------------------------------------------------------------------------
1157
+ // Recovery
1158
+ // -------------------------------------------------------------------------
1159
+ /**
1160
+ * The run stream died before the run settled. Reconcile against the durable
1161
+ * session: if the backend is waiting on tool calls, re-run READ-ONLY ones
1162
+ * (safe) and fail mutating ones rather than re-applying side effects
1163
+ * silently. The event channel watches for a release while the round is
1164
+ * finished (no request stream is attached in this window).
1165
+ */
1166
+ async reconcile(cb, signal) {
1167
+ if (!this.threadId) {
1168
+ cb.onNotice("Connection dropped before a session id was assigned.", "error");
1169
+ return;
1170
+ }
1171
+ cb.onNotice("Connection dropped — reconciling session state…", "warn");
1172
+ let detail = null;
1173
+ try {
1174
+ detail = await this.opts.client.getSession(this.threadId);
1175
+ }
1176
+ catch {
1177
+ cb.onNotice("Could not reach the server to reconcile.", "error");
1178
+ return;
1179
+ }
1180
+ if (detail.last_run_interrupted_at)
1181
+ this.lastRunInterruptedAt = detail.last_run_interrupted_at;
1182
+ const pending = detail.pending_tool_calls ?? [];
1183
+ if (pending.length > 0) {
1184
+ this.startEventSubscription();
1185
+ const calls = [];
1186
+ const outputs = [];
1187
+ for (const item of pending) {
1188
+ // A stop pressed mid-reconcile must not keep re-executing calls —
1189
+ // same rule as executeToolCalls' loop guard.
1190
+ if (signal.aborted)
1191
+ break;
1192
+ const call = { id: item.id, name: item.name, arguments: item.arguments };
1193
+ calls.push(call);
1194
+ // Register before execution (same as executeAndSubmit/reconcileRound):
1195
+ // a release arriving while an approval is pending must cancel it via
1196
+ // the release filter, not wait out the approval timeout.
1197
+ this.pendingSubmission.add(item.id);
1198
+ const handler = this.opts.registry.get(item.name);
1199
+ const parsed = this.parseCall(call);
1200
+ if (!isMutatingCall(handler, parsed.args)) {
1201
+ const result = await this.executeOne(parsed, signal);
1202
+ if (result !== null)
1203
+ outputs.push(result);
1204
+ }
1205
+ else {
1206
+ const output = "Error: tool was not re-executed after the CLI reconnected (mutating tools are never replayed automatically). Re-run it if needed.";
1207
+ cb.onToolStart(call, item.name);
1208
+ cb.onToolEnd(call, output, true);
1209
+ outputs.push({ tool_call_id: item.id, output, isError: true });
1210
+ }
1211
+ }
1212
+ if (signal.aborted) {
1213
+ // Abandoned mid-round: the server releases the un-answered calls when
1214
+ // the lease expires — submitting partial output under an aborted
1215
+ // signal would only surface a confusing AbortError.
1216
+ this.stopEventSubscription();
1217
+ return;
1218
+ }
1219
+ try {
1220
+ // Same delivery path as an event-driven round: reconcile against the
1221
+ // server's pending list, retry a transient failure, and drop only the
1222
+ // ids the server reports as expired. A raw batch send would discard
1223
+ // every result when one id raced a release.
1224
+ await this.submitRound(calls, outputs, cb, signal);
1225
+ cb.onNotice("Session resumed after reconnect.", "info");
1226
+ }
1227
+ catch (err) {
1228
+ cb.onNotice(`Failed to resume: ${err instanceof Error ? err.message : String(err)}`, "error");
1229
+ }
1230
+ finally {
1231
+ this.stopEventSubscription();
1232
+ }
1233
+ return;
1234
+ }
1235
+ cb.onNotice(`Run state on the server: ${detail.status}. Use /sessions to inspect it.`, "warn");
1236
+ }
1237
+ }
1238
+ /** Convert persisted session usage into the live-usage shape the UI renders. */
1239
+ export function usageFromSession(usage) {
1240
+ if (!usage)
1241
+ return null;
1242
+ return {
1243
+ input: usage.tokenUsage.input,
1244
+ output: usage.tokenUsage.output,
1245
+ cacheRead: usage.tokenUsage.cacheRead ?? 0,
1246
+ cacheWrite: usage.tokenUsage.cacheWrite ?? 0,
1247
+ totalTokens: usage.tokenUsage.totalTokens,
1248
+ turns: usage.tokenUsage.turns,
1249
+ ...(usage.tokenUsage.cost ? { cost: usage.tokenUsage.cost } : {}),
1250
+ contextTokens: usage.contextTokens,
1251
+ contextWindow: usage.contextWindow,
1252
+ };
1253
+ }