@agentex/agent 0.0.36 → 0.0.38
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 +460 -0
- package/README.md +32 -7
- 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/dist/utils/instructions.d.ts +36 -12
- package/dist/utils/instructions.d.ts.map +1 -1
- package/dist/utils/instructions.js +104 -53
- package/dist/utils/instructions.js.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
- package/src/utils/instructions.ts +152 -64
|
@@ -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
|
-
/**
|
|
376
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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 (
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
*
|
|
1079
|
-
*
|
|
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(
|
|
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
|
});
|