@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.
@@ -89,6 +89,8 @@ function buildCodexUserInputAnswers(questions, resp) {
89
89
  }
90
90
  return out;
91
91
  }
92
+ const BACKGROUND_TASK_POLL_INTERVAL_MS = 2_000;
93
+ const BACKGROUND_TASK_READ_TIMEOUT_MS = 5_000;
92
94
  // ---------------------------------------------------------------------------
93
95
  // JSON-RPC 2.0 helpers
94
96
  // ---------------------------------------------------------------------------
@@ -116,6 +118,30 @@ function asObj(parent, key) {
116
118
  ? v
117
119
  : {};
118
120
  }
121
+ function obj(value) {
122
+ return typeof value === "object" && value !== null && !Array.isArray(value)
123
+ ? value
124
+ : {};
125
+ }
126
+ function stringArray(value) {
127
+ return Array.isArray(value)
128
+ ? value.filter((entry) => typeof entry === "string" && entry.length > 0)
129
+ : [];
130
+ }
131
+ function codexAgentState(value) {
132
+ const state = obj(value);
133
+ const nativeStatus = str(state, "status");
134
+ const summary = str(state, "message") || null;
135
+ if (nativeStatus === "completed")
136
+ return { terminal: true, status: "completed", summary };
137
+ if (nativeStatus === "errored" || nativeStatus === "notFound") {
138
+ return { terminal: true, status: "failed", summary };
139
+ }
140
+ if (nativeStatus === "interrupted" || nativeStatus === "shutdown") {
141
+ return { terminal: true, status: "stopped", summary };
142
+ }
143
+ return { terminal: false, status: "running", summary };
144
+ }
119
145
  function classifyMessage(msg) {
120
146
  // Detect JSON-RPC by *structure*, not by the `jsonrpc:"2.0"` discriminator.
121
147
  // codex-cli 0.130.0's `app-server` emits responses without the `jsonrpc`
@@ -271,6 +297,18 @@ export class CodexSessionImpl {
271
297
  _draining = false;
272
298
  /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
273
299
  _drainPromise = null;
300
+ /**
301
+ * Root turn targeted by `interrupt()`. The id is learned asynchronously from
302
+ * the leader `turn/start` response or the root `turn/started` notification.
303
+ * Concurrent sends reuse this latch so a queued response cannot replace the
304
+ * actual active turn.
305
+ */
306
+ _activeTurnId = null;
307
+ _activeTurnReady = null;
308
+ /** Successful repeated interrupts coalesce until the terminal notification. */
309
+ _interruptPromise = null;
310
+ /** Prevents a late interrupt request after the terminal frame was observed. */
311
+ _turnTerminalObserved = false;
274
312
  /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
275
313
  _trackToolName = createToolNameTracker();
276
314
  // Per-turn accumulators. Cleared after each result delivery so a subsequent
@@ -279,8 +317,16 @@ export class CodexSessionImpl {
279
317
  _turnUsage = null;
280
318
  _turnModel = null;
281
319
  _turnIsError = false;
320
+ _turnWasInterrupted = false;
282
321
  _turnErrorMessage = null;
283
322
  _turnStartedAt = null;
323
+ /** Child-agent lifecycle is informational and never participates in root turn settlement. */
324
+ _backgroundTasks = new Map();
325
+ _backgroundTaskIdsByPath = new Map();
326
+ /** One protocol-native thread/read poller per child when Codex does not multiplex its notifications. */
327
+ _backgroundTaskPollers = new Map();
328
+ /** Cancellable retry delay for each poller. Cleared when the session closes. */
329
+ _backgroundTaskPollTimers = new Map();
284
330
  /**
285
331
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
286
332
  * handler invocation; the chain enforces in-order delivery and lets
@@ -348,17 +394,51 @@ export class CodexSessionImpl {
348
394
  proc.on("exit", (code, signal) => {
349
395
  if (this._state !== "closed") {
350
396
  this._state = "closed";
351
- const err = new Error(`Codex process exited unexpectedly (code=${code}, signal=${signal})`);
397
+ const message = `Codex process exited unexpectedly (code=${code}, signal=${signal})`;
398
+ this.finalizeActiveBackgroundTasks("failed", message, {
399
+ method: "process/exit",
400
+ code,
401
+ signal,
402
+ });
403
+ this.cancelBackgroundTaskPollTimers();
404
+ const err = new Error(message);
352
405
  this.rejectAllPending(err);
353
406
  }
354
407
  });
355
408
  proc.on("error", (err) => {
356
409
  if (this._state !== "closed") {
357
410
  this._state = "closed";
411
+ this.finalizeActiveBackgroundTasks("failed", err.message, {
412
+ method: "process/error",
413
+ message: err.message,
414
+ });
415
+ this.cancelBackgroundTaskPollTimers();
358
416
  this.rejectAllPending(err);
359
417
  }
360
418
  });
361
419
  }
420
+ cancelBackgroundTaskPollTimers() {
421
+ for (const pending of this._backgroundTaskPollTimers.values()) {
422
+ clearTimeout(pending.timer);
423
+ pending.resolve();
424
+ }
425
+ this._backgroundTaskPollTimers.clear();
426
+ }
427
+ finalizeActiveBackgroundTasks(status, summary, raw) {
428
+ for (const task of this._backgroundTasks.values()) {
429
+ if (task.terminal)
430
+ continue;
431
+ this.dispatchEvent(this.backgroundTaskEvent(task.taskId, "completed", status, {
432
+ description: task.description,
433
+ summary,
434
+ parentTaskId: task.parentTaskId,
435
+ eventId: this._threadId
436
+ ? `codex:${this._threadId}:background-task:${task.taskId}:session-${status}:completed`
437
+ : null,
438
+ raw,
439
+ }));
440
+ }
441
+ }
362
442
  /** Reject every pending send() Promise and outgoing JSON-RPC call. */
363
443
  rejectAllPending(err) {
364
444
  const pending = this._pendingResults.splice(0);
@@ -372,9 +452,75 @@ export class CodexSessionImpl {
372
452
  for (const [, p] of this._pendingRpc)
373
453
  p.reject(err);
374
454
  this._pendingRpc.clear();
455
+ this.clearActiveTurn(err);
375
456
  }
376
457
  get sessionId() { return this._threadId; }
377
458
  get state() { return this._state; }
459
+ /** Start the identity latch before writing the leader `turn/start` request. */
460
+ beginActiveTurn() {
461
+ let resolveFn;
462
+ let rejectFn;
463
+ const promise = new Promise((resolve, reject) => {
464
+ resolveFn = resolve;
465
+ rejectFn = reject;
466
+ });
467
+ // A turn can finish without anyone calling interrupt(). Keep a later
468
+ // process-exit rejection from becoming an unhandled promise rejection.
469
+ void promise.catch(() => { });
470
+ const ready = {
471
+ promise,
472
+ resolve: resolveFn,
473
+ reject: rejectFn,
474
+ settled: false,
475
+ };
476
+ this._activeTurnId = null;
477
+ this._activeTurnReady = ready;
478
+ this._interruptPromise = null;
479
+ this._turnTerminalObserved = false;
480
+ this._turnWasInterrupted = false;
481
+ return ready;
482
+ }
483
+ /** First root turn id wins for the current latch. */
484
+ captureActiveTurnId(turnId, expected) {
485
+ const ready = this._activeTurnReady;
486
+ if (!turnId || !ready || ready.settled)
487
+ return;
488
+ if (expected && ready !== expected)
489
+ return;
490
+ this._activeTurnId = turnId;
491
+ ready.settled = true;
492
+ ready.resolve(turnId);
493
+ }
494
+ rejectActiveTurnReady(err, expected) {
495
+ if (this._activeTurnReady !== expected || expected.settled)
496
+ return;
497
+ expected.settled = true;
498
+ expected.reject(err);
499
+ }
500
+ /** Clear the current turn and release an interrupt waiting for its id. */
501
+ clearActiveTurn(err) {
502
+ const ready = this._activeTurnReady;
503
+ if (ready && !ready.settled) {
504
+ ready.settled = true;
505
+ if (err)
506
+ ready.reject(err);
507
+ else
508
+ ready.resolve(null);
509
+ }
510
+ this._activeTurnId = null;
511
+ this._activeTurnReady = null;
512
+ this._interruptPromise = null;
513
+ this._turnTerminalObserved = false;
514
+ }
515
+ /** Mark root turn termination and release an interrupt still awaiting its id. */
516
+ markTurnTerminalObserved() {
517
+ this._turnTerminalObserved = true;
518
+ const ready = this._activeTurnReady;
519
+ if (ready && !ready.settled) {
520
+ ready.settled = true;
521
+ ready.resolve(null);
522
+ }
523
+ }
378
524
  /**
379
525
  * Durable identity for persistence + later `attachSession`. Null until Codex
380
526
  * has assigned a thread id; serializes `{sessionId, cwd}` through the codec so
@@ -406,6 +552,30 @@ export class CodexSessionImpl {
406
552
  this._pendingRpc.set(id, { resolve, reject });
407
553
  });
408
554
  }
555
+ /** A bounded RPC whose pending-map entry is removed if Codex never replies. */
556
+ boundedRpcRequest(method, params, timeoutMs = BACKGROUND_TASK_READ_TIMEOUT_MS) {
557
+ const id = this._nextId++;
558
+ this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
559
+ return new Promise((resolve, reject) => {
560
+ const timer = setTimeout(() => {
561
+ if (!this._pendingRpc.delete(id))
562
+ return;
563
+ reject(new Error(`codex ${method} timed out`));
564
+ }, timeoutMs);
565
+ if (typeof timer.unref === "function")
566
+ timer.unref();
567
+ this._pendingRpc.set(id, {
568
+ resolve: (result) => {
569
+ clearTimeout(timer);
570
+ resolve(result);
571
+ },
572
+ reject: (err) => {
573
+ clearTimeout(timer);
574
+ reject(err);
575
+ },
576
+ });
577
+ });
578
+ }
409
579
  /**
410
580
  * Bounded RPC for experimental, best-effort methods (the `thread/goal/*`
411
581
  * family). An app-server build that doesn't recognize the method may never
@@ -534,6 +704,9 @@ export class CodexSessionImpl {
534
704
  // and pass through. If the second `turn/start` lands during the first
535
705
  // turn, the per-turn accumulators continue collecting until the result
536
706
  // event fires; the result then drains all pending resolvers.
707
+ const existingTurnReady = this._activeTurnReady;
708
+ const isTurnLeader = existingTurnReady === null;
709
+ const turnReady = existingTurnReady ?? this.beginActiveTurn();
537
710
  if (this._state === "idle") {
538
711
  this._state = "thinking";
539
712
  this._turnStartedAt = new Date();
@@ -571,14 +744,30 @@ export class CodexSessionImpl {
571
744
  // Per-send timeout / abort, falling back to the session-level
572
745
  // ProviderConfig.timeoutSec default when no per-call timeout is given.
573
746
  this.armSendDeadline(entry, options);
574
- this.rpcRequest("turn/start", turnParams).catch(() => {
575
- // Turn-level errors arrive via turn.failed notifications.
576
- });
747
+ const turnStart = this.rpcRequest("turn/start", turnParams);
748
+ if (isTurnLeader) {
749
+ void turnStart.then((response) => {
750
+ const turnId = str(asObj(response, "turn"), "id");
751
+ if (turnId) {
752
+ this.captureActiveTurnId(turnId, turnReady);
753
+ }
754
+ // Some app-server versions may omit the id from the response and send
755
+ // it only in turn/started. Keep the latch open for that notification.
756
+ }).catch((err) => {
757
+ this.rejectActiveTurnReady(err instanceof Error ? err : new Error(String(err)), turnReady);
758
+ // Turn-level failures may also arrive via turn/failed notifications.
759
+ });
760
+ }
761
+ else {
762
+ void turnStart.catch(() => {
763
+ // Turn-level failures arrive via turn/failed notifications.
764
+ });
765
+ }
577
766
  return { uuid, result };
578
767
  }
579
768
  /**
580
769
  * Wire up this send's timeout and/or abort signal. On fire, the active turn
581
- * is cancelled (`turn/cancel`) and the send settles with `timeout` /
770
+ * is interrupted (`turn/interrupt`) and the send settles with `timeout` /
582
771
  * `aborted`. No-op when neither a timeout nor a signal applies.
583
772
  */
584
773
  armSendDeadline(entry, options) {
@@ -621,7 +810,7 @@ export class CodexSessionImpl {
621
810
  this._pendingResults.splice(idx, 1);
622
811
  // Best-effort cancel of the active turn. With concurrent sends this ends
623
812
  // the single shared turn for all of them — see SendOptions JSDoc.
624
- void this.interrupt();
813
+ void this.interrupt().catch(() => { });
625
814
  entry.resolve({
626
815
  summary: null,
627
816
  usage: undefined,
@@ -635,7 +824,7 @@ export class CodexSessionImpl {
635
824
  }
636
825
  async cancel(_uuid) {
637
826
  // Codex's JSON-RPC protocol exposes no per-message cancel — only
638
- // turn-wide `turn/cancel` (which is what `interrupt()` calls).
827
+ // turn-wide `turn/interrupt` (which is what `interrupt()` calls).
639
828
  // capabilities.cancelQueuedMessage is false; this is a documented no-op.
640
829
  return { cancelled: false };
641
830
  }
@@ -689,13 +878,39 @@ export class CodexSessionImpl {
689
878
  }
690
879
  }
691
880
  async interrupt() {
692
- if (this._state === "idle" || this._state === "closed")
881
+ if (this._state === "closed")
693
882
  return;
883
+ const ready = this._activeTurnReady;
884
+ if (!ready || this._turnTerminalObserved)
885
+ return;
886
+ if (this._interruptPromise)
887
+ return this._interruptPromise;
888
+ const threadId = this._threadId;
889
+ if (!threadId) {
890
+ throw new Error("Cannot interrupt Codex turn before the root thread id is known");
891
+ }
694
892
  this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
893
+ const interruptPromise = (async () => {
894
+ const turnId = this._activeTurnId ?? await ready.promise;
895
+ // The turn may have completed while interrupt() was waiting for the
896
+ // leader turn/start response. In that race, completion is the success.
897
+ if (!turnId || this._activeTurnReady !== ready || this._turnTerminalObserved)
898
+ return;
899
+ await this.rpcRequest("turn/interrupt", { threadId, turnId });
900
+ })();
901
+ this._interruptPromise = interruptPromise;
695
902
  try {
696
- await this.rpcRequest("turn/cancel", {});
903
+ await interruptPromise;
904
+ }
905
+ catch (err) {
906
+ // A rejected control request must reach the host instead of becoming a
907
+ // false successful Stop. Clear only this turn's failed attempt so a
908
+ // subsequent click can retry.
909
+ if (this._activeTurnReady === ready && this._interruptPromise === interruptPromise) {
910
+ this._interruptPromise = null;
911
+ }
912
+ throw err;
697
913
  }
698
- catch { /* best effort */ }
699
914
  }
700
915
  async drain() {
701
916
  if (this._state === "closed")
@@ -716,6 +931,11 @@ export class CodexSessionImpl {
716
931
  if (this._state === "closed")
717
932
  return;
718
933
  this._state = "closed";
934
+ this.finalizeActiveBackgroundTasks("stopped", "Codex session closed", {
935
+ method: "session/closed",
936
+ });
937
+ this.cancelBackgroundTaskPollTimers();
938
+ this.rejectAllPending(new Error("Codex session closed"));
719
939
  this.proc.stdin.end();
720
940
  // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
721
941
  // for sessions running long tools.
@@ -894,7 +1114,461 @@ export class CodexSessionImpl {
894
1114
  const rootThreadId = this._threadId ?? this._expectedThreadId;
895
1115
  return !!threadId && !!rootThreadId && threadId !== rootThreadId;
896
1116
  }
1117
+ backgroundTaskParentIdForPath(agentPath) {
1118
+ if (!agentPath)
1119
+ return null;
1120
+ const separator = agentPath.lastIndexOf("/");
1121
+ if (separator <= 0)
1122
+ return null;
1123
+ return this._backgroundTaskIdsByPath.get(agentPath.slice(0, separator)) ?? null;
1124
+ }
1125
+ agentMessageText(item) {
1126
+ const direct = str(item, "text");
1127
+ if (direct)
1128
+ return direct;
1129
+ const content = Array.isArray(item["content"]) ? item["content"] : [];
1130
+ for (const entry of content) {
1131
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
1132
+ continue;
1133
+ const block = entry;
1134
+ const text = str(block, "text");
1135
+ if (text && (str(block, "type") === "output_text" || str(block, "type") === "text")) {
1136
+ return text;
1137
+ }
1138
+ }
1139
+ return null;
1140
+ }
1141
+ backgroundTaskEvent(taskId, phase, status, options) {
1142
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1143
+ return {
1144
+ type: "background_task",
1145
+ taskId,
1146
+ taskType: "subagent",
1147
+ phase,
1148
+ status,
1149
+ description: options.description ?? null,
1150
+ summary: options.summary ?? null,
1151
+ parentTaskId: options.parentTaskId ?? null,
1152
+ timestamp: new Date().toISOString(),
1153
+ providerType: "codex",
1154
+ sessionId: rootThreadId,
1155
+ messageId: null,
1156
+ eventId: options.eventId ?? null,
1157
+ turnId: options.turnId ?? null,
1158
+ parentToolCallId: null,
1159
+ raw: options.raw,
1160
+ };
1161
+ }
1162
+ reconciledBackgroundTaskRaw(threadId, turn) {
1163
+ const error = turn ? str(asObj(turn, "error"), "message") || null : null;
1164
+ return {
1165
+ method: "thread/read",
1166
+ reconciled: true,
1167
+ threadId,
1168
+ turnId: turn ? str(turn, "id") || null : null,
1169
+ status: turn ? str(turn, "status") || null : null,
1170
+ error,
1171
+ };
1172
+ }
1173
+ /**
1174
+ * Codex 0.144+ represents collaboration as a root collabAgentToolCall item.
1175
+ * A completed spawn call only means the child was created. Register every
1176
+ * receiver and then follow the child thread itself for the real terminus.
1177
+ */
1178
+ handleCollabAgentToolCall(item, raw, parentTaskId) {
1179
+ if (this._state === "closed" || !this.ctx.onEvent)
1180
+ return;
1181
+ const tool = str(item, "tool");
1182
+ const states = asObj(item, "agentsStates");
1183
+ const receiverIds = stringArray(item["receiverThreadIds"]);
1184
+ const taskIds = [...new Set([...receiverIds, ...Object.keys(states)])];
1185
+ const canStart = tool === "spawnAgent";
1186
+ const metadataOnly = tool === "resumeAgent" || tool === "sendInput";
1187
+ const canStopWithoutState = tool === "closeAgent" && str(item, "status") === "completed";
1188
+ for (const taskId of taskIds) {
1189
+ let previous = this._backgroundTasks.get(taskId);
1190
+ const description = str(item, "prompt") || previous?.description || null;
1191
+ const resolvedParentTaskId = previous?.parentTaskId ?? parentTaskId;
1192
+ // The child thread's own turn/started notification is the only
1193
+ // authoritative reactivation edge. Root resume/send calls can arrive
1194
+ // before or after that child turn, so they only enrich an active task.
1195
+ if (metadataOnly) {
1196
+ if (!previous || previous.terminal)
1197
+ continue;
1198
+ if (description && description !== previous.description) {
1199
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1200
+ description,
1201
+ summary: previous.summary,
1202
+ parentTaskId: resolvedParentTaskId,
1203
+ eventId: this._threadId
1204
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1205
+ : null,
1206
+ raw,
1207
+ }));
1208
+ }
1209
+ continue;
1210
+ }
1211
+ if (!previous && !canStart)
1212
+ continue;
1213
+ if (!canStart && !canStopWithoutState && !(taskId in states))
1214
+ continue;
1215
+ const hasState = taskId in states;
1216
+ const state = !hasState && tool === "closeAgent" && str(item, "status") === "completed"
1217
+ ? { terminal: true, status: "stopped", summary: null }
1218
+ : codexAgentState(states[taskId]);
1219
+ if (!previous) {
1220
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "started", "running", {
1221
+ description,
1222
+ summary: null,
1223
+ parentTaskId: resolvedParentTaskId,
1224
+ eventId: this._threadId
1225
+ ? `codex:${this._threadId}:background-task:${taskId}:started`
1226
+ : null,
1227
+ raw,
1228
+ }));
1229
+ previous = this._backgroundTasks.get(taskId);
1230
+ }
1231
+ else if (previous.terminal) {
1232
+ continue;
1233
+ }
1234
+ else if (description && description !== previous.description) {
1235
+ // thread/started can beat the richer root collab item. Preserve one
1236
+ // start edge, then publish the prompt as ordinary progress metadata.
1237
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "progress", "running", {
1238
+ description,
1239
+ summary: previous.summary,
1240
+ parentTaskId: resolvedParentTaskId,
1241
+ eventId: this._threadId
1242
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "metadata"}:progress`
1243
+ : null,
1244
+ raw,
1245
+ }));
1246
+ }
1247
+ if (state.terminal) {
1248
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", state.status, {
1249
+ description,
1250
+ summary: state.summary,
1251
+ parentTaskId: resolvedParentTaskId,
1252
+ eventId: this._threadId
1253
+ ? `codex:${this._threadId}:background-task:${taskId}:${str(item, "id") || "state"}:completed`
1254
+ : null,
1255
+ raw,
1256
+ }));
1257
+ }
1258
+ else {
1259
+ this.startBackgroundTaskPoller(taskId);
1260
+ }
1261
+ }
1262
+ }
1263
+ waitForBackgroundTaskPoll(pollKey) {
1264
+ if (this._state === "closed")
1265
+ return Promise.resolve();
1266
+ return new Promise((resolve) => {
1267
+ const timer = setTimeout(() => {
1268
+ this._backgroundTaskPollTimers.delete(pollKey);
1269
+ resolve();
1270
+ }, BACKGROUND_TASK_POLL_INTERVAL_MS);
1271
+ if (typeof timer.unref === "function")
1272
+ timer.unref();
1273
+ this._backgroundTaskPollTimers.set(pollKey, { timer, resolve });
1274
+ });
1275
+ }
1276
+ startBackgroundTaskPoller(taskId) {
1277
+ if (this._state === "closed")
1278
+ return;
1279
+ const task = this._backgroundTasks.get(taskId);
1280
+ if (!task || task.terminal)
1281
+ return;
1282
+ const existing = this._backgroundTaskPollers.get(taskId);
1283
+ if (existing?.generation === task.generation)
1284
+ return;
1285
+ const generation = task.generation;
1286
+ const pollKey = `${taskId}:${generation}`;
1287
+ let poller;
1288
+ poller = this.pollBackgroundTask(taskId, generation, pollKey).finally(() => {
1289
+ if (this._backgroundTaskPollers.get(taskId)?.promise === poller) {
1290
+ this._backgroundTaskPollers.delete(taskId);
1291
+ }
1292
+ const pending = this._backgroundTaskPollTimers.get(pollKey);
1293
+ if (pending)
1294
+ clearTimeout(pending.timer);
1295
+ this._backgroundTaskPollTimers.delete(pollKey);
1296
+ });
1297
+ this._backgroundTaskPollers.set(taskId, { generation, promise: poller });
1298
+ }
1299
+ async pollBackgroundTask(taskId, generation, pollKey) {
1300
+ while (this._state !== "closed") {
1301
+ const task = this._backgroundTasks.get(taskId);
1302
+ if (!task || task.generation !== generation || task.terminal)
1303
+ return;
1304
+ try {
1305
+ const response = await this.boundedRpcRequest("thread/read", {
1306
+ threadId: taskId,
1307
+ includeTurns: true,
1308
+ });
1309
+ if (this.observeBackgroundTaskThread(response, taskId, generation))
1310
+ return;
1311
+ }
1312
+ catch {
1313
+ // A child can briefly be pendingInit before thread/read can load it.
1314
+ }
1315
+ const current = this._backgroundTasks.get(taskId);
1316
+ if (!current || current.generation !== generation || current.terminal)
1317
+ return;
1318
+ await this.waitForBackgroundTaskPoll(pollKey);
1319
+ }
1320
+ }
1321
+ /** Return true once thread/read proves that the child's latest turn ended. */
1322
+ observeBackgroundTaskThread(response, taskId, generation) {
1323
+ const task = this._backgroundTasks.get(taskId);
1324
+ if (!task || task.generation !== generation || task.terminal)
1325
+ return true;
1326
+ const thread = asObj(response, "thread");
1327
+ const turns = Array.isArray(thread["turns"])
1328
+ ? thread["turns"].map(obj)
1329
+ : [];
1330
+ const latestTurn = turns[turns.length - 1];
1331
+ if (!latestTurn) {
1332
+ const threadStatus = str(asObj(thread, "status"), "type");
1333
+ if (threadStatus !== "systemError")
1334
+ return false;
1335
+ const failureTurn = { status: "systemError", error: { message: "Subagent failed to initialize" } };
1336
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", "failed", {
1337
+ description: task.description,
1338
+ summary: "Subagent failed to initialize",
1339
+ parentTaskId: task.parentTaskId,
1340
+ eventId: this._threadId
1341
+ ? `codex:${this._threadId}:background-task:${taskId}:system-error:completed`
1342
+ : null,
1343
+ raw: this.reconciledBackgroundTaskRaw(taskId, failureTurn),
1344
+ }));
1345
+ return true;
1346
+ }
1347
+ const nativeStatus = str(latestTurn, "status");
1348
+ if (nativeStatus === "inProgress" || !nativeStatus)
1349
+ return false;
1350
+ const turnId = str(latestTurn, "id") || null;
1351
+ // The child turn/started notification is authoritative for a reactivated
1352
+ // generation. thread/read can briefly lag and still end at an older turn.
1353
+ if (task.activeTurnId && turnId !== task.activeTurnId)
1354
+ return false;
1355
+ const items = Array.isArray(latestTurn["items"])
1356
+ ? latestTurn["items"].map(obj)
1357
+ : [];
1358
+ let summary = null;
1359
+ for (let index = items.length - 1; index >= 0; index--) {
1360
+ const item = items[index];
1361
+ if (str(item, "type") !== "agentMessage")
1362
+ continue;
1363
+ summary = this.agentMessageText(item);
1364
+ if (summary)
1365
+ break;
1366
+ }
1367
+ const errorMessage = str(asObj(latestTurn, "error"), "message") || null;
1368
+ const status = nativeStatus === "failed"
1369
+ ? "failed"
1370
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1371
+ ? "stopped"
1372
+ : "completed";
1373
+ this.dispatchEvent(this.backgroundTaskEvent(taskId, "completed", status, {
1374
+ description: task.description,
1375
+ summary: summary ?? errorMessage,
1376
+ parentTaskId: task.parentTaskId,
1377
+ turnId,
1378
+ eventId: this._threadId && turnId
1379
+ ? `codex:${this._threadId}:background-task:${taskId}:${turnId}:completed`
1380
+ : null,
1381
+ raw: this.reconciledBackgroundTaskRaw(taskId, latestTurn),
1382
+ }));
1383
+ return true;
1384
+ }
1385
+ /**
1386
+ * Maintain just enough child metadata to turn a later foreign-thread
1387
+ * terminal notification into one provider-neutral task event. This reducer
1388
+ * is deliberately separate from every root turn accumulator.
1389
+ */
1390
+ observeBackgroundTask(event) {
1391
+ const previous = this._backgroundTasks.get(event.taskId);
1392
+ // Codex reports `subAgentActivity:interacted` after it forwards a child's
1393
+ // final answer to the parent. The authoritative child turn/completed can
1394
+ // arrive first, so suppress that late progress edge instead of resurrecting
1395
+ // a task that already reached a terminal state. A later child turn/started
1396
+ // explicitly reactivates the record below in handleForeignNotification.
1397
+ if (previous?.terminal)
1398
+ return false;
1399
+ // collabAgentToolCall and child thread/started can both announce the same
1400
+ // spawn. The first edge is sufficient and gives hosts one durable start.
1401
+ if (previous && event.phase === "started")
1402
+ return false;
1403
+ const description = event.description ?? previous?.description ?? null;
1404
+ const summary = event.summary ?? previous?.summary ?? null;
1405
+ const parentTaskId = event.parentTaskId
1406
+ ?? previous?.parentTaskId
1407
+ ?? this.backgroundTaskParentIdForPath(description);
1408
+ event.description = description;
1409
+ event.summary = summary;
1410
+ event.parentTaskId = parentTaskId;
1411
+ this._backgroundTasks.set(event.taskId, {
1412
+ taskId: event.taskId,
1413
+ description,
1414
+ summary,
1415
+ parentTaskId,
1416
+ terminal: event.phase === "completed",
1417
+ generation: previous?.generation ?? 0,
1418
+ activeTurnId: event.phase === "completed"
1419
+ ? null
1420
+ : event.turnId ?? previous?.activeTurnId ?? null,
1421
+ });
1422
+ if (description)
1423
+ this._backgroundTaskIdsByPath.set(description, event.taskId);
1424
+ return true;
1425
+ }
1426
+ /**
1427
+ * A Codex app-server connection also publishes child thread notifications.
1428
+ * They are useful only as background-task metadata. They must never flow
1429
+ * through root state, summary, usage, or `resolveTurn()`.
1430
+ */
1431
+ handleForeignNotification(method, params, rawLine, childThreadId) {
1432
+ if (method === "thread/started") {
1433
+ const thread = asObj(params, "thread");
1434
+ const parentThreadId = str(thread, "parentThreadId") || str(thread, "parent_thread_id");
1435
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1436
+ const parentTask = this._backgroundTasks.get(parentThreadId);
1437
+ // App-server can publish child thread/started before the corresponding
1438
+ // root subAgentActivity item. Register only descendants of this session,
1439
+ // not unrelated foreign threads multiplexed by a future server version.
1440
+ if (parentThreadId !== rootThreadId && !parentTask)
1441
+ return;
1442
+ const source = asObj(thread, "source");
1443
+ const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1444
+ ? asObj(source, "subAgent")
1445
+ : asObj(source, "subagent");
1446
+ const spawnSource = Object.keys(asObj(subAgent, "threadSpawn")).length > 0
1447
+ ? asObj(subAgent, "threadSpawn")
1448
+ : asObj(subAgent, "thread_spawn");
1449
+ const description = str(spawnSource, "agentPath")
1450
+ || str(spawnSource, "agent_path")
1451
+ || str(thread, "name")
1452
+ || str(thread, "agentNickname")
1453
+ || str(thread, "agentRole")
1454
+ || null;
1455
+ this.dispatchEvent({
1456
+ type: "background_task",
1457
+ taskId: childThreadId,
1458
+ taskType: "subagent",
1459
+ phase: "started",
1460
+ status: "running",
1461
+ description,
1462
+ summary: null,
1463
+ parentTaskId: parentThreadId === rootThreadId ? null : parentThreadId,
1464
+ timestamp: new Date().toISOString(),
1465
+ providerType: "codex",
1466
+ sessionId: rootThreadId,
1467
+ messageId: null,
1468
+ eventId: rootThreadId
1469
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:started`
1470
+ : null,
1471
+ turnId: null,
1472
+ parentToolCallId: null,
1473
+ raw: parseJson(rawLine) ?? params,
1474
+ });
1475
+ this.startBackgroundTaskPoller(childThreadId);
1476
+ return;
1477
+ }
1478
+ const task = this._backgroundTasks.get(childThreadId);
1479
+ if (!task)
1480
+ return;
1481
+ if (method === "turn/started") {
1482
+ if (!task.terminal)
1483
+ return;
1484
+ task.terminal = false;
1485
+ task.summary = null;
1486
+ task.generation += 1;
1487
+ const turn = asObj(params, "turn");
1488
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1489
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1490
+ this.dispatchEvent({
1491
+ type: "background_task",
1492
+ taskId: childThreadId,
1493
+ taskType: "subagent",
1494
+ phase: "progress",
1495
+ status: "running",
1496
+ description: task.description,
1497
+ summary: null,
1498
+ parentTaskId: task.parentTaskId,
1499
+ timestamp: new Date().toISOString(),
1500
+ providerType: "codex",
1501
+ sessionId: rootThreadId,
1502
+ messageId: null,
1503
+ eventId: rootThreadId && turnId
1504
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:progress`
1505
+ : null,
1506
+ turnId,
1507
+ parentToolCallId: null,
1508
+ raw: parseJson(rawLine) ?? params,
1509
+ });
1510
+ this.startBackgroundTaskPoller(childThreadId);
1511
+ return;
1512
+ }
1513
+ if (method === "item/completed") {
1514
+ const item = asObj(params, "item");
1515
+ const itemType = str(item, "type");
1516
+ if (itemType === "collabAgentToolCall") {
1517
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, childThreadId);
1518
+ return;
1519
+ }
1520
+ if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1521
+ const summary = this.agentMessageText(item);
1522
+ if (summary)
1523
+ task.summary = summary;
1524
+ }
1525
+ return;
1526
+ }
1527
+ if (method !== "turn/completed" && method !== "turn/failed")
1528
+ return;
1529
+ const turn = asObj(params, "turn");
1530
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1531
+ const nativeStatus = method === "turn/failed" ? "failed" : str(turn, "status");
1532
+ const status = nativeStatus === "failed"
1533
+ ? "failed"
1534
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1535
+ ? "stopped"
1536
+ : "completed";
1537
+ const errorMessage = str(asObj(turn, "error"), "message")
1538
+ || str(params, "message")
1539
+ || str(params, "error");
1540
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1541
+ this.dispatchEvent({
1542
+ type: "background_task",
1543
+ taskId: childThreadId,
1544
+ taskType: "subagent",
1545
+ phase: "completed",
1546
+ status,
1547
+ description: task.description,
1548
+ summary: task.summary ?? (errorMessage || null),
1549
+ parentTaskId: task.parentTaskId,
1550
+ timestamp: new Date().toISOString(),
1551
+ providerType: "codex",
1552
+ sessionId: rootThreadId,
1553
+ messageId: null,
1554
+ eventId: rootThreadId && turnId
1555
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:completed`
1556
+ : null,
1557
+ turnId,
1558
+ parentToolCallId: null,
1559
+ raw: {
1560
+ method,
1561
+ reconciled: false,
1562
+ threadId: childThreadId,
1563
+ turnId,
1564
+ status: nativeStatus || null,
1565
+ error: errorMessage || null,
1566
+ },
1567
+ });
1568
+ }
897
1569
  handleNotification(method, params, rawLine) {
1570
+ if (this._state === "closed")
1571
+ return;
898
1572
  // codex/event — legacy wrapper
899
1573
  if (method === "codex/event") {
900
1574
  const innerMsg = str(params, "msg");
@@ -911,8 +1585,10 @@ export class CodexSessionImpl {
911
1585
  // its root thread, so foreign items must not change root state/summary and,
912
1586
  // most importantly, a child turn/completed must not resolve the root send.
913
1587
  const notificationThreadId = this.notificationThreadId(params);
914
- if (this.isForeignThread(notificationThreadId))
1588
+ if (this.isForeignThread(notificationThreadId)) {
1589
+ this.handleForeignNotification(method, params, rawLine, notificationThreadId);
915
1590
  return;
1591
+ }
916
1592
  // Map v2 notification methods to processing
917
1593
  if (method === "thread/started") {
918
1594
  // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... } }
@@ -921,6 +1597,12 @@ export class CodexSessionImpl {
921
1597
  this.emitStreamEvent(rawLine);
922
1598
  return;
923
1599
  }
1600
+ if (method === "turn/started") {
1601
+ this.captureActiveTurnId(str(asObj(params, "turn"), "id") || str(params, "turnId"));
1602
+ // The parser intentionally suppresses this lifecycle-only event.
1603
+ this.emitStreamEvent(rawLine);
1604
+ return;
1605
+ }
924
1606
  if (method === "item/started") {
925
1607
  this._state = "tool_executing";
926
1608
  this.emitStreamEvent(rawLine);
@@ -929,6 +1611,11 @@ export class CodexSessionImpl {
929
1611
  if (method === "item/completed") {
930
1612
  this._state = "thinking";
931
1613
  this.extractSummaryFromItem(params);
1614
+ const item = asObj(params, "item");
1615
+ if (str(item, "type") === "collabAgentToolCall") {
1616
+ this.handleCollabAgentToolCall(item, parseJson(rawLine) ?? params, null);
1617
+ return;
1618
+ }
932
1619
  this.emitStreamEvent(rawLine);
933
1620
  return;
934
1621
  }
@@ -937,6 +1624,7 @@ export class CodexSessionImpl {
937
1624
  return;
938
1625
  }
939
1626
  if (method === "turn/failed") {
1627
+ this.markTurnTerminalObserved();
940
1628
  this._turnIsError = true;
941
1629
  this._turnErrorMessage = str(params, "message") || str(params, "error") || "Turn failed";
942
1630
  // Emit before resolve so the result event is queued onto _eventChain
@@ -963,6 +1651,8 @@ export class CodexSessionImpl {
963
1651
  // Legacy event handling (NDJSON events with `type` field)
964
1652
  // -------------------------------------------------------------------------
965
1653
  handleLegacyEvent(event, rawLine) {
1654
+ if (this._state === "closed")
1655
+ return;
966
1656
  const type = str(event, "type");
967
1657
  const eventThreadId = str(event, "thread_id") || str(event, "threadId") || str(event, "session_id") || null;
968
1658
  // Older NDJSON-shaped events can also carry explicit thread scope. Keep
@@ -991,6 +1681,7 @@ export class CodexSessionImpl {
991
1681
  return;
992
1682
  }
993
1683
  if (type === "turn.failed" || type === "error") {
1684
+ this.markTurnTerminalObserved();
994
1685
  this._turnIsError = true;
995
1686
  this._turnErrorMessage = str(event, "message") || str(event, "error") || "Turn failed";
996
1687
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1038,6 +1729,7 @@ export class CodexSessionImpl {
1038
1729
  }
1039
1730
  }
1040
1731
  handleTurnCompleted(params) {
1732
+ this.markTurnTerminalObserved();
1041
1733
  const usage = typeof params["usage"] === "object" && params["usage"] !== null
1042
1734
  ? params["usage"]
1043
1735
  : null;
@@ -1057,7 +1749,10 @@ export class CodexSessionImpl {
1057
1749
  // the error instead of a false "completed".
1058
1750
  const turn = asObj(params, "turn");
1059
1751
  const turnStatus = str(turn, "status");
1060
- if (turnStatus === "failed" || turnStatus === "cancelled") {
1752
+ if (turnStatus === "interrupted" || turnStatus === "cancelled") {
1753
+ this._turnWasInterrupted = true;
1754
+ }
1755
+ else if (turnStatus === "failed") {
1061
1756
  this._turnIsError = true;
1062
1757
  const msg = str(asObj(turn, "error"), "message");
1063
1758
  this._turnErrorMessage = msg || this._turnErrorMessage || `Turn ${turnStatus}`;
@@ -1123,9 +1818,9 @@ export class CodexSessionImpl {
1123
1818
  summary: this._turnSummary,
1124
1819
  usage,
1125
1820
  costUsd: null,
1126
- status: this._turnIsError ? "failed" : "completed",
1127
- errorCode: this._turnIsError ? "execution_error" : null,
1128
- errorMessage: this._turnErrorMessage,
1821
+ status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
1822
+ errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
1823
+ errorMessage: this._turnWasInterrupted ? "Turn was interrupted" : this._turnErrorMessage,
1129
1824
  };
1130
1825
  // Drain pending onEvent handlers so callers awaiting send() see a settled
1131
1826
  // DB / log / UI state by the time TurnResult resolves. The chain snapshot
@@ -1149,8 +1844,10 @@ export class CodexSessionImpl {
1149
1844
  this._turnUsage = null;
1150
1845
  this._turnModel = null;
1151
1846
  this._turnIsError = false;
1847
+ this._turnWasInterrupted = false;
1152
1848
  this._turnErrorMessage = null;
1153
1849
  this._turnStartedAt = null;
1850
+ this.clearActiveTurn();
1154
1851
  for (const p of pending) {
1155
1852
  // Skip sends already settled early by timeout / abort.
1156
1853
  if (p.settled)
@@ -1181,6 +1878,8 @@ export class CodexSessionImpl {
1181
1878
  * throwing handler does not break delivery of subsequent events.
1182
1879
  */
1183
1880
  dispatchEvent(event) {
1881
+ if (event.type === "background_task" && !this.observeBackgroundTask(event))
1882
+ return;
1184
1883
  // Track native goal_status transitions (keeps getGoal() accurate).
1185
1884
  this._goals.observe(event);
1186
1885
  const cb = this.ctx.onEvent;