@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
@@ -9,7 +9,7 @@ import { translateEndpoint } from "../../utils/endpoint.js";
9
9
  import { injectWorkspaceSkills } from "../../utils/skills.js";
10
10
  import { resolveInstructions } from "../../utils/instructions.js";
11
11
  import { createToolNameTracker } from "../../utils/tool-names.js";
12
- import { parseCodexStreamLine } from "./parse.js";
12
+ import { parseCodexStreamLines } from "./parse.js";
13
13
  import { withPlanModePreamble } from "./plan-mode.js";
14
14
  import { scanCodexSessionUsage } from "./usage-scanner.js";
15
15
  import { codexSessionCodec } from "./codec.js";
@@ -89,6 +89,8 @@ function buildCodexUserInputAnswers(questions, resp) {
89
89
  }
90
90
  return out;
91
91
  }
92
+ const BACKGROUND_TASK_POLL_INTERVAL_MS = 2_000;
93
+ const BACKGROUND_TASK_READ_TIMEOUT_MS = 5_000;
92
94
  // ---------------------------------------------------------------------------
93
95
  // JSON-RPC 2.0 helpers
94
96
  // ---------------------------------------------------------------------------
@@ -116,6 +118,30 @@ function asObj(parent, key) {
116
118
  ? v
117
119
  : {};
118
120
  }
121
+ function obj(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value)
123
+ ? value
124
+ : {};
125
+ }
126
+ function stringArray(value) {
127
+ return Array.isArray(value)
128
+ ? value.filter((entry) => typeof entry === "string" && entry.length > 0)
129
+ : [];
130
+ }
131
+ function codexAgentState(value) {
132
+ const state = obj(value);
133
+ const nativeStatus = str(state, "status");
134
+ const summary = str(state, "message") || null;
135
+ if (nativeStatus === "completed")
136
+ return { terminal: true, status: "completed", summary };
137
+ if (nativeStatus === "errored" || nativeStatus === "notFound") {
138
+ return { terminal: true, status: "failed", summary };
139
+ }
140
+ if (nativeStatus === "interrupted" || nativeStatus === "shutdown") {
141
+ return { terminal: true, status: "stopped", summary };
142
+ }
143
+ return { terminal: false, status: "running", summary };
144
+ }
119
145
  function classifyMessage(msg) {
120
146
  // Detect JSON-RPC by *structure*, not by the `jsonrpc:"2.0"` discriminator.
121
147
  // codex-cli 0.130.0's `app-server` emits responses without the `jsonrpc`
@@ -279,6 +305,8 @@ export class CodexSessionImpl {
279
305
  */
280
306
  _activeTurnId = null;
281
307
  _activeTurnReady = null;
308
+ /** Incremented per turn latch. Never reused, so staleness is decidable. */
309
+ _turnGeneration = 0;
282
310
  /** Successful repeated interrupts coalesce until the terminal notification. */
283
311
  _interruptPromise = null;
284
312
  /** Prevents a late interrupt request after the terminal frame was observed. */
@@ -297,6 +325,10 @@ export class CodexSessionImpl {
297
325
  /** Child-agent lifecycle is informational and never participates in root turn settlement. */
298
326
  _backgroundTasks = new Map();
299
327
  _backgroundTaskIdsByPath = new Map();
328
+ /** One protocol-native thread/read poller per child when Codex does not multiplex its notifications. */
329
+ _backgroundTaskPollers = new Map();
330
+ /** Cancellable retry delay for each poller. Cleared when the session closes. */
331
+ _backgroundTaskPollTimers = new Map();
300
332
  /**
301
333
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
302
334
  * handler invocation; the chain enforces in-order delivery and lets
@@ -364,17 +396,66 @@ export class CodexSessionImpl {
364
396
  proc.on("exit", (code, signal) => {
365
397
  if (this._state !== "closed") {
366
398
  this._state = "closed";
367
- const err = new Error(`Codex process exited unexpectedly (code=${code}, signal=${signal})`);
399
+ const message = `Codex process exited unexpectedly (code=${code}, signal=${signal})`;
400
+ this.finalizeActiveBackgroundTasks("failed", message, {
401
+ method: "process/exit",
402
+ code,
403
+ signal,
404
+ });
405
+ this.cancelBackgroundTaskPollTimers();
406
+ const err = new Error(message);
368
407
  this.rejectAllPending(err);
369
408
  }
370
409
  });
371
410
  proc.on("error", (err) => {
372
411
  if (this._state !== "closed") {
373
412
  this._state = "closed";
413
+ this.finalizeActiveBackgroundTasks("failed", err.message, {
414
+ method: "process/error",
415
+ message: err.message,
416
+ });
417
+ this.cancelBackgroundTaskPollTimers();
374
418
  this.rejectAllPending(err);
375
419
  }
376
420
  });
377
421
  }
422
+ cancelBackgroundTaskPollTimers() {
423
+ for (const pending of this._backgroundTaskPollTimers.values()) {
424
+ clearTimeout(pending.timer);
425
+ pending.resolve();
426
+ }
427
+ this._backgroundTaskPollTimers.clear();
428
+ }
429
+ finalizeActiveBackgroundTasks(status, summary, raw) {
430
+ for (const task of this._backgroundTasks.values()) {
431
+ if (task.terminal)
432
+ continue;
433
+ this.dispatchEvent(this.backgroundTaskEvent(task.taskId, "completed", status, {
434
+ description: task.description,
435
+ summary,
436
+ parentTaskId: task.parentTaskId,
437
+ eventId: this._threadId
438
+ ? `codex:${this._threadId}:background-task:${task.taskId}:session-${status}:completed`
439
+ : null,
440
+ raw,
441
+ }));
442
+ }
443
+ }
444
+ /**
445
+ * Remove and return the pending sends belonging to one turn generation.
446
+ *
447
+ * `undefined` matches entries with no generation, which is how tests that
448
+ * drive the session without `send()` enqueue resolvers.
449
+ */
450
+ takePendingResults(generation) {
451
+ const mine = [];
452
+ const others = [];
453
+ for (const entry of this._pendingResults) {
454
+ (entry.generation === generation ? mine : others).push(entry);
455
+ }
456
+ this._pendingResults = others;
457
+ return mine;
458
+ }
378
459
  /** Reject every pending send() Promise and outgoing JSON-RPC call. */
379
460
  rejectAllPending(err) {
380
461
  const pending = this._pendingResults.splice(0);
@@ -408,6 +489,7 @@ export class CodexSessionImpl {
408
489
  resolve: resolveFn,
409
490
  reject: rejectFn,
410
491
  settled: false,
492
+ generation: ++this._turnGeneration,
411
493
  };
412
494
  this._activeTurnId = null;
413
495
  this._activeTurnReady = ready;
@@ -433,9 +515,17 @@ export class CodexSessionImpl {
433
515
  expected.settled = true;
434
516
  expected.reject(err);
435
517
  }
436
- /** Clear the current turn and release an interrupt waiting for its id. */
437
- clearActiveTurn(err) {
518
+ /**
519
+ * Clear the current turn and release an interrupt waiting for its id.
520
+ *
521
+ * `expected` fences the clear against a turn that started after the one being
522
+ * torn down. Without it, a late teardown nulls the latch a newer, running
523
+ * turn is relying on, which is the same silent-Stop failure by another route.
524
+ */
525
+ clearActiveTurn(err, expected) {
438
526
  const ready = this._activeTurnReady;
527
+ if (expected && ready !== expected)
528
+ return;
439
529
  if (ready && !ready.settled) {
440
530
  ready.settled = true;
441
531
  if (err)
@@ -488,6 +578,30 @@ export class CodexSessionImpl {
488
578
  this._pendingRpc.set(id, { resolve, reject });
489
579
  });
490
580
  }
581
+ /** A bounded RPC whose pending-map entry is removed if Codex never replies. */
582
+ boundedRpcRequest(method, params, timeoutMs = BACKGROUND_TASK_READ_TIMEOUT_MS) {
583
+ const id = this._nextId++;
584
+ this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
585
+ return new Promise((resolve, reject) => {
586
+ const timer = setTimeout(() => {
587
+ if (!this._pendingRpc.delete(id))
588
+ return;
589
+ reject(new Error(`codex ${method} timed out`));
590
+ }, timeoutMs);
591
+ if (typeof timer.unref === "function")
592
+ timer.unref();
593
+ this._pendingRpc.set(id, {
594
+ resolve: (result) => {
595
+ clearTimeout(timer);
596
+ resolve(result);
597
+ },
598
+ reject: (err) => {
599
+ clearTimeout(timer);
600
+ reject(err);
601
+ },
602
+ });
603
+ });
604
+ }
491
605
  /**
492
606
  * Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
493
607
  * family). An app-server build that doesn't recognize the method may never
@@ -616,9 +730,17 @@ export class CodexSessionImpl {
616
730
  // and pass through. If the second `turn/start` lands during the first
617
731
  // turn, the per-turn accumulators continue collecting until the result
618
732
  // event fires; the result then drains all pending resolvers.
733
+ // Joining the active turn requires that it still be active. The latch
734
+ // survives the terminal frame until deliverTurnResult() clears it, and that
735
+ // gap is real time: it spans `await this._eventChain` (a host persisting
736
+ // events) and, when turn/completed carried no usage, an on-disk usage scan.
737
+ // A send landing in that gap belongs to a NEW turn — joining the dead one
738
+ // would leave it with no interrupt latch and no turn/start capture, so Stop
739
+ // would find nothing to interrupt and return as if it had succeeded.
619
740
  const existingTurnReady = this._activeTurnReady;
620
- const isTurnLeader = existingTurnReady === null;
621
- const turnReady = existingTurnReady ?? this.beginActiveTurn();
741
+ const canJoinActiveTurn = existingTurnReady !== null && !this._turnTerminalObserved;
742
+ const isTurnLeader = !canJoinActiveTurn;
743
+ const turnReady = canJoinActiveTurn ? existingTurnReady : this.beginActiveTurn();
622
744
  if (this._state === "idle") {
623
745
  this._state = "thinking";
624
746
  this._turnStartedAt = new Date();
@@ -648,7 +770,11 @@ export class CodexSessionImpl {
648
770
  resolveFn = resolve;
649
771
  rejectFn = reject;
650
772
  });
651
- const entry = { resolve: resolveFn, reject: rejectFn };
773
+ const entry = {
774
+ resolve: resolveFn,
775
+ reject: rejectFn,
776
+ generation: turnReady.generation,
777
+ };
652
778
  this._pendingResults.push(entry);
653
779
  // Track the in-flight turn so drain() can await it; drop it on settle.
654
780
  this._inFlight.add(result);
@@ -843,6 +969,14 @@ export class CodexSessionImpl {
843
969
  if (this._state === "closed")
844
970
  return;
845
971
  this._state = "closed";
972
+ this.finalizeActiveBackgroundTasks("stopped", "Codex session closed", {
973
+ method: "session/closed",
974
+ });
975
+ this.cancelBackgroundTaskPollTimers();
976
+ // finalizeActiveBackgroundTasks above has already emitted the terminal
977
+ // edges, so nothing downstream needs these records any more.
978
+ this._backgroundTasks.clear();
979
+ this._backgroundTaskIdsByPath.clear();
846
980
  this.rejectAllPending(new Error("Codex session closed"));
847
981
  this.proc.stdin.end();
848
982
  // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
@@ -925,13 +1059,22 @@ export class CodexSessionImpl {
925
1059
  method === "item/fileChange/requestApproval") {
926
1060
  void this.handleApproval(id, method, params);
927
1061
  }
1062
+ else if (method === "item/permissions/requestApproval") {
1063
+ void this.handlePermissionsApproval(id, params);
1064
+ }
928
1065
  else if (method === "item/tool/requestUserInput" ||
929
1066
  method === "tool/requestUserInput") {
930
1067
  // `tool/requestUserInput` is the legacy method name on older codex builds.
931
1068
  void this.handleUserInputRequest(id, params);
932
1069
  }
933
1070
  else {
934
- // Unknown server request — ack to unblock the turn.
1071
+ // Unknown server request. `{}` is a best-effort reply, NOT a guarantee
1072
+ // that the turn proceeds: every server request the protocol defines but
1073
+ // this file does not handle declares required response fields, so an
1074
+ // empty result is schema-invalid for all of them. A JSON-RPC error reply
1075
+ // would be the honest answer, but `rpcResponse` has no error path and
1076
+ // introducing one changes behavior (an error can abort a turn where this
1077
+ // limps along), so that is a deliberate follow-up rather than a fix here.
935
1078
  this.rpcResponse(id, {});
936
1079
  }
937
1080
  }
@@ -947,13 +1090,24 @@ export class CodexSessionImpl {
947
1090
  this._state = this._pendingResults.length > 0 ? "thinking" : "idle";
948
1091
  }
949
1092
  async handleApproval(id, method, params) {
950
- this._state = "waiting_for_approval";
1093
+ // A child thread's approval still has to be answered — somebody must decide
1094
+ // whether that subagent may run its command — but it is not the root turn's
1095
+ // business. Driving root state from it would show the session as blocked on
1096
+ // approval when the root turn is running fine, and `restoreStateAfter` would
1097
+ // then recompute root state from a child's lifecycle. The protocol makes
1098
+ // `threadId` mandatory on every approval, so the attribution is free.
1099
+ const requestThreadId = str(params, "threadId") || null;
1100
+ const foreign = this.isForeignThread(requestThreadId);
1101
+ if (!foreign)
1102
+ this._state = "waiting_for_approval";
1103
+ const restore = () => { if (!foreign)
1104
+ this.restoreStateAfter("waiting_for_approval"); };
951
1105
  // Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
952
1106
  // (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
953
1107
  // concept, so allow → accept and deny → decline.
954
1108
  if (!this.ctx.onUserInputRequest) {
955
1109
  this.rpcResponse(id, { decision: "accept" });
956
- this.restoreStateAfter("waiting_for_approval");
1110
+ restore();
957
1111
  return;
958
1112
  }
959
1113
  const toolName = method === "item/commandExecution/requestApproval"
@@ -963,15 +1117,66 @@ export class CodexSessionImpl {
963
1117
  const resp = await this.ctx.onUserInputRequest({
964
1118
  toolName,
965
1119
  input: params,
966
- toolUseId: str(params, "id"),
967
- description: str(params, "command") || str(params, "path") || undefined,
1120
+ // `itemId`, not `id` — the protocol has no `id` here, so this was
1121
+ // always "". It is the field that lets a host line an approval up with
1122
+ // the tool_call event carrying the same item id.
1123
+ toolUseId: str(params, "itemId"),
1124
+ // FileChangeRequestApprovalParams carries `grantRoot`, never `path`,
1125
+ // so file-change approvals reached hosts with no description at all.
1126
+ description: str(params, "command")
1127
+ || str(params, "grantRoot")
1128
+ || str(params, "reason")
1129
+ || undefined,
1130
+ // Which subagent is asking. The child thread id is also its
1131
+ // `background_task.taskId`, so a host can line the approval up with
1132
+ // the task it already renders. Absent for the root turn.
1133
+ ...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
968
1134
  });
969
1135
  this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
970
1136
  }
971
1137
  catch {
972
1138
  this.rpcResponse(id, { decision: "decline" });
973
1139
  }
974
- this.restoreStateAfter("waiting_for_approval");
1140
+ restore();
1141
+ }
1142
+ /**
1143
+ * Handle `item/permissions/requestApproval`.
1144
+ *
1145
+ * Unlike the accept/decline approvals, the response here IS the grant: the
1146
+ * schema requires a `permissions` profile, so the generic "ack with `{}` to
1147
+ * unblock the turn" fallback this used to land in was schema-invalid.
1148
+ * Granting means echoing back the profile the agent asked for; refusing means
1149
+ * an empty profile, which is valid and grants nothing beyond what it already
1150
+ * has. With no host handler we refuse, because silently widening a sandbox is
1151
+ * not a safe default for an unattended session.
1152
+ */
1153
+ async handlePermissionsApproval(id, params) {
1154
+ const requestThreadId = str(params, "threadId") || null;
1155
+ const foreign = this.isForeignThread(requestThreadId);
1156
+ if (!foreign)
1157
+ this._state = "waiting_for_approval";
1158
+ const restore = () => { if (!foreign)
1159
+ this.restoreStateAfter("waiting_for_approval"); };
1160
+ const requested = asObj(params, "permissions");
1161
+ if (!this.ctx.onUserInputRequest) {
1162
+ this.rpcResponse(id, { permissions: {} });
1163
+ restore();
1164
+ return;
1165
+ }
1166
+ try {
1167
+ const resp = await this.ctx.onUserInputRequest({
1168
+ toolName: "permissions",
1169
+ input: params,
1170
+ toolUseId: str(params, "itemId"),
1171
+ description: str(params, "reason") || undefined,
1172
+ ...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
1173
+ });
1174
+ this.rpcResponse(id, { permissions: resp.allow ? requested : {} });
1175
+ }
1176
+ catch {
1177
+ this.rpcResponse(id, { permissions: {} });
1178
+ }
1179
+ restore();
975
1180
  }
976
1181
  /**
977
1182
  * Handle a Codex `requestUserInput` server→client request: the agent is asking
@@ -980,29 +1185,41 @@ export class CodexSessionImpl {
980
1185
  * back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
981
1186
  */
982
1187
  async handleUserInputRequest(id, params) {
1188
+ // Same scoping as the approval handlers: a child subagent's question still
1189
+ // has to be answered, but it is not the root turn's business. Blocking root
1190
+ // state on it shows the session as waiting for input when the root turn is
1191
+ // running fine. `ToolRequestUserInputParams` requires `threadId`, so the
1192
+ // attribution is read from the wire rather than inferred.
1193
+ const requestThreadId = str(params, "threadId") || null;
1194
+ const foreign = this.isForeignThread(requestThreadId);
983
1195
  // Questions are user *input*, not a tool-permission gate — distinct state so a
984
1196
  // host UI can render a question form vs an approval prompt.
985
- this._state = "waiting_for_input";
1197
+ if (!foreign)
1198
+ this._state = "waiting_for_input";
1199
+ const restore = () => { if (!foreign)
1200
+ this.restoreStateAfter("waiting_for_input"); };
986
1201
  const questions = parseCodexQuestions(params);
987
1202
  // No host handler, or nothing answerable → return empty answers so the
988
1203
  // agent proceeds without hanging.
989
1204
  if (!this.ctx.onUserInputRequest || questions.length === 0) {
990
1205
  this.rpcResponse(id, { answers: {} });
991
- this.restoreStateAfter("waiting_for_input");
1206
+ restore();
992
1207
  return;
993
1208
  }
994
1209
  try {
995
1210
  const resp = await this.ctx.onUserInputRequest({
996
1211
  toolName: "AskUserQuestion",
997
1212
  input: { questions },
998
- toolUseId: str(params, "id") || "codex-user-input",
1213
+ toolUseId: str(params, "itemId") || "codex-user-input",
1214
+ // Which subagent is asking, matching its `background_task.taskId`.
1215
+ ...(foreign && requestThreadId ? { agentId: requestThreadId } : {}),
999
1216
  });
1000
1217
  this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
1001
1218
  }
1002
1219
  catch {
1003
1220
  this.rpcResponse(id, { answers: {} });
1004
1221
  }
1005
- this.restoreStateAfter("waiting_for_input");
1222
+ restore();
1006
1223
  }
1007
1224
  // -------------------------------------------------------------------------
1008
1225
  // Notification handling (v2 format)
@@ -1046,6 +1263,253 @@ export class CodexSessionImpl {
1046
1263
  }
1047
1264
  return null;
1048
1265
  }
1266
+ backgroundTaskEvent(taskId, phase, status, options) {
1267
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1268
+ return {
1269
+ type: "background_task",
1270
+ taskId,
1271
+ taskType: "subagent",
1272
+ phase,
1273
+ status,
1274
+ description: options.description ?? null,
1275
+ summary: options.summary ?? null,
1276
+ parentTaskId: options.parentTaskId ?? null,
1277
+ timestamp: new Date().toISOString(),
1278
+ providerType: "codex",
1279
+ sessionId: rootThreadId,
1280
+ messageId: null,
1281
+ eventId: options.eventId ?? null,
1282
+ turnId: options.turnId ?? null,
1283
+ parentToolCallId: null,
1284
+ raw: options.raw,
1285
+ };
1286
+ }
1287
+ reconciledBackgroundTaskRaw(threadId, turn) {
1288
+ const error = turn ? str(asObj(turn, "error"), "message") || null : null;
1289
+ return {
1290
+ method: "thread/read",
1291
+ reconciled: true,
1292
+ threadId,
1293
+ turnId: turn ? str(turn, "id") || null : null,
1294
+ status: turn ? str(turn, "status") || null : null,
1295
+ error,
1296
+ };
1297
+ }
1298
+ /**
1299
+ * Codex 0.144+ represents collaboration as a root collabAgentToolCall item.
1300
+ * A completed spawn call only means the child was created. Register every
1301
+ * receiver and then follow the child thread itself for the real terminus.
1302
+ */
1303
+ /** @returns whether the item produced at least one background-task edge. */
1304
+ handleCollabAgentToolCall(item, raw, parentTaskId) {
1305
+ if (this._state === "closed" || !this.ctx.onEvent)
1306
+ return false;
1307
+ let dispatched = false;
1308
+ const tool = str(item, "tool");
1309
+ const states = asObj(item, "agentsStates");
1310
+ const receiverIds = stringArray(item["receiverThreadIds"]);
1311
+ const taskIds = [...new Set([...receiverIds, ...Object.keys(states)])];
1312
+ const canStart = tool === "spawnAgent";
1313
+ const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
1314
+ const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
1315
+ for (const taskId of taskIds) {
1316
+ const previous = this._backgroundTasks.get(taskId);
1317
+ const description = str(item, "prompt") || previous?.description || null;
1318
+ const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
1319
+ // The child thread's own turn/started notification is the only
1320
+ // authoritative reactivation edge. Root resume/send calls can arrive
1321
+ // before or after that child turn, so they only enrich an active task.
1322
+ if (metadataOnly) {
1323
+ if (!previous || previous.terminal)
1324
+ continue;
1325
+ if (description && description !== previous.description) {
1326
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1327
+ description,
1328
+ summary: previous.summary,
1329
+ parentTaskId: resolvedParentTaskId,
1330
+ eventId: this._threadId
1331
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1332
+ : null,
1333
+ raw,
1334
+ }));
1335
+ dispatched = true;
1336
+ }
1337
+ continue;
1338
+ }
1339
+ if (!previous && !canStart)
1340
+ continue;
1341
+ if (!canStart && !canStopWithoutState && !(taskId in states))
1342
+ continue;
1343
+ const hasState = taskId in states;
1344
+ const state = !hasState && canStopWithoutState
1345
+ ? { terminal: true, status: "stopped", summary: null }
1346
+ : codexAgentState(states[taskId]);
1347
+ if (!previous) {
1348
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "started", "running", {
1349
+ description,
1350
+ summary: null,
1351
+ parentTaskId: resolvedParentTaskId,
1352
+ eventId: this._threadId
1353
+ ? `codex:${this._threadId}:background-task:${taskId}:started`
1354
+ : null,
1355
+ raw,
1356
+ }));
1357
+ dispatched = true;
1358
+ }
1359
+ else if (previous.terminal) {
1360
+ continue;
1361
+ }
1362
+ else if (description && description !== previous.description) {
1363
+ // thread/started can beat the richer root collab item. Preserve one
1364
+ // start edge, then publish the prompt as ordinary progress metadata.
1365
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1366
+ description,
1367
+ summary: previous.summary,
1368
+ parentTaskId: resolvedParentTaskId,
1369
+ eventId: this._threadId
1370
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1371
+ : null,
1372
+ raw,
1373
+ }));
1374
+ dispatched = true;
1375
+ }
1376
+ if (state.terminal) {
1377
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
1378
+ description,
1379
+ summary: state.summary,
1380
+ parentTaskId: resolvedParentTaskId,
1381
+ eventId: this._threadId
1382
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "state"}:completed`
1383
+ : null,
1384
+ raw,
1385
+ }));
1386
+ dispatched = true;
1387
+ }
1388
+ }
1389
+ return dispatched;
1390
+ }
1391
+ waitForBackgroundTaskPoll(pollKey) {
1392
+ if (this._state === "closed")
1393
+ return Promise.resolve();
1394
+ return new Promise((resolve) => {
1395
+ const timer = setTimeout(() => {
1396
+ this._backgroundTaskPollTimers.delete(pollKey);
1397
+ resolve();
1398
+ }, BACKGROUND_TASK_POLL_INTERVAL_MS);
1399
+ if (typeof timer.unref === "function")
1400
+ timer.unref();
1401
+ this._backgroundTaskPollTimers.set(pollKey, { timer, resolve });
1402
+ });
1403
+ }
1404
+ startBackgroundTaskPoller(taskId) {
1405
+ if (this._state === "closed")
1406
+ return;
1407
+ const task = this._backgroundTasks.get(taskId);
1408
+ if (!task || task.terminal)
1409
+ return;
1410
+ const existing = this._backgroundTaskPollers.get(taskId);
1411
+ if (existing?.generation === task.generation)
1412
+ return;
1413
+ const generation = task.generation;
1414
+ const pollKey = `${taskId}:${generation}`;
1415
+ let poller;
1416
+ poller = this.pollBackgroundTask(taskId, generation, pollKey).finally(() => {
1417
+ if (this._backgroundTaskPollers.get(taskId)?.promise === poller) {
1418
+ this._backgroundTaskPollers.delete(taskId);
1419
+ }
1420
+ const pending = this._backgroundTaskPollTimers.get(pollKey);
1421
+ if (pending)
1422
+ clearTimeout(pending.timer);
1423
+ this._backgroundTaskPollTimers.delete(pollKey);
1424
+ });
1425
+ this._backgroundTaskPollers.set(taskId, { generation, promise: poller });
1426
+ }
1427
+ async pollBackgroundTask(taskId, generation, pollKey) {
1428
+ while (this._state !== "closed") {
1429
+ const task = this._backgroundTasks.get(taskId);
1430
+ if (!task || task.generation !== generation || task.terminal)
1431
+ return;
1432
+ try {
1433
+ const response = await this.boundedRpcRequest("thread/read", {
1434
+ threadId: taskId,
1435
+ includeTurns: true,
1436
+ });
1437
+ if (this.observeBackgroundTaskThread(response, taskId, generation))
1438
+ return;
1439
+ }
1440
+ catch {
1441
+ // A child can briefly be pendingInit before thread/read can load it.
1442
+ }
1443
+ const current = this._backgroundTasks.get(taskId);
1444
+ if (!current || current.generation !== generation || current.terminal)
1445
+ return;
1446
+ await this.waitForBackgroundTaskPoll(pollKey);
1447
+ }
1448
+ }
1449
+ /** Return true once thread/read proves that the child's latest turn ended. */
1450
+ observeBackgroundTaskThread(response, taskId, generation) {
1451
+ const task = this._backgroundTasks.get(taskId);
1452
+ if (!task || task.generation !== generation || task.terminal)
1453
+ return true;
1454
+ const thread = asObj(response, "thread");
1455
+ const turns = Array.isArray(thread["turns"])
1456
+ ? thread["turns"].map(obj)
1457
+ : [];
1458
+ const latestTurn = turns[turns.length - 1];
1459
+ if (!latestTurn) {
1460
+ const threadStatus = str(asObj(thread, "status"), "type");
1461
+ if (threadStatus !== "systemError")
1462
+ return false;
1463
+ const failureTurn = { status: "systemError", error: { message: "Subagent failed to initialize" } };
1464
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", "failed", {
1465
+ description: task.description,
1466
+ summary: "Subagent failed to initialize",
1467
+ parentTaskId: task.parentTaskId,
1468
+ eventId: this._threadId
1469
+ ? `codex:${this._threadId}:background-task:${taskId}:system-error:completed`
1470
+ : null,
1471
+ raw: this.reconciledBackgroundTaskRaw(taskId, failureTurn),
1472
+ }));
1473
+ return true;
1474
+ }
1475
+ const nativeStatus = str(latestTurn, "status");
1476
+ if (nativeStatus === "inProgress" || !nativeStatus)
1477
+ return false;
1478
+ const turnId = str(latestTurn, "id") || null;
1479
+ // The child turn/started notification is authoritative for a reactivated
1480
+ // generation. thread/read can briefly lag and still end at an older turn.
1481
+ if (task.activeTurnId && turnId !== task.activeTurnId)
1482
+ return false;
1483
+ const items = Array.isArray(latestTurn["items"])
1484
+ ? latestTurn["items"].map(obj)
1485
+ : [];
1486
+ let summary = null;
1487
+ for (let index = items.length - 1; index >= 0; index--) {
1488
+ const item = items[index];
1489
+ if (str(item, "type") !== "agentMessage")
1490
+ continue;
1491
+ summary = this.agentMessageText(item);
1492
+ if (summary)
1493
+ break;
1494
+ }
1495
+ const errorMessage = str(asObj(latestTurn, "error"), "message") || null;
1496
+ const status = nativeStatus === "failed"
1497
+ ? "failed"
1498
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1499
+ ? "stopped"
1500
+ : "completed";
1501
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", status, {
1502
+ description: task.description,
1503
+ summary: summary ?? errorMessage,
1504
+ parentTaskId: task.parentTaskId,
1505
+ turnId,
1506
+ eventId: this._threadId && turnId
1507
+ ? `codex:${this._threadId}:background-task:${taskId}:${turnId}:completed`
1508
+ : null,
1509
+ raw: this.reconciledBackgroundTaskRaw(taskId, latestTurn),
1510
+ }));
1511
+ return true;
1512
+ }
1049
1513
  /**
1050
1514
  * Maintain just enough child metadata to turn a later foreign-thread
1051
1515
  * terminal notification into one provider-neutral task event. This reducer
@@ -1060,6 +1524,10 @@ export class CodexSessionImpl {
1060
1524
  // explicitly reactivates the record below in handleForeignNotification.
1061
1525
  if (previous?.terminal)
1062
1526
  return false;
1527
+ // collabAgentToolCall and child thread/started can both announce the same
1528
+ // spawn. The first edge is sufficient and gives hosts one durable start.
1529
+ if (previous && event.phase === "started")
1530
+ return false;
1063
1531
  const description = event.description ?? previous?.description ?? null;
1064
1532
  const summary = event.summary ?? previous?.summary ?? null;
1065
1533
  const parentTaskId = event.parentTaskId
@@ -1068,15 +1536,40 @@ export class CodexSessionImpl {
1068
1536
  event.description = description;
1069
1537
  event.summary = summary;
1070
1538
  event.parentTaskId = parentTaskId;
1539
+ const terminal = event.phase === "completed";
1071
1540
  this._backgroundTasks.set(event.taskId, {
1072
1541
  taskId: event.taskId,
1542
+ // `description` survives termination: the reactivation path in
1543
+ // handleForeignNotification reads it precisely BECAUSE the record is
1544
+ // terminal, and dropping it lost a reactivated child's label forever.
1545
+ // It is one bounded string per child. `summary` is the unbounded one —
1546
+ // full agent messages, appended per item — and has no reader that
1547
+ // outlives the task, so it is released.
1073
1548
  description,
1074
- summary,
1549
+ summary: terminal ? null : summary,
1075
1550
  parentTaskId,
1076
- terminal: event.phase === "completed",
1551
+ terminal,
1552
+ generation: previous?.generation ?? 0,
1553
+ activeTurnId: terminal ? null : event.turnId ?? previous?.activeTurnId ?? null,
1077
1554
  });
1555
+ // Kept past termination too: a grandchild can spawn after its parent has
1556
+ // finished, and lineage resolves through this index.
1078
1557
  if (description)
1079
1558
  this._backgroundTaskIdsByPath.set(description, event.taskId);
1559
+ // Every registration route converges here, so this is the one place that
1560
+ // can guarantee a reconciliation poller exists. Registering by way of the
1561
+ // parser's subAgentActivity branch previously produced a task with no
1562
+ // safety net at all, wholly dependent on the child notification arriving.
1563
+ //
1564
+ // Correctness rests on `startBackgroundTaskPoller` and the poll loop both
1565
+ // re-reading the record and bailing when it is terminal — NOT on the
1566
+ // microtask. The deferral is an optimization: one wire item can emit
1567
+ // several edges in a tick (a collab spawn whose child is already finished
1568
+ // dispatches `started` then `completed`), and yielding first means the
1569
+ // common case declines before spawning a `thread/read` rather than after.
1570
+ // Do not remove the terminal checks on the grounds that this defers.
1571
+ if (!terminal)
1572
+ queueMicrotask(() => this.startBackgroundTaskPoller(event.taskId));
1080
1573
  return true;
1081
1574
  }
1082
1575
  /**
@@ -1093,8 +1586,21 @@ export class CodexSessionImpl {
1093
1586
  // App-server can publish child thread/started before the corresponding
1094
1587
  // root subAgentActivity item. Register only descendants of this session,
1095
1588
  // not unrelated foreign threads multiplexed by a future server version.
1096
- if (parentThreadId !== rootThreadId && !parentTask)
1589
+ if (parentThreadId !== rootThreadId && !parentTask) {
1590
+ // Say so. A wire shape that omits parentThreadId takes this branch for
1591
+ // every child, which silently disables the whole registration and
1592
+ // reconciliation path — the safety net that exists precisely because
1593
+ // child notifications cannot be relied on. Degrading quietly here
1594
+ // hides the one failure this machinery was built to survive.
1595
+ if (!parentThreadId && this.ctx.onOutput) {
1596
+ try {
1597
+ void this.ctx.onOutput("stderr", `agentex: codex child thread ${childThreadId} announced no parent thread id; `
1598
+ + "background-task tracking and reconciliation are disabled for it\n");
1599
+ }
1600
+ catch { /* swallow */ }
1601
+ }
1097
1602
  return;
1603
+ }
1098
1604
  const source = asObj(thread, "source");
1099
1605
  const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1100
1606
  ? asObj(source, "subAgent")
@@ -1138,6 +1644,7 @@ export class CodexSessionImpl {
1138
1644
  return;
1139
1645
  task.terminal = false;
1140
1646
  task.summary = null;
1647
+ task.generation += 1;
1141
1648
  const turn = asObj(params, "turn");
1142
1649
  const turnId = str(turn, "id") || str(params, "turnId") || null;
1143
1650
  const rootThreadId = this._threadId ?? this._expectedThreadId;
@@ -1166,6 +1673,10 @@ export class CodexSessionImpl {
1166
1673
  if (method === "item/completed") {
1167
1674
  const item = asObj(params, "item");
1168
1675
  const itemType = str(item, "type");
1676
+ if (itemType === "collabAgentToolCall") {
1677
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, childThreadId);
1678
+ return;
1679
+ }
1169
1680
  if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1170
1681
  const summary = this.agentMessageText(item);
1171
1682
  if (summary)
@@ -1205,10 +1716,19 @@ export class CodexSessionImpl {
1205
1716
  : null,
1206
1717
  turnId,
1207
1718
  parentToolCallId: null,
1208
- raw: parseJson(rawLine) ?? params,
1719
+ raw: {
1720
+ method,
1721
+ reconciled: false,
1722
+ threadId: childThreadId,
1723
+ turnId,
1724
+ status: nativeStatus || null,
1725
+ error: errorMessage || null,
1726
+ },
1209
1727
  });
1210
1728
  }
1211
1729
  handleNotification(method, params, rawLine) {
1730
+ if (this._state === "closed")
1731
+ return;
1212
1732
  // codex/event — legacy wrapper
1213
1733
  if (method === "codex/event") {
1214
1734
  const innerMsg = str(params, "msg");
@@ -1251,6 +1771,17 @@ export class CodexSessionImpl {
1251
1771
  if (method === "item/completed") {
1252
1772
  this._state = "thinking";
1253
1773
  this.extractSummaryFromItem(params);
1774
+ const item = asObj(params, "item");
1775
+ if (str(item, "type") === "collabAgentToolCall") {
1776
+ // Shapes we model become background_task edges. Anything that produced
1777
+ // no edge still reaches the host as a raw `unknown` event rather than
1778
+ // vanishing — that escape hatch is how unmodeled wire shapes stay
1779
+ // visible, and collab is exactly where new shapes keep appearing.
1780
+ if (!this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null)) {
1781
+ this.emitStreamEvent(rawLine);
1782
+ }
1783
+ return;
1784
+ }
1254
1785
  this.emitStreamEvent(rawLine);
1255
1786
  return;
1256
1787
  }
@@ -1286,6 +1817,8 @@ export class CodexSessionImpl {
1286
1817
  // Legacy event handling (NDJSON events with `type` field)
1287
1818
  // -------------------------------------------------------------------------
1288
1819
  handleLegacyEvent(event, rawLine) {
1820
+ if (this._state === "closed")
1821
+ return;
1289
1822
  const type = str(event, "type");
1290
1823
  const eventThreadId = str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
1291
1824
  // Older NDJSON-shaped events can also carry explicit thread scope. Keep
@@ -1418,10 +1951,35 @@ export class CodexSessionImpl {
1418
1951
  this.resolveTurn();
1419
1952
  }
1420
1953
  resolveTurn() {
1954
+ // Bind delivery to the turn that just terminated. Everything below this
1955
+ // point can yield (usage scan, event chain), and a send() arriving in the
1956
+ // meantime starts a new turn that must not be settled by this result.
1957
+ const delivering = this._activeTurnReady;
1421
1958
  const resolvedModel = this._turnModel ?? this.model;
1422
1959
  let usage = this._turnUsage && resolvedModel
1423
1960
  ? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
1424
1961
  : undefined;
1962
+ // Freeze this turn's outcome and clear the accumulators NOW, while we are
1963
+ // still synchronous with the terminal frame. Delivery reads the snapshot,
1964
+ // so a turn that starts during the async gap below neither inherits these
1965
+ // values nor has its own overwritten. Resetting inside beginActiveTurn()
1966
+ // instead would not work: the usage-scan path re-reads state after an
1967
+ // await, so the new turn's reset would eat the old turn's summary.
1968
+ const outcome = {
1969
+ summary: this._turnSummary,
1970
+ isError: this._turnIsError,
1971
+ errorMessage: this._turnErrorMessage,
1972
+ wasInterrupted: this._turnWasInterrupted,
1973
+ };
1974
+ this._turnSummary = null;
1975
+ this._turnUsage = null;
1976
+ this._turnModel = null;
1977
+ this._turnIsError = false;
1978
+ this._turnWasInterrupted = false;
1979
+ this._turnErrorMessage = null;
1980
+ // Captured before clearing: the usage-scan fallback below still needs it.
1981
+ const turnStartedAt = this._turnStartedAt;
1982
+ this._turnStartedAt = null;
1425
1983
  // Usage precedence: the `turn.completed` payload is authoritative when
1426
1984
  // present (captured above into _turnUsage). Only when the stream carried no
1427
1985
  // usage do we fall back to scanning Codex's on-disk session logs.
@@ -1431,8 +1989,8 @@ export class CodexSessionImpl {
1431
1989
  // latest turn or read partially-written totals. We therefore scan ONLY when
1432
1990
  // there is no in-band usage, and never let a scan failure fail the turn
1433
1991
  // (usage simply stays undefined). Prefer the in-band payload always.
1434
- if (!usage && this._turnStartedAt) {
1435
- const startedAt = this._turnStartedAt;
1992
+ if (!usage && turnStartedAt) {
1993
+ const startedAt = turnStartedAt;
1436
1994
  const threadId = this._threadId ?? undefined;
1437
1995
  // Fire-and-forget: scan logs then deliver result
1438
1996
  void scanCodexSessionUsage({ startedAfter: startedAt, threadId }).then((scanned) => {
@@ -1440,20 +1998,20 @@ export class CodexSessionImpl {
1440
1998
  }).catch(() => {
1441
1999
  // Non-fatal — usage stays undefined
1442
2000
  }).finally(() => {
1443
- void this.deliverTurnResult(usage);
2001
+ void this.deliverTurnResult(usage, delivering, outcome);
1444
2002
  });
1445
2003
  return;
1446
2004
  }
1447
- void this.deliverTurnResult(usage);
2005
+ void this.deliverTurnResult(usage, delivering, outcome);
1448
2006
  }
1449
- async deliverTurnResult(usage) {
2007
+ async deliverTurnResult(usage, delivering, outcome) {
1450
2008
  const result = {
1451
- summary: this._turnSummary,
2009
+ summary: outcome.summary,
1452
2010
  usage,
1453
2011
  costUsd: null,
1454
- status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
1455
- errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
1456
- errorMessage: this._turnWasInterrupted ? "Turn was interrupted" : this._turnErrorMessage,
2012
+ status: outcome.wasInterrupted ? "aborted" : outcome.isError ? "failed" : "completed",
2013
+ errorCode: outcome.wasInterrupted ? "aborted" : outcome.isError ? "execution_error" : null,
2014
+ errorMessage: outcome.wasInterrupted ? "Turn was interrupted" : outcome.errorMessage,
1457
2015
  };
1458
2016
  // Drain pending onEvent handlers so callers awaiting send() see a settled
1459
2017
  // DB / log / UI state by the time TurnResult resolves. The chain snapshot
@@ -1467,20 +2025,27 @@ export class CodexSessionImpl {
1467
2025
  // session whose stdin is dead.
1468
2026
  if (this._state === "closed")
1469
2027
  return;
1470
- this._state = "idle";
1471
- // Drain ALL pending send() resolvers with this turn's result. Multiple
1472
- // concurrent sends coalesced into one turn share the same TurnResult.
1473
- const pending = this._pendingResults.splice(0);
1474
- // Clear per-turn accumulators so a subsequent turn doesn't inherit
1475
- // stale summary / usage / model.
1476
- this._turnSummary = null;
1477
- this._turnUsage = null;
1478
- this._turnModel = null;
1479
- this._turnIsError = false;
1480
- this._turnWasInterrupted = false;
1481
- this._turnErrorMessage = null;
1482
- this._turnStartedAt = null;
1483
- this.clearActiveTurn();
2028
+ // A send() that landed while this delivery was suspended has already begun
2029
+ // a new turn and owns the session. Its result is still owed to it, so this
2030
+ // delivery settles only its OWN generation and touches neither the state
2031
+ // nor the interrupt latch the new turn needs to stay stoppable.
2032
+ //
2033
+ // `delivering === null` must take this branch too. A duplicate terminal
2034
+ // frame for an already-delivered turn (codex can report failure through
2035
+ // both `turn/completed` with a failed status and a separate `turn/failed`)
2036
+ // reaches here with a null latch, and exempting it restored every symptom
2037
+ // the fence exists to prevent: silent Stop, idle-while-running, and a
2038
+ // stale settle.
2039
+ const superseded = this._activeTurnReady !== delivering;
2040
+ // Drain the pending send() resolvers belonging to this turn. Several
2041
+ // concurrent sends coalesced into one turn share the same TurnResult; a
2042
+ // send from a later turn is left in place for its own delivery.
2043
+ const pending = this.takePendingResults(delivering?.generation);
2044
+ if (!superseded) {
2045
+ this._state = "idle";
2046
+ // Accumulators were already snapshotted and cleared in resolveTurn().
2047
+ this.clearActiveTurn(undefined, delivering ?? undefined);
2048
+ }
1484
2049
  for (const p of pending) {
1485
2050
  // Skip sends already settled early by timeout / abort.
1486
2051
  if (p.settled)
@@ -1500,9 +2065,9 @@ export class CodexSessionImpl {
1500
2065
  // Pass current threadId so NDJSON-shaped events (via codex/event wrapper)
1501
2066
  // carry sessionId. v2 notifications parse threadId from params directly
1502
2067
  // and ignore this arg.
1503
- const event = parseCodexStreamLine(rawLine, this._threadId);
1504
- if (event)
2068
+ for (const event of parseCodexStreamLines(rawLine, this._threadId)) {
1505
2069
  this.dispatchEvent(event);
2070
+ }
1506
2071
  }
1507
2072
  /**
1508
2073
  * Queue an event for in-order delivery to `onEvent`. Each call appends a