@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
@@ -132,6 +132,22 @@ interface PendingResult {
132
132
  cleanup?: () => void;
133
133
  }
134
134
 
135
+ interface TrackedBackgroundTask {
136
+ taskId: string;
137
+ description: string | null;
138
+ summary: string | null;
139
+ parentTaskId: string | null;
140
+ terminal: boolean;
141
+ }
142
+
143
+ /** Identity latch for the root turn currently represented by this session. */
144
+ interface ActiveTurnReady {
145
+ promise: Promise<string | null>;
146
+ resolve: (turnId: string | null) => void;
147
+ reject: (err: Error) => void;
148
+ settled: boolean;
149
+ }
150
+
135
151
  // ---------------------------------------------------------------------------
136
152
  // JSON-RPC 2.0 helpers
137
153
  // ---------------------------------------------------------------------------
@@ -307,9 +323,16 @@ export { codexGoalCapability } from "./goal-capability.js";
307
323
 
308
324
  export class CodexSessionImpl implements AgentSession {
309
325
  private _state: SessionState = "idle";
326
+ /**
327
+ * The root thread represented by this AgentSession. Codex app-server also
328
+ * reports child-agent threads on the same stdout connection, so this id is
329
+ * pinned once discovered and must never be promoted to a child thread.
330
+ */
310
331
  private _threadId: string | null = null;
311
332
  /** Thread id to resume (from ctx.sessionParams); null starts a fresh thread. */
312
333
  private readonly _resumeThreadId: string | null;
334
+ /** Expected root during handshake, cleared when resume falls back to fresh. */
335
+ private _expectedThreadId: string | null;
313
336
  private _lineBuffer = "";
314
337
  private _nextId = 1;
315
338
 
@@ -335,6 +358,19 @@ export class CodexSessionImpl implements AgentSession {
335
358
  /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
336
359
  private _drainPromise: Promise<void> | null = null;
337
360
 
361
+ /**
362
+ * Root turn targeted by `interrupt()`. The id is learned asynchronously from
363
+ * the leader `turn/start` response or the root `turn/started` notification.
364
+ * Concurrent sends reuse this latch so a queued response cannot replace the
365
+ * actual active turn.
366
+ */
367
+ private _activeTurnId: string | null = null;
368
+ private _activeTurnReady: ActiveTurnReady | null = null;
369
+ /** Successful repeated interrupts coalesce until the terminal notification. */
370
+ private _interruptPromise: Promise<void> | null = null;
371
+ /** Prevents a late interrupt request after the terminal frame was observed. */
372
+ private _turnTerminalObserved = false;
373
+
338
374
  /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
339
375
  private readonly _trackToolName = createToolNameTracker();
340
376
 
@@ -344,9 +380,14 @@ export class CodexSessionImpl implements AgentSession {
344
380
  private _turnUsage: { inputTokens: number; outputTokens: number } | null = null;
345
381
  private _turnModel: string | null = null;
346
382
  private _turnIsError = false;
383
+ private _turnWasInterrupted = false;
347
384
  private _turnErrorMessage: string | null = null;
348
385
  private _turnStartedAt: Date | null = null;
349
386
 
387
+ /** Child-agent lifecycle is informational and never participates in root turn settlement. */
388
+ private readonly _backgroundTasks = new Map<string, TrackedBackgroundTask>();
389
+ private readonly _backgroundTaskIdsByPath = new Map<string, string>();
390
+
350
391
  /**
351
392
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
352
393
  * handler invocation; the chain enforces in-order delivery and lets
@@ -366,6 +407,7 @@ export class CodexSessionImpl implements AgentSession {
366
407
  private readonly instructions: string | null,
367
408
  ) {
368
409
  this._resumeThreadId = readCodexResumeId(ctx.sessionParams);
410
+ this._expectedThreadId = this._resumeThreadId;
369
411
 
370
412
  this._goals = new GoalController({
371
413
  providerType: "codex",
@@ -438,11 +480,78 @@ export class CodexSessionImpl implements AgentSession {
438
480
  }
439
481
  for (const [, p] of this._pendingRpc) p.reject(err);
440
482
  this._pendingRpc.clear();
483
+ this.clearActiveTurn(err);
441
484
  }
442
485
 
443
486
  get sessionId(): string | null { return this._threadId; }
444
487
  get state(): SessionState { return this._state; }
445
488
 
489
+ /** Start the identity latch before writing the leader `turn/start` request. */
490
+ private beginActiveTurn(): ActiveTurnReady {
491
+ let resolveFn!: (turnId: string | null) => void;
492
+ let rejectFn!: (err: Error) => void;
493
+ const promise = new Promise<string | null>((resolve, reject) => {
494
+ resolveFn = resolve;
495
+ rejectFn = reject;
496
+ });
497
+ // A turn can finish without anyone calling interrupt(). Keep a later
498
+ // process-exit rejection from becoming an unhandled promise rejection.
499
+ void promise.catch(() => {});
500
+
501
+ const ready: ActiveTurnReady = {
502
+ promise,
503
+ resolve: resolveFn,
504
+ reject: rejectFn,
505
+ settled: false,
506
+ };
507
+ this._activeTurnId = null;
508
+ this._activeTurnReady = ready;
509
+ this._interruptPromise = null;
510
+ this._turnTerminalObserved = false;
511
+ this._turnWasInterrupted = false;
512
+ return ready;
513
+ }
514
+
515
+ /** First root turn id wins for the current latch. */
516
+ private captureActiveTurnId(turnId: string, expected?: ActiveTurnReady): void {
517
+ const ready = this._activeTurnReady;
518
+ if (!turnId || !ready || ready.settled) return;
519
+ if (expected && ready !== expected) return;
520
+ this._activeTurnId = turnId;
521
+ ready.settled = true;
522
+ ready.resolve(turnId);
523
+ }
524
+
525
+ private rejectActiveTurnReady(err: Error, expected: ActiveTurnReady): void {
526
+ if (this._activeTurnReady !== expected || expected.settled) return;
527
+ expected.settled = true;
528
+ expected.reject(err);
529
+ }
530
+
531
+ /** Clear the current turn and release an interrupt waiting for its id. */
532
+ private clearActiveTurn(err?: Error): void {
533
+ const ready = this._activeTurnReady;
534
+ if (ready && !ready.settled) {
535
+ ready.settled = true;
536
+ if (err) ready.reject(err);
537
+ else ready.resolve(null);
538
+ }
539
+ this._activeTurnId = null;
540
+ this._activeTurnReady = null;
541
+ this._interruptPromise = null;
542
+ this._turnTerminalObserved = false;
543
+ }
544
+
545
+ /** Mark root turn termination and release an interrupt still awaiting its id. */
546
+ private markTurnTerminalObserved(): void {
547
+ this._turnTerminalObserved = true;
548
+ const ready = this._activeTurnReady;
549
+ if (ready && !ready.settled) {
550
+ ready.settled = true;
551
+ ready.resolve(null);
552
+ }
553
+ }
554
+
446
555
  /**
447
556
  * Durable identity for persistence + later `attachSession`. Null until Codex
448
557
  * has assigned a thread id; serializes `{sessionId, cwd}` through the codec so
@@ -527,12 +636,18 @@ export class CodexSessionImpl implements AgentSession {
527
636
  // thread/resume may echo the thread back or return {}; fall back to the
528
637
  // id we resumed with so `sessionId` is always populated.
529
638
  this._threadId = str(thread, "id") || str(thread, "sessionId") || this._resumeThreadId;
639
+ this._expectedThreadId = this._threadId;
530
640
  // Rehydrate a durable Codex goal so getGoal() reflects it immediately
531
641
  // (goals live in SQLite, not the transcript, so a resumed thread would
532
642
  // otherwise report null until the next goal notification).
533
643
  await this.hydrateGoalFromThread();
534
644
  return;
535
645
  } catch (err) {
646
+ // A failed resume can emit thread/started before its error response.
647
+ // Clear that provisional identity so the fresh thread's init event is
648
+ // accepted instead of being mistaken for a foreign child thread.
649
+ if (this._threadId === this._expectedThreadId) this._threadId = null;
650
+ this._expectedThreadId = null;
536
651
  // The thread is unknown to this codex install (different machine, pruned
537
652
  // history). Don't fail the whole session — fall back to a fresh thread
538
653
  // and surface the downgrade on stderr. The new id flows back out via
@@ -586,6 +701,7 @@ export class CodexSessionImpl implements AgentSession {
586
701
  // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... }, model, ... }
587
702
  const thread = asObj(res, "thread");
588
703
  this._threadId = str(thread, "id") || str(thread, "sessionId") || null;
704
+ this._expectedThreadId = this._threadId;
589
705
  }
590
706
 
591
707
  // -------------------------------------------------------------------------
@@ -603,6 +719,10 @@ export class CodexSessionImpl implements AgentSession {
603
719
  // and pass through. If the second `turn/start` lands during the first
604
720
  // turn, the per-turn accumulators continue collecting until the result
605
721
  // event fires; the result then drains all pending resolvers.
722
+ const existingTurnReady = this._activeTurnReady;
723
+ const isTurnLeader = existingTurnReady === null;
724
+ const turnReady = existingTurnReady ?? this.beginActiveTurn();
725
+
606
726
  if (this._state === "idle") {
607
727
  this._state = "thinking";
608
728
  this._turnStartedAt = new Date();
@@ -644,16 +764,34 @@ export class CodexSessionImpl implements AgentSession {
644
764
  // ProviderConfig.timeoutSec default when no per-call timeout is given.
645
765
  this.armSendDeadline(entry, options);
646
766
 
647
- this.rpcRequest("turn/start", turnParams).catch(() => {
648
- // Turn-level errors arrive via turn.failed notifications.
649
- });
767
+ const turnStart = this.rpcRequest("turn/start", turnParams);
768
+ if (isTurnLeader) {
769
+ void turnStart.then((response) => {
770
+ const turnId = str(asObj(response, "turn"), "id");
771
+ if (turnId) {
772
+ this.captureActiveTurnId(turnId, turnReady);
773
+ }
774
+ // Some app-server versions may omit the id from the response and send
775
+ // it only in turn/started. Keep the latch open for that notification.
776
+ }).catch((err: unknown) => {
777
+ this.rejectActiveTurnReady(
778
+ err instanceof Error ? err : new Error(String(err)),
779
+ turnReady,
780
+ );
781
+ // Turn-level failures may also arrive via turn/failed notifications.
782
+ });
783
+ } else {
784
+ void turnStart.catch(() => {
785
+ // Turn-level failures arrive via turn/failed notifications.
786
+ });
787
+ }
650
788
 
651
789
  return { uuid, result };
652
790
  }
653
791
 
654
792
  /**
655
793
  * Wire up this send's timeout and/or abort signal. On fire, the active turn
656
- * is cancelled (`turn/cancel`) and the send settles with `timeout` /
794
+ * is interrupted (`turn/interrupt`) and the send settles with `timeout` /
657
795
  * `aborted`. No-op when neither a timeout nor a signal applies.
658
796
  */
659
797
  private armSendDeadline(entry: PendingResult, options?: SendOptions): void {
@@ -696,7 +834,7 @@ export class CodexSessionImpl implements AgentSession {
696
834
 
697
835
  // Best-effort cancel of the active turn. With concurrent sends this ends
698
836
  // the single shared turn for all of them — see SendOptions JSDoc.
699
- void this.interrupt();
837
+ void this.interrupt().catch(() => {});
700
838
 
701
839
  entry.resolve({
702
840
  summary: null,
@@ -712,7 +850,7 @@ export class CodexSessionImpl implements AgentSession {
712
850
 
713
851
  async cancel(_uuid: string): Promise<CancelResult> {
714
852
  // Codex's JSON-RPC protocol exposes no per-message cancel — only
715
- // turn-wide `turn/cancel` (which is what `interrupt()` calls).
853
+ // turn-wide `turn/interrupt` (which is what `interrupt()` calls).
716
854
  // capabilities.cancelQueuedMessage is false; this is a documented no-op.
717
855
  return { cancelled: false };
718
856
  }
@@ -765,11 +903,37 @@ export class CodexSessionImpl implements AgentSession {
765
903
  }
766
904
 
767
905
  async interrupt(): Promise<void> {
768
- if (this._state === "idle" || this._state === "closed") return;
906
+ if (this._state === "closed") return;
907
+ const ready = this._activeTurnReady;
908
+ if (!ready || this._turnTerminalObserved) return;
909
+ if (this._interruptPromise) return this._interruptPromise;
910
+
911
+ const threadId = this._threadId;
912
+ if (!threadId) {
913
+ throw new Error("Cannot interrupt Codex turn before the root thread id is known");
914
+ }
769
915
  this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
916
+
917
+ const interruptPromise = (async () => {
918
+ const turnId = this._activeTurnId ?? await ready.promise;
919
+ // The turn may have completed while interrupt() was waiting for the
920
+ // leader turn/start response. In that race, completion is the success.
921
+ if (!turnId || this._activeTurnReady !== ready || this._turnTerminalObserved) return;
922
+ await this.rpcRequest("turn/interrupt", { threadId, turnId });
923
+ })();
924
+ this._interruptPromise = interruptPromise;
925
+
770
926
  try {
771
- await this.rpcRequest("turn/cancel", {});
772
- } catch { /* best effort */ }
927
+ await interruptPromise;
928
+ } catch (err) {
929
+ // A rejected control request must reach the host instead of becoming a
930
+ // false successful Stop. Clear only this turn's failed attempt so a
931
+ // subsequent click can retry.
932
+ if (this._activeTurnReady === ready && this._interruptPromise === interruptPromise) {
933
+ this._interruptPromise = null;
934
+ }
935
+ throw err;
936
+ }
773
937
  }
774
938
 
775
939
  async drain(): Promise<void> {
@@ -789,6 +953,7 @@ export class CodexSessionImpl implements AgentSession {
789
953
  async close(): Promise<void> {
790
954
  if (this._state === "closed") return;
791
955
  this._state = "closed";
956
+ this.rejectAllPending(new Error("Codex session closed"));
792
957
 
793
958
  this.proc.stdin!.end();
794
959
 
@@ -971,6 +1136,218 @@ export class CodexSessionImpl implements AgentSession {
971
1136
  // Notification handling (v2 format)
972
1137
  // -------------------------------------------------------------------------
973
1138
 
1139
+ /** Extract the thread scope carried by a v2 app-server notification. */
1140
+ private notificationThreadId(params: Record<string, unknown>): string | null {
1141
+ const thread = asObj(params, "thread");
1142
+ return str(params, "threadId") || str(thread, "id") || str(thread, "sessionId") || null;
1143
+ }
1144
+
1145
+ /**
1146
+ * Whether an explicitly-scoped event belongs to another app-server thread.
1147
+ * `_expectedThreadId` protects the resume handshake window before `_threadId`
1148
+ * has been populated and is cleared if resume falls back to a fresh thread.
1149
+ * Unscoped global notifications remain eligible.
1150
+ */
1151
+ private isForeignThread(threadId: string | null): boolean {
1152
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1153
+ return !!threadId && !!rootThreadId && threadId !== rootThreadId;
1154
+ }
1155
+
1156
+ private backgroundTaskParentIdForPath(agentPath: string | null): string | null {
1157
+ if (!agentPath) return null;
1158
+ const separator = agentPath.lastIndexOf("/");
1159
+ if (separator <= 0) return null;
1160
+ return this._backgroundTaskIdsByPath.get(agentPath.slice(0, separator)) ?? null;
1161
+ }
1162
+
1163
+ private agentMessageText(item: Record<string, unknown>): string | null {
1164
+ const direct = str(item, "text");
1165
+ if (direct) return direct;
1166
+ const content = Array.isArray(item["content"]) ? item["content"] : [];
1167
+ for (const entry of content) {
1168
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
1169
+ const block = entry as Record<string, unknown>;
1170
+ const text = str(block, "text");
1171
+ if (text && (str(block, "type") === "output_text" || str(block, "type") === "text")) {
1172
+ return text;
1173
+ }
1174
+ }
1175
+ return null;
1176
+ }
1177
+
1178
+ /**
1179
+ * Maintain just enough child metadata to turn a later foreign-thread
1180
+ * terminal notification into one provider-neutral task event. This reducer
1181
+ * is deliberately separate from every root turn accumulator.
1182
+ */
1183
+ private observeBackgroundTask(event: Extract<StreamEvent, { type: "background_task" }>): boolean {
1184
+ const previous = this._backgroundTasks.get(event.taskId);
1185
+ // Codex reports `subAgentActivity:interacted` after it forwards a child's
1186
+ // final answer to the parent. The authoritative child turn/completed can
1187
+ // arrive first, so suppress that late progress edge instead of resurrecting
1188
+ // a task that already reached a terminal state. A later child turn/started
1189
+ // explicitly reactivates the record below in handleForeignNotification.
1190
+ if (previous?.terminal) return false;
1191
+
1192
+ const description = event.description ?? previous?.description ?? null;
1193
+ const summary = event.summary ?? previous?.summary ?? null;
1194
+ const parentTaskId = event.parentTaskId
1195
+ ?? previous?.parentTaskId
1196
+ ?? this.backgroundTaskParentIdForPath(description);
1197
+
1198
+ event.description = description;
1199
+ event.summary = summary;
1200
+ event.parentTaskId = parentTaskId;
1201
+
1202
+ this._backgroundTasks.set(event.taskId, {
1203
+ taskId: event.taskId,
1204
+ description,
1205
+ summary,
1206
+ parentTaskId,
1207
+ terminal: event.phase === "completed",
1208
+ });
1209
+ if (description) this._backgroundTaskIdsByPath.set(description, event.taskId);
1210
+ return true;
1211
+ }
1212
+
1213
+ /**
1214
+ * A Codex app-server connection also publishes child thread notifications.
1215
+ * They are useful only as background-task metadata. They must never flow
1216
+ * through root state, summary, usage, or `resolveTurn()`.
1217
+ */
1218
+ private handleForeignNotification(
1219
+ method: string,
1220
+ params: Record<string, unknown>,
1221
+ rawLine: string,
1222
+ childThreadId: string,
1223
+ ): void {
1224
+ if (method === "thread/started") {
1225
+ const thread = asObj(params, "thread");
1226
+ const parentThreadId = str(thread, "parentThreadId") || str(thread, "parent_thread_id");
1227
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1228
+ const parentTask = this._backgroundTasks.get(parentThreadId);
1229
+ // App-server can publish child thread/started before the corresponding
1230
+ // root subAgentActivity item. Register only descendants of this session,
1231
+ // not unrelated foreign threads multiplexed by a future server version.
1232
+ if (parentThreadId !== rootThreadId && !parentTask) return;
1233
+
1234
+ const source = asObj(thread, "source");
1235
+ const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1236
+ ? asObj(source, "subAgent")
1237
+ : asObj(source, "subagent");
1238
+ const spawnSource = Object.keys(asObj(subAgent, "threadSpawn")).length > 0
1239
+ ? asObj(subAgent, "threadSpawn")
1240
+ : asObj(subAgent, "thread_spawn");
1241
+ const description = str(spawnSource, "agentPath")
1242
+ || str(spawnSource, "agent_path")
1243
+ || str(thread, "name")
1244
+ || str(thread, "agentNickname")
1245
+ || str(thread, "agentRole")
1246
+ || null;
1247
+
1248
+ this.dispatchEvent({
1249
+ type: "background_task",
1250
+ taskId: childThreadId,
1251
+ taskType: "subagent",
1252
+ phase: "started",
1253
+ status: "running",
1254
+ description,
1255
+ summary: null,
1256
+ parentTaskId: parentThreadId === rootThreadId ? null : parentThreadId,
1257
+ timestamp: new Date().toISOString(),
1258
+ providerType: "codex",
1259
+ sessionId: rootThreadId,
1260
+ messageId: null,
1261
+ eventId: rootThreadId
1262
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:started`
1263
+ : null,
1264
+ turnId: null,
1265
+ parentToolCallId: null,
1266
+ raw: parseJson(rawLine) ?? params,
1267
+ });
1268
+ return;
1269
+ }
1270
+
1271
+ const task = this._backgroundTasks.get(childThreadId);
1272
+ if (!task) return;
1273
+
1274
+ if (method === "turn/started") {
1275
+ if (!task.terminal) return;
1276
+ task.terminal = false;
1277
+ task.summary = null;
1278
+ const turn = asObj(params, "turn");
1279
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1280
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1281
+ this.dispatchEvent({
1282
+ type: "background_task",
1283
+ taskId: childThreadId,
1284
+ taskType: "subagent",
1285
+ phase: "progress",
1286
+ status: "running",
1287
+ description: task.description,
1288
+ summary: null,
1289
+ parentTaskId: task.parentTaskId,
1290
+ timestamp: new Date().toISOString(),
1291
+ providerType: "codex",
1292
+ sessionId: rootThreadId,
1293
+ messageId: null,
1294
+ eventId: rootThreadId && turnId
1295
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:progress`
1296
+ : null,
1297
+ turnId,
1298
+ parentToolCallId: null,
1299
+ raw: parseJson(rawLine) ?? params,
1300
+ });
1301
+ return;
1302
+ }
1303
+
1304
+ if (method === "item/completed") {
1305
+ const item = asObj(params, "item");
1306
+ const itemType = str(item, "type");
1307
+ if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1308
+ const summary = this.agentMessageText(item);
1309
+ if (summary) task.summary = summary;
1310
+ }
1311
+ return;
1312
+ }
1313
+
1314
+ if (method !== "turn/completed" && method !== "turn/failed") return;
1315
+
1316
+ const turn = asObj(params, "turn");
1317
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1318
+ const nativeStatus = method === "turn/failed" ? "failed" : str(turn, "status");
1319
+ const status = nativeStatus === "failed"
1320
+ ? "failed"
1321
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1322
+ ? "stopped"
1323
+ : "completed";
1324
+ const errorMessage = str(asObj(turn, "error"), "message")
1325
+ || str(params, "message")
1326
+ || str(params, "error");
1327
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1328
+
1329
+ this.dispatchEvent({
1330
+ type: "background_task",
1331
+ taskId: childThreadId,
1332
+ taskType: "subagent",
1333
+ phase: "completed",
1334
+ status,
1335
+ description: task.description,
1336
+ summary: task.summary ?? (errorMessage || null),
1337
+ parentTaskId: task.parentTaskId,
1338
+ timestamp: new Date().toISOString(),
1339
+ providerType: "codex",
1340
+ sessionId: rootThreadId,
1341
+ messageId: null,
1342
+ eventId: rootThreadId && turnId
1343
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:completed`
1344
+ : null,
1345
+ turnId,
1346
+ parentToolCallId: null,
1347
+ raw: parseJson(rawLine) ?? params,
1348
+ });
1349
+ }
1350
+
974
1351
  private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
975
1352
  // codex/event — legacy wrapper
976
1353
  if (method === "codex/event") {
@@ -983,11 +1360,27 @@ export class CodexSessionImpl implements AgentSession {
983
1360
  return;
984
1361
  }
985
1362
 
1363
+ // One Codex app-server connection multiplexes notifications for the root
1364
+ // thread and any child agents it spawns. An AgentSession represents only
1365
+ // its root thread, so foreign items must not change root state/summary and,
1366
+ // most importantly, a child turn/completed must not resolve the root send.
1367
+ const notificationThreadId = this.notificationThreadId(params);
1368
+ if (this.isForeignThread(notificationThreadId)) {
1369
+ this.handleForeignNotification(method, params, rawLine, notificationThreadId!);
1370
+ return;
1371
+ }
1372
+
986
1373
  // Map v2 notification methods to processing
987
1374
  if (method === "thread/started") {
988
1375
  // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... } }
989
- const thread = asObj(params, "thread");
990
- this._threadId = str(thread, "id") || str(thread, "sessionId") || this._threadId;
1376
+ if (!this._threadId) this._threadId = notificationThreadId;
1377
+ this.emitStreamEvent(rawLine);
1378
+ return;
1379
+ }
1380
+
1381
+ if (method === "turn/started") {
1382
+ this.captureActiveTurnId(str(asObj(params, "turn"), "id") || str(params, "turnId"));
1383
+ // The parser intentionally suppresses this lifecycle-only event.
991
1384
  this.emitStreamEvent(rawLine);
992
1385
  return;
993
1386
  }
@@ -1011,6 +1404,7 @@ export class CodexSessionImpl implements AgentSession {
1011
1404
  }
1012
1405
 
1013
1406
  if (method === "turn/failed") {
1407
+ this.markTurnTerminalObserved();
1014
1408
  this._turnIsError = true;
1015
1409
  this._turnErrorMessage = str(params, "message") || str(params, "error") || "Turn failed";
1016
1410
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1041,9 +1435,15 @@ export class CodexSessionImpl implements AgentSession {
1041
1435
 
1042
1436
  private handleLegacyEvent(event: Record<string, unknown>, rawLine: string): void {
1043
1437
  const type = str(event, "type");
1438
+ const eventThreadId =
1439
+ str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
1440
+
1441
+ // Older NDJSON-shaped events can also carry explicit thread scope. Keep
1442
+ // the same root-only invariant when that scope is available.
1443
+ if (this.isForeignThread(eventThreadId)) return;
1044
1444
 
1045
1445
  if (type === "thread.started") {
1046
- this._threadId = str(event, "thread_id") || this._threadId;
1446
+ if (!this._threadId) this._threadId = eventThreadId;
1047
1447
  this.emitStreamEvent(rawLine);
1048
1448
  return;
1049
1449
  }
@@ -1067,6 +1467,7 @@ export class CodexSessionImpl implements AgentSession {
1067
1467
  }
1068
1468
 
1069
1469
  if (type === "turn.failed" || type === "error") {
1470
+ this.markTurnTerminalObserved();
1070
1471
  this._turnIsError = true;
1071
1472
  this._turnErrorMessage = str(event, "message") || str(event, "error") || "Turn failed";
1072
1473
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1093,6 +1494,11 @@ export class CodexSessionImpl implements AgentSession {
1093
1494
  const itemType = str(item, "type");
1094
1495
  if (itemType !== "agent_message" && itemType !== "agentMessage") return;
1095
1496
 
1497
+ // Commentary is progress, not the terminal answer. Keep phase-absent
1498
+ // legacy events as a compatibility fallback, while known final_answer
1499
+ // items remain eligible for TurnResult.summary.
1500
+ if (str(item, "phase") === "commentary") return;
1501
+
1096
1502
  // Direct text (Codex 0.30+)
1097
1503
  const directText = str(item, "text");
1098
1504
  if (directText) {
@@ -1113,6 +1519,7 @@ export class CodexSessionImpl implements AgentSession {
1113
1519
  }
1114
1520
 
1115
1521
  private handleTurnCompleted(params: Record<string, unknown>): void {
1522
+ this.markTurnTerminalObserved();
1116
1523
  const usage = typeof params["usage"] === "object" && params["usage"] !== null
1117
1524
  ? (params["usage"] as Record<string, unknown>)
1118
1525
  : null;
@@ -1132,7 +1539,9 @@ export class CodexSessionImpl implements AgentSession {
1132
1539
  // the error instead of a false "completed".
1133
1540
  const turn = asObj(params, "turn");
1134
1541
  const turnStatus = str(turn, "status");
1135
- if (turnStatus === "failed" || turnStatus === "cancelled") {
1542
+ if (turnStatus === "interrupted" || turnStatus === "cancelled") {
1543
+ this._turnWasInterrupted = true;
1544
+ } else if (turnStatus === "failed") {
1136
1545
  this._turnIsError = true;
1137
1546
  const msg = str(asObj(turn, "error"), "message");
1138
1547
  this._turnErrorMessage = msg || this._turnErrorMessage || `Turn ${turnStatus}`;
@@ -1204,9 +1613,9 @@ export class CodexSessionImpl implements AgentSession {
1204
1613
  summary: this._turnSummary,
1205
1614
  usage,
1206
1615
  costUsd: null,
1207
- status: this._turnIsError ? "failed" : "completed",
1208
- errorCode: this._turnIsError ? "execution_error" : null,
1209
- errorMessage: this._turnErrorMessage,
1616
+ status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
1617
+ errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
1618
+ errorMessage: this._turnWasInterrupted ? "Turn was interrupted" : this._turnErrorMessage,
1210
1619
  };
1211
1620
 
1212
1621
  // Drain pending onEvent handlers so callers awaiting send() see a settled
@@ -1234,8 +1643,10 @@ export class CodexSessionImpl implements AgentSession {
1234
1643
  this._turnUsage = null;
1235
1644
  this._turnModel = null;
1236
1645
  this._turnIsError = false;
1646
+ this._turnWasInterrupted = false;
1237
1647
  this._turnErrorMessage = null;
1238
1648
  this._turnStartedAt = null;
1649
+ this.clearActiveTurn();
1239
1650
 
1240
1651
  for (const p of pending) {
1241
1652
  // Skip sends already settled early by timeout / abort.
@@ -1267,6 +1678,7 @@ export class CodexSessionImpl implements AgentSession {
1267
1678
  * throwing handler does not break delivery of subsequent events.
1268
1679
  */
1269
1680
  private dispatchEvent(event: StreamEvent): void {
1681
+ if (event.type === "background_task" && !this.observeBackgroundTask(event)) return;
1270
1682
  // Track native goal_status transitions (keeps getGoal() accurate).
1271
1683
  this._goals.observe(event);
1272
1684
  const cb = this.ctx.onEvent;
@@ -1280,8 +1692,9 @@ export class CodexSessionImpl implements AgentSession {
1280
1692
  // share an id; the last write wins. It also does NOT match the transcript
1281
1693
  // reader's `codex:<sessionId>:<offset>` scheme (different wire vocabulary
1282
1694
  // on disk) — cross-shape dedup remains a host concern.
1283
- if (!event.eventId && this._threadId && event.turnId && event.messageId) {
1284
- event.eventId = `codex:${this._threadId}:${event.turnId}:${event.messageId}:${event.type}`;
1695
+ const eventThreadId = event.sessionId ?? this._threadId;
1696
+ if (!event.eventId && eventThreadId && event.turnId && event.messageId) {
1697
+ event.eventId = `codex:${eventThreadId}:${event.turnId}:${event.messageId}:${event.type}`;
1285
1698
  }
1286
1699
  // Enrich synchronously (in stream order) so tool_result events carry the
1287
1700
  // name of the tool_call they answer.
@@ -61,7 +61,13 @@ function mapLine(line: CodexTranscriptLine, sessionId: string | null): StreamEve
61
61
  // Only assistant messages surface; developer/user messages are
62
62
  // system-prompt material we don't replay.
63
63
  if (payload["role"] !== "assistant") return [];
64
- return [{ type: "assistant", text: extractMessageText(payload["content"]) ?? "", ...base }];
64
+ const phase = messagePhase(payload["phase"]);
65
+ return [{
66
+ type: "assistant",
67
+ text: extractMessageText(payload["content"]) ?? "",
68
+ ...(phase ? { phase } : {}),
69
+ ...base,
70
+ }];
65
71
  }
66
72
 
67
73
  if (innerType === "reasoning") {
@@ -129,6 +135,10 @@ function str(v: unknown): string | null {
129
135
  return typeof v === "string" && v.length > 0 ? v : null;
130
136
  }
131
137
 
138
+ function messagePhase(v: unknown): "commentary" | "final_answer" | undefined {
139
+ return v === "commentary" || v === "final_answer" ? v : undefined;
140
+ }
141
+
132
142
  /**
133
143
  * `response_item/message.content` is an array of typed parts (`output_text`
134
144
  * for assistant replies). Concat the text parts with a blank-line separator.