@agentex/agent 0.0.36 → 0.0.37

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.
@@ -2,6 +2,8 @@ import type { ChildProcess } from "node:child_process";
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type {
5
+ BackgroundTaskType,
6
+ TurnTrigger,
5
7
  AgentSession,
6
8
  CancelResult,
7
9
  ClearGoalResult,
@@ -21,6 +23,7 @@ import type {
21
23
  import { GoalController, latestGoalFromEvents, isTerminalGoalStatus } from "../../goals/index.js";
22
24
  import { claudeGoalCapability } from "./goal-capability.js";
23
25
  import { claudeSessionCodec } from "./codec.js";
26
+ import { backgroundTaskEventFromClaude } from "./parse.js";
24
27
  import { createSessionRecord } from "../../sessions/record.js";
25
28
  import { claudeTranscriptOps } from "./transcript.js";
26
29
  import { findBinary } from "../../utils/binary.js";
@@ -34,6 +37,12 @@ import { parseStreamLine, classifyClaudeAuthFromResult, CLAUDE_LOGIN_COMMAND, ty
34
37
 
35
38
  /** A pending `send()` whose `result` Promise hasn't settled yet. */
36
39
  interface PendingResult {
40
+ /**
41
+ * The `command_uuid` of the host message this send wrote. A turn claims a
42
+ * uuid when it opens, so the turn's `result` settles exactly the sends it
43
+ * actually ran — rather than whichever `result` happened to land next.
44
+ */
45
+ commandUuid: string;
37
46
  resolve: (result: TurnResult) => void;
38
47
  reject: (err: Error) => void;
39
48
  /** Set once the entry has been settled (by result, timeout, abort, or
@@ -246,9 +255,92 @@ export async function createClaudeSession(ctx: SessionContext): Promise<AgentSes
246
255
  */
247
256
  export { claudeGoalCapability } from "./goal-capability.js";
248
257
 
258
+ /** Provider tag stamped on every event this session emits. */
259
+ const CLAUDE_PROVIDER_TYPE = "claude";
260
+
261
+ /**
262
+ * `command_lifecycle` states that retire a host message. `completed` follows a
263
+ * `result`; the rest do not produce one at all, which is why they have to
264
+ * close a turn themselves.
265
+ */
266
+ const TERMINAL_COMMAND_STATES = new Set(["completed", "cancelled", "discarded", "refused"]);
267
+
249
268
  export class ClaudeSessionImpl implements AgentSession {
250
269
  private _state: SessionState = "idle";
251
270
  private _sessionId: string | null = null;
271
+ /**
272
+ * The open root turn, or null.
273
+ *
274
+ * `commandUuids` is a set because the CLI coalesces: a second host message
275
+ * dequeued while a turn is running joins that turn rather than starting its
276
+ * own. Modelling one uuid per turn left every coalesced message unsettled.
277
+ *
278
+ * An empty set means the provider opened this turn itself.
279
+ */
280
+ private _openTurn: { turnId: string; trigger: TurnTrigger; commandUuids: Set<string> } | null = null;
281
+ /** Monotonic turn counter, so `turn_start`/`turn_end` can be paired by id. */
282
+ private _turnSeq = 0;
283
+ /**
284
+ * Host messages the CLI has acknowledged and not yet claimed by a turn,
285
+ * keyed by the uuid `send()` generated.
286
+ *
287
+ * The CLI echoes that uuid back on every `command_lifecycle` record, so a
288
+ * turn opened by a `started` naming one of these *is* that message's turn —
289
+ * exact, not inferred. Entries leave only two ways: claimed by a turn, or
290
+ * retired by a terminal lifecycle record.
291
+ */
292
+ private _outstandingCommands = new Set<string>();
293
+ /** Background tasks currently live, by id, with the type they started as. */
294
+ private _activeTasks = new Map<string, BackgroundTaskType>();
295
+ /**
296
+ * Task ids whose results were delivered while no turn was open, and whose
297
+ * resume turn has not arrived yet.
298
+ *
299
+ * A set rather than a flag: two subagents finishing together deliver twice,
300
+ * and one bit could not represent that. Used only to hold `drain()` across
301
+ * the 24-71ms gap between a delivery and the turn it triggers — the task is
302
+ * no longer live by then, so nothing else covers it. Cleared when a turn
303
+ * opens or closes, so a delivery the CLI folds into a running turn cannot
304
+ * pin the session.
305
+ */
306
+ private _pendingDeliveries = new Set<string>();
307
+ /**
308
+ * Settlement batches captured but not yet resolved, one array per turn.
309
+ *
310
+ * Isolated so no turn can drain another's — that sharing is what made two
311
+ * back-to-back results resolve both sends with the first result — but still
312
+ * reachable, so a process exit mid-drain can reject them instead of
313
+ * stranding the caller forever.
314
+ */
315
+ private _settlingBatches = new Set<PendingResult[]>();
316
+ /**
317
+ * Whether this CLI build has ever emitted `command_lifecycle`. Learned once:
318
+ * builds that emit it do so for every host message, so after the first
319
+ * sighting an unnamed turn is genuinely provider-initiated and the
320
+ * oldest-unclaimed fallback must not fire.
321
+ */
322
+ private _seenCommandLifecycle = false;
323
+ /**
324
+ * Whether this stream has produced a `result` yet. `system/init` is session
325
+ * metadata at boot but heads every provider-initiated resume turn, and this
326
+ * is what tells the two apart.
327
+ */
328
+ private _seenResult = false;
329
+ /**
330
+ * Task type and description as first reported on `task_started`, keyed by
331
+ * task id. Later lifecycle records are sparse — `task_updated` is a bare
332
+ * patch and `task_notification` carries no type — so without this a
333
+ * finished subagent normalizes to `taskType: "unknown"` and hosts render
334
+ * "Background task completed" for what is plainly a subagent.
335
+ */
336
+ private _taskFacts = new Map<string, { taskType: BackgroundTaskType; description: string | null }>();
337
+ /**
338
+ * Cap on `_taskFacts`. Entries cannot be dropped on task completion, because
339
+ * a completion arrives as two records and the second still needs enriching,
340
+ * so the map is bounded by eviction instead. A session that ran this many
341
+ * background tasks will not miss the oldest one's label.
342
+ */
343
+ private static readonly TASK_FACT_LIMIT = 512;
252
344
  private _lineBuffer = "";
253
345
  private _stderrBuffer = "";
254
346
 
@@ -372,15 +464,182 @@ export class ClaudeSessionImpl implements AgentSession {
372
464
  });
373
465
  }
374
466
 
375
- /** Reject every pending send() Promise and outgoing control_response. */
376
- private rejectAllPending(err: Error): void {
467
+ /**
468
+ * Open a root turn and announce it. Returns the turn that was opened.
469
+ *
470
+ * `commandUuids` starts with the message that opened it, if any; a coalesced
471
+ * message dequeued later joins the same turn via `joinTurn`.
472
+ */
473
+ private openTurn(trigger: TurnTrigger, commandUuid: string | null, msg: Record<string, unknown>): void {
474
+ const turnId = `turn-${++this._turnSeq}`;
475
+ // The set holds every command associated with this turn, including one the
476
+ // CLI named that we have no record of sending. `trigger` says whether it
477
+ // was ours; the set is what lets a terminal lifecycle record close the turn
478
+ // it opened. Without that, a `started`/`refused` pair for an unrecognized
479
+ // uuid opened a turn nothing could close.
480
+ this._openTurn = {
481
+ turnId,
482
+ trigger,
483
+ commandUuids: new Set(commandUuid ? [commandUuid] : []),
484
+ };
485
+ // The delivery that prompted this turn has been answered by it opening.
486
+ this._pendingDeliveries.clear();
487
+ // A host turn takes `thinking` from send(); a provider-initiated one has
488
+ // nothing else to set it, and `state` must not contradict `turn_start`.
489
+ if (this._state === "idle") this._state = "thinking";
490
+ this.dispatchEvent({
491
+ type: "turn_start",
492
+ turnId,
493
+ trigger,
494
+ timestamp: new Date().toISOString(),
495
+ providerType: CLAUDE_PROVIDER_TYPE,
496
+ sessionId: this._sessionId,
497
+ messageId: null,
498
+ eventId: str(msg, "uuid") || null,
499
+ parentToolCallId: null,
500
+ raw: { type: str(msg, "type"), ...(commandUuid ? { command_uuid: commandUuid } : {}) },
501
+ });
502
+ }
503
+
504
+ /** Attach a coalesced host message to the turn already running. */
505
+ private joinTurn(commandUuid: string): void {
506
+ this._openTurn?.commandUuids.add(commandUuid);
507
+ this._outstandingCommands.delete(commandUuid);
508
+ }
509
+
510
+ /**
511
+ * Close the open turn and announce it. Returns what was closed, or null.
512
+ *
513
+ * Every `turn_start` gets exactly one `turn_end`, which is why this is the
514
+ * only place a turn is cleared. `result` alone could not serve: a cancelled,
515
+ * discarded, or refused message opens a turn and produces no result.
516
+ */
517
+ private closeTurn(
518
+ reason: "result" | "cancelled" | "discarded" | "refused" | "session_closed",
519
+ ): { turnId: string; trigger: TurnTrigger; commandUuids: Set<string> } | null {
520
+ const turn = this.takeOpenTurn();
521
+ this.emitTurnEnd(turn, reason);
522
+ return turn;
523
+ }
524
+
525
+ /**
526
+ * Clear the open turn and return it, without announcing the close.
527
+ *
528
+ * Split from the announcement because the state change has to be synchronous
529
+ * — the CLI can flush a result and the next turn's opening line in one chunk
530
+ * — while `turn_end` must be ordered *after* the `result` that carries the
531
+ * turn's payload.
532
+ */
533
+ private takeOpenTurn(): { turnId: string; trigger: TurnTrigger; commandUuids: Set<string> } | null {
534
+ const turn = this._openTurn;
535
+ this._openTurn = null;
536
+ this._pendingDeliveries.clear();
537
+ return turn;
538
+ }
539
+
540
+ /** Announce a close for a turn already taken. No-op for a null turn. */
541
+ private emitTurnEnd(
542
+ turn: { turnId: string; trigger: TurnTrigger; commandUuids: Set<string> } | null,
543
+ reason: "result" | "cancelled" | "discarded" | "refused" | "session_closed",
544
+ ): void {
545
+ if (!turn) return;
546
+ this.dispatchEvent({
547
+ type: "turn_end",
548
+ turnId: turn.turnId,
549
+ trigger: turn.trigger,
550
+ reason,
551
+ timestamp: new Date().toISOString(),
552
+ providerType: CLAUDE_PROVIDER_TYPE,
553
+ sessionId: this._sessionId,
554
+ messageId: null,
555
+ eventId: null,
556
+ parentToolCallId: null,
557
+ raw: { reason },
558
+ });
559
+ }
560
+
561
+ /**
562
+ * Remove and return the send resolvers belonging to `turn`.
563
+ *
564
+ * Synchronous and exhaustive by design: the caller holds the only reference
565
+ * to the returned array, so no later turn can drain it.
566
+ */
567
+ private claimSettlementBatch(
568
+ turn: { commandUuids: Set<string> } | null,
569
+ ): PendingResult[] {
570
+ const batch: PendingResult[] = [];
571
+ this._settlingBatches.add(batch);
572
+ if (!turn) {
573
+ // A result with no turn to attribute it to. Settling everything is the
574
+ // lesser evil: resolving with a neighbouring turn's result is wrong, but
575
+ // hanging every caller is worse.
576
+ batch.push(...this._pendingResults.splice(0));
577
+ return batch;
578
+ }
579
+ if (turn.commandUuids.size === 0) return batch; // provider-initiated
580
+ for (let i = this._pendingResults.length - 1; i >= 0; i--) {
581
+ if (turn.commandUuids.has(this._pendingResults[i]!.commandUuid)) {
582
+ batch.unshift(...this._pendingResults.splice(i, 1));
583
+ }
584
+ }
585
+ return batch;
586
+ }
587
+
588
+ /**
589
+ * Settle any send still waiting on a command the CLI has finished with.
590
+ *
591
+ * `completed` means its turn ran, so it takes that turn's result; the other
592
+ * terminal states mean it never ran at all.
593
+ */
594
+ private settleCommand(commandUuid: string, state: string): void {
595
+ for (let i = this._pendingResults.length - 1; i >= 0; i--) {
596
+ const entry = this._pendingResults[i]!;
597
+ if (entry.commandUuid !== commandUuid || entry.settled) continue;
598
+ this._pendingResults.splice(i, 1);
599
+ entry.settled = true;
600
+ entry.cleanup?.();
601
+ // Reaching here means no turn ever claimed this message, so there is no
602
+ // result of its own to give it. `completed` without a claiming turn is
603
+ // not expected — it is reported honestly rather than papered over with a
604
+ // neighbouring turn's payload.
605
+ entry.resolve({
606
+ status: state === "completed" ? "completed" : "aborted",
607
+ summary: null,
608
+ costUsd: null,
609
+ errorCode: `command_${state}`,
610
+ errorMessage: `Message was ${state} by the CLI without a turn of its own`,
611
+ } as TurnResult);
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Reject every send still waiting on a turn, and clear the turn bookkeeping
617
+ * that described it. Shared by the fatal paths and by `close()`.
618
+ */
619
+ private rejectPendingSends(err: Error): void {
377
620
  const pending = this._pendingResults.splice(0);
621
+ // Batches already captured for a turn still settling. Their handler may
622
+ // never resume — the process is gone — so they are rejected here rather
623
+ // than left hanging their callers.
624
+ for (const captured of this._settlingBatches) pending.push(...captured);
625
+ this._settlingBatches.clear();
626
+ // No turn survives this, and the host is told so rather than left holding
627
+ // an unmatched `turn_start`.
628
+ this.closeTurn("session_closed");
629
+ this._outstandingCommands.clear();
630
+ this._activeTasks.clear();
631
+ this._taskFacts.clear();
378
632
  for (const p of pending) {
379
633
  if (p.settled) continue;
380
634
  p.settled = true;
381
635
  p.cleanup?.();
382
636
  p.reject(err);
383
637
  }
638
+ }
639
+
640
+ /** Reject every pending send() Promise and outgoing control_response. */
641
+ private rejectAllPending(err: Error): void {
642
+ this.rejectPendingSends(err);
384
643
  for (const [, p] of this._pendingControlResponses) p.reject(err);
385
644
  this._pendingControlResponses.clear();
386
645
  }
@@ -425,6 +684,9 @@ export class ClaudeSessionImpl implements AgentSession {
425
684
  if (this._state === "idle") this._state = "thinking";
426
685
 
427
686
  const uuid = randomUUID();
687
+ // The CLI echoes this back on every `command_lifecycle` record for the
688
+ // message, which is what lets a turn be attributed exactly.
689
+ this._outstandingCommands.add(uuid);
428
690
 
429
691
  // Write user message in stream-json format. `uuid` becomes the queue
430
692
  // key the CLI uses for `cancel_async_message`.
@@ -443,7 +705,7 @@ export class ClaudeSessionImpl implements AgentSession {
443
705
  rejectFn = reject;
444
706
  });
445
707
 
446
- const entry: PendingResult = { resolve: resolveFn, reject: rejectFn };
708
+ const entry: PendingResult = { commandUuid: uuid, resolve: resolveFn, reject: rejectFn };
447
709
  this._pendingResults.push(entry);
448
710
 
449
711
  // Track the in-flight turn so drain() can await it; drop it on settle.
@@ -541,6 +803,8 @@ export class ClaudeSessionImpl implements AgentSession {
541
803
 
542
804
  try {
543
805
  const response = await responsePromise;
806
+ // No bookkeeping here: the CLI emits `command_lifecycle/cancelled` for
807
+ // the message, and that is what retires it.
544
808
  return { cancelled: response["cancelled"] === true };
545
809
  } catch {
546
810
  // Process exited / error before response — treat as "not cancelled."
@@ -617,14 +881,55 @@ export class ClaudeSessionImpl implements AgentSession {
617
881
  // Let every in-flight turn settle (resolve or reject) before closing, so
618
882
  // a running tool finishes rather than being killed mid-flight.
619
883
  await Promise.allSettled([...this._inFlight]);
884
+ // `_inFlight` only tracks turns this host dispatched. A provider-initiated
885
+ // turn — the CLI acting on a finished background task — has no send()
886
+ // behind it, so draining used to SIGTERM the CLI mid-turn and kill
887
+ // whatever that turn was doing. Now that the session can see those turns,
888
+ // the one API whose contract is "let in-flight work settle" honors them.
889
+ await this.awaitTurnClose();
620
890
  await this.close();
621
891
  })();
622
892
  return this._drainPromise;
623
893
  }
624
894
 
895
+ /**
896
+ * Resolve once no turn is open, or once the deadline passes.
897
+ *
898
+ * Bounded on purpose: a turn that never produces a `result` (a wedged CLI)
899
+ * must not make `drain()` hang forever. Polling rather than eventing because
900
+ * turn close is driven from the stream reader, and a missed edge here would
901
+ * be the same hang by another route.
902
+ */
903
+ private async awaitTurnClose(timeoutMs = 120_000): Promise<void> {
904
+ const deadline = Date.now() + timeoutMs;
905
+ const settling = (): boolean => {
906
+ if (this._openTurn) return true;
907
+ // A running subagent will hand its result back and open a resume turn,
908
+ // so it is in-flight work. Closing during the gap between the root
909
+ // result and that turn — measured at 8-11s live — killed it outright.
910
+ //
911
+ // Background *processes* are excluded on purpose: a dev server started
912
+ // with run_in_background may never exit, and the contract is "let the
913
+ // agent's work settle", not "outlive whatever it launched".
914
+ // A delivered result whose resume turn has not opened yet. This is the
915
+ // provider's own signal that more work is coming, used where it matters.
916
+ if (this._pendingDeliveries.size > 0) return true;
917
+ for (const taskType of this._activeTasks.values()) {
918
+ if (taskType === "subagent") return true;
919
+ }
920
+ return false;
921
+ };
922
+ while (settling() && this._state !== "closed" && Date.now() < deadline) {
923
+ await new Promise((r) => setTimeout(r, 50));
924
+ }
925
+ }
926
+
625
927
  async close(): Promise<void> {
626
928
  if (this._state === "closed") return;
627
929
  this._state = "closed";
930
+ // Anything still waiting has no turn left to settle it. Without this a
931
+ // bare `close()` with a send in flight hung that caller for good.
932
+ this.rejectPendingSends(new Error("Session closed before the turn completed"));
628
933
  // Final transcript scan so a goal_status (e.g. `met`) written since the last
629
934
  // ~800ms poll isn't lost on a fast close. No-op when no goal is observed.
630
935
  await this.scanGoalTranscript().catch(() => { /* best effort */ });
@@ -711,8 +1016,20 @@ export class ClaudeSessionImpl implements AgentSession {
711
1016
  // await inside handleResult drains the chain so handlers settle before
712
1017
  // the awaiting send() returns.
713
1018
  if (type === "result") {
1019
+ // Close the turn and snapshot its settlement batch synchronously, before
1020
+ // any `await`. Both halves matter. Closing late swallows the next
1021
+ // `turn_start`, because the CLI can flush this result and the following
1022
+ // turn's opening line in one chunk. Snapshotting late is worse: the
1023
+ // batch used to live in a field shared by every turn, so whichever
1024
+ // async handler resumed first drained all of it and resolved the second
1025
+ // turn's sends with the first turn's result.
1026
+ const closingTurn = this.takeOpenTurn();
1027
+ const batch = this.claimSettlementBatch(closingTurn);
1028
+ this._seenResult = true;
714
1029
  this.handleStreamMessage(msg, line);
715
- void this.handleResult(msg);
1030
+ // Announced after the result event, so the payload precedes the close.
1031
+ this.emitTurnEnd(closingTurn, "result");
1032
+ void this.handleResult(msg, batch);
716
1033
  return;
717
1034
  }
718
1035
 
@@ -728,19 +1045,25 @@ export class ClaudeSessionImpl implements AgentSession {
728
1045
  const requestId = str(msg, "request_id");
729
1046
  const request = obj(msg, "request");
730
1047
  const subtype = str(request, "subtype");
1048
+ const nested = msg["parent_tool_use_id"] != null
1049
+ || request["parent_tool_use_id"] != null;
731
1050
 
732
1051
  switch (subtype) {
733
1052
  case "initialize":
734
1053
  this.sendControlResponse(requestId, {});
735
1054
  break;
736
1055
 
1056
+ // A detached subagent raises its own prompts, and those are the child
1057
+ // being blocked, not this session. Parking the parent in
1058
+ // `waiting_for_approval` there outlived the root turn with nothing able
1059
+ // to clear it — the same nested-actor rule the stream path applies.
737
1060
  case "can_use_tool":
738
- this._state = "waiting_for_approval";
1061
+ if (!nested) this._state = "waiting_for_approval";
739
1062
  this.handlePermissionRequest(requestId, request);
740
1063
  break;
741
1064
 
742
1065
  case "elicitation":
743
- this._state = "waiting_for_input";
1066
+ if (!nested) this._state = "waiting_for_input";
744
1067
  this.handleElicitationRequest(requestId, request);
745
1068
  break;
746
1069
 
@@ -941,7 +1264,10 @@ export class ClaudeSessionImpl implements AgentSession {
941
1264
  // Result handling
942
1265
  // -------------------------------------------------------------------------
943
1266
 
944
- private async handleResult(msg: Record<string, unknown>): Promise<void> {
1267
+ private async handleResult(
1268
+ msg: Record<string, unknown>,
1269
+ batch: PendingResult[] = [],
1270
+ ): Promise<void> {
945
1271
  const summary = typeof msg["result"] === "string" ? msg["result"] : null;
946
1272
  const isError = msg["is_error"] === true;
947
1273
  const costUsd = typeof msg["total_cost_usd"] === "number" ? msg["total_cost_usd"] : null;
@@ -1023,14 +1349,19 @@ export class ClaudeSessionImpl implements AgentSession {
1023
1349
  // session whose stdin is dead.
1024
1350
  if (this._state === "closed") return;
1025
1351
 
1026
- this._state = "idle";
1027
-
1028
- // Drain ALL pending send() resolvers with this turn's result. Multiple
1029
- // concurrent sends coalesced by the CLI into one turn share the same
1030
- // TurnResult — documented in SendHandle JSDoc. Splice empties the list
1031
- // so subsequent sends queue against a fresh list for the next turn.
1032
- const pending = this._pendingResults.splice(0);
1033
- for (const p of pending) {
1352
+ // Only fall back to idle if no new turn opened while the chain drained.
1353
+ // The CLI can start the next turn in the same chunk as this result, and
1354
+ // asserting idle over a turn that is already running is the exact false
1355
+ // "finished" this release exists to remove.
1356
+ if (!this._openTurn) this._state = "idle";
1357
+
1358
+ // Settle the batch this turn owns — and only that batch. It was captured
1359
+ // synchronously when the result line was read, so a turn that opened while
1360
+ // this handler was suspended cannot have its sends resolved here. That
1361
+ // sharing is what made two back-to-back results resolve both sends with
1362
+ // the first result.
1363
+ this._settlingBatches.delete(batch);
1364
+ for (const p of batch) {
1034
1365
  // Skip sends already settled early by timeout / abort.
1035
1366
  if (p.settled) continue;
1036
1367
  p.settled = true;
@@ -1047,19 +1378,90 @@ export class ClaudeSessionImpl implements AgentSession {
1047
1378
  // -------------------------------------------------------------------------
1048
1379
 
1049
1380
  private handleStreamMessage(msg: Record<string, unknown>, rawLine: string): void {
1381
+ // A line can arrive during close()'s grace window. Acting on it would
1382
+ // reopen a turn on a dead session, flip `state` off "closed", and emit a
1383
+ // `turn_start` to a host that has already drained — with nothing left to
1384
+ // ever close it again.
1385
+ if (this._state === "closed") return;
1386
+
1050
1387
  // Extract session ID from any message that has one
1051
1388
  if (typeof msg["session_id"] === "string" && msg["session_id"]) {
1052
1389
  this._sessionId = msg["session_id"];
1053
1390
  }
1054
1391
 
1055
- // Update session state based on message type
1392
+ // Update session state based on message type. Nested-actor lines are
1393
+ // excluded for the same reason they do not open a turn: a detached
1394
+ // subagent streams its own output onto the parent after the root result,
1395
+ // and letting it drive `state` left the session reporting "thinking" for
1396
+ // the child's whole run — directly contradicting `turn_start`/`result`,
1397
+ // with nothing to clear it if the child is stopped or killed.
1056
1398
  const type = str(msg, "type");
1057
- if (type === "assistant" || type === "thinking") {
1058
- this._state = "thinking";
1059
- } else if (type === "tool_use") {
1060
- this._state = "tool_executing";
1061
- } else if (type === "tool_result") {
1062
- this._state = "thinking";
1399
+ if (msg["parent_tool_use_id"] == null) {
1400
+ if (type === "assistant" || type === "thinking") {
1401
+ this._state = "thinking";
1402
+ } else if (type === "tool_use") {
1403
+ this._state = "tool_executing";
1404
+ } else if (type === "tool_result") {
1405
+ this._state = "thinking";
1406
+ }
1407
+ }
1408
+
1409
+ // Retire host messages the CLI has finished with, before deciding what a
1410
+ // turn opening means. `refused`/`discarded` never produce a `result`, so
1411
+ // this is also the only thing that closes a turn they opened — without it
1412
+ // the session would pin as working with no path back.
1413
+ if (type === "command_lifecycle") {
1414
+ this._seenCommandLifecycle = true;
1415
+ const commandUuid = str(msg, "command_uuid");
1416
+ const state = str(msg, "state");
1417
+ if (commandUuid && TERMINAL_COMMAND_STATES.has(state)) {
1418
+ this._outstandingCommands.delete(commandUuid);
1419
+ // The CLI is done with this message. Anything still waiting on it will
1420
+ // never be settled by a `result` — a cancelled or refused message
1421
+ // produces none, and a `completed` one whose turn we could not attribute
1422
+ // would otherwise wait forever.
1423
+ this.settleCommand(commandUuid, state);
1424
+ if (state !== "completed" && this._openTurn?.commandUuids.has(commandUuid)) {
1425
+ // Nothing else will close this turn: these states never produce a
1426
+ // `result`. (A closed session returned above, so state is live.)
1427
+ this.closeTurn(state as "cancelled" | "discarded" | "refused");
1428
+ this._state = "idle";
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ // Background-task state is session state: `drain()` waits on live
1434
+ // subagents and turn attribution consumes delivered results. Decoded here
1435
+ // from the wire rather than in the dispatch path, which only runs when a
1436
+ // host subscribed — a host that reads `session.state` and calls `drain()`
1437
+ // without an onEvent handler would otherwise get neither.
1438
+ this.trackBackgroundTaskState(msg);
1439
+
1440
+ // Open a turn on the first line of turn content, before that line's own
1441
+ // events, so a host sees turn_start → … → result → turn_end in order.
1442
+ const namedUuid = type === "command_lifecycle" ? str(msg, "command_uuid") : "";
1443
+ if (this._openTurn) {
1444
+ // The CLI coalesces: a host message dequeued while a turn is running
1445
+ // joins it rather than starting its own. Modelling one command per turn
1446
+ // left every coalesced message permanently unsettled.
1447
+ if (namedUuid && this._outstandingCommands.has(namedUuid)) this.joinTurn(namedUuid);
1448
+ } else if (this.opensTurn(msg, type)) {
1449
+ // Exact attribution: the CLI stamps every `command_lifecycle` for a host
1450
+ // message with the uuid `send()` minted, so a `started` naming one we are
1451
+ // still waiting on *is* that message's turn. Anything else opened the
1452
+ // turn without us asking — which is what this event exists to expose.
1453
+ const claimedUuid = namedUuid
1454
+ ? (this._outstandingCommands.has(namedUuid) ? namedUuid : null)
1455
+ // Only guess for a build that has never named a command. Once one has,
1456
+ // it names every host message, so an unnamed turn is provider-initiated
1457
+ // and consuming a uuid here would mislabel twice.
1458
+ : this._seenCommandLifecycle
1459
+ ? null
1460
+ : this._outstandingCommands.values().next().value ?? null;
1461
+ if (claimedUuid) this._outstandingCommands.delete(claimedUuid);
1462
+ // Track the named command even when it is not one of ours, so its
1463
+ // terminal lifecycle record can close this turn.
1464
+ this.openTurn(claimedUuid ? "send" : "resume", claimedUuid ?? (namedUuid || null), msg);
1063
1465
  }
1064
1466
 
1065
1467
  // Parse + dispatch when there's an onEvent subscriber OR an active goal to
@@ -1073,20 +1475,144 @@ export class ClaudeSessionImpl implements AgentSession {
1073
1475
  }
1074
1476
 
1075
1477
  /**
1076
- * Queue an event for in-order delivery to `onEvent`. Each call appends a
1077
- * `.then` to `_eventChain` so handler N+1 only starts after handler N's
1078
- * returned promise settles. Errors are swallowed inside the chain so a
1079
- * throwing handler does not break delivery of subsequent events.
1478
+ * Whether this wire line is the first content of a turn.
1479
+ *
1480
+ * Deliberately excludes two things.
1481
+ *
1482
+ * The background-task records (`task_started`, `task_updated`,
1483
+ * `task_notification`, `background_tasks_changed`) arrive *between* turns —
1484
+ * a detached child reporting in while nothing is running — so treating one
1485
+ * as a turn opening would report the session as working every time a
1486
+ * background process coughed.
1487
+ *
1488
+ * `system/init` is special. It is boot metadata the first time — before any
1489
+ * result, opening a turn on it would create a phantom `resume` at startup,
1490
+ * leave it open, and swallow the first real send's `turn_start`. But the
1491
+ * CLI re-emits it at the head of every provider-initiated resume turn, and
1492
+ * measured live it precedes that turn's first assistant line by 1.4–1.9s.
1493
+ * Ignoring it outright traded a phantom turn for a second-long blind window
1494
+ * on exactly the turns this event exists to expose, so it opens a turn once
1495
+ * a result has been seen on this stream and not before.
1496
+ *
1497
+ * And anything carrying `parent_tool_use_id`, which is a nested actor's own
1498
+ * output. Claude streams a detached subagent's assistant text, thinking and
1499
+ * tool calls onto the parent stream while the root turn is over. Those are
1500
+ * the child working, not this session, and counting them would hold the
1501
+ * session "working" for the entire detached run — when the honest answer,
1502
+ * and what the CLI shows, is that the root turn finished and its result is
1503
+ * ready to read. The real resume turn arrives afterwards, at root level,
1504
+ * once the child's result is delivered back.
1080
1505
  */
1506
+ private opensTurn(msg: Record<string, unknown>, type: string): boolean {
1507
+ if (msg["parent_tool_use_id"] != null) return false;
1508
+ // `stream_event` is the partial-message wrapper. Without it, a host that
1509
+ // opted into `includePartialMessages` receives the whole streamed reply
1510
+ // before `turn_start` — the turn's output arriving before the event that
1511
+ // says the turn began.
1512
+ if (type === "stream_event") return true;
1513
+ // `command_lifecycle/started` heads every host-dispatched turn and only
1514
+ // those — never a resume — so it is safe to open on and carries no
1515
+ // phantom-turn risk. It is also the earliest signal available: without it
1516
+ // the session's very first turn has no opener until its first assistant
1517
+ // line, measured live at 1.8-6.4s of reading as idle while working.
1518
+ if (type === "command_lifecycle") return str(msg, "state") === "started";
1519
+ if (type === "system") return this._seenResult && str(msg, "subtype") === "init";
1520
+ // `thinking` and `tool_use` are content blocks inside an `assistant`
1521
+ // message, not top-level wire types; they are listed because the state
1522
+ // machine above accepts them and the two should not disagree about what
1523
+ // counts as turn content.
1524
+ return type === "assistant"
1525
+ || type === "thinking"
1526
+ || type === "tool_use"
1527
+ || type === "user";
1528
+ }
1529
+
1530
+ /**
1531
+ * Fold one background-task wire record into session state.
1532
+ *
1533
+ * Separate from the enrichment below on purpose: this must run for every
1534
+ * line regardless of whether anything is listening, because `drain()` and
1535
+ * turn attribution read what it maintains.
1536
+ */
1537
+ private trackBackgroundTaskState(msg: Record<string, unknown>): void {
1538
+ const event = backgroundTaskEventFromClaude(msg);
1539
+ if (!event) return;
1540
+
1541
+ const terminal = event.status === "completed"
1542
+ || event.status === "failed"
1543
+ || event.status === "stopped";
1544
+ if (terminal) {
1545
+ this._activeTasks.delete(event.taskId);
1546
+ } else if (event.status !== null || event.phase === "started") {
1547
+ // `status: null` means "no change reported" — a sparse patch that only
1548
+ // renames a task says nothing about liveness. Writing the task back into
1549
+ // the live set there resurrected work that had already completed.
1550
+ this._activeTasks.set(event.taskId, this._taskFacts.get(event.taskId)?.taskType ?? event.taskType);
1551
+ }
1552
+
1553
+ // The provider stating that a turn is coming — held so `drain()` does not
1554
+ // close in the gap before it opens. Keyed on the outcome rather than on
1555
+ // the delivery record alone, because a completion arrives as two records
1556
+ // (`task_updated` then `task_notification`) and the first already removes
1557
+ // the task from the live set: waiting only for the second left a window
1558
+ // where neither the task nor the delivery was visible.
1559
+ //
1560
+ // Only for outcomes that actually deliver. A stopped task hands nothing
1561
+ // back and starts no turn, so holding for one would just burn the deadline.
1562
+ const delivers = event.status === "completed" || event.status === "failed";
1563
+ if ((event.report || delivers) && !this._openTurn) {
1564
+ this._pendingDeliveries.add(event.taskId);
1565
+ }
1566
+
1567
+ if (event.phase === "started") {
1568
+ this._taskFacts.set(event.taskId, {
1569
+ taskType: event.taskType,
1570
+ description: event.description,
1571
+ });
1572
+ // Evict oldest-first. Map iteration is insertion-ordered, so the first
1573
+ // key is the least recently started task.
1574
+ while (this._taskFacts.size > ClaudeSessionImpl.TASK_FACT_LIMIT) {
1575
+ const oldest = this._taskFacts.keys().next();
1576
+ if (oldest.done) break;
1577
+ this._taskFacts.delete(oldest.value);
1578
+ }
1579
+ }
1580
+ }
1581
+
1582
+ /**
1583
+ * Fill in what a sparse background-task record leaves out.
1584
+ *
1585
+ * `task_started` is the only record that names the task's type; the patches
1586
+ * and the completion notification that follow identify it by id alone. This
1587
+ * remembers the first description and carries it forward so a completion
1588
+ * still knows it was a subagent.
1589
+ */
1590
+ private _trackTaskFacts(event: StreamEvent): StreamEvent {
1591
+ if (event.type !== "background_task") return event;
1592
+ const known = this._taskFacts.get(event.taskId);
1593
+ if (!known) return event;
1594
+ const needsType = event.taskType === "unknown" && known.taskType !== "unknown";
1595
+ const needsDescription = event.description === null && known.description !== null;
1596
+ if (!needsType && !needsDescription) return event;
1597
+ return {
1598
+ ...event,
1599
+ taskType: needsType ? known.taskType : event.taskType,
1600
+ description: needsDescription ? known.description : event.description,
1601
+ };
1602
+ }
1603
+
1081
1604
  private dispatchEvent(event: StreamEvent): void {
1605
+ // Enrich and record before anything can bail out. `_trackTaskFacts` also
1606
+ // maintains session state — which tasks are live, which results are
1607
+ // waiting for a resume turn — and gating that on a subscriber meant
1608
+ // `session.state`, `drain()` and turn attribution all silently degraded
1609
+ // for a host that reads the session without an onEvent handler.
1610
+ const enriched = this._trackTaskFacts(this._trackToolName(event));
1082
1611
  // Let the goal engine track native goal_status transitions even when no
1083
1612
  // onEvent handler is attached (keeps getGoal() accurate).
1084
- this._goals.observe(event);
1613
+ this._goals.observe(enriched);
1085
1614
  const cb = this.ctx.onEvent;
1086
1615
  if (!cb) return;
1087
- // Enrich synchronously (in stream order) so tool_result events carry the
1088
- // name of the tool_call they answer.
1089
- const enriched = this._trackToolName(event);
1090
1616
  this._eventChain = this._eventChain.then(async () => {
1091
1617
  try { await cb(enriched); } catch { /* swallow */ }
1092
1618
  });