@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.
- package/CHANGELOG.md +441 -0
- package/README.md +23 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/claude/parse.d.ts +3 -0
- package/dist/providers/claude/parse.d.ts.map +1 -1
- package/dist/providers/claude/parse.js +23 -1
- package/dist/providers/claude/parse.js.map +1 -1
- package/dist/providers/claude/session.d.ts +174 -4
- package/dist/providers/claude/session.d.ts.map +1 -1
- package/dist/providers/claude/session.js +541 -29
- package/dist/providers/claude/session.js.map +1 -1
- package/dist/providers/codex/execute.d.ts.map +1 -1
- package/dist/providers/codex/execute.js +3 -0
- package/dist/providers/codex/execute.js.map +1 -1
- package/dist/providers/codex/parse.d.ts +12 -1
- package/dist/providers/codex/parse.d.ts.map +1 -1
- package/dist/providers/codex/parse.js +21 -0
- package/dist/providers/codex/parse.js.map +1 -1
- package/dist/providers/codex/session.d.ts.map +1 -1
- package/dist/providers/codex/session.js +21 -1
- package/dist/providers/codex/session.js.map +1 -1
- package/dist/types.d.ts +95 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/providers/claude/parse.ts +23 -1
- package/src/providers/claude/session.ts +556 -30
- package/src/providers/codex/execute.ts +3 -0
- package/src/providers/codex/parse.ts +25 -0
- package/src/providers/codex/session.ts +24 -1
- package/src/types.ts +97 -0
|
@@ -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
|
-
/**
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
955
|
+
if (!nested)
|
|
956
|
+
this._state = "waiting_for_approval";
|
|
651
957
|
this.handlePermissionRequest(requestId, request);
|
|
652
958
|
break;
|
|
653
959
|
case "elicitation":
|
|
654
|
-
|
|
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
|
-
|
|
903
|
-
//
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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 (
|
|
930
|
-
|
|
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
|
-
|
|
933
|
-
|
|
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
|
-
|
|
936
|
-
|
|
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
|
-
*
|
|
949
|
-
*
|
|
950
|
-
*
|
|
951
|
-
*
|
|
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(
|
|
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);
|