@khorsheed/dsh-local-agent-claude-code 0.2.0-rc.1 → 0.2.0

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.
@@ -12,12 +12,10 @@ import { claudeDirectory } from './model-catalog.js';
12
12
  * continuable) and the same-shape push stream folded live per turn.
13
13
  *
14
14
  * Auth discipline (a hard requirement): the resident process runs under the
15
- * family's scoped `CLAUDE_CONFIG_DIR` and nothing else — the OAuth marker in
16
- * the scoped `.claude.json` and the keychain entry hashed to that path are
17
- * exactly what the exec mode's one-shot processes already use. The driver
18
- * never touches the user's global `~/.claude`, never runs any `auth` verb,
19
- * and only adds `ANTHROPIC_BASE_URL` when the plugin config supplies it (the
20
- * exec path's exact env rule).
15
+ * family's scoped `CLAUDE_CONFIG_DIR` plus the resolved endpoint credential's
16
+ * `ANTHROPIC_*` variables (the scope's endpoint-auth.json over the plugin
17
+ * config — the exec path's exact env rule, resolved per spawn). The driver
18
+ * never touches the user's global `~/.claude` and never runs any `auth` verb.
21
19
  *
22
20
  * Protocol facts (all probed): the first stdin `user` message triggers
23
21
  * `system/init` carrying the server-assigned session id (EVERY turn emits a
@@ -30,8 +28,9 @@ import { claudeDirectory } from './model-catalog.js';
30
28
  * Fold: the resident stream speaks the exec path's exact event vocabulary
31
29
  * (`system`/`assistant`/`user`/`result`), so each turn folds through the
32
30
  * shared `ClaudeStreamParser` with the exec live mirror's hold-back rule (the
33
- * volatile last line waits for `result`, which carries the round's usage).
34
- * Every line folds 1:1. The always-enabled
31
+ * volatile last line waits for `result`, which carries the round's usage, and
32
+ * an unresulted tool line holds the fold until its result merges in place —
33
+ * the native lifecycle folds as one closed step). Every line folds 1:1. The always-enabled
35
34
  * `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
36
35
  * stream (native message id + block index, with ordered legacy fallback),
37
36
  * which reserves a (turn, step) at its first delta and
@@ -62,11 +61,13 @@ import { claudeDirectory } from './model-catalog.js';
62
61
  */
63
62
  import { ClaudeControlRequests } from './control-requests.js';
64
63
  import { StringDecoder } from 'node:string_decoder';
64
+ import { randomUUID } from 'node:crypto';
65
65
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
66
+ import { SessionId } from '@deepseek-ai/dsh-session';
66
67
  import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
67
68
  import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
68
- import { appendClaudeTranscriptLine, assistantEvent, claudeLineText, ClaudeStreamParser, claudeVersionFromInit, DEFAULT_DISPOSE_GRACE_MS, registerClaudeMemberRun, textTask, toolCallsOf, } from './claude-cli-provider.js';
69
- import { syncClaudeCredentialFile } from './records.js';
69
+ import { appendClaudeTranscriptLine, assistantEvent, appendClaudeRequestHeader, claudeLineText, ClaudeStreamParser, claudeVersionFromInit, DEFAULT_DISPOSE_GRACE_MS, registerClaudeMemberRun, MEMBER_TOOL_TIMEOUT_MS, textTask, toolCallsOf, usageFromClaude, } from './claude-cli-provider.js';
70
+ import { endpointAuthEnv } from './endpoint-auth.js';
70
71
  /** Default idle lifetime of an unused resident runtime before reclaim. */
71
72
  export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
72
73
  /** Default timeout for the turn's system/init ack (a cold boot can be slow). */
@@ -81,11 +82,6 @@ const RECLAIM_EOF_GRACE_MS = 1_000;
81
82
  export const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
82
83
  /** Default minimum text growth between one streaming kind's snapshot messages. */
83
84
  export const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
84
- /**
85
- * The stream-json channel could not come up (spawn failure or the first
86
- * turn's init never arrived). The provider catches exactly this and falls
87
- * back to the exec one-shot; any other error is a real round failure.
88
- */
89
85
  export class LiveChannelUnavailableError extends Error {
90
86
  constructor(message) {
91
87
  super(message);
@@ -419,14 +415,10 @@ export class ClaudeLiveDriver {
419
415
  if (this.disposed) {
420
416
  throw new LiveChannelUnavailableError('the live driver is disposed');
421
417
  }
422
- // Keychain→file sync before EVERY spawn: claude 2.1.236 reads
423
- // <home>/.credentials.json at runtime while login and the process's own
424
- // refresh write the keychain — the file goes stale on every rotation
425
- // (single-rotation refresh tokens make a consumed copy poison for the
426
- // next spawn). The login watch's sync alone cannot keep a resident
427
- // runtime authenticatable across rotations. Best-effort: a missing grant
428
- // fails the turn with the CLI's own "Not logged in", not here.
429
- await syncClaudeCredentialFile(spec.homeDir, message => { this.ctx.logger.warn(message); }).catch(() => false);
418
+ // The endpoint credential is resolved per spawn (the scope's
419
+ // endpoint-auth.json over the plugin config): a key rotation reaches the
420
+ // resident runtime at its next respawn, with no reload and no reconcile.
421
+ const endpoint = await this.config.endpoint?.(spec.homeDir);
430
422
  // The member bridge rides the spawn argv (`--mcp-config` + `--allowedTools`),
431
423
  // exactly as in the exec path; the resident process serves one member, so
432
424
  // its token lives with the process.
@@ -460,11 +452,14 @@ export class ClaudeLiveDriver {
460
452
  stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
461
453
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
462
454
  // The auth discipline: exactly the exec path's env — the scoped config
463
- // dir, plus the configured base URL override only.
455
+ // dir plus the resolved endpoint credential's `ANTHROPIC_*` variables.
464
456
  env: delegationEnv({
465
457
  CLAUDE_CONFIG_DIR: spec.homeDir,
458
+ // A bridge call may wait for the human (ask_user_question, a mode
459
+ // tool's approval); give MCP calls the bridge's own 30-minute bound.
460
+ ...member === undefined ? {} : { MCP_TOOL_TIMEOUT: MEMBER_TOOL_TIMEOUT_MS },
466
461
  ...spec.configuration === undefined ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? 'auto' },
467
- ...this.config.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.config.baseUrl },
462
+ ...endpointAuthEnv(endpoint),
468
463
  }),
469
464
  };
470
465
  let child;
@@ -579,27 +574,22 @@ export class ClaudeLiveDriver {
579
574
  */
580
575
  let roundModel;
581
576
  let roundCliVersion;
582
- /** Whether a folded line already carries the round's usage (no backfill event exists). */
577
+ /** Whether a folded line already carried usage (per-call inline, or the round's total as the fallback — no backfill event exists). */
583
578
  let usageCarried = false;
584
579
  /**
585
- * The step ledger: `reservedSteps` holds every step a stream reservation
586
- * consumed (in increasing order — permanently, whether the stream later
587
- * completes or stays an orphan snapshot), and a sequential line folds at
588
- * its index shifted past every reservation before it. A tool completed
589
- * mid-stream therefore folds ABOVE the stream's step and the projection
590
- * renders the round's chronological order.
591
- */
592
- const reservedSteps = [];
593
- /**
594
- * The streaming kinds seen this round by stream key:
595
- * deltas accumulate into throttled snapshot assistant/messages appended
596
- * at the kind's reserved (turn, step) — the host folds repeated settles
597
- * at one coordinate into one live-updating chat node, which is the only
598
- * streaming channel left after 0.1.5 retired the durable per-chunk event.
599
- * The kind's completed line folds at the same step and finalizes it; an
600
- * entry whose completion never arrives is force-finalized at settle.
580
+ * The streaming blocks seen this round by stream key: deltas accumulate
581
+ * into throttled snapshots at the block's step once it may open in the
582
+ * log (previewed live before that) — the host folds repeated settles at
583
+ * one coordinate into one live-updating chat node. The block's completed
584
+ * line folds at the same step and finalizes it; an entry whose
585
+ * completion never arrives is force-finalized at settle. A tool that
586
+ * completes mid-stream folds after the stream (chronological order).
601
587
  */
602
588
  const streams = new Map();
589
+ /** Provisional stream keys (live flush and preview identity), never log steps. */
590
+ let streamKeys = 0;
591
+ /** Line indexes already written (a line may fold ahead of one it chronologically precedes). */
592
+ const folded = new Set();
603
593
  /** The stream deltas currently accumulate into (kinds stream sequentially). */
604
594
  let activeStream;
605
595
  let persistQueue = Promise.resolve();
@@ -635,6 +625,44 @@ export class ClaudeLiveDriver {
635
625
  // key, paired with their completion in arrival order.
636
626
  let messageKey;
637
627
  let legacyMessage = 0;
628
+ // One API call per native message id. Claude emits an `assistant` event
629
+ // per content block, each repeating the usage snapshot taken at
630
+ // message_start (output counted only that far); the call's final count
631
+ // arrives in its message_delta. A call's lines therefore fold only once
632
+ // the call ended (or the round settles), and its usage — the start
633
+ // snapshot completed by the delta — rides its last line alone.
634
+ const callUsage = new Map();
635
+ const openCalls = new Set();
636
+ let currentCall;
637
+ const endCall = (usage) => {
638
+ if (currentCall === undefined)
639
+ return;
640
+ if (typeof usage === 'object' && usage !== null) {
641
+ const merged = { ...callUsage.get(currentCall) };
642
+ for (const [key, value] of Object.entries(usage))
643
+ if (value !== null && value !== undefined)
644
+ merged[key] = value;
645
+ callUsage.set(currentCall, merged);
646
+ }
647
+ openCalls.delete(currentCall);
648
+ };
649
+ /**
650
+ * A line's usage when its call is known: `skip` on every line but the
651
+ * call's last, the call's final usage (or, without stream events, the
652
+ * line's own report) on the last; `null` for an id-less line.
653
+ */
654
+ const callLineUsage = (index) => {
655
+ const id = parser.lines[index]?.messageId;
656
+ if (id === undefined)
657
+ return null;
658
+ let last = -1;
659
+ parser.lines.forEach((line, position) => { if (line.messageId === id)
660
+ last = position; });
661
+ if (index !== last)
662
+ return { skip: true };
663
+ const raw = callUsage.get(id);
664
+ return { usage: raw !== undefined ? usageFromClaude(raw) : parser.lines[index].usage };
665
+ };
638
666
  const streamKey = (kind, index) => `${messageKey ?? `legacy-${turn}-${legacyMessage}`}:${typeof index === 'number' ? index : kind}`;
639
667
  /**
640
668
  * Append one snapshot of a streaming kind at its reserved (turn, step).
@@ -646,6 +674,28 @@ export class ClaudeLiveDriver {
646
674
  const streamPublisher = localAgent?.liveStreams === undefined ? undefined
647
675
  : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
648
676
  const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
677
+ // The step ledger. The log admits steps strictly in sequence — one open
678
+ // at a time, numbered when WRITTEN (`lastStep`) — while chronology is
679
+ // kept by start order: a stream sorts before every line that completed
680
+ // after its first delta (`after`). A stream's step opens in the log only
681
+ // once it is the oldest unfinished stream and everything before it is
682
+ // written; until then the browser previews it and its fold writes it.
683
+ let lastStep = 0;
684
+ let openStep;
685
+ const openStreamStep = (stream) => {
686
+ lastStep += 1;
687
+ childSession.append('step/start', { turn, step: lastStep });
688
+ stream.durable = lastStep;
689
+ stream.opened = true;
690
+ openStep = lastStep;
691
+ };
692
+ const closeOpenStep = () => {
693
+ if (openStep === undefined)
694
+ return;
695
+ childSession.append('step/end', { turn, step: openStep });
696
+ openStep = undefined;
697
+ };
698
+ const canOpenStream = (stream) => openStep === undefined && mirrored >= stream.after && streams.keys().next().value === stream.itemId;
649
699
  const pendingStreamArrival = new Map();
650
700
  const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
651
701
  if (stream.text.trim() === '')
@@ -660,44 +710,62 @@ export class ClaudeLiveDriver {
660
710
  liveFlush.cancel(stream.step);
661
711
  const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
662
712
  pendingStreamArrival.delete(stream.step);
663
- if (streamPublisher !== undefined && !final) {
664
- if (!stream.opened) {
665
- childSession.append('step/start', { turn, step: stream.step });
666
- stream.opened = true;
713
+ if (!stream.opened) {
714
+ if (canOpenStream(stream))
715
+ openStreamStep(stream);
716
+ else if (!final) {
717
+ // Not its turn in the log yet: show it live, write it later.
718
+ streamPublisher?.preview(stream.step, stream.kind, stream.text, { itemId: stream.itemId, receivedAt });
719
+ return;
667
720
  }
668
- streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
721
+ else {
722
+ // Settle: every line has folded; take the next step.
723
+ closeOpenStep();
724
+ openStreamStep(stream);
725
+ }
726
+ }
727
+ if (streamPublisher !== undefined && !final) {
728
+ streamPublisher.update(stream.durable, stream.kind, stream.text, { receivedAt });
669
729
  stream.lastSnapshotAt = now;
670
730
  stream.lastSnapshotLen = stream.text.length;
671
731
  return;
672
732
  }
673
- if (!stream.opened) {
674
- childSession.append('step/start', { turn, step: stream.step });
675
- stream.opened = true;
676
- }
677
733
  const usage = parser.usage;
734
+ // Per-call folds carry the accounting; the round total only ever
735
+ // back-fills a stream that named none (a sum would double count).
736
+ const totalFallback = withUsage && !usageCarried && usage !== undefined && !parser.lines.some(line => line.usage !== undefined);
678
737
  childSession.append('assistant/message', {
679
738
  turn,
680
- step: stream.step,
739
+ step: stream.durable,
681
740
  message: assistantEvent([
682
741
  stream.kind === 'think'
683
742
  ? { type: 'reasoning', text: stream.text }
684
743
  : { type: 'text', text: stream.text },
685
744
  ]),
686
745
  stream: [],
687
- ...withUsage && !usageCarried && usage !== undefined ? { usage } : {},
746
+ ...totalFallback ? { usage } : {},
688
747
  ...interrupted ? { interrupted: true } : {},
689
748
  }, { surfaceOp: 'append' });
690
- if (withUsage && !usageCarried && usage !== undefined)
749
+ if (totalFallback)
691
750
  usageCarried = true;
692
751
  stream.lastSnapshotAt = now;
693
752
  stream.lastSnapshotLen = stream.text.length;
694
753
  persist();
695
754
  };
755
+ /** Close a stream's live presentation once its final text is in the log. */
756
+ const finishStream = (stream) => {
757
+ liveFlush.cancel(stream.step);
758
+ streams.delete(stream.itemId);
759
+ if (activeStream === stream.itemId)
760
+ activeStream = undefined;
761
+ streamPublisher?.finish(stream.step);
762
+ if (stream.durable !== undefined)
763
+ streamPublisher?.finish(stream.durable);
764
+ };
696
765
  /**
697
- * Reserve the step one streaming kind will occupy — past every completed
698
- * line (folded or held back) and every earlier reservation. A different
699
- * kind's deltas force one final snapshot of the current stream (its
700
- * completion still folds at its own reserved step when it lands).
766
+ * Track one new streaming block: its provisional key and how many lines
767
+ * had completed when it began. A different block's deltas force one
768
+ * freshness snapshot of the current stream.
701
769
  */
702
770
  const reserveStream = (itemId, kind) => {
703
771
  if (activeStream !== undefined && activeStream !== itemId) {
@@ -711,78 +779,121 @@ export class ClaudeLiveDriver {
711
779
  }
712
780
  let stream = streams.get(itemId);
713
781
  if (stream === undefined) {
714
- const step = parser.lines.length + reservedSteps.length + 1;
715
- reservedSteps.push(step);
716
- stream = { itemId, kind, step, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
782
+ streamKeys += 1;
783
+ stream = { itemId, kind, step: streamKeys, after: parser.lines.length, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
717
784
  streams.set(itemId, stream);
718
785
  }
719
786
  activeStream = itemId;
720
787
  };
721
- /** The sequential fold step for `parser.lines[index]`: its position shifted past every reservation before it. */
722
- const foldStep = (index) => {
723
- let step = index + 1;
724
- for (const reserved of reservedSteps) {
725
- if (reserved <= step)
726
- step += 1;
727
- else
728
- break;
788
+ /** The stream a completed line finalizes, if it was streamed. */
789
+ const streamOfLine = (line) => line === undefined || line.kind === 'tool' ? undefined
790
+ : (line.streamId === undefined ? undefined : streams.get(line.streamId))
791
+ ?? [...streams.values()].find(candidate => candidate.kind === line.kind && (line.streamId === undefined || candidate.itemId.startsWith('legacy-')));
792
+ /** Write parser.lines[index] at the next step(s). */
793
+ const foldLine = (index, lineUsage) => {
794
+ const line = parser.lines[index];
795
+ // The fold's per-call usage (the assistant event's inline report) wins
796
+ // over the round-total fallback; once any line carries its own, the
797
+ // round total never joins the log (a sum would double count).
798
+ const perCall = callLineUsage(index);
799
+ const effectiveUsage = perCall === null ? line.usage ?? lineUsage
800
+ : 'skip' in perCall ? undefined : perCall.usage ?? lineUsage;
801
+ if (effectiveUsage !== undefined)
802
+ usageCarried = true;
803
+ const stream = streamOfLine(line);
804
+ if (stream !== undefined) {
805
+ // A streamed line finalizes its own step (opening it now if no
806
+ // snapshot ever could) and closes it.
807
+ if (!stream.opened) {
808
+ closeOpenStep();
809
+ openStreamStep(stream);
810
+ }
811
+ const blocks = stream.kind === 'think'
812
+ ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
813
+ : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
814
+ childSession.append('assistant/message', {
815
+ turn,
816
+ step: stream.durable,
817
+ message: assistantEvent(blocks),
818
+ stream: [],
819
+ ...effectiveUsage === undefined ? {} : { usage: effectiveUsage },
820
+ }, { surfaceOp: 'append' });
821
+ finishStream(stream);
822
+ closeOpenStep();
823
+ }
824
+ else {
825
+ closeOpenStep();
826
+ lastStep += 1;
827
+ // The fold decided the usage; the line's own (per-block snapshot) must not override it.
828
+ const { usage: _snapshot, ...bare } = line;
829
+ appendClaudeTranscriptLine(childSession, turn, lastStep, bare, effectiveUsage);
729
830
  }
730
- return step;
831
+ folded.add(index);
832
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
731
833
  };
732
- /** Mirror folded lines [mirrored, upto); the last line is held back until `result`. */
733
- const mirrorUpTo = (upto, withUsage) => {
734
- // The usage rides the last NON-tool line (tool events carry no usage
735
- // slot, and a kill mid-command ends the transcript with a tool line).
736
- // A carrier mirrored in an earlier flush (before the usage was knowable)
737
- // loses the accounting to the settle's final snapshot — host 0.1.5 has
738
- // no usage-backfill event.
834
+ /**
835
+ * Mirror the completed lines before `upto` in chronological order; the
836
+ * last line is held back until `result`. A line waits behind every stream
837
+ * that began before it completed (that stream's line folds first, or the
838
+ * mirror stops while it still streams). A tool line whose result the
839
+ * stream has not recorded yet HOLDS the fold mid-round: the fold writes
840
+ * the native lifecycle as one closed step (the released-log reader
841
+ * resolves every advertised call before step/end), and the result merges
842
+ * into the call's line in place, so a later pass retries from here. The
843
+ * settle flushes (`settle: true` — the turn's result event, or the final
844
+ * flush after a cancel/failure) never hold: a result that will never
845
+ * arrive folds as the interrupted placeholder.
846
+ */
847
+ const mirrorUpTo = (upto, withUsage, settle = false) => {
848
+ // Fallback carrier only: when the fold stamped per-call usage onto any
849
+ // line, the round total never joins the log (a sum would double count);
850
+ // otherwise it rides the last NON-tool line (tool/call and tool/result
851
+ // events carry no usage slot, and a kill mid-command ends the
852
+ // transcript with a tool line). A carrier mirrored in an earlier flush
853
+ // (before the usage was knowable) loses the accounting to the settle's
854
+ // final snapshot — host 0.1.5 has no usage-backfill event.
739
855
  let usageIndex = -1;
740
- if (withUsage) {
856
+ if (withUsage && !parser.lines.some(line => line.usage !== undefined)) {
741
857
  for (let index = 0; index < parser.lines.length; index += 1) {
742
858
  if (parser.lines[index]?.kind !== 'tool')
743
859
  usageIndex = index;
744
860
  }
745
861
  }
746
- for (let index = mirrored; index < upto; index += 1) {
747
- const line = parser.lines[index];
748
- if (line === undefined)
749
- continue;
862
+ const usageFor = (index) => {
750
863
  const lineUsage = withUsage && index === usageIndex ? parser.usage : undefined;
751
864
  if (lineUsage !== undefined)
752
865
  usageCarried = true;
753
- const stream = line.kind === 'tool' ? undefined
754
- : (line.streamId === undefined ? undefined : streams.get(line.streamId))
755
- ?? [...streams.values()].find(candidate => candidate.kind === line.kind && (line.streamId === undefined || candidate.itemId.startsWith('legacy-')));
756
- const itemId = stream?.itemId;
757
- if (itemId !== undefined && stream !== undefined) {
758
- // A streamed line folds at its reserved step, finalizing the
759
- // snapshots: the step opens only if no snapshot ever landed (a
760
- // fast stream that stayed under the throttle), and closes here.
761
- liveFlush.cancel(stream.step);
762
- streams.delete(itemId);
763
- if (activeStream === itemId)
764
- activeStream = undefined;
765
- if (!stream.opened)
766
- childSession.append('step/start', { turn, step: stream.step });
767
- const blocks = stream.kind === 'think'
768
- ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
769
- : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
770
- childSession.append('assistant/message', {
771
- turn,
772
- step: stream.step,
773
- message: assistantEvent(blocks),
774
- stream: [],
775
- ...lineUsage === undefined ? {} : { usage: lineUsage },
776
- }, { surfaceOp: 'append' });
777
- streamPublisher?.finish(stream.step);
778
- childSession.append('step/end', { turn, step: stream.step });
779
- }
780
- else {
781
- appendClaudeTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
866
+ return lineUsage;
867
+ };
868
+ outer: for (let index = mirrored; index < upto; index += 1) {
869
+ if (folded.has(index))
870
+ continue;
871
+ const line = parser.lines[index];
872
+ if (line === undefined)
873
+ continue;
874
+ if (!settle && line.kind === 'tool' && line.result === undefined)
875
+ break;
876
+ // A call still streaming more blocks folds once it ends (its usage is final then).
877
+ if (!settle && line.messageId !== undefined && openCalls.has(line.messageId))
878
+ break;
879
+ const own = streamOfLine(line);
880
+ // Streams order among themselves by start; a plain line follows every
881
+ // stream that began before it completed.
882
+ const all = [...streams.values()];
883
+ const before = own === undefined ? all.filter(stream => stream.after <= index) : all.slice(0, all.indexOf(own));
884
+ for (const earlier of before) {
885
+ const at = parser.lines.findIndex((candidate, position) => position > index && position < upto && !folded.has(position) && streamOfLine(candidate) === earlier);
886
+ if (at < 0)
887
+ break outer;
888
+ const atCall = parser.lines[at]?.messageId;
889
+ if (!settle && atCall !== undefined && openCalls.has(atCall))
890
+ break outer;
891
+ foldLine(at, usageFor(at));
782
892
  }
783
- mirrored = index + 1;
784
- localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
893
+ foldLine(index, usageFor(index));
785
894
  }
895
+ while (folded.has(mirrored))
896
+ mirrored += 1;
786
897
  if (upto > 0)
787
898
  persist();
788
899
  };
@@ -794,6 +905,16 @@ export class ClaudeLiveDriver {
794
905
  /** The round's event sink (installed on the runtime while it runs). */
795
906
  const onEvent = (event) => {
796
907
  const type = event['type'];
908
+ // Once settled, the turn/end is written: the sink stays only so a late
909
+ // result converges the chain. Anything else (the CLI's interrupt notice
910
+ // after a cancel) would land after the turn closed and corrupt the log.
911
+ if (roundSettled) {
912
+ if (type === 'result') {
913
+ awaitingResult = false;
914
+ resolveResult(event);
915
+ }
916
+ return;
917
+ }
797
918
  if (type === 'system' && typeof event['session_id'] === 'string') {
798
919
  // EVERY turn emits an init; the first one mints the delegation id.
799
920
  // Its model/build fields are this round's settled observation (the
@@ -811,9 +932,11 @@ export class ClaudeLiveDriver {
811
932
  // The turn closes: fold the result event itself (it carries the
812
933
  // round's usage — the fold's usage carrier rule needs it in both
813
934
  // granularities), then flush the held-back last line WITH the usage.
935
+ // Settle semantics: a tool call whose result never arrived folds with
936
+ // the interrupted placeholder — the turn is over, so it never will.
814
937
  awaitingResult = false;
815
938
  parser.push(JSON.stringify(event) + '\n');
816
- mirrorUpTo(parser.lines.length, true);
939
+ mirrorUpTo(parser.lines.length, true, true);
817
940
  resolveResult(event);
818
941
  return;
819
942
  }
@@ -822,6 +945,18 @@ export class ClaudeLiveDriver {
822
945
  if (inner?.['type'] === 'message_start') {
823
946
  const message = inner['message'];
824
947
  messageKey = typeof message?.['id'] === 'string' ? message['id'] : `legacy-${turn}-${++legacyMessage}`;
948
+ currentCall = typeof message?.['id'] === 'string' ? message['id'] : undefined;
949
+ if (currentCall !== undefined) {
950
+ openCalls.add(currentCall);
951
+ const usage = message?.['usage'];
952
+ if (typeof usage === 'object' && usage !== null)
953
+ callUsage.set(currentCall, { ...usage });
954
+ }
955
+ return;
956
+ }
957
+ if (inner?.['type'] === 'message_delta' || inner?.['type'] === 'message_stop') {
958
+ endCall(inner['usage']);
959
+ mirrorUpTo(parser.lines.length - 1, false);
825
960
  return;
826
961
  }
827
962
  const delta = inner?.['delta'];
@@ -905,6 +1040,17 @@ export class ClaudeLiveDriver {
905
1040
  // The turn boundary opens before the message goes out (exec parity: the
906
1041
  // exec path opens at spawn).
907
1042
  childSession.append('turn/start', { turn });
1043
+ // The provider's own launch facts are the child log's request config —
1044
+ // claude's wire never carries effort, so the env the provider sets is
1045
+ // the authoritative source. The header contract requires a model: an
1046
+ // unconfigured round records nothing rather than inventing one.
1047
+ if (spec.configuration?.model !== undefined) {
1048
+ appendClaudeRequestHeader(childSession, {
1049
+ provider: 'claude-local',
1050
+ model: spec.configuration.model,
1051
+ reasoningEffort: (spec.configuration.effort ?? 'auto'),
1052
+ });
1053
+ }
908
1054
  childSession.append('user/message', createUserMessage({
909
1055
  content: [{ type: 'text', text: task }],
910
1056
  source: { kind: 'user' },
@@ -960,7 +1106,16 @@ export class ClaudeLiveDriver {
960
1106
  // held-back line (and the usage already observed) would be lost —
961
1107
  // flush it now (the exec settle-mirror's partial-work contract).
962
1108
  if (mirrored < parser.lines.length)
963
- mirrorUpTo(parser.lines.length, true);
1109
+ mirrorUpTo(parser.lines.length, true, true);
1110
+ // A line still waiting behind a stream whose completion never came:
1111
+ // finalize that stream from its last snapshot, then keep folding.
1112
+ while (mirrored < parser.lines.length && streams.size > 0) {
1113
+ const oldest = streams.values().next().value;
1114
+ appendStreamSnapshot(oldest, true, settled.stopReason !== 'completed', false, true);
1115
+ finishStream(oldest);
1116
+ closeOpenStep();
1117
+ mirrorUpTo(parser.lines.length, true, true);
1118
+ }
964
1119
  // Streams whose completion never arrived (abort, or the turn ended
965
1120
  // mid-message) still finalize INSIDE the turn window: one forced
966
1121
  // snapshot each — interrupted on a non-completed round, the LAST one
@@ -969,9 +1124,8 @@ export class ClaudeLiveDriver {
969
1124
  const remaining = [...streams.values()];
970
1125
  for (const [position, stream] of remaining.entries()) {
971
1126
  appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
972
- streamPublisher?.finish(stream.step);
973
- if (stream.opened)
974
- childSession.append('step/end', { turn, step: stream.step });
1127
+ finishStream(stream);
1128
+ closeOpenStep();
975
1129
  }
976
1130
  streams.clear();
977
1131
  activeStream = undefined;
@@ -1039,7 +1193,11 @@ export class ClaudeLiveDriver {
1039
1193
  // never hold it hostage — a cancel returns immediately.
1040
1194
  await Promise.race([accepted, abortBranch.catch(() => undefined)]);
1041
1195
  return subprocessRunHandle({
1042
- id: childSession.id,
1196
+ // Host 0.2.1: a fresh PER-EXECUTION run id — the activation manager
1197
+ // rejects a run id naming a live Session (this child session is live),
1198
+ // and resume rounds reuse the child session id. The family's durable
1199
+ // tracking key (the child session id) rides the staged intent instead.
1200
+ id: SessionId(randomUUID()),
1043
1201
  result,
1044
1202
  signal: request.signal,
1045
1203
  onAbort,
@@ -13,6 +13,7 @@
13
13
  import type { Context } from '@deepseek-ai/cordis';
14
14
  import type { SettingsFace } from '@khorsheed/dsh-local-agent';
15
15
  import type { Config } from './index.ts';
16
+ import type { ClaudeEndpointAuth } from './endpoint-auth.ts';
16
17
  import { ClaudeLiveDriver } from './live-driver.ts';
17
18
  import type { ClaudeLiveMirrorGranularity } from './live-driver.ts';
18
19
  /** The resolved live settings (rc.1: volatile row config; 0.1.5: schema defaults ← YAML base ← user layer). */
@@ -25,8 +26,10 @@ export interface ClaudeLiveSettings {
25
26
  recentModels?: readonly string[];
26
27
  }
27
28
  /** The Cordis-config inputs every driver generation shares. */
28
- type DriverBase = Pick<Config, 'permissionMode' | 'baseUrl'> & {
29
+ type DriverBase = Pick<Config, 'permissionMode'> & {
29
30
  liveIdleMs?: number;
31
+ /** Per-spawn endpoint credential resolver, handed to every driver generation. */
32
+ endpoint?: (homeDir: string) => Promise<ClaudeEndpointAuth>;
30
33
  /**
31
34
  * Per-spawn member model resolver, handed to every driver generation:
32
35
  * answers the member's effective model from its session-level override, the
@@ -70,8 +70,10 @@ export interface ClaudeModelBrokerDeps {
70
70
  home: string;
71
71
  directory: (refresh?: boolean) => LocalAgentModelDirectory | Promise<LocalAgentModelDirectory>;
72
72
  };
73
- /** The settings layer: the plugin-config model, read per call. */
74
- readonly settingsModel: () => string | undefined;
73
+ /** The settings layer: the curated list's first entry, else the plugin-config model, read per call. */
74
+ readonly settingsModel: (scope?: string) => string | undefined;
75
+ /** The scope's curated model list, read per call (empty = the fallback chain decides the vocabulary too). */
76
+ readonly modelList?: (scope?: string) => readonly string[];
75
77
  /** The scoped settings.json's own model, minus live-spawn scratch writes. */
76
78
  readonly cliDefault: (childSessionId?: string) => Promise<string | undefined>;
77
79
  readonly directory?: (childSessionId?: string, refresh?: boolean) => LocalAgentModelDirectory | Promise<LocalAgentModelDirectory>;