@agentex/agent 0.0.32 → 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.
@@ -271,6 +271,18 @@ export class CodexSessionImpl {
271
271
  _draining = false;
272
272
  /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
273
273
  _drainPromise = null;
274
+ /**
275
+ * Root turn targeted by `interrupt()`. The id is learned asynchronously from
276
+ * the leader `turn/start` response or the root `turn/started` notification.
277
+ * Concurrent sends reuse this latch so a queued response cannot replace the
278
+ * actual active turn.
279
+ */
280
+ _activeTurnId = null;
281
+ _activeTurnReady = null;
282
+ /** Successful repeated interrupts coalesce until the terminal notification. */
283
+ _interruptPromise = null;
284
+ /** Prevents a late interrupt request after the terminal frame was observed. */
285
+ _turnTerminalObserved = false;
274
286
  /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
275
287
  _trackToolName = createToolNameTracker();
276
288
  // Per-turn accumulators. Cleared after each result delivery so a subsequent
@@ -279,8 +291,12 @@ export class CodexSessionImpl {
279
291
  _turnUsage = null;
280
292
  _turnModel = null;
281
293
  _turnIsError = false;
294
+ _turnWasInterrupted = false;
282
295
  _turnErrorMessage = null;
283
296
  _turnStartedAt = null;
297
+ /** Child-agent lifecycle is informational and never participates in root turn settlement. */
298
+ _backgroundTasks = new Map();
299
+ _backgroundTaskIdsByPath = new Map();
284
300
  /**
285
301
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
286
302
  * handler invocation; the chain enforces in-order delivery and lets
@@ -372,9 +388,75 @@ export class CodexSessionImpl {
372
388
  for (const [, p] of this._pendingRpc)
373
389
  p.reject(err);
374
390
  this._pendingRpc.clear();
391
+ this.clearActiveTurn(err);
375
392
  }
376
393
  get sessionId() { return this._threadId; }
377
394
  get state() { return this._state; }
395
+ /** Start the identity latch before writing the leader `turn/start` request. */
396
+ beginActiveTurn() {
397
+ let resolveFn;
398
+ let rejectFn;
399
+ const promise = new Promise((resolve, reject) => {
400
+ resolveFn = resolve;
401
+ rejectFn = reject;
402
+ });
403
+ // A turn can finish without anyone calling interrupt(). Keep a later
404
+ // process-exit rejection from becoming an unhandled promise rejection.
405
+ void promise.catch(() => { });
406
+ const ready = {
407
+ promise,
408
+ resolve: resolveFn,
409
+ reject: rejectFn,
410
+ settled: false,
411
+ };
412
+ this._activeTurnId = null;
413
+ this._activeTurnReady = ready;
414
+ this._interruptPromise = null;
415
+ this._turnTerminalObserved = false;
416
+ this._turnWasInterrupted = false;
417
+ return ready;
418
+ }
419
+ /** First root turn id wins for the current latch. */
420
+ captureActiveTurnId(turnId, expected) {
421
+ const ready = this._activeTurnReady;
422
+ if (!turnId || !ready || ready.settled)
423
+ return;
424
+ if (expected && ready !== expected)
425
+ return;
426
+ this._activeTurnId = turnId;
427
+ ready.settled = true;
428
+ ready.resolve(turnId);
429
+ }
430
+ rejectActiveTurnReady(err, expected) {
431
+ if (this._activeTurnReady !== expected || expected.settled)
432
+ return;
433
+ expected.settled = true;
434
+ expected.reject(err);
435
+ }
436
+ /** Clear the current turn and release an interrupt waiting for its id. */
437
+ clearActiveTurn(err) {
438
+ const ready = this._activeTurnReady;
439
+ if (ready && !ready.settled) {
440
+ ready.settled = true;
441
+ if (err)
442
+ ready.reject(err);
443
+ else
444
+ ready.resolve(null);
445
+ }
446
+ this._activeTurnId = null;
447
+ this._activeTurnReady = null;
448
+ this._interruptPromise = null;
449
+ this._turnTerminalObserved = false;
450
+ }
451
+ /** Mark root turn termination and release an interrupt still awaiting its id. */
452
+ markTurnTerminalObserved() {
453
+ this._turnTerminalObserved = true;
454
+ const ready = this._activeTurnReady;
455
+ if (ready && !ready.settled) {
456
+ ready.settled = true;
457
+ ready.resolve(null);
458
+ }
459
+ }
378
460
  /**
379
461
  * Durable identity for persistence + later `attachSession`. Null until Codex
380
462
  * has assigned a thread id; serializes `{sessionId, cwd}` through the codec so
@@ -534,6 +616,9 @@ export class CodexSessionImpl {
534
616
  // and pass through. If the second `turn/start` lands during the first
535
617
  // turn, the per-turn accumulators continue collecting until the result
536
618
  // event fires; the result then drains all pending resolvers.
619
+ const existingTurnReady = this._activeTurnReady;
620
+ const isTurnLeader = existingTurnReady === null;
621
+ const turnReady = existingTurnReady ?? this.beginActiveTurn();
537
622
  if (this._state === "idle") {
538
623
  this._state = "thinking";
539
624
  this._turnStartedAt = new Date();
@@ -571,14 +656,30 @@ export class CodexSessionImpl {
571
656
  // Per-send timeout / abort, falling back to the session-level
572
657
  // ProviderConfig.timeoutSec default when no per-call timeout is given.
573
658
  this.armSendDeadline(entry, options);
574
- this.rpcRequest("turn/start", turnParams).catch(() => {
575
- // Turn-level errors arrive via turn.failed notifications.
576
- });
659
+ const turnStart = this.rpcRequest("turn/start", turnParams);
660
+ if (isTurnLeader) {
661
+ void turnStart.then((response) => {
662
+ const turnId = str(asObj(response, "turn"), "id");
663
+ if (turnId) {
664
+ this.captureActiveTurnId(turnId, turnReady);
665
+ }
666
+ // Some app-server versions may omit the id from the response and send
667
+ // it only in turn/started. Keep the latch open for that notification.
668
+ }).catch((err) => {
669
+ this.rejectActiveTurnReady(err instanceof Error ? err : new Error(String(err)), turnReady);
670
+ // Turn-level failures may also arrive via turn/failed notifications.
671
+ });
672
+ }
673
+ else {
674
+ void turnStart.catch(() => {
675
+ // Turn-level failures arrive via turn/failed notifications.
676
+ });
677
+ }
577
678
  return { uuid, result };
578
679
  }
579
680
  /**
580
681
  * 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` /
682
+ * is interrupted (`turn/interrupt`) and the send settles with `timeout` /
582
683
  * `aborted`. No-op when neither a timeout nor a signal applies.
583
684
  */
584
685
  armSendDeadline(entry, options) {
@@ -621,7 +722,7 @@ export class CodexSessionImpl {
621
722
  this._pendingResults.splice(idx, 1);
622
723
  // Best-effort cancel of the active turn. With concurrent sends this ends
623
724
  // the single shared turn for all of them — see SendOptions JSDoc.
624
- void this.interrupt();
725
+ void this.interrupt().catch(() => { });
625
726
  entry.resolve({
626
727
  summary: null,
627
728
  usage: undefined,
@@ -635,7 +736,7 @@ export class CodexSessionImpl {
635
736
  }
636
737
  async cancel(_uuid) {
637
738
  // Codex's JSON-RPC protocol exposes no per-message cancel — only
638
- // turn-wide `turn/cancel` (which is what `interrupt()` calls).
739
+ // turn-wide `turn/interrupt` (which is what `interrupt()` calls).
639
740
  // capabilities.cancelQueuedMessage is false; this is a documented no-op.
640
741
  return { cancelled: false };
641
742
  }
@@ -689,13 +790,39 @@ export class CodexSessionImpl {
689
790
  }
690
791
  }
691
792
  async interrupt() {
692
- if (this._state === "idle" || this._state === "closed")
793
+ if (this._state === "closed")
794
+ return;
795
+ const ready = this._activeTurnReady;
796
+ if (!ready || this._turnTerminalObserved)
693
797
  return;
798
+ if (this._interruptPromise)
799
+ return this._interruptPromise;
800
+ const threadId = this._threadId;
801
+ if (!threadId) {
802
+ throw new Error("Cannot interrupt Codex turn before the root thread id is known");
803
+ }
694
804
  this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
805
+ const interruptPromise = (async () => {
806
+ const turnId = this._activeTurnId ?? await ready.promise;
807
+ // The turn may have completed while interrupt() was waiting for the
808
+ // leader turn/start response. In that race, completion is the success.
809
+ if (!turnId || this._activeTurnReady !== ready || this._turnTerminalObserved)
810
+ return;
811
+ await this.rpcRequest("turn/interrupt", { threadId, turnId });
812
+ })();
813
+ this._interruptPromise = interruptPromise;
695
814
  try {
696
- await this.rpcRequest("turn/cancel", {});
815
+ await interruptPromise;
816
+ }
817
+ catch (err) {
818
+ // A rejected control request must reach the host instead of becoming a
819
+ // false successful Stop. Clear only this turn's failed attempt so a
820
+ // subsequent click can retry.
821
+ if (this._activeTurnReady === ready && this._interruptPromise === interruptPromise) {
822
+ this._interruptPromise = null;
823
+ }
824
+ throw err;
697
825
  }
698
- catch { /* best effort */ }
699
826
  }
700
827
  async drain() {
701
828
  if (this._state === "closed")
@@ -716,6 +843,7 @@ export class CodexSessionImpl {
716
843
  if (this._state === "closed")
717
844
  return;
718
845
  this._state = "closed";
846
+ this.rejectAllPending(new Error("Codex session closed"));
719
847
  this.proc.stdin.end();
720
848
  // Grace window before SIGKILL is configurable via ProviderConfig.graceSec
721
849
  // for sessions running long tools.
@@ -894,6 +1022,192 @@ export class CodexSessionImpl {
894
1022
  const rootThreadId = this._threadId ?? this._expectedThreadId;
895
1023
  return !!threadId && !!rootThreadId && threadId !== rootThreadId;
896
1024
  }
1025
+ backgroundTaskParentIdForPath(agentPath) {
1026
+ if (!agentPath)
1027
+ return null;
1028
+ const separator = agentPath.lastIndexOf("/");
1029
+ if (separator <= 0)
1030
+ return null;
1031
+ return this._backgroundTaskIdsByPath.get(agentPath.slice(0, separator)) ?? null;
1032
+ }
1033
+ agentMessageText(item) {
1034
+ const direct = str(item, "text");
1035
+ if (direct)
1036
+ return direct;
1037
+ const content = Array.isArray(item["content"]) ? item["content"] : [];
1038
+ for (const entry of content) {
1039
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry))
1040
+ continue;
1041
+ const block = entry;
1042
+ const text = str(block, "text");
1043
+ if (text && (str(block, "type") === "output_text" || str(block, "type") === "text")) {
1044
+ return text;
1045
+ }
1046
+ }
1047
+ return null;
1048
+ }
1049
+ /**
1050
+ * Maintain just enough child metadata to turn a later foreign-thread
1051
+ * terminal notification into one provider-neutral task event. This reducer
1052
+ * is deliberately separate from every root turn accumulator.
1053
+ */
1054
+ observeBackgroundTask(event) {
1055
+ const previous = this._backgroundTasks.get(event.taskId);
1056
+ // Codex reports `subAgentActivity:interacted` after it forwards a child's
1057
+ // final answer to the parent. The authoritative child turn/completed can
1058
+ // arrive first, so suppress that late progress edge instead of resurrecting
1059
+ // a task that already reached a terminal state. A later child turn/started
1060
+ // explicitly reactivates the record below in handleForeignNotification.
1061
+ if (previous?.terminal)
1062
+ return false;
1063
+ const description = event.description ?? previous?.description ?? null;
1064
+ const summary = event.summary ?? previous?.summary ?? null;
1065
+ const parentTaskId = event.parentTaskId
1066
+ ?? previous?.parentTaskId
1067
+ ?? this.backgroundTaskParentIdForPath(description);
1068
+ event.description = description;
1069
+ event.summary = summary;
1070
+ event.parentTaskId = parentTaskId;
1071
+ this._backgroundTasks.set(event.taskId, {
1072
+ taskId: event.taskId,
1073
+ description,
1074
+ summary,
1075
+ parentTaskId,
1076
+ terminal: event.phase === "completed",
1077
+ });
1078
+ if (description)
1079
+ this._backgroundTaskIdsByPath.set(description, event.taskId);
1080
+ return true;
1081
+ }
1082
+ /**
1083
+ * A Codex app-server connection also publishes child thread notifications.
1084
+ * They are useful only as background-task metadata. They must never flow
1085
+ * through root state, summary, usage, or `resolveTurn()`.
1086
+ */
1087
+ handleForeignNotification(method, params, rawLine, childThreadId) {
1088
+ if (method === "thread/started") {
1089
+ const thread = asObj(params, "thread");
1090
+ const parentThreadId = str(thread, "parentThreadId") || str(thread, "parent_thread_id");
1091
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1092
+ const parentTask = this._backgroundTasks.get(parentThreadId);
1093
+ // App-server can publish child thread/started before the corresponding
1094
+ // root subAgentActivity item. Register only descendants of this session,
1095
+ // not unrelated foreign threads multiplexed by a future server version.
1096
+ if (parentThreadId !== rootThreadId && !parentTask)
1097
+ return;
1098
+ const source = asObj(thread, "source");
1099
+ const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1100
+ ? asObj(source, "subAgent")
1101
+ : asObj(source, "subagent");
1102
+ const spawnSource = Object.keys(asObj(subAgent, "threadSpawn")).length > 0
1103
+ ? asObj(subAgent, "threadSpawn")
1104
+ : asObj(subAgent, "thread_spawn");
1105
+ const description = str(spawnSource, "agentPath")
1106
+ || str(spawnSource, "agent_path")
1107
+ || str(thread, "name")
1108
+ || str(thread, "agentNickname")
1109
+ || str(thread, "agentRole")
1110
+ || null;
1111
+ this.dispatchEvent({
1112
+ type: "background_task",
1113
+ taskId: childThreadId,
1114
+ taskType: "subagent",
1115
+ phase: "started",
1116
+ status: "running",
1117
+ description,
1118
+ summary: null,
1119
+ parentTaskId: parentThreadId === rootThreadId ? null : parentThreadId,
1120
+ timestamp: new Date().toISOString(),
1121
+ providerType: "codex",
1122
+ sessionId: rootThreadId,
1123
+ messageId: null,
1124
+ eventId: rootThreadId
1125
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:started`
1126
+ : null,
1127
+ turnId: null,
1128
+ parentToolCallId: null,
1129
+ raw: parseJson(rawLine) ?? params,
1130
+ });
1131
+ return;
1132
+ }
1133
+ const task = this._backgroundTasks.get(childThreadId);
1134
+ if (!task)
1135
+ return;
1136
+ if (method === "turn/started") {
1137
+ if (!task.terminal)
1138
+ return;
1139
+ task.terminal = false;
1140
+ task.summary = null;
1141
+ const turn = asObj(params, "turn");
1142
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1143
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1144
+ this.dispatchEvent({
1145
+ type: "background_task",
1146
+ taskId: childThreadId,
1147
+ taskType: "subagent",
1148
+ phase: "progress",
1149
+ status: "running",
1150
+ description: task.description,
1151
+ summary: null,
1152
+ parentTaskId: task.parentTaskId,
1153
+ timestamp: new Date().toISOString(),
1154
+ providerType: "codex",
1155
+ sessionId: rootThreadId,
1156
+ messageId: null,
1157
+ eventId: rootThreadId && turnId
1158
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:progress`
1159
+ : null,
1160
+ turnId,
1161
+ parentToolCallId: null,
1162
+ raw: parseJson(rawLine) ?? params,
1163
+ });
1164
+ return;
1165
+ }
1166
+ if (method === "item/completed") {
1167
+ const item = asObj(params, "item");
1168
+ const itemType = str(item, "type");
1169
+ if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1170
+ const summary = this.agentMessageText(item);
1171
+ if (summary)
1172
+ task.summary = summary;
1173
+ }
1174
+ return;
1175
+ }
1176
+ if (method !== "turn/completed" && method !== "turn/failed")
1177
+ return;
1178
+ const turn = asObj(params, "turn");
1179
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1180
+ const nativeStatus = method === "turn/failed" ? "failed" : str(turn, "status");
1181
+ const status = nativeStatus === "failed"
1182
+ ? "failed"
1183
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1184
+ ? "stopped"
1185
+ : "completed";
1186
+ const errorMessage = str(asObj(turn, "error"), "message")
1187
+ || str(params, "message")
1188
+ || str(params, "error");
1189
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1190
+ this.dispatchEvent({
1191
+ type: "background_task",
1192
+ taskId: childThreadId,
1193
+ taskType: "subagent",
1194
+ phase: "completed",
1195
+ status,
1196
+ description: task.description,
1197
+ summary: task.summary ?? (errorMessage || null),
1198
+ parentTaskId: task.parentTaskId,
1199
+ timestamp: new Date().toISOString(),
1200
+ providerType: "codex",
1201
+ sessionId: rootThreadId,
1202
+ messageId: null,
1203
+ eventId: rootThreadId && turnId
1204
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:completed`
1205
+ : null,
1206
+ turnId,
1207
+ parentToolCallId: null,
1208
+ raw: parseJson(rawLine) ?? params,
1209
+ });
1210
+ }
897
1211
  handleNotification(method, params, rawLine) {
898
1212
  // codex/event — legacy wrapper
899
1213
  if (method === "codex/event") {
@@ -911,8 +1225,10 @@ export class CodexSessionImpl {
911
1225
  // its root thread, so foreign items must not change root state/summary and,
912
1226
  // most importantly, a child turn/completed must not resolve the root send.
913
1227
  const notificationThreadId = this.notificationThreadId(params);
914
- if (this.isForeignThread(notificationThreadId))
1228
+ if (this.isForeignThread(notificationThreadId)) {
1229
+ this.handleForeignNotification(method, params, rawLine, notificationThreadId);
915
1230
  return;
1231
+ }
916
1232
  // Map v2 notification methods to processing
917
1233
  if (method === "thread/started") {
918
1234
  // codex-cli 0.130.0+ shape: { thread: { id, sessionId, ... } }
@@ -921,6 +1237,12 @@ export class CodexSessionImpl {
921
1237
  this.emitStreamEvent(rawLine);
922
1238
  return;
923
1239
  }
1240
+ if (method === "turn/started") {
1241
+ this.captureActiveTurnId(str(asObj(params, "turn"), "id") || str(params, "turnId"));
1242
+ // The parser intentionally suppresses this lifecycle-only event.
1243
+ this.emitStreamEvent(rawLine);
1244
+ return;
1245
+ }
924
1246
  if (method === "item/started") {
925
1247
  this._state = "tool_executing";
926
1248
  this.emitStreamEvent(rawLine);
@@ -937,6 +1259,7 @@ export class CodexSessionImpl {
937
1259
  return;
938
1260
  }
939
1261
  if (method === "turn/failed") {
1262
+ this.markTurnTerminalObserved();
940
1263
  this._turnIsError = true;
941
1264
  this._turnErrorMessage = str(params, "message") || str(params, "error") || "Turn failed";
942
1265
  // Emit before resolve so the result event is queued onto _eventChain
@@ -991,6 +1314,7 @@ export class CodexSessionImpl {
991
1314
  return;
992
1315
  }
993
1316
  if (type === "turn.failed" || type === "error") {
1317
+ this.markTurnTerminalObserved();
994
1318
  this._turnIsError = true;
995
1319
  this._turnErrorMessage = str(event, "message") || str(event, "error") || "Turn failed";
996
1320
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1038,6 +1362,7 @@ export class CodexSessionImpl {
1038
1362
  }
1039
1363
  }
1040
1364
  handleTurnCompleted(params) {
1365
+ this.markTurnTerminalObserved();
1041
1366
  const usage = typeof params["usage"] === "object" && params["usage"] !== null
1042
1367
  ? params["usage"]
1043
1368
  : null;
@@ -1057,7 +1382,10 @@ export class CodexSessionImpl {
1057
1382
  // the error instead of a false "completed".
1058
1383
  const turn = asObj(params, "turn");
1059
1384
  const turnStatus = str(turn, "status");
1060
- if (turnStatus === "failed" || turnStatus === "cancelled") {
1385
+ if (turnStatus === "interrupted" || turnStatus === "cancelled") {
1386
+ this._turnWasInterrupted = true;
1387
+ }
1388
+ else if (turnStatus === "failed") {
1061
1389
  this._turnIsError = true;
1062
1390
  const msg = str(asObj(turn, "error"), "message");
1063
1391
  this._turnErrorMessage = msg || this._turnErrorMessage || `Turn ${turnStatus}`;
@@ -1123,9 +1451,9 @@ export class CodexSessionImpl {
1123
1451
  summary: this._turnSummary,
1124
1452
  usage,
1125
1453
  costUsd: null,
1126
- status: this._turnIsError ? "failed" : "completed",
1127
- errorCode: this._turnIsError ? "execution_error" : null,
1128
- errorMessage: this._turnErrorMessage,
1454
+ status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
1455
+ errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
1456
+ errorMessage: this._turnWasInterrupted ? "Turn was interrupted" : this._turnErrorMessage,
1129
1457
  };
1130
1458
  // Drain pending onEvent handlers so callers awaiting send() see a settled
1131
1459
  // DB / log / UI state by the time TurnResult resolves. The chain snapshot
@@ -1149,8 +1477,10 @@ export class CodexSessionImpl {
1149
1477
  this._turnUsage = null;
1150
1478
  this._turnModel = null;
1151
1479
  this._turnIsError = false;
1480
+ this._turnWasInterrupted = false;
1152
1481
  this._turnErrorMessage = null;
1153
1482
  this._turnStartedAt = null;
1483
+ this.clearActiveTurn();
1154
1484
  for (const p of pending) {
1155
1485
  // Skip sends already settled early by timeout / abort.
1156
1486
  if (p.settled)
@@ -1181,6 +1511,8 @@ export class CodexSessionImpl {
1181
1511
  * throwing handler does not break delivery of subsequent events.
1182
1512
  */
1183
1513
  dispatchEvent(event) {
1514
+ if (event.type === "background_task" && !this.observeBackgroundTask(event))
1515
+ return;
1184
1516
  // Track native goal_status transitions (keeps getGoal() accurate).
1185
1517
  this._goals.observe(event);
1186
1518
  const cb = this.ctx.onEvent;