@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.
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { GoalController, latestGoalFromEvents, isTerminalGoalStatus } from "../../goals/index.js";
4
4
  import { claudeGoalCapability } from "./goal-capability.js";
5
5
  import { claudeSessionCodec } from "./codec.js";
6
+ import { backgroundTaskEventFromClaude } from "./parse.js";
6
7
  import { createSessionRecord } from "../../sessions/record.js";
7
8
  import { claudeTranscriptOps } from "./transcript.js";
8
9
  import { findBinary } from "../../utils/binary.js";
@@ -204,6 +205,14 @@ export async function createClaudeSession(ctx) {
204
205
  * heavy session module (spec §5.1).
205
206
  */
206
207
  export { claudeGoalCapability } from "./goal-capability.js";
208
+ /** Provider tag stamped on every event this session emits. */
209
+ const CLAUDE_PROVIDER_TYPE = "claude";
210
+ /**
211
+ * `command_lifecycle` states that retire a host message. `completed` follows a
212
+ * `result`; the rest do not produce one at all, which is why they have to
213
+ * close a turn themselves.
214
+ */
215
+ const TERMINAL_COMMAND_STATES = new Set(["completed", "cancelled", "discarded", "refused"]);
207
216
  export class ClaudeSessionImpl {
208
217
  proc;
209
218
  ctx;
@@ -211,6 +220,79 @@ export class ClaudeSessionImpl {
211
220
  mcpConfigPath;
212
221
  _state = "idle";
213
222
  _sessionId = null;
223
+ /**
224
+ * The open root turn, or null.
225
+ *
226
+ * `commandUuids` is a set because the CLI coalesces: a second host message
227
+ * dequeued while a turn is running joins that turn rather than starting its
228
+ * own. Modelling one uuid per turn left every coalesced message unsettled.
229
+ *
230
+ * An empty set means the provider opened this turn itself.
231
+ */
232
+ _openTurn = null;
233
+ /** Monotonic turn counter, so `turn_start`/`turn_end` can be paired by id. */
234
+ _turnSeq = 0;
235
+ /**
236
+ * Host messages the CLI has acknowledged and not yet claimed by a turn,
237
+ * keyed by the uuid `send()` generated.
238
+ *
239
+ * The CLI echoes that uuid back on every `command_lifecycle` record, so a
240
+ * turn opened by a `started` naming one of these *is* that message's turn —
241
+ * exact, not inferred. Entries leave only two ways: claimed by a turn, or
242
+ * retired by a terminal lifecycle record.
243
+ */
244
+ _outstandingCommands = new Set();
245
+ /** Background tasks currently live, by id, with the type they started as. */
246
+ _activeTasks = new Map();
247
+ /**
248
+ * Task ids whose results were delivered while no turn was open, and whose
249
+ * resume turn has not arrived yet.
250
+ *
251
+ * A set rather than a flag: two subagents finishing together deliver twice,
252
+ * and one bit could not represent that. Used only to hold `drain()` across
253
+ * the 24-71ms gap between a delivery and the turn it triggers — the task is
254
+ * no longer live by then, so nothing else covers it. Cleared when a turn
255
+ * opens or closes, so a delivery the CLI folds into a running turn cannot
256
+ * pin the session.
257
+ */
258
+ _pendingDeliveries = new Set();
259
+ /**
260
+ * Settlement batches captured but not yet resolved, one array per turn.
261
+ *
262
+ * Isolated so no turn can drain another's — that sharing is what made two
263
+ * back-to-back results resolve both sends with the first result — but still
264
+ * reachable, so a process exit mid-drain can reject them instead of
265
+ * stranding the caller forever.
266
+ */
267
+ _settlingBatches = new Set();
268
+ /**
269
+ * Whether this CLI build has ever emitted `command_lifecycle`. Learned once:
270
+ * builds that emit it do so for every host message, so after the first
271
+ * sighting an unnamed turn is genuinely provider-initiated and the
272
+ * oldest-unclaimed fallback must not fire.
273
+ */
274
+ _seenCommandLifecycle = false;
275
+ /**
276
+ * Whether this stream has produced a `result` yet. `system/init` is session
277
+ * metadata at boot but heads every provider-initiated resume turn, and this
278
+ * is what tells the two apart.
279
+ */
280
+ _seenResult = false;
281
+ /**
282
+ * Task type and description as first reported on `task_started`, keyed by
283
+ * task id. Later lifecycle records are sparse — `task_updated` is a bare
284
+ * patch and `task_notification` carries no type — so without this a
285
+ * finished subagent normalizes to `taskType: "unknown"` and hosts render
286
+ * "Background task completed" for what is plainly a subagent.
287
+ */
288
+ _taskFacts = new Map();
289
+ /**
290
+ * Cap on `_taskFacts`. Entries cannot be dropped on task completion, because
291
+ * a completion arrives as two records and the second still needs enriching,
292
+ * so the map is bounded by eviction instead. A session that ran this many
293
+ * background tasks will not miss the oldest one's label.
294
+ */
295
+ static TASK_FACT_LIMIT = 512;
214
296
  _lineBuffer = "";
215
297
  _stderrBuffer = "";
216
298
  // Pending result-resolvers. With concurrent send, multiple in-flight send()
@@ -316,9 +398,162 @@ export class ClaudeSessionImpl {
316
398
  }
317
399
  });
318
400
  }
319
- /** Reject every pending send() Promise and outgoing control_response. */
320
- rejectAllPending(err) {
401
+ /**
402
+ * Open a root turn and announce it. Returns the turn that was opened.
403
+ *
404
+ * `commandUuids` starts with the message that opened it, if any; a coalesced
405
+ * message dequeued later joins the same turn via `joinTurn`.
406
+ */
407
+ openTurn(trigger, commandUuid, msg) {
408
+ const turnId = `turn-${++this._turnSeq}`;
409
+ // The set holds every command associated with this turn, including one the
410
+ // CLI named that we have no record of sending. `trigger` says whether it
411
+ // was ours; the set is what lets a terminal lifecycle record close the turn
412
+ // it opened. Without that, a `started`/`refused` pair for an unrecognized
413
+ // uuid opened a turn nothing could close.
414
+ this._openTurn = {
415
+ turnId,
416
+ trigger,
417
+ commandUuids: new Set(commandUuid ? [commandUuid] : []),
418
+ };
419
+ // The delivery that prompted this turn has been answered by it opening.
420
+ this._pendingDeliveries.clear();
421
+ // A host turn takes `thinking` from send(); a provider-initiated one has
422
+ // nothing else to set it, and `state` must not contradict `turn_start`.
423
+ if (this._state === "idle")
424
+ this._state = "thinking";
425
+ this.dispatchEvent({
426
+ type: "turn_start",
427
+ turnId,
428
+ trigger,
429
+ timestamp: new Date().toISOString(),
430
+ providerType: CLAUDE_PROVIDER_TYPE,
431
+ sessionId: this._sessionId,
432
+ messageId: null,
433
+ eventId: str(msg, "uuid") || null,
434
+ parentToolCallId: null,
435
+ raw: { type: str(msg, "type"), ...(commandUuid ? { command_uuid: commandUuid } : {}) },
436
+ });
437
+ }
438
+ /** Attach a coalesced host message to the turn already running. */
439
+ joinTurn(commandUuid) {
440
+ this._openTurn?.commandUuids.add(commandUuid);
441
+ this._outstandingCommands.delete(commandUuid);
442
+ }
443
+ /**
444
+ * Close the open turn and announce it. Returns what was closed, or null.
445
+ *
446
+ * Every `turn_start` gets exactly one `turn_end`, which is why this is the
447
+ * only place a turn is cleared. `result` alone could not serve: a cancelled,
448
+ * discarded, or refused message opens a turn and produces no result.
449
+ */
450
+ closeTurn(reason) {
451
+ const turn = this.takeOpenTurn();
452
+ this.emitTurnEnd(turn, reason);
453
+ return turn;
454
+ }
455
+ /**
456
+ * Clear the open turn and return it, without announcing the close.
457
+ *
458
+ * Split from the announcement because the state change has to be synchronous
459
+ * — the CLI can flush a result and the next turn's opening line in one chunk
460
+ * — while `turn_end` must be ordered *after* the `result` that carries the
461
+ * turn's payload.
462
+ */
463
+ takeOpenTurn() {
464
+ const turn = this._openTurn;
465
+ this._openTurn = null;
466
+ this._pendingDeliveries.clear();
467
+ return turn;
468
+ }
469
+ /** Announce a close for a turn already taken. No-op for a null turn. */
470
+ emitTurnEnd(turn, reason) {
471
+ if (!turn)
472
+ return;
473
+ this.dispatchEvent({
474
+ type: "turn_end",
475
+ turnId: turn.turnId,
476
+ trigger: turn.trigger,
477
+ reason,
478
+ timestamp: new Date().toISOString(),
479
+ providerType: CLAUDE_PROVIDER_TYPE,
480
+ sessionId: this._sessionId,
481
+ messageId: null,
482
+ eventId: null,
483
+ parentToolCallId: null,
484
+ raw: { reason },
485
+ });
486
+ }
487
+ /**
488
+ * Remove and return the send resolvers belonging to `turn`.
489
+ *
490
+ * Synchronous and exhaustive by design: the caller holds the only reference
491
+ * to the returned array, so no later turn can drain it.
492
+ */
493
+ claimSettlementBatch(turn) {
494
+ const batch = [];
495
+ this._settlingBatches.add(batch);
496
+ if (!turn) {
497
+ // A result with no turn to attribute it to. Settling everything is the
498
+ // lesser evil: resolving with a neighbouring turn's result is wrong, but
499
+ // hanging every caller is worse.
500
+ batch.push(...this._pendingResults.splice(0));
501
+ return batch;
502
+ }
503
+ if (turn.commandUuids.size === 0)
504
+ return batch; // provider-initiated
505
+ for (let i = this._pendingResults.length - 1; i >= 0; i--) {
506
+ if (turn.commandUuids.has(this._pendingResults[i].commandUuid)) {
507
+ batch.unshift(...this._pendingResults.splice(i, 1));
508
+ }
509
+ }
510
+ return batch;
511
+ }
512
+ /**
513
+ * Settle any send still waiting on a command the CLI has finished with.
514
+ *
515
+ * `completed` means its turn ran, so it takes that turn's result; the other
516
+ * terminal states mean it never ran at all.
517
+ */
518
+ settleCommand(commandUuid, state) {
519
+ for (let i = this._pendingResults.length - 1; i >= 0; i--) {
520
+ const entry = this._pendingResults[i];
521
+ if (entry.commandUuid !== commandUuid || entry.settled)
522
+ continue;
523
+ this._pendingResults.splice(i, 1);
524
+ entry.settled = true;
525
+ entry.cleanup?.();
526
+ // Reaching here means no turn ever claimed this message, so there is no
527
+ // result of its own to give it. `completed` without a claiming turn is
528
+ // not expected — it is reported honestly rather than papered over with a
529
+ // neighbouring turn's payload.
530
+ entry.resolve({
531
+ status: state === "completed" ? "completed" : "aborted",
532
+ summary: null,
533
+ costUsd: null,
534
+ errorCode: `command_${state}`,
535
+ errorMessage: `Message was ${state} by the CLI without a turn of its own`,
536
+ });
537
+ }
538
+ }
539
+ /**
540
+ * Reject every send still waiting on a turn, and clear the turn bookkeeping
541
+ * that described it. Shared by the fatal paths and by `close()`.
542
+ */
543
+ rejectPendingSends(err) {
321
544
  const pending = this._pendingResults.splice(0);
545
+ // Batches already captured for a turn still settling. Their handler may
546
+ // never resume — the process is gone — so they are rejected here rather
547
+ // than left hanging their callers.
548
+ for (const captured of this._settlingBatches)
549
+ pending.push(...captured);
550
+ this._settlingBatches.clear();
551
+ // No turn survives this, and the host is told so rather than left holding
552
+ // an unmatched `turn_start`.
553
+ this.closeTurn("session_closed");
554
+ this._outstandingCommands.clear();
555
+ this._activeTasks.clear();
556
+ this._taskFacts.clear();
322
557
  for (const p of pending) {
323
558
  if (p.settled)
324
559
  continue;
@@ -326,6 +561,10 @@ export class ClaudeSessionImpl {
326
561
  p.cleanup?.();
327
562
  p.reject(err);
328
563
  }
564
+ }
565
+ /** Reject every pending send() Promise and outgoing control_response. */
566
+ rejectAllPending(err) {
567
+ this.rejectPendingSends(err);
329
568
  for (const [, p] of this._pendingControlResponses)
330
569
  p.reject(err);
331
570
  this._pendingControlResponses.clear();
@@ -370,6 +609,9 @@ export class ClaudeSessionImpl {
370
609
  if (this._state === "idle")
371
610
  this._state = "thinking";
372
611
  const uuid = randomUUID();
612
+ // The CLI echoes this back on every `command_lifecycle` record for the
613
+ // message, which is what lets a turn be attributed exactly.
614
+ this._outstandingCommands.add(uuid);
373
615
  // Write user message in stream-json format. `uuid` becomes the queue
374
616
  // key the CLI uses for `cancel_async_message`.
375
617
  const userMsg = ndjsonLine({
@@ -385,7 +627,7 @@ export class ClaudeSessionImpl {
385
627
  resolveFn = resolve;
386
628
  rejectFn = reject;
387
629
  });
388
- const entry = { resolve: resolveFn, reject: rejectFn };
630
+ const entry = { commandUuid: uuid, resolve: resolveFn, reject: rejectFn };
389
631
  this._pendingResults.push(entry);
390
632
  // Track the in-flight turn so drain() can await it; drop it on settle.
391
633
  this._inFlight.add(result);
@@ -473,6 +715,8 @@ export class ClaudeSessionImpl {
473
715
  this.proc.stdin.write(cancelMsg);
474
716
  try {
475
717
  const response = await responsePromise;
718
+ // No bookkeeping here: the CLI emits `command_lifecycle/cancelled` for
719
+ // the message, and that is what retires it.
476
720
  return { cancelled: response["cancelled"] === true };
477
721
  }
478
722
  catch {
@@ -543,14 +787,57 @@ export class ClaudeSessionImpl {
543
787
  // Let every in-flight turn settle (resolve or reject) before closing, so
544
788
  // a running tool finishes rather than being killed mid-flight.
545
789
  await Promise.allSettled([...this._inFlight]);
790
+ // `_inFlight` only tracks turns this host dispatched. A provider-initiated
791
+ // turn — the CLI acting on a finished background task — has no send()
792
+ // behind it, so draining used to SIGTERM the CLI mid-turn and kill
793
+ // whatever that turn was doing. Now that the session can see those turns,
794
+ // the one API whose contract is "let in-flight work settle" honors them.
795
+ await this.awaitTurnClose();
546
796
  await this.close();
547
797
  })();
548
798
  return this._drainPromise;
549
799
  }
800
+ /**
801
+ * Resolve once no turn is open, or once the deadline passes.
802
+ *
803
+ * Bounded on purpose: a turn that never produces a `result` (a wedged CLI)
804
+ * must not make `drain()` hang forever. Polling rather than eventing because
805
+ * turn close is driven from the stream reader, and a missed edge here would
806
+ * be the same hang by another route.
807
+ */
808
+ async awaitTurnClose(timeoutMs = 120_000) {
809
+ const deadline = Date.now() + timeoutMs;
810
+ const settling = () => {
811
+ if (this._openTurn)
812
+ return true;
813
+ // A running subagent will hand its result back and open a resume turn,
814
+ // so it is in-flight work. Closing during the gap between the root
815
+ // result and that turn — measured at 8-11s live — killed it outright.
816
+ //
817
+ // Background *processes* are excluded on purpose: a dev server started
818
+ // with run_in_background may never exit, and the contract is "let the
819
+ // agent's work settle", not "outlive whatever it launched".
820
+ // A delivered result whose resume turn has not opened yet. This is the
821
+ // provider's own signal that more work is coming, used where it matters.
822
+ if (this._pendingDeliveries.size > 0)
823
+ return true;
824
+ for (const taskType of this._activeTasks.values()) {
825
+ if (taskType === "subagent")
826
+ return true;
827
+ }
828
+ return false;
829
+ };
830
+ while (settling() && this._state !== "closed" && Date.now() < deadline) {
831
+ await new Promise((r) => setTimeout(r, 50));
832
+ }
833
+ }
550
834
  async close() {
551
835
  if (this._state === "closed")
552
836
  return;
553
837
  this._state = "closed";
838
+ // Anything still waiting has no turn left to settle it. Without this a
839
+ // bare `close()` with a send in flight hung that caller for good.
840
+ this.rejectPendingSends(new Error("Session closed before the turn completed"));
554
841
  // Final transcript scan so a goal_status (e.g. `met`) written since the last
555
842
  // ~800ms poll isn't lost on a fast close. No-op when no goal is observed.
556
843
  await this.scanGoalTranscript().catch(() => { });
@@ -628,8 +915,20 @@ export class ClaudeSessionImpl {
628
915
  // await inside handleResult drains the chain so handlers settle before
629
916
  // the awaiting send() returns.
630
917
  if (type === "result") {
918
+ // Close the turn and snapshot its settlement batch synchronously, before
919
+ // any `await`. Both halves matter. Closing late swallows the next
920
+ // `turn_start`, because the CLI can flush this result and the following
921
+ // turn's opening line in one chunk. Snapshotting late is worse: the
922
+ // batch used to live in a field shared by every turn, so whichever
923
+ // async handler resumed first drained all of it and resolved the second
924
+ // turn's sends with the first turn's result.
925
+ const closingTurn = this.takeOpenTurn();
926
+ const batch = this.claimSettlementBatch(closingTurn);
927
+ this._seenResult = true;
631
928
  this.handleStreamMessage(msg, line);
632
- void this.handleResult(msg);
929
+ // Announced after the result event, so the payload precedes the close.
930
+ this.emitTurnEnd(closingTurn, "result");
931
+ void this.handleResult(msg, batch);
633
932
  return;
634
933
  }
635
934
  // Stream events — forward via onEvent and parse for agentex StreamEvent
@@ -642,16 +941,24 @@ export class ClaudeSessionImpl {
642
941
  const requestId = str(msg, "request_id");
643
942
  const request = obj(msg, "request");
644
943
  const subtype = str(request, "subtype");
944
+ const nested = msg["parent_tool_use_id"] != null
945
+ || request["parent_tool_use_id"] != null;
645
946
  switch (subtype) {
646
947
  case "initialize":
647
948
  this.sendControlResponse(requestId, {});
648
949
  break;
950
+ // A detached subagent raises its own prompts, and those are the child
951
+ // being blocked, not this session. Parking the parent in
952
+ // `waiting_for_approval` there outlived the root turn with nothing able
953
+ // to clear it — the same nested-actor rule the stream path applies.
649
954
  case "can_use_tool":
650
- this._state = "waiting_for_approval";
955
+ if (!nested)
956
+ this._state = "waiting_for_approval";
651
957
  this.handlePermissionRequest(requestId, request);
652
958
  break;
653
959
  case "elicitation":
654
- this._state = "waiting_for_input";
960
+ if (!nested)
961
+ this._state = "waiting_for_input";
655
962
  this.handleElicitationRequest(requestId, request);
656
963
  break;
657
964
  case "hook_callback":
@@ -824,7 +1131,7 @@ export class ClaudeSessionImpl {
824
1131
  // -------------------------------------------------------------------------
825
1132
  // Result handling
826
1133
  // -------------------------------------------------------------------------
827
- async handleResult(msg) {
1134
+ async handleResult(msg, batch = []) {
828
1135
  const summary = typeof msg["result"] === "string" ? msg["result"] : null;
829
1136
  const isError = msg["is_error"] === true;
830
1137
  const costUsd = typeof msg["total_cost_usd"] === "number" ? msg["total_cost_usd"] : null;
@@ -899,13 +1206,19 @@ export class ClaudeSessionImpl {
899
1206
  // session whose stdin is dead.
900
1207
  if (this._state === "closed")
901
1208
  return;
902
- this._state = "idle";
903
- // Drain ALL pending send() resolvers with this turn's result. Multiple
904
- // concurrent sends coalesced by the CLI into one turn share the same
905
- // TurnResult — documented in SendHandle JSDoc. Splice empties the list
906
- // so subsequent sends queue against a fresh list for the next turn.
907
- const pending = this._pendingResults.splice(0);
908
- for (const p of pending) {
1209
+ // Only fall back to idle if no new turn opened while the chain drained.
1210
+ // The CLI can start the next turn in the same chunk as this result, and
1211
+ // asserting idle over a turn that is already running is the exact false
1212
+ // "finished" this release exists to remove.
1213
+ if (!this._openTurn)
1214
+ this._state = "idle";
1215
+ // Settle the batch this turn owns — and only that batch. It was captured
1216
+ // synchronously when the result line was read, so a turn that opened while
1217
+ // this handler was suspended cannot have its sends resolved here. That
1218
+ // sharing is what made two back-to-back results resolve both sends with
1219
+ // the first result.
1220
+ this._settlingBatches.delete(batch);
1221
+ for (const p of batch) {
909
1222
  // Skip sends already settled early by timeout / abort.
910
1223
  if (p.settled)
911
1224
  continue;
@@ -920,20 +1233,91 @@ export class ClaudeSessionImpl {
920
1233
  // Stream event forwarding
921
1234
  // -------------------------------------------------------------------------
922
1235
  handleStreamMessage(msg, rawLine) {
1236
+ // A line can arrive during close()'s grace window. Acting on it would
1237
+ // reopen a turn on a dead session, flip `state` off "closed", and emit a
1238
+ // `turn_start` to a host that has already drained — with nothing left to
1239
+ // ever close it again.
1240
+ if (this._state === "closed")
1241
+ return;
923
1242
  // Extract session ID from any message that has one
924
1243
  if (typeof msg["session_id"] === "string" && msg["session_id"]) {
925
1244
  this._sessionId = msg["session_id"];
926
1245
  }
927
- // Update session state based on message type
1246
+ // Update session state based on message type. Nested-actor lines are
1247
+ // excluded for the same reason they do not open a turn: a detached
1248
+ // subagent streams its own output onto the parent after the root result,
1249
+ // and letting it drive `state` left the session reporting "thinking" for
1250
+ // the child's whole run — directly contradicting `turn_start`/`result`,
1251
+ // with nothing to clear it if the child is stopped or killed.
928
1252
  const type = str(msg, "type");
929
- if (type === "assistant" || type === "thinking") {
930
- this._state = "thinking";
1253
+ if (msg["parent_tool_use_id"] == null) {
1254
+ if (type === "assistant" || type === "thinking") {
1255
+ this._state = "thinking";
1256
+ }
1257
+ else if (type === "tool_use") {
1258
+ this._state = "tool_executing";
1259
+ }
1260
+ else if (type === "tool_result") {
1261
+ this._state = "thinking";
1262
+ }
931
1263
  }
932
- else if (type === "tool_use") {
933
- this._state = "tool_executing";
1264
+ // Retire host messages the CLI has finished with, before deciding what a
1265
+ // turn opening means. `refused`/`discarded` never produce a `result`, so
1266
+ // this is also the only thing that closes a turn they opened — without it
1267
+ // the session would pin as working with no path back.
1268
+ if (type === "command_lifecycle") {
1269
+ this._seenCommandLifecycle = true;
1270
+ const commandUuid = str(msg, "command_uuid");
1271
+ const state = str(msg, "state");
1272
+ if (commandUuid && TERMINAL_COMMAND_STATES.has(state)) {
1273
+ this._outstandingCommands.delete(commandUuid);
1274
+ // The CLI is done with this message. Anything still waiting on it will
1275
+ // never be settled by a `result` — a cancelled or refused message
1276
+ // produces none, and a `completed` one whose turn we could not attribute
1277
+ // would otherwise wait forever.
1278
+ this.settleCommand(commandUuid, state);
1279
+ if (state !== "completed" && this._openTurn?.commandUuids.has(commandUuid)) {
1280
+ // Nothing else will close this turn: these states never produce a
1281
+ // `result`. (A closed session returned above, so state is live.)
1282
+ this.closeTurn(state);
1283
+ this._state = "idle";
1284
+ }
1285
+ }
934
1286
  }
935
- else if (type === "tool_result") {
936
- this._state = "thinking";
1287
+ // Background-task state is session state: `drain()` waits on live
1288
+ // subagents and turn attribution consumes delivered results. Decoded here
1289
+ // from the wire rather than in the dispatch path, which only runs when a
1290
+ // host subscribed — a host that reads `session.state` and calls `drain()`
1291
+ // without an onEvent handler would otherwise get neither.
1292
+ this.trackBackgroundTaskState(msg);
1293
+ // Open a turn on the first line of turn content, before that line's own
1294
+ // events, so a host sees turn_start → … → result → turn_end in order.
1295
+ const namedUuid = type === "command_lifecycle" ? str(msg, "command_uuid") : "";
1296
+ if (this._openTurn) {
1297
+ // The CLI coalesces: a host message dequeued while a turn is running
1298
+ // joins it rather than starting its own. Modelling one command per turn
1299
+ // left every coalesced message permanently unsettled.
1300
+ if (namedUuid && this._outstandingCommands.has(namedUuid))
1301
+ this.joinTurn(namedUuid);
1302
+ }
1303
+ else if (this.opensTurn(msg, type)) {
1304
+ // Exact attribution: the CLI stamps every `command_lifecycle` for a host
1305
+ // message with the uuid `send()` minted, so a `started` naming one we are
1306
+ // still waiting on *is* that message's turn. Anything else opened the
1307
+ // turn without us asking — which is what this event exists to expose.
1308
+ const claimedUuid = namedUuid
1309
+ ? (this._outstandingCommands.has(namedUuid) ? namedUuid : null)
1310
+ // Only guess for a build that has never named a command. Once one has,
1311
+ // it names every host message, so an unnamed turn is provider-initiated
1312
+ // and consuming a uuid here would mislabel twice.
1313
+ : this._seenCommandLifecycle
1314
+ ? null
1315
+ : this._outstandingCommands.values().next().value ?? null;
1316
+ if (claimedUuid)
1317
+ this._outstandingCommands.delete(claimedUuid);
1318
+ // Track the named command even when it is not one of ours, so its
1319
+ // terminal lifecycle record can close this turn.
1320
+ this.openTurn(claimedUuid ? "send" : "resume", claimedUuid ?? (namedUuid || null), msg);
937
1321
  }
938
1322
  // Parse + dispatch when there's an onEvent subscriber OR an active goal to
939
1323
  // observe, so native goal_status transitions update getGoal() even with no
@@ -945,21 +1329,149 @@ export class ClaudeSessionImpl {
945
1329
  }
946
1330
  }
947
1331
  /**
948
- * Queue an event for in-order delivery to `onEvent`. Each call appends a
949
- * `.then` to `_eventChain` so handler N+1 only starts after handler N's
950
- * returned promise settles. Errors are swallowed inside the chain so a
951
- * throwing handler does not break delivery of subsequent events.
1332
+ * Whether this wire line is the first content of a turn.
1333
+ *
1334
+ * Deliberately excludes two things.
1335
+ *
1336
+ * The background-task records (`task_started`, `task_updated`,
1337
+ * `task_notification`, `background_tasks_changed`) arrive *between* turns —
1338
+ * a detached child reporting in while nothing is running — so treating one
1339
+ * as a turn opening would report the session as working every time a
1340
+ * background process coughed.
1341
+ *
1342
+ * `system/init` is special. It is boot metadata the first time — before any
1343
+ * result, opening a turn on it would create a phantom `resume` at startup,
1344
+ * leave it open, and swallow the first real send's `turn_start`. But the
1345
+ * CLI re-emits it at the head of every provider-initiated resume turn, and
1346
+ * measured live it precedes that turn's first assistant line by 1.4–1.9s.
1347
+ * Ignoring it outright traded a phantom turn for a second-long blind window
1348
+ * on exactly the turns this event exists to expose, so it opens a turn once
1349
+ * a result has been seen on this stream and not before.
1350
+ *
1351
+ * And anything carrying `parent_tool_use_id`, which is a nested actor's own
1352
+ * output. Claude streams a detached subagent's assistant text, thinking and
1353
+ * tool calls onto the parent stream while the root turn is over. Those are
1354
+ * the child working, not this session, and counting them would hold the
1355
+ * session "working" for the entire detached run — when the honest answer,
1356
+ * and what the CLI shows, is that the root turn finished and its result is
1357
+ * ready to read. The real resume turn arrives afterwards, at root level,
1358
+ * once the child's result is delivered back.
1359
+ */
1360
+ opensTurn(msg, type) {
1361
+ if (msg["parent_tool_use_id"] != null)
1362
+ return false;
1363
+ // `stream_event` is the partial-message wrapper. Without it, a host that
1364
+ // opted into `includePartialMessages` receives the whole streamed reply
1365
+ // before `turn_start` — the turn's output arriving before the event that
1366
+ // says the turn began.
1367
+ if (type === "stream_event")
1368
+ return true;
1369
+ // `command_lifecycle/started` heads every host-dispatched turn and only
1370
+ // those — never a resume — so it is safe to open on and carries no
1371
+ // phantom-turn risk. It is also the earliest signal available: without it
1372
+ // the session's very first turn has no opener until its first assistant
1373
+ // line, measured live at 1.8-6.4s of reading as idle while working.
1374
+ if (type === "command_lifecycle")
1375
+ return str(msg, "state") === "started";
1376
+ if (type === "system")
1377
+ return this._seenResult && str(msg, "subtype") === "init";
1378
+ // `thinking` and `tool_use` are content blocks inside an `assistant`
1379
+ // message, not top-level wire types; they are listed because the state
1380
+ // machine above accepts them and the two should not disagree about what
1381
+ // counts as turn content.
1382
+ return type === "assistant"
1383
+ || type === "thinking"
1384
+ || type === "tool_use"
1385
+ || type === "user";
1386
+ }
1387
+ /**
1388
+ * Fold one background-task wire record into session state.
1389
+ *
1390
+ * Separate from the enrichment below on purpose: this must run for every
1391
+ * line regardless of whether anything is listening, because `drain()` and
1392
+ * turn attribution read what it maintains.
952
1393
  */
1394
+ trackBackgroundTaskState(msg) {
1395
+ const event = backgroundTaskEventFromClaude(msg);
1396
+ if (!event)
1397
+ return;
1398
+ const terminal = event.status === "completed"
1399
+ || event.status === "failed"
1400
+ || event.status === "stopped";
1401
+ if (terminal) {
1402
+ this._activeTasks.delete(event.taskId);
1403
+ }
1404
+ else if (event.status !== null || event.phase === "started") {
1405
+ // `status: null` means "no change reported" — a sparse patch that only
1406
+ // renames a task says nothing about liveness. Writing the task back into
1407
+ // the live set there resurrected work that had already completed.
1408
+ this._activeTasks.set(event.taskId, this._taskFacts.get(event.taskId)?.taskType ?? event.taskType);
1409
+ }
1410
+ // The provider stating that a turn is coming — held so `drain()` does not
1411
+ // close in the gap before it opens. Keyed on the outcome rather than on
1412
+ // the delivery record alone, because a completion arrives as two records
1413
+ // (`task_updated` then `task_notification`) and the first already removes
1414
+ // the task from the live set: waiting only for the second left a window
1415
+ // where neither the task nor the delivery was visible.
1416
+ //
1417
+ // Only for outcomes that actually deliver. A stopped task hands nothing
1418
+ // back and starts no turn, so holding for one would just burn the deadline.
1419
+ const delivers = event.status === "completed" || event.status === "failed";
1420
+ if ((event.report || delivers) && !this._openTurn) {
1421
+ this._pendingDeliveries.add(event.taskId);
1422
+ }
1423
+ if (event.phase === "started") {
1424
+ this._taskFacts.set(event.taskId, {
1425
+ taskType: event.taskType,
1426
+ description: event.description,
1427
+ });
1428
+ // Evict oldest-first. Map iteration is insertion-ordered, so the first
1429
+ // key is the least recently started task.
1430
+ while (this._taskFacts.size > ClaudeSessionImpl.TASK_FACT_LIMIT) {
1431
+ const oldest = this._taskFacts.keys().next();
1432
+ if (oldest.done)
1433
+ break;
1434
+ this._taskFacts.delete(oldest.value);
1435
+ }
1436
+ }
1437
+ }
1438
+ /**
1439
+ * Fill in what a sparse background-task record leaves out.
1440
+ *
1441
+ * `task_started` is the only record that names the task's type; the patches
1442
+ * and the completion notification that follow identify it by id alone. This
1443
+ * remembers the first description and carries it forward so a completion
1444
+ * still knows it was a subagent.
1445
+ */
1446
+ _trackTaskFacts(event) {
1447
+ if (event.type !== "background_task")
1448
+ return event;
1449
+ const known = this._taskFacts.get(event.taskId);
1450
+ if (!known)
1451
+ return event;
1452
+ const needsType = event.taskType === "unknown" && known.taskType !== "unknown";
1453
+ const needsDescription = event.description === null && known.description !== null;
1454
+ if (!needsType && !needsDescription)
1455
+ return event;
1456
+ return {
1457
+ ...event,
1458
+ taskType: needsType ? known.taskType : event.taskType,
1459
+ description: needsDescription ? known.description : event.description,
1460
+ };
1461
+ }
953
1462
  dispatchEvent(event) {
1463
+ // Enrich and record before anything can bail out. `_trackTaskFacts` also
1464
+ // maintains session state — which tasks are live, which results are
1465
+ // waiting for a resume turn — and gating that on a subscriber meant
1466
+ // `session.state`, `drain()` and turn attribution all silently degraded
1467
+ // for a host that reads the session without an onEvent handler.
1468
+ const enriched = this._trackTaskFacts(this._trackToolName(event));
954
1469
  // Let the goal engine track native goal_status transitions even when no
955
1470
  // onEvent handler is attached (keeps getGoal() accurate).
956
- this._goals.observe(event);
1471
+ this._goals.observe(enriched);
957
1472
  const cb = this.ctx.onEvent;
958
1473
  if (!cb)
959
1474
  return;
960
- // Enrich synchronously (in stream order) so tool_result events carry the
961
- // name of the tool_call they answer.
962
- const enriched = this._trackToolName(event);
963
1475
  this._eventChain = this._eventChain.then(async () => {
964
1476
  try {
965
1477
  await cb(enriched);