@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.
@@ -132,6 +132,22 @@ interface PendingResult {
132
132
  cleanup?: () => void;
133
133
  }
134
134
 
135
+ interface TrackedBackgroundTask {
136
+ taskId: string;
137
+ description: string | null;
138
+ summary: string | null;
139
+ parentTaskId: string | null;
140
+ terminal: boolean;
141
+ }
142
+
143
+ /** Identity latch for the root turn currently represented by this session. */
144
+ interface ActiveTurnReady {
145
+ promise: Promise<string | null>;
146
+ resolve: (turnId: string | null) => void;
147
+ reject: (err: Error) => void;
148
+ settled: boolean;
149
+ }
150
+
135
151
  // ---------------------------------------------------------------------------
136
152
  // JSON-RPC 2.0 helpers
137
153
  // ---------------------------------------------------------------------------
@@ -342,6 +358,19 @@ export class CodexSessionImpl implements AgentSession {
342
358
  /** Shared promise so concurrent / repeated `drain()` calls coalesce. */
343
359
  private _drainPromise: Promise<void> | null = null;
344
360
 
361
+ /**
362
+ * Root turn targeted by `interrupt()`. The id is learned asynchronously from
363
+ * the leader `turn/start` response or the root `turn/started` notification.
364
+ * Concurrent sends reuse this latch so a queued response cannot replace the
365
+ * actual active turn.
366
+ */
367
+ private _activeTurnId: string | null = null;
368
+ private _activeTurnReady: ActiveTurnReady | null = null;
369
+ /** Successful repeated interrupts coalesce until the terminal notification. */
370
+ private _interruptPromise: Promise<void> | null = null;
371
+ /** Prevents a late interrupt request after the terminal frame was observed. */
372
+ private _turnTerminalObserved = false;
373
+
345
374
  /** Stamps `tool_result.toolName` by correlating with prior `tool_call`s. */
346
375
  private readonly _trackToolName = createToolNameTracker();
347
376
 
@@ -351,9 +380,14 @@ export class CodexSessionImpl implements AgentSession {
351
380
  private _turnUsage: { inputTokens: number; outputTokens: number } | null = null;
352
381
  private _turnModel: string | null = null;
353
382
  private _turnIsError = false;
383
+ private _turnWasInterrupted = false;
354
384
  private _turnErrorMessage: string | null = null;
355
385
  private _turnStartedAt: Date | null = null;
356
386
 
387
+ /** Child-agent lifecycle is informational and never participates in root turn settlement. */
388
+ private readonly _backgroundTasks = new Map<string, TrackedBackgroundTask>();
389
+ private readonly _backgroundTaskIdsByPath = new Map<string, string>();
390
+
357
391
  /**
358
392
  * Serial dispatch chain for `onEvent`. Each dispatched event appends a
359
393
  * handler invocation; the chain enforces in-order delivery and lets
@@ -446,11 +480,78 @@ export class CodexSessionImpl implements AgentSession {
446
480
  }
447
481
  for (const [, p] of this._pendingRpc) p.reject(err);
448
482
  this._pendingRpc.clear();
483
+ this.clearActiveTurn(err);
449
484
  }
450
485
 
451
486
  get sessionId(): string | null { return this._threadId; }
452
487
  get state(): SessionState { return this._state; }
453
488
 
489
+ /** Start the identity latch before writing the leader `turn/start` request. */
490
+ private beginActiveTurn(): ActiveTurnReady {
491
+ let resolveFn!: (turnId: string | null) => void;
492
+ let rejectFn!: (err: Error) => void;
493
+ const promise = new Promise<string | null>((resolve, reject) => {
494
+ resolveFn = resolve;
495
+ rejectFn = reject;
496
+ });
497
+ // A turn can finish without anyone calling interrupt(). Keep a later
498
+ // process-exit rejection from becoming an unhandled promise rejection.
499
+ void promise.catch(() => {});
500
+
501
+ const ready: ActiveTurnReady = {
502
+ promise,
503
+ resolve: resolveFn,
504
+ reject: rejectFn,
505
+ settled: false,
506
+ };
507
+ this._activeTurnId = null;
508
+ this._activeTurnReady = ready;
509
+ this._interruptPromise = null;
510
+ this._turnTerminalObserved = false;
511
+ this._turnWasInterrupted = false;
512
+ return ready;
513
+ }
514
+
515
+ /** First root turn id wins for the current latch. */
516
+ private captureActiveTurnId(turnId: string, expected?: ActiveTurnReady): void {
517
+ const ready = this._activeTurnReady;
518
+ if (!turnId || !ready || ready.settled) return;
519
+ if (expected && ready !== expected) return;
520
+ this._activeTurnId = turnId;
521
+ ready.settled = true;
522
+ ready.resolve(turnId);
523
+ }
524
+
525
+ private rejectActiveTurnReady(err: Error, expected: ActiveTurnReady): void {
526
+ if (this._activeTurnReady !== expected || expected.settled) return;
527
+ expected.settled = true;
528
+ expected.reject(err);
529
+ }
530
+
531
+ /** Clear the current turn and release an interrupt waiting for its id. */
532
+ private clearActiveTurn(err?: Error): void {
533
+ const ready = this._activeTurnReady;
534
+ if (ready && !ready.settled) {
535
+ ready.settled = true;
536
+ if (err) ready.reject(err);
537
+ else ready.resolve(null);
538
+ }
539
+ this._activeTurnId = null;
540
+ this._activeTurnReady = null;
541
+ this._interruptPromise = null;
542
+ this._turnTerminalObserved = false;
543
+ }
544
+
545
+ /** Mark root turn termination and release an interrupt still awaiting its id. */
546
+ private markTurnTerminalObserved(): void {
547
+ this._turnTerminalObserved = true;
548
+ const ready = this._activeTurnReady;
549
+ if (ready && !ready.settled) {
550
+ ready.settled = true;
551
+ ready.resolve(null);
552
+ }
553
+ }
554
+
454
555
  /**
455
556
  * Durable identity for persistence + later `attachSession`. Null until Codex
456
557
  * has assigned a thread id; serializes `{sessionId, cwd}` through the codec so
@@ -618,6 +719,10 @@ export class CodexSessionImpl implements AgentSession {
618
719
  // and pass through. If the second `turn/start` lands during the first
619
720
  // turn, the per-turn accumulators continue collecting until the result
620
721
  // event fires; the result then drains all pending resolvers.
722
+ const existingTurnReady = this._activeTurnReady;
723
+ const isTurnLeader = existingTurnReady === null;
724
+ const turnReady = existingTurnReady ?? this.beginActiveTurn();
725
+
621
726
  if (this._state === "idle") {
622
727
  this._state = "thinking";
623
728
  this._turnStartedAt = new Date();
@@ -659,16 +764,34 @@ export class CodexSessionImpl implements AgentSession {
659
764
  // ProviderConfig.timeoutSec default when no per-call timeout is given.
660
765
  this.armSendDeadline(entry, options);
661
766
 
662
- this.rpcRequest("turn/start", turnParams).catch(() => {
663
- // Turn-level errors arrive via turn.failed notifications.
664
- });
767
+ const turnStart = this.rpcRequest("turn/start", turnParams);
768
+ if (isTurnLeader) {
769
+ void turnStart.then((response) => {
770
+ const turnId = str(asObj(response, "turn"), "id");
771
+ if (turnId) {
772
+ this.captureActiveTurnId(turnId, turnReady);
773
+ }
774
+ // Some app-server versions may omit the id from the response and send
775
+ // it only in turn/started. Keep the latch open for that notification.
776
+ }).catch((err: unknown) => {
777
+ this.rejectActiveTurnReady(
778
+ err instanceof Error ? err : new Error(String(err)),
779
+ turnReady,
780
+ );
781
+ // Turn-level failures may also arrive via turn/failed notifications.
782
+ });
783
+ } else {
784
+ void turnStart.catch(() => {
785
+ // Turn-level failures arrive via turn/failed notifications.
786
+ });
787
+ }
665
788
 
666
789
  return { uuid, result };
667
790
  }
668
791
 
669
792
  /**
670
793
  * 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` /
794
+ * is interrupted (`turn/interrupt`) and the send settles with `timeout` /
672
795
  * `aborted`. No-op when neither a timeout nor a signal applies.
673
796
  */
674
797
  private armSendDeadline(entry: PendingResult, options?: SendOptions): void {
@@ -711,7 +834,7 @@ export class CodexSessionImpl implements AgentSession {
711
834
 
712
835
  // Best-effort cancel of the active turn. With concurrent sends this ends
713
836
  // the single shared turn for all of them — see SendOptions JSDoc.
714
- void this.interrupt();
837
+ void this.interrupt().catch(() => {});
715
838
 
716
839
  entry.resolve({
717
840
  summary: null,
@@ -727,7 +850,7 @@ export class CodexSessionImpl implements AgentSession {
727
850
 
728
851
  async cancel(_uuid: string): Promise<CancelResult> {
729
852
  // Codex's JSON-RPC protocol exposes no per-message cancel — only
730
- // turn-wide `turn/cancel` (which is what `interrupt()` calls).
853
+ // turn-wide `turn/interrupt` (which is what `interrupt()` calls).
731
854
  // capabilities.cancelQueuedMessage is false; this is a documented no-op.
732
855
  return { cancelled: false };
733
856
  }
@@ -780,11 +903,37 @@ export class CodexSessionImpl implements AgentSession {
780
903
  }
781
904
 
782
905
  async interrupt(): Promise<void> {
783
- if (this._state === "idle" || this._state === "closed") return;
906
+ if (this._state === "closed") return;
907
+ const ready = this._activeTurnReady;
908
+ if (!ready || this._turnTerminalObserved) return;
909
+ if (this._interruptPromise) return this._interruptPromise;
910
+
911
+ const threadId = this._threadId;
912
+ if (!threadId) {
913
+ throw new Error("Cannot interrupt Codex turn before the root thread id is known");
914
+ }
784
915
  this._goals.notifyInterrupted(); // don't let an emulated goal auto-continue
916
+
917
+ const interruptPromise = (async () => {
918
+ const turnId = this._activeTurnId ?? await ready.promise;
919
+ // The turn may have completed while interrupt() was waiting for the
920
+ // leader turn/start response. In that race, completion is the success.
921
+ if (!turnId || this._activeTurnReady !== ready || this._turnTerminalObserved) return;
922
+ await this.rpcRequest("turn/interrupt", { threadId, turnId });
923
+ })();
924
+ this._interruptPromise = interruptPromise;
925
+
785
926
  try {
786
- await this.rpcRequest("turn/cancel", {});
787
- } catch { /* best effort */ }
927
+ await interruptPromise;
928
+ } catch (err) {
929
+ // A rejected control request must reach the host instead of becoming a
930
+ // false successful Stop. Clear only this turn's failed attempt so a
931
+ // subsequent click can retry.
932
+ if (this._activeTurnReady === ready && this._interruptPromise === interruptPromise) {
933
+ this._interruptPromise = null;
934
+ }
935
+ throw err;
936
+ }
788
937
  }
789
938
 
790
939
  async drain(): Promise<void> {
@@ -804,6 +953,7 @@ export class CodexSessionImpl implements AgentSession {
804
953
  async close(): Promise<void> {
805
954
  if (this._state === "closed") return;
806
955
  this._state = "closed";
956
+ this.rejectAllPending(new Error("Codex session closed"));
807
957
 
808
958
  this.proc.stdin!.end();
809
959
 
@@ -1003,6 +1153,201 @@ export class CodexSessionImpl implements AgentSession {
1003
1153
  return !!threadId && !!rootThreadId && threadId !== rootThreadId;
1004
1154
  }
1005
1155
 
1156
+ private backgroundTaskParentIdForPath(agentPath: string | null): string | null {
1157
+ if (!agentPath) return null;
1158
+ const separator = agentPath.lastIndexOf("/");
1159
+ if (separator <= 0) return null;
1160
+ return this._backgroundTaskIdsByPath.get(agentPath.slice(0, separator)) ?? null;
1161
+ }
1162
+
1163
+ private agentMessageText(item: Record<string, unknown>): string | null {
1164
+ const direct = str(item, "text");
1165
+ if (direct) return direct;
1166
+ const content = Array.isArray(item["content"]) ? item["content"] : [];
1167
+ for (const entry of content) {
1168
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
1169
+ const block = entry as Record<string, unknown>;
1170
+ const text = str(block, "text");
1171
+ if (text && (str(block, "type") === "output_text" || str(block, "type") === "text")) {
1172
+ return text;
1173
+ }
1174
+ }
1175
+ return null;
1176
+ }
1177
+
1178
+ /**
1179
+ * Maintain just enough child metadata to turn a later foreign-thread
1180
+ * terminal notification into one provider-neutral task event. This reducer
1181
+ * is deliberately separate from every root turn accumulator.
1182
+ */
1183
+ private observeBackgroundTask(event: Extract<StreamEvent, { type: "background_task" }>): boolean {
1184
+ const previous = this._backgroundTasks.get(event.taskId);
1185
+ // Codex reports `subAgentActivity:interacted` after it forwards a child's
1186
+ // final answer to the parent. The authoritative child turn/completed can
1187
+ // arrive first, so suppress that late progress edge instead of resurrecting
1188
+ // a task that already reached a terminal state. A later child turn/started
1189
+ // explicitly reactivates the record below in handleForeignNotification.
1190
+ if (previous?.terminal) return false;
1191
+
1192
+ const description = event.description ?? previous?.description ?? null;
1193
+ const summary = event.summary ?? previous?.summary ?? null;
1194
+ const parentTaskId = event.parentTaskId
1195
+ ?? previous?.parentTaskId
1196
+ ?? this.backgroundTaskParentIdForPath(description);
1197
+
1198
+ event.description = description;
1199
+ event.summary = summary;
1200
+ event.parentTaskId = parentTaskId;
1201
+
1202
+ this._backgroundTasks.set(event.taskId, {
1203
+ taskId: event.taskId,
1204
+ description,
1205
+ summary,
1206
+ parentTaskId,
1207
+ terminal: event.phase === "completed",
1208
+ });
1209
+ if (description) this._backgroundTaskIdsByPath.set(description, event.taskId);
1210
+ return true;
1211
+ }
1212
+
1213
+ /**
1214
+ * A Codex app-server connection also publishes child thread notifications.
1215
+ * They are useful only as background-task metadata. They must never flow
1216
+ * through root state, summary, usage, or `resolveTurn()`.
1217
+ */
1218
+ private handleForeignNotification(
1219
+ method: string,
1220
+ params: Record<string, unknown>,
1221
+ rawLine: string,
1222
+ childThreadId: string,
1223
+ ): void {
1224
+ if (method === "thread/started") {
1225
+ const thread = asObj(params, "thread");
1226
+ const parentThreadId = str(thread, "parentThreadId") || str(thread, "parent_thread_id");
1227
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1228
+ const parentTask = this._backgroundTasks.get(parentThreadId);
1229
+ // App-server can publish child thread/started before the corresponding
1230
+ // root subAgentActivity item. Register only descendants of this session,
1231
+ // not unrelated foreign threads multiplexed by a future server version.
1232
+ if (parentThreadId !== rootThreadId && !parentTask) return;
1233
+
1234
+ const source = asObj(thread, "source");
1235
+ const subAgent = Object.keys(asObj(source, "subAgent")).length > 0
1236
+ ? asObj(source, "subAgent")
1237
+ : asObj(source, "subagent");
1238
+ const spawnSource = Object.keys(asObj(subAgent, "threadSpawn")).length > 0
1239
+ ? asObj(subAgent, "threadSpawn")
1240
+ : asObj(subAgent, "thread_spawn");
1241
+ const description = str(spawnSource, "agentPath")
1242
+ || str(spawnSource, "agent_path")
1243
+ || str(thread, "name")
1244
+ || str(thread, "agentNickname")
1245
+ || str(thread, "agentRole")
1246
+ || null;
1247
+
1248
+ this.dispatchEvent({
1249
+ type: "background_task",
1250
+ taskId: childThreadId,
1251
+ taskType: "subagent",
1252
+ phase: "started",
1253
+ status: "running",
1254
+ description,
1255
+ summary: null,
1256
+ parentTaskId: parentThreadId === rootThreadId ? null : parentThreadId,
1257
+ timestamp: new Date().toISOString(),
1258
+ providerType: "codex",
1259
+ sessionId: rootThreadId,
1260
+ messageId: null,
1261
+ eventId: rootThreadId
1262
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:started`
1263
+ : null,
1264
+ turnId: null,
1265
+ parentToolCallId: null,
1266
+ raw: parseJson(rawLine) ?? params,
1267
+ });
1268
+ return;
1269
+ }
1270
+
1271
+ const task = this._backgroundTasks.get(childThreadId);
1272
+ if (!task) return;
1273
+
1274
+ if (method === "turn/started") {
1275
+ if (!task.terminal) return;
1276
+ task.terminal = false;
1277
+ task.summary = null;
1278
+ const turn = asObj(params, "turn");
1279
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1280
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1281
+ this.dispatchEvent({
1282
+ type: "background_task",
1283
+ taskId: childThreadId,
1284
+ taskType: "subagent",
1285
+ phase: "progress",
1286
+ status: "running",
1287
+ description: task.description,
1288
+ summary: null,
1289
+ parentTaskId: task.parentTaskId,
1290
+ timestamp: new Date().toISOString(),
1291
+ providerType: "codex",
1292
+ sessionId: rootThreadId,
1293
+ messageId: null,
1294
+ eventId: rootThreadId && turnId
1295
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:progress`
1296
+ : null,
1297
+ turnId,
1298
+ parentToolCallId: null,
1299
+ raw: parseJson(rawLine) ?? params,
1300
+ });
1301
+ return;
1302
+ }
1303
+
1304
+ if (method === "item/completed") {
1305
+ const item = asObj(params, "item");
1306
+ const itemType = str(item, "type");
1307
+ if ((itemType === "agentMessage" || itemType === "agent_message") && str(item, "phase") !== "commentary") {
1308
+ const summary = this.agentMessageText(item);
1309
+ if (summary) task.summary = summary;
1310
+ }
1311
+ return;
1312
+ }
1313
+
1314
+ if (method !== "turn/completed" && method !== "turn/failed") return;
1315
+
1316
+ const turn = asObj(params, "turn");
1317
+ const turnId = str(turn, "id") || str(params, "turnId") || null;
1318
+ const nativeStatus = method === "turn/failed" ? "failed" : str(turn, "status");
1319
+ const status = nativeStatus === "failed"
1320
+ ? "failed"
1321
+ : nativeStatus === "interrupted" || nativeStatus === "cancelled"
1322
+ ? "stopped"
1323
+ : "completed";
1324
+ const errorMessage = str(asObj(turn, "error"), "message")
1325
+ || str(params, "message")
1326
+ || str(params, "error");
1327
+ const rootThreadId = this._threadId ?? this._expectedThreadId;
1328
+
1329
+ this.dispatchEvent({
1330
+ type: "background_task",
1331
+ taskId: childThreadId,
1332
+ taskType: "subagent",
1333
+ phase: "completed",
1334
+ status,
1335
+ description: task.description,
1336
+ summary: task.summary ?? (errorMessage || null),
1337
+ parentTaskId: task.parentTaskId,
1338
+ timestamp: new Date().toISOString(),
1339
+ providerType: "codex",
1340
+ sessionId: rootThreadId,
1341
+ messageId: null,
1342
+ eventId: rootThreadId && turnId
1343
+ ? `codex:${rootThreadId}:background-task:${childThreadId}:${turnId}:completed`
1344
+ : null,
1345
+ turnId,
1346
+ parentToolCallId: null,
1347
+ raw: parseJson(rawLine) ?? params,
1348
+ });
1349
+ }
1350
+
1006
1351
  private handleNotification(method: string, params: Record<string, unknown>, rawLine: string): void {
1007
1352
  // codex/event — legacy wrapper
1008
1353
  if (method === "codex/event") {
@@ -1020,7 +1365,10 @@ export class CodexSessionImpl implements AgentSession {
1020
1365
  // its root thread, so foreign items must not change root state/summary and,
1021
1366
  // most importantly, a child turn/completed must not resolve the root send.
1022
1367
  const notificationThreadId = this.notificationThreadId(params);
1023
- if (this.isForeignThread(notificationThreadId)) return;
1368
+ if (this.isForeignThread(notificationThreadId)) {
1369
+ this.handleForeignNotification(method, params, rawLine, notificationThreadId!);
1370
+ return;
1371
+ }
1024
1372
 
1025
1373
  // Map v2 notification methods to processing
1026
1374
  if (method === "thread/started") {
@@ -1030,6 +1378,13 @@ export class CodexSessionImpl implements AgentSession {
1030
1378
  return;
1031
1379
  }
1032
1380
 
1381
+ if (method === "turn/started") {
1382
+ this.captureActiveTurnId(str(asObj(params, "turn"), "id") || str(params, "turnId"));
1383
+ // The parser intentionally suppresses this lifecycle-only event.
1384
+ this.emitStreamEvent(rawLine);
1385
+ return;
1386
+ }
1387
+
1033
1388
  if (method === "item/started") {
1034
1389
  this._state = "tool_executing";
1035
1390
  this.emitStreamEvent(rawLine);
@@ -1049,6 +1404,7 @@ export class CodexSessionImpl implements AgentSession {
1049
1404
  }
1050
1405
 
1051
1406
  if (method === "turn/failed") {
1407
+ this.markTurnTerminalObserved();
1052
1408
  this._turnIsError = true;
1053
1409
  this._turnErrorMessage = str(params, "message") || str(params, "error") || "Turn failed";
1054
1410
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1111,6 +1467,7 @@ export class CodexSessionImpl implements AgentSession {
1111
1467
  }
1112
1468
 
1113
1469
  if (type === "turn.failed" || type === "error") {
1470
+ this.markTurnTerminalObserved();
1114
1471
  this._turnIsError = true;
1115
1472
  this._turnErrorMessage = str(event, "message") || str(event, "error") || "Turn failed";
1116
1473
  // Emit before resolve so the result event is queued onto _eventChain
@@ -1162,6 +1519,7 @@ export class CodexSessionImpl implements AgentSession {
1162
1519
  }
1163
1520
 
1164
1521
  private handleTurnCompleted(params: Record<string, unknown>): void {
1522
+ this.markTurnTerminalObserved();
1165
1523
  const usage = typeof params["usage"] === "object" && params["usage"] !== null
1166
1524
  ? (params["usage"] as Record<string, unknown>)
1167
1525
  : null;
@@ -1181,7 +1539,9 @@ export class CodexSessionImpl implements AgentSession {
1181
1539
  // the error instead of a false "completed".
1182
1540
  const turn = asObj(params, "turn");
1183
1541
  const turnStatus = str(turn, "status");
1184
- if (turnStatus === "failed" || turnStatus === "cancelled") {
1542
+ if (turnStatus === "interrupted" || turnStatus === "cancelled") {
1543
+ this._turnWasInterrupted = true;
1544
+ } else if (turnStatus === "failed") {
1185
1545
  this._turnIsError = true;
1186
1546
  const msg = str(asObj(turn, "error"), "message");
1187
1547
  this._turnErrorMessage = msg || this._turnErrorMessage || `Turn ${turnStatus}`;
@@ -1253,9 +1613,9 @@ export class CodexSessionImpl implements AgentSession {
1253
1613
  summary: this._turnSummary,
1254
1614
  usage,
1255
1615
  costUsd: null,
1256
- status: this._turnIsError ? "failed" : "completed",
1257
- errorCode: this._turnIsError ? "execution_error" : null,
1258
- errorMessage: this._turnErrorMessage,
1616
+ status: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "failed" : "completed",
1617
+ errorCode: this._turnWasInterrupted ? "aborted" : this._turnIsError ? "execution_error" : null,
1618
+ errorMessage: this._turnWasInterrupted ? "Turn was interrupted" : this._turnErrorMessage,
1259
1619
  };
1260
1620
 
1261
1621
  // Drain pending onEvent handlers so callers awaiting send() see a settled
@@ -1283,8 +1643,10 @@ export class CodexSessionImpl implements AgentSession {
1283
1643
  this._turnUsage = null;
1284
1644
  this._turnModel = null;
1285
1645
  this._turnIsError = false;
1646
+ this._turnWasInterrupted = false;
1286
1647
  this._turnErrorMessage = null;
1287
1648
  this._turnStartedAt = null;
1649
+ this.clearActiveTurn();
1288
1650
 
1289
1651
  for (const p of pending) {
1290
1652
  // Skip sends already settled early by timeout / abort.
@@ -1316,6 +1678,7 @@ export class CodexSessionImpl implements AgentSession {
1316
1678
  * throwing handler does not break delivery of subsequent events.
1317
1679
  */
1318
1680
  private dispatchEvent(event: StreamEvent): void {
1681
+ if (event.type === "background_task" && !this.observeBackgroundTask(event)) return;
1319
1682
  // Track native goal_status transitions (keeps getGoal() accurate).
1320
1683
  this._goals.observe(event);
1321
1684
  const cb = this.ctx.onEvent;
package/src/types.ts CHANGED
@@ -63,6 +63,13 @@ export interface ProviderCapabilities {
63
63
  * process — the model is not involved.
64
64
  */
65
65
  stopTask: boolean;
66
+ /**
67
+ * Provider emits first-class `background_task` StreamEvents for work that
68
+ * can outlive the root turn, such as async subagents and backgrounded
69
+ * processes. Absent/false means callers must not infer task lifecycle from
70
+ * provider-specific `unknown` events.
71
+ */
72
+ backgroundTaskEvents?: boolean;
66
73
  /**
67
74
  * Provider exposes selectable operating modes via `listModes()` — e.g. Codex
68
75
  * collaboration modes, Copilot's allow-all/agent/plan. When `false`,
@@ -985,6 +992,21 @@ export interface BaseStreamEventFields {
985
992
  raw: Record<string, unknown>;
986
993
  }
987
994
 
995
+ /** Provider-neutral category for work that may outlive its launching turn. */
996
+ export type BackgroundTaskType = "subagent" | "process" | "unknown";
997
+
998
+ /** Lifecycle edge represented by one `background_task` StreamEvent. */
999
+ export type BackgroundTaskPhase = "started" | "progress" | "completed";
1000
+
1001
+ /** Current normalized state carried by a `background_task` event. */
1002
+ export type BackgroundTaskStatus =
1003
+ | "pending"
1004
+ | "running"
1005
+ | "paused"
1006
+ | "completed"
1007
+ | "failed"
1008
+ | "stopped";
1009
+
988
1010
  /**
989
1011
  * Categorical reason for an `auth_required` event. Derived from the
990
1012
  * provider's user-facing error text. Stable across providers — new
@@ -1176,6 +1198,26 @@ export type StreamEvent =
1176
1198
  tokenBudget?: number;
1177
1199
  iterations?: number;
1178
1200
  } & BaseStreamEventFields)
1201
+ /**
1202
+ * Lifecycle for work that is independent from the root turn. In particular,
1203
+ * `phase: "completed"` settles only this task. It is never a root turn
1204
+ * result and must not be used to resolve `SendHandle.result`.
1205
+ *
1206
+ * Events are reducer-friendly snapshots. `status` is always populated,
1207
+ * while description/summary may be null when the provider did not report
1208
+ * them. `parentTaskId` identifies a nested background task when that lineage
1209
+ * is available.
1210
+ */
1211
+ | ({
1212
+ type: "background_task";
1213
+ taskId: string;
1214
+ taskType: BackgroundTaskType;
1215
+ phase: BackgroundTaskPhase;
1216
+ status: BackgroundTaskStatus;
1217
+ description: string | null;
1218
+ summary: string | null;
1219
+ parentTaskId: string | null;
1220
+ } & BaseStreamEventFields)
1179
1221
  | ({
1180
1222
  type: "result";
1181
1223
  text: string;