@khorsheed/dsh-local-agent 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 +181 -0
- package/README.i18n.yaml +2 -0
- package/README.md +181 -0
- package/cordis.patch.yml +21 -0
- package/lib/client.js +8832 -0
- package/lib/index.js +5281 -0
- package/lib/invariant.js +85 -0
- package/lib/member-bridge.js +298 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3137 -0
- package/lib/typert.remote-client.d.ts +67 -0
- package/lib/typert.remote-client.js +1971 -0
- package/lib/types/cli-version.d.ts +82 -0
- package/lib/types/cli-version.js +165 -0
- package/lib/types/client/AuthStatusDot.d.ts +17 -0
- package/lib/types/client/AuthStatusDot.js +9 -0
- package/lib/types/client/HarnessModelPicker.d.ts +17 -0
- package/lib/types/client/HarnessModelPicker.js +48 -0
- package/lib/types/client/LocalAgentRecordsAction.d.ts +41 -0
- package/lib/types/client/LocalAgentRecordsAction.js +74 -0
- package/lib/types/client/MemberComposer.d.ts +79 -0
- package/lib/types/client/MemberComposer.js +308 -0
- package/lib/types/client/MemberConfiguration.d.ts +9 -0
- package/lib/types/client/MemberConfiguration.js +46 -0
- package/lib/types/client/MemberInboxView.d.ts +12 -0
- package/lib/types/client/MemberInboxView.js +38 -0
- package/lib/types/client/MemberLiveNode.d.ts +14 -0
- package/lib/types/client/MemberLiveNode.js +74 -0
- package/lib/types/client/MemberPaintDiagnostics.d.ts +7 -0
- package/lib/types/client/MemberPaintDiagnostics.js +20 -0
- package/lib/types/client/ModelConfigurationFields.d.ts +15 -0
- package/lib/types/client/ModelConfigurationFields.js +23 -0
- package/lib/types/client/ProviderAuthBlock.d.ts +59 -0
- package/lib/types/client/ProviderAuthBlock.js +164 -0
- package/lib/types/client/auth-status.d.ts +28 -0
- package/lib/types/client/auth-status.js +56 -0
- package/lib/types/client/icons.d.ts +22 -0
- package/lib/types/client/icons.js +6 -0
- package/lib/types/client/index.d.ts +62 -0
- package/lib/types/client/index.js +135 -0
- package/lib/types/client/live-node.d.ts +14 -0
- package/lib/types/client/live-node.js +34 -0
- package/lib/types/client/live-output.d.ts +34 -0
- package/lib/types/client/live-output.js +94 -0
- package/lib/types/client/live-paint.d.ts +40 -0
- package/lib/types/client/live-paint.js +74 -0
- package/lib/types/client/locales.d.ts +120 -0
- package/lib/types/client/locales.js +227 -0
- package/lib/types/client/member-configuration.d.ts +41 -0
- package/lib/types/client/member-configuration.js +114 -0
- package/lib/types/client/member-dock.d.ts +94 -0
- package/lib/types/client/member-dock.js +102 -0
- package/lib/types/client/member-feed.d.ts +24 -0
- package/lib/types/client/member-feed.js +134 -0
- package/lib/types/client/request-id.d.ts +3 -0
- package/lib/types/client/request-id.js +6 -0
- package/lib/types/client/settings-scope.d.ts +74 -0
- package/lib/types/client/settings-scope.js +98 -0
- package/lib/types/client/use-configuration-menu.d.ts +10 -0
- package/lib/types/client/use-configuration-menu.js +61 -0
- package/lib/types/container.d.ts +69 -0
- package/lib/types/container.js +129 -0
- package/lib/types/env.d.ts +17 -0
- package/lib/types/env.js +89 -0
- package/lib/types/gateway.d.ts +162 -0
- package/lib/types/gateway.js +470 -0
- package/lib/types/index.d.ts +1463 -0
- package/lib/types/index.js +2704 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +105 -0
- package/lib/types/live-flush.d.ts +21 -0
- package/lib/types/live-flush.js +59 -0
- package/lib/types/live-stream.d.ts +41 -0
- package/lib/types/live-stream.js +178 -0
- package/lib/types/member-bridge.d.ts +50 -0
- package/lib/types/member-bridge.js +216 -0
- package/lib/types/member-channel.d.ts +86 -0
- package/lib/types/member-channel.js +232 -0
- package/lib/types/member-control-storage.d.ts +10 -0
- package/lib/types/member-control-storage.js +100 -0
- package/lib/types/member-control.d.ts +62 -0
- package/lib/types/member-control.js +359 -0
- package/lib/types/member-controls.d.ts +23 -0
- package/lib/types/member-controls.js +143 -0
- package/lib/types/member-feed.d.ts +4 -0
- package/lib/types/member-feed.js +45 -0
- package/lib/types/member-inbox.d.ts +30 -0
- package/lib/types/member-inbox.js +225 -0
- package/lib/types/model-directory.d.ts +43 -0
- package/lib/types/model-directory.js +192 -0
- package/lib/types/prepared-members.d.ts +15 -0
- package/lib/types/prepared-members.js +61 -0
- package/lib/types/settings-face.d.ts +61 -0
- package/lib/types/settings-face.js +61 -0
- package/lib/types/types.d.ts +959 -0
- package/lib/types/types.js +24 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +112 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Durable human input; the provider FIFO remains the sole execution order. */
|
|
5
|
+
export class MemberInbox {
|
|
6
|
+
directory;
|
|
7
|
+
execute;
|
|
8
|
+
onError;
|
|
9
|
+
states = new Map();
|
|
10
|
+
active = new Map();
|
|
11
|
+
storageErrors = new Map();
|
|
12
|
+
closed = false;
|
|
13
|
+
constructor(directory, execute, onError) {
|
|
14
|
+
this.directory = directory;
|
|
15
|
+
this.execute = execute;
|
|
16
|
+
this.onError = onError;
|
|
17
|
+
}
|
|
18
|
+
path(memberId) { return join(this.directory, createHash('sha256').update(memberId).digest('hex') + '.json'); }
|
|
19
|
+
load(memberId) {
|
|
20
|
+
const known = this.states.get(memberId);
|
|
21
|
+
if (known !== undefined)
|
|
22
|
+
return known;
|
|
23
|
+
let raw;
|
|
24
|
+
try {
|
|
25
|
+
raw = readFileSync(this.path(memberId), 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== 'ENOENT')
|
|
29
|
+
throw error;
|
|
30
|
+
const empty = { memberId, paused: false, messages: [] };
|
|
31
|
+
this.states.set(memberId, empty);
|
|
32
|
+
return empty;
|
|
33
|
+
}
|
|
34
|
+
const stored = JSON.parse(raw);
|
|
35
|
+
const state = stored?.state;
|
|
36
|
+
const statuses = ['queued', 'running', 'done', 'failed', 'cancelled', 'uncertain'];
|
|
37
|
+
if (stored?.version !== 1 || state?.memberId !== memberId || typeof state.paused !== 'boolean' || !Array.isArray(state.messages)
|
|
38
|
+
|| state.messages.some(row => typeof row?.id !== 'string' || typeof row.text !== 'string' || !statuses.includes(row.status)
|
|
39
|
+
|| !Number.isFinite(row.createdAt) || !Number.isFinite(row.updatedAt) || (row.error !== undefined && typeof row.error !== 'string'))
|
|
40
|
+
|| new Set(state.messages.map(row => row.id)).size !== state.messages.length)
|
|
41
|
+
throw new Error('Invalid member inbox; admission is blocked');
|
|
42
|
+
const recovered = structuredClone(state);
|
|
43
|
+
for (const row of recovered.messages)
|
|
44
|
+
if (row.status === 'running') {
|
|
45
|
+
row.status = 'uncertain';
|
|
46
|
+
row.error = 'Host restarted before durable settlement; reconcile this input before continuing';
|
|
47
|
+
}
|
|
48
|
+
if (recovered.messages.some(row => row.status === 'queued' || row.status === 'uncertain'))
|
|
49
|
+
recovered.paused = true;
|
|
50
|
+
this.save(recovered);
|
|
51
|
+
return recovered;
|
|
52
|
+
}
|
|
53
|
+
save(state) {
|
|
54
|
+
const path = this.path(state.memberId);
|
|
55
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
58
|
+
const fd = openSync(temporary, 'wx', 0o600);
|
|
59
|
+
try {
|
|
60
|
+
writeFileSync(fd, JSON.stringify({ version: 1, state }));
|
|
61
|
+
fsyncSync(fd);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
renameSync(temporary, path);
|
|
67
|
+
const directory = openSync(this.directory, 'r');
|
|
68
|
+
try {
|
|
69
|
+
fsyncSync(directory);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
closeSync(directory);
|
|
73
|
+
}
|
|
74
|
+
this.states.set(state.memberId, state);
|
|
75
|
+
this.storageErrors.delete(state.memberId);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
this.storageErrors.set(state.memberId, String(error));
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(temporary);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error.code !== 'ENOENT')
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
read(memberId) {
|
|
92
|
+
const state = structuredClone(this.load(memberId));
|
|
93
|
+
const error = this.storageErrors.get(memberId);
|
|
94
|
+
return error === undefined ? state : { ...state, error };
|
|
95
|
+
}
|
|
96
|
+
assertAdmission(memberId) {
|
|
97
|
+
if (this.storageErrors.has(memberId))
|
|
98
|
+
throw new Error('Member inbox persistence failed; reconcile storage before admission');
|
|
99
|
+
const state = this.load(memberId);
|
|
100
|
+
if (state.messages.some(row => row.status === 'uncertain'))
|
|
101
|
+
throw new Error('A member input has an uncertain outcome; reconcile before continuing');
|
|
102
|
+
if (state.paused && state.messages.some(row => row.status === 'queued'))
|
|
103
|
+
throw new Error('Member inbox is paused; resume or cancel queued inputs first');
|
|
104
|
+
}
|
|
105
|
+
enqueue(memberId, text, requestId) {
|
|
106
|
+
if (this.closed)
|
|
107
|
+
throw new Error('Member inbox is closed');
|
|
108
|
+
if (text.trim() === '' || requestId.trim() === '')
|
|
109
|
+
throw new Error('Member input and request ID must be non-empty');
|
|
110
|
+
const state = this.read(memberId);
|
|
111
|
+
const previous = state.messages.find(row => row.id === requestId);
|
|
112
|
+
if (previous !== undefined) {
|
|
113
|
+
if (previous.text !== text)
|
|
114
|
+
throw new Error('Member input request ID was already used for different text');
|
|
115
|
+
return previous;
|
|
116
|
+
}
|
|
117
|
+
if (state.messages.filter(row => row.status === 'queued').length >= 100)
|
|
118
|
+
throw new Error('Member inbox has reached its 100 queued input limit');
|
|
119
|
+
const row = { id: requestId, text, status: 'queued', createdAt: Date.now(), updatedAt: Date.now() };
|
|
120
|
+
state.messages.push(row);
|
|
121
|
+
this.save(state);
|
|
122
|
+
this.pump(memberId);
|
|
123
|
+
return structuredClone(row);
|
|
124
|
+
}
|
|
125
|
+
pause(memberId) {
|
|
126
|
+
const state = this.read(memberId);
|
|
127
|
+
state.paused = true;
|
|
128
|
+
this.save(state);
|
|
129
|
+
for (const row of state.messages)
|
|
130
|
+
if (row.status === 'queued')
|
|
131
|
+
this.active.get(`${memberId}:${row.id}`)?.controller.abort(new Error('Inbox paused'));
|
|
132
|
+
}
|
|
133
|
+
resume(memberId) {
|
|
134
|
+
if (this.storageErrors.has(memberId)) {
|
|
135
|
+
this.states.delete(memberId);
|
|
136
|
+
this.storageErrors.delete(memberId);
|
|
137
|
+
}
|
|
138
|
+
const state = this.read(memberId);
|
|
139
|
+
if (state.messages.some(row => row.status === 'uncertain'))
|
|
140
|
+
throw new Error('Reconcile uncertain inputs before resuming');
|
|
141
|
+
state.paused = false;
|
|
142
|
+
this.save(state);
|
|
143
|
+
this.pump(memberId);
|
|
144
|
+
}
|
|
145
|
+
cancelStarting(memberId) {
|
|
146
|
+
const row = this.load(memberId).messages.find(input => input.status === 'running');
|
|
147
|
+
const entry = row === undefined ? undefined : this.active.get(`${memberId}:${row.id}`);
|
|
148
|
+
if (entry === undefined)
|
|
149
|
+
return false;
|
|
150
|
+
entry.controller.abort(new Error('Member input stopped'));
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
cancel(memberId, id) {
|
|
154
|
+
const state = this.read(memberId);
|
|
155
|
+
const row = state.messages.find(row => row.id === id);
|
|
156
|
+
if (row?.status !== 'queued')
|
|
157
|
+
throw new Error('Only a queued input can be cancelled here; use member Stop for a running turn');
|
|
158
|
+
row.status = 'cancelled';
|
|
159
|
+
row.updatedAt = Date.now();
|
|
160
|
+
this.save(state);
|
|
161
|
+
this.active.get(`${memberId}:${id}`)?.controller.abort(new Error('Queued input cancelled'));
|
|
162
|
+
}
|
|
163
|
+
reconcile(memberId, id, outcome, evidence) {
|
|
164
|
+
const state = this.read(memberId);
|
|
165
|
+
const row = state.messages.find(row => row.id === id);
|
|
166
|
+
if (row?.status !== 'uncertain' || evidence.trim() === '')
|
|
167
|
+
throw new Error('Reconciliation requires an uncertain input and evidence');
|
|
168
|
+
row.status = outcome;
|
|
169
|
+
row.error = evidence.trim();
|
|
170
|
+
row.updatedAt = Date.now();
|
|
171
|
+
this.save(state);
|
|
172
|
+
}
|
|
173
|
+
update(memberId, id, update) {
|
|
174
|
+
const state = this.read(memberId);
|
|
175
|
+
const row = state.messages.find(row => row.id === id);
|
|
176
|
+
if (row === undefined)
|
|
177
|
+
throw new Error('Member input disappeared');
|
|
178
|
+
update(row);
|
|
179
|
+
row.updatedAt = Date.now();
|
|
180
|
+
this.save(state);
|
|
181
|
+
}
|
|
182
|
+
pump(memberId) {
|
|
183
|
+
if (this.closed || this.storageErrors.has(memberId))
|
|
184
|
+
return;
|
|
185
|
+
const state = this.load(memberId);
|
|
186
|
+
if (state.paused || state.messages.some(row => row.status === 'uncertain'))
|
|
187
|
+
return;
|
|
188
|
+
for (const row of state.messages) {
|
|
189
|
+
const key = `${memberId}:${row.id}`;
|
|
190
|
+
if (row.status !== 'queued' || this.active.has(key))
|
|
191
|
+
continue;
|
|
192
|
+
const controller = new AbortController();
|
|
193
|
+
this.active.set(key, { memberId, controller });
|
|
194
|
+
void (async () => {
|
|
195
|
+
try {
|
|
196
|
+
const run = await this.execute(memberId, row.text, controller.signal, () => {
|
|
197
|
+
controller.signal.throwIfAborted();
|
|
198
|
+
this.assertAdmission(memberId);
|
|
199
|
+
this.update(memberId, row.id, input => { if (input.status !== 'queued')
|
|
200
|
+
throw new Error('Input is no longer queued'); input.status = 'running'; });
|
|
201
|
+
});
|
|
202
|
+
const result = await run.result;
|
|
203
|
+
if (!this.closed)
|
|
204
|
+
this.update(memberId, row.id, input => { input.status = result.stopReason === 'aborted' ? 'cancelled' : result.stopReason === 'completed' ? 'done' : 'failed'; });
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (!this.closed) {
|
|
208
|
+
const current = this.load(memberId);
|
|
209
|
+
const status = current.messages.find(input => input.id === row.id)?.status;
|
|
210
|
+
if (!(controller.signal.aborted && current.paused && status === 'queued') && status !== 'cancelled') {
|
|
211
|
+
this.update(memberId, row.id, input => { input.status = controller.signal.aborted ? 'cancelled' : 'failed'; input.error = String(error); });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
this.active.delete(key);
|
|
217
|
+
if (!this.closed)
|
|
218
|
+
this.pump(memberId);
|
|
219
|
+
}
|
|
220
|
+
})().catch(this.onError);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
dispose() { this.closed = true; for (const entry of this.active.values())
|
|
224
|
+
entry.controller.abort(new Error('Inbox disposed')); this.active.clear(); }
|
|
225
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { LocalAgentModelDirectory, LocalAgentModelDirectoryData } from './types.ts';
|
|
2
|
+
/** File metadata only: credential bytes and URL credentials never enter cache keys. */
|
|
3
|
+
export declare function modelDirectoryContextKey(context: {
|
|
4
|
+
provider: string;
|
|
5
|
+
homeDir: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
cli: readonly string[];
|
|
8
|
+
files: readonly string[];
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
}): string;
|
|
11
|
+
/** Native facts win; configured candidates and historical suggestions retain provenance. */
|
|
12
|
+
export declare function extendModelDirectory(directory: LocalAgentModelDirectory, configuration: readonly (string | undefined)[], history: readonly (string | undefined)[]): LocalAgentModelDirectory;
|
|
13
|
+
/**
|
|
14
|
+
* Shared mechanics only: providers own discovery and supply a non-secret
|
|
15
|
+
* context key covering scope, endpoint, configuration, cwd and CLI identity.
|
|
16
|
+
* Failed refreshes keep the last successful data. Invalidation fences stale
|
|
17
|
+
* in-flight replies, including account changes while a probe is running.
|
|
18
|
+
*/
|
|
19
|
+
export declare class ModelDirectoryCache {
|
|
20
|
+
private readonly options;
|
|
21
|
+
private readonly entries;
|
|
22
|
+
private revision;
|
|
23
|
+
private disposed;
|
|
24
|
+
constructor(options: {
|
|
25
|
+
load: (context: string, signal: AbortSignal) => Promise<LocalAgentModelDirectoryData>;
|
|
26
|
+
ttlMs?: number;
|
|
27
|
+
retryMs?: number;
|
|
28
|
+
maxEntries?: number;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
onError?: (error: unknown) => void;
|
|
31
|
+
});
|
|
32
|
+
private now;
|
|
33
|
+
private entry;
|
|
34
|
+
private publish;
|
|
35
|
+
read(key: string): LocalAgentModelDirectory;
|
|
36
|
+
refresh(key: string): Promise<LocalAgentModelDirectory>;
|
|
37
|
+
/** Same context changed on disk. New identities should use a different key. */
|
|
38
|
+
invalidate(key: string): void;
|
|
39
|
+
/** Initial snapshot plus coalesced updates; no retained event queue. */
|
|
40
|
+
follow(key: string | (() => string), signal: AbortSignal): AsyncIterable<LocalAgentModelDirectory>;
|
|
41
|
+
dispose(): void;
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=model-directory.d.ts.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { cliExecutableIdentity } from './cli-version.js';
|
|
3
|
+
/** File metadata only: credential bytes and URL credentials never enter cache keys. */
|
|
4
|
+
export function modelDirectoryContextKey(context) {
|
|
5
|
+
const files = context.files.map(path => {
|
|
6
|
+
try {
|
|
7
|
+
const stat = statSync(path);
|
|
8
|
+
return [path, stat.ino, stat.mtimeMs, stat.ctimeMs, stat.size];
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return [path, 'absent'];
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
let endpoint;
|
|
15
|
+
if (context.endpoint !== undefined) {
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(context.endpoint);
|
|
18
|
+
endpoint = url.origin + url.pathname;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
endpoint = 'invalid-endpoint';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return JSON.stringify({ provider: context.provider, homeDir: context.homeDir, cwd: context.cwd, cli: cliExecutableIdentity(context.cli), files, endpoint });
|
|
25
|
+
}
|
|
26
|
+
/** Native facts win; configured candidates and historical suggestions retain provenance. */
|
|
27
|
+
export function extendModelDirectory(directory, configuration, history) {
|
|
28
|
+
const entries = new Map(directory.entries.map(entry => [entry.value, entry]));
|
|
29
|
+
for (const [source, values] of [['configuration', configuration], ['history', history]]) {
|
|
30
|
+
for (const candidate of values) {
|
|
31
|
+
const value = candidate?.trim();
|
|
32
|
+
if (value !== undefined && value !== '' && !entries.has(value))
|
|
33
|
+
entries.set(value, { value, label: value, source });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { ...directory, entries: [...entries.values()] };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Shared mechanics only: providers own discovery and supply a non-secret
|
|
40
|
+
* context key covering scope, endpoint, configuration, cwd and CLI identity.
|
|
41
|
+
* Failed refreshes keep the last successful data. Invalidation fences stale
|
|
42
|
+
* in-flight replies, including account changes while a probe is running.
|
|
43
|
+
*/
|
|
44
|
+
export class ModelDirectoryCache {
|
|
45
|
+
options;
|
|
46
|
+
entries = new Map();
|
|
47
|
+
revision = 0;
|
|
48
|
+
disposed = false;
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.options = options;
|
|
51
|
+
}
|
|
52
|
+
now() { return (this.options.now ?? Date.now)(); }
|
|
53
|
+
entry(key) {
|
|
54
|
+
let entry = this.entries.get(key);
|
|
55
|
+
if (entry === undefined) {
|
|
56
|
+
entry = {
|
|
57
|
+
snapshot: { entries: [], complete: false, customInput: true, status: 'loading', refreshing: false, revision: ++this.revision },
|
|
58
|
+
nextAttemptAt: 0, generation: 0, listeners: new Set(),
|
|
59
|
+
};
|
|
60
|
+
this.entries.set(key, entry);
|
|
61
|
+
}
|
|
62
|
+
// Configuration and credential rotations produce new identities. Retain
|
|
63
|
+
// active subscriptions/probes, but do not accumulate every old identity.
|
|
64
|
+
this.entries.delete(key);
|
|
65
|
+
this.entries.set(key, entry);
|
|
66
|
+
for (const [candidate, value] of this.entries) {
|
|
67
|
+
if (this.entries.size <= Math.max(1, this.options.maxEntries ?? 128))
|
|
68
|
+
break;
|
|
69
|
+
if (candidate !== key && value.listeners.size === 0 && value.pending === undefined)
|
|
70
|
+
this.entries.delete(candidate);
|
|
71
|
+
}
|
|
72
|
+
return entry;
|
|
73
|
+
}
|
|
74
|
+
publish(entry, snapshot) {
|
|
75
|
+
entry.snapshot = { ...snapshot, revision: ++this.revision };
|
|
76
|
+
for (const listener of entry.listeners)
|
|
77
|
+
listener();
|
|
78
|
+
}
|
|
79
|
+
read(key) {
|
|
80
|
+
const entry = this.entry(key);
|
|
81
|
+
if (!this.disposed && this.now() >= entry.nextAttemptAt)
|
|
82
|
+
void this.refresh(key);
|
|
83
|
+
return entry.snapshot;
|
|
84
|
+
}
|
|
85
|
+
refresh(key) {
|
|
86
|
+
const entry = this.entry(key);
|
|
87
|
+
if (this.disposed)
|
|
88
|
+
return Promise.resolve(entry.snapshot);
|
|
89
|
+
if (entry.pending !== undefined)
|
|
90
|
+
return entry.pending;
|
|
91
|
+
const generation = ++entry.generation;
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
entry.controller = controller;
|
|
94
|
+
this.publish(entry, {
|
|
95
|
+
...entry.snapshot, refreshing: true,
|
|
96
|
+
status: entry.snapshot.refreshedAt === undefined ? 'loading' : 'stale',
|
|
97
|
+
});
|
|
98
|
+
const pending = Promise.resolve().then(() => this.options.load(key, controller.signal)).then(data => {
|
|
99
|
+
if (this.disposed || generation !== entry.generation)
|
|
100
|
+
return entry.snapshot;
|
|
101
|
+
entry.nextAttemptAt = this.now() + (this.options.ttlMs ?? 300_000);
|
|
102
|
+
this.publish(entry, {
|
|
103
|
+
...data, status: data.unsupported === true ? 'unsupported' : 'ready',
|
|
104
|
+
refreshing: false, refreshedAt: this.now(),
|
|
105
|
+
});
|
|
106
|
+
return entry.snapshot;
|
|
107
|
+
}, error => {
|
|
108
|
+
if (this.disposed || generation !== entry.generation)
|
|
109
|
+
return entry.snapshot;
|
|
110
|
+
this.options.onError?.(error);
|
|
111
|
+
entry.nextAttemptAt = this.now() + (this.options.retryMs ?? this.options.ttlMs ?? 30_000);
|
|
112
|
+
this.publish(entry, {
|
|
113
|
+
...entry.snapshot, status: entry.snapshot.refreshedAt === undefined ? 'error' : 'stale',
|
|
114
|
+
refreshing: false, reason: error instanceof Error ? error.message : 'Model discovery failed',
|
|
115
|
+
});
|
|
116
|
+
return entry.snapshot;
|
|
117
|
+
}).finally(() => {
|
|
118
|
+
if (generation === entry.generation) {
|
|
119
|
+
delete entry.pending;
|
|
120
|
+
delete entry.controller;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
entry.pending = pending;
|
|
124
|
+
return pending;
|
|
125
|
+
}
|
|
126
|
+
/** Same context changed on disk. New identities should use a different key. */
|
|
127
|
+
invalidate(key) {
|
|
128
|
+
const entry = this.entries.get(key);
|
|
129
|
+
if (entry === undefined || this.disposed)
|
|
130
|
+
return;
|
|
131
|
+
entry.generation++;
|
|
132
|
+
entry.controller?.abort();
|
|
133
|
+
delete entry.pending;
|
|
134
|
+
delete entry.controller;
|
|
135
|
+
entry.nextAttemptAt = 0;
|
|
136
|
+
this.publish(entry, {
|
|
137
|
+
...entry.snapshot, refreshing: false,
|
|
138
|
+
status: entry.snapshot.refreshedAt === undefined ? 'loading' : 'stale',
|
|
139
|
+
});
|
|
140
|
+
if (entry.listeners.size > 0)
|
|
141
|
+
void this.refresh(key);
|
|
142
|
+
}
|
|
143
|
+
/** Initial snapshot plus coalesced updates; no retained event queue. */
|
|
144
|
+
async *follow(key, signal) {
|
|
145
|
+
if (signal.aborted || this.disposed)
|
|
146
|
+
return;
|
|
147
|
+
const currentKey = () => typeof key === 'string' ? key : key();
|
|
148
|
+
let entry = this.entry(currentKey());
|
|
149
|
+
let wake;
|
|
150
|
+
const notify = () => { wake?.(); wake = undefined; };
|
|
151
|
+
entry.listeners.add(notify);
|
|
152
|
+
signal.addEventListener('abort', notify, { once: true });
|
|
153
|
+
// Re-check native config/auth file identities and TTL while a picker stays open.
|
|
154
|
+
const timer = setInterval(notify, 1_000);
|
|
155
|
+
timer.unref();
|
|
156
|
+
let revision = -1;
|
|
157
|
+
try {
|
|
158
|
+
while (!signal.aborted && !this.disposed) {
|
|
159
|
+
const activeKey = currentKey();
|
|
160
|
+
const active = this.entry(activeKey);
|
|
161
|
+
if (active !== entry) {
|
|
162
|
+
entry.listeners.delete(notify);
|
|
163
|
+
entry = active;
|
|
164
|
+
entry.listeners.add(notify);
|
|
165
|
+
}
|
|
166
|
+
const snapshot = this.read(activeKey);
|
|
167
|
+
if (snapshot.revision !== revision) {
|
|
168
|
+
revision = snapshot.revision;
|
|
169
|
+
yield snapshot;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
await new Promise(resolve => { wake = resolve; });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
entry.listeners.delete(notify);
|
|
178
|
+
signal.removeEventListener('abort', notify);
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
dispose() {
|
|
183
|
+
this.disposed = true;
|
|
184
|
+
for (const entry of this.entries.values()) {
|
|
185
|
+
entry.generation++;
|
|
186
|
+
entry.controller?.abort();
|
|
187
|
+
for (const listener of entry.listeners)
|
|
188
|
+
listener();
|
|
189
|
+
}
|
|
190
|
+
this.entries.clear();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { LocalAgentMemberBinding } from './types.ts';
|
|
2
|
+
export interface PreparedMemberRecord {
|
|
3
|
+
version: 1;
|
|
4
|
+
binding: LocalAgentMemberBinding;
|
|
5
|
+
phase: 'preparing' | 'ready' | 'starting' | 'used';
|
|
6
|
+
}
|
|
7
|
+
/** Preparation identity survives restart independently of a not-yet-created native transcript. */
|
|
8
|
+
export declare class PreparedMembers {
|
|
9
|
+
private readonly directory;
|
|
10
|
+
constructor(directory: string);
|
|
11
|
+
private path;
|
|
12
|
+
read(id: string): PreparedMemberRecord | undefined;
|
|
13
|
+
write(record: PreparedMemberRecord): void;
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=prepared-members.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** Preparation identity survives restart independently of a not-yet-created native transcript. */
|
|
5
|
+
export class PreparedMembers {
|
|
6
|
+
directory;
|
|
7
|
+
constructor(directory) {
|
|
8
|
+
this.directory = directory;
|
|
9
|
+
}
|
|
10
|
+
path(id) { return join(this.directory, createHash('sha256').update(id).digest('hex') + '.json'); }
|
|
11
|
+
read(id) {
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
text = readFileSync(this.path(id), 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === 'ENOENT')
|
|
18
|
+
return undefined;
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
const raw = JSON.parse(text);
|
|
22
|
+
if (raw?.version !== 1 || !['preparing', 'ready', 'starting', 'used'].includes(raw.phase)
|
|
23
|
+
|| raw.binding?.childSessionId !== id || ['provider', 'parentSessionId', 'cwd'].some(key => typeof raw.binding[key] !== 'string')
|
|
24
|
+
|| ['scope', 'model', 'effort', 'configurationLock'].some(key => raw.binding[key] !== undefined && typeof raw.binding[key] !== 'string')) {
|
|
25
|
+
throw new Error('Invalid prepared member identity; native admission is blocked');
|
|
26
|
+
}
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
write(record) {
|
|
30
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
31
|
+
const path = this.path(record.binding.childSessionId);
|
|
32
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
33
|
+
try {
|
|
34
|
+
const fd = openSync(temporary, 'wx', 0o600);
|
|
35
|
+
try {
|
|
36
|
+
writeFileSync(fd, JSON.stringify(record));
|
|
37
|
+
fsyncSync(fd);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
closeSync(fd);
|
|
41
|
+
}
|
|
42
|
+
renameSync(temporary, path);
|
|
43
|
+
const directory = openSync(this.directory, 'r');
|
|
44
|
+
try {
|
|
45
|
+
fsyncSync(directory);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
closeSync(directory);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(temporary);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== 'ENOENT')
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-line settings face for the family providers: one read/watch contract
|
|
3
|
+
* over the two host settings generations, probed by capability, never by
|
|
4
|
+
* version.
|
|
5
|
+
*
|
|
6
|
+
* - 0.1.5: `settings.register(ns, schema, { base })` returns a namespace scope
|
|
7
|
+
* whose `get()` resolves schema defaults ← composition base ← user layer and
|
|
8
|
+
* whose `watch` round-trips every write. That scope IS the face.
|
|
9
|
+
* - 0.1.7-rc.1 (`SettingsForms`): the settings fields live on the provider's
|
|
10
|
+
* own plugin Config as `.volatile()` fields — writes ride the profile patch,
|
|
11
|
+
* the running fiber's Volatile references track them without a remount, and
|
|
12
|
+
* `settings/document-updated` is the change channel. `get()` unwraps the
|
|
13
|
+
* Volatile references; `watch` re-reads on a matching namespace update.
|
|
14
|
+
*
|
|
15
|
+
* The legacy arm keeps old `settings.yaml` values working untouched; on rc.1
|
|
16
|
+
* the official one-shot import moves the same-named section into the row's
|
|
17
|
+
* config, which the volatile read then serves.
|
|
18
|
+
*/
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
20
|
+
/** The resolved settings document one provider consumes. */
|
|
21
|
+
export interface SettingsFace<T> {
|
|
22
|
+
/** Read the current resolved settings. */
|
|
23
|
+
get(): T;
|
|
24
|
+
/**
|
|
25
|
+
* Observe settings replacements. The listener receives the freshly re-read
|
|
26
|
+
* value on every change (own writes round-trip too, exactly like the 0.1.5
|
|
27
|
+
* namespace scope).
|
|
28
|
+
* @returns the unwatch disposer.
|
|
29
|
+
*/
|
|
30
|
+
watch(listener: (next: T) => void): () => void;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Unwrap one volatile config reference (rc.1 wraps each `.volatile()` field
|
|
34
|
+
* in a `{ get() }` reference) or pass a plain value through (0.1.5's
|
|
35
|
+
* schemastery has no `.volatile()`, so the field resolves bare there).
|
|
36
|
+
* @param field - the config field as the fiber received it.
|
|
37
|
+
* @returns the plain value.
|
|
38
|
+
*/
|
|
39
|
+
export declare function vol<T>(field: T | {
|
|
40
|
+
get(): T;
|
|
41
|
+
}): T;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the provider's settings face for the host line actually serving.
|
|
44
|
+
* @param ctx - the provider's plugin context (`settings` injected).
|
|
45
|
+
* @param options.namespace - the settings namespace; identical to the plugin
|
|
46
|
+
* row id, which is what rc.1's `settings/document-updated` emits and what
|
|
47
|
+
* the rc.1 legacy import maps the old settings.yaml section onto.
|
|
48
|
+
* @param options.legacySchema - the 0.1.5 namespace schema (plain fields).
|
|
49
|
+
* @param options.legacyBase - the 0.1.5 composition base, built from the
|
|
50
|
+
* deployment's plugin config exactly as before.
|
|
51
|
+
* @param options.read - the rc.1 read: unwrap the row config's volatile
|
|
52
|
+
* fields into the same resolved shape.
|
|
53
|
+
* @returns the face both arms share.
|
|
54
|
+
*/
|
|
55
|
+
export declare function settingsFace<T>(ctx: Context, options: {
|
|
56
|
+
namespace: string;
|
|
57
|
+
legacySchema: unknown;
|
|
58
|
+
legacyBase: Record<string, unknown>;
|
|
59
|
+
read: () => T;
|
|
60
|
+
}): SettingsFace<T>;
|
|
61
|
+
//# sourceMappingURL=settings-face.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dual-line settings face for the family providers: one read/watch contract
|
|
3
|
+
* over the two host settings generations, probed by capability, never by
|
|
4
|
+
* version.
|
|
5
|
+
*
|
|
6
|
+
* - 0.1.5: `settings.register(ns, schema, { base })` returns a namespace scope
|
|
7
|
+
* whose `get()` resolves schema defaults ← composition base ← user layer and
|
|
8
|
+
* whose `watch` round-trips every write. That scope IS the face.
|
|
9
|
+
* - 0.1.7-rc.1 (`SettingsForms`): the settings fields live on the provider's
|
|
10
|
+
* own plugin Config as `.volatile()` fields — writes ride the profile patch,
|
|
11
|
+
* the running fiber's Volatile references track them without a remount, and
|
|
12
|
+
* `settings/document-updated` is the change channel. `get()` unwraps the
|
|
13
|
+
* Volatile references; `watch` re-reads on a matching namespace update.
|
|
14
|
+
*
|
|
15
|
+
* The legacy arm keeps old `settings.yaml` values working untouched; on rc.1
|
|
16
|
+
* the official one-shot import moves the same-named section into the row's
|
|
17
|
+
* config, which the volatile read then serves.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Unwrap one volatile config reference (rc.1 wraps each `.volatile()` field
|
|
21
|
+
* in a `{ get() }` reference) or pass a plain value through (0.1.5's
|
|
22
|
+
* schemastery has no `.volatile()`, so the field resolves bare there).
|
|
23
|
+
* @param field - the config field as the fiber received it.
|
|
24
|
+
* @returns the plain value.
|
|
25
|
+
*/
|
|
26
|
+
export function vol(field) {
|
|
27
|
+
if (field !== null && typeof field === 'object' && typeof field.get === 'function') {
|
|
28
|
+
return field.get();
|
|
29
|
+
}
|
|
30
|
+
return field;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the provider's settings face for the host line actually serving.
|
|
34
|
+
* @param ctx - the provider's plugin context (`settings` injected).
|
|
35
|
+
* @param options.namespace - the settings namespace; identical to the plugin
|
|
36
|
+
* row id, which is what rc.1's `settings/document-updated` emits and what
|
|
37
|
+
* the rc.1 legacy import maps the old settings.yaml section onto.
|
|
38
|
+
* @param options.legacySchema - the 0.1.5 namespace schema (plain fields).
|
|
39
|
+
* @param options.legacyBase - the 0.1.5 composition base, built from the
|
|
40
|
+
* deployment's plugin config exactly as before.
|
|
41
|
+
* @param options.read - the rc.1 read: unwrap the row config's volatile
|
|
42
|
+
* fields into the same resolved shape.
|
|
43
|
+
* @returns the face both arms share.
|
|
44
|
+
*/
|
|
45
|
+
export function settingsFace(ctx, options) {
|
|
46
|
+
const legacy = ctx.get('settings');
|
|
47
|
+
if (legacy !== undefined && typeof legacy.register === 'function') {
|
|
48
|
+
return legacy.register(options.namespace, options.legacySchema, { base: options.legacyBase });
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
get: options.read,
|
|
52
|
+
watch: (listener) => {
|
|
53
|
+
const dispose = ctx.on('settings/document-updated', (ns) => {
|
|
54
|
+
if (ns !== options.namespace)
|
|
55
|
+
return;
|
|
56
|
+
listener(options.read());
|
|
57
|
+
});
|
|
58
|
+
return () => { dispose(); };
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|