talon-agent 3.33.4 → 3.34.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 (98) hide show
  1. package/package.json +5 -2
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/handler.ts +428 -415
  4. package/src/backend/claude-sdk/stream.ts +76 -53
  5. package/src/backend/codex/handler/message.ts +275 -366
  6. package/src/backend/codex/handler/rollout-accounting.ts +137 -0
  7. package/src/backend/openai-agents/handler/message.ts +282 -356
  8. package/src/backend/remote-server/chat-turn.ts +66 -122
  9. package/src/backend/remote-server/index.ts +4 -0
  10. package/src/backend/remote-server/mcp.ts +73 -9
  11. package/src/backend/remote-server/model-catalog/presentation.ts +269 -228
  12. package/src/backend/remote-server/sessions.ts +2 -2
  13. package/src/backend/remote-server/turn.ts +58 -55
  14. package/src/backend/shared/cache-telemetry.ts +17 -1
  15. package/src/backend/shared/handler-to-events.ts +11 -16
  16. package/src/backend/shared/index.ts +14 -15
  17. package/src/backend/shared/result-events.ts +30 -0
  18. package/src/backend/shared/turn-phases.ts +277 -0
  19. package/src/bootstrap.ts +120 -79
  20. package/src/cli/setup.ts +375 -349
  21. package/src/core/background/cron-spec.ts +273 -0
  22. package/src/core/background/heartbeat/agent.ts +167 -104
  23. package/src/core/background/triggers/exit.ts +19 -2
  24. package/src/core/background/triggers/resume.ts +14 -7
  25. package/src/core/background/triggers/state.ts +9 -0
  26. package/src/core/engine/backend-controller/pool.ts +2 -0
  27. package/src/core/engine/backend-controller/state.ts +25 -12
  28. package/src/core/engine/gateway-actions/cron.ts +28 -292
  29. package/src/core/engine/gateway-routes.ts +239 -0
  30. package/src/core/engine/gateway.ts +66 -238
  31. package/src/core/mcp-hub/child-transport.ts +215 -0
  32. package/src/core/mcp-hub/children.ts +72 -13
  33. package/src/core/mcp-hub/index.ts +32 -13
  34. package/src/core/models/active-model.ts +29 -6
  35. package/src/core/vfs/mounts/files.ts +128 -115
  36. package/src/core/weaver/shuttle.ts +33 -2
  37. package/src/core/weaver/weaver.ts +37 -6
  38. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  39. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  40. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  41. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  42. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  43. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  44. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  45. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  46. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  47. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  48. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  49. package/src/frontend/discord/callbacks/index.ts +4 -4
  50. package/src/frontend/discord/connection.ts +36 -0
  51. package/src/frontend/discord/diagnostics.ts +62 -0
  52. package/src/frontend/discord/guild-policy.ts +89 -0
  53. package/src/frontend/discord/index.ts +44 -305
  54. package/src/frontend/discord/outbound.ts +61 -0
  55. package/src/frontend/discord/ready.ts +73 -0
  56. package/src/frontend/discord/runtime.ts +55 -0
  57. package/src/frontend/native/chat-lifecycle.ts +39 -0
  58. package/src/frontend/native/chat-wire.ts +69 -0
  59. package/src/frontend/native/context.ts +106 -0
  60. package/src/frontend/native/control.ts +78 -0
  61. package/src/frontend/native/emit.ts +167 -0
  62. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  63. package/src/frontend/native/handlers.ts +121 -0
  64. package/src/frontend/native/history.ts +101 -0
  65. package/src/frontend/native/index.ts +90 -1293
  66. package/src/frontend/native/media.ts +43 -0
  67. package/src/frontend/native/models.ts +221 -0
  68. package/src/frontend/native/queue.ts +47 -0
  69. package/src/frontend/native/reset.ts +50 -0
  70. package/src/frontend/native/routes/chats.ts +115 -0
  71. package/src/frontend/native/routes/daemon.ts +70 -0
  72. package/src/frontend/native/routes/host.ts +151 -0
  73. package/src/frontend/native/routes/index.ts +22 -0
  74. package/src/frontend/native/routes/mesh.ts +72 -0
  75. package/src/frontend/native/routes/models.ts +54 -0
  76. package/src/frontend/native/routes/params.ts +29 -0
  77. package/src/frontend/native/routes/pre-auth.ts +94 -0
  78. package/src/frontend/native/routes/table.ts +92 -0
  79. package/src/frontend/native/runtime.ts +109 -0
  80. package/src/frontend/native/server.ts +30 -555
  81. package/src/frontend/native/status.ts +26 -0
  82. package/src/frontend/native/tool-result.ts +48 -0
  83. package/src/frontend/native/turn.ts +341 -0
  84. package/src/frontend/telegram/admin.ts +75 -52
  85. package/src/frontend/whatsapp/access.ts +67 -0
  86. package/src/frontend/whatsapp/connection.ts +280 -0
  87. package/src/frontend/whatsapp/inbound.ts +327 -0
  88. package/src/frontend/whatsapp/index.ts +28 -599
  89. package/src/frontend/whatsapp/runtime.ts +74 -0
  90. package/src/storage/metrics.ts +18 -0
  91. package/src/storage/session-record.ts +29 -0
  92. package/src/storage/sessions.ts +24 -0
  93. package/src/storage/trigger-store.ts +8 -0
  94. package/src/util/boot-timer.ts +31 -0
  95. package/src/util/concurrency.ts +28 -0
  96. package/src/util/watchdog.ts +32 -7
  97. package/src/frontend/discord/callbacks/components.ts +0 -793
  98. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Codex main message handler.
3
3
  *
4
- * Orchestrates the full turn lifecycle on top of `@openai/codex-sdk`'s
5
- * `Thread.runStreamed`. Shares the non-SDK-specific primitives with the other
6
- * backends via `../../shared/`. Codex-specific bits: reading the `runStreamed`
7
- * event stream, translating items into shared stream state (see `events.ts`),
8
- * resuming via `codex.resumeThread(id)`, the rollout-JSONL live/settle usage
9
- * accounting, and the ChatGPT-OAuth model-mismatch recovery ladder.
4
+ * Orchestrates the turn on top of `@openai/codex-sdk`'s `Thread.runStreamed`.
5
+ * Codex-specific bits: reading the `runStreamed` event stream, translating
6
+ * items into shared stream state (see `events.ts`), resuming via
7
+ * `codex.resumeThread(id)`, the rollout-JSONL live/settle usage accounting
8
+ * (`rollout-accounting.ts`), and the ChatGPT-OAuth model-mismatch recovery
9
+ * ladder. The post-stream phases are the shared ones in
10
+ * `backend/shared/turn-phases.ts`.
10
11
  */
11
12
 
12
13
  import type { Thread, Usage } from "@openai/codex-sdk";
@@ -14,9 +15,6 @@ import type { QueryParams, QueryResult } from "../../shared/handler-types.js";
14
15
  import {
15
16
  getSession,
16
17
  incrementTurns,
17
- recordUsage,
18
- setSessionName,
19
- setSessionId,
20
18
  resetSession,
21
19
  } from "../../../storage/sessions.js";
22
20
  import { getChatSettings } from "../../../storage/chat-settings.js";
@@ -26,20 +24,19 @@ import { incrementCounter } from "../../../storage/metrics.js";
26
24
 
27
25
  import {
28
26
  createStreamState,
29
- recordTokens,
30
27
  finalizeResponseText,
31
28
  formatUserPrompt,
32
29
  prepareSystemPrompt,
33
- extractSessionName,
34
- summarizeUsage,
35
30
  routeDelivery,
36
31
  buildDeliveryFailureReminder,
37
32
  TextBlockDeliveryError,
38
33
  applyRetryDecision,
39
- recordTurnMetrics,
40
- recordFailedTurnAccounting,
41
- pushLiveUsage,
42
34
  registerTurnInterrupt,
35
+ accountTurn,
36
+ accountFailedTurn,
37
+ nameSessionFromFirstMessage,
38
+ finishCallbackTurn,
39
+ type StreamState,
43
40
  } from "../../shared/index.js";
44
41
 
45
42
  import {
@@ -47,7 +44,6 @@ import {
47
44
  CODEX_DEFAULT_MODEL,
48
45
  CODEX_CHATGPT_DEFAULT_MODEL,
49
46
  CODEX_THREAD_PERMISSIONS,
50
- CODEX_LIVE_POLL_INTERVAL_MS,
51
47
  } from "../constants.js";
52
48
  import {
53
49
  frontendsForChat,
@@ -67,16 +63,24 @@ import {
67
63
  import { supportsReasoningLevel } from "../../../core/models/reasoning-levels.js";
68
64
  import { toCodexReasoningEffort } from "../effort.js";
69
65
  import { markOAuthIncompat } from "../oauth-incompat.js";
70
- import { readLastRolloutSnapshot } from "../token-usage.js";
71
66
  import { activeAborts } from "./state.js";
72
67
  import { CodexUsageExhaustedError, probeUsageExhausted } from "./usage.js";
73
- import { handleEvent } from "./events.js";
68
+ import { handleEvent, type HandleEventContext } from "./events.js";
69
+ import {
70
+ createRolloutAccounting,
71
+ type RolloutAccounting,
72
+ } from "./rollout-accounting.js";
74
73
 
75
74
  // ── Local utility ───────────────────────────────────────────────────────────
76
75
 
77
76
  const errMsg = (e: unknown): string =>
78
77
  e instanceof Error ? e.message : String(e);
79
78
 
79
+ /** The expected close on `end_turn` / a user interrupt: abort after the terminator. */
80
+ const isTerminatorAbort = (state: StreamState, err: unknown): boolean =>
81
+ state.turnTerminated &&
82
+ (errMsg(err) === "AbortError" || /abort/i.test(errMsg(err)));
83
+
80
84
  /**
81
85
  * One-shot ChatGPT-OAuth model-mismatch recovery.
82
86
  *
@@ -183,52 +187,24 @@ async function maybeFallbackForChatGptMismatch(
183
187
  return await handleMessage({ ...params, model: fallbackModel }, true);
184
188
  }
185
189
 
186
- // ── Main handler ────────────────────────────────────────────────────────────
187
-
188
- export async function handleMessage(
189
- params: QueryParams,
190
- _retried = false,
191
- ): Promise<QueryResult> {
192
- const state = getState();
193
- const config = state.config;
194
- if (!config) {
195
- throw new Error("Codex agent not initialized");
196
- }
197
- const codex = ensureCodex(params.chatId);
198
-
199
- const {
200
- chatId,
201
- text,
202
- senderName,
203
- senderHandle,
204
- isGroup,
205
- messageId,
206
- onTextBlock,
207
- onToolUse,
208
- onToolStart,
209
- onToolEnd,
210
- } = params;
211
- const t0 = Date.now();
212
- const session = getSession(chatId);
213
- const previousTurns = session.turns;
190
+ // ── Model resolution ────────────────────────────────────────────────────────
214
191
 
215
- // Resolve active model. Codex accepts arbitrary model strings; we
216
- // pass through whatever the chat settings hold. The fallback chain
217
- // is: chat-settings → config → auth-aware default. The auth-aware
218
- // default is `gpt-5-codex` when an API key is present, `gpt-5.5`
219
- // when only ChatGPT OAuth is configured (because `gpt-5-codex` is
220
- // rejected with a 400 on ChatGPT-mode accounts).
221
- const chatSettings = getChatSettings(chatId);
192
+ /**
193
+ * Codex accepts arbitrary model strings; we pass through whatever the
194
+ * caller resolved (chat-settings → config) and fall back to the auth-aware
195
+ * default: `gpt-5-codex` when an API key is present, `gpt-5.5` when only
196
+ * ChatGPT OAuth is configured (because `gpt-5-codex` is rejected with a
197
+ * 400 on ChatGPT-mode accounts). A model known to be OAuth-incompat on a
198
+ * ChatGPT-OAuth account is swapped pre-emptively rather than letting the
199
+ * first turn fail.
200
+ */
201
+ function resolveCodexModel(chatId: string, requested: string | undefined) {
222
202
  const authInfo = getCodexAuthInfo();
223
203
  const authAwareDefault =
224
204
  authInfo?.mode === "chatgpt"
225
205
  ? CODEX_CHATGPT_DEFAULT_MODEL
226
206
  : CODEX_DEFAULT_MODEL;
227
- const requestedModel =
228
- params.model ?? chatSettings.model ?? config.model ?? authAwareDefault;
229
- // If the resolved model is known OAuth-incompat AND we're on
230
- // ChatGPT OAuth, pre-emptively swap to the chatgpt-compatible
231
- // fallback rather than letting the first turn fail.
207
+ const requestedModel = requested ?? authAwareDefault;
232
208
  let activeModel = requestedModel;
233
209
  if (authInfo?.mode === "chatgpt" && isCodexOAuthIncompat(requestedModel)) {
234
210
  const fallback =
@@ -246,6 +222,188 @@ export async function handleMessage(
246
222
  }
247
223
  }
248
224
  log("agent", `[${chatId}] Codex model resolved: ${activeModel}`);
225
+ return activeModel;
226
+ }
227
+
228
+ /**
229
+ * Availability check (does this model offer the level?) then vocabulary
230
+ * translation (can Codex express it?) — the latter is shared with the
231
+ * one-shot path via `toCodexReasoningEffort` so the two can't drift.
232
+ */
233
+ async function buildThreadOptions(
234
+ activeModel: string,
235
+ requestedEffort: ReturnType<typeof getChatSettings>["effort"],
236
+ ) {
237
+ const activeModelInfo = await getModelInfo(activeModel).catch(
238
+ () => undefined,
239
+ );
240
+ const supportedReasoningLevels =
241
+ activeModelInfo?.supportedReasoningLevels ?? [];
242
+ const modelReasoningEffort =
243
+ requestedEffort &&
244
+ supportsReasoningLevel(requestedEffort, supportedReasoningLevels)
245
+ ? toCodexReasoningEffort(requestedEffort)
246
+ : undefined;
247
+ return {
248
+ activeModelInfo,
249
+ threadOptions: {
250
+ model: activeModel,
251
+ skipGitRepoCheck: true,
252
+ ...(modelReasoningEffort ? { modelReasoningEffort } : {}),
253
+ ...CODEX_THREAD_PERMISSIONS,
254
+ },
255
+ };
256
+ }
257
+
258
+ // ── Stream loop ─────────────────────────────────────────────────────────────
259
+
260
+ function createEventContext(
261
+ params: QueryParams,
262
+ state: StreamState,
263
+ ): HandleEventContext {
264
+ return {
265
+ state,
266
+ seenToolCallIds: new Set<string>(),
267
+ startedToolIds: new Set<string>(),
268
+ codexToolMetrics: { count: 0 },
269
+ onTextBlock: params.onTextBlock,
270
+ onToolUse: params.onToolUse,
271
+ onToolStart: params.onToolStart,
272
+ onToolEnd: params.onToolEnd,
273
+ chatId: params.chatId,
274
+ };
275
+ }
276
+
277
+ /** What the stream reported, readable mid-loop by the failure path too. */
278
+ type CodexStreamOutcome = {
279
+ usage: Usage | null;
280
+ turnFailedError: string | undefined;
281
+ };
282
+
283
+ async function driveCodexStream(inputs: {
284
+ thread: Thread;
285
+ inputText: string;
286
+ abortController: AbortController;
287
+ eventContext: HandleEventContext;
288
+ rollout: RolloutAccounting;
289
+ outcome: CodexStreamOutcome;
290
+ }): Promise<void> {
291
+ const { abortController, eventContext, rollout, outcome } = inputs;
292
+ const { state, chatId } = eventContext;
293
+ const { events } = await inputs.thread.runStreamed(inputs.inputText, {
294
+ signal: abortController.signal,
295
+ });
296
+
297
+ for await (const event of events) {
298
+ if (abortController.signal.aborted && !state.turnTerminated) break;
299
+ handleEvent(event, eventContext);
300
+
301
+ if (event.type === "thread.started") {
302
+ rollout.threadId = event.thread_id;
303
+ } else if (event.type === "turn.completed") {
304
+ outcome.usage = event.usage;
305
+ } else if (event.type === "turn.failed") {
306
+ outcome.turnFailedError = event.error.message;
307
+ } else if (event.type === "error") {
308
+ outcome.turnFailedError = event.message;
309
+ }
310
+
311
+ rollout.pollLive();
312
+
313
+ // Terminator-driven abort: a delivery tool already shipped the
314
+ // reply via the bridge. Cancel further model generation to skip
315
+ // the wrap-up round-trip Codex would otherwise burn.
316
+ if (state.turnTerminated && !abortController.signal.aborted) {
317
+ log("agent", `[${chatId}] terminator fired — aborting Codex turn`);
318
+ try {
319
+ abortController.abort();
320
+ } catch (err) {
321
+ logWarn("agent", `[${chatId}] abort failed: ${errMsg(err)}`);
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ /**
328
+ * The failure ladder: ChatGPT-OAuth mismatch recovery, then the shared
329
+ * retry decision, then terminal-failure accounting and the throw.
330
+ */
331
+ async function recoverCodexFailure(inputs: {
332
+ err: unknown;
333
+ params: QueryParams;
334
+ retried: boolean;
335
+ activeModel: string;
336
+ state: StreamState;
337
+ rollout: RolloutAccounting;
338
+ outcome: CodexStreamOutcome;
339
+ toolCalls: number;
340
+ t0: number;
341
+ }): Promise<QueryResult> {
342
+ const { err, params, retried, activeModel, state, rollout, outcome } = inputs;
343
+ const { chatId } = params;
344
+
345
+ // Check both the captured event-stream message and the thrown error —
346
+ // Codex SDK surfaces it via both channels. Only use the thread ID from
347
+ // this run.
348
+ const fallback = await maybeFallbackForChatGptMismatch(
349
+ `${outcome.turnFailedError ?? ""} ${errMsg(err)}`,
350
+ activeModel,
351
+ params,
352
+ retried,
353
+ chatId,
354
+ rollout.threadId,
355
+ );
356
+ if (fallback) return fallback;
357
+
358
+ const decision = await applyRetryDecision({
359
+ err,
360
+ chatId,
361
+ activeModel,
362
+ retried,
363
+ params,
364
+ recurseWithRetried: (p) => handleMessage(p, true),
365
+ backendLabel: "Codex",
366
+ resetNoun: "thread",
367
+ });
368
+ if (decision.retry) return decision.retry;
369
+
370
+ // Terminal failure — recover whatever usage the rollout recorded
371
+ // before the turn died, then account for it.
372
+ await rollout.settle(outcome.usage).catch(() => {});
373
+ accountFailedTurn({
374
+ backend: "codex",
375
+ chatId,
376
+ state,
377
+ durationMs: Date.now() - inputs.t0,
378
+ model: activeModel,
379
+ toolCalls: inputs.toolCalls,
380
+ });
381
+ logError("agent", `[${chatId}] Codex error: ${decision.classified.message}`);
382
+ throw decision.classified;
383
+ }
384
+
385
+ // ── Main handler ────────────────────────────────────────────────────────────
386
+
387
+ export async function handleMessage(
388
+ params: QueryParams,
389
+ _retried = false,
390
+ ): Promise<QueryResult> {
391
+ const config = getState().config;
392
+ if (!config) {
393
+ throw new Error("Codex agent not initialized");
394
+ }
395
+ const codex = ensureCodex(params.chatId);
396
+
397
+ const { chatId, text, senderName, senderHandle, isGroup, messageId } = params;
398
+ const t0 = Date.now();
399
+ const session = getSession(chatId);
400
+ const previousTurns = session.turns;
401
+
402
+ const chatSettings = getChatSettings(chatId);
403
+ const activeModel = resolveCodexModel(
404
+ chatId,
405
+ params.model ?? chatSettings.model ?? config.model,
406
+ );
249
407
 
250
408
  // Per-session frozen prompt + Codex-specific delivery suffix.
251
409
  const { text: systemPrompt } = prepareSystemPrompt({
@@ -270,49 +428,22 @@ export async function handleMessage(
270
428
  log("agent", `[${chatId}] <- (${text.length} chars)`);
271
429
  traceMessage(chatId, "in", text, { senderName, isGroup });
272
430
 
273
- // Resume an existing Codex thread or start a fresh one. Codex persists
274
- // threads under `~/.codex/sessions/`; we store the thread id in Talon's
275
- // session storage so `resumeThread()` keeps the conversation continuous.
276
- const activeModelInfo = await getModelInfo(activeModel).catch(
277
- () => undefined,
431
+ // Resume the stored Codex thread (persisted under `~/.codex/sessions/`).
432
+ const { activeModelInfo, threadOptions } = await buildThreadOptions(
433
+ activeModel,
434
+ chatSettings.effort,
278
435
  );
279
- const supportedReasoningLevels =
280
- activeModelInfo?.supportedReasoningLevels ?? [];
281
- const requestedEffort = chatSettings.effort;
282
- // Availability check (does this model offer the level?) then vocabulary
283
- // translation (can Codex express it?) — the latter is shared with the
284
- // one-shot path via `toCodexReasoningEffort` so the two can't drift.
285
- const modelReasoningEffort =
286
- requestedEffort &&
287
- supportsReasoningLevel(requestedEffort, supportedReasoningLevels)
288
- ? toCodexReasoningEffort(requestedEffort)
289
- : undefined;
290
- const threadOptions = {
291
- model: activeModel,
292
- skipGitRepoCheck: true,
293
- ...(modelReasoningEffort ? { modelReasoningEffort } : {}),
294
- ...CODEX_THREAD_PERMISSIONS,
295
- };
296
436
  const thread: Thread = session.sessionId
297
437
  ? codex.resumeThread(session.sessionId, threadOptions)
298
438
  : codex.startThread(threadOptions);
299
439
 
300
- // Baseline cumulative token totals from the rollout JSONL, captured
301
- // BEFORE the turn runs. `total_token_usage` accumulates across the
302
- // whole session file, so this turn's usage = post-turn totals minus
303
- // this baseline. Fresh threads have no rollout yet → zero baseline.
304
- // `null` = resumed thread whose baseline couldn't be read.
305
- const baselineTotals = session.sessionId
306
- ? ((await readLastRolloutSnapshot(session.sessionId).catch(() => null))
307
- ?.totals ?? null)
308
- : { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
309
-
310
- // Bind the stream state to the chat so token mutators mirror counts
311
- // into the live-turn overlay — /status updates while the turn runs.
440
+ // Chat-bound state mirrors counts into the live-turn overlay.
312
441
  const streamState = createStreamState(chatId);
313
- const seenToolCallIds = new Set<string>();
314
- const startedToolIds = new Set<string>();
315
- const codexToolMetrics = { count: 0 };
442
+ const rollout = await createRolloutAccounting({
443
+ state: streamState,
444
+ sessionId: session.sessionId,
445
+ });
446
+ const eventContext = createEventContext(params, streamState);
316
447
  const abortController = new AbortController();
317
448
  activeAborts.set(chatId, abortController);
318
449
  // A user interrupt is a synthetic turn terminator: marking the flag
@@ -324,217 +455,42 @@ export async function handleMessage(
324
455
  abortController.abort();
325
456
  });
326
457
 
327
- let usage: Usage | null = null;
328
- let turnFailedError: string | undefined;
329
- let resolvedThreadId: string | undefined;
330
-
331
- // Throttled mid-turn rollout poll. The Codex CLI appends a
332
- // `token_count` event to the rollout JSONL after every API call, so
333
- // tailing it during the turn gives live context-fill / token / API-call
334
- // stats long before `turn.completed`. Fire-and-forget with an in-flight
335
- // guard — never blocks the event loop, never throws.
336
- let rolloutPollInFlight = false;
337
- let lastRolloutPollAt = 0;
338
- const pollRolloutForLiveStats = () => {
339
- if (!resolvedThreadId || rolloutPollInFlight) return;
340
- const now = Date.now();
341
- if (now - lastRolloutPollAt < CODEX_LIVE_POLL_INTERVAL_MS) return;
342
- rolloutPollInFlight = true;
343
- lastRolloutPollAt = now;
344
- readLastRolloutSnapshot(resolvedThreadId)
345
- .then((snap) => {
346
- if (!snap) return;
347
- if (snap.usage) {
348
- streamState.contextTokens = snap.usage.contextTokens;
349
- if (snap.usage.contextWindow) {
350
- streamState.contextWindow = snap.usage.contextWindow;
351
- }
352
- }
353
- if (typeof snap.numApiCalls === "number") {
354
- streamState.numApiCalls = snap.numApiCalls;
355
- }
356
- // Same delta-vs-baseline math as the post-loop accounting; the
357
- // final pass recomputes and overwrites, so a torn mid-turn read
358
- // can't corrupt the committed numbers.
359
- if (snap.totals && baselineTotals) {
360
- streamState.sdkInputTokens = Math.max(
361
- 0,
362
- snap.totals.inputTokens - baselineTotals.inputTokens,
363
- );
364
- streamState.sdkOutputTokens = Math.max(
365
- 0,
366
- snap.totals.outputTokens - baselineTotals.outputTokens,
367
- );
368
- streamState.sdkCacheRead = Math.max(
369
- 0,
370
- snap.totals.cachedInputTokens - baselineTotals.cachedInputTokens,
371
- );
372
- }
373
- pushLiveUsage(streamState);
374
- })
375
- .catch(() => {})
376
- .finally(() => {
377
- rolloutPollInFlight = false;
378
- });
379
- };
380
-
381
- // Final authoritative usage settlement — shared by the success post-loop
382
- // and the terminal-failure path so failed turns account for the tokens
383
- // they burned too. Codex's `turn.completed.usage` is CUMULATIVE across
384
- // every API call in the turn — never in the per-turn units the shared
385
- // stream state (and everything downstream: /status, the companion's
386
- // per-message counts) speaks. The rollout JSONL's totals diffed against
387
- // the pre-turn baseline are this turn's real usage; that is the ONLY
388
- // authoritative source. The SDK figure is a last-resort fallback when
389
- // the rollout can't be read, and it overstates multi-call turns.
390
- const settleUsageAccounting = async (): Promise<void> => {
391
- const last = resolvedThreadId
392
- ? await readLastRolloutSnapshot(resolvedThreadId).catch(() => null)
393
- : null;
394
- if (last?.usage) {
395
- streamState.contextTokens = last.usage.contextTokens;
396
- if (last.usage.contextWindow) {
397
- streamState.contextWindow = last.usage.contextWindow;
398
- }
399
- }
400
- if (typeof last?.numApiCalls === "number") {
401
- streamState.numApiCalls = last.numApiCalls;
402
- }
403
- if (last?.totals && baselineTotals) {
404
- recordTokens(streamState, {
405
- inputTokens: last.totals.inputTokens - baselineTotals.inputTokens,
406
- outputTokens: last.totals.outputTokens - baselineTotals.outputTokens,
407
- cacheRead:
408
- last.totals.cachedInputTokens - baselineTotals.cachedInputTokens,
409
- cacheWrite: 0, // Codex doesn't report cache writes
410
- });
411
- } else if (usage) {
412
- recordTokens(streamState, {
413
- inputTokens: usage.input_tokens,
414
- outputTokens: usage.output_tokens,
415
- cacheRead: usage.cached_input_tokens,
416
- cacheWrite: 0, // Codex doesn't report cache writes
417
- });
418
- }
458
+ const outcome: CodexStreamOutcome = {
459
+ usage: null,
460
+ turnFailedError: undefined,
419
461
  };
420
-
421
462
  const setupMs = Date.now() - t0;
422
463
  let turnMs = 0;
423
464
 
424
465
  try {
425
466
  const turnStart = Date.now();
426
-
427
- // Codex's SDK does not expose `system` directly on `runStreamed`;
428
- // system prompts are baked at thread creation via the CLI's config.
429
- // Talon-side workaround: prepend the system prompt to the user prompt
430
- // as a fenced block on the first turn only. Subsequent turns inherit
431
- // instructions from the resumed thread.
467
+ // `runStreamed` has no `system` slot: prepend the system prompt as a
468
+ // fenced block on the first turn only; resumed threads inherit it.
432
469
  const inputText =
433
470
  previousTurns === 0 ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt;
434
-
435
- const { events } = await thread.runStreamed(inputText, {
436
- signal: abortController.signal,
471
+ await driveCodexStream({
472
+ thread,
473
+ inputText,
474
+ abortController,
475
+ eventContext,
476
+ rollout,
477
+ outcome,
437
478
  });
438
-
439
- for await (const event of events) {
440
- if (abortController.signal.aborted && !streamState.turnTerminated) break;
441
- handleEvent(event, {
442
- state: streamState,
443
- seenToolCallIds,
444
- startedToolIds,
445
- codexToolMetrics,
446
- onTextBlock,
447
- onToolUse,
448
- onToolStart,
449
- onToolEnd,
450
- chatId,
451
- });
452
-
453
- if (event.type === "thread.started") {
454
- resolvedThreadId = event.thread_id;
455
- } else if (event.type === "turn.completed") {
456
- usage = event.usage;
457
- } else if (event.type === "turn.failed") {
458
- turnFailedError = event.error.message;
459
- } else if (event.type === "error") {
460
- turnFailedError = event.message;
461
- }
462
-
463
- pollRolloutForLiveStats();
464
-
465
- // Terminator-driven abort: a delivery tool already shipped the
466
- // reply via the bridge. Cancel further model generation to skip
467
- // the wrap-up round-trip Codex would otherwise burn.
468
- if (streamState.turnTerminated && !abortController.signal.aborted) {
469
- log("agent", `[${chatId}] terminator fired — aborting Codex turn`);
470
- try {
471
- abortController.abort();
472
- } catch (err) {
473
- logWarn("agent", `[${chatId}] abort failed: ${errMsg(err)}`);
474
- }
475
- }
476
- }
477
-
478
479
  turnMs = Date.now() - turnStart;
479
480
  } catch (err) {
480
481
  // Aborted-by-terminator path is the expected close on `end_turn`.
481
- if (
482
- streamState.turnTerminated &&
483
- (errMsg(err) === "AbortError" || /abort/i.test(errMsg(err)))
484
- ) {
485
- // Swallow — turn completed via terminator tool.
486
- } else {
487
- // ChatGPT-OAuth model-mismatch path. Check both the captured
488
- // event-stream message and the thrown error — Codex SDK surfaces
489
- // it via both channels. Only use the thread ID from this run.
490
- const fallback = await maybeFallbackForChatGptMismatch(
491
- `${turnFailedError ?? ""} ${errMsg(err)}`,
492
- activeModel,
493
- params,
494
- _retried,
495
- chatId,
496
- resolvedThreadId,
497
- );
498
- if (fallback) return fallback;
499
-
500
- const outcome = await applyRetryDecision({
482
+ if (!isTerminatorAbort(streamState, err)) {
483
+ return await recoverCodexFailure({
501
484
  err,
502
- chatId,
503
- activeModel,
504
- retried: _retried,
505
485
  params,
506
- recurseWithRetried: (p) => handleMessage(p, true),
507
- backendLabel: "Codex",
508
- resetNoun: "thread",
509
- });
510
- if (outcome.retry) return outcome.retry;
511
-
512
- // Terminal failure — recover whatever usage the rollout recorded
513
- // before the turn died, then account for it (failed turns burn
514
- // real tokens; they must not vanish from /status and /metrics).
515
- await settleUsageAccounting().catch(() => {});
516
- recordFailedTurnAccounting({
517
- backend: "codex",
518
- chatId,
519
- durationMs: Date.now() - t0,
520
- toolCalls: codexToolMetrics.count,
521
- apiCalls: streamState.numApiCalls,
522
- model: activeModel,
523
- usage: {
524
- inputTokens: streamState.sdkInputTokens,
525
- outputTokens: streamState.sdkOutputTokens,
526
- cacheRead: streamState.sdkCacheRead,
527
- cacheWrite: streamState.sdkCacheWrite,
528
- },
529
- contextTokens: streamState.contextTokens,
530
- contextWindow: streamState.contextWindow,
486
+ retried: _retried,
487
+ activeModel,
488
+ state: streamState,
489
+ rollout,
490
+ outcome,
491
+ toolCalls: eventContext.codexToolMetrics.count,
492
+ t0,
531
493
  });
532
-
533
- logError(
534
- "agent",
535
- `[${chatId}] Codex error: ${outcome.classified.message}`,
536
- );
537
- throw outcome.classified;
538
494
  }
539
495
  } finally {
540
496
  unregisterInterrupt();
@@ -548,9 +504,9 @@ export async function handleMessage(
548
504
  // Event-only ChatGPT-mismatch recovery: if the SDK emitted a
549
505
  // `turn.failed` carrying the mismatch text but DIDN'T rethrow, the
550
506
  // catch block above never fired. Catch it here too.
551
- if (turnFailedError && !_retried) {
507
+ if (outcome.turnFailedError && !_retried) {
552
508
  const fallback = await maybeFallbackForChatGptMismatch(
553
- turnFailedError,
509
+ outcome.turnFailedError,
554
510
  activeModel,
555
511
  params,
556
512
  _retried,
@@ -559,57 +515,35 @@ export async function handleMessage(
559
515
  if (fallback) return fallback;
560
516
  }
561
517
 
562
- if (resolvedThreadId) {
563
- const stored = getSession(chatId).sessionId;
564
- if (stored !== resolvedThreadId) {
565
- setSessionId(chatId, resolvedThreadId);
566
- }
567
- }
568
-
569
- await settleUsageAccounting();
518
+ await rollout.settle(outcome.usage);
570
519
 
571
520
  // Surface a synthetic error if Codex failed the turn upstream.
572
- if (turnFailedError) {
573
- streamState.syntheticError = turnFailedError;
521
+ if (outcome.turnFailedError) {
522
+ streamState.syntheticError = outcome.turnFailedError;
574
523
  }
575
524
 
576
525
  const responseText = finalizeResponseText(streamState);
577
526
  const durationMs = Date.now() - t0;
578
- recordTurnMetrics({
527
+ accountTurn({
579
528
  chatId,
580
529
  backend: "codex",
581
- durationMs,
582
- toolCalls: codexToolMetrics.count,
583
- apiCalls: streamState.numApiCalls,
584
- failed: Boolean(turnFailedError),
585
- usage: {
586
- inputTokens: streamState.sdkInputTokens,
587
- outputTokens: streamState.sdkOutputTokens,
588
- cacheRead: streamState.sdkCacheRead,
589
- cacheWrite: streamState.sdkCacheWrite,
590
- },
591
- });
592
-
593
- recordUsage(chatId, {
594
- inputTokens: streamState.sdkInputTokens,
595
- outputTokens: streamState.sdkOutputTokens,
596
- cacheRead: streamState.sdkCacheRead,
597
- cacheWrite: streamState.sdkCacheWrite,
530
+ state: streamState,
598
531
  durationMs,
599
532
  model: activeModel,
600
- // contextTokens comes from the rollout JSONL when available. Falls
601
- // back to 0 → /status shows "unknown", correct under-promise behaviour.
602
- contextTokens: streamState.contextTokens || undefined,
603
- // Prefer the rollout's reported context window over the static catalog.
604
- contextWindow: streamState.contextWindow ?? activeModelInfo?.contextWindow,
605
- numApiCalls: streamState.numApiCalls || undefined,
533
+ sessionId: rollout.threadId,
534
+ failed: Boolean(outcome.turnFailedError),
535
+ toolCalls: eventContext.codexToolMetrics.count,
536
+ context: {
537
+ // contextTokens comes from the rollout JSONL when available. Falls
538
+ // back to 0 → /status shows "unknown", correct under-promise behaviour.
539
+ contextTokens: streamState.contextTokens || undefined,
540
+ // Prefer the rollout's reported context window over the static catalog.
541
+ contextWindow:
542
+ streamState.contextWindow ?? activeModelInfo?.contextWindow,
543
+ numApiCalls: streamState.numApiCalls || undefined,
544
+ },
606
545
  });
607
-
608
- // Set a descriptive session name from the user's first message.
609
- if (previousTurns === 0) {
610
- const name = extractSessionName(text);
611
- if (name) setSessionName(chatId, name);
612
- }
546
+ nameSessionFromFirstMessage({ chatId, text, previousTurns });
613
547
 
614
548
  // ── Delivery — decision tree shared with the other backends ────────────────
615
549
  let delivery;
@@ -619,7 +553,7 @@ export async function handleMessage(
619
553
  chatId,
620
554
  state: streamState,
621
555
  responseText,
622
- onTextBlock,
556
+ onTextBlock: params.onTextBlock,
623
557
  propagateDeliveryFailure: true,
624
558
  });
625
559
  } catch (err) {
@@ -638,38 +572,13 @@ export async function handleMessage(
638
572
  }
639
573
 
640
574
  incrementTurns(chatId);
641
-
642
- log(
643
- "agent",
644
- `[${chatId}] delivery: ${delivery.route} (${delivery.chars} chars)`,
645
- );
646
-
647
- log(
648
- "agent",
649
- `[${chatId}] -> (${summarizeUsage(
650
- {
651
- inputTokens: streamState.sdkInputTokens,
652
- outputTokens: streamState.sdkOutputTokens,
653
- cacheRead: streamState.sdkCacheRead,
654
- cacheWrite: streamState.sdkCacheWrite,
655
- },
656
- { durationMs, toolCalls: streamState.toolCalls },
657
- )} terminator=${streamState.turnTerminated ? "yes" : "no"} ` +
658
- `delivered=${streamState.deliveredTextNorms.length} ` +
659
- `respLen=${responseText.length} ` +
660
- `setup=${setupMs}ms turn=${turnMs}ms)`,
661
- );
662
- traceMessage(chatId, "out", responseText, {
575
+ return finishCallbackTurn({
576
+ chatId,
577
+ state: streamState,
578
+ responseText,
663
579
  durationMs,
664
- toolCalls: streamState.toolCalls,
580
+ setupMs,
581
+ turnMs,
582
+ delivery,
665
583
  });
666
-
667
- return {
668
- text: responseText,
669
- durationMs,
670
- inputTokens: streamState.sdkInputTokens,
671
- outputTokens: streamState.sdkOutputTokens,
672
- cacheRead: streamState.sdkCacheRead,
673
- cacheWrite: streamState.sdkCacheWrite,
674
- };
675
584
  }