@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
@@ -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 {
@@ -152,11 +158,28 @@ const BACKGROUND_TASK_POLL_INTERVAL_MS = 2_000;
152
158
  const BACKGROUND_TASK_READ_TIMEOUT_MS = 5_000;
153
159
 
154
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
+
155
176
  interface ActiveTurnReady {
156
177
  promise: Promise<string | null>;
157
178
  resolve: (turnId: string | null) => void;
158
179
  reject: (err: Error) => void;
159
180
  settled: boolean;
181
+ /** Monotonic id for this turn, used to fence late result delivery. */
182
+ generation: number;
160
183
  }
161
184
 
162
185
  // ---------------------------------------------------------------------------
@@ -403,6 +426,8 @@ export class CodexSessionImpl implements AgentSession {
403
426
  */
404
427
  private _activeTurnId: string | null = null;
405
428
  private _activeTurnReady: ActiveTurnReady | null = null;
429
+ /** Incremented per turn latch. Never reused, so staleness is decidable. */
430
+ private _turnGeneration = 0;
406
431
  /** Successful repeated interrupts coalesce until the terminal notification. */
407
432
  private _interruptPromise: Promise<void> | null = null;
408
433
  /** Prevents a late interrupt request after the terminal frame was observed. */
@@ -555,6 +580,22 @@ export class CodexSessionImpl implements AgentSession {
555
580
  }
556
581
  }
557
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
+
558
599
  /** Reject every pending send() Promise and outgoing JSON-RPC call. */
559
600
  private rejectAllPending(err: Error): void {
560
601
  const pending = this._pendingResults.splice(0);
@@ -589,6 +630,7 @@ export class CodexSessionImpl implements AgentSession {
589
630
  resolve: resolveFn,
590
631
  reject: rejectFn,
591
632
  settled: false,
633
+ generation: ++this._turnGeneration,
592
634
  };
593
635
  this._activeTurnId = null;
594
636
  this._activeTurnReady = ready;
@@ -614,9 +656,16 @@ export class CodexSessionImpl implements AgentSession {
614
656
  expected.reject(err);
615
657
  }
616
658
 
617
- /** Clear the current turn and release an interrupt waiting for its id. */
618
- 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 {
619
667
  const ready = this._activeTurnReady;
668
+ if (expected && ready !== expected) return;
620
669
  if (ready && !ready.settled) {
621
670
  ready.settled = true;
622
671
  if (err) ready.reject(err);
@@ -834,9 +883,17 @@ export class CodexSessionImpl implements AgentSession {
834
883
  // and pass through. If the second `turn/start` lands during the first
835
884
  // turn, the per-turn accumulators continue collecting until the result
836
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.
837
893
  const existingTurnReady = this._activeTurnReady;
838
- const isTurnLeader = existingTurnReady === null;
839
- const turnReady = existingTurnReady ?? this.beginActiveTurn();
894
+ const canJoinActiveTurn = existingTurnReady !== null && !this._turnTerminalObserved;
895
+ const isTurnLeader = !canJoinActiveTurn;
896
+ const turnReady = canJoinActiveTurn ? existingTurnReady! : this.beginActiveTurn();
840
897
 
841
898
  if (this._state === "idle") {
842
899
  this._state = "thinking";
@@ -868,7 +925,11 @@ export class CodexSessionImpl implements AgentSession {
868
925
  rejectFn = reject;
869
926
  });
870
927
 
871
- const entry: PendingResult = { resolve: resolveFn, reject: rejectFn };
928
+ const entry: PendingResult = {
929
+ resolve: resolveFn,
930
+ reject: rejectFn,
931
+ generation: turnReady.generation,
932
+ };
872
933
  this._pendingResults.push(entry);
873
934
 
874
935
  // Track the in-flight turn so drain() can await it; drop it on settle.
@@ -1072,6 +1133,10 @@ export class CodexSessionImpl implements AgentSession {
1072
1133
  method: "session/closed",
1073
1134
  });
1074
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();
1075
1140
  this.rejectAllPending(new Error("Codex session closed"));
1076
1141
 
1077
1142
  this.proc.stdin!.end();
@@ -1162,6 +1227,8 @@ export class CodexSessionImpl implements AgentSession {
1162
1227
  method === "item/fileChange/requestApproval"
1163
1228
  ) {
1164
1229
  void this.handleApproval(id, method, params);
1230
+ } else if (method === "item/permissions/requestApproval") {
1231
+ void this.handlePermissionsApproval(id, params);
1165
1232
  } else if (
1166
1233
  method === "item/tool/requestUserInput" ||
1167
1234
  method === "tool/requestUserInput"
@@ -1169,7 +1236,13 @@ export class CodexSessionImpl implements AgentSession {
1169
1236
  // `tool/requestUserInput` is the legacy method name on older codex builds.
1170
1237
  void this.handleUserInputRequest(id, params);
1171
1238
  } else {
1172
- // 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.
1173
1246
  this.rpcResponse(id, {});
1174
1247
  }
1175
1248
  }
@@ -1186,14 +1259,23 @@ export class CodexSessionImpl implements AgentSession {
1186
1259
  }
1187
1260
 
1188
1261
  private async handleApproval(id: number, method: string, params: Record<string, unknown>): Promise<void> {
1189
- 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"); };
1190
1272
 
1191
1273
  // Codex's app-server expects `{ decision: "accept" | "decline" | "cancel" }`
1192
1274
  // (NOT `{ approved: boolean }`). agentex's UserInputResponse has no interrupt
1193
1275
  // concept, so allow → accept and deny → decline.
1194
1276
  if (!this.ctx.onUserInputRequest) {
1195
1277
  this.rpcResponse(id, { decision: "accept" });
1196
- this.restoreStateAfter("waiting_for_approval");
1278
+ restore();
1197
1279
  return;
1198
1280
  }
1199
1281
 
@@ -1205,15 +1287,67 @@ export class CodexSessionImpl implements AgentSession {
1205
1287
  const resp = await this.ctx.onUserInputRequest({
1206
1288
  toolName,
1207
1289
  input: params,
1208
- toolUseId: str(params, "id"),
1209
- 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 } : {}),
1210
1304
  });
1211
1305
  this.rpcResponse(id, { decision: resp.allow ? "accept" : "decline" });
1212
1306
  } catch {
1213
1307
  this.rpcResponse(id, { decision: "decline" });
1214
1308
  }
1215
1309
 
1216
- 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();
1217
1351
  }
1218
1352
 
1219
1353
  /**
@@ -1223,9 +1357,17 @@ export class CodexSessionImpl implements AgentSession {
1223
1357
  * back in Codex's `{ answers: { [questionId]: { answers: string[] } } }` shape.
1224
1358
  */
1225
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);
1226
1367
  // Questions are user *input*, not a tool-permission gate — distinct state so a
1227
1368
  // host UI can render a question form vs an approval prompt.
1228
- this._state = "waiting_for_input";
1369
+ if (!foreign) this._state = "waiting_for_input";
1370
+ const restore = () => { if (!foreign) this.restoreStateAfter("waiting_for_input"); };
1229
1371
 
1230
1372
  const questions = parseCodexQuestions(params);
1231
1373
 
@@ -1233,7 +1375,7 @@ export class CodexSessionImpl implements AgentSession {
1233
1375
  // agent proceeds without hanging.
1234
1376
  if (!this.ctx.onUserInputRequest || questions.length === 0) {
1235
1377
  this.rpcResponse(id, { answers: {} });
1236
- this.restoreStateAfter("waiting_for_input");
1378
+ restore();
1237
1379
  return;
1238
1380
  }
1239
1381
 
@@ -1241,14 +1383,16 @@ export class CodexSessionImpl implements AgentSession {
1241
1383
  const resp = await this.ctx.onUserInputRequest({
1242
1384
  toolName: "AskUserQuestion",
1243
1385
  input: { questions },
1244
- 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 } : {}),
1245
1389
  });
1246
1390
  this.rpcResponse(id, { answers: buildCodexUserInputAnswers(questions, resp) });
1247
1391
  } catch {
1248
1392
  this.rpcResponse(id, { answers: {} });
1249
1393
  }
1250
1394
 
1251
- this.restoreStateAfter("waiting_for_input");
1395
+ restore();
1252
1396
  }
1253
1397
 
1254
1398
  // -------------------------------------------------------------------------
@@ -1348,12 +1492,14 @@ export class CodexSessionImpl implements AgentSession {
1348
1492
  * A completed spawn call only means the child was created. Register every
1349
1493
  * receiver and then follow the child thread itself for the real terminus.
1350
1494
  */
1495
+ /** @returns whether the item produced at least one background-task edge. */
1351
1496
  private handleCollabAgentToolCall(
1352
1497
  item: Record<string, unknown>,
1353
1498
  raw: Record<string, unknown>,
1354
1499
  parentTaskId: string | null,
1355
- ): void {
1356
- if (this._state === "closed" || !this.ctx.onEvent) return;
1500
+ ): boolean {
1501
+ if (this._state === "closed" || !this.ctx.onEvent) return false;
1502
+ let dispatched = false;
1357
1503
 
1358
1504
  const tool = str(item, "tool");
1359
1505
  const states = asObj(item, "agentsStates");
@@ -1364,7 +1510,7 @@ export class CodexSessionImpl implements AgentSession {
1364
1510
  const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
1365
1511
 
1366
1512
  for (const taskId of taskIds) {
1367
- let previous = this._backgroundTasks.get(taskId);
1513
+ const previous = this._backgroundTasks.get(taskId);
1368
1514
  const description = str(item, "prompt") || previous?.description || null;
1369
1515
  const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
1370
1516
 
@@ -1383,6 +1529,7 @@ export class CodexSessionImpl implements AgentSession {
1383
1529
  : null,
1384
1530
  raw,
1385
1531
  }));
1532
+ dispatched = true;
1386
1533
  }
1387
1534
  continue;
1388
1535
  }
@@ -1391,7 +1538,7 @@ export class CodexSessionImpl implements AgentSession {
1391
1538
  if (!canStart && !canStopWithoutState && !(taskId in states)) continue;
1392
1539
 
1393
1540
  const hasState = taskId in states;
1394
- const state = !hasState && tool === "closeAgent" && str(item, "status") === "completed"
1541
+ const state = !hasState && canStopWithoutState
1395
1542
  ? { terminal: true, status: "stopped" as const, summary: null }
1396
1543
  : codexAgentState(states[taskId]);
1397
1544
 
@@ -1405,7 +1552,7 @@ export class CodexSessionImpl implements AgentSession {
1405
1552
  : null,
1406
1553
  raw,
1407
1554
  }));
1408
- previous = this._backgroundTasks.get(taskId);
1555
+ dispatched = true;
1409
1556
  } else if (previous.terminal) {
1410
1557
  continue;
1411
1558
  } else if (description && description !== previous.description) {
@@ -1420,6 +1567,7 @@ export class CodexSessionImpl implements AgentSession {
1420
1567
  : null,
1421
1568
  raw,
1422
1569
  }));
1570
+ dispatched = true;
1423
1571
  }
1424
1572
 
1425
1573
  if (state.terminal) {
@@ -1432,10 +1580,10 @@ export class CodexSessionImpl implements AgentSession {
1432
1580
  : null,
1433
1581
  raw,
1434
1582
  }));
1435
- } else {
1436
- this.startBackgroundTaskPoller(taskId);
1583
+ dispatched = true;
1437
1584
  }
1438
1585
  }
1586
+ return dispatched;
1439
1587
  }
1440
1588
 
1441
1589
  private waitForBackgroundTaskPoll(pollKey: string): Promise<void> {
@@ -1591,18 +1739,38 @@ export class CodexSessionImpl implements AgentSession {
1591
1739
  event.summary = summary;
1592
1740
  event.parentTaskId = parentTaskId;
1593
1741
 
1742
+ const terminal = event.phase === "completed";
1594
1743
  this._backgroundTasks.set(event.taskId, {
1595
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.
1596
1751
  description,
1597
- summary,
1752
+ summary: terminal ? null : summary,
1598
1753
  parentTaskId,
1599
- terminal: event.phase === "completed",
1754
+ terminal,
1600
1755
  generation: previous?.generation ?? 0,
1601
- activeTurnId: event.phase === "completed"
1602
- ? null
1603
- : event.turnId ?? previous?.activeTurnId ?? null,
1756
+ activeTurnId: terminal ? null : event.turnId ?? previous?.activeTurnId ?? null,
1604
1757
  });
1758
+ // Kept past termination too: a grandchild can spawn after its parent has
1759
+ // finished, and lineage resolves through this index.
1605
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));
1606
1774
  return true;
1607
1775
  }
1608
1776
 
@@ -1625,7 +1793,23 @@ export class CodexSessionImpl implements AgentSession {
1625
1793
  // App-server can publish child thread/started before the corresponding
1626
1794
  // root subAgentActivity item. Register only descendants of this session,
1627
1795
  // not unrelated foreign threads multiplexed by a future server version.
1628
- 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
+ }
1629
1813
 
1630
1814
  const source = asObj(thread, "source");
1631
1815
  const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
@@ -1661,7 +1845,6 @@ export class CodexSessionImpl implements AgentSession {
1661
1845
  parentToolCallId: null,
1662
1846
  raw: parseJson(rawLine) ?? params,
1663
1847
  });
1664
- this.startBackgroundTaskPoller(childThreadId);
1665
1848
  return;
1666
1849
  }
1667
1850
 
@@ -1696,7 +1879,6 @@ export class CodexSessionImpl implements AgentSession {
1696
1879
  parentToolCallId: null,
1697
1880
  raw: parseJson(rawLine) ?? params,
1698
1881
  });
1699
- this.startBackgroundTaskPoller(childThreadId);
1700
1882
  return;
1701
1883
  }
1702
1884
 
@@ -1807,7 +1989,13 @@ export class CodexSessionImpl implements AgentSession {
1807
1989
  this.extractSummaryFromItem(params);
1808
1990
  const item = asObj(params, "item");
1809
1991
  if (str(item, "type") === "collabAgentToolCall") {
1810
- this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null);
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
+ }
1811
1999
  return;
1812
2000
  }
1813
2001
  this.emitStreamEvent(rawLine);
@@ -1994,11 +2182,37 @@ export class CodexSessionImpl implements AgentSession {
1994
2182
  }
1995
2183
 
1996
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;
1997
2189
  const resolvedModel = this._turnModel ?? this.model;
1998
2190
  let usage = this._turnUsage && resolvedModel
1999
2191
  ? { [resolvedModel]: { inputTokens: this._turnUsage.inputTokens, outputTokens: this._turnUsage.outputTokens } }
2000
2192
  : undefined;
2001
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
+
2002
2216
  // Usage precedence: the `turn.completed` payload is authoritative when
2003
2217
  // present (captured above into _turnUsage). Only when the stream carried no
2004
2218
  // usage do we fall back to scanning Codex's on-disk session logs.
@@ -2008,8 +2222,8 @@ export class CodexSessionImpl implements AgentSession {
2008
2222
  // latest turn or read partially-written totals. We therefore scan ONLY when
2009
2223
  // there is no in-band usage, and never let a scan failure fail the turn
2010
2224
  // (usage simply stays undefined). Prefer the in-band payload always.
2011
- if (!usage && this._turnStartedAt) {
2012
- const startedAt = this._turnStartedAt;
2225
+ if (!usage && turnStartedAt) {
2226
+ const startedAt = turnStartedAt;
2013
2227
  const threadId = this._threadId ?? undefined;
2014
2228
  // Fire-and-forget: scan logs then deliver result
2015
2229
  void scanCodexSessionUsage({ startedAfter: startedAt, threadId }).then((scanned) => {
@@ -2017,22 +2231,26 @@ export class CodexSessionImpl implements AgentSession {
2017
2231
  }).catch(() => {
2018
2232
  // Non-fatal — usage stays undefined
2019
2233
  }).finally(() => {
2020
- void this.deliverTurnResult(usage);
2234
+ void this.deliverTurnResult(usage, delivering, outcome);
2021
2235
  });
2022
2236
  return;
2023
2237
  }
2024
2238
 
2025
- void this.deliverTurnResult(usage);
2239
+ void this.deliverTurnResult(usage, delivering, outcome);
2026
2240
  }
2027
2241
 
2028
- 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> {
2029
2247
  const result: TurnResult = {
2030
- summary: this._turnSummary,
2248
+ summary: outcome.summary,
2031
2249
  usage,
2032
2250
  costUsd: null,
2033
- status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
2034
- errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
2035
- 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,
2036
2254
  };
2037
2255
 
2038
2256
  // Drain pending onEvent handlers so callers awaiting send() see a settled
@@ -2048,22 +2266,29 @@ export class CodexSessionImpl implements AgentSession {
2048
2266
  // session whose stdin is dead.
2049
2267
  if (this._state === "closed") return;
2050
2268
 
2051
- this._state = "idle";
2052
-
2053
- // Drain ALL pending send() resolvers with this turn's result. Multiple
2054
- // concurrent sends coalesced into one turn share the same TurnResult.
2055
- const pending = this._pendingResults.splice(0);
2056
-
2057
- // Clear per-turn accumulators so a subsequent turn doesn't inherit
2058
- // stale summary / usage / model.
2059
- this._turnSummary = null;
2060
- this._turnUsage = null;
2061
- this._turnModel = null;
2062
- this._turnIsError = false;
2063
- this._turnWasInterrupted = false;
2064
- this._turnErrorMessage = null;
2065
- this._turnStartedAt = null;
2066
- 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
+ }
2067
2292
 
2068
2293
  for (const p of pending) {
2069
2294
  // Skip sends already settled early by timeout / abort.
@@ -2084,8 +2309,9 @@ export class CodexSessionImpl implements AgentSession {
2084
2309
  // Pass current threadId so NDJSON-shaped events (via codex/event wrapper)
2085
2310
  // carry sessionId. v2 notifications parse threadId from params directly
2086
2311
  // and ignore this arg.
2087
- const event = parseCodexStreamLine(rawLine, this._threadId);
2088
- if (event) this.dispatchEvent(event);
2312
+ for (const event of parseCodexStreamLines(rawLine, this._threadId)) {
2313
+ this.dispatchEvent(event);
2314
+ }
2089
2315
  }
2090
2316
 
2091
2317
  /**
@@ -2,6 +2,7 @@ import type { AgentMode, ListModelsOptions, ListModesOptions, ProviderModel } fr
2
2
  import { buildEnv, ensurePathInEnv } from "../../utils/env.js";
3
3
  import { runChildProcess } from "../../utils/process.js";
4
4
  import { findCursorBinary } from "./runtime.js";
5
+ import { withModelCache } from "../../utils/model-cache.js";
5
6
 
6
7
  function rec(value: unknown): Record<string, unknown> {
7
8
  return value && typeof value === "object" && !Array.isArray(value)
@@ -71,6 +72,10 @@ export function cursorModesFromHelp(output: string): AgentMode[] {
71
72
  }
72
73
 
73
74
  export async function listCursorModels(options: ListModelsOptions = {}): Promise<ProviderModel[]> {
75
+ return withModelCache("cursor", options, options.cacheTtlMs, () => discoverCursorModels(options));
76
+ }
77
+
78
+ async function discoverCursorModels(options: ListModelsOptions): Promise<ProviderModel[]> {
74
79
  const resolved = await findCursorBinary(options);
75
80
  const env = buildEnv(options.env);
76
81
  ensurePathInEnv(env);