@khorsheed/dsh-local-agent-claude-code 0.1.0-rc.6
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/README.en.md +120 -0
- package/README.i18n.yaml +6 -0
- package/README.md +120 -0
- package/cordis.patch.yml +42 -0
- package/lib/client.js +1065 -0
- package/lib/index.js +3771 -0
- package/lib/invariant.js +31 -0
- package/lib/types/claude-cli-provider.d.ts +389 -0
- package/lib/types/claude-cli-provider.js +1509 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +198 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/index.js +87 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/control-requests.d.ts +15 -0
- package/lib/types/control-requests.js +62 -0
- package/lib/types/index.d.ts +81 -0
- package/lib/types/index.js +300 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/live-driver.d.ts +172 -0
- package/lib/types/live-driver.js +1057 -0
- package/lib/types/live-switch.d.ts +67 -0
- package/lib/types/live-switch.js +85 -0
- package/lib/types/model-broker.d.ts +131 -0
- package/lib/types/model-broker.js +248 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +118 -0
- package/lib/types/provision.d.ts +63 -0
- package/lib/types/provision.js +132 -0
- package/lib/types/records.d.ts +120 -0
- package/lib/types/records.js +687 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +106 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings-driven live-driver switch: owns the active driver generation
|
|
3
|
+
* and retires old ones on a settings change, without a plugin reload.
|
|
4
|
+
*
|
|
5
|
+
* Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
|
|
6
|
+
* new rounds are refused (the provider's catch falls back to exec), in-flight
|
|
7
|
+
* rounds finish on their runtime, idle runtimes are reclaimed at once.
|
|
8
|
+
* Toggling ON builds the next generation lazily (no process until the first
|
|
9
|
+
* round). Legacy granularity settings are ignored.
|
|
10
|
+
*
|
|
11
|
+
* @module @khorsheed/dsh-local-agent-claude-code — internal, unit-tested directly.
|
|
12
|
+
*/
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
14
|
+
import type { SettingsFace } from '@khorsheed/dsh-local-agent';
|
|
15
|
+
import type { Config } from './index.ts';
|
|
16
|
+
import { ClaudeLiveDriver } from './live-driver.ts';
|
|
17
|
+
import type { ClaudeLiveMirrorGranularity } from './live-driver.ts';
|
|
18
|
+
/** The resolved live settings (rc.1: volatile row config; 0.1.5: schema defaults ← YAML base ← user layer). */
|
|
19
|
+
export interface ClaudeLiveSettings {
|
|
20
|
+
live: boolean;
|
|
21
|
+
liveMirrorGranularity: ClaudeLiveMirrorGranularity;
|
|
22
|
+
/** The configured model, when the card or the YAML base names one. */
|
|
23
|
+
model?: string;
|
|
24
|
+
/** Model identifiers the card has saved before (its input's suggestions). */
|
|
25
|
+
recentModels?: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
/** The Cordis-config inputs every driver generation shares. */
|
|
28
|
+
type DriverBase = Pick<Config, 'permissionMode' | 'baseUrl'> & {
|
|
29
|
+
liveIdleMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Per-spawn member model resolver, handed to every driver generation:
|
|
32
|
+
* answers the member's effective model from its session-level override, the
|
|
33
|
+
* round's delegation model, and the settings key, in that order.
|
|
34
|
+
*/
|
|
35
|
+
model?: (childSessionId: string, delegationModel?: string) => string | undefined;
|
|
36
|
+
/** Pre-spawn scoped-settings.json model scratch (the shared memory). */
|
|
37
|
+
provisionModel?: (homeDir: string, model: string | undefined) => Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
export declare class LiveDriverSwitch {
|
|
40
|
+
private readonly ctx;
|
|
41
|
+
private readonly driverBase;
|
|
42
|
+
private liveOn;
|
|
43
|
+
private active;
|
|
44
|
+
/** Retiring generations still draining their in-flight rounds. */
|
|
45
|
+
private readonly draining;
|
|
46
|
+
private readonly unwatch;
|
|
47
|
+
constructor(ctx: Context, face: SettingsFace<ClaudeLiveSettings>, driverBase: DriverBase);
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the live driver for one round's member (the provider's call
|
|
50
|
+
* site). A retiring generation still hosting the member gates the new one —
|
|
51
|
+
* the round falls back to exec rather than double-loading the same claude
|
|
52
|
+
* session into two processes.
|
|
53
|
+
*/
|
|
54
|
+
readonly resolve: (childSessionId: string) => ClaudeLiveDriver | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The generation currently hosting the member's runtime — the active one,
|
|
57
|
+
* or a retiring generation still draining it (the model broker retires the
|
|
58
|
+
* member's runtime through whichever generation owns it on a model switch).
|
|
59
|
+
*/
|
|
60
|
+
hostingDriver(childSessionId: string): ClaudeLiveDriver | undefined;
|
|
61
|
+
/** Mirror the resolved settings into the driver generation. */
|
|
62
|
+
private apply;
|
|
63
|
+
/** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
|
|
64
|
+
dispose(): void;
|
|
65
|
+
}
|
|
66
|
+
export {};
|
|
67
|
+
//# sourceMappingURL=live-switch.d.ts.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings-driven live-driver switch: owns the active driver generation
|
|
3
|
+
* and retires old ones on a settings change, without a plugin reload.
|
|
4
|
+
*
|
|
5
|
+
* Toggle OFF (or unload): the retiring generation is DRAINED, not killed —
|
|
6
|
+
* new rounds are refused (the provider's catch falls back to exec), in-flight
|
|
7
|
+
* rounds finish on their runtime, idle runtimes are reclaimed at once.
|
|
8
|
+
* Toggling ON builds the next generation lazily (no process until the first
|
|
9
|
+
* round). Legacy granularity settings are ignored.
|
|
10
|
+
*
|
|
11
|
+
* @module @khorsheed/dsh-local-agent-claude-code — internal, unit-tested directly.
|
|
12
|
+
*/
|
|
13
|
+
import { ClaudeLiveDriver } from './live-driver.js';
|
|
14
|
+
export class LiveDriverSwitch {
|
|
15
|
+
ctx;
|
|
16
|
+
driverBase;
|
|
17
|
+
liveOn = false;
|
|
18
|
+
active;
|
|
19
|
+
/** Retiring generations still draining their in-flight rounds. */
|
|
20
|
+
draining = new Set();
|
|
21
|
+
unwatch;
|
|
22
|
+
constructor(ctx, face, driverBase) {
|
|
23
|
+
this.ctx = ctx;
|
|
24
|
+
this.driverBase = driverBase;
|
|
25
|
+
this.apply(face.get());
|
|
26
|
+
this.unwatch = face.watch((next) => { this.apply(next); });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the live driver for one round's member (the provider's call
|
|
30
|
+
* site). A retiring generation still hosting the member gates the new one —
|
|
31
|
+
* the round falls back to exec rather than double-loading the same claude
|
|
32
|
+
* session into two processes.
|
|
33
|
+
*/
|
|
34
|
+
resolve = (childSessionId) => {
|
|
35
|
+
if (this.active === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
for (const retiring of this.draining) {
|
|
38
|
+
if (retiring.hasRuntime(childSessionId))
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return this.active;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The generation currently hosting the member's runtime — the active one,
|
|
45
|
+
* or a retiring generation still draining it (the model broker retires the
|
|
46
|
+
* member's runtime through whichever generation owns it on a model switch).
|
|
47
|
+
*/
|
|
48
|
+
hostingDriver(childSessionId) {
|
|
49
|
+
if (this.active?.hasRuntime(childSessionId) === true)
|
|
50
|
+
return this.active;
|
|
51
|
+
for (const retiring of this.draining) {
|
|
52
|
+
if (retiring.hasRuntime(childSessionId))
|
|
53
|
+
return retiring;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Mirror the resolved settings into the driver generation. */
|
|
58
|
+
apply(next) {
|
|
59
|
+
if (next.live === this.liveOn) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.liveOn = next.live;
|
|
63
|
+
const retiring = this.active;
|
|
64
|
+
this.active = next.live
|
|
65
|
+
? new ClaudeLiveDriver(this.ctx, {
|
|
66
|
+
...this.driverBase,
|
|
67
|
+
})
|
|
68
|
+
: undefined;
|
|
69
|
+
if (retiring !== undefined) {
|
|
70
|
+
this.draining.add(retiring);
|
|
71
|
+
void retiring.drain().then(() => { this.draining.delete(retiring); }, (error) => {
|
|
72
|
+
this.draining.delete(retiring);
|
|
73
|
+
this.ctx.logger.warn(`local-agent-claude-code: live driver drain failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Plugin unload: interrupt whatever survives (disposeAll, not drain). */
|
|
78
|
+
dispose() {
|
|
79
|
+
this.unwatch();
|
|
80
|
+
void this.active?.disposeAll();
|
|
81
|
+
for (const retiring of this.draining)
|
|
82
|
+
void retiring.disposeAll();
|
|
83
|
+
this.draining.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claude-code harness's model broker: the family gateway routes
|
|
3
|
+
* `harnessModel` / `memberModel` / `setMemberModel` here, and the broker
|
|
4
|
+
* answers the family's fixed resolution order — the session-level override
|
|
5
|
+
* (the composer's picker), the delegation's recorded model, the plugin-config
|
|
6
|
+
* `model` key (the settings card), the scoped settings.json's own `model`,
|
|
7
|
+
* and last the CLI's built-in default, which names nothing.
|
|
8
|
+
*
|
|
9
|
+
* The override map is IN-MEMORY by design (it deliberately does not survive a
|
|
10
|
+
* host restart) and SHARED: index.ts hands the same map to the provider's and
|
|
11
|
+
* the live driver's model resolvers, so a composer switch reaches the next
|
|
12
|
+
* round through every drive. With the live driver on, switching to a
|
|
13
|
+
* DIFFERENT model retires the member's resident runtime so the next round
|
|
14
|
+
* respawns onto the new model — the CLI session itself resumes on disk, so
|
|
15
|
+
* the conversation carries over. A switch while a round is in flight is
|
|
16
|
+
* refused: retiring a runtime mid-round would kill the run.
|
|
17
|
+
*
|
|
18
|
+
* `lastObserved` (the cli-builtin layer's display hint): the delegation
|
|
19
|
+
* record's `observedModel` answers when the live-settle channel reported
|
|
20
|
+
* one; the member's own CLI transcript (`transcriptModel`, a bounded read of
|
|
21
|
+
* the projects tree) is the history backstop for rounds that predate that
|
|
22
|
+
* channel — read-only, TTL-cached, never a write into `delegations.jsonl`.
|
|
23
|
+
*
|
|
24
|
+
* @module @khorsheed/dsh-local-agent-claude-code/model-broker — internal, unit-tested directly.
|
|
25
|
+
*/
|
|
26
|
+
import type { LocalAgentRegistry, MemberConfigurationAdapter } from '@khorsheed/dsh-local-agent';
|
|
27
|
+
import type { LocalAgentModelBroker, LocalAgentModelInfo, LocalAgentModelDirectory, LocalAgentMemberBinding } from '@khorsheed/dsh-local-agent/types';
|
|
28
|
+
import type { LiveDriverSwitch } from './live-switch.ts';
|
|
29
|
+
/**
|
|
30
|
+
* The scoped settings.json `model` key across live-spawn scratch writes. The
|
|
31
|
+
* live driver writes a member's effective model into the file before a
|
|
32
|
+
* resident spawn (a `--resume` respawn restores the session's stored model
|
|
33
|
+
* over the `--model` flag), and this memory keeps the person's own configured
|
|
34
|
+
* value straight across those writes: what the file held before our first
|
|
35
|
+
* write is the `cli-config` layer, and a spawn with NO effective model
|
|
36
|
+
* restores that value instead of leaking the previous member's model into
|
|
37
|
+
* the next runtime. In-memory on purpose: a restart re-reads the file as it
|
|
38
|
+
* stands. Writes serialize on an internal queue so concurrent member spawns
|
|
39
|
+
* cannot lose each other's read-modify-write.
|
|
40
|
+
*/
|
|
41
|
+
export declare class ClaudeScopedModelMemory {
|
|
42
|
+
/** The value we last scratched in and believe the file still holds. */
|
|
43
|
+
private written;
|
|
44
|
+
/** What the file held before our first write; null = it named no model. */
|
|
45
|
+
private previous;
|
|
46
|
+
private queue;
|
|
47
|
+
/**
|
|
48
|
+
* Bring the scoped settings.json's `model` key to the effective model a
|
|
49
|
+
* spawn is about to bind — writing it, or restoring the person's value when
|
|
50
|
+
* the spawn binds none and the file still holds our earlier scratch.
|
|
51
|
+
* @param homeDir - the scoped home the spawn runs against.
|
|
52
|
+
* @param model - the spawn's effective model, or undefined for none.
|
|
53
|
+
*/
|
|
54
|
+
provision(homeDir: string, model: string | undefined): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* The person's configured model — the `cli-config` layer. A file value we
|
|
57
|
+
* did not write reports as read; our own scratch value reports what the
|
|
58
|
+
* file held before we wrote it.
|
|
59
|
+
* @param homeDir - the scoped home to read.
|
|
60
|
+
* @returns the person's configured model, or undefined when none.
|
|
61
|
+
*/
|
|
62
|
+
cliDefault(homeDir: string): Promise<string | undefined>;
|
|
63
|
+
private provisionLocked;
|
|
64
|
+
}
|
|
65
|
+
/** Everything the broker reads that it does not own. */
|
|
66
|
+
export interface ClaudeModelBrokerDeps {
|
|
67
|
+
/** The registry face for the in-flight check and the delegation record. */
|
|
68
|
+
readonly localAgent: Pick<LocalAgentRegistry, 'isDelegationActive' | 'getDelegation'> & Partial<Pick<LocalAgentRegistry, 'setMemberModel'>>;
|
|
69
|
+
readonly configurationContext?: (binding: LocalAgentMemberBinding) => {
|
|
70
|
+
home: string;
|
|
71
|
+
directory: (refresh?: boolean) => LocalAgentModelDirectory | Promise<LocalAgentModelDirectory>;
|
|
72
|
+
};
|
|
73
|
+
/** The settings layer: the plugin-config model, read per call. */
|
|
74
|
+
readonly settingsModel: () => string | undefined;
|
|
75
|
+
/** The scoped settings.json's own model, minus live-spawn scratch writes. */
|
|
76
|
+
readonly cliDefault: (childSessionId?: string) => Promise<string | undefined>;
|
|
77
|
+
readonly directory?: (childSessionId?: string, refresh?: boolean) => LocalAgentModelDirectory | Promise<LocalAgentModelDirectory>;
|
|
78
|
+
readonly followDirectory?: (childSessionId: string | undefined, signal: AbortSignal) => AsyncIterable<LocalAgentModelDirectory>;
|
|
79
|
+
/** Model identifiers the settings card saved before (the card's memory). */
|
|
80
|
+
readonly recentModels: () => readonly string[];
|
|
81
|
+
/** Whether the live driver is on for the member's rounds. */
|
|
82
|
+
readonly live: () => boolean;
|
|
83
|
+
/** The session-level override map, shared with the round model resolvers. */
|
|
84
|
+
readonly overrides: Map<string, string>;
|
|
85
|
+
/** The live-driver switch, for the hosting generation's retire on a switch. */
|
|
86
|
+
readonly liveSwitch: Pick<LiveDriverSwitch, 'hostingDriver'>;
|
|
87
|
+
/**
|
|
88
|
+
* The transcript backstop for `lastObserved`: read the model the member's
|
|
89
|
+
* own CLI transcript names (its recorded cliSessionId), or — absent, the
|
|
90
|
+
* settings card's memberless read — the newest transcript in the scoped
|
|
91
|
+
* home's projects tree. Read-only history; never throws.
|
|
92
|
+
*/
|
|
93
|
+
readonly transcriptModel: (cliSessionId?: string, childSessionId?: string) => Promise<string | undefined>;
|
|
94
|
+
}
|
|
95
|
+
/** How long one transcript read-back answer is reused (repeated card opens). */
|
|
96
|
+
export declare const OBSERVED_MODEL_CACHE_TTL_MS = 60000;
|
|
97
|
+
export declare class ClaudeModelBroker implements LocalAgentModelBroker {
|
|
98
|
+
private readonly deps;
|
|
99
|
+
constructor(deps: ClaudeModelBrokerDeps);
|
|
100
|
+
configurationAdapter(binding: LocalAgentMemberBinding): MemberConfigurationAdapter;
|
|
101
|
+
modelDirectory(childSessionId?: string, refresh?: boolean): Promise<LocalAgentModelDirectory>;
|
|
102
|
+
followModelDirectory(childSessionId: string | undefined, signal: AbortSignal): AsyncIterable<LocalAgentModelDirectory>;
|
|
103
|
+
/**
|
|
104
|
+
* The `lastObserved` cache: childSessionId (empty key = the memberless
|
|
105
|
+
* harness read) → the answer and when it was read. A miss is cached too —
|
|
106
|
+
* a member with no transcript yet should not pay a rescan per card open.
|
|
107
|
+
*/
|
|
108
|
+
private readonly observedCache;
|
|
109
|
+
/**
|
|
110
|
+
* The last model the member (or the harness) was observed running. The
|
|
111
|
+
* live-settle channel is primary — a record whose latest round reported
|
|
112
|
+
* `observedModel` answers without touching disk; the CLI's own transcripts
|
|
113
|
+
* are the history backstop for every round that predates it.
|
|
114
|
+
*/
|
|
115
|
+
private lastObserved;
|
|
116
|
+
/**
|
|
117
|
+
* Read the model surface in the family's fixed order. Without a member the
|
|
118
|
+
* override and delegation layers stay absent — the settings card's "what
|
|
119
|
+
* would a round run with" read.
|
|
120
|
+
*/
|
|
121
|
+
modelInfo(childSessionId?: string, delegationModel?: string): Promise<LocalAgentModelInfo>;
|
|
122
|
+
/**
|
|
123
|
+
* Set or clear a member's session-level override. Throws while a round is
|
|
124
|
+
* in flight for the member; a same-model set is a no-op; a real change
|
|
125
|
+
* retires the member's resident runtime when its bound model is no longer
|
|
126
|
+
* what the next round would spawn with (the CLI session resumes on the
|
|
127
|
+
* respawn, so the conversation carries over).
|
|
128
|
+
*/
|
|
129
|
+
setMemberModel(childSessionId: string, model: string | undefined): Promise<void>;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=model-broker.d.ts.map
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claude-code harness's model broker: the family gateway routes
|
|
3
|
+
* `harnessModel` / `memberModel` / `setMemberModel` here, and the broker
|
|
4
|
+
* answers the family's fixed resolution order — the session-level override
|
|
5
|
+
* (the composer's picker), the delegation's recorded model, the plugin-config
|
|
6
|
+
* `model` key (the settings card), the scoped settings.json's own `model`,
|
|
7
|
+
* and last the CLI's built-in default, which names nothing.
|
|
8
|
+
*
|
|
9
|
+
* The override map is IN-MEMORY by design (it deliberately does not survive a
|
|
10
|
+
* host restart) and SHARED: index.ts hands the same map to the provider's and
|
|
11
|
+
* the live driver's model resolvers, so a composer switch reaches the next
|
|
12
|
+
* round through every drive. With the live driver on, switching to a
|
|
13
|
+
* DIFFERENT model retires the member's resident runtime so the next round
|
|
14
|
+
* respawns onto the new model — the CLI session itself resumes on disk, so
|
|
15
|
+
* the conversation carries over. A switch while a round is in flight is
|
|
16
|
+
* refused: retiring a runtime mid-round would kill the run.
|
|
17
|
+
*
|
|
18
|
+
* `lastObserved` (the cli-builtin layer's display hint): the delegation
|
|
19
|
+
* record's `observedModel` answers when the live-settle channel reported
|
|
20
|
+
* one; the member's own CLI transcript (`transcriptModel`, a bounded read of
|
|
21
|
+
* the projects tree) is the history backstop for rounds that predate that
|
|
22
|
+
* channel — read-only, TTL-cached, never a write into `delegations.jsonl`.
|
|
23
|
+
*
|
|
24
|
+
* @module @khorsheed/dsh-local-agent-claude-code/model-broker — internal, unit-tested directly.
|
|
25
|
+
*/
|
|
26
|
+
import { extendModelDirectory } from '@khorsheed/dsh-local-agent';
|
|
27
|
+
import { readClaudeConfiguredModel, readClaudeConfiguredEffort, writeClaudeScopedModel } from './provision.js';
|
|
28
|
+
/**
|
|
29
|
+
* The scoped settings.json `model` key across live-spawn scratch writes. The
|
|
30
|
+
* live driver writes a member's effective model into the file before a
|
|
31
|
+
* resident spawn (a `--resume` respawn restores the session's stored model
|
|
32
|
+
* over the `--model` flag), and this memory keeps the person's own configured
|
|
33
|
+
* value straight across those writes: what the file held before our first
|
|
34
|
+
* write is the `cli-config` layer, and a spawn with NO effective model
|
|
35
|
+
* restores that value instead of leaking the previous member's model into
|
|
36
|
+
* the next runtime. In-memory on purpose: a restart re-reads the file as it
|
|
37
|
+
* stands. Writes serialize on an internal queue so concurrent member spawns
|
|
38
|
+
* cannot lose each other's read-modify-write.
|
|
39
|
+
*/
|
|
40
|
+
export class ClaudeScopedModelMemory {
|
|
41
|
+
/** The value we last scratched in and believe the file still holds. */
|
|
42
|
+
written;
|
|
43
|
+
/** What the file held before our first write; null = it named no model. */
|
|
44
|
+
previous = null;
|
|
45
|
+
queue = Promise.resolve();
|
|
46
|
+
/**
|
|
47
|
+
* Bring the scoped settings.json's `model` key to the effective model a
|
|
48
|
+
* spawn is about to bind — writing it, or restoring the person's value when
|
|
49
|
+
* the spawn binds none and the file still holds our earlier scratch.
|
|
50
|
+
* @param homeDir - the scoped home the spawn runs against.
|
|
51
|
+
* @param model - the spawn's effective model, or undefined for none.
|
|
52
|
+
*/
|
|
53
|
+
provision(homeDir, model) {
|
|
54
|
+
const run = this.queue.then(() => this.provisionLocked(homeDir, model));
|
|
55
|
+
this.queue = run.catch(() => undefined);
|
|
56
|
+
return run;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The person's configured model — the `cli-config` layer. A file value we
|
|
60
|
+
* did not write reports as read; our own scratch value reports what the
|
|
61
|
+
* file held before we wrote it.
|
|
62
|
+
* @param homeDir - the scoped home to read.
|
|
63
|
+
* @returns the person's configured model, or undefined when none.
|
|
64
|
+
*/
|
|
65
|
+
async cliDefault(homeDir) {
|
|
66
|
+
const file = await readClaudeConfiguredModel(homeDir);
|
|
67
|
+
if (file !== this.written) {
|
|
68
|
+
// Not our scratch (or absent): the person owns this value — adopt it.
|
|
69
|
+
this.previous = file ?? null;
|
|
70
|
+
this.written = undefined;
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
return this.previous ?? undefined;
|
|
74
|
+
}
|
|
75
|
+
async provisionLocked(homeDir, model) {
|
|
76
|
+
const file = await readClaudeConfiguredModel(homeDir);
|
|
77
|
+
if (file !== this.written) {
|
|
78
|
+
// The file changed outside our last write (a person edit, or the first
|
|
79
|
+
// observation): it is the person's value, and our scratch is gone.
|
|
80
|
+
this.previous = file ?? null;
|
|
81
|
+
this.written = undefined;
|
|
82
|
+
}
|
|
83
|
+
if (model === undefined) {
|
|
84
|
+
if (this.written === undefined)
|
|
85
|
+
return;
|
|
86
|
+
// The spawn binds no model: restore the person's value rather than
|
|
87
|
+
// leaking the previous member's scratch into this runtime.
|
|
88
|
+
await writeClaudeScopedModel(homeDir, this.previous ?? undefined);
|
|
89
|
+
this.written = undefined;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (file === model)
|
|
93
|
+
return;
|
|
94
|
+
await writeClaudeScopedModel(homeDir, model);
|
|
95
|
+
this.written = model;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** How long one transcript read-back answer is reused (repeated card opens). */
|
|
99
|
+
export const OBSERVED_MODEL_CACHE_TTL_MS = 60_000;
|
|
100
|
+
export class ClaudeModelBroker {
|
|
101
|
+
deps;
|
|
102
|
+
constructor(deps) {
|
|
103
|
+
this.deps = deps;
|
|
104
|
+
}
|
|
105
|
+
configurationAdapter(binding) {
|
|
106
|
+
const context = this.deps.configurationContext?.(binding);
|
|
107
|
+
if (context === undefined)
|
|
108
|
+
throw new Error('Claude member configuration context is unavailable');
|
|
109
|
+
const resolve = async (selection) => {
|
|
110
|
+
let directory = await context.directory();
|
|
111
|
+
if (directory.status === 'loading')
|
|
112
|
+
directory = await context.directory(true);
|
|
113
|
+
const model = selection.model.mode === 'value' ? selection.model.value
|
|
114
|
+
: (selection.model.mode === 'inherit' ? binding.model : undefined) ?? this.deps.settingsModel()
|
|
115
|
+
?? await readClaudeConfiguredModel(context.home) ?? directory.defaultModel;
|
|
116
|
+
const entry = directory.entries.find(entry => entry.value === model || entry.resolvedModel === model);
|
|
117
|
+
const effort = (selection.effort.mode === 'value' ? selection.effort.value : selection.effort.mode === 'inherit' ? binding.effort : undefined)
|
|
118
|
+
?? await readClaudeConfiguredEffort(context.home) ?? entry?.reasoning?.default;
|
|
119
|
+
if (effort !== undefined && !entry?.reasoning?.options.some(option => option.value === effort)) {
|
|
120
|
+
throw new Error('Claude has not advertised this reasoning effort for the selected model');
|
|
121
|
+
}
|
|
122
|
+
return { ...model === undefined ? {} : { model }, ...effort === undefined ? {} : { effort } };
|
|
123
|
+
};
|
|
124
|
+
const retire = async () => { await this.deps.liveSwitch.hostingDriver(binding.childSessionId)?.retireRuntime(binding.childSessionId); };
|
|
125
|
+
return {
|
|
126
|
+
validate: async (selection) => { await resolve(selection); }, prepare: resolve,
|
|
127
|
+
apply: async (selection) => { const resolved = await resolve(selection); await retire(); return resolved; },
|
|
128
|
+
reconcile: async (state) => {
|
|
129
|
+
if (this.deps.localAgent.isDelegationActive(binding.childSessionId))
|
|
130
|
+
return { active: true, matches: 'unknown', resolved: {} };
|
|
131
|
+
await retire();
|
|
132
|
+
return { active: false, matches: 'current', resolved: await resolve(state.current.selection) };
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async modelDirectory(childSessionId, refresh = false) {
|
|
137
|
+
if (refresh)
|
|
138
|
+
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 };
|
|
141
|
+
}
|
|
142
|
+
async *followModelDirectory(childSessionId, signal) {
|
|
143
|
+
if (this.deps.followDirectory === undefined) {
|
|
144
|
+
yield await this.modelDirectory(childSessionId);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for await (const _snapshot of this.deps.followDirectory(childSessionId, signal))
|
|
148
|
+
yield await this.modelDirectory(childSessionId);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The `lastObserved` cache: childSessionId (empty key = the memberless
|
|
152
|
+
* harness read) → the answer and when it was read. A miss is cached too —
|
|
153
|
+
* a member with no transcript yet should not pay a rescan per card open.
|
|
154
|
+
*/
|
|
155
|
+
observedCache = new Map();
|
|
156
|
+
/**
|
|
157
|
+
* The last model the member (or the harness) was observed running. The
|
|
158
|
+
* live-settle channel is primary — a record whose latest round reported
|
|
159
|
+
* `observedModel` answers without touching disk; the CLI's own transcripts
|
|
160
|
+
* are the history backstop for every round that predates it.
|
|
161
|
+
*/
|
|
162
|
+
async lastObserved(childSessionId) {
|
|
163
|
+
const record = childSessionId === undefined ? undefined : this.deps.localAgent.getDelegation(childSessionId);
|
|
164
|
+
if (childSessionId !== undefined && record === undefined)
|
|
165
|
+
return undefined;
|
|
166
|
+
if (record?.observedModel !== undefined && record.observedModel.trim() !== '')
|
|
167
|
+
return record.observedModel;
|
|
168
|
+
const key = childSessionId ?? '';
|
|
169
|
+
const hit = this.observedCache.get(key);
|
|
170
|
+
if (hit !== undefined && Date.now() - hit.at < OBSERVED_MODEL_CACHE_TTL_MS)
|
|
171
|
+
return hit.model;
|
|
172
|
+
const model = await this.deps.transcriptModel(record?.cliSessionId, childSessionId).catch(() => undefined);
|
|
173
|
+
this.observedCache.set(key, { at: Date.now(), model });
|
|
174
|
+
return model;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Read the model surface in the family's fixed order. Without a member the
|
|
178
|
+
* override and delegation layers stay absent — the settings card's "what
|
|
179
|
+
* would a round run with" read.
|
|
180
|
+
*/
|
|
181
|
+
async modelInfo(childSessionId, delegationModel) {
|
|
182
|
+
const override = childSessionId === undefined ? undefined : this.deps.overrides.get(childSessionId);
|
|
183
|
+
const delegation = delegationModel?.trim() === '' ? undefined : delegationModel?.trim();
|
|
184
|
+
const settings = this.deps.settingsModel();
|
|
185
|
+
const cliDefault = await this.deps.cliDefault(childSessionId);
|
|
186
|
+
const native = await this.deps.directory?.(childSessionId);
|
|
187
|
+
const [effective, source] = override !== undefined
|
|
188
|
+
? [override, 'override']
|
|
189
|
+
: delegation !== undefined
|
|
190
|
+
? [delegation, 'delegation']
|
|
191
|
+
: settings !== undefined
|
|
192
|
+
? [settings, 'settings']
|
|
193
|
+
: cliDefault !== undefined
|
|
194
|
+
? [cliDefault, 'cli-config']
|
|
195
|
+
: [native?.defaultModel, 'cli-builtin'];
|
|
196
|
+
// Native candidates plus labelled configuration and historical suggestions.
|
|
197
|
+
const choices = [...new Set([settings, cliDefault, ...native?.entries.filter(entry => !entry.hidden).map(entry => entry.value) ?? [], ...this.deps.recentModels()]
|
|
198
|
+
.filter((value) => value !== undefined))];
|
|
199
|
+
const lastObserved = await this.lastObserved(childSessionId);
|
|
200
|
+
const active = childSessionId !== undefined && this.deps.localAgent.isDelegationActive(childSessionId);
|
|
201
|
+
return {
|
|
202
|
+
...effective === undefined ? {} : { effective },
|
|
203
|
+
source,
|
|
204
|
+
...override === undefined ? {} : { override },
|
|
205
|
+
...delegation === undefined ? {} : { delegation },
|
|
206
|
+
...settings === undefined ? {} : { settings },
|
|
207
|
+
...cliDefault === undefined ? {} : { cliDefault },
|
|
208
|
+
...lastObserved === undefined ? {} : { lastObserved },
|
|
209
|
+
choices,
|
|
210
|
+
...native === undefined ? {} : { directory: extendModelDirectory(native, [settings, cliDefault], [...this.deps.recentModels(), lastObserved]) },
|
|
211
|
+
live: this.deps.live(),
|
|
212
|
+
switchable: !active,
|
|
213
|
+
...active
|
|
214
|
+
? { reason: '该成员有进行中的委派轮次,等其完成后再切换模型 (a delegation round is in flight)' }
|
|
215
|
+
: {},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Set or clear a member's session-level override. Throws while a round is
|
|
220
|
+
* in flight for the member; a same-model set is a no-op; a real change
|
|
221
|
+
* retires the member's resident runtime when its bound model is no longer
|
|
222
|
+
* what the next round would spawn with (the CLI session resumes on the
|
|
223
|
+
* respawn, so the conversation carries over).
|
|
224
|
+
*/
|
|
225
|
+
async setMemberModel(childSessionId, model) {
|
|
226
|
+
if (this.deps.localAgent.setMemberModel !== undefined)
|
|
227
|
+
return this.deps.localAgent.setMemberModel(childSessionId, model);
|
|
228
|
+
if (this.deps.localAgent.isDelegationActive(childSessionId)) {
|
|
229
|
+
throw new Error(`subagent-claude: 成员 ${childSessionId} 有进行中的委派轮次,等其完成后再切换模型 `
|
|
230
|
+
+ '(a delegation round is in flight for this member)');
|
|
231
|
+
}
|
|
232
|
+
const trimmed = model?.trim();
|
|
233
|
+
const next = trimmed === undefined || trimmed === '' ? undefined : trimmed;
|
|
234
|
+
if (next === this.deps.overrides.get(childSessionId))
|
|
235
|
+
return;
|
|
236
|
+
if (next === undefined)
|
|
237
|
+
this.deps.overrides.delete(childSessionId);
|
|
238
|
+
else
|
|
239
|
+
this.deps.overrides.set(childSessionId, next);
|
|
240
|
+
// What the next spawn would bind, in the round resolvers' own order.
|
|
241
|
+
const delegation = this.deps.localAgent.getDelegation(childSessionId)?.model?.trim();
|
|
242
|
+
const spawnModel = next ?? (delegation === undefined || delegation === '' ? undefined : delegation) ?? this.deps.settingsModel();
|
|
243
|
+
const host = this.deps.liveSwitch.hostingDriver(childSessionId);
|
|
244
|
+
if (host !== undefined && host.runtimeModel(childSessionId) !== spawnModel) {
|
|
245
|
+
await host.retireRuntime(childSessionId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess';
|
|
2
|
+
import type { LocalAgentModelDirectory, LocalAgentModelDirectoryData } from '@khorsheed/dsh-local-agent/types';
|
|
3
|
+
type JsonObject = Record<string, unknown>;
|
|
4
|
+
/** Initialize's native picker vocabulary, not an account entitlement claim. */
|
|
5
|
+
export declare function claudeDirectory(result: JsonObject): LocalAgentModelDirectoryData;
|
|
6
|
+
/** Scoped native control probe. Never syncs credentials, writes settings or submits a user turn. */
|
|
7
|
+
export declare class ClaudeModelCatalog {
|
|
8
|
+
private readonly deps;
|
|
9
|
+
private readonly cache;
|
|
10
|
+
constructor(deps: {
|
|
11
|
+
spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle;
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
cli?: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
ttlMs?: number;
|
|
16
|
+
warn?: (message: string) => void;
|
|
17
|
+
});
|
|
18
|
+
private key;
|
|
19
|
+
read(homeDir: string, cwd?: string): LocalAgentModelDirectory;
|
|
20
|
+
refresh(homeDir: string, cwd?: string): Promise<LocalAgentModelDirectory>;
|
|
21
|
+
follow(homeDir: string, cwd: string | undefined, signal: AbortSignal): AsyncIterable<LocalAgentModelDirectory>;
|
|
22
|
+
invalidate(homeDir: string, cwd?: string): void;
|
|
23
|
+
dispose(): void;
|
|
24
|
+
private probe;
|
|
25
|
+
}
|
|
26
|
+
export {};
|
|
27
|
+
//# sourceMappingURL=model-catalog.d.ts.map
|