@agentex/agent 0.0.31 → 0.0.33

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 (40) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +30 -5
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/providers/claude/index.d.ts.map +1 -1
  7. package/dist/providers/claude/index.js +1 -0
  8. package/dist/providers/claude/index.js.map +1 -1
  9. package/dist/providers/claude/parse.d.ts +4 -6
  10. package/dist/providers/claude/parse.d.ts.map +1 -1
  11. package/dist/providers/claude/parse.js +79 -25
  12. package/dist/providers/claude/parse.js.map +1 -1
  13. package/dist/providers/codex/history.d.ts.map +1 -1
  14. package/dist/providers/codex/history.js +3 -1
  15. package/dist/providers/codex/history.js.map +1 -1
  16. package/dist/providers/codex/index.d.ts.map +1 -1
  17. package/dist/providers/codex/index.js +1 -0
  18. package/dist/providers/codex/index.js.map +1 -1
  19. package/dist/providers/codex/parse.d.ts.map +1 -1
  20. package/dist/providers/codex/parse.js +37 -5
  21. package/dist/providers/codex/parse.js.map +1 -1
  22. package/dist/providers/codex/session.d.ts +56 -1
  23. package/dist/providers/codex/session.d.ts.map +1 -1
  24. package/dist/providers/codex/session.js +400 -18
  25. package/dist/providers/codex/session.js.map +1 -1
  26. package/dist/providers/codex/transcript-normalize.js +10 -1
  27. package/dist/providers/codex/transcript-normalize.js.map +1 -1
  28. package/dist/types.d.ts +35 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/types.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/index.ts +3 -0
  33. package/src/providers/claude/index.ts +1 -0
  34. package/src/providers/claude/parse.ts +81 -23
  35. package/src/providers/codex/history.ts +3 -1
  36. package/src/providers/codex/index.ts +1 -0
  37. package/src/providers/codex/parse.ts +38 -5
  38. package/src/providers/codex/session.ts +431 -18
  39. package/src/providers/codex/transcript-normalize.ts +11 -1
  40. package/src/types.ts +48 -1
@@ -244,9 +244,16 @@ export class CodexSessionImpl {
244
244
  model;
245
245
  instructions;
246
246
  _state = "idle";
247
+ /**
248
+ * The root thread represented by this AgentSession. Codex app-server also
249
+ * reports child-agent threads on the same stdout connection, so this id is
250
+ * pinned once discovered and must never be promoted to a child thread.
251
+ */
247
252
  _threadId = null;
248
253
  /** Thread id to resume (from ctx.sessionParams); null starts a fresh thread. */
249
254
  _resumeThreadId;
255
+ /** Expected root during handshake, cleared when resume falls back to fresh. */
256
+ _expectedThreadId;
250
257
  _lineBuffer = "";
251
258
  _nextId = 1;
252
259
  // Pending outgoing RPC responses (keyed by request id)
@@ -264,6 +271,18 @@ export class CodexSessionImpl {
264
271
  _draining = false;
265
272
  /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
266
273
  _drainPromise = null;
274
+ /**
275
+ * Root turn targeted by `interrupt()`. The id is learned asynchronously from
276
+ * the leader `turn/start` response or the root `turn/started` notification.
277
+ * Concurrent sends reuse this latch so a queued response cannot replace the
278
+ * actual active turn.
279
+ */
280
+ _activeTurnId = null;
281
+ _activeTurnReady = null;
282
+ /** Successful repeated interrupts coalesce until the terminal notification. */
283
+ _interruptPromise = null;
284
+ /** Prevents a late interrupt request after the terminal frame was observed. */
285
+ _turnTerminalObserved = false;
267
286
  /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
268
287
  _trackToolName = createToolNameTracker();
269
288
  // Per-turn accumulators. Cleared after each result delivery so a subsequent
@@ -272,8 +291,12 @@ export class CodexSessionImpl {
272
291
  _turnUsage = null;
273
292
  _turnModel = null;
274
293
  _turnIsError = false;
294
+ _turnWasInterrupted = false;
275
295
  _turnErrorMessage = null;
276
296
  _turnStartedAt = null;
297
+ /** Child-agent lifecycle is informational and never participates in root turn settlement. */
298
+ _backgroundTasks = new Map();
299
+ _backgroundTaskIdsByPath = new Map();
277
300
  /**
278
301
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
279
302
  * handler invocation; the chain enforces in-order delivery and lets
@@ -290,6 +313,7 @@ export class CodexSessionImpl {
290
313
  this.model = model;
291
314
  this.instructions = instructions;
292
315
  this._resumeThreadId = readCodexResumeId(ctx.sessionParams);
316
+ this._expectedThreadId = this._resumeThreadId;
293
317
  this._goals = new GoalController({
294
318
  providerType: "codex",
295
319
  capability: codexGoalCapability,
@@ -364,9 +388,75 @@ export class CodexSessionImpl {
364
388
  for (const [, p] of this._pendingRpc)
365
389
  p.reject(err);
366
390
  this._pendingRpc.clear();
391
+ this.clearActiveTurn(err);
367
392
  }
368
393
  get sessionId() { return this._threadId; }
369
394
  get state() { return this._state; }
395
+ /** Start the identity latch before writing the leader `turn/start` request. */
396
+ beginActiveTurn() {
397
+ let resolveFn;
398
+ let rejectFn;
399
+ const promise = new Promise((resolve, reject) => {
400
+ resolveFn = resolve;
401
+ rejectFn = reject;
402
+ });
403
+ // A turn can finish without anyone calling interrupt(). Keep a later
404
+ // process-exit rejection from becoming an unhandled promise rejection.
405
+ void promise.catch(() => { });
406
+ const ready = {
407
+ promise,
408
+ resolve: resolveFn,
409
+ reject: rejectFn,
410
+ settled: false,
411
+ };
412
+ this._activeTurnId = null;
413
+ this._activeTurnReady = ready;
414
+ this._interruptPromise = null;
415
+ this._turnTerminalObserved = false;
416
+ this._turnWasInterrupted = false;
417
+ return ready;
418
+ }
419
+ /** First root turn id wins for the current latch. */
420
+ captureActiveTurnId(turnId, expected) {
421
+ const ready = this._activeTurnReady;
422
+ if (!turnId || !ready || ready.settled)
423
+ return;
424
+ if (expected && ready !== expected)
425
+ return;
426
+ this._activeTurnId = turnId;
427
+ ready.settled = true;
428
+ ready.resolve(turnId);
429
+ }
430
+ rejectActiveTurnReady(err, expected) {
431
+ if (this._activeTurnReady !== expected || expected.settled)
432
+ return;
433
+ expected.settled = true;
434
+ expected.reject(err);
435
+ }
436
+ /** Clear the current turn and release an interrupt waiting for its id. */
437
+ clearActiveTurn(err) {
438
+ const ready = this._activeTurnReady;
439
+ if (ready && !ready.settled) {
440
+ ready.settled = true;
441
+ if (err)
442
+ ready.reject(err);
443
+ else
444
+ ready.resolve(null);
445
+ }
446
+ this._activeTurnId = null;
447
+ this._activeTurnReady = null;
448
+ this._interruptPromise = null;
449
+ this._turnTerminalObserved = false;
450
+ }
451
+ /** Mark root turn termination and release an interrupt still awaiting its id. */
452
+ markTurnTerminalObserved() {
453
+ this._turnTerminalObserved = true;
454
+ const ready = this._activeTurnReady;
455
+ if (ready && !ready.settled) {
456
+ ready.settled = true;
457
+ ready.resolve(null);
458
+ }
459
+ }
370
460
  /**
371
461
  * Durable identity for persistence + later `attachSession`. Null until Codex
372
462
  * has assigned a thread id; serializes `{sessionId, cwd}` through the codec so
@@ -448,6 +538,7 @@ export class CodexSessionImpl {
448
538
  // thread/resume may echo the thread back or return {}; fall back to the
449
539
  // id we resumed with so `sessionId` is always populated.
450
540
  this._threadId = str(thread, "id") || str(thread, "sessionId") || this._resumeThreadId;
541
+ this._expectedThreadId = this._threadId;
451
542
  // Rehydrate a durable Codex goal so getGoal() reflects it immediately
452
543
  // (goals live in SQLite, not the transcript, so a resumed thread would
453
544
  // otherwise report null until the next goal notification).
@@ -455,6 +546,12 @@ export class CodexSessionImpl {
455
546
  return;
456
547
  }
457
548
  catch (err) {
549
+ // A failed resume can emit thread/started before its error response.
550
+ // Clear that provisional identity so the fresh thread's init event is
551
+ // accepted instead of being mistaken for a foreign child thread.
552
+ if (this._threadId === this._expectedThreadId)
553
+ this._threadId = null;
554
+ this._expectedThreadId = null;
458
555
  // The thread is unknown to this codex install (different machine, pruned
459
556
  // history). Don't fail the whole session — fall back to a fresh thread
460
557
  // and surface the downgrade on stderr. The new id flows back out via
@@ -502,6 +599,7 @@ export class CodexSessionImpl {
502
599
  // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... }, model, ... }
503
600
  const thread = asObj(res, "thread");
504
601
  this._threadId = str(thread, "id") || str(thread, "sessionId") || null;
602
+ this._expectedThreadId = this._threadId;
505
603
  }
506
604
  // -------------------------------------------------------------------------
507
605
  // Public API
@@ -518,6 +616,9 @@ export class CodexSessionImpl {
518
616
  // and pass through. If the second `turn/start` lands during the first
519
617
  // turn, the per-turn accumulators continue collecting until the result
520
618
  // event fires; the result then drains all pending resolvers.
619
+ const existingTurnReady = this._activeTurnReady;
620
+ const isTurnLeader = existingTurnReady === null;
621
+ const turnReady = existingTurnReady ?? this.beginActiveTurn();
521
622
  if (this._state === "idle") {
522
623
  this._state = "thinking";
523
624
  this._turnStartedAt = new Date();
@@ -555,14 +656,30 @@ export class CodexSessionImpl {
555
656
  // Per-send timeout / abort, falling back to the session-level
556
657
  // ProviderConfig.timeoutSec default when no per-call timeout is given.
557
658
  this.armSendDeadline(entry, options);
558
- this.rpcRequest("turn/start", turnParams).catch(() => {
559
- // Turn-level errors arrive via turn.failed notifications.
560
- });
659
+ const turnStart = this.rpcRequest("turn/start", turnParams);
660
+ if (isTurnLeader) {
661
+ void turnStart.then((response) => {
662
+ const turnId = str(asObj(response, "turn"), "id");
663
+ if (turnId) {
664
+ this.captureActiveTurnId(turnId, turnReady);
665
+ }
666
+ // Some app-server versions may omit the id from the response and send
667
+ // it only in turn/started. Keep the latch open for that notification.
668
+ }).catch((err) => {
669
+ this.rejectActiveTurnReady(err instanceof Error ? err : new Error(String(err)), turnReady);
670
+ // Turn-level failures may also arrive via turn/failed notifications.
671
+ });
672
+ }
673
+ else {
674
+ void turnStart.catch(() => {
675
+ // Turn-level failures arrive via turn/failed notifications.
676
+ });
677
+ }
561
678
  return { uuid, result };
562
679
  }
563
680
  /**
564
681
  * Wire up this send's timeout and/or abort signal. On fire, the active turn
565
- * is cancelled (`turn/cancel`) and the send settles with `timeout` /
682
+ * is interrupted (`turn/interrupt`) and the send settles with `timeout` /
566
683
  * `aborted`. No-op when neither a timeout nor a signal applies.
567
684
  */
568
685
  armSendDeadline(entry, options) {
@@ -605,7 +722,7 @@ export class CodexSessionImpl {
605
722
  this._pendingResults.splice(idx, 1);
606
723
  // Best-effort cancel of the active turn. With concurrent sends this ends
607
724
  // the single shared turn for all of them — see SendOptions JSDoc.
608
- void this.interrupt();
725
+ void this.interrupt().catch(() => { });
609
726
  entry.resolve({
610
727
  summary: null,
611
728
  usage: undefined,
@@ -619,7 +736,7 @@ export class CodexSessionImpl {
619
736
  }
620
737
  async cancel(_uuid) {
621
738
  // Codex's JSON-RPC protocol exposes no per-message cancel — only
622
- // turn-wide `turn/cancel` (which is what `interrupt()` calls).
739
+ // turn-wide `turn/interrupt` (which is what `interrupt()` calls).
623
740
  // capabilities.cancelQueuedMessage is false; this is a documented no-op.
624
741
  return { cancelled: false };
625
742
  }
@@ -673,13 +790,39 @@ export class CodexSessionImpl {
673
790
  }
674
791
  }
675
792
  async interrupt() {
676
- if (this._state === "idle" || this._state === "closed")
793
+ if (this._state === "closed")
794
+ return;
795
+ const ready = this._activeTurnReady;
796
+ if (!ready || this._turnTerminalObserved)
677
797
  return;
798
+ if (this._interruptPromise)
799
+ return this._interruptPromise;
800
+ const threadId = this._threadId;
801
+ if (!threadId) {
802
+ throw new Error("Cannot interrupt Codex turn before the root thread id is known");
803
+ }
678
804
  this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
805
+ const interruptPromise = (async () => {
806
+ const turnId = this._activeTurnId ?? await ready.promise;
807
+ // The turn may have completed while interrupt() was waiting for the
808
+ // leader turn/start response. In that race, completion is the success.
809
+ if (!turnId || this._activeTurnReady !== ready || this._turnTerminalObserved)
810
+ return;
811
+ await this.rpcRequest("turn/interrupt", { threadId, turnId });
812
+ })();
813
+ this._interruptPromise = interruptPromise;
679
814
  try {
680
- await this.rpcRequest("turn/cancel", {});
815
+ await interruptPromise;
816
+ }
817
+ catch (err) {
818
+ // A rejected control request must reach the host instead of becoming a
819
+ // false successful Stop. Clear only this turn's failed attempt so a
820
+ // subsequent click can retry.
821
+ if (this._activeTurnReady === ready && this._interruptPromise === interruptPromise) {
822
+ this._interruptPromise = null;
823
+ }
824
+ throw err;
681
825
  }
682
- catch { /* best effort */ }
683
826
  }
684
827
  async drain() {
685
828
  if (this._state === "closed")
@@ -700,6 +843,7 @@ export class CodexSessionImpl {
700
843
  if (this._state === "closed")
701
844
  return;
702
845
  this._state = "closed";
846
+ this.rejectAllPending(new Error("Codex session closed"));
703
847
  this.proc.stdin.end();
704
848
  // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
705
849
  // for sessions running long tools.
@@ -863,6 +1007,207 @@ export class CodexSessionImpl {
863
1007
  // -------------------------------------------------------------------------
864
1008
  // Notification handling (v2 format)
865
1009
  // -------------------------------------------------------------------------
1010
+ /** Extract the thread scope carried by a v2 app-server notification. */
1011
+ notificationThreadId(params) {
1012
+ const thread = asObj(params, "thread");
1013
+ return str(params, "threadId") || str(thread, "id") || str(thread, "sessionId") || null;
1014
+ }
1015
+ /**
1016
+ * Whether an explicitly-scoped event belongs to another app-server thread.
1017
+ * `_expectedThreadId` protects the resume handshake window before `_threadId`
1018
+ * has been populated and is cleared if resume falls back to a fresh thread.
1019
+ * Unscoped global notifications remain eligible.
1020
+ */
1021
+ isForeignThread(threadId) {
1022
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1023
+ return !!threadId && !!rootThreadId && threadId !== rootThreadId;
1024
+ }
1025
+ backgroundTaskParentIdForPath(agentPath) {
1026
+ if (!agentPath)
1027
+ return null;
1028
+ const separator = agentPath.lastIndexOf("/");
1029
+ if (separator <= 0)
1030
+ return null;
1031
+ return this._backgroundTaskIdsByPath.get(agentPath.slice(0, separator)) ?? null;
1032
+ }
1033
+ agentMessageText(item) {
1034
+ const direct = str(item, "text");
1035
+ if (direct)
1036
+ return direct;
1037
+ const content = Array.isArray(item["content"]) ? item["content"] : [];
1038
+ for (const entry of content) {
1039
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
1040
+ continue;
1041
+ const block = entry;
1042
+ const text = str(block, "text");
1043
+ if (text && (str(block, "type") === "output_text" || str(block, "type") === "text")) {
1044
+ return text;
1045
+ }
1046
+ }
1047
+ return null;
1048
+ }
1049
+ /**
1050
+ * Maintain just enough child metadata to turn a later foreign-thread
1051
+ * terminal notification into one provider-neutral task event. This reducer
1052
+ * is deliberately separate from every root turn accumulator.
1053
+ */
1054
+ observeBackgroundTask(event) {
1055
+ const previous = this._backgroundTasks.get(event.taskId);
1056
+ // Codex reports `subAgentActivity:interacted` after it forwards a child's
1057
+ // final answer to the parent. The authoritative child turn/completed can
1058
+ // arrive first, so suppress that late progress edge instead of resurrecting
1059
+ // a task that already reached a terminal state. A later child turn/started
1060
+ // explicitly reactivates the record below in handleForeignNotification.
1061
+ if (previous?.terminal)
1062
+ return false;
1063
+ const description = event.description ?? previous?.description ?? null;
1064
+ const summary = event.summary ?? previous?.summary ?? null;
1065
+ const parentTaskId = event.parentTaskId
1066
+ ?? previous?.parentTaskId
1067
+ ?? this.backgroundTaskParentIdForPath(description);
1068
+ event.description = description;
1069
+ event.summary = summary;
1070
+ event.parentTaskId = parentTaskId;
1071
+ this._backgroundTasks.set(event.taskId, {
1072
+ taskId: event.taskId,
1073
+ description,
1074
+ summary,
1075
+ parentTaskId,
1076
+ terminal: event.phase === "completed",
1077
+ });
1078
+ if (description)
1079
+ this._backgroundTaskIdsByPath.set(description, event.taskId);
1080
+ return true;
1081
+ }
1082
+ /**
1083
+ * A Codex app-server connection also publishes child thread notifications.
1084
+ * They are useful only as background-task metadata. They must never flow
1085
+ * through root state, summary, usage, or `resolveTurn()`.
1086
+ */
1087
+ handleForeignNotification(method, params, rawLine, childThreadId) {
1088
+ if (method === "thread/started") {
1089
+ const thread = asObj(params, "thread");
1090
+ const parentThreadId = str(thread, "parentThreadId") || str(thread, "parent_thread_id");
1091
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1092
+ const parentTask = this._backgroundTasks.get(parentThreadId);
1093
+ // App-server can publish child thread/started before the corresponding
1094
+ // root subAgentActivity item. Register only descendants of this session,
1095
+ // not unrelated foreign threads multiplexed by a future server version.
1096
+ if (parentThreadId !== rootThreadId && !parentTask)
1097
+ return;
1098
+ const source = asObj(thread, "source");
1099
+ const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1100
+ ? asObj(source, "subAgent")
1101
+ : asObj(source, "subagent");
1102
+ const spawnSource = Object.keys(asObj(subAgent, "threadSpawn")).length > 0
1103
+ ? asObj(subAgent, "threadSpawn")
1104
+ : asObj(subAgent, "thread_spawn");
1105
+ const description = str(spawnSource, "agentPath")
1106
+ || str(spawnSource, "agent_path")
1107
+ || str(thread, "name")
1108
+ || str(thread, "agentNickname")
1109
+ || str(thread, "agentRole")
1110
+ || null;
1111
+ this.dispatchEvent({
1112
+ type: "background_task",
1113
+ taskId: childThreadId,
1114
+ taskType: "subagent",
1115
+ phase: "started",
1116
+ status: "running",
1117
+ description,
1118
+ summary: null,
1119
+ parentTaskId: parentThreadId === rootThreadId ? null : parentThreadId,
1120
+ timestamp: new Date().toISOString(),
1121
+ providerType: "codex",
1122
+ sessionId: rootThreadId,
1123
+ messageId: null,
1124
+ eventId: rootThreadId
1125
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:started`
1126
+ : null,
1127
+ turnId: null,
1128
+ parentToolCallId: null,
1129
+ raw: parseJson(rawLine) ?? params,
1130
+ });
1131
+ return;
1132
+ }
1133
+ const task = this._backgroundTasks.get(childThreadId);
1134
+ if (!task)
1135
+ return;
1136
+ if (method === "turn/started") {
1137
+ if (!task.terminal)
1138
+ return;
1139
+ task.terminal = false;
1140
+ task.summary = null;
1141
+ const turn = asObj(params, "turn");
1142
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1143
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1144
+ this.dispatchEvent({
1145
+ type: "background_task",
1146
+ taskId: childThreadId,
1147
+ taskType: "subagent",
1148
+ phase: "progress",
1149
+ status: "running",
1150
+ description: task.description,
1151
+ summary: null,
1152
+ parentTaskId: task.parentTaskId,
1153
+ timestamp: new Date().toISOString(),
1154
+ providerType: "codex",
1155
+ sessionId: rootThreadId,
1156
+ messageId: null,
1157
+ eventId: rootThreadId && turnId
1158
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:progress`
1159
+ : null,
1160
+ turnId,
1161
+ parentToolCallId: null,
1162
+ raw: parseJson(rawLine) ?? params,
1163
+ });
1164
+ return;
1165
+ }
1166
+ if (method === "item/completed") {
1167
+ const item = asObj(params, "item");
1168
+ const itemType = str(item, "type");
1169
+ if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1170
+ const summary = this.agentMessageText(item);
1171
+ if (summary)
1172
+ task.summary = summary;
1173
+ }
1174
+ return;
1175
+ }
1176
+ if (method !== "turn/completed" && method !== "turn/failed")
1177
+ return;
1178
+ const turn = asObj(params, "turn");
1179
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1180
+ const nativeStatus = method === "turn/failed" ? "failed" : str(turn, "status");
1181
+ const status = nativeStatus === "failed"
1182
+ ? "failed"
1183
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1184
+ ? "stopped"
1185
+ : "completed";
1186
+ const errorMessage = str(asObj(turn, "error"), "message")
1187
+ || str(params, "message")
1188
+ || str(params, "error");
1189
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1190
+ this.dispatchEvent({
1191
+ type: "background_task",
1192
+ taskId: childThreadId,
1193
+ taskType: "subagent",
1194
+ phase: "completed",
1195
+ status,
1196
+ description: task.description,
1197
+ summary: task.summary ?? (errorMessage || null),
1198
+ parentTaskId: task.parentTaskId,
1199
+ timestamp: new Date().toISOString(),
1200
+ providerType: "codex",
1201
+ sessionId: rootThreadId,
1202
+ messageId: null,
1203
+ eventId: rootThreadId && turnId
1204
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:completed`
1205
+ : null,
1206
+ turnId,
1207
+ parentToolCallId: null,
1208
+ raw: parseJson(rawLine) ?? params,
1209
+ });
1210
+ }
866
1211
  handleNotification(method, params, rawLine) {
867
1212
  // codex/event — legacy wrapper
868
1213
  if (method === "codex/event") {
@@ -875,11 +1220,26 @@ export class CodexSessionImpl {
875
1220
  }
876
1221
  return;
877
1222
  }
1223
+ // One Codex app-server connection multiplexes notifications for the root
1224
+ // thread and any child agents it spawns. An AgentSession represents only
1225
+ // its root thread, so foreign items must not change root state/summary and,
1226
+ // most importantly, a child turn/completed must not resolve the root send.
1227
+ const notificationThreadId = this.notificationThreadId(params);
1228
+ if (this.isForeignThread(notificationThreadId)) {
1229
+ this.handleForeignNotification(method, params, rawLine, notificationThreadId);
1230
+ return;
1231
+ }
878
1232
  // Map v2 notification methods to processing
879
1233
  if (method === "thread/started") {
880
1234
  // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... } }
881
- const thread = asObj(params, "thread");
882
- this._threadId = str(thread, "id") || str(thread, "sessionId") || this._threadId;
1235
+ if (!this._threadId)
1236
+ this._threadId = notificationThreadId;
1237
+ this.emitStreamEvent(rawLine);
1238
+ return;
1239
+ }
1240
+ if (method === "turn/started") {
1241
+ this.captureActiveTurnId(str(asObj(params, "turn"), "id") || str(params, "turnId"));
1242
+ // The parser intentionally suppresses this lifecycle-only event.
883
1243
  this.emitStreamEvent(rawLine);
884
1244
  return;
885
1245
  }
@@ -899,6 +1259,7 @@ export class CodexSessionImpl {
899
1259
  return;
900
1260
  }
901
1261
  if (method === "turn/failed") {
1262
+ this.markTurnTerminalObserved();
902
1263
  this._turnIsError = true;
903
1264
  this._turnErrorMessage = str(params, "message") || str(params, "error") || "Turn failed";
904
1265
  // Emit before resolve so the result event is queued onto _eventChain
@@ -926,8 +1287,14 @@ export class CodexSessionImpl {
926
1287
  // -------------------------------------------------------------------------
927
1288
  handleLegacyEvent(event, rawLine) {
928
1289
  const type = str(event, "type");
1290
+ const eventThreadId = str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
1291
+ // Older NDJSON-shaped events can also carry explicit thread scope. Keep
1292
+ // the same root-only invariant when that scope is available.
1293
+ if (this.isForeignThread(eventThreadId))
1294
+ return;
929
1295
  if (type === "thread.started") {
930
- this._threadId = str(event, "thread_id") || this._threadId;
1296
+ if (!this._threadId)
1297
+ this._threadId = eventThreadId;
931
1298
  this.emitStreamEvent(rawLine);
932
1299
  return;
933
1300
  }
@@ -947,6 +1314,7 @@ export class CodexSessionImpl {
947
1314
  return;
948
1315
  }
949
1316
  if (type === "turn.failed" || type === "error") {
1317
+ this.markTurnTerminalObserved();
950
1318
  this._turnIsError = true;
951
1319
  this._turnErrorMessage = str(event, "message") || str(event, "error") || "Turn failed";
952
1320
  // Emit before resolve so the result event is queued onto _eventChain
@@ -969,6 +1337,11 @@ export class CodexSessionImpl {
969
1337
  const itemType = str(item, "type");
970
1338
  if (itemType !== "agent_message" && itemType !== "agentMessage")
971
1339
  return;
1340
+ // Commentary is progress, not the terminal answer. Keep phase-absent
1341
+ // legacy events as a compatibility fallback, while known final_answer
1342
+ // items remain eligible for TurnResult.summary.
1343
+ if (str(item, "phase") === "commentary")
1344
+ return;
972
1345
  // Direct text (Codex 0.30+)
973
1346
  const directText = str(item, "text");
974
1347
  if (directText) {
@@ -989,6 +1362,7 @@ export class CodexSessionImpl {
989
1362
  }
990
1363
  }
991
1364
  handleTurnCompleted(params) {
1365
+ this.markTurnTerminalObserved();
992
1366
  const usage = typeof params["usage"] === "object" && params["usage"] !== null
993
1367
  ? params["usage"]
994
1368
  : null;
@@ -1008,7 +1382,10 @@ export class CodexSessionImpl {
1008
1382
  // the error instead of a false "completed".
1009
1383
  const turn = asObj(params, "turn");
1010
1384
  const turnStatus = str(turn, "status");
1011
- if (turnStatus === "failed" || turnStatus === "cancelled") {
1385
+ if (turnStatus === "interrupted" || turnStatus === "cancelled") {
1386
+ this._turnWasInterrupted = true;
1387
+ }
1388
+ else if (turnStatus === "failed") {
1012
1389
  this._turnIsError = true;
1013
1390
  const msg = str(asObj(turn, "error"), "message");
1014
1391
  this._turnErrorMessage = msg || this._turnErrorMessage || `Turn ${turnStatus}`;
@@ -1074,9 +1451,9 @@ export class CodexSessionImpl {
1074
1451
  summary: this._turnSummary,
1075
1452
  usage,
1076
1453
  costUsd: null,
1077
- status: this._turnIsError ? "failed" : "completed",
1078
- errorCode: this._turnIsError ? "execution_error" : null,
1079
- errorMessage: this._turnErrorMessage,
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,
1080
1457
  };
1081
1458
  // Drain pending onEvent handlers so callers awaiting send() see a settled
1082
1459
  // DB / log / UI state by the time TurnResult resolves. The chain snapshot
@@ -1100,8 +1477,10 @@ export class CodexSessionImpl {
1100
1477
  this._turnUsage = null;
1101
1478
  this._turnModel = null;
1102
1479
  this._turnIsError = false;
1480
+ this._turnWasInterrupted = false;
1103
1481
  this._turnErrorMessage = null;
1104
1482
  this._turnStartedAt = null;
1483
+ this.clearActiveTurn();
1105
1484
  for (const p of pending) {
1106
1485
  // Skip sends already settled early by timeout / abort.
1107
1486
  if (p.settled)
@@ -1132,6 +1511,8 @@ export class CodexSessionImpl {
1132
1511
  * throwing handler does not break delivery of subsequent events.
1133
1512
  */
1134
1513
  dispatchEvent(event) {
1514
+ if (event.type === "background_task" && !this.observeBackgroundTask(event))
1515
+ return;
1135
1516
  // Track native goal_status transitions (keeps getGoal() accurate).
1136
1517
  this._goals.observe(event);
1137
1518
  const cb = this.ctx.onEvent;
@@ -1146,8 +1527,9 @@ export class CodexSessionImpl {
1146
1527
  // share an id; the last write wins. It also does NOT match the transcript
1147
1528
  // reader's `codex:<sessionId>:<offset>` scheme (different wire vocabulary
1148
1529
  // on disk) — cross-shape dedup remains a host concern.
1149
- if (!event.eventId && this._threadId && event.turnId && event.messageId) {
1150
- event.eventId = `codex:${this._threadId}:${event.turnId}:${event.messageId}:${event.type}`;
1530
+ const eventThreadId = event.sessionId ?? this._threadId;
1531
+ if (!event.eventId && eventThreadId && event.turnId && event.messageId) {
1532
+ event.eventId = `codex:${eventThreadId}:${event.turnId}:${event.messageId}:${event.type}`;
1151
1533
  }
1152
1534
  // Enrich synchronously (in stream order) so tool_result events carry the
1153
1535
  // name of the tool_call they answer.