@agentex/agent 0.0.32 → 0.0.34

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.
@@ -132,6 +132,33 @@ 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
+ generation: number;
142
+ activeTurnId: string | null;
143
+ }
144
+
145
+ interface CodexBackgroundTaskState {
146
+ terminal: boolean;
147
+ status: "running" | "completed" | "failed" | "stopped";
148
+ summary: string | null;
149
+ }
150
+
151
+ const BACKGROUND_TASK_POLL_INTERVAL_MS = 2_000;
152
+ const BACKGROUND_TASK_READ_TIMEOUT_MS = 5_000;
153
+
154
+ /** Identity latch for the root turn currently represented by this session. */
155
+ interface ActiveTurnReady {
156
+ promise: Promise<string | null>;
157
+ resolve: (turnId: string | null) => void;
158
+ reject: (err: Error) => void;
159
+ settled: boolean;
160
+ }
161
+
135
162
  // ---------------------------------------------------------------------------
136
163
  // JSON-RPC 2.0 helpers
137
164
  // ---------------------------------------------------------------------------
@@ -163,6 +190,32 @@ function asObj(parent: Record<string, unknown>, key: string): Record<string, unk
163
190
  : {};
164
191
  }
165
192
 
193
+ function obj(value: unknown): Record<string, unknown> {
194
+ return typeof value === "object" && value !== null && !Array.isArray(value)
195
+ ? (value as Record<string, unknown>)
196
+ : {};
197
+ }
198
+
199
+ function stringArray(value: unknown): string[] {
200
+ return Array.isArray(value)
201
+ ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
202
+ : [];
203
+ }
204
+
205
+ function codexAgentState(value: unknown): CodexBackgroundTaskState {
206
+ const state = obj(value);
207
+ const nativeStatus = str(state, "status");
208
+ const summary = str(state, "message") || null;
209
+ if (nativeStatus === "completed") return { terminal: true, status: "completed", summary };
210
+ if (nativeStatus === "errored" || nativeStatus === "notFound") {
211
+ return { terminal: true, status: "failed", summary };
212
+ }
213
+ if (nativeStatus === "interrupted" || nativeStatus === "shutdown") {
214
+ return { terminal: true, status: "stopped", summary };
215
+ }
216
+ return { terminal: false, status: "running", summary };
217
+ }
218
+
166
219
  /** Discriminated incoming message from the Codex CLI. */
167
220
  type IncomingMessage =
168
221
  | { kind: "response"; id: number; result?: Record<string, unknown>; error?: { code: number; message: string } }
@@ -342,6 +395,19 @@ export class CodexSessionImpl implements AgentSession {
342
395
  /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
343
396
  private _drainPromise: Promise<void> | null = null;
344
397
 
398
+ /**
399
+ * Root turn targeted by `interrupt()`. The id is learned asynchronously from
400
+ * the leader `turn/start` response or the root `turn/started` notification.
401
+ * Concurrent sends reuse this latch so a queued response cannot replace the
402
+ * actual active turn.
403
+ */
404
+ private _activeTurnId: string | null = null;
405
+ private _activeTurnReady: ActiveTurnReady | null = null;
406
+ /** Successful repeated interrupts coalesce until the terminal notification. */
407
+ private _interruptPromise: Promise<void> | null = null;
408
+ /** Prevents a late interrupt request after the terminal frame was observed. */
409
+ private _turnTerminalObserved = false;
410
+
345
411
  /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
346
412
  private readonly _trackToolName = createToolNameTracker();
347
413
 
@@ -351,9 +417,24 @@ export class CodexSessionImpl implements AgentSession {
351
417
  private _turnUsage: { inputTokens: number; outputTokens: number } | null = null;
352
418
  private _turnModel: string | null = null;
353
419
  private _turnIsError = false;
420
+ private _turnWasInterrupted = false;
354
421
  private _turnErrorMessage: string | null = null;
355
422
  private _turnStartedAt: Date | null = null;
356
423
 
424
+ /** Child-agent lifecycle is informational and never participates in root turn settlement. */
425
+ private readonly _backgroundTasks = new Map<string, TrackedBackgroundTask>();
426
+ private readonly _backgroundTaskIdsByPath = new Map<string, string>();
427
+ /** One protocol-native thread/read poller per child when Codex does not multiplex its notifications. */
428
+ private readonly _backgroundTaskPollers = new Map<string, {
429
+ generation: number;
430
+ promise: Promise<void>;
431
+ }>();
432
+ /** Cancellable retry delay for each poller. Cleared when the session closes. */
433
+ private readonly _backgroundTaskPollTimers = new Map<string, {
434
+ timer: ReturnType<typeof setTimeout>;
435
+ resolve: () => void;
436
+ }>();
437
+
357
438
  /**
358
439
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
359
440
  * handler invocation; the chain enforces in-order delivery and lets
@@ -422,7 +503,14 @@ export class CodexSessionImpl implements AgentSession {
422
503
  proc.on("exit", (code, signal) => {
423
504
  if (this._state !== "closed") {
424
505
  this._state = "closed";
425
- const err = new Error(`Codex process exited unexpectedly (code=${code}, signal=${signal})`);
506
+ const message = `Codex process exited unexpectedly (code=${code}, signal=${signal})`;
507
+ this.finalizeActiveBackgroundTasks("failed", message, {
508
+ method: "process/exit",
509
+ code,
510
+ signal,
511
+ });
512
+ this.cancelBackgroundTaskPollTimers();
513
+ const err = new Error(message);
426
514
  this.rejectAllPending(err);
427
515
  }
428
516
  });
@@ -430,11 +518,43 @@ export class CodexSessionImpl implements AgentSession {
430
518
  proc.on("error", (err) => {
431
519
  if (this._state !== "closed") {
432
520
  this._state = "closed";
521
+ this.finalizeActiveBackgroundTasks("failed", err.message, {
522
+ method: "process/error",
523
+ message: err.message,
524
+ });
525
+ this.cancelBackgroundTaskPollTimers();
433
526
  this.rejectAllPending(err);
434
527
  }
435
528
  });
436
529
  }
437
530
 
531
+ private cancelBackgroundTaskPollTimers(): void {
532
+ for (const pending of this._backgroundTaskPollTimers.values()) {
533
+ clearTimeout(pending.timer);
534
+ pending.resolve();
535
+ }
536
+ this._backgroundTaskPollTimers.clear();
537
+ }
538
+
539
+ private finalizeActiveBackgroundTasks(
540
+ status: "failed" | "stopped",
541
+ summary: string,
542
+ raw: Record<string, unknown>,
543
+ ): void {
544
+ for (const task of this._backgroundTasks.values()) {
545
+ if (task.terminal) continue;
546
+ this.dispatchEvent(this.backgroundTaskEvent(task.taskId, "completed", status, {
547
+ description: task.description,
548
+ summary,
549
+ parentTaskId: task.parentTaskId,
550
+ eventId: this._threadId
551
+ ? `codex:${this._threadId}:background-task:${task.taskId}:session-${status}:completed`
552
+ : null,
553
+ raw,
554
+ }));
555
+ }
556
+ }
557
+
438
558
  /** Reject every pending send() Promise and outgoing JSON-RPC call. */
439
559
  private rejectAllPending(err: Error): void {
440
560
  const pending = this._pendingResults.splice(0);
@@ -446,11 +566,78 @@ export class CodexSessionImpl implements AgentSession {
446
566
  }
447
567
  for (const [, p] of this._pendingRpc) p.reject(err);
448
568
  this._pendingRpc.clear();
569
+ this.clearActiveTurn(err);
449
570
  }
450
571
 
451
572
  get sessionId(): string | null { return this._threadId; }
452
573
  get state(): SessionState { return this._state; }
453
574
 
575
+ /** Start the identity latch before writing the leader `turn/start` request. */
576
+ private beginActiveTurn(): ActiveTurnReady {
577
+ let resolveFn!: (turnId: string | null) => void;
578
+ let rejectFn!: (err: Error) => void;
579
+ const promise = new Promise<string | null>((resolve, reject) => {
580
+ resolveFn = resolve;
581
+ rejectFn = reject;
582
+ });
583
+ // A turn can finish without anyone calling interrupt(). Keep a later
584
+ // process-exit rejection from becoming an unhandled promise rejection.
585
+ void promise.catch(() => {});
586
+
587
+ const ready: ActiveTurnReady = {
588
+ promise,
589
+ resolve: resolveFn,
590
+ reject: rejectFn,
591
+ settled: false,
592
+ };
593
+ this._activeTurnId = null;
594
+ this._activeTurnReady = ready;
595
+ this._interruptPromise = null;
596
+ this._turnTerminalObserved = false;
597
+ this._turnWasInterrupted = false;
598
+ return ready;
599
+ }
600
+
601
+ /** First root turn id wins for the current latch. */
602
+ private captureActiveTurnId(turnId: string, expected?: ActiveTurnReady): void {
603
+ const ready = this._activeTurnReady;
604
+ if (!turnId || !ready || ready.settled) return;
605
+ if (expected && ready !== expected) return;
606
+ this._activeTurnId = turnId;
607
+ ready.settled = true;
608
+ ready.resolve(turnId);
609
+ }
610
+
611
+ private rejectActiveTurnReady(err: Error, expected: ActiveTurnReady): void {
612
+ if (this._activeTurnReady !== expected || expected.settled) return;
613
+ expected.settled = true;
614
+ expected.reject(err);
615
+ }
616
+
617
+ /** Clear the current turn and release an interrupt waiting for its id. */
618
+ private clearActiveTurn(err?: Error): void {
619
+ const ready = this._activeTurnReady;
620
+ if (ready && !ready.settled) {
621
+ ready.settled = true;
622
+ if (err) ready.reject(err);
623
+ else ready.resolve(null);
624
+ }
625
+ this._activeTurnId = null;
626
+ this._activeTurnReady = null;
627
+ this._interruptPromise = null;
628
+ this._turnTerminalObserved = false;
629
+ }
630
+
631
+ /** Mark root turn termination and release an interrupt still awaiting its id. */
632
+ private markTurnTerminalObserved(): void {
633
+ this._turnTerminalObserved = true;
634
+ const ready = this._activeTurnReady;
635
+ if (ready && !ready.settled) {
636
+ ready.settled = true;
637
+ ready.resolve(null);
638
+ }
639
+ }
640
+
454
641
  /**
455
642
  * Durable identity for persistence + later `attachSession`. Null until Codex
456
643
  * has assigned a thread id; serializes `{sessionId, cwd}` through the codec so
@@ -483,6 +670,35 @@ export class CodexSessionImpl implements AgentSession {
483
670
  });
484
671
  }
485
672
 
673
+ /** A bounded RPC whose pending-map entry is removed if Codex never replies. */
674
+ private boundedRpcRequest(
675
+ method: string,
676
+ params: Record<string, unknown>,
677
+ timeoutMs = BACKGROUND_TASK_READ_TIMEOUT_MS,
678
+ ): Promise<Record<string, unknown>> {
679
+ const id = this._nextId++;
680
+ this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
681
+
682
+ return new Promise((resolve, reject) => {
683
+ const timer = setTimeout(() => {
684
+ if (!this._pendingRpc.delete(id)) return;
685
+ reject(new Error(`codex ${method} timed out`));
686
+ }, timeoutMs);
687
+ if (typeof timer.unref === "function") timer.unref();
688
+
689
+ this._pendingRpc.set(id, {
690
+ resolve: (result) => {
691
+ clearTimeout(timer);
692
+ resolve(result);
693
+ },
694
+ reject: (err) => {
695
+ clearTimeout(timer);
696
+ reject(err);
697
+ },
698
+ });
699
+ });
700
+ }
701
+
486
702
  /**
487
703
  * Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
488
704
  * family). An app-server build that doesn't recognize the method may never
@@ -618,6 +834,10 @@ export class CodexSessionImpl implements AgentSession {
618
834
  // and pass through. If the second `turn/start` lands during the first
619
835
  // turn, the per-turn accumulators continue collecting until the result
620
836
  // event fires; the result then drains all pending resolvers.
837
+ const existingTurnReady = this._activeTurnReady;
838
+ const isTurnLeader = existingTurnReady === null;
839
+ const turnReady = existingTurnReady ?? this.beginActiveTurn();
840
+
621
841
  if (this._state === "idle") {
622
842
  this._state = "thinking";
623
843
  this._turnStartedAt = new Date();
@@ -659,16 +879,34 @@ export class CodexSessionImpl implements AgentSession {
659
879
  // ProviderConfig.timeoutSec default when no per-call timeout is given.
660
880
  this.armSendDeadline(entry, options);
661
881
 
662
- this.rpcRequest("turn/start", turnParams).catch(() => {
663
- // Turn-level errors arrive via turn.failed notifications.
664
- });
882
+ const turnStart = this.rpcRequest("turn/start", turnParams);
883
+ if (isTurnLeader) {
884
+ void turnStart.then((response) => {
885
+ const turnId = str(asObj(response, "turn"), "id");
886
+ if (turnId) {
887
+ this.captureActiveTurnId(turnId, turnReady);
888
+ }
889
+ // Some app-server versions may omit the id from the response and send
890
+ // it only in turn/started. Keep the latch open for that notification.
891
+ }).catch((err: unknown) => {
892
+ this.rejectActiveTurnReady(
893
+ err instanceof Error ? err : new Error(String(err)),
894
+ turnReady,
895
+ );
896
+ // Turn-level failures may also arrive via turn/failed notifications.
897
+ });
898
+ } else {
899
+ void turnStart.catch(() => {
900
+ // Turn-level failures arrive via turn/failed notifications.
901
+ });
902
+ }
665
903
 
666
904
  return { uuid, result };
667
905
  }
668
906
 
669
907
  /**
670
908
  * Wire up this send's timeout and/or abort signal. On fire, the active turn
671
- * is cancelled (`turn/cancel`) and the send settles with `timeout` /
909
+ * is interrupted (`turn/interrupt`) and the send settles with `timeout` /
672
910
  * `aborted`. No-op when neither a timeout nor a signal applies.
673
911
  */
674
912
  private armSendDeadline(entry: PendingResult, options?: SendOptions): void {
@@ -711,7 +949,7 @@ export class CodexSessionImpl implements AgentSession {
711
949
 
712
950
  // Best-effort cancel of the active turn. With concurrent sends this ends
713
951
  // the single shared turn for all of them — see SendOptions JSDoc.
714
- void this.interrupt();
952
+ void this.interrupt().catch(() => {});
715
953
 
716
954
  entry.resolve({
717
955
  summary: null,
@@ -727,7 +965,7 @@ export class CodexSessionImpl implements AgentSession {
727
965
 
728
966
  async cancel(_uuid: string): Promise<CancelResult> {
729
967
  // Codex's JSON-RPC protocol exposes no per-message cancel — only
730
- // turn-wide `turn/cancel` (which is what `interrupt()` calls).
968
+ // turn-wide `turn/interrupt` (which is what `interrupt()` calls).
731
969
  // capabilities.cancelQueuedMessage is false; this is a documented no-op.
732
970
  return { cancelled: false };
733
971
  }
@@ -780,11 +1018,37 @@ export class CodexSessionImpl implements AgentSession {
780
1018
  }
781
1019
 
782
1020
  async interrupt(): Promise<void> {
783
- if (this._state === "idle" || this._state === "closed") return;
1021
+ if (this._state === "closed") return;
1022
+ const ready = this._activeTurnReady;
1023
+ if (!ready || this._turnTerminalObserved) return;
1024
+ if (this._interruptPromise) return this._interruptPromise;
1025
+
1026
+ const threadId = this._threadId;
1027
+ if (!threadId) {
1028
+ throw new Error("Cannot interrupt Codex turn before the root thread id is known");
1029
+ }
784
1030
  this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
1031
+
1032
+ const interruptPromise = (async () => {
1033
+ const turnId = this._activeTurnId ?? await ready.promise;
1034
+ // The turn may have completed while interrupt() was waiting for the
1035
+ // leader turn/start response. In that race, completion is the success.
1036
+ if (!turnId || this._activeTurnReady !== ready || this._turnTerminalObserved) return;
1037
+ await this.rpcRequest("turn/interrupt", { threadId, turnId });
1038
+ })();
1039
+ this._interruptPromise = interruptPromise;
1040
+
785
1041
  try {
786
- await this.rpcRequest("turn/cancel", {});
787
- } catch { /* best effort */ }
1042
+ await interruptPromise;
1043
+ } catch (err) {
1044
+ // A rejected control request must reach the host instead of becoming a
1045
+ // false successful Stop. Clear only this turn's failed attempt so a
1046
+ // subsequent click can retry.
1047
+ if (this._activeTurnReady === ready && this._interruptPromise === interruptPromise) {
1048
+ this._interruptPromise = null;
1049
+ }
1050
+ throw err;
1051
+ }
788
1052
  }
789
1053
 
790
1054
  async drain(): Promise<void> {
@@ -804,6 +1068,11 @@ export class CodexSessionImpl implements AgentSession {
804
1068
  async close(): Promise<void> {
805
1069
  if (this._state === "closed") return;
806
1070
  this._state = "closed";
1071
+ this.finalizeActiveBackgroundTasks("stopped", "Codex session closed", {
1072
+ method: "session/closed",
1073
+ });
1074
+ this.cancelBackgroundTaskPollTimers();
1075
+ this.rejectAllPending(new Error("Codex session closed"));
807
1076
 
808
1077
  this.proc.stdin!.end();
809
1078
 
@@ -1003,7 +1272,494 @@ export class CodexSessionImpl implements AgentSession {
1003
1272
  return !!threadId && !!rootThreadId && threadId !== rootThreadId;
1004
1273
  }
1005
1274
 
1275
+ private backgroundTaskParentIdForPath(agentPath: string | null): string | null {
1276
+ if (!agentPath) return null;
1277
+ const separator = agentPath.lastIndexOf("/");
1278
+ if (separator <= 0) return null;
1279
+ return this._backgroundTaskIdsByPath.get(agentPath.slice(0, separator)) ?? null;
1280
+ }
1281
+
1282
+ private agentMessageText(item: Record<string, unknown>): string | null {
1283
+ const direct = str(item, "text");
1284
+ if (direct) return direct;
1285
+ const content = Array.isArray(item["content"]) ? item["content"] : [];
1286
+ for (const entry of content) {
1287
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
1288
+ const block = entry as Record<string, unknown>;
1289
+ const text = str(block, "text");
1290
+ if (text && (str(block, "type") === "output_text" || str(block, "type") === "text")) {
1291
+ return text;
1292
+ }
1293
+ }
1294
+ return null;
1295
+ }
1296
+
1297
+ private backgroundTaskEvent(
1298
+ taskId: string,
1299
+ phase: "started" | "progress" | "completed",
1300
+ status: "running" | "completed" | "failed" | "stopped",
1301
+ options: {
1302
+ description?: string | null;
1303
+ summary?: string | null;
1304
+ parentTaskId?: string | null;
1305
+ turnId?: string | null;
1306
+ eventId?: string | null;
1307
+ raw: Record<string, unknown>;
1308
+ },
1309
+ ): Extract<StreamEvent, { type: "background_task" }> {
1310
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1311
+ return {
1312
+ type: "background_task",
1313
+ taskId,
1314
+ taskType: "subagent",
1315
+ phase,
1316
+ status,
1317
+ description: options.description ?? null,
1318
+ summary: options.summary ?? null,
1319
+ parentTaskId: options.parentTaskId ?? null,
1320
+ timestamp: new Date().toISOString(),
1321
+ providerType: "codex",
1322
+ sessionId: rootThreadId,
1323
+ messageId: null,
1324
+ eventId: options.eventId ?? null,
1325
+ turnId: options.turnId ?? null,
1326
+ parentToolCallId: null,
1327
+ raw: options.raw,
1328
+ };
1329
+ }
1330
+
1331
+ private reconciledBackgroundTaskRaw(
1332
+ threadId: string,
1333
+ turn: Record<string, unknown> | null,
1334
+ ): Record<string, unknown> {
1335
+ const error = turn ? str(asObj(turn, "error"), "message") || null : null;
1336
+ return {
1337
+ method: "thread/read",
1338
+ reconciled: true,
1339
+ threadId,
1340
+ turnId: turn ? str(turn, "id") || null : null,
1341
+ status: turn ? str(turn, "status") || null : null,
1342
+ error,
1343
+ };
1344
+ }
1345
+
1346
+ /**
1347
+ * Codex 0.144+ represents collaboration as a root collabAgentToolCall item.
1348
+ * A completed spawn call only means the child was created. Register every
1349
+ * receiver and then follow the child thread itself for the real terminus.
1350
+ */
1351
+ private handleCollabAgentToolCall(
1352
+ item: Record<string, unknown>,
1353
+ raw: Record<string, unknown>,
1354
+ parentTaskId: string | null,
1355
+ ): void {
1356
+ if (this._state === "closed" || !this.ctx.onEvent) return;
1357
+
1358
+ const tool = str(item, "tool");
1359
+ const states = asObj(item, "agentsStates");
1360
+ const receiverIds = stringArray(item["receiverThreadIds"]);
1361
+ const taskIds = [...new Set([...receiverIds, ...Object.keys(states)])];
1362
+ const canStart = tool === "spawnAgent";
1363
+ const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
1364
+ const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
1365
+
1366
+ for (const taskId of taskIds) {
1367
+ let previous = this._backgroundTasks.get(taskId);
1368
+ const description = str(item, "prompt") || previous?.description || null;
1369
+ const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
1370
+
1371
+ // The child thread's own turn/started notification is the only
1372
+ // authoritative reactivation edge. Root resume/send calls can arrive
1373
+ // before or after that child turn, so they only enrich an active task.
1374
+ if (metadataOnly) {
1375
+ if (!previous || previous.terminal) continue;
1376
+ if (description && description !== previous.description) {
1377
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1378
+ description,
1379
+ summary: previous.summary,
1380
+ parentTaskId: resolvedParentTaskId,
1381
+ eventId: this._threadId
1382
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1383
+ : null,
1384
+ raw,
1385
+ }));
1386
+ }
1387
+ continue;
1388
+ }
1389
+
1390
+ if (!previous && !canStart) continue;
1391
+ if (!canStart && !canStopWithoutState && !(taskId in states)) continue;
1392
+
1393
+ const hasState = taskId in states;
1394
+ const state = !hasState && tool === "closeAgent" && str(item, "status") === "completed"
1395
+ ? { terminal: true, status: "stopped" as const, summary: null }
1396
+ : codexAgentState(states[taskId]);
1397
+
1398
+ if (!previous) {
1399
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "started", "running", {
1400
+ description,
1401
+ summary: null,
1402
+ parentTaskId: resolvedParentTaskId,
1403
+ eventId: this._threadId
1404
+ ? `codex:${this._threadId}:background-task:${taskId}:started`
1405
+ : null,
1406
+ raw,
1407
+ }));
1408
+ previous = this._backgroundTasks.get(taskId);
1409
+ } else if (previous.terminal) {
1410
+ continue;
1411
+ } else if (description && description !== previous.description) {
1412
+ // thread/started can beat the richer root collab item. Preserve one
1413
+ // start edge, then publish the prompt as ordinary progress metadata.
1414
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1415
+ description,
1416
+ summary: previous.summary,
1417
+ parentTaskId: resolvedParentTaskId,
1418
+ eventId: this._threadId
1419
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1420
+ : null,
1421
+ raw,
1422
+ }));
1423
+ }
1424
+
1425
+ if (state.terminal) {
1426
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
1427
+ description,
1428
+ summary: state.summary,
1429
+ parentTaskId: resolvedParentTaskId,
1430
+ eventId: this._threadId
1431
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "state"}:completed`
1432
+ : null,
1433
+ raw,
1434
+ }));
1435
+ } else {
1436
+ this.startBackgroundTaskPoller(taskId);
1437
+ }
1438
+ }
1439
+ }
1440
+
1441
+ private waitForBackgroundTaskPoll(pollKey: string): Promise<void> {
1442
+ if (this._state === "closed") return Promise.resolve();
1443
+ return new Promise((resolve) => {
1444
+ const timer = setTimeout(() => {
1445
+ this._backgroundTaskPollTimers.delete(pollKey);
1446
+ resolve();
1447
+ }, BACKGROUND_TASK_POLL_INTERVAL_MS);
1448
+ if (typeof timer.unref === "function") timer.unref();
1449
+ this._backgroundTaskPollTimers.set(pollKey, { timer, resolve });
1450
+ });
1451
+ }
1452
+
1453
+ private startBackgroundTaskPoller(taskId: string): void {
1454
+ if (this._state === "closed") return;
1455
+ const task = this._backgroundTasks.get(taskId);
1456
+ if (!task || task.terminal) return;
1457
+ const existing = this._backgroundTaskPollers.get(taskId);
1458
+ if (existing?.generation === task.generation) return;
1459
+
1460
+ const generation = task.generation;
1461
+ const pollKey = `${taskId}:${generation}`;
1462
+ let poller!: Promise<void>;
1463
+ poller = this.pollBackgroundTask(taskId, generation, pollKey).finally(() => {
1464
+ if (this._backgroundTaskPollers.get(taskId)?.promise === poller) {
1465
+ this._backgroundTaskPollers.delete(taskId);
1466
+ }
1467
+ const pending = this._backgroundTaskPollTimers.get(pollKey);
1468
+ if (pending) clearTimeout(pending.timer);
1469
+ this._backgroundTaskPollTimers.delete(pollKey);
1470
+ });
1471
+ this._backgroundTaskPollers.set(taskId, { generation, promise: poller });
1472
+ }
1473
+
1474
+ private async pollBackgroundTask(
1475
+ taskId: string,
1476
+ generation: number,
1477
+ pollKey: string,
1478
+ ): Promise<void> {
1479
+ while (this._state !== "closed") {
1480
+ const task = this._backgroundTasks.get(taskId);
1481
+ if (!task || task.generation !== generation || task.terminal) return;
1482
+
1483
+ try {
1484
+ const response = await this.boundedRpcRequest("thread/read", {
1485
+ threadId: taskId,
1486
+ includeTurns: true,
1487
+ });
1488
+ if (this.observeBackgroundTaskThread(response, taskId, generation)) return;
1489
+ } catch {
1490
+ // A child can briefly be pendingInit before thread/read can load it.
1491
+ }
1492
+
1493
+ const current = this._backgroundTasks.get(taskId);
1494
+ if (!current || current.generation !== generation || current.terminal) return;
1495
+ await this.waitForBackgroundTaskPoll(pollKey);
1496
+ }
1497
+ }
1498
+
1499
+ /** Return true once thread/read proves that the child's latest turn ended. */
1500
+ private observeBackgroundTaskThread(
1501
+ response: Record<string, unknown>,
1502
+ taskId: string,
1503
+ generation: number,
1504
+ ): boolean {
1505
+ const task = this._backgroundTasks.get(taskId);
1506
+ if (!task || task.generation !== generation || task.terminal) return true;
1507
+
1508
+ const thread = asObj(response, "thread");
1509
+ const turns = Array.isArray(thread["turns"])
1510
+ ? thread["turns"].map(obj)
1511
+ : [];
1512
+
1513
+ const latestTurn = turns[turns.length - 1];
1514
+ if (!latestTurn) {
1515
+ const threadStatus = str(asObj(thread, "status"), "type");
1516
+ if (threadStatus !== "systemError") return false;
1517
+ const failureTurn = { status: "systemError", error: { message: "Subagent failed to initialize" } };
1518
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", "failed", {
1519
+ description: task.description,
1520
+ summary: "Subagent failed to initialize",
1521
+ parentTaskId: task.parentTaskId,
1522
+ eventId: this._threadId
1523
+ ? `codex:${this._threadId}:background-task:${taskId}:system-error:completed`
1524
+ : null,
1525
+ raw: this.reconciledBackgroundTaskRaw(taskId, failureTurn),
1526
+ }));
1527
+ return true;
1528
+ }
1529
+
1530
+ const nativeStatus = str(latestTurn, "status");
1531
+ if (nativeStatus === "inProgress" || !nativeStatus) return false;
1532
+
1533
+ const turnId = str(latestTurn, "id") || null;
1534
+ // The child turn/started notification is authoritative for a reactivated
1535
+ // generation. thread/read can briefly lag and still end at an older turn.
1536
+ if (task.activeTurnId && turnId !== task.activeTurnId) return false;
1537
+
1538
+ const items = Array.isArray(latestTurn["items"])
1539
+ ? latestTurn["items"].map(obj)
1540
+ : [];
1541
+ let summary: string | null = null;
1542
+ for (let index = items.length - 1; index >= 0; index--) {
1543
+ const item = items[index]!;
1544
+ if (str(item, "type") !== "agentMessage") continue;
1545
+ summary = this.agentMessageText(item);
1546
+ if (summary) break;
1547
+ }
1548
+ const errorMessage = str(asObj(latestTurn, "error"), "message") || null;
1549
+ const status = nativeStatus === "failed"
1550
+ ? "failed"
1551
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1552
+ ? "stopped"
1553
+ : "completed";
1554
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", status, {
1555
+ description: task.description,
1556
+ summary: summary ?? errorMessage,
1557
+ parentTaskId: task.parentTaskId,
1558
+ turnId,
1559
+ eventId: this._threadId && turnId
1560
+ ? `codex:${this._threadId}:background-task:${taskId}:${turnId}:completed`
1561
+ : null,
1562
+ raw: this.reconciledBackgroundTaskRaw(taskId, latestTurn),
1563
+ }));
1564
+ return true;
1565
+ }
1566
+
1567
+ /**
1568
+ * Maintain just enough child metadata to turn a later foreign-thread
1569
+ * terminal notification into one provider-neutral task event. This reducer
1570
+ * is deliberately separate from every root turn accumulator.
1571
+ */
1572
+ private observeBackgroundTask(event: Extract<StreamEvent, { type: "background_task" }>): boolean {
1573
+ const previous = this._backgroundTasks.get(event.taskId);
1574
+ // Codex reports `subAgentActivity:interacted` after it forwards a child's
1575
+ // final answer to the parent. The authoritative child turn/completed can
1576
+ // arrive first, so suppress that late progress edge instead of resurrecting
1577
+ // a task that already reached a terminal state. A later child turn/started
1578
+ // explicitly reactivates the record below in handleForeignNotification.
1579
+ if (previous?.terminal) return false;
1580
+ // collabAgentToolCall and child thread/started can both announce the same
1581
+ // spawn. The first edge is sufficient and gives hosts one durable start.
1582
+ if (previous && event.phase === "started") return false;
1583
+
1584
+ const description = event.description ?? previous?.description ?? null;
1585
+ const summary = event.summary ?? previous?.summary ?? null;
1586
+ const parentTaskId = event.parentTaskId
1587
+ ?? previous?.parentTaskId
1588
+ ?? this.backgroundTaskParentIdForPath(description);
1589
+
1590
+ event.description = description;
1591
+ event.summary = summary;
1592
+ event.parentTaskId = parentTaskId;
1593
+
1594
+ this._backgroundTasks.set(event.taskId, {
1595
+ taskId: event.taskId,
1596
+ description,
1597
+ summary,
1598
+ parentTaskId,
1599
+ terminal: event.phase === "completed",
1600
+ generation: previous?.generation ?? 0,
1601
+ activeTurnId: event.phase === "completed"
1602
+ ? null
1603
+ : event.turnId ?? previous?.activeTurnId ?? null,
1604
+ });
1605
+ if (description) this._backgroundTaskIdsByPath.set(description, event.taskId);
1606
+ return true;
1607
+ }
1608
+
1609
+ /**
1610
+ * A Codex app-server connection also publishes child thread notifications.
1611
+ * They are useful only as background-task metadata. They must never flow
1612
+ * through root state, summary, usage, or `resolveTurn()`.
1613
+ */
1614
+ private handleForeignNotification(
1615
+ method: string,
1616
+ params: Record<string, unknown>,
1617
+ rawLine: string,
1618
+ childThreadId: string,
1619
+ ): void {
1620
+ if (method === "thread/started") {
1621
+ const thread = asObj(params, "thread");
1622
+ const parentThreadId = str(thread, "parentThreadId") || str(thread, "parent_thread_id");
1623
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1624
+ const parentTask = this._backgroundTasks.get(parentThreadId);
1625
+ // App-server can publish child thread/started before the corresponding
1626
+ // root subAgentActivity item. Register only descendants of this session,
1627
+ // not unrelated foreign threads multiplexed by a future server version.
1628
+ if (parentThreadId !== rootThreadId && !parentTask) return;
1629
+
1630
+ const source = asObj(thread, "source");
1631
+ const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1632
+ ? asObj(source, "subAgent")
1633
+ : asObj(source, "subagent");
1634
+ const spawnSource = Object.keys(asObj(subAgent, "threadSpawn")).length > 0
1635
+ ? asObj(subAgent, "threadSpawn")
1636
+ : asObj(subAgent, "thread_spawn");
1637
+ const description = str(spawnSource, "agentPath")
1638
+ || str(spawnSource, "agent_path")
1639
+ || str(thread, "name")
1640
+ || str(thread, "agentNickname")
1641
+ || str(thread, "agentRole")
1642
+ || null;
1643
+
1644
+ this.dispatchEvent({
1645
+ type: "background_task",
1646
+ taskId: childThreadId,
1647
+ taskType: "subagent",
1648
+ phase: "started",
1649
+ status: "running",
1650
+ description,
1651
+ summary: null,
1652
+ parentTaskId: parentThreadId === rootThreadId ? null : parentThreadId,
1653
+ timestamp: new Date().toISOString(),
1654
+ providerType: "codex",
1655
+ sessionId: rootThreadId,
1656
+ messageId: null,
1657
+ eventId: rootThreadId
1658
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:started`
1659
+ : null,
1660
+ turnId: null,
1661
+ parentToolCallId: null,
1662
+ raw: parseJson(rawLine) ?? params,
1663
+ });
1664
+ this.startBackgroundTaskPoller(childThreadId);
1665
+ return;
1666
+ }
1667
+
1668
+ const task = this._backgroundTasks.get(childThreadId);
1669
+ if (!task) return;
1670
+
1671
+ if (method === "turn/started") {
1672
+ if (!task.terminal) return;
1673
+ task.terminal = false;
1674
+ task.summary = null;
1675
+ task.generation += 1;
1676
+ const turn = asObj(params, "turn");
1677
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1678
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1679
+ this.dispatchEvent({
1680
+ type: "background_task",
1681
+ taskId: childThreadId,
1682
+ taskType: "subagent",
1683
+ phase: "progress",
1684
+ status: "running",
1685
+ description: task.description,
1686
+ summary: null,
1687
+ parentTaskId: task.parentTaskId,
1688
+ timestamp: new Date().toISOString(),
1689
+ providerType: "codex",
1690
+ sessionId: rootThreadId,
1691
+ messageId: null,
1692
+ eventId: rootThreadId && turnId
1693
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:progress`
1694
+ : null,
1695
+ turnId,
1696
+ parentToolCallId: null,
1697
+ raw: parseJson(rawLine) ?? params,
1698
+ });
1699
+ this.startBackgroundTaskPoller(childThreadId);
1700
+ return;
1701
+ }
1702
+
1703
+ if (method === "item/completed") {
1704
+ const item = asObj(params, "item");
1705
+ const itemType = str(item, "type");
1706
+ if (itemType === "collabAgentToolCall") {
1707
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, childThreadId);
1708
+ return;
1709
+ }
1710
+ if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1711
+ const summary = this.agentMessageText(item);
1712
+ if (summary) task.summary = summary;
1713
+ }
1714
+ return;
1715
+ }
1716
+
1717
+ if (method !== "turn/completed" && method !== "turn/failed") return;
1718
+
1719
+ const turn = asObj(params, "turn");
1720
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1721
+ const nativeStatus = method === "turn/failed" ? "failed" : str(turn, "status");
1722
+ const status = nativeStatus === "failed"
1723
+ ? "failed"
1724
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1725
+ ? "stopped"
1726
+ : "completed";
1727
+ const errorMessage = str(asObj(turn, "error"), "message")
1728
+ || str(params, "message")
1729
+ || str(params, "error");
1730
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1731
+
1732
+ this.dispatchEvent({
1733
+ type: "background_task",
1734
+ taskId: childThreadId,
1735
+ taskType: "subagent",
1736
+ phase: "completed",
1737
+ status,
1738
+ description: task.description,
1739
+ summary: task.summary ?? (errorMessage || null),
1740
+ parentTaskId: task.parentTaskId,
1741
+ timestamp: new Date().toISOString(),
1742
+ providerType: "codex",
1743
+ sessionId: rootThreadId,
1744
+ messageId: null,
1745
+ eventId: rootThreadId && turnId
1746
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:completed`
1747
+ : null,
1748
+ turnId,
1749
+ parentToolCallId: null,
1750
+ raw: {
1751
+ method,
1752
+ reconciled: false,
1753
+ threadId: childThreadId,
1754
+ turnId,
1755
+ status: nativeStatus || null,
1756
+ error: errorMessage || null,
1757
+ },
1758
+ });
1759
+ }
1760
+
1006
1761
  private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
1762
+ if (this._state === "closed") return;
1007
1763
  // codex/event — legacy wrapper
1008
1764
  if (method === "codex/event") {
1009
1765
  const innerMsg = str(params, "msg");
@@ -1020,7 +1776,10 @@ export class CodexSessionImpl implements AgentSession {
1020
1776
  // its root thread, so foreign items must not change root state/summary and,
1021
1777
  // most importantly, a child turn/completed must not resolve the root send.
1022
1778
  const notificationThreadId = this.notificationThreadId(params);
1023
- if (this.isForeignThread(notificationThreadId)) return;
1779
+ if (this.isForeignThread(notificationThreadId)) {
1780
+ this.handleForeignNotification(method, params, rawLine, notificationThreadId!);
1781
+ return;
1782
+ }
1024
1783
 
1025
1784
  // Map v2 notification methods to processing
1026
1785
  if (method === "thread/started") {
@@ -1030,6 +1789,13 @@ export class CodexSessionImpl implements AgentSession {
1030
1789
  return;
1031
1790
  }
1032
1791
 
1792
+ if (method === "turn/started") {
1793
+ this.captureActiveTurnId(str(asObj(params, "turn"), "id") || str(params, "turnId"));
1794
+ // The parser intentionally suppresses this lifecycle-only event.
1795
+ this.emitStreamEvent(rawLine);
1796
+ return;
1797
+ }
1798
+
1033
1799
  if (method === "item/started") {
1034
1800
  this._state = "tool_executing";
1035
1801
  this.emitStreamEvent(rawLine);
@@ -1039,6 +1805,11 @@ export class CodexSessionImpl implements AgentSession {
1039
1805
  if (method === "item/completed") {
1040
1806
  this._state = "thinking";
1041
1807
  this.extractSummaryFromItem(params);
1808
+ const item = asObj(params, "item");
1809
+ if (str(item, "type") === "collabAgentToolCall") {
1810
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null);
1811
+ return;
1812
+ }
1042
1813
  this.emitStreamEvent(rawLine);
1043
1814
  return;
1044
1815
  }
@@ -1049,6 +1820,7 @@ export class CodexSessionImpl implements AgentSession {
1049
1820
  }
1050
1821
 
1051
1822
  if (method === "turn/failed") {
1823
+ this.markTurnTerminalObserved();
1052
1824
  this._turnIsError = true;
1053
1825
  this._turnErrorMessage = str(params, "message") || str(params, "error") || "Turn failed";
1054
1826
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1078,6 +1850,7 @@ export class CodexSessionImpl implements AgentSession {
1078
1850
  // -------------------------------------------------------------------------
1079
1851
 
1080
1852
  private handleLegacyEvent(event: Record<string, unknown>, rawLine: string): void {
1853
+ if (this._state === "closed") return;
1081
1854
  const type = str(event, "type");
1082
1855
  const eventThreadId =
1083
1856
  str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
@@ -1111,6 +1884,7 @@ export class CodexSessionImpl implements AgentSession {
1111
1884
  }
1112
1885
 
1113
1886
  if (type === "turn.failed" || type === "error") {
1887
+ this.markTurnTerminalObserved();
1114
1888
  this._turnIsError = true;
1115
1889
  this._turnErrorMessage = str(event, "message") || str(event, "error") || "Turn failed";
1116
1890
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1162,6 +1936,7 @@ export class CodexSessionImpl implements AgentSession {
1162
1936
  }
1163
1937
 
1164
1938
  private handleTurnCompleted(params: Record<string, unknown>): void {
1939
+ this.markTurnTerminalObserved();
1165
1940
  const usage = typeof params["usage"] === "object" && params["usage"] !== null
1166
1941
  ? (params["usage"] as Record<string, unknown>)
1167
1942
  : null;
@@ -1181,7 +1956,9 @@ export class CodexSessionImpl implements AgentSession {
1181
1956
  // the error instead of a false "completed".
1182
1957
  const turn = asObj(params, "turn");
1183
1958
  const turnStatus = str(turn, "status");
1184
- if (turnStatus === "failed" || turnStatus === "cancelled") {
1959
+ if (turnStatus === "interrupted" || turnStatus === "cancelled") {
1960
+ this._turnWasInterrupted = true;
1961
+ } else if (turnStatus === "failed") {
1185
1962
  this._turnIsError = true;
1186
1963
  const msg = str(asObj(turn, "error"), "message");
1187
1964
  this._turnErrorMessage = msg || this._turnErrorMessage || `Turn ${turnStatus}`;
@@ -1253,9 +2030,9 @@ export class CodexSessionImpl implements AgentSession {
1253
2030
  summary: this._turnSummary,
1254
2031
  usage,
1255
2032
  costUsd: null,
1256
- status: this._turnIsError ? "failed" : "completed",
1257
- errorCode: this._turnIsError ? "execution_error" : null,
1258
- errorMessage: this._turnErrorMessage,
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,
1259
2036
  };
1260
2037
 
1261
2038
  // Drain pending onEvent handlers so callers awaiting send() see a settled
@@ -1283,8 +2060,10 @@ export class CodexSessionImpl implements AgentSession {
1283
2060
  this._turnUsage = null;
1284
2061
  this._turnModel = null;
1285
2062
  this._turnIsError = false;
2063
+ this._turnWasInterrupted = false;
1286
2064
  this._turnErrorMessage = null;
1287
2065
  this._turnStartedAt = null;
2066
+ this.clearActiveTurn();
1288
2067
 
1289
2068
  for (const p of pending) {
1290
2069
  // Skip sends already settled early by timeout / abort.
@@ -1316,6 +2095,7 @@ export class CodexSessionImpl implements AgentSession {
1316
2095
  * throwing handler does not break delivery of subsequent events.
1317
2096
  */
1318
2097
  private dispatchEvent(event: StreamEvent): void {
2098
+ if (event.type === "background_task" && !this.observeBackgroundTask(event)) return;
1319
2099
  // Track native goal_status transitions (keeps getGoal() accurate).
1320
2100
  this._goals.observe(event);
1321
2101
  const cb = this.ctx.onEvent;