@agentex/agent 0.0.34 → 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 +185 -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 +51 -16
  36. package/dist/providers/codex/parse.js.map +1 -1
  37. package/dist/providers/codex/session.d.ts +29 -1
  38. package/dist/providers/codex/session.d.ts.map +1 -1
  39. package/dist/providers/codex/session.js +255 -57
  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 +60 -18
  74. package/src/providers/codex/session.ts +285 -59
  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";
@@ -305,6 +305,8 @@ export class CodexSessionImpl {
305
305
  */
306
306
  _activeTurnId = null;
307
307
  _activeTurnReady = null;
308
+ /** Incremented per turn latch. Never reused, so staleness is decidable. */
309
+ _turnGeneration = 0;
308
310
  /** Successful repeated interrupts coalesce until the terminal notification. */
309
311
  _interruptPromise = null;
310
312
  /** Prevents a late interrupt request after the terminal frame was observed. */
@@ -439,6 +441,21 @@ export class CodexSessionImpl {
439
441
  }));
440
442
  }
441
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
+ }
442
459
  /** Reject every pending send() Promise and outgoing JSON-RPC call. */
443
460
  rejectAllPending(err) {
444
461
  const pending = this._pendingResults.splice(0);
@@ -472,6 +489,7 @@ export class CodexSessionImpl {
472
489
  resolve: resolveFn,
473
490
  reject: rejectFn,
474
491
  settled: false,
492
+ generation: ++this._turnGeneration,
475
493
  };
476
494
  this._activeTurnId = null;
477
495
  this._activeTurnReady = ready;
@@ -497,9 +515,17 @@ export class CodexSessionImpl {
497
515
  expected.settled = true;
498
516
  expected.reject(err);
499
517
  }
500
- /** Clear the current turn and release an interrupt waiting for its id. */
501
- 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) {
502
526
  const ready = this._activeTurnReady;
527
+ if (expected && ready !== expected)
528
+ return;
503
529
  if (ready && !ready.settled) {
504
530
  ready.settled = true;
505
531
  if (err)
@@ -704,9 +730,17 @@ export class CodexSessionImpl {
704
730
  // and pass through. If the second `turn/start` lands during the first
705
731
  // turn, the per-turn accumulators continue collecting until the result
706
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.
707
740
  const existingTurnReady = this._activeTurnReady;
708
- const isTurnLeader = existingTurnReady === null;
709
- const turnReady = existingTurnReady ?? this.beginActiveTurn();
741
+ const canJoinActiveTurn = existingTurnReady !== null && !this._turnTerminalObserved;
742
+ const isTurnLeader = !canJoinActiveTurn;
743
+ const turnReady = canJoinActiveTurn ? existingTurnReady : this.beginActiveTurn();
710
744
  if (this._state === "idle") {
711
745
  this._state = "thinking";
712
746
  this._turnStartedAt = new Date();
@@ -736,7 +770,11 @@ export class CodexSessionImpl {
736
770
  resolveFn = resolve;
737
771
  rejectFn = reject;
738
772
  });
739
- const entry = { resolve: resolveFn, reject: rejectFn };
773
+ const entry = {
774
+ resolve: resolveFn,
775
+ reject: rejectFn,
776
+ generation: turnReady.generation,
777
+ };
740
778
  this._pendingResults.push(entry);
741
779
  // Track the in-flight turn so drain() can await it; drop it on settle.
742
780
  this._inFlight.add(result);
@@ -935,6 +973,10 @@ export class CodexSessionImpl {
935
973
  method: "session/closed",
936
974
  });
937
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();
938
980
  this.rejectAllPending(new Error("Codex session closed"));
939
981
  this.proc.stdin.end();
940
982
  // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
@@ -1017,13 +1059,22 @@ export class CodexSessionImpl {
1017
1059
  method === "item/fileChange/requestApproval") {
1018
1060
  void this.handleApproval(id, method, params);
1019
1061
  }
1062
+ else if (method === "item/permissions/requestApproval") {
1063
+ void this.handlePermissionsApproval(id, params);
1064
+ }
1020
1065
  else if (method === "item/tool/requestUserInput" ||
1021
1066
  method === "tool/requestUserInput") {
1022
1067
  // `tool/requestUserInput` is the legacy method name on older codex builds.
1023
1068
  void this.handleUserInputRequest(id, params);
1024
1069
  }
1025
1070
  else {
1026
- // 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.
1027
1078
  this.rpcResponse(id, {});
1028
1079
  }
1029
1080
  }
@@ -1039,13 +1090,24 @@ export class CodexSessionImpl {
1039
1090
  this._state = this._pendingResults.length > 0 ? "thinking" : "idle";
1040
1091
  }
1041
1092
  async handleApproval(id, method, params) {
1042
- 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"); };
1043
1105
  // Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
1044
1106
  // (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
1045
1107
  // concept, so allow → accept and deny → decline.
1046
1108
  if (!this.ctx.onUserInputRequest) {
1047
1109
  this.rpcResponse(id, { decision: "accept" });
1048
- this.restoreStateAfter("waiting_for_approval");
1110
+ restore();
1049
1111
  return;
1050
1112
  }
1051
1113
  const toolName = method === "item/commandExecution/requestApproval"
@@ -1055,15 +1117,66 @@ export class CodexSessionImpl {
1055
1117
  const resp = await this.ctx.onUserInputRequest({
1056
1118
  toolName,
1057
1119
  input: params,
1058
- toolUseId: str(params, "id"),
1059
- 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 } : {}),
1060
1134
  });
1061
1135
  this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
1062
1136
  }
1063
1137
  catch {
1064
1138
  this.rpcResponse(id, { decision: "decline" });
1065
1139
  }
1066
- 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();
1067
1180
  }
1068
1181
  /**
1069
1182
  * Handle a Codex `requestUserInput` server→client request: the agent is asking
@@ -1072,29 +1185,41 @@ export class CodexSessionImpl {
1072
1185
  * back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
1073
1186
  */
1074
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);
1075
1195
  // Questions are user *input*, not a tool-permission gate — distinct state so a
1076
1196
  // host UI can render a question form vs an approval prompt.
1077
- 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"); };
1078
1201
  const questions = parseCodexQuestions(params);
1079
1202
  // No host handler, or nothing answerable → return empty answers so the
1080
1203
  // agent proceeds without hanging.
1081
1204
  if (!this.ctx.onUserInputRequest || questions.length === 0) {
1082
1205
  this.rpcResponse(id, { answers: {} });
1083
- this.restoreStateAfter("waiting_for_input");
1206
+ restore();
1084
1207
  return;
1085
1208
  }
1086
1209
  try {
1087
1210
  const resp = await this.ctx.onUserInputRequest({
1088
1211
  toolName: "AskUserQuestion",
1089
1212
  input: { questions },
1090
- 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 } : {}),
1091
1216
  });
1092
1217
  this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
1093
1218
  }
1094
1219
  catch {
1095
1220
  this.rpcResponse(id, { answers: {} });
1096
1221
  }
1097
- this.restoreStateAfter("waiting_for_input");
1222
+ restore();
1098
1223
  }
1099
1224
  // -------------------------------------------------------------------------
1100
1225
  // Notification handling (v2 format)
@@ -1175,9 +1300,11 @@ export class CodexSessionImpl {
1175
1300
  * A completed spawn call only means the child was created. Register every
1176
1301
  * receiver and then follow the child thread itself for the real terminus.
1177
1302
  */
1303
+ /** @returns whether the item produced at least one background-task edge. */
1178
1304
  handleCollabAgentToolCall(item, raw, parentTaskId) {
1179
1305
  if (this._state === "closed" || !this.ctx.onEvent)
1180
- return;
1306
+ return false;
1307
+ let dispatched = false;
1181
1308
  const tool = str(item, "tool");
1182
1309
  const states = asObj(item, "agentsStates");
1183
1310
  const receiverIds = stringArray(item["receiverThreadIds"]);
@@ -1186,7 +1313,7 @@ export class CodexSessionImpl {
1186
1313
  const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
1187
1314
  const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
1188
1315
  for (const taskId of taskIds) {
1189
- let previous = this._backgroundTasks.get(taskId);
1316
+ const previous = this._backgroundTasks.get(taskId);
1190
1317
  const description = str(item, "prompt") || previous?.description || null;
1191
1318
  const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
1192
1319
  // The child thread's own turn/started notification is the only
@@ -1205,6 +1332,7 @@ export class CodexSessionImpl {
1205
1332
  : null,
1206
1333
  raw,
1207
1334
  }));
1335
+ dispatched = true;
1208
1336
  }
1209
1337
  continue;
1210
1338
  }
@@ -1213,7 +1341,7 @@ export class CodexSessionImpl {
1213
1341
  if (!canStart && !canStopWithoutState && !(taskId in states))
1214
1342
  continue;
1215
1343
  const hasState = taskId in states;
1216
- const state = !hasState && tool === "closeAgent" && str(item, "status") === "completed"
1344
+ const state = !hasState && canStopWithoutState
1217
1345
  ? { terminal: true, status: "stopped", summary: null }
1218
1346
  : codexAgentState(states[taskId]);
1219
1347
  if (!previous) {
@@ -1226,7 +1354,7 @@ export class CodexSessionImpl {
1226
1354
  : null,
1227
1355
  raw,
1228
1356
  }));
1229
- previous = this._backgroundTasks.get(taskId);
1357
+ dispatched = true;
1230
1358
  }
1231
1359
  else if (previous.terminal) {
1232
1360
  continue;
@@ -1243,6 +1371,7 @@ export class CodexSessionImpl {
1243
1371
  : null,
1244
1372
  raw,
1245
1373
  }));
1374
+ dispatched = true;
1246
1375
  }
1247
1376
  if (state.terminal) {
1248
1377
  this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
@@ -1254,11 +1383,10 @@ export class CodexSessionImpl {
1254
1383
  : null,
1255
1384
  raw,
1256
1385
  }));
1257
- }
1258
- else {
1259
- this.startBackgroundTaskPoller(taskId);
1386
+ dispatched = true;
1260
1387
  }
1261
1388
  }
1389
+ return dispatched;
1262
1390
  }
1263
1391
  waitForBackgroundTaskPoll(pollKey) {
1264
1392
  if (this._state === "closed")
@@ -1408,19 +1536,40 @@ export class CodexSessionImpl {
1408
1536
  event.description = description;
1409
1537
  event.summary = summary;
1410
1538
  event.parentTaskId = parentTaskId;
1539
+ const terminal = event.phase === "completed";
1411
1540
  this._backgroundTasks.set(event.taskId, {
1412
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.
1413
1548
  description,
1414
- summary,
1549
+ summary: terminal ? null : summary,
1415
1550
  parentTaskId,
1416
- terminal: event.phase === "completed",
1551
+ terminal,
1417
1552
  generation: previous?.generation ?? 0,
1418
- activeTurnId: event.phase === "completed"
1419
- ? null
1420
- : event.turnId ?? previous?.activeTurnId ?? null,
1553
+ activeTurnId: terminal ? null : event.turnId ?? previous?.activeTurnId ?? null,
1421
1554
  });
1555
+ // Kept past termination too: a grandchild can spawn after its parent has
1556
+ // finished, and lineage resolves through this index.
1422
1557
  if (description)
1423
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));
1424
1573
  return true;
1425
1574
  }
1426
1575
  /**
@@ -1437,8 +1586,21 @@ export class CodexSessionImpl {
1437
1586
  // App-server can publish child thread/started before the corresponding
1438
1587
  // root subAgentActivity item. Register only descendants of this session,
1439
1588
  // not unrelated foreign threads multiplexed by a future server version.
1440
- 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
+ }
1441
1602
  return;
1603
+ }
1442
1604
  const source = asObj(thread, "source");
1443
1605
  const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1444
1606
  ? asObj(source, "subAgent")
@@ -1472,7 +1634,6 @@ export class CodexSessionImpl {
1472
1634
  parentToolCallId: null,
1473
1635
  raw: parseJson(rawLine) ?? params,
1474
1636
  });
1475
- this.startBackgroundTaskPoller(childThreadId);
1476
1637
  return;
1477
1638
  }
1478
1639
  const task = this._backgroundTasks.get(childThreadId);
@@ -1507,7 +1668,6 @@ export class CodexSessionImpl {
1507
1668
  parentToolCallId: null,
1508
1669
  raw: parseJson(rawLine) ?? params,
1509
1670
  });
1510
- this.startBackgroundTaskPoller(childThreadId);
1511
1671
  return;
1512
1672
  }
1513
1673
  if (method === "item/completed") {
@@ -1613,7 +1773,13 @@ export class CodexSessionImpl {
1613
1773
  this.extractSummaryFromItem(params);
1614
1774
  const item = asObj(params, "item");
1615
1775
  if (str(item, "type") === "collabAgentToolCall") {
1616
- this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null);
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
+ }
1617
1783
  return;
1618
1784
  }
1619
1785
  this.emitStreamEvent(rawLine);
@@ -1785,10 +1951,35 @@ export class CodexSessionImpl {
1785
1951
  this.resolveTurn();
1786
1952
  }
1787
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;
1788
1958
  const resolvedModel = this._turnModel ?? this.model;
1789
1959
  let usage = this._turnUsage && resolvedModel
1790
1960
  ? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
1791
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;
1792
1983
  // Usage precedence: the `turn.completed` payload is authoritative when
1793
1984
  // present (captured above into _turnUsage). Only when the stream carried no
1794
1985
  // usage do we fall back to scanning Codex's on-disk session logs.
@@ -1798,8 +1989,8 @@ export class CodexSessionImpl {
1798
1989
  // latest turn or read partially-written totals. We therefore scan ONLY when
1799
1990
  // there is no in-band usage, and never let a scan failure fail the turn
1800
1991
  // (usage simply stays undefined). Prefer the in-band payload always.
1801
- if (!usage && this._turnStartedAt) {
1802
- const startedAt = this._turnStartedAt;
1992
+ if (!usage && turnStartedAt) {
1993
+ const startedAt = turnStartedAt;
1803
1994
  const threadId = this._threadId ?? undefined;
1804
1995
  // Fire-and-forget: scan logs then deliver result
1805
1996
  void scanCodexSessionUsage({ startedAfter: startedAt, threadId }).then((scanned) => {
@@ -1807,20 +1998,20 @@ export class CodexSessionImpl {
1807
1998
  }).catch(() => {
1808
1999
  // Non-fatal — usage stays undefined
1809
2000
  }).finally(() => {
1810
- void this.deliverTurnResult(usage);
2001
+ void this.deliverTurnResult(usage, delivering, outcome);
1811
2002
  });
1812
2003
  return;
1813
2004
  }
1814
- void this.deliverTurnResult(usage);
2005
+ void this.deliverTurnResult(usage, delivering, outcome);
1815
2006
  }
1816
- async deliverTurnResult(usage) {
2007
+ async deliverTurnResult(usage, delivering, outcome) {
1817
2008
  const result = {
1818
- summary: this._turnSummary,
2009
+ summary: outcome.summary,
1819
2010
  usage,
1820
2011
  costUsd: null,
1821
- status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
1822
- errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
1823
- 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,
1824
2015
  };
1825
2016
  // Drain pending onEvent handlers so callers awaiting send() see a settled
1826
2017
  // DB / log / UI state by the time TurnResult resolves. The chain snapshot
@@ -1834,20 +2025,27 @@ export class CodexSessionImpl {
1834
2025
  // session whose stdin is dead.
1835
2026
  if (this._state === "closed")
1836
2027
  return;
1837
- this._state = "idle";
1838
- // Drain ALL pending send() resolvers with this turn's result. Multiple
1839
- // concurrent sends coalesced into one turn share the same TurnResult.
1840
- const pending = this._pendingResults.splice(0);
1841
- // Clear per-turn accumulators so a subsequent turn doesn't inherit
1842
- // stale summary / usage / model.
1843
- this._turnSummary = null;
1844
- this._turnUsage = null;
1845
- this._turnModel = null;
1846
- this._turnIsError = false;
1847
- this._turnWasInterrupted = false;
1848
- this._turnErrorMessage = null;
1849
- this._turnStartedAt = null;
1850
- 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
+ }
1851
2049
  for (const p of pending) {
1852
2050
  // Skip sends already settled early by timeout / abort.
1853
2051
  if (p.settled)
@@ -1867,9 +2065,9 @@ export class CodexSessionImpl {
1867
2065
  // Pass current threadId so NDJSON-shaped events (via codex/event wrapper)
1868
2066
  // carry sessionId. v2 notifications parse threadId from params directly
1869
2067
  // and ignore this arg.
1870
- const event = parseCodexStreamLine(rawLine, this._threadId);
1871
- if (event)
2068
+ for (const event of parseCodexStreamLines(rawLine, this._threadId)) {
1872
2069
  this.dispatchEvent(event);
2070
+ }
1873
2071
  }
1874
2072
  /**
1875
2073
  * Queue an event for in-order delivery to `onEvent`. Each call appends a