@khorsheed/dsh-local-agent-claude-code 0.2.0-rc.3 → 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.
@@ -186,6 +186,22 @@ function servedEfforts(effort) {
186
186
  .filter(level => effort[level]?.supported === true);
187
187
  return levels.length === 0 ? undefined : levels;
188
188
  }
189
+ /**
190
+ * The effort block's home in a served-model entry. Anthropic's current
191
+ * /v1/models shape nests it under `capabilities.effort` (verified against the
192
+ * openproxy relay 2026-10-22: `capabilities.effort.<level>.supported`); the
193
+ * flat top-level `effort` the parser was written against still wins when
194
+ * present, so either shape answers.
195
+ */
196
+ function servedEffortsOf(record) {
197
+ const direct = servedEfforts(record['effort']);
198
+ if (direct !== undefined)
199
+ return direct;
200
+ const capabilities = record['capabilities'];
201
+ if (typeof capabilities !== 'object' || capabilities === null)
202
+ return undefined;
203
+ return servedEfforts(capabilities['effort']);
204
+ }
189
205
  /**
190
206
  * `GET <baseUrl>/v1/models` with the resolved credential's auth header(s) —
191
207
  * the token-free endpoint probe shared by the `/claude-code check` verdict
@@ -231,7 +247,7 @@ export async function fetchEndpointModels(endpoint, fetchImpl, signal) {
231
247
  const record = entry;
232
248
  if (typeof record.id !== 'string' || record.id === '')
233
249
  continue;
234
- const efforts = servedEfforts(record.effort);
250
+ const efforts = servedEffortsOf(record);
235
251
  models.push({
236
252
  id: record.id,
237
253
  ...typeof record.display_name === 'string' && record.display_name !== '' ? { displayName: record.display_name } : {},
@@ -16,11 +16,6 @@ export declare const DEFAULT_LIVE_CHANNEL_RETRY_MS: number;
16
16
  export declare const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = 50;
17
17
  /** Default minimum text growth between one streaming kind's snapshot messages. */
18
18
  export declare const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
19
- /**
20
- * The stream-json channel could not come up (spawn failure or the first
21
- * turn's init never arrived). The provider catches exactly this and falls
22
- * back to the exec one-shot; any other error is a real round failure.
23
- */
24
19
  export declare class LiveChannelUnavailableError extends Error {
25
20
  constructor(message: string);
26
21
  }
@@ -28,8 +28,9 @@ import { claudeDirectory } from './model-catalog.js';
28
28
  * Fold: the resident stream speaks the exec path's exact event vocabulary
29
29
  * (`system`/`assistant`/`user`/`result`), so each turn folds through the
30
30
  * shared `ClaudeStreamParser` with the exec live mirror's hold-back rule (the
31
- * volatile last line waits for `result`, which carries the round's usage).
32
- * 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
33
34
  * `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
34
35
  * stream (native message id + block index, with ordered legacy fallback),
35
36
  * which reserves a (turn, step) at its first delta and
@@ -65,7 +66,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
65
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 { appendClaudeTranscriptLine, assistantEvent, appendClaudeRequestHeader, claudeLineText, ClaudeStreamParser, claudeVersionFromInit, DEFAULT_DISPOSE_GRACE_MS, registerClaudeMemberRun, MEMBER_TOOL_TIMEOUT_MS, textTask, toolCallsOf, usageFromClaude, } from './claude-cli-provider.js';
69
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;
@@ -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);
@@ -459,6 +455,9 @@ export class ClaudeLiveDriver {
459
455
  // dir plus the resolved endpoint credential's `ANTHROPIC_*` variables.
460
456
  env: delegationEnv({
461
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 },
462
461
  ...spec.configuration === undefined ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? 'auto' },
463
462
  ...endpointAuthEnv(endpoint),
464
463
  }),
@@ -575,27 +574,22 @@ export class ClaudeLiveDriver {
575
574
  */
576
575
  let roundModel;
577
576
  let roundCliVersion;
578
- /** 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). */
579
578
  let usageCarried = false;
580
579
  /**
581
- * The step ledger: `reservedSteps` holds every step a stream reservation
582
- * consumed (in increasing order — permanently, whether the stream later
583
- * completes or stays an orphan snapshot), and a sequential line folds at
584
- * its index shifted past every reservation before it. A tool completed
585
- * mid-stream therefore folds ABOVE the stream's step and the projection
586
- * renders the round's chronological order.
587
- */
588
- const reservedSteps = [];
589
- /**
590
- * The streaming kinds seen this round by stream key:
591
- * deltas accumulate into throttled snapshot assistant/messages appended
592
- * at the kind's reserved (turn, step) — the host folds repeated settles
593
- * at one coordinate into one live-updating chat node, which is the only
594
- * streaming channel left after 0.1.5 retired the durable per-chunk event.
595
- * The kind's completed line folds at the same step and finalizes it; an
596
- * 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).
597
587
  */
598
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();
599
593
  /** The stream deltas currently accumulate into (kinds stream sequentially). */
600
594
  let activeStream;
601
595
  let persistQueue = Promise.resolve();
@@ -631,6 +625,44 @@ export class ClaudeLiveDriver {
631
625
  // key, paired with their completion in arrival order.
632
626
  let messageKey;
633
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
+ };
634
666
  const streamKey = (kind, index) => `${messageKey ?? `legacy-${turn}-${legacyMessage}`}:${typeof index === 'number' ? index : kind}`;
635
667
  /**
636
668
  * Append one snapshot of a streaming kind at its reserved (turn, step).
@@ -642,6 +674,28 @@ export class ClaudeLiveDriver {
642
674
  const streamPublisher = localAgent?.liveStreams === undefined ? undefined
643
675
  : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
644
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;
645
699
  const pendingStreamArrival = new Map();
646
700
  const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
647
701
  if (stream.text.trim() === '')
@@ -656,44 +710,62 @@ export class ClaudeLiveDriver {
656
710
  liveFlush.cancel(stream.step);
657
711
  const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
658
712
  pendingStreamArrival.delete(stream.step);
659
- if (streamPublisher !== undefined && !final) {
660
- if (!stream.opened) {
661
- childSession.append('step/start', { turn, step: stream.step });
662
- 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;
663
720
  }
664
- 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 });
665
729
  stream.lastSnapshotAt = now;
666
730
  stream.lastSnapshotLen = stream.text.length;
667
731
  return;
668
732
  }
669
- if (!stream.opened) {
670
- childSession.append('step/start', { turn, step: stream.step });
671
- stream.opened = true;
672
- }
673
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);
674
737
  childSession.append('assistant/message', {
675
738
  turn,
676
- step: stream.step,
739
+ step: stream.durable,
677
740
  message: assistantEvent([
678
741
  stream.kind === 'think'
679
742
  ? { type: 'reasoning', text: stream.text }
680
743
  : { type: 'text', text: stream.text },
681
744
  ]),
682
745
  stream: [],
683
- ...withUsage && !usageCarried && usage !== undefined ? { usage } : {},
746
+ ...totalFallback ? { usage } : {},
684
747
  ...interrupted ? { interrupted: true } : {},
685
748
  }, { surfaceOp: 'append' });
686
- if (withUsage && !usageCarried && usage !== undefined)
749
+ if (totalFallback)
687
750
  usageCarried = true;
688
751
  stream.lastSnapshotAt = now;
689
752
  stream.lastSnapshotLen = stream.text.length;
690
753
  persist();
691
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
+ };
692
765
  /**
693
- * Reserve the step one streaming kind will occupy — past every completed
694
- * line (folded or held back) and every earlier reservation. A different
695
- * kind's deltas force one final snapshot of the current stream (its
696
- * 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.
697
769
  */
698
770
  const reserveStream = (itemId, kind) => {
699
771
  if (activeStream !== undefined && activeStream !== itemId) {
@@ -707,78 +779,121 @@ export class ClaudeLiveDriver {
707
779
  }
708
780
  let stream = streams.get(itemId);
709
781
  if (stream === undefined) {
710
- const step = parser.lines.length + reservedSteps.length + 1;
711
- reservedSteps.push(step);
712
- 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 };
713
784
  streams.set(itemId, stream);
714
785
  }
715
786
  activeStream = itemId;
716
787
  };
717
- /** The sequential fold step for `parser.lines[index]`: its position shifted past every reservation before it. */
718
- const foldStep = (index) => {
719
- let step = index + 1;
720
- for (const reserved of reservedSteps) {
721
- if (reserved <= step)
722
- step += 1;
723
- else
724
- 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);
725
830
  }
726
- return step;
831
+ folded.add(index);
832
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
727
833
  };
728
- /** Mirror folded lines [mirrored, upto); the last line is held back until `result`. */
729
- const mirrorUpTo = (upto, withUsage) => {
730
- // The usage rides the last NON-tool line (tool events carry no usage
731
- // slot, and a kill mid-command ends the transcript with a tool line).
732
- // A carrier mirrored in an earlier flush (before the usage was knowable)
733
- // loses the accounting to the settle's final snapshot — host 0.1.5 has
734
- // 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.
735
855
  let usageIndex = -1;
736
- if (withUsage) {
856
+ if (withUsage && !parser.lines.some(line => line.usage !== undefined)) {
737
857
  for (let index = 0; index < parser.lines.length; index += 1) {
738
858
  if (parser.lines[index]?.kind !== 'tool')
739
859
  usageIndex = index;
740
860
  }
741
861
  }
742
- for (let index = mirrored; index < upto; index += 1) {
743
- const line = parser.lines[index];
744
- if (line === undefined)
745
- continue;
862
+ const usageFor = (index) => {
746
863
  const lineUsage = withUsage && index === usageIndex ? parser.usage : undefined;
747
864
  if (lineUsage !== undefined)
748
865
  usageCarried = true;
749
- const stream = line.kind === 'tool' ? undefined
750
- : (line.streamId === undefined ? undefined : streams.get(line.streamId))
751
- ?? [...streams.values()].find(candidate => candidate.kind === line.kind && (line.streamId === undefined || candidate.itemId.startsWith('legacy-')));
752
- const itemId = stream?.itemId;
753
- if (itemId !== undefined && stream !== undefined) {
754
- // A streamed line folds at its reserved step, finalizing the
755
- // snapshots: the step opens only if no snapshot ever landed (a
756
- // fast stream that stayed under the throttle), and closes here.
757
- liveFlush.cancel(stream.step);
758
- streams.delete(itemId);
759
- if (activeStream === itemId)
760
- activeStream = undefined;
761
- if (!stream.opened)
762
- childSession.append('step/start', { turn, step: stream.step });
763
- const blocks = stream.kind === 'think'
764
- ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
765
- : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
766
- childSession.append('assistant/message', {
767
- turn,
768
- step: stream.step,
769
- message: assistantEvent(blocks),
770
- stream: [],
771
- ...lineUsage === undefined ? {} : { usage: lineUsage },
772
- }, { surfaceOp: 'append' });
773
- streamPublisher?.finish(stream.step);
774
- childSession.append('step/end', { turn, step: stream.step });
775
- }
776
- else {
777
- 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));
778
892
  }
779
- mirrored = index + 1;
780
- localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
893
+ foldLine(index, usageFor(index));
781
894
  }
895
+ while (folded.has(mirrored))
896
+ mirrored += 1;
782
897
  if (upto > 0)
783
898
  persist();
784
899
  };
@@ -790,6 +905,16 @@ export class ClaudeLiveDriver {
790
905
  /** The round's event sink (installed on the runtime while it runs). */
791
906
  const onEvent = (event) => {
792
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
+ }
793
918
  if (type === 'system' && typeof event['session_id'] === 'string') {
794
919
  // EVERY turn emits an init; the first one mints the delegation id.
795
920
  // Its model/build fields are this round's settled observation (the
@@ -807,9 +932,11 @@ export class ClaudeLiveDriver {
807
932
  // The turn closes: fold the result event itself (it carries the
808
933
  // round's usage — the fold's usage carrier rule needs it in both
809
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.
810
937
  awaitingResult = false;
811
938
  parser.push(JSON.stringify(event) + '\n');
812
- mirrorUpTo(parser.lines.length, true);
939
+ mirrorUpTo(parser.lines.length, true, true);
813
940
  resolveResult(event);
814
941
  return;
815
942
  }
@@ -818,6 +945,18 @@ export class ClaudeLiveDriver {
818
945
  if (inner?.['type'] === 'message_start') {
819
946
  const message = inner['message'];
820
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);
821
960
  return;
822
961
  }
823
962
  const delta = inner?.['delta'];
@@ -901,6 +1040,17 @@ export class ClaudeLiveDriver {
901
1040
  // The turn boundary opens before the message goes out (exec parity: the
902
1041
  // exec path opens at spawn).
903
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
+ }
904
1054
  childSession.append('user/message', createUserMessage({
905
1055
  content: [{ type: 'text', text: task }],
906
1056
  source: { kind: 'user' },
@@ -956,7 +1106,16 @@ export class ClaudeLiveDriver {
956
1106
  // held-back line (and the usage already observed) would be lost —
957
1107
  // flush it now (the exec settle-mirror's partial-work contract).
958
1108
  if (mirrored < parser.lines.length)
959
- 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
+ }
960
1119
  // Streams whose completion never arrived (abort, or the turn ended
961
1120
  // mid-message) still finalize INSIDE the turn window: one forced
962
1121
  // snapshot each — interrupted on a non-completed round, the LAST one
@@ -965,9 +1124,8 @@ export class ClaudeLiveDriver {
965
1124
  const remaining = [...streams.values()];
966
1125
  for (const [position, stream] of remaining.entries()) {
967
1126
  appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
968
- streamPublisher?.finish(stream.step);
969
- if (stream.opened)
970
- childSession.append('step/end', { turn, step: stream.step });
1127
+ finishStream(stream);
1128
+ closeOpenStep();
971
1129
  }
972
1130
  streams.clear();
973
1131
  activeStream = undefined;
@@ -97,6 +97,42 @@ export class ClaudeScopedModelMemory {
97
97
  }
98
98
  /** How long one transcript read-back answer is reused (repeated card opens). */
99
99
  export const OBSERVED_MODEL_CACHE_TTL_MS = 60_000;
100
+ /**
101
+ * Scope the served directory to the surface asking for it (the same helper
102
+ * lives in each provider's broker — fold into the family core when the
103
+ * directory faces next evolve):
104
+ *
105
+ * - memberless (the card's add-model dialog, the invite picker): the
106
+ * endpoint's served catalog only — the configuration/history extras the
107
+ * broker's info extension adds for STATUS display must never become add
108
+ * candidates;
109
+ * - a member whose harness has a curated list: the list IS the vocabulary,
110
+ * in the list's own order (the same rule `choices` already follows) — a
111
+ * curated value the endpoint no longer serves stays as a bare row (the
112
+ * card shows it the same way), and the member's current effective model is
113
+ * kept visible as a bare entry when it sits outside the list, so a pre-list
114
+ * binding never renders blank;
115
+ * - a member without a curated list: the legacy extended vocabulary.
116
+ */
117
+ function scopeServedDirectory(directory, opts) {
118
+ if (opts.member && opts.curated.length === 0)
119
+ return directory;
120
+ const served = directory.entries.filter(entry => entry.source === 'native');
121
+ if (!opts.member)
122
+ return { ...directory, entries: served };
123
+ const byValue = new Map(served.flatMap(entry => entry.resolvedModel === undefined
124
+ ? [[entry.value, entry]]
125
+ : [[entry.value, entry], [entry.resolvedModel, entry]]));
126
+ // Curated ORDER leads (the card's first row is the default); a curated value
127
+ // the endpoint no longer serves stays listed as a bare row, exactly like the
128
+ // card shows it.
129
+ const entries = opts.curated.map(value => byValue.get(value) ?? { value, label: value, source: 'configuration' });
130
+ const current = opts.current?.trim();
131
+ if (current !== undefined && current !== '' && !entries.some(entry => entry.value === current || entry.resolvedModel === current)) {
132
+ entries.push({ value: current, label: current, source: 'configuration' });
133
+ }
134
+ return { ...directory, entries };
135
+ }
100
136
  export class ClaudeModelBroker {
101
137
  deps;
102
138
  constructor(deps) {
@@ -136,8 +172,14 @@ export class ClaudeModelBroker {
136
172
  async modelDirectory(childSessionId, refresh = false) {
137
173
  if (refresh)
138
174
  await this.deps.directory?.(childSessionId, true);
139
- const info = await this.modelInfo(childSessionId, childSessionId === undefined ? undefined : this.deps.localAgent.getDelegation(childSessionId)?.model);
140
- return info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
175
+ const record = childSessionId === undefined ? undefined : this.deps.localAgent.getDelegation(childSessionId);
176
+ const info = await this.modelInfo(childSessionId, record?.model);
177
+ const directory = info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
178
+ return scopeServedDirectory(directory, {
179
+ member: childSessionId !== undefined,
180
+ curated: this.deps.modelList?.(record?.scope) ?? [],
181
+ current: info.effective,
182
+ });
141
183
  }
142
184
  async *followModelDirectory(childSessionId, signal) {
143
185
  if (this.deps.followDirectory === undefined) {
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "local-agent",
9
9
  "claude-code"
10
10
  ],
11
- "version": "0.2.0-rc.3",
11
+ "version": "0.2.0",
12
12
  "type": "module",
13
13
  "main": "lib/index.js",
14
14
  "types": "lib/types/index.d.ts",
@@ -69,7 +69,7 @@
69
69
  "dsh": ">=0.1.5-rc.1 <0.3.0-0"
70
70
  },
71
71
  "dependencies": {
72
- "@khorsheed/dsh-local-agent": "^0.2.0-rc.3"
72
+ "@khorsheed/dsh-local-agent": "^0.2.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@deepseek-ai/cordis": "^4.0.1",