@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.
- package/CHANGELOG.md +17 -1
- package/README.en.md +24 -112
- package/README.i18n.yaml +2 -2
- package/README.md +24 -108
- package/lib/client.js +63 -63
- package/lib/index.js +528 -184
- package/lib/types/claude-cli-provider.d.ts +73 -2
- package/lib/types/claude-cli-provider.js +330 -97
- package/lib/types/client/icons.d.ts +1 -1
- package/lib/types/endpoint-auth.js +17 -1
- package/lib/types/live-driver.d.ts +0 -5
- package/lib/types/live-driver.js +259 -101
- package/lib/types/model-broker.js +44 -2
- package/package.json +2 -2
|
@@ -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 =
|
|
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
|
}
|
package/lib/types/live-driver.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
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 (
|
|
660
|
-
if (
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
...
|
|
746
|
+
...totalFallback ? { usage } : {},
|
|
684
747
|
...interrupted ? { interrupted: true } : {},
|
|
685
748
|
}, { surfaceOp: 'append' });
|
|
686
|
-
if (
|
|
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
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
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
|
-
|
|
711
|
-
|
|
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
|
|
718
|
-
const
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
831
|
+
folded.add(index);
|
|
832
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
|
|
727
833
|
};
|
|
728
|
-
/**
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
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
|
-
|
|
969
|
-
|
|
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
|
|
140
|
-
|
|
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
|
|
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
|
|
72
|
+
"@khorsheed/dsh-local-agent": "^0.2.0"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"@deepseek-ai/cordis": "^4.0.1",
|