@agentex/agent 0.0.33 → 0.0.35

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 (81) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +2 -1
  3. package/dist/providers/claude/discovery.d.ts +64 -0
  4. package/dist/providers/claude/discovery.d.ts.map +1 -0
  5. package/dist/providers/claude/discovery.js +147 -0
  6. package/dist/providers/claude/discovery.js.map +1 -0
  7. package/dist/providers/claude/effort.d.ts +20 -0
  8. package/dist/providers/claude/effort.d.ts.map +1 -0
  9. package/dist/providers/claude/effort.js +32 -0
  10. package/dist/providers/claude/effort.js.map +1 -0
  11. package/dist/providers/claude/execute.d.ts.map +1 -1
  12. package/dist/providers/claude/execute.js +2 -1
  13. package/dist/providers/claude/execute.js.map +1 -1
  14. package/dist/providers/claude/index.d.ts.map +1 -1
  15. package/dist/providers/claude/index.js +2 -1
  16. package/dist/providers/claude/index.js.map +1 -1
  17. package/dist/providers/claude/parse.d.ts.map +1 -1
  18. package/dist/providers/claude/parse.js +26 -4
  19. package/dist/providers/claude/parse.js.map +1 -1
  20. package/dist/providers/claude/session.d.ts.map +1 -1
  21. package/dist/providers/claude/session.js +2 -1
  22. package/dist/providers/claude/session.js.map +1 -1
  23. package/dist/providers/codex/discovery.d.ts +20 -0
  24. package/dist/providers/codex/discovery.d.ts.map +1 -0
  25. package/dist/providers/codex/discovery.js +76 -0
  26. package/dist/providers/codex/discovery.js.map +1 -0
  27. package/dist/providers/codex/execute.d.ts.map +1 -1
  28. package/dist/providers/codex/execute.js +45 -3
  29. package/dist/providers/codex/execute.js.map +1 -1
  30. package/dist/providers/codex/index.d.ts.map +1 -1
  31. package/dist/providers/codex/index.js +2 -1
  32. package/dist/providers/codex/index.js.map +1 -1
  33. package/dist/providers/codex/parse.d.ts +14 -6
  34. package/dist/providers/codex/parse.d.ts.map +1 -1
  35. package/dist/providers/codex/parse.js +83 -1
  36. package/dist/providers/codex/parse.js.map +1 -1
  37. package/dist/providers/codex/session.d.ts +50 -1
  38. package/dist/providers/codex/session.d.ts.map +1 -1
  39. package/dist/providers/codex/session.js +611 -46
  40. package/dist/providers/codex/session.js.map +1 -1
  41. package/dist/providers/cursor/discovery.d.ts.map +1 -1
  42. package/dist/providers/cursor/discovery.js +4 -0
  43. package/dist/providers/cursor/discovery.js.map +1 -1
  44. package/dist/providers/opencode/discovery.d.ts.map +1 -1
  45. package/dist/providers/opencode/discovery.js +11 -8
  46. package/dist/providers/opencode/discovery.js.map +1 -1
  47. package/dist/providers/opencode/event-parse.d.ts +23 -0
  48. package/dist/providers/opencode/event-parse.d.ts.map +1 -1
  49. package/dist/providers/opencode/event-parse.js +44 -0
  50. package/dist/providers/opencode/event-parse.js.map +1 -1
  51. package/dist/providers/opencode/history.d.ts.map +1 -1
  52. package/dist/providers/opencode/history.js +25 -4
  53. package/dist/providers/opencode/history.js.map +1 -1
  54. package/dist/providers/opencode/http-session.d.ts.map +1 -1
  55. package/dist/providers/opencode/http-session.js +59 -8
  56. package/dist/providers/opencode/http-session.js.map +1 -1
  57. package/dist/types.d.ts +19 -5
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/utils/model-cache.d.ts +33 -0
  60. package/dist/utils/model-cache.d.ts.map +1 -0
  61. package/dist/utils/model-cache.js +57 -0
  62. package/dist/utils/model-cache.js.map +1 -0
  63. package/package.json +1 -1
  64. package/src/providers/claude/discovery.ts +215 -0
  65. package/src/providers/claude/effort.ts +34 -0
  66. package/src/providers/claude/execute.ts +2 -1
  67. package/src/providers/claude/index.ts +2 -1
  68. package/src/providers/claude/parse.ts +27 -5
  69. package/src/providers/claude/session.ts +2 -1
  70. package/src/providers/codex/discovery.ts +91 -0
  71. package/src/providers/codex/execute.ts +42 -3
  72. package/src/providers/codex/index.ts +2 -1
  73. package/src/providers/codex/parse.ts +95 -3
  74. package/src/providers/codex/session.ts +691 -48
  75. package/src/providers/cursor/discovery.ts +5 -0
  76. package/src/providers/opencode/discovery.ts +10 -7
  77. package/src/providers/opencode/event-parse.ts +63 -0
  78. package/src/providers/opencode/history.ts +32 -4
  79. package/src/providers/opencode/http-session.ts +59 -8
  80. package/src/types.ts +19 -5
  81. package/src/utils/model-cache.ts +67 -0
@@ -27,7 +27,7 @@ import { translateEndpoint } from "../../utils/endpoint.js";
27
27
  import { injectWorkspaceSkills } from "../../utils/skills.js";
28
28
  import { resolveInstructions } from "../../utils/instructions.js";
29
29
  import { createToolNameTracker } from "../../utils/tool-names.js";
30
- import { parseCodexStreamLine } from "./parse.js";
30
+ import { parseCodexStreamLines } from "./parse.js";
31
31
  import { withPlanModePreamble } from "./plan-mode.js";
32
32
  import { scanCodexSessionUsage } from "./usage-scanner.js";
33
33
  import { codexSessionCodec } from "./codec.js";
@@ -130,6 +130,12 @@ interface PendingResult {
130
130
  settled?: boolean;
131
131
  /** Tear down this send's timeout timer / abort listener. */
132
132
  cleanup?: () => void;
133
+ /**
134
+ * Turn generation this send joined. Result delivery drains only the
135
+ * generation it belongs to, so a send that starts a NEW turn while the
136
+ * previous turn's result is still draining cannot be settled by it.
137
+ */
138
+ generation: number;
133
139
  }
134
140
 
135
141
  interface TrackedBackgroundTask {
@@ -138,14 +144,42 @@ interface TrackedBackgroundTask {
138
144
  summary: string | null;
139
145
  parentTaskId: string | null;
140
146
  terminal: boolean;
147
+ generation: number;
148
+ activeTurnId: string | null;
149
+ }
150
+
151
+ interface CodexBackgroundTaskState {
152
+ terminal: boolean;
153
+ status: "running" | "completed" | "failed" | "stopped";
154
+ summary: string | null;
141
155
  }
142
156
 
157
+ const BACKGROUND_TASK_POLL_INTERVAL_MS = 2_000;
158
+ const BACKGROUND_TASK_READ_TIMEOUT_MS = 5_000;
159
+
143
160
  /** Identity latch for the root turn currently represented by this session. */
161
+ /**
162
+ * One turn's outcome, frozen at the terminal frame.
163
+ *
164
+ * Delivery is asynchronous (event chain, and an on-disk usage scan when the
165
+ * stream carried no usage), so reading the live accumulators at delivery time
166
+ * reads whatever turn is running *then*. Snapshotting decouples the two: the
167
+ * accumulators reset immediately and belong to the next turn from that moment.
168
+ */
169
+ interface TurnOutcome {
170
+ summary: string | null;
171
+ isError: boolean;
172
+ errorMessage: string | null;
173
+ wasInterrupted: boolean;
174
+ }
175
+
144
176
  interface ActiveTurnReady {
145
177
  promise: Promise<string | null>;
146
178
  resolve: (turnId: string | null) => void;
147
179
  reject: (err: Error) => void;
148
180
  settled: boolean;
181
+ /** Monotonic id for this turn, used to fence late result delivery. */
182
+ generation: number;
149
183
  }
150
184
 
151
185
  // ---------------------------------------------------------------------------
@@ -179,6 +213,32 @@ function asObj(parent: Record<string, unknown>, key: string): Record<string, unk
179
213
  : {};
180
214
  }
181
215
 
216
+ function obj(value: unknown): Record<string, unknown> {
217
+ return typeof value === "object" && value !== null && !Array.isArray(value)
218
+ ? (value as Record<string, unknown>)
219
+ : {};
220
+ }
221
+
222
+ function stringArray(value: unknown): string[] {
223
+ return Array.isArray(value)
224
+ ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
225
+ : [];
226
+ }
227
+
228
+ function codexAgentState(value: unknown): CodexBackgroundTaskState {
229
+ const state = obj(value);
230
+ const nativeStatus = str(state, "status");
231
+ const summary = str(state, "message") || null;
232
+ if (nativeStatus === "completed") return { terminal: true, status: "completed", summary };
233
+ if (nativeStatus === "errored" || nativeStatus === "notFound") {
234
+ return { terminal: true, status: "failed", summary };
235
+ }
236
+ if (nativeStatus === "interrupted" || nativeStatus === "shutdown") {
237
+ return { terminal: true, status: "stopped", summary };
238
+ }
239
+ return { terminal: false, status: "running", summary };
240
+ }
241
+
182
242
  /** Discriminated incoming message from the Codex CLI. */
183
243
  type IncomingMessage =
184
244
  | { kind: "response"; id: number; result?: Record<string, unknown>; error?: { code: number; message: string } }
@@ -366,6 +426,8 @@ export class CodexSessionImpl implements AgentSession {
366
426
  */
367
427
  private _activeTurnId: string | null = null;
368
428
  private _activeTurnReady: ActiveTurnReady | null = null;
429
+ /** Incremented per turn latch. Never reused, so staleness is decidable. */
430
+ private _turnGeneration = 0;
369
431
  /** Successful repeated interrupts coalesce until the terminal notification. */
370
432
  private _interruptPromise: Promise<void> | null = null;
371
433
  /** Prevents a late interrupt request after the terminal frame was observed. */
@@ -387,6 +449,16 @@ export class CodexSessionImpl implements AgentSession {
387
449
  /** Child-agent lifecycle is informational and never participates in root turn settlement. */
388
450
  private readonly _backgroundTasks = new Map<string, TrackedBackgroundTask>();
389
451
  private readonly _backgroundTaskIdsByPath = new Map<string, string>();
452
+ /** One protocol-native thread/read poller per child when Codex does not multiplex its notifications. */
453
+ private readonly _backgroundTaskPollers = new Map<string, {
454
+ generation: number;
455
+ promise: Promise<void>;
456
+ }>();
457
+ /** Cancellable retry delay for each poller. Cleared when the session closes. */
458
+ private readonly _backgroundTaskPollTimers = new Map<string, {
459
+ timer: ReturnType<typeof setTimeout>;
460
+ resolve: () => void;
461
+ }>();
390
462
 
391
463
  /**
392
464
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
@@ -456,7 +528,14 @@ export class CodexSessionImpl implements AgentSession {
456
528
  proc.on("exit", (code, signal) => {
457
529
  if (this._state !== "closed") {
458
530
  this._state = "closed";
459
- const err = new Error(`Codex process exited unexpectedly (code=${code}, signal=${signal})`);
531
+ const message = `Codex process exited unexpectedly (code=${code}, signal=${signal})`;
532
+ this.finalizeActiveBackgroundTasks("failed", message, {
533
+ method: "process/exit",
534
+ code,
535
+ signal,
536
+ });
537
+ this.cancelBackgroundTaskPollTimers();
538
+ const err = new Error(message);
460
539
  this.rejectAllPending(err);
461
540
  }
462
541
  });
@@ -464,11 +543,59 @@ export class CodexSessionImpl implements AgentSession {
464
543
  proc.on("error", (err) => {
465
544
  if (this._state !== "closed") {
466
545
  this._state = "closed";
546
+ this.finalizeActiveBackgroundTasks("failed", err.message, {
547
+ method: "process/error",
548
+ message: err.message,
549
+ });
550
+ this.cancelBackgroundTaskPollTimers();
467
551
  this.rejectAllPending(err);
468
552
  }
469
553
  });
470
554
  }
471
555
 
556
+ private cancelBackgroundTaskPollTimers(): void {
557
+ for (const pending of this._backgroundTaskPollTimers.values()) {
558
+ clearTimeout(pending.timer);
559
+ pending.resolve();
560
+ }
561
+ this._backgroundTaskPollTimers.clear();
562
+ }
563
+
564
+ private finalizeActiveBackgroundTasks(
565
+ status: "failed" | "stopped",
566
+ summary: string,
567
+ raw: Record<string, unknown>,
568
+ ): void {
569
+ for (const task of this._backgroundTasks.values()) {
570
+ if (task.terminal) continue;
571
+ this.dispatchEvent(this.backgroundTaskEvent(task.taskId, "completed", status, {
572
+ description: task.description,
573
+ summary,
574
+ parentTaskId: task.parentTaskId,
575
+ eventId: this._threadId
576
+ ? `codex:${this._threadId}:background-task:${task.taskId}:session-${status}:completed`
577
+ : null,
578
+ raw,
579
+ }));
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Remove and return the pending sends belonging to one turn generation.
585
+ *
586
+ * `undefined` matches entries with no generation, which is how tests that
587
+ * drive the session without `send()` enqueue resolvers.
588
+ */
589
+ private takePendingResults(generation: number | undefined): PendingResult[] {
590
+ const mine: PendingResult[] = [];
591
+ const others: PendingResult[] = [];
592
+ for (const entry of this._pendingResults) {
593
+ (entry.generation === generation ? mine : others).push(entry);
594
+ }
595
+ this._pendingResults = others;
596
+ return mine;
597
+ }
598
+
472
599
  /** Reject every pending send() Promise and outgoing JSON-RPC call. */
473
600
  private rejectAllPending(err: Error): void {
474
601
  const pending = this._pendingResults.splice(0);
@@ -503,6 +630,7 @@ export class CodexSessionImpl implements AgentSession {
503
630
  resolve: resolveFn,
504
631
  reject: rejectFn,
505
632
  settled: false,
633
+ generation: ++this._turnGeneration,
506
634
  };
507
635
  this._activeTurnId = null;
508
636
  this._activeTurnReady = ready;
@@ -528,9 +656,16 @@ export class CodexSessionImpl implements AgentSession {
528
656
  expected.reject(err);
529
657
  }
530
658
 
531
- /** Clear the current turn and release an interrupt waiting for its id. */
532
- private clearActiveTurn(err?: Error): void {
659
+ /**
660
+ * Clear the current turn and release an interrupt waiting for its id.
661
+ *
662
+ * `expected` fences the clear against a turn that started after the one being
663
+ * torn down. Without it, a late teardown nulls the latch a newer, running
664
+ * turn is relying on, which is the same silent-Stop failure by another route.
665
+ */
666
+ private clearActiveTurn(err?: Error, expected?: ActiveTurnReady): void {
533
667
  const ready = this._activeTurnReady;
668
+ if (expected && ready !== expected) return;
534
669
  if (ready && !ready.settled) {
535
670
  ready.settled = true;
536
671
  if (err) ready.reject(err);
@@ -584,6 +719,35 @@ export class CodexSessionImpl implements AgentSession {
584
719
  });
585
720
  }
586
721
 
722
+ /** A bounded RPC whose pending-map entry is removed if Codex never replies. */
723
+ private boundedRpcRequest(
724
+ method: string,
725
+ params: Record<string, unknown>,
726
+ timeoutMs = BACKGROUND_TASK_READ_TIMEOUT_MS,
727
+ ): Promise<Record<string, unknown>> {
728
+ const id = this._nextId++;
729
+ this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
730
+
731
+ return new Promise((resolve, reject) => {
732
+ const timer = setTimeout(() => {
733
+ if (!this._pendingRpc.delete(id)) return;
734
+ reject(new Error(`codex ${method} timed out`));
735
+ }, timeoutMs);
736
+ if (typeof timer.unref === "function") timer.unref();
737
+
738
+ this._pendingRpc.set(id, {
739
+ resolve: (result) => {
740
+ clearTimeout(timer);
741
+ resolve(result);
742
+ },
743
+ reject: (err) => {
744
+ clearTimeout(timer);
745
+ reject(err);
746
+ },
747
+ });
748
+ });
749
+ }
750
+
587
751
  /**
588
752
  * Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
589
753
  * family). An app-server build that doesn't recognize the method may never
@@ -719,9 +883,17 @@ export class CodexSessionImpl implements AgentSession {
719
883
  // and pass through. If the second `turn/start` lands during the first
720
884
  // turn, the per-turn accumulators continue collecting until the result
721
885
  // event fires; the result then drains all pending resolvers.
886
+ // Joining the active turn requires that it still be active. The latch
887
+ // survives the terminal frame until deliverTurnResult() clears it, and that
888
+ // gap is real time: it spans `await this._eventChain` (a host persisting
889
+ // events) and, when turn/completed carried no usage, an on-disk usage scan.
890
+ // A send landing in that gap belongs to a NEW turn — joining the dead one
891
+ // would leave it with no interrupt latch and no turn/start capture, so Stop
892
+ // would find nothing to interrupt and return as if it had succeeded.
722
893
  const existingTurnReady = this._activeTurnReady;
723
- const isTurnLeader = existingTurnReady === null;
724
- const turnReady = existingTurnReady ?? this.beginActiveTurn();
894
+ const canJoinActiveTurn = existingTurnReady !== null && !this._turnTerminalObserved;
895
+ const isTurnLeader = !canJoinActiveTurn;
896
+ const turnReady = canJoinActiveTurn ? existingTurnReady! : this.beginActiveTurn();
725
897
 
726
898
  if (this._state === "idle") {
727
899
  this._state = "thinking";
@@ -753,7 +925,11 @@ export class CodexSessionImpl implements AgentSession {
753
925
  rejectFn = reject;
754
926
  });
755
927
 
756
- const entry: PendingResult = { resolve: resolveFn, reject: rejectFn };
928
+ const entry: PendingResult = {
929
+ resolve: resolveFn,
930
+ reject: rejectFn,
931
+ generation: turnReady.generation,
932
+ };
757
933
  this._pendingResults.push(entry);
758
934
 
759
935
  // Track the in-flight turn so drain() can await it; drop it on settle.
@@ -953,6 +1129,14 @@ export class CodexSessionImpl implements AgentSession {
953
1129
  async close(): Promise<void> {
954
1130
  if (this._state === "closed") return;
955
1131
  this._state = "closed";
1132
+ this.finalizeActiveBackgroundTasks("stopped", "Codex session closed", {
1133
+ method: "session/closed",
1134
+ });
1135
+ this.cancelBackgroundTaskPollTimers();
1136
+ // finalizeActiveBackgroundTasks above has already emitted the terminal
1137
+ // edges, so nothing downstream needs these records any more.
1138
+ this._backgroundTasks.clear();
1139
+ this._backgroundTaskIdsByPath.clear();
956
1140
  this.rejectAllPending(new Error("Codex session closed"));
957
1141
 
958
1142
  this.proc.stdin!.end();
@@ -1043,6 +1227,8 @@ export class CodexSessionImpl implements AgentSession {
1043
1227
  method === "item/fileChange/requestApproval"
1044
1228
  ) {
1045
1229
  void this.handleApproval(id, method, params);
1230
+ } else if (method === "item/permissions/requestApproval") {
1231
+ void this.handlePermissionsApproval(id, params);
1046
1232
  } else if (
1047
1233
  method === "item/tool/requestUserInput" ||
1048
1234
  method === "tool/requestUserInput"
@@ -1050,7 +1236,13 @@ export class CodexSessionImpl implements AgentSession {
1050
1236
  // `tool/requestUserInput` is the legacy method name on older codex builds.
1051
1237
  void this.handleUserInputRequest(id, params);
1052
1238
  } else {
1053
- // Unknown server request — ack to unblock the turn.
1239
+ // Unknown server request. `{}` is a best-effort reply, NOT a guarantee
1240
+ // that the turn proceeds: every server request the protocol defines but
1241
+ // this file does not handle declares required response fields, so an
1242
+ // empty result is schema-invalid for all of them. A JSON-RPC error reply
1243
+ // would be the honest answer, but `rpcResponse` has no error path and
1244
+ // introducing one changes behavior (an error can abort a turn where this
1245
+ // limps along), so that is a deliberate follow-up rather than a fix here.
1054
1246
  this.rpcResponse(id, {});
1055
1247
  }
1056
1248
  }
@@ -1067,14 +1259,23 @@ export class CodexSessionImpl implements AgentSession {
1067
1259
  }
1068
1260
 
1069
1261
  private async handleApproval(id: number, method: string, params: Record<string, unknown>): Promise<void> {
1070
- this._state = "waiting_for_approval";
1262
+ // A child thread's approval still has to be answered — somebody must decide
1263
+ // whether that subagent may run its command — but it is not the root turn's
1264
+ // business. Driving root state from it would show the session as blocked on
1265
+ // approval when the root turn is running fine, and `restoreStateAfter` would
1266
+ // then recompute root state from a child's lifecycle. The protocol makes
1267
+ // `threadId` mandatory on every approval, so the attribution is free.
1268
+ const requestThreadId = str(params, "threadId") || null;
1269
+ const foreign = this.isForeignThread(requestThreadId);
1270
+ if (!foreign) this._state = "waiting_for_approval";
1271
+ const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_approval"); };
1071
1272
 
1072
1273
  // Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
1073
1274
  // (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
1074
1275
  // concept, so allow → accept and deny → decline.
1075
1276
  if (!this.ctx.onUserInputRequest) {
1076
1277
  this.rpcResponse(id, { decision: "accept" });
1077
- this.restoreStateAfter("waiting_for_approval");
1278
+ restore();
1078
1279
  return;
1079
1280
  }
1080
1281
 
@@ -1086,15 +1287,67 @@ export class CodexSessionImpl implements AgentSession {
1086
1287
  const resp = await this.ctx.onUserInputRequest({
1087
1288
  toolName,
1088
1289
  input: params,
1089
- toolUseId: str(params, "id"),
1090
- description: str(params, "command") || str(params, "path") || undefined,
1290
+ // `itemId`, not `id` — the protocol has no `id` here, so this was
1291
+ // always "". It is the field that lets a host line an approval up with
1292
+ // the tool_call event carrying the same item id.
1293
+ toolUseId: str(params, "itemId"),
1294
+ // FileChangeRequestApprovalParams carries `grantRoot`, never `path`,
1295
+ // so file-change approvals reached hosts with no description at all.
1296
+ description: str(params, "command")
1297
+ || str(params, "grantRoot")
1298
+ || str(params, "reason")
1299
+ || undefined,
1300
+ // Which subagent is asking. The child thread id is also its
1301
+ // `background_task.taskId`, so a host can line the approval up with
1302
+ // the task it already renders. Absent for the root turn.
1303
+ ...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
1091
1304
  });
1092
1305
  this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
1093
1306
  } catch {
1094
1307
  this.rpcResponse(id, { decision: "decline" });
1095
1308
  }
1096
1309
 
1097
- this.restoreStateAfter("waiting_for_approval");
1310
+ restore();
1311
+ }
1312
+
1313
+ /**
1314
+ * Handle `item/permissions/requestApproval`.
1315
+ *
1316
+ * Unlike the accept/decline approvals, the response here IS the grant: the
1317
+ * schema requires a `permissions` profile, so the generic "ack with `{}` to
1318
+ * unblock the turn" fallback this used to land in was schema-invalid.
1319
+ * Granting means echoing back the profile the agent asked for; refusing means
1320
+ * an empty profile, which is valid and grants nothing beyond what it already
1321
+ * has. With no host handler we refuse, because silently widening a sandbox is
1322
+ * not a safe default for an unattended session.
1323
+ */
1324
+ private async handlePermissionsApproval(id: number, params: Record<string, unknown>): Promise<void> {
1325
+ const requestThreadId = str(params, "threadId") || null;
1326
+ const foreign = this.isForeignThread(requestThreadId);
1327
+ if (!foreign) this._state = "waiting_for_approval";
1328
+ const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_approval"); };
1329
+ const requested = asObj(params, "permissions");
1330
+
1331
+ if (!this.ctx.onUserInputRequest) {
1332
+ this.rpcResponse(id, { permissions: {} });
1333
+ restore();
1334
+ return;
1335
+ }
1336
+
1337
+ try {
1338
+ const resp = await this.ctx.onUserInputRequest({
1339
+ toolName: "permissions",
1340
+ input: params,
1341
+ toolUseId: str(params, "itemId"),
1342
+ description: str(params, "reason") || undefined,
1343
+ ...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
1344
+ });
1345
+ this.rpcResponse(id, { permissions: resp.allow ? requested : {} });
1346
+ } catch {
1347
+ this.rpcResponse(id, { permissions: {} });
1348
+ }
1349
+
1350
+ restore();
1098
1351
  }
1099
1352
 
1100
1353
  /**
@@ -1104,9 +1357,17 @@ export class CodexSessionImpl implements AgentSession {
1104
1357
  * back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
1105
1358
  */
1106
1359
  private async handleUserInputRequest(id: number, params: Record<string, unknown>): Promise<void> {
1360
+ // Same scoping as the approval handlers: a child subagent's question still
1361
+ // has to be answered, but it is not the root turn's business. Blocking root
1362
+ // state on it shows the session as waiting for input when the root turn is
1363
+ // running fine. `ToolRequestUserInputParams` requires `threadId`, so the
1364
+ // attribution is read from the wire rather than inferred.
1365
+ const requestThreadId = str(params, "threadId") || null;
1366
+ const foreign = this.isForeignThread(requestThreadId);
1107
1367
  // Questions are user *input*, not a tool-permission gate — distinct state so a
1108
1368
  // host UI can render a question form vs an approval prompt.
1109
- this._state = "waiting_for_input";
1369
+ if (!foreign) this._state = "waiting_for_input";
1370
+ const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_input"); };
1110
1371
 
1111
1372
  const questions = parseCodexQuestions(params);
1112
1373
 
@@ -1114,7 +1375,7 @@ export class CodexSessionImpl implements AgentSession {
1114
1375
  // agent proceeds without hanging.
1115
1376
  if (!this.ctx.onUserInputRequest || questions.length === 0) {
1116
1377
  this.rpcResponse(id, { answers: {} });
1117
- this.restoreStateAfter("waiting_for_input");
1378
+ restore();
1118
1379
  return;
1119
1380
  }
1120
1381
 
@@ -1122,14 +1383,16 @@ export class CodexSessionImpl implements AgentSession {
1122
1383
  const resp = await this.ctx.onUserInputRequest({
1123
1384
  toolName: "AskUserQuestion",
1124
1385
  input: { questions },
1125
- toolUseId: str(params, "id") || "codex-user-input",
1386
+ toolUseId: str(params, "itemId") || "codex-user-input",
1387
+ // Which subagent is asking, matching its `background_task.taskId`.
1388
+ ...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
1126
1389
  });
1127
1390
  this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
1128
1391
  } catch {
1129
1392
  this.rpcResponse(id, { answers: {} });
1130
1393
  }
1131
1394
 
1132
- this.restoreStateAfter("waiting_for_input");
1395
+ restore();
1133
1396
  }
1134
1397
 
1135
1398
  // -------------------------------------------------------------------------
@@ -1175,6 +1438,280 @@ export class CodexSessionImpl implements AgentSession {
1175
1438
  return null;
1176
1439
  }
1177
1440
 
1441
+ private backgroundTaskEvent(
1442
+ taskId: string,
1443
+ phase: "started" | "progress" | "completed",
1444
+ status: "running" | "completed" | "failed" | "stopped",
1445
+ options: {
1446
+ description?: string | null;
1447
+ summary?: string | null;
1448
+ parentTaskId?: string | null;
1449
+ turnId?: string | null;
1450
+ eventId?: string | null;
1451
+ raw: Record<string, unknown>;
1452
+ },
1453
+ ): Extract<StreamEvent, { type: "background_task" }> {
1454
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1455
+ return {
1456
+ type: "background_task",
1457
+ taskId,
1458
+ taskType: "subagent",
1459
+ phase,
1460
+ status,
1461
+ description: options.description ?? null,
1462
+ summary: options.summary ?? null,
1463
+ parentTaskId: options.parentTaskId ?? null,
1464
+ timestamp: new Date().toISOString(),
1465
+ providerType: "codex",
1466
+ sessionId: rootThreadId,
1467
+ messageId: null,
1468
+ eventId: options.eventId ?? null,
1469
+ turnId: options.turnId ?? null,
1470
+ parentToolCallId: null,
1471
+ raw: options.raw,
1472
+ };
1473
+ }
1474
+
1475
+ private reconciledBackgroundTaskRaw(
1476
+ threadId: string,
1477
+ turn: Record<string, unknown> | null,
1478
+ ): Record<string, unknown> {
1479
+ const error = turn ? str(asObj(turn, "error"), "message") || null : null;
1480
+ return {
1481
+ method: "thread/read",
1482
+ reconciled: true,
1483
+ threadId,
1484
+ turnId: turn ? str(turn, "id") || null : null,
1485
+ status: turn ? str(turn, "status") || null : null,
1486
+ error,
1487
+ };
1488
+ }
1489
+
1490
+ /**
1491
+ * Codex 0.144+ represents collaboration as a root collabAgentToolCall item.
1492
+ * A completed spawn call only means the child was created. Register every
1493
+ * receiver and then follow the child thread itself for the real terminus.
1494
+ */
1495
+ /** @returns whether the item produced at least one background-task edge. */
1496
+ private handleCollabAgentToolCall(
1497
+ item: Record<string, unknown>,
1498
+ raw: Record<string, unknown>,
1499
+ parentTaskId: string | null,
1500
+ ): boolean {
1501
+ if (this._state === "closed" || !this.ctx.onEvent) return false;
1502
+ let dispatched = false;
1503
+
1504
+ const tool = str(item, "tool");
1505
+ const states = asObj(item, "agentsStates");
1506
+ const receiverIds = stringArray(item["receiverThreadIds"]);
1507
+ const taskIds = [...new Set([...receiverIds, ...Object.keys(states)])];
1508
+ const canStart = tool === "spawnAgent";
1509
+ const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
1510
+ const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
1511
+
1512
+ for (const taskId of taskIds) {
1513
+ const previous = this._backgroundTasks.get(taskId);
1514
+ const description = str(item, "prompt") || previous?.description || null;
1515
+ const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
1516
+
1517
+ // The child thread's own turn/started notification is the only
1518
+ // authoritative reactivation edge. Root resume/send calls can arrive
1519
+ // before or after that child turn, so they only enrich an active task.
1520
+ if (metadataOnly) {
1521
+ if (!previous || previous.terminal) continue;
1522
+ if (description && description !== previous.description) {
1523
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1524
+ description,
1525
+ summary: previous.summary,
1526
+ parentTaskId: resolvedParentTaskId,
1527
+ eventId: this._threadId
1528
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1529
+ : null,
1530
+ raw,
1531
+ }));
1532
+ dispatched = true;
1533
+ }
1534
+ continue;
1535
+ }
1536
+
1537
+ if (!previous && !canStart) continue;
1538
+ if (!canStart && !canStopWithoutState && !(taskId in states)) continue;
1539
+
1540
+ const hasState = taskId in states;
1541
+ const state = !hasState && canStopWithoutState
1542
+ ? { terminal: true, status: "stopped" as const, summary: null }
1543
+ : codexAgentState(states[taskId]);
1544
+
1545
+ if (!previous) {
1546
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "started", "running", {
1547
+ description,
1548
+ summary: null,
1549
+ parentTaskId: resolvedParentTaskId,
1550
+ eventId: this._threadId
1551
+ ? `codex:${this._threadId}:background-task:${taskId}:started`
1552
+ : null,
1553
+ raw,
1554
+ }));
1555
+ dispatched = true;
1556
+ } else if (previous.terminal) {
1557
+ continue;
1558
+ } else if (description && description !== previous.description) {
1559
+ // thread/started can beat the richer root collab item. Preserve one
1560
+ // start edge, then publish the prompt as ordinary progress metadata.
1561
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1562
+ description,
1563
+ summary: previous.summary,
1564
+ parentTaskId: resolvedParentTaskId,
1565
+ eventId: this._threadId
1566
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1567
+ : null,
1568
+ raw,
1569
+ }));
1570
+ dispatched = true;
1571
+ }
1572
+
1573
+ if (state.terminal) {
1574
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
1575
+ description,
1576
+ summary: state.summary,
1577
+ parentTaskId: resolvedParentTaskId,
1578
+ eventId: this._threadId
1579
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "state"}:completed`
1580
+ : null,
1581
+ raw,
1582
+ }));
1583
+ dispatched = true;
1584
+ }
1585
+ }
1586
+ return dispatched;
1587
+ }
1588
+
1589
+ private waitForBackgroundTaskPoll(pollKey: string): Promise<void> {
1590
+ if (this._state === "closed") return Promise.resolve();
1591
+ return new Promise((resolve) => {
1592
+ const timer = setTimeout(() => {
1593
+ this._backgroundTaskPollTimers.delete(pollKey);
1594
+ resolve();
1595
+ }, BACKGROUND_TASK_POLL_INTERVAL_MS);
1596
+ if (typeof timer.unref === "function") timer.unref();
1597
+ this._backgroundTaskPollTimers.set(pollKey, { timer, resolve });
1598
+ });
1599
+ }
1600
+
1601
+ private startBackgroundTaskPoller(taskId: string): void {
1602
+ if (this._state === "closed") return;
1603
+ const task = this._backgroundTasks.get(taskId);
1604
+ if (!task || task.terminal) return;
1605
+ const existing = this._backgroundTaskPollers.get(taskId);
1606
+ if (existing?.generation === task.generation) return;
1607
+
1608
+ const generation = task.generation;
1609
+ const pollKey = `${taskId}:${generation}`;
1610
+ let poller!: Promise<void>;
1611
+ poller = this.pollBackgroundTask(taskId, generation, pollKey).finally(() => {
1612
+ if (this._backgroundTaskPollers.get(taskId)?.promise === poller) {
1613
+ this._backgroundTaskPollers.delete(taskId);
1614
+ }
1615
+ const pending = this._backgroundTaskPollTimers.get(pollKey);
1616
+ if (pending) clearTimeout(pending.timer);
1617
+ this._backgroundTaskPollTimers.delete(pollKey);
1618
+ });
1619
+ this._backgroundTaskPollers.set(taskId, { generation, promise: poller });
1620
+ }
1621
+
1622
+ private async pollBackgroundTask(
1623
+ taskId: string,
1624
+ generation: number,
1625
+ pollKey: string,
1626
+ ): Promise<void> {
1627
+ while (this._state !== "closed") {
1628
+ const task = this._backgroundTasks.get(taskId);
1629
+ if (!task || task.generation !== generation || task.terminal) return;
1630
+
1631
+ try {
1632
+ const response = await this.boundedRpcRequest("thread/read", {
1633
+ threadId: taskId,
1634
+ includeTurns: true,
1635
+ });
1636
+ if (this.observeBackgroundTaskThread(response, taskId, generation)) return;
1637
+ } catch {
1638
+ // A child can briefly be pendingInit before thread/read can load it.
1639
+ }
1640
+
1641
+ const current = this._backgroundTasks.get(taskId);
1642
+ if (!current || current.generation !== generation || current.terminal) return;
1643
+ await this.waitForBackgroundTaskPoll(pollKey);
1644
+ }
1645
+ }
1646
+
1647
+ /** Return true once thread/read proves that the child's latest turn ended. */
1648
+ private observeBackgroundTaskThread(
1649
+ response: Record<string, unknown>,
1650
+ taskId: string,
1651
+ generation: number,
1652
+ ): boolean {
1653
+ const task = this._backgroundTasks.get(taskId);
1654
+ if (!task || task.generation !== generation || task.terminal) return true;
1655
+
1656
+ const thread = asObj(response, "thread");
1657
+ const turns = Array.isArray(thread["turns"])
1658
+ ? thread["turns"].map(obj)
1659
+ : [];
1660
+
1661
+ const latestTurn = turns[turns.length - 1];
1662
+ if (!latestTurn) {
1663
+ const threadStatus = str(asObj(thread, "status"), "type");
1664
+ if (threadStatus !== "systemError") return false;
1665
+ const failureTurn = { status: "systemError", error: { message: "Subagent failed to initialize" } };
1666
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", "failed", {
1667
+ description: task.description,
1668
+ summary: "Subagent failed to initialize",
1669
+ parentTaskId: task.parentTaskId,
1670
+ eventId: this._threadId
1671
+ ? `codex:${this._threadId}:background-task:${taskId}:system-error:completed`
1672
+ : null,
1673
+ raw: this.reconciledBackgroundTaskRaw(taskId, failureTurn),
1674
+ }));
1675
+ return true;
1676
+ }
1677
+
1678
+ const nativeStatus = str(latestTurn, "status");
1679
+ if (nativeStatus === "inProgress" || !nativeStatus) return false;
1680
+
1681
+ const turnId = str(latestTurn, "id") || null;
1682
+ // The child turn/started notification is authoritative for a reactivated
1683
+ // generation. thread/read can briefly lag and still end at an older turn.
1684
+ if (task.activeTurnId && turnId !== task.activeTurnId) return false;
1685
+
1686
+ const items = Array.isArray(latestTurn["items"])
1687
+ ? latestTurn["items"].map(obj)
1688
+ : [];
1689
+ let summary: string | null = null;
1690
+ for (let index = items.length - 1; index >= 0; index--) {
1691
+ const item = items[index]!;
1692
+ if (str(item, "type") !== "agentMessage") continue;
1693
+ summary = this.agentMessageText(item);
1694
+ if (summary) break;
1695
+ }
1696
+ const errorMessage = str(asObj(latestTurn, "error"), "message") || null;
1697
+ const status = nativeStatus === "failed"
1698
+ ? "failed"
1699
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1700
+ ? "stopped"
1701
+ : "completed";
1702
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", status, {
1703
+ description: task.description,
1704
+ summary: summary ?? errorMessage,
1705
+ parentTaskId: task.parentTaskId,
1706
+ turnId,
1707
+ eventId: this._threadId && turnId
1708
+ ? `codex:${this._threadId}:background-task:${taskId}:${turnId}:completed`
1709
+ : null,
1710
+ raw: this.reconciledBackgroundTaskRaw(taskId, latestTurn),
1711
+ }));
1712
+ return true;
1713
+ }
1714
+
1178
1715
  /**
1179
1716
  * Maintain just enough child metadata to turn a later foreign-thread
1180
1717
  * terminal notification into one provider-neutral task event. This reducer
@@ -1188,6 +1725,9 @@ export class CodexSessionImpl implements AgentSession {
1188
1725
  // a task that already reached a terminal state. A later child turn/started
1189
1726
  // explicitly reactivates the record below in handleForeignNotification.
1190
1727
  if (previous?.terminal) return false;
1728
+ // collabAgentToolCall and child thread/started can both announce the same
1729
+ // spawn. The first edge is sufficient and gives hosts one durable start.
1730
+ if (previous && event.phase === "started") return false;
1191
1731
 
1192
1732
  const description = event.description ?? previous?.description ?? null;
1193
1733
  const summary = event.summary ?? previous?.summary ?? null;
@@ -1199,14 +1739,38 @@ export class CodexSessionImpl implements AgentSession {
1199
1739
  event.summary = summary;
1200
1740
  event.parentTaskId = parentTaskId;
1201
1741
 
1742
+ const terminal = event.phase === "completed";
1202
1743
  this._backgroundTasks.set(event.taskId, {
1203
1744
  taskId: event.taskId,
1745
+ // `description` survives termination: the reactivation path in
1746
+ // handleForeignNotification reads it precisely BECAUSE the record is
1747
+ // terminal, and dropping it lost a reactivated child's label forever.
1748
+ // It is one bounded string per child. `summary` is the unbounded one —
1749
+ // full agent messages, appended per item — and has no reader that
1750
+ // outlives the task, so it is released.
1204
1751
  description,
1205
- summary,
1752
+ summary: terminal ? null : summary,
1206
1753
  parentTaskId,
1207
- terminal: event.phase === "completed",
1754
+ terminal,
1755
+ generation: previous?.generation ?? 0,
1756
+ activeTurnId: terminal ? null : event.turnId ?? previous?.activeTurnId ?? null,
1208
1757
  });
1758
+ // Kept past termination too: a grandchild can spawn after its parent has
1759
+ // finished, and lineage resolves through this index.
1209
1760
  if (description) this._backgroundTaskIdsByPath.set(description, event.taskId);
1761
+ // Every registration route converges here, so this is the one place that
1762
+ // can guarantee a reconciliation poller exists. Registering by way of the
1763
+ // parser's subAgentActivity branch previously produced a task with no
1764
+ // safety net at all, wholly dependent on the child notification arriving.
1765
+ //
1766
+ // Correctness rests on `startBackgroundTaskPoller` and the poll loop both
1767
+ // re-reading the record and bailing when it is terminal — NOT on the
1768
+ // microtask. The deferral is an optimization: one wire item can emit
1769
+ // several edges in a tick (a collab spawn whose child is already finished
1770
+ // dispatches `started` then `completed`), and yielding first means the
1771
+ // common case declines before spawning a `thread/read` rather than after.
1772
+ // Do not remove the terminal checks on the grounds that this defers.
1773
+ if (!terminal) queueMicrotask(() => this.startBackgroundTaskPoller(event.taskId));
1210
1774
  return true;
1211
1775
  }
1212
1776
 
@@ -1229,7 +1793,23 @@ export class CodexSessionImpl implements AgentSession {
1229
1793
  // App-server can publish child thread/started before the corresponding
1230
1794
  // root subAgentActivity item. Register only descendants of this session,
1231
1795
  // not unrelated foreign threads multiplexed by a future server version.
1232
- if (parentThreadId !== rootThreadId && !parentTask) return;
1796
+ if (parentThreadId !== rootThreadId && !parentTask) {
1797
+ // Say so. A wire shape that omits parentThreadId takes this branch for
1798
+ // every child, which silently disables the whole registration and
1799
+ // reconciliation path — the safety net that exists precisely because
1800
+ // child notifications cannot be relied on. Degrading quietly here
1801
+ // hides the one failure this machinery was built to survive.
1802
+ if (!parentThreadId && this.ctx.onOutput) {
1803
+ try {
1804
+ void this.ctx.onOutput(
1805
+ "stderr",
1806
+ `agentex: codex child thread ${childThreadId} announced no parent thread id; `
1807
+ + "background-task tracking and reconciliation are disabled for it\n",
1808
+ );
1809
+ } catch { /* swallow */ }
1810
+ }
1811
+ return;
1812
+ }
1233
1813
 
1234
1814
  const source = asObj(thread, "source");
1235
1815
  const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
@@ -1275,6 +1855,7 @@ export class CodexSessionImpl implements AgentSession {
1275
1855
  if (!task.terminal) return;
1276
1856
  task.terminal = false;
1277
1857
  task.summary = null;
1858
+ task.generation += 1;
1278
1859
  const turn = asObj(params, "turn");
1279
1860
  const turnId = str(turn, "id") || str(params, "turnId") || null;
1280
1861
  const rootThreadId = this._threadId ?? this._expectedThreadId;
@@ -1304,6 +1885,10 @@ export class CodexSessionImpl implements AgentSession {
1304
1885
  if (method === "item/completed") {
1305
1886
  const item = asObj(params, "item");
1306
1887
  const itemType = str(item, "type");
1888
+ if (itemType === "collabAgentToolCall") {
1889
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, childThreadId);
1890
+ return;
1891
+ }
1307
1892
  if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1308
1893
  const summary = this.agentMessageText(item);
1309
1894
  if (summary) task.summary = summary;
@@ -1344,11 +1929,19 @@ export class CodexSessionImpl implements AgentSession {
1344
1929
  : null,
1345
1930
  turnId,
1346
1931
  parentToolCallId: null,
1347
- raw: parseJson(rawLine) ?? params,
1932
+ raw: {
1933
+ method,
1934
+ reconciled: false,
1935
+ threadId: childThreadId,
1936
+ turnId,
1937
+ status: nativeStatus || null,
1938
+ error: errorMessage || null,
1939
+ },
1348
1940
  });
1349
1941
  }
1350
1942
 
1351
1943
  private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
1944
+ if (this._state === "closed") return;
1352
1945
  // codex/event — legacy wrapper
1353
1946
  if (method === "codex/event") {
1354
1947
  const innerMsg = str(params, "msg");
@@ -1394,6 +1987,17 @@ export class CodexSessionImpl implements AgentSession {
1394
1987
  if (method === "item/completed") {
1395
1988
  this._state = "thinking";
1396
1989
  this.extractSummaryFromItem(params);
1990
+ const item = asObj(params, "item");
1991
+ if (str(item, "type") === "collabAgentToolCall") {
1992
+ // Shapes we model become background_task edges. Anything that produced
1993
+ // no edge still reaches the host as a raw `unknown` event rather than
1994
+ // vanishing — that escape hatch is how unmodeled wire shapes stay
1995
+ // visible, and collab is exactly where new shapes keep appearing.
1996
+ if (!this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null)) {
1997
+ this.emitStreamEvent(rawLine);
1998
+ }
1999
+ return;
2000
+ }
1397
2001
  this.emitStreamEvent(rawLine);
1398
2002
  return;
1399
2003
  }
@@ -1434,6 +2038,7 @@ export class CodexSessionImpl implements AgentSession {
1434
2038
  // -------------------------------------------------------------------------
1435
2039
 
1436
2040
  private handleLegacyEvent(event: Record<string, unknown>, rawLine: string): void {
2041
+ if (this._state === "closed") return;
1437
2042
  const type = str(event, "type");
1438
2043
  const eventThreadId =
1439
2044
  str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
@@ -1577,11 +2182,37 @@ export class CodexSessionImpl implements AgentSession {
1577
2182
  }
1578
2183
 
1579
2184
  private resolveTurn(): void {
2185
+ // Bind delivery to the turn that just terminated. Everything below this
2186
+ // point can yield (usage scan, event chain), and a send() arriving in the
2187
+ // meantime starts a new turn that must not be settled by this result.
2188
+ const delivering = this._activeTurnReady;
1580
2189
  const resolvedModel = this._turnModel ?? this.model;
1581
2190
  let usage = this._turnUsage && resolvedModel
1582
2191
  ? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
1583
2192
  : undefined;
1584
2193
 
2194
+ // Freeze this turn's outcome and clear the accumulators NOW, while we are
2195
+ // still synchronous with the terminal frame. Delivery reads the snapshot,
2196
+ // so a turn that starts during the async gap below neither inherits these
2197
+ // values nor has its own overwritten. Resetting inside beginActiveTurn()
2198
+ // instead would not work: the usage-scan path re-reads state after an
2199
+ // await, so the new turn's reset would eat the old turn's summary.
2200
+ const outcome: TurnOutcome = {
2201
+ summary: this._turnSummary,
2202
+ isError: this._turnIsError,
2203
+ errorMessage: this._turnErrorMessage,
2204
+ wasInterrupted: this._turnWasInterrupted,
2205
+ };
2206
+ this._turnSummary = null;
2207
+ this._turnUsage = null;
2208
+ this._turnModel = null;
2209
+ this._turnIsError = false;
2210
+ this._turnWasInterrupted = false;
2211
+ this._turnErrorMessage = null;
2212
+ // Captured before clearing: the usage-scan fallback below still needs it.
2213
+ const turnStartedAt = this._turnStartedAt;
2214
+ this._turnStartedAt = null;
2215
+
1585
2216
  // Usage precedence: the `turn.completed` payload is authoritative when
1586
2217
  // present (captured above into _turnUsage). Only when the stream carried no
1587
2218
  // usage do we fall back to scanning Codex's on-disk session logs.
@@ -1591,8 +2222,8 @@ export class CodexSessionImpl implements AgentSession {
1591
2222
  // latest turn or read partially-written totals. We therefore scan ONLY when
1592
2223
  // there is no in-band usage, and never let a scan failure fail the turn
1593
2224
  // (usage simply stays undefined). Prefer the in-band payload always.
1594
- if (!usage && this._turnStartedAt) {
1595
- const startedAt = this._turnStartedAt;
2225
+ if (!usage && turnStartedAt) {
2226
+ const startedAt = turnStartedAt;
1596
2227
  const threadId = this._threadId ?? undefined;
1597
2228
  // Fire-and-forget: scan logs then deliver result
1598
2229
  void scanCodexSessionUsage({ startedAfter: startedAt, threadId }).then((scanned) => {
@@ -1600,22 +2231,26 @@ export class CodexSessionImpl implements AgentSession {
1600
2231
  }).catch(() => {
1601
2232
  // Non-fatal — usage stays undefined
1602
2233
  }).finally(() => {
1603
- void this.deliverTurnResult(usage);
2234
+ void this.deliverTurnResult(usage, delivering, outcome);
1604
2235
  });
1605
2236
  return;
1606
2237
  }
1607
2238
 
1608
- void this.deliverTurnResult(usage);
2239
+ void this.deliverTurnResult(usage, delivering, outcome);
1609
2240
  }
1610
2241
 
1611
- private async deliverTurnResult(usage: Record<string, import("../../types.js").TokenUsage> | undefined): Promise<void> {
2242
+ private async deliverTurnResult(
2243
+ usage: Record<string, import("../../types.js").TokenUsage> | undefined,
2244
+ delivering: ActiveTurnReady | null,
2245
+ outcome: TurnOutcome,
2246
+ ): Promise<void> {
1612
2247
  const result: TurnResult = {
1613
- summary: this._turnSummary,
2248
+ summary: outcome.summary,
1614
2249
  usage,
1615
2250
  costUsd: null,
1616
- status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
1617
- errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
1618
- errorMessage: this._turnWasInterrupted ? "Turn was interrupted" : this._turnErrorMessage,
2251
+ status: outcome.wasInterrupted ? "aborted" : outcome.isError ? "failed" : "completed",
2252
+ errorCode: outcome.wasInterrupted ? "aborted" : outcome.isError ? "execution_error" : null,
2253
+ errorMessage: outcome.wasInterrupted ? "Turn was interrupted" : outcome.errorMessage,
1619
2254
  };
1620
2255
 
1621
2256
  // Drain pending onEvent handlers so callers awaiting send() see a settled
@@ -1631,22 +2266,29 @@ export class CodexSessionImpl implements AgentSession {
1631
2266
  // session whose stdin is dead.
1632
2267
  if (this._state === "closed") return;
1633
2268
 
1634
- this._state = "idle";
1635
-
1636
- // Drain ALL pending send() resolvers with this turn's result. Multiple
1637
- // concurrent sends coalesced into one turn share the same TurnResult.
1638
- const pending = this._pendingResults.splice(0);
1639
-
1640
- // Clear per-turn accumulators so a subsequent turn doesn't inherit
1641
- // stale summary / usage / model.
1642
- this._turnSummary = null;
1643
- this._turnUsage = null;
1644
- this._turnModel = null;
1645
- this._turnIsError = false;
1646
- this._turnWasInterrupted = false;
1647
- this._turnErrorMessage = null;
1648
- this._turnStartedAt = null;
1649
- this.clearActiveTurn();
2269
+ // A send() that landed while this delivery was suspended has already begun
2270
+ // a new turn and owns the session. Its result is still owed to it, so this
2271
+ // delivery settles only its OWN generation and touches neither the state
2272
+ // nor the interrupt latch the new turn needs to stay stoppable.
2273
+ //
2274
+ // `delivering === null` must take this branch too. A duplicate terminal
2275
+ // frame for an already-delivered turn (codex can report failure through
2276
+ // both `turn/completed` with a failed status and a separate `turn/failed`)
2277
+ // reaches here with a null latch, and exempting it restored every symptom
2278
+ // the fence exists to prevent: silent Stop, idle-while-running, and a
2279
+ // stale settle.
2280
+ const superseded = this._activeTurnReady !== delivering;
2281
+
2282
+ // Drain the pending send() resolvers belonging to this turn. Several
2283
+ // concurrent sends coalesced into one turn share the same TurnResult; a
2284
+ // send from a later turn is left in place for its own delivery.
2285
+ const pending = this.takePendingResults(delivering?.generation);
2286
+
2287
+ if (!superseded) {
2288
+ this._state = "idle";
2289
+ // Accumulators were already snapshotted and cleared in resolveTurn().
2290
+ this.clearActiveTurn(undefined, delivering ?? undefined);
2291
+ }
1650
2292
 
1651
2293
  for (const p of pending) {
1652
2294
  // Skip sends already settled early by timeout / abort.
@@ -1667,8 +2309,9 @@ export class CodexSessionImpl implements AgentSession {
1667
2309
  // Pass current threadId so NDJSON-shaped events (via codex/event wrapper)
1668
2310
  // carry sessionId. v2 notifications parse threadId from params directly
1669
2311
  // and ignore this arg.
1670
- const event = parseCodexStreamLine(rawLine, this._threadId);
1671
- if (event) this.dispatchEvent(event);
2312
+ for (const event of parseCodexStreamLines(rawLine, this._threadId)) {
2313
+ this.dispatchEvent(event);
2314
+ }
1672
2315
  }
1673
2316
 
1674
2317
  /**