dsh-codebuddy-llm 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +129 -2
- package/README.zh.md +130 -0
- package/cordis.patch.yml +38 -0
- package/index.js +1707 -0
- package/package.json +67 -4
- package/types/acp.d.ts +177 -0
- package/types/adapter.d.ts +166 -0
- package/types/config.d.ts +186 -0
- package/types/discovery.d.ts +57 -0
- package/types/index.d.ts +43 -0
- package/types/translate.d.ts +73 -0
- package/types/wire.d.ts +57 -0
package/index.js
ADDED
|
@@ -0,0 +1,1707 @@
|
|
|
1
|
+
import { LlmAdapter } from "@deepseek-ai/dsh-llm";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { accessSync, constants } from "node:fs";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
/** Largest delay a Node timer accepts; inlined so this file needs no dsh-timeout package. */
|
|
6
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
7
|
+
/**
|
|
8
|
+
* Report why the configured executable cannot be run, or undefined when it can.
|
|
9
|
+
*
|
|
10
|
+
* A host launched from a desktop shell inherits launchd's PATH rather than the
|
|
11
|
+
* one a login shell assembles, so an installed CLI is routinely unreachable
|
|
12
|
+
* under the bare name `codebuddy`. Discovery would otherwise report an empty
|
|
13
|
+
* catalog, which reads as "this route serves no models" instead of "the CLI is
|
|
14
|
+
* not installed or not on this process's PATH".
|
|
15
|
+
* @param command - configured executable, either a bare name or a path.
|
|
16
|
+
* @param env - extra environment entries the child receives over the seam's base.
|
|
17
|
+
* @returns an actionable diagnostic, or undefined when the executable resolves.
|
|
18
|
+
*/
|
|
19
|
+
function executableProblem(command, env) {
|
|
20
|
+
const runs = (candidate) => {
|
|
21
|
+
try {
|
|
22
|
+
accessSync(candidate, constants.X_OK);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
if (command.length === 0) return "the configured command is empty";
|
|
29
|
+
if (command.includes("/")) return runs(command) ? undefined : `${command} is missing or not executable`;
|
|
30
|
+
const path = env.PATH ?? process.env.PATH ?? "";
|
|
31
|
+
const searched = path.split(delimiter).filter((dir) => dir.length > 0);
|
|
32
|
+
for (const dir of searched) if (runs(join(dir, command))) return undefined;
|
|
33
|
+
return `${command} is not on this process's PATH (${searched.length === 0 ? "PATH is empty" : `${searched.length} directories searched`})`;
|
|
34
|
+
}
|
|
35
|
+
//#region lib/types/acp.js
|
|
36
|
+
/**
|
|
37
|
+
* ACP transport: one long-lived CLI child that carries every delegated run of a
|
|
38
|
+
* route, driven over the Agent Client Protocol (NDJSON on stdin and stdout).
|
|
39
|
+
*
|
|
40
|
+
* The print transport starts a child per model call, so every call pays the
|
|
41
|
+
* CLI's cold start and the answer can only be read from that one run. This
|
|
42
|
+
* transport starts the child once, handshakes, and maps each model call to a
|
|
43
|
+
* `session/prompt` on the session its conversation owns; the answer arrives as
|
|
44
|
+
* `session/update` notifications, which is also where the CLI's own thinking
|
|
45
|
+
* becomes visible instead of staying inside the child.
|
|
46
|
+
*
|
|
47
|
+
* The child is addressed by JSON-RPC framing: requests carry an id this module
|
|
48
|
+
* correlates, notifications carry a session id it routes, and a request the CLI
|
|
49
|
+
* raises for itself — a tool permission — is answered by the configured policy
|
|
50
|
+
* rather than left hanging, because an unanswered request stalls the turn.
|
|
51
|
+
*
|
|
52
|
+
* @module dsh-codebuddy-llm/acp
|
|
53
|
+
*/
|
|
54
|
+
/** Protocol revision this client speaks; the CLI answers with its own. */
|
|
55
|
+
const PROTOCOL_VERSION = 1;
|
|
56
|
+
/** A delegated turn that outlived the connection's deadline. */
|
|
57
|
+
var AcpTurnTimeoutError = class extends Error {
|
|
58
|
+
/** @param timeoutMs - the deadline the turn exceeded. */
|
|
59
|
+
constructor(timeoutMs) {
|
|
60
|
+
super(`the delegated turn exceeded its ${String(timeoutMs)}ms deadline`);
|
|
61
|
+
this.name = "AcpTurnTimeoutError";
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
/** A buffered consumer of one turn's events. */
|
|
65
|
+
var TurnBuffer = class {
|
|
66
|
+
events = [];
|
|
67
|
+
waiter;
|
|
68
|
+
ended = false;
|
|
69
|
+
failure;
|
|
70
|
+
/** Append one event and wake a waiting consumer. */
|
|
71
|
+
push(event) {
|
|
72
|
+
this.events.push(event);
|
|
73
|
+
this.wake();
|
|
74
|
+
}
|
|
75
|
+
/** End the turn without an error. */
|
|
76
|
+
end() {
|
|
77
|
+
this.ended = true;
|
|
78
|
+
this.wake();
|
|
79
|
+
}
|
|
80
|
+
/** End the turn with the failure the caller must not lose. */
|
|
81
|
+
fail(error) {
|
|
82
|
+
this.failure ??= error;
|
|
83
|
+
this.ended = true;
|
|
84
|
+
this.wake();
|
|
85
|
+
}
|
|
86
|
+
/** Wait for the next event, or undefined once the turn ended and drained. */
|
|
87
|
+
async shift() {
|
|
88
|
+
while (this.events.length === 0) {
|
|
89
|
+
if (!this.ended) {
|
|
90
|
+
await new Promise((resolve) => {
|
|
91
|
+
this.waiter = resolve;
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (this.failure !== void 0) throw this.failure;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
return this.events.shift();
|
|
99
|
+
}
|
|
100
|
+
wake() {
|
|
101
|
+
const waiter = this.waiter;
|
|
102
|
+
this.waiter = void 0;
|
|
103
|
+
waiter?.();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
/** Narrow a parsed JSON value to a plain record. */
|
|
107
|
+
function asRecord$1(value) {
|
|
108
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
/** Parent-agent text and thoughts; delegated tool output is not its answer. */
|
|
112
|
+
function updateEventOf(update) {
|
|
113
|
+
const kind = update.sessionUpdate;
|
|
114
|
+
if (kind !== "agent_message_chunk" && kind !== "agent_thought_chunk") return void 0;
|
|
115
|
+
const parentToolCallId = asRecord$1(update._meta)?.["codebuddy.ai/parentToolCallId"];
|
|
116
|
+
if (typeof parentToolCallId === "string" && parentToolCallId.length > 0) return void 0;
|
|
117
|
+
const text = asRecord$1(update.content)?.text;
|
|
118
|
+
if (typeof text !== "string" || text.length === 0) return void 0;
|
|
119
|
+
return kind === "agent_message_chunk" ? {
|
|
120
|
+
kind: "text",
|
|
121
|
+
text
|
|
122
|
+
} : {
|
|
123
|
+
kind: "thought",
|
|
124
|
+
text
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Read the catalog one `session/new` response advertises. The CLI reports the
|
|
129
|
+
* ids it accepts and the input capacity behind each of them, which is the only
|
|
130
|
+
* capacity a route that owns no endpoint can obtain.
|
|
131
|
+
* @param result - the response's result, as the CLI sent it.
|
|
132
|
+
* @returns one entry per advertised id, in the CLI's own order.
|
|
133
|
+
*/
|
|
134
|
+
function catalogOf(result) {
|
|
135
|
+
const available = asRecord$1(asRecord$1(result)?.models)?.availableModels;
|
|
136
|
+
if (!Array.isArray(available)) return [];
|
|
137
|
+
const catalog = [];
|
|
138
|
+
const seen = /* @__PURE__ */ new Set();
|
|
139
|
+
for (const entry of available) {
|
|
140
|
+
const record = asRecord$1(entry);
|
|
141
|
+
const id = record?.modelId;
|
|
142
|
+
if (typeof id !== "string" || id.length === 0 || seen.has(id)) continue;
|
|
143
|
+
seen.add(id);
|
|
144
|
+
const name = record?.name;
|
|
145
|
+
const capacity = asRecord$1(record?._meta)?.maxInputTokens;
|
|
146
|
+
catalog.push({
|
|
147
|
+
id,
|
|
148
|
+
...typeof name === "string" && name.length > 0 ? { name } : {},
|
|
149
|
+
...typeof capacity === "number" && Number.isInteger(capacity) && capacity > 0 ? { contextWindow: capacity } : {}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return catalog;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Read how one `session/prompt` response ended the turn.
|
|
156
|
+
* @param result - the response's result, as the CLI sent it.
|
|
157
|
+
* @returns the stop reason and, when the CLI reported one, its failure detail.
|
|
158
|
+
*/
|
|
159
|
+
function promptOutcomeOf(result) {
|
|
160
|
+
const record = asRecord$1(result);
|
|
161
|
+
const stopReason = record?.stopReason;
|
|
162
|
+
const detail = asRecord$1(record?._meta)?.["codebuddy.ai/errorMessage"];
|
|
163
|
+
return {
|
|
164
|
+
stopReason: typeof stopReason === "string" && stopReason.length > 0 ? stopReason : "unknown",
|
|
165
|
+
...typeof detail === "string" && detail.length > 0 ? { detail } : {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** The permission options one request offers, in the CLI's own order. */
|
|
169
|
+
function permissionOptionsOf(params) {
|
|
170
|
+
const options = params.options;
|
|
171
|
+
if (!Array.isArray(options)) return [];
|
|
172
|
+
const parsed = [];
|
|
173
|
+
for (const option of options) {
|
|
174
|
+
const record = asRecord$1(option);
|
|
175
|
+
if (record === void 0) continue;
|
|
176
|
+
const id = record.optionId ?? record.id;
|
|
177
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
178
|
+
parsed.push({
|
|
179
|
+
id,
|
|
180
|
+
kind: typeof record.kind === "string" ? record.kind : ""
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return parsed;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* One route's long-lived ACP connection.
|
|
187
|
+
*
|
|
188
|
+
* A transport is started lazily on first use and lives until {@link dispose};
|
|
189
|
+
* sessions are created per harness conversation and reused across its calls,
|
|
190
|
+
* so a second call to the same conversation pays neither a cold start nor a
|
|
191
|
+
* handshake.
|
|
192
|
+
*/
|
|
193
|
+
var AcpTransport = class {
|
|
194
|
+
options;
|
|
195
|
+
child;
|
|
196
|
+
starting;
|
|
197
|
+
buffer = "";
|
|
198
|
+
nextId = 1;
|
|
199
|
+
disposed = false;
|
|
200
|
+
pending = /* @__PURE__ */ new Map();
|
|
201
|
+
turns = /* @__PURE__ */ new Map();
|
|
202
|
+
sessions = /* @__PURE__ */ new Map();
|
|
203
|
+
constructor(options) {
|
|
204
|
+
this.options = options;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The ACP session one conversation is bound to, opened on first use.
|
|
208
|
+
*
|
|
209
|
+
* A conversation this child already serves keeps its session. Otherwise the
|
|
210
|
+
* caller's `adopt` id — the session a previous child created for the same
|
|
211
|
+
* conversation — is loaded through `session/load`, which is what keeps the
|
|
212
|
+
* CLI's own history across a restarted harness or a replaced child. A failed
|
|
213
|
+
* load (the CLI forgot it, or does not support loading) falls back to a fresh
|
|
214
|
+
* session, and the returned lease says so, which is the caller's signal that
|
|
215
|
+
* this call must carry the conversation itself.
|
|
216
|
+
*
|
|
217
|
+
* @param key - the harness conversation this session belongs to.
|
|
218
|
+
* @param cwd - workspace the session runs in.
|
|
219
|
+
* @param adopt - CLI session id a previous child created for this conversation.
|
|
220
|
+
* @returns the session id and whether this child created it.
|
|
221
|
+
*/
|
|
222
|
+
async openSession(key, cwd, adopt) {
|
|
223
|
+
const existing = this.sessions.get(key);
|
|
224
|
+
if (existing !== void 0) return {
|
|
225
|
+
sessionId: existing,
|
|
226
|
+
created: false
|
|
227
|
+
};
|
|
228
|
+
await this.ensureStarted();
|
|
229
|
+
if (adopt !== void 0 && await this.loadSession(cwd, adopt)) {
|
|
230
|
+
this.sessions.set(key, adopt);
|
|
231
|
+
return {
|
|
232
|
+
sessionId: adopt,
|
|
233
|
+
created: false
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const sessionId = asRecord$1(await this.request("session/new", {
|
|
237
|
+
cwd,
|
|
238
|
+
mcpServers: []
|
|
239
|
+
}))?.sessionId;
|
|
240
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("llm-cli: the CLI created no session for this conversation");
|
|
241
|
+
this.sessions.set(key, sessionId);
|
|
242
|
+
return {
|
|
243
|
+
sessionId,
|
|
244
|
+
created: true
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Read the model catalog this CLI advertises, by opening the session whose
|
|
249
|
+
* answer carries it. The connection is the caller's to dispose afterwards: a
|
|
250
|
+
* discovery probe wants the catalog and nothing of the session's history.
|
|
251
|
+
* @param cwd - workspace the probe session runs in.
|
|
252
|
+
* @returns one entry per advertised model, with the capacity it discloses.
|
|
253
|
+
*/
|
|
254
|
+
async describeModels(cwd) {
|
|
255
|
+
await this.ensureStarted();
|
|
256
|
+
return catalogOf(await this.request("session/new", {
|
|
257
|
+
cwd,
|
|
258
|
+
mcpServers: []
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Ask the child to resume a session a previous child created.
|
|
263
|
+
* @param cwd - workspace the session runs in.
|
|
264
|
+
* @param sessionId - CLI session id to load.
|
|
265
|
+
* @returns whether the child resumed it; a refusal is not this call's failure.
|
|
266
|
+
*/
|
|
267
|
+
async loadSession(cwd, sessionId) {
|
|
268
|
+
try {
|
|
269
|
+
await this.request("session/load", {
|
|
270
|
+
sessionId,
|
|
271
|
+
cwd,
|
|
272
|
+
mcpServers: []
|
|
273
|
+
});
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Run one model call as a prompt on the session that owns the conversation.
|
|
281
|
+
* @param sessionId - the ACP session from {@link openSession}.
|
|
282
|
+
* @param text - the prompt text this call carries.
|
|
283
|
+
* @param signal - caller cancellation, forwarded as `session/cancel`.
|
|
284
|
+
* @returns the answer's incremental events, and how the CLI ended the turn
|
|
285
|
+
* once they are drained — `cancelled` and `refusal` are outcomes, not errors.
|
|
286
|
+
*/
|
|
287
|
+
async *prompt(sessionId, text, signal) {
|
|
288
|
+
await this.ensureStarted();
|
|
289
|
+
if (signal?.aborted === true) throw new Error("llm-cli: the request was aborted before the turn started");
|
|
290
|
+
const turn = new TurnBuffer();
|
|
291
|
+
this.turns.set(sessionId, turn);
|
|
292
|
+
const cancel = () => {
|
|
293
|
+
this.notify("session/cancel", { sessionId });
|
|
294
|
+
};
|
|
295
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
296
|
+
const deadline = this.options.turnTimeoutMs;
|
|
297
|
+
const expiry = deadline === void 0 || deadline <= 0 ? void 0 : setTimeout(() => {
|
|
298
|
+
this.notify("session/cancel", { sessionId });
|
|
299
|
+
turn.fail(new AcpTurnTimeoutError(deadline));
|
|
300
|
+
}, deadline);
|
|
301
|
+
expiry?.unref();
|
|
302
|
+
let outcome;
|
|
303
|
+
const response = this.request("session/prompt", {
|
|
304
|
+
sessionId,
|
|
305
|
+
prompt: [{
|
|
306
|
+
type: "text",
|
|
307
|
+
text
|
|
308
|
+
}]
|
|
309
|
+
}).then((result) => {
|
|
310
|
+
outcome = promptOutcomeOf(result);
|
|
311
|
+
turn.end();
|
|
312
|
+
}).catch((error) => {
|
|
313
|
+
turn.fail(error instanceof Error ? error : new Error(String(error)));
|
|
314
|
+
});
|
|
315
|
+
try {
|
|
316
|
+
for (;;) {
|
|
317
|
+
const event = await turn.shift();
|
|
318
|
+
if (event === void 0) break;
|
|
319
|
+
yield event;
|
|
320
|
+
}
|
|
321
|
+
await response;
|
|
322
|
+
return outcome ?? { stopReason: "unknown" };
|
|
323
|
+
} finally {
|
|
324
|
+
if (expiry !== void 0) clearTimeout(expiry);
|
|
325
|
+
signal?.removeEventListener("abort", cancel);
|
|
326
|
+
if (this.turns.get(sessionId) === turn) this.turns.delete(sessionId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* End the connection and the child behind it.
|
|
331
|
+
* @returns once the child's process tree is gone.
|
|
332
|
+
*/
|
|
333
|
+
async dispose() {
|
|
334
|
+
if (this.disposed) return;
|
|
335
|
+
this.disposed = true;
|
|
336
|
+
const child = this.child;
|
|
337
|
+
this.child = void 0;
|
|
338
|
+
this.failAll(/* @__PURE__ */ new Error("llm-cli: the ACP connection was disposed"));
|
|
339
|
+
if (child === void 0) return;
|
|
340
|
+
child.terminate();
|
|
341
|
+
await child.done.catch(() => void 0);
|
|
342
|
+
}
|
|
343
|
+
/** Start the child and handshake once; a second caller joins the first attempt. */
|
|
344
|
+
async ensureStarted() {
|
|
345
|
+
if (this.disposed) throw new Error("llm-cli: the ACP connection was disposed");
|
|
346
|
+
this.starting ??= this.start();
|
|
347
|
+
await this.starting;
|
|
348
|
+
}
|
|
349
|
+
async start() {
|
|
350
|
+
const { argv, cwd, graceMs, env, spawn } = this.options;
|
|
351
|
+
const child = spawn({
|
|
352
|
+
argv: [...argv],
|
|
353
|
+
cwd,
|
|
354
|
+
stdio: {
|
|
355
|
+
stdin: "pipe",
|
|
356
|
+
stdout: "pipe",
|
|
357
|
+
stderr: "inherit"
|
|
358
|
+
},
|
|
359
|
+
graceMs,
|
|
360
|
+
env
|
|
361
|
+
});
|
|
362
|
+
this.child = child;
|
|
363
|
+
const stdout = child.stdout;
|
|
364
|
+
if (stdout === void 0) throw new Error("llm-cli: the ACP child has no stdout to read from");
|
|
365
|
+
stdout.on("data", (data) => {
|
|
366
|
+
this.buffer += data.toString("utf8");
|
|
367
|
+
for (;;) {
|
|
368
|
+
const newline = this.buffer.indexOf("\n");
|
|
369
|
+
if (newline === -1) break;
|
|
370
|
+
const line = this.buffer.slice(0, newline);
|
|
371
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
372
|
+
this.handleLine(line);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
child.done.then(() => {
|
|
376
|
+
this.failAll(/* @__PURE__ */ new Error("llm-cli: the ACP child exited"));
|
|
377
|
+
}, (error) => {
|
|
378
|
+
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
|
379
|
+
});
|
|
380
|
+
await this.request("initialize", {
|
|
381
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
382
|
+
clientCapabilities: { fs: {
|
|
383
|
+
readTextFile: false,
|
|
384
|
+
writeTextFile: false
|
|
385
|
+
} }
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/** Route one parsed line: a response, a turn update, or a request to answer. */
|
|
389
|
+
handleLine(line) {
|
|
390
|
+
const trimmed = line.trim();
|
|
391
|
+
if (trimmed.length === 0) return;
|
|
392
|
+
let parsed;
|
|
393
|
+
try {
|
|
394
|
+
parsed = JSON.parse(trimmed);
|
|
395
|
+
} catch {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const message = asRecord$1(parsed);
|
|
399
|
+
if (message === void 0) return;
|
|
400
|
+
const id = message.id;
|
|
401
|
+
if (typeof id === "number" && (message.result !== void 0 || message.error !== void 0)) {
|
|
402
|
+
const pending = this.pending.get(id);
|
|
403
|
+
if (pending === void 0) return;
|
|
404
|
+
this.pending.delete(id);
|
|
405
|
+
if (message.error !== void 0) {
|
|
406
|
+
const detail = asRecord$1(message.error)?.message;
|
|
407
|
+
pending.reject(new Error(typeof detail === "string" ? detail : "llm-cli: the CLI refused a request"));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
pending.resolve(message.result);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const params = asRecord$1(message.params);
|
|
414
|
+
if (message.method === "session/update" && params !== void 0) {
|
|
415
|
+
const sessionId = params.sessionId;
|
|
416
|
+
const update = asRecord$1(params.update);
|
|
417
|
+
if (typeof sessionId !== "string" || update === void 0) return;
|
|
418
|
+
const event = updateEventOf(update);
|
|
419
|
+
if (event !== void 0) this.turns.get(sessionId)?.push(event);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (typeof id === "number" && typeof message.method === "string") this.answerRequest(id, message.method, params ?? {});
|
|
423
|
+
}
|
|
424
|
+
/** Answer one request the CLI raised, so the turn it belongs to can continue. */
|
|
425
|
+
answerRequest(id, method, params) {
|
|
426
|
+
if (method === "session/request_permission") {
|
|
427
|
+
const selected = this.options.selectPermission?.(permissionOptionsOf(params));
|
|
428
|
+
this.write({
|
|
429
|
+
jsonrpc: "2.0",
|
|
430
|
+
id,
|
|
431
|
+
result: selected === void 0 ? { outcome: { outcome: "cancelled" } } : { outcome: {
|
|
432
|
+
outcome: "selected",
|
|
433
|
+
optionId: selected
|
|
434
|
+
} }
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.write({
|
|
439
|
+
jsonrpc: "2.0",
|
|
440
|
+
id,
|
|
441
|
+
error: {
|
|
442
|
+
code: -32601,
|
|
443
|
+
message: `unsupported request ${method}`
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** Send one request and await its response. */
|
|
448
|
+
async request(method, params) {
|
|
449
|
+
if (this.disposed) throw new Error("llm-cli: the ACP connection was disposed");
|
|
450
|
+
const id = this.nextId;
|
|
451
|
+
this.nextId += 1;
|
|
452
|
+
const answered = new Promise((resolve, reject) => {
|
|
453
|
+
this.pending.set(id, {
|
|
454
|
+
resolve,
|
|
455
|
+
reject
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
this.write({
|
|
459
|
+
jsonrpc: "2.0",
|
|
460
|
+
id,
|
|
461
|
+
method,
|
|
462
|
+
params
|
|
463
|
+
});
|
|
464
|
+
return answered;
|
|
465
|
+
}
|
|
466
|
+
/** Send one notification, which expects no answer. */
|
|
467
|
+
notify(method, params) {
|
|
468
|
+
if (this.disposed) return;
|
|
469
|
+
this.write({
|
|
470
|
+
jsonrpc: "2.0",
|
|
471
|
+
method,
|
|
472
|
+
params
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
write(message) {
|
|
476
|
+
this.child?.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
477
|
+
}
|
|
478
|
+
/** Fail every waiter: the child is gone, so no request can be answered. */
|
|
479
|
+
failAll(error) {
|
|
480
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
481
|
+
this.pending.clear();
|
|
482
|
+
for (const turn of this.turns.values()) turn.fail(error);
|
|
483
|
+
this.turns.clear();
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region lib/types/wire.js
|
|
488
|
+
/**
|
|
489
|
+
* Line-protocol parsing for a CLI child's stream-json output. CodeBuddy-style
|
|
490
|
+
* CLIs emit one JSON object per line; this parser owns line framing, the event
|
|
491
|
+
* vocabulary it understands, and the text-delta computation.
|
|
492
|
+
*
|
|
493
|
+
* Two line families carry answer text, and a run may produce either or both:
|
|
494
|
+
* `stream_event` frames wrap the model request's own SSE events when the child
|
|
495
|
+
* is asked for partial messages, so text arrives while it is still being
|
|
496
|
+
* generated; and the completed `assistant` frame repeats the whole message once
|
|
497
|
+
* the message ends. Both are measured against the text already observed *for
|
|
498
|
+
* the message in progress*, which is why `message_start` is reported rather
|
|
499
|
+
* than ignored — it is the only boundary a caller has for resetting that
|
|
500
|
+
* measurement. A frame adding nothing beyond the text already seen is ignored,
|
|
501
|
+
* so a run reading both families emits each character once.
|
|
502
|
+
*
|
|
503
|
+
* Unknown events are ignored: the event vocabulary is merge-extensible and this
|
|
504
|
+
* adapter only consumes what it maps.
|
|
505
|
+
*
|
|
506
|
+
* @module dsh-codebuddy-llm/wire
|
|
507
|
+
*/
|
|
508
|
+
/** Narrow a parsed JSON value to a plain record. */
|
|
509
|
+
function asRecord(value) {
|
|
510
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
511
|
+
return value;
|
|
512
|
+
}
|
|
513
|
+
/** Parse one line as JSON; a non-JSON line is a diagnostic outside the protocol. */
|
|
514
|
+
function parseJson(text) {
|
|
515
|
+
try {
|
|
516
|
+
return JSON.parse(text);
|
|
517
|
+
} catch {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
/** Extract the concatenated text from an assistant message content array. */
|
|
522
|
+
function messageText(record) {
|
|
523
|
+
const message = asRecord(record.message);
|
|
524
|
+
if (message === void 0) return void 0;
|
|
525
|
+
const content = message.content;
|
|
526
|
+
if (!Array.isArray(content)) return void 0;
|
|
527
|
+
const parts = [];
|
|
528
|
+
for (const block of content) {
|
|
529
|
+
const item = asRecord(block);
|
|
530
|
+
if (item !== void 0 && item.type === "text" && typeof item.text === "string") parts.push(item.text);
|
|
531
|
+
}
|
|
532
|
+
return parts.join("");
|
|
533
|
+
}
|
|
534
|
+
/** Extract a complete {@link TokenUsage} from a result event, when it carries one. */
|
|
535
|
+
function extractUsage(record) {
|
|
536
|
+
const usage = asRecord(record.usage);
|
|
537
|
+
if (usage === void 0) return void 0;
|
|
538
|
+
const inputTokens = usage.input_tokens;
|
|
539
|
+
const outputTokens = usage.output_tokens;
|
|
540
|
+
if (typeof inputTokens !== "number" || typeof outputTokens !== "number") return void 0;
|
|
541
|
+
return {
|
|
542
|
+
inputTokens,
|
|
543
|
+
outputTokens
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/** Describe a failed result event, from its own detail or from its subtype. */
|
|
547
|
+
function failureOf(record, subtype) {
|
|
548
|
+
return {
|
|
549
|
+
message: typeof record.result === "string" && record.result.length > 0 ? record.result : subtype.length > 0 ? subtype : "unknown CLI failure",
|
|
550
|
+
code: "CLI_RUN_FAILED"
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** Read the terminal a result event reports. */
|
|
554
|
+
function terminalOf(record) {
|
|
555
|
+
const subtype = typeof record.subtype === "string" ? record.subtype : "";
|
|
556
|
+
if (record.is_error === true) return subtype.includes("max_turns") || subtype.includes("max-turns") ? { kind: "max-turns" } : {
|
|
557
|
+
kind: "error",
|
|
558
|
+
failure: failureOf(record, subtype)
|
|
559
|
+
};
|
|
560
|
+
const sessionId = typeof record.session_id === "string" && record.session_id.length > 0 ? record.session_id : void 0;
|
|
561
|
+
const usage = extractUsage(record);
|
|
562
|
+
return {
|
|
563
|
+
kind: "stop",
|
|
564
|
+
...sessionId === void 0 ? {} : { sessionId },
|
|
565
|
+
...usage === void 0 ? {} : { usage }
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Read the SSE event a partial-message line wraps.
|
|
570
|
+
* @param record - the parsed stream-json line.
|
|
571
|
+
* @returns the wrapped event, or undefined when the line is not one.
|
|
572
|
+
*/
|
|
573
|
+
function streamEventOf(record) {
|
|
574
|
+
if (record.type !== "stream_event") return void 0;
|
|
575
|
+
return asRecord(record.event);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Read the text one SSE event contributes to the message being generated.
|
|
579
|
+
* @param event - the wrapped SSE event.
|
|
580
|
+
* @returns the incremental text, or undefined for any event that carries none.
|
|
581
|
+
*/
|
|
582
|
+
function partialTextOf(event) {
|
|
583
|
+
if (event.type !== "content_block_delta") return void 0;
|
|
584
|
+
const delta = asRecord(event.delta);
|
|
585
|
+
if (delta === void 0 || delta.type !== "text_delta") return void 0;
|
|
586
|
+
const text = delta.text;
|
|
587
|
+
return typeof text === "string" && text.length > 0 ? text : void 0;
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Parse one line of CLI stream-json output.
|
|
591
|
+
* @param line - one raw stdout line (already stripped of its newline).
|
|
592
|
+
* @param lastText - the text observed so far for the message in progress; a delta is measured against it.
|
|
593
|
+
* @returns the event this line contributes, or undefined when the line is blank.
|
|
594
|
+
*/
|
|
595
|
+
function parseCliLine(line, lastText) {
|
|
596
|
+
const trimmed = line.trim();
|
|
597
|
+
if (trimmed.length === 0) return void 0;
|
|
598
|
+
const record = asRecord(parseJson(trimmed));
|
|
599
|
+
if (record === void 0) return { kind: "ignored" };
|
|
600
|
+
const streamEvent = streamEventOf(record);
|
|
601
|
+
if (streamEvent !== void 0) {
|
|
602
|
+
if (streamEvent.type === "message_start") return { kind: "message-start" };
|
|
603
|
+
const text = partialTextOf(streamEvent);
|
|
604
|
+
return text === void 0 ? { kind: "ignored" } : {
|
|
605
|
+
kind: "text",
|
|
606
|
+
delta: { text }
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
if (record.type === "assistant") {
|
|
610
|
+
const full = messageText(record);
|
|
611
|
+
if (full === void 0 || full.length <= lastText.length) return { kind: "ignored" };
|
|
612
|
+
return {
|
|
613
|
+
kind: "text",
|
|
614
|
+
delta: { text: full.slice(lastText.length) }
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (record.type === "result") return {
|
|
618
|
+
kind: "terminal",
|
|
619
|
+
terminal: terminalOf(record)
|
|
620
|
+
};
|
|
621
|
+
return { kind: "ignored" };
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region lib/types/translate.js
|
|
625
|
+
/**
|
|
626
|
+
* Message translation for the CLI adapter: turns harness conversation content
|
|
627
|
+
* into the prompt text one CLI child run consumes, and lifts the system prompt
|
|
628
|
+
* out of the history into the CLI's own system-prompt slot.
|
|
629
|
+
*
|
|
630
|
+
* The delegated CLI runs its own agentic loop with its own tools, so no harness
|
|
631
|
+
* tool vocabulary crosses the boundary; only human-authored user text and prior
|
|
632
|
+
* assistant answers do, and the CLI's own session (keyed by the harness session
|
|
633
|
+
* id) owns its tool history.
|
|
634
|
+
*
|
|
635
|
+
* @module dsh-codebuddy-llm/translate
|
|
636
|
+
*/
|
|
637
|
+
/**
|
|
638
|
+
* Flatten one message's text content into consecutive lines.
|
|
639
|
+
* @param message - the harness message to flatten.
|
|
640
|
+
* @returns its text blocks, in order.
|
|
641
|
+
*/
|
|
642
|
+
function textBlocksOf(message) {
|
|
643
|
+
const texts = [];
|
|
644
|
+
for (const block of message.content) if (block.type === "text") texts.push(block.text);
|
|
645
|
+
return texts;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* The rendered system prompt a request carries.
|
|
649
|
+
*
|
|
650
|
+
* Two request shapes reach an adapter, and they carry the prompt differently: a
|
|
651
|
+
* loop-built request passes its derived history, whose leading system-role
|
|
652
|
+
* message is the prompt, and leaves `GenerateOptions.system` undefined; a
|
|
653
|
+
* hand-built one-shot may fill that field instead. Reading both — preferring
|
|
654
|
+
* the explicit field — is what keeps the prompt from being silently dropped on
|
|
655
|
+
* either path. System blocks are concatenated without a separator, matching the
|
|
656
|
+
* projection `dsh-llm-deepseek` applies to the same history so both routes read
|
|
657
|
+
* one session alike.
|
|
658
|
+
* @param messages - ordered conversation messages.
|
|
659
|
+
* @param system - the request's explicit system slot, when it has one.
|
|
660
|
+
* @returns the system prompt text, or undefined when the request carries none.
|
|
661
|
+
*/
|
|
662
|
+
function systemTextOf(messages, system) {
|
|
663
|
+
if (system !== void 0 && system.length > 0) return system;
|
|
664
|
+
for (const message of messages) {
|
|
665
|
+
if (message.role !== "system") continue;
|
|
666
|
+
const text = textBlocksOf(message).join("");
|
|
667
|
+
if (text.length > 0) return text;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Render the conversation as role-prefixed lines, in order.
|
|
672
|
+
*
|
|
673
|
+
* Only human-visible conversation crosses: user text and prior assistant
|
|
674
|
+
* answers. System-role messages are excluded because {@link systemTextOf} owns
|
|
675
|
+
* the system prompt, and developer and tool-role content is excluded because
|
|
676
|
+
* the delegated CLI owns its tool loop — forwarding it would present harness
|
|
677
|
+
* tool scaffolding to a second agent as if it were conversation.
|
|
678
|
+
*
|
|
679
|
+
* @param messages - ordered conversation messages.
|
|
680
|
+
* @returns one part per text block, in conversation order.
|
|
681
|
+
*/
|
|
682
|
+
function conversationParts(messages) {
|
|
683
|
+
const parts = [];
|
|
684
|
+
for (const message of messages) {
|
|
685
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
686
|
+
for (const text of textBlocksOf(message)) {
|
|
687
|
+
if (text.trim().length === 0) continue;
|
|
688
|
+
parts.push({
|
|
689
|
+
text: message.role === "assistant" ? `Assistant: ${text}` : `User: ${text}`,
|
|
690
|
+
human: message.role === "user" && message.source?.kind === "user"
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return parts;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Flatten the harness conversation into one prompt text for a stateless call.
|
|
698
|
+
*
|
|
699
|
+
* Plugin-injected user-role context is user-role conversation and therefore
|
|
700
|
+
* *is* forwarded here: a stateless call has no CLI-side history, so dropping it
|
|
701
|
+
* would hand the CLI a different conversation than the harness model would have
|
|
702
|
+
* seen. That is the opposite of {@link trailingUserText}, where the same
|
|
703
|
+
* injected text must not displace the human's newest input.
|
|
704
|
+
*
|
|
705
|
+
* @param messages - ordered conversation messages.
|
|
706
|
+
* @returns the complete prompt, or `undefined` when nothing text-worthy exists.
|
|
707
|
+
*/
|
|
708
|
+
function flattenConversation(messages) {
|
|
709
|
+
const parts = conversationParts(messages);
|
|
710
|
+
if (parts.length === 0) return void 0;
|
|
711
|
+
return parts.map((part) => part.text).join("\n\n");
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* The conversation as it stood before the newest human input, for a delegated
|
|
715
|
+
* CLI that owns no history of it.
|
|
716
|
+
*
|
|
717
|
+
* The ACP transport keeps the CLI's own session alive, so an ongoing
|
|
718
|
+
* conversation normally never needs this. It is the fallback for the cases
|
|
719
|
+
* where that session is gone — a restarted harness, a replaced child, or a CLI
|
|
720
|
+
* that refused to resume one — and the CLI would otherwise answer the newest
|
|
721
|
+
* message knowing nothing that came before it.
|
|
722
|
+
*
|
|
723
|
+
* @param messages - ordered conversation messages.
|
|
724
|
+
* @returns the prior turns, or `undefined` when only the newest input exists.
|
|
725
|
+
*/
|
|
726
|
+
function conversationRecap(messages) {
|
|
727
|
+
const parts = conversationParts(messages);
|
|
728
|
+
const newest = parts.findLastIndex((part) => part.human);
|
|
729
|
+
const recap = newest === -1 ? parts : parts.slice(0, newest);
|
|
730
|
+
if (recap.length === 0) return void 0;
|
|
731
|
+
return recap.map((part) => part.text).join("\n\n");
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Extract the trailing human-authored user text of a conversation. A
|
|
735
|
+
* persistent session forwards only the newest human input, because the CLI
|
|
736
|
+
* already knows the history under the session id. Plugin context also uses the
|
|
737
|
+
* user role, so the message source — not the role alone — is what distinguishes
|
|
738
|
+
* the human's words from an injected context message that must not replace them.
|
|
739
|
+
* @param messages - ordered conversation messages.
|
|
740
|
+
* @returns the last human-authored user text, or `undefined` when none exists.
|
|
741
|
+
*/
|
|
742
|
+
function trailingUserText(messages) {
|
|
743
|
+
let found;
|
|
744
|
+
for (const message of messages) {
|
|
745
|
+
if (message.role !== "user" || message.source?.kind !== "user") continue;
|
|
746
|
+
const texts = textBlocksOf(message).filter((text) => text.trim().length > 0);
|
|
747
|
+
if (texts.length > 0) found = texts.join("\n");
|
|
748
|
+
}
|
|
749
|
+
return found;
|
|
750
|
+
}
|
|
751
|
+
//#endregion
|
|
752
|
+
//#region lib/types/adapter.js
|
|
753
|
+
/**
|
|
754
|
+
* `CliAdapter`: the delegating LLM adapter that answers one model call by
|
|
755
|
+
* running one CLI child process and translating its stream-json output into
|
|
756
|
+
* harness `StreamChunk`s. The configured CLI (CodeBuddy by default) carries its
|
|
757
|
+
* own authentication and runs its own agentic loop with its own tools, so the
|
|
758
|
+
* harness forwards conversation text and receives the final answer: no API key,
|
|
759
|
+
* no harness-side tool loop, and no harness tool vocabulary on the wire.
|
|
760
|
+
*
|
|
761
|
+
* The adapter is transport-only. Executable facts arrive through a thunk
|
|
762
|
+
* resolved once per operation, and the registering plugin owns validation,
|
|
763
|
+
* defaults, and configuration — so a settings change reaches the next call
|
|
764
|
+
* without re-registering this route.
|
|
765
|
+
*
|
|
766
|
+
* @module dsh-codebuddy-llm/adapter
|
|
767
|
+
*/
|
|
768
|
+
/** The CLI permission modes its non-interactive print command accepts. */
|
|
769
|
+
const CODEBUDDY_PERMISSION_MODES = [
|
|
770
|
+
"acceptEdits",
|
|
771
|
+
"bypassPermissions",
|
|
772
|
+
"default",
|
|
773
|
+
"plan",
|
|
774
|
+
"dontAsk",
|
|
775
|
+
"auto"
|
|
776
|
+
];
|
|
777
|
+
/** The reasoning effort levels the CLI's own flag accepts. */
|
|
778
|
+
const CODEBUDDY_EFFORT_LEVELS = [
|
|
779
|
+
"minimal",
|
|
780
|
+
"low",
|
|
781
|
+
"medium",
|
|
782
|
+
"high",
|
|
783
|
+
"xhigh",
|
|
784
|
+
"max"
|
|
785
|
+
];
|
|
786
|
+
/** The ways this route can reach the CLI. */
|
|
787
|
+
const CLI_TRANSPORTS = ["print", "acp"];
|
|
788
|
+
/** Transport a deployment gets without asking for one. */
|
|
789
|
+
const DEFAULT_TRANSPORT = "print";
|
|
790
|
+
/** Failure code reported when the child cannot be started or breaks its protocol. */
|
|
791
|
+
const CLI_START_FAILED_CODE = "CLI_START_FAILED";
|
|
792
|
+
/** Failure code reported for a turn the CLI cancelled on its own. */
|
|
793
|
+
const CLI_TURN_CANCELLED_CODE = "CLI_TURN_CANCELLED";
|
|
794
|
+
/** Failure code reported for a turn the CLI refused to answer. */
|
|
795
|
+
const CLI_TURN_REFUSED_CODE = "CLI_TURN_REFUSED";
|
|
796
|
+
/** Failure code reported for a turn that ended for a reason this route cannot name. */
|
|
797
|
+
const CLI_TURN_UNKNOWN_CODE = "CLI_TURN_FAILED";
|
|
798
|
+
/** Failure code reported for a delegated turn that outlived its deadline. */
|
|
799
|
+
const CLI_TURN_TIMEOUT_CODE = "CLI_TURN_TIMEOUT";
|
|
800
|
+
/** Render one terminal failure chunk, classified as aborted when the signal fired. */
|
|
801
|
+
function failureChunk(message, code, signal) {
|
|
802
|
+
return {
|
|
803
|
+
type: "finish",
|
|
804
|
+
reason: signal?.aborted === true ? {
|
|
805
|
+
kind: "aborted",
|
|
806
|
+
failure: {
|
|
807
|
+
message,
|
|
808
|
+
code
|
|
809
|
+
}
|
|
810
|
+
} : {
|
|
811
|
+
kind: "error",
|
|
812
|
+
failure: {
|
|
813
|
+
message,
|
|
814
|
+
code
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* A single-consumer handoff between the stdout callbacks that produce chunks and
|
|
821
|
+
* the generator that yields them. Closing it ends consumption once drained, so
|
|
822
|
+
* a terminal event or a stream end settles the generator without the callbacks
|
|
823
|
+
* knowing about it.
|
|
824
|
+
*/
|
|
825
|
+
var ChunkQueue = class {
|
|
826
|
+
items = [];
|
|
827
|
+
waiter;
|
|
828
|
+
closed = false;
|
|
829
|
+
/** Append chunks and wake a waiting consumer. */
|
|
830
|
+
push(...chunks) {
|
|
831
|
+
this.items.push(...chunks);
|
|
832
|
+
this.wake();
|
|
833
|
+
}
|
|
834
|
+
/** Close the queue; the consumer ends once every queued chunk is drained. */
|
|
835
|
+
close() {
|
|
836
|
+
this.closed = true;
|
|
837
|
+
this.wake();
|
|
838
|
+
}
|
|
839
|
+
/** @returns the next queued chunk, or undefined once closed and drained. */
|
|
840
|
+
async next() {
|
|
841
|
+
while (this.items.length === 0) {
|
|
842
|
+
if (this.closed) return void 0;
|
|
843
|
+
await new Promise((resolve) => {
|
|
844
|
+
this.waiter = resolve;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return this.items.shift();
|
|
848
|
+
}
|
|
849
|
+
wake() {
|
|
850
|
+
const waiter = this.waiter;
|
|
851
|
+
this.waiter = void 0;
|
|
852
|
+
waiter?.();
|
|
853
|
+
}
|
|
854
|
+
};
|
|
855
|
+
/**
|
|
856
|
+
* Delegating CLI adapter: one provider route whose model ids name the CLI's own
|
|
857
|
+
* model selection. Its default transport spawns a child per call, hands it the
|
|
858
|
+
* prompt as its final positional argument, and consumes stream-json on stdout;
|
|
859
|
+
* the ACP transport keeps one child per route and prompts a session on it, so
|
|
860
|
+
* the CLI's cold start is paid once per route instead of once per call.
|
|
861
|
+
*/
|
|
862
|
+
var CliAdapter = class extends LlmAdapter {
|
|
863
|
+
options;
|
|
864
|
+
spawn;
|
|
865
|
+
resolveSessionCwd;
|
|
866
|
+
/**
|
|
867
|
+
* The long-lived ACP connection of each workspace, replaced when what starts
|
|
868
|
+
* its child changes. The CLI takes its workspace from the directory the child
|
|
869
|
+
* starts in rather than from `session/new`, so a workspace needs its own child.
|
|
870
|
+
*/
|
|
871
|
+
acp = /* @__PURE__ */ new Map();
|
|
872
|
+
/**
|
|
873
|
+
* CLI sessions this route opened, by harness conversation. Held here rather
|
|
874
|
+
* than in the transport because a replaced child must be able to resume them:
|
|
875
|
+
* the id names a session in the CLI's own store, not in one child.
|
|
876
|
+
*/
|
|
877
|
+
acpSessions = /* @__PURE__ */ new Map();
|
|
878
|
+
/** Numbers the throwaway sessions a stateless call gets. */
|
|
879
|
+
statelessCalls = 0;
|
|
880
|
+
constructor(options) {
|
|
881
|
+
super();
|
|
882
|
+
this.options = options.options;
|
|
883
|
+
this.spawn = options.spawn;
|
|
884
|
+
this.resolveSessionCwd = options.resolveSessionCwd ?? (() => void 0);
|
|
885
|
+
}
|
|
886
|
+
providerInfo(provider) {
|
|
887
|
+
return {
|
|
888
|
+
id: provider,
|
|
889
|
+
name: provider
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
listModels(provider) {
|
|
893
|
+
return Promise.resolve(this.options().models.map((model) => ({
|
|
894
|
+
provider,
|
|
895
|
+
id: model.id,
|
|
896
|
+
name: model.name ?? model.id,
|
|
897
|
+
inputModalities: ["text"]
|
|
898
|
+
})));
|
|
899
|
+
}
|
|
900
|
+
resolveModel(provider, model) {
|
|
901
|
+
const entry = this.options().models.find((candidate) => candidate.id === model);
|
|
902
|
+
const contextWindow = entry?.contextWindow;
|
|
903
|
+
return Promise.resolve({
|
|
904
|
+
provider,
|
|
905
|
+
id: model,
|
|
906
|
+
name: entry?.name ?? model,
|
|
907
|
+
...contextWindow === void 0 ? {} : { context: { contextWindow } }
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
async *stream(options) {
|
|
911
|
+
const facts = this.options();
|
|
912
|
+
const signal = options.signal;
|
|
913
|
+
if (signal?.aborted === true) {
|
|
914
|
+
yield failureChunk("llm-cli: the request was aborted before the CLI child started", "ABORTED", signal);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const sessionId = options.sessionId;
|
|
918
|
+
const prompt = sessionId === void 0 ? flattenConversation(options.messages) : trailingUserText(options.messages);
|
|
919
|
+
if (prompt === void 0) {
|
|
920
|
+
yield failureChunk("llm-cli: the request carries no text the CLI can consume", "INVALID_REQUEST");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const argv = [
|
|
924
|
+
...facts.argv,
|
|
925
|
+
"--permission-mode",
|
|
926
|
+
facts.permissionMode,
|
|
927
|
+
"--model",
|
|
928
|
+
options.model
|
|
929
|
+
];
|
|
930
|
+
if (facts.tools !== void 0) argv.push("--tools", facts.tools);
|
|
931
|
+
if (facts.maxTurns !== void 0) argv.push("--max-turns", String(facts.maxTurns));
|
|
932
|
+
if (facts.effort !== void 0) argv.push("--effort", facts.effort);
|
|
933
|
+
const system = systemTextOf(options.messages, options.system);
|
|
934
|
+
if (system !== void 0) argv.push("--append-system-prompt", system);
|
|
935
|
+
if (sessionId !== void 0 && facts.sessionIdArg !== void 0) argv.push(facts.sessionIdArg, sessionId);
|
|
936
|
+
argv.push(prompt);
|
|
937
|
+
const cwd = sessionId !== void 0 && facts.useSessionCwd ? this.resolveSessionCwd(sessionId) ?? facts.cwd : facts.cwd;
|
|
938
|
+
if (facts.transport === "acp") {
|
|
939
|
+
const recap = sessionId === void 0 ? void 0 : conversationRecap(options.messages);
|
|
940
|
+
yield* this.streamAcp(facts, options, prompt, recap, cwd, signal);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
let child;
|
|
944
|
+
try {
|
|
945
|
+
child = this.spawn({
|
|
946
|
+
argv,
|
|
947
|
+
cwd,
|
|
948
|
+
stdio: {
|
|
949
|
+
stdin: "ignore",
|
|
950
|
+
stdout: "pipe",
|
|
951
|
+
stderr: "inherit"
|
|
952
|
+
},
|
|
953
|
+
graceMs: facts.disposeGraceMs,
|
|
954
|
+
env: facts.env,
|
|
955
|
+
...signal === void 0 ? {} : { signal }
|
|
956
|
+
});
|
|
957
|
+
} catch (error) {
|
|
958
|
+
yield failureChunk(`llm-cli: the CLI child could not be started: ${error instanceof Error ? error.message : String(error)}`, CLI_START_FAILED_CODE, signal);
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const stdout = child.stdout;
|
|
962
|
+
if (stdout === void 0) {
|
|
963
|
+
yield failureChunk("llm-cli: the CLI child has no stdout to read stream-json from", CLI_START_FAILED_CODE, signal);
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
const queue = new ChunkQueue();
|
|
967
|
+
let buffer = "";
|
|
968
|
+
let messageText = "";
|
|
969
|
+
let lastText = "";
|
|
970
|
+
let blockStarted = false;
|
|
971
|
+
let settled = false;
|
|
972
|
+
let pendingUsage;
|
|
973
|
+
let finish;
|
|
974
|
+
/**
|
|
975
|
+
* Translate one stdout line into queued chunks and, at most once, a terminal.
|
|
976
|
+
* @param line - one complete stdout line, newline removed.
|
|
977
|
+
* @returns whether the line carried the run's terminal event.
|
|
978
|
+
*/
|
|
979
|
+
const acceptLine = (line) => {
|
|
980
|
+
const event = parseCliLine(line, messageText);
|
|
981
|
+
if (event === void 0 || event.kind === "ignored") return false;
|
|
982
|
+
if (event.kind === "message-start") {
|
|
983
|
+
messageText = "";
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
if (event.kind === "text") {
|
|
987
|
+
if (!blockStarted) {
|
|
988
|
+
blockStarted = true;
|
|
989
|
+
queue.push({
|
|
990
|
+
type: "block-start",
|
|
991
|
+
index: 0,
|
|
992
|
+
blockType: "text"
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
messageText += event.delta.text;
|
|
996
|
+
lastText += event.delta.text;
|
|
997
|
+
queue.push({
|
|
998
|
+
type: "text-delta",
|
|
999
|
+
index: 0,
|
|
1000
|
+
text: event.delta.text
|
|
1001
|
+
});
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
const terminal = event.terminal;
|
|
1005
|
+
if (terminal.kind === "stop") {
|
|
1006
|
+
if (terminal.usage !== void 0) pendingUsage = {
|
|
1007
|
+
type: "usage",
|
|
1008
|
+
usage: terminal.usage
|
|
1009
|
+
};
|
|
1010
|
+
finish = {
|
|
1011
|
+
type: "finish",
|
|
1012
|
+
reason: { kind: "stop" }
|
|
1013
|
+
};
|
|
1014
|
+
} else if (terminal.kind === "max-turns") finish = {
|
|
1015
|
+
type: "finish",
|
|
1016
|
+
reason: { kind: "max-tokens" }
|
|
1017
|
+
};
|
|
1018
|
+
else finish = failureChunk(`llm-cli: ${terminal.failure.message}`, terminal.failure.code, signal);
|
|
1019
|
+
settled = true;
|
|
1020
|
+
queue.close();
|
|
1021
|
+
return true;
|
|
1022
|
+
};
|
|
1023
|
+
const onData = (data) => {
|
|
1024
|
+
if (settled) return;
|
|
1025
|
+
buffer += data.toString("utf8");
|
|
1026
|
+
for (;;) {
|
|
1027
|
+
const newline = buffer.indexOf("\n");
|
|
1028
|
+
if (newline === -1) break;
|
|
1029
|
+
const line = buffer.slice(0, newline);
|
|
1030
|
+
buffer = buffer.slice(newline + 1);
|
|
1031
|
+
if (acceptLine(line)) break;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
const onEnd = () => {
|
|
1035
|
+
if (!settled) {
|
|
1036
|
+
if (buffer.trim().length > 0) acceptLine(buffer);
|
|
1037
|
+
settled = true;
|
|
1038
|
+
}
|
|
1039
|
+
queue.close();
|
|
1040
|
+
};
|
|
1041
|
+
const onError = (error) => {
|
|
1042
|
+
if (!settled) {
|
|
1043
|
+
settled = true;
|
|
1044
|
+
finish ??= failureChunk(`llm-cli: the CLI output stream failed: ${error.message}`, CLI_START_FAILED_CODE, signal);
|
|
1045
|
+
}
|
|
1046
|
+
queue.close();
|
|
1047
|
+
};
|
|
1048
|
+
const onAbort = () => {
|
|
1049
|
+
if (settled) return;
|
|
1050
|
+
settled = true;
|
|
1051
|
+
finish = failureChunk("llm-cli: the CLI run was aborted", "ABORTED", signal);
|
|
1052
|
+
queue.close();
|
|
1053
|
+
};
|
|
1054
|
+
stdout.on("data", onData);
|
|
1055
|
+
stdout.on("end", onEnd);
|
|
1056
|
+
stdout.on("error", onError);
|
|
1057
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1058
|
+
try {
|
|
1059
|
+
for (;;) {
|
|
1060
|
+
const chunk = await queue.next();
|
|
1061
|
+
if (chunk === void 0) break;
|
|
1062
|
+
yield chunk;
|
|
1063
|
+
}
|
|
1064
|
+
if (lastText.length > 0) yield {
|
|
1065
|
+
type: "block-end",
|
|
1066
|
+
index: 0,
|
|
1067
|
+
block: {
|
|
1068
|
+
type: "text",
|
|
1069
|
+
text: lastText
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
if (pendingUsage !== void 0) yield pendingUsage;
|
|
1073
|
+
if (finish !== void 0) {
|
|
1074
|
+
yield finish;
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const outcome = await child.done;
|
|
1078
|
+
yield failureChunk(`llm-cli: ${lastText.length > 0 ? `the CLI exited (${String(outcome.exitCode)}) without a result event after producing text` : `the CLI exited (${String(outcome.exitCode)}) without a result event`}`, CLI_START_FAILED_CODE);
|
|
1079
|
+
} finally {
|
|
1080
|
+
stdout.off("data", onData);
|
|
1081
|
+
stdout.off("end", onEnd);
|
|
1082
|
+
stdout.off("error", onError);
|
|
1083
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1084
|
+
child.terminate();
|
|
1085
|
+
await child.waitForExit().catch(() => {});
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Answer one call through the route's long-lived ACP child. The session owns
|
|
1090
|
+
* the conversation, so only this call's text is sent — which is also why the
|
|
1091
|
+
* transport pays the CLI's cold start once per route instead of once per call.
|
|
1092
|
+
* @param facts - resolved executable facts for this operation.
|
|
1093
|
+
* @param options - the model call being answered.
|
|
1094
|
+
* @param prompt - the text this call carries.
|
|
1095
|
+
* @param recap - the conversation before this call's newest human text, sent
|
|
1096
|
+
* instead of `prompt` when the CLI session is new and holds no history.
|
|
1097
|
+
* @param cwd - workspace the conversation's session runs in.
|
|
1098
|
+
* @param signal - caller cancellation, forwarded as a session cancel.
|
|
1099
|
+
* @returns the call's chunks, ending with its terminal.
|
|
1100
|
+
*/
|
|
1101
|
+
async *streamAcp(facts, options, prompt, recap, cwd, signal) {
|
|
1102
|
+
const transport = this.acpTransport(facts, options.model, cwd);
|
|
1103
|
+
this.statelessCalls += 1;
|
|
1104
|
+
const key = options.sessionId ?? `stateless:${String(this.statelessCalls)}`;
|
|
1105
|
+
let lease;
|
|
1106
|
+
try {
|
|
1107
|
+
lease = await transport.openSession(key, cwd, this.acpSessions.get(key));
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
yield failureChunk(`llm-cli: the CLI session could not be opened: ${error instanceof Error ? error.message : String(error)}`, CLI_START_FAILED_CODE, signal);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
this.acpSessions.set(key, lease.sessionId);
|
|
1113
|
+
const promptText = lease.created && recap !== void 0 ? `${recap}\n\nUser: ${prompt}` : prompt;
|
|
1114
|
+
const session = lease.sessionId;
|
|
1115
|
+
let kind;
|
|
1116
|
+
let index = -1;
|
|
1117
|
+
let blockText = "";
|
|
1118
|
+
try {
|
|
1119
|
+
const events = transport.prompt(session, promptText, signal);
|
|
1120
|
+
let step = await events.next();
|
|
1121
|
+
while (!step.done) {
|
|
1122
|
+
const event = step.value;
|
|
1123
|
+
if (kind !== event.kind) {
|
|
1124
|
+
if (kind !== void 0) yield {
|
|
1125
|
+
type: "block-end",
|
|
1126
|
+
index,
|
|
1127
|
+
block: answerBlock(kind, blockText)
|
|
1128
|
+
};
|
|
1129
|
+
kind = event.kind;
|
|
1130
|
+
blockText = "";
|
|
1131
|
+
index += 1;
|
|
1132
|
+
yield {
|
|
1133
|
+
type: "block-start",
|
|
1134
|
+
index,
|
|
1135
|
+
blockType: blockTypeOf(kind)
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
blockText += event.text;
|
|
1139
|
+
if (event.kind === "text") yield {
|
|
1140
|
+
type: "text-delta",
|
|
1141
|
+
index,
|
|
1142
|
+
text: event.text
|
|
1143
|
+
};
|
|
1144
|
+
else yield {
|
|
1145
|
+
type: "reasoning-delta",
|
|
1146
|
+
index,
|
|
1147
|
+
text: event.text
|
|
1148
|
+
};
|
|
1149
|
+
step = await events.next();
|
|
1150
|
+
}
|
|
1151
|
+
if (kind !== void 0) yield {
|
|
1152
|
+
type: "block-end",
|
|
1153
|
+
index,
|
|
1154
|
+
block: answerBlock(kind, blockText)
|
|
1155
|
+
};
|
|
1156
|
+
yield finishOfAcpOutcome(step.value, signal);
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
if (signal?.aborted === true) {
|
|
1159
|
+
yield failureChunk("llm-cli: the CLI run was aborted", "ABORTED", signal);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (error instanceof AcpTurnTimeoutError) {
|
|
1163
|
+
yield failureChunk(`llm-cli: ${error.message}`, CLI_TURN_TIMEOUT_CODE, signal);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
yield failureChunk(`llm-cli: ${error instanceof Error ? error.message : String(error)}`, CLI_START_FAILED_CODE, signal);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Release the route's long-lived children, if any were ever started. The
|
|
1171
|
+
* plugin calls this from the effect that registered the route, so unloading
|
|
1172
|
+
* the route unloads its children rather than leaving them to outlive the
|
|
1173
|
+
* composition.
|
|
1174
|
+
* @returns once every child's process tree is gone.
|
|
1175
|
+
*/
|
|
1176
|
+
async dispose() {
|
|
1177
|
+
const transports = [...this.acp.values()].map((entry) => entry.transport);
|
|
1178
|
+
this.acp.clear();
|
|
1179
|
+
await Promise.all(transports.map((transport) => transport.dispose()));
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* The ACP connection one workspace runs on, started on first use and replaced
|
|
1183
|
+
* when the facts that started it change. The model and the workspace ride the
|
|
1184
|
+
* process: the CLI takes both as launch facts, so one child serves one model
|
|
1185
|
+
* in one workspace.
|
|
1186
|
+
* @param facts - resolved executable facts for this operation.
|
|
1187
|
+
* @param model - model id this call selected.
|
|
1188
|
+
* @param cwd - workspace the conversation's session runs in.
|
|
1189
|
+
* @returns the connection to prompt on.
|
|
1190
|
+
*/
|
|
1191
|
+
acpTransport(facts, model, cwd) {
|
|
1192
|
+
const argv = [
|
|
1193
|
+
...facts.acpArgv,
|
|
1194
|
+
"--permission-mode",
|
|
1195
|
+
facts.permissionMode,
|
|
1196
|
+
...facts.tools === void 0 ? [] : ["--tools", facts.tools],
|
|
1197
|
+
...facts.maxTurns === void 0 ? [] : ["--max-turns", String(facts.maxTurns)],
|
|
1198
|
+
...facts.effort === void 0 ? [] : ["--effort", facts.effort],
|
|
1199
|
+
"--model",
|
|
1200
|
+
model
|
|
1201
|
+
];
|
|
1202
|
+
const signature = JSON.stringify([
|
|
1203
|
+
argv,
|
|
1204
|
+
facts.env,
|
|
1205
|
+
facts.disposeGraceMs
|
|
1206
|
+
]);
|
|
1207
|
+
const existing = this.acp.get(cwd);
|
|
1208
|
+
if (existing?.signature === signature) return existing.transport;
|
|
1209
|
+
const selectPermission = selectPermissionFor(facts.permissionMode);
|
|
1210
|
+
const transport = new AcpTransport({
|
|
1211
|
+
spawn: this.spawn,
|
|
1212
|
+
argv,
|
|
1213
|
+
cwd,
|
|
1214
|
+
env: facts.env,
|
|
1215
|
+
graceMs: facts.disposeGraceMs,
|
|
1216
|
+
...facts.turnTimeoutMs <= 0 ? {} : { turnTimeoutMs: facts.turnTimeoutMs },
|
|
1217
|
+
...selectPermission === void 0 ? {} : { selectPermission }
|
|
1218
|
+
});
|
|
1219
|
+
this.acp.set(cwd, {
|
|
1220
|
+
signature,
|
|
1221
|
+
transport
|
|
1222
|
+
});
|
|
1223
|
+
existing?.transport.dispose().catch(() => void 0);
|
|
1224
|
+
return transport;
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
/**
|
|
1228
|
+
* The terminal this route reports for how the CLI ended one delegated turn. The
|
|
1229
|
+
* stop reason is the only thing that separates a turn the CLI abandoned from a
|
|
1230
|
+
* short reply, so every reason but a completed turn is reported as a failure.
|
|
1231
|
+
* @param outcome - stop reason the CLI reported for the turn.
|
|
1232
|
+
* @param signal - caller cancellation, which outranks the CLI's own reason.
|
|
1233
|
+
* @returns the terminal chunk for the call.
|
|
1234
|
+
*/
|
|
1235
|
+
function finishOfAcpOutcome(outcome, signal) {
|
|
1236
|
+
const detail = outcome.detail === void 0 ? "" : ` (${outcome.detail})`;
|
|
1237
|
+
switch (outcome.stopReason) {
|
|
1238
|
+
case "end_turn": return {
|
|
1239
|
+
type: "finish",
|
|
1240
|
+
reason: { kind: "stop" }
|
|
1241
|
+
};
|
|
1242
|
+
case "max_tokens":
|
|
1243
|
+
case "max_turn_requests":
|
|
1244
|
+
case "max_turns": return {
|
|
1245
|
+
type: "finish",
|
|
1246
|
+
reason: { kind: "max-tokens" }
|
|
1247
|
+
};
|
|
1248
|
+
case "cancelled": return failureChunk(`llm-cli: the CLI cancelled the turn${detail}`, CLI_TURN_CANCELLED_CODE, signal);
|
|
1249
|
+
case "refusal": return failureChunk(`llm-cli: the CLI refused the turn${detail}`, CLI_TURN_REFUSED_CODE, signal);
|
|
1250
|
+
default: return failureChunk(`llm-cli: the CLI ended the turn with stop reason "${outcome.stopReason}"${detail}`, CLI_TURN_UNKNOWN_CODE, signal);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
/** The block kind one ACP stream reports as, in this seam's vocabulary. */
|
|
1254
|
+
function blockTypeOf(kind) {
|
|
1255
|
+
return kind === "text" ? "text" : "reasoning";
|
|
1256
|
+
}
|
|
1257
|
+
/** The finished block one partial stream leaves behind. */
|
|
1258
|
+
function answerBlock(kind, text) {
|
|
1259
|
+
return kind === "text" ? {
|
|
1260
|
+
type: "text",
|
|
1261
|
+
text
|
|
1262
|
+
} : {
|
|
1263
|
+
type: "reasoning",
|
|
1264
|
+
text
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* The policy a permission request is answered with. A mode that already lets the
|
|
1269
|
+
* CLI run its tools unattended answers with the same intent the CLI's own flag
|
|
1270
|
+
* means; every other mode refuses, because approving there would override a
|
|
1271
|
+
* policy the deployment chose deliberately.
|
|
1272
|
+
* @param mode - the route's permission policy.
|
|
1273
|
+
* @returns the selector, or undefined to refuse every request.
|
|
1274
|
+
*/
|
|
1275
|
+
function selectPermissionFor(mode) {
|
|
1276
|
+
return mode === "bypassPermissions" || mode === "acceptEdits" || mode === "auto" || mode === "dontAsk" ? (options) => options.find((option) => option.kind === "allow_always")?.id ?? options.find((option) => option.kind.startsWith("allow"))?.id : void 0;
|
|
1277
|
+
}
|
|
1278
|
+
//#endregion
|
|
1279
|
+
//#region lib/types/config.js
|
|
1280
|
+
/**
|
|
1281
|
+
* Plugin configuration and the one resolve step from raw config to validated
|
|
1282
|
+
* executable facts.
|
|
1283
|
+
*
|
|
1284
|
+
* Every field is `Volatile`, so a Models-page edit reaches the next request
|
|
1285
|
+
* through the loader's volatile update without restarting anything. The resolve
|
|
1286
|
+
* step re-judges every default and bound because programmatic construction and
|
|
1287
|
+
* a settings snapshot both bypass Schemastery normalization.
|
|
1288
|
+
*
|
|
1289
|
+
* @module dsh-codebuddy-llm/config
|
|
1290
|
+
*/
|
|
1291
|
+
/** The default CLI executable; a bare name resolves on PATH. */
|
|
1292
|
+
const DEFAULT_COMMAND = "codebuddy";
|
|
1293
|
+
/**
|
|
1294
|
+
* Base arguments producing stream-json on stdout with a trailing positional
|
|
1295
|
+
* prompt. Partial messages are requested too: without them the CLI reports a
|
|
1296
|
+
* message only once it is complete, so a caller waits out the child's whole
|
|
1297
|
+
* agent loop — tool rounds included — before the first character arrives.
|
|
1298
|
+
*/
|
|
1299
|
+
const DEFAULT_ARGS = [
|
|
1300
|
+
"--print",
|
|
1301
|
+
"--output-format",
|
|
1302
|
+
"stream-json",
|
|
1303
|
+
"--include-partial-messages"
|
|
1304
|
+
];
|
|
1305
|
+
/** The argument that carries the persistent session id. */
|
|
1306
|
+
const DEFAULT_SESSION_ID_ARG = "--session-id";
|
|
1307
|
+
/** Permission mode that makes a delegated, non-interactive CLI run usable by default. */
|
|
1308
|
+
const DEFAULT_PERMISSION_MODE = "bypassPermissions";
|
|
1309
|
+
/** Default graceful-termination window for a CLI child. */
|
|
1310
|
+
const DEFAULT_DISPOSE_GRACE_MS = 3e3;
|
|
1311
|
+
/** Arguments that start the CLI as a long-lived ACP agent on stdio. */
|
|
1312
|
+
const DEFAULT_ACP_ARGS = ["--acp"];
|
|
1313
|
+
/** Arguments that print the CLI's supported model ids without starting an agent session. */
|
|
1314
|
+
const DEFAULT_MODEL_DISCOVERY_ARGS = ["--help"];
|
|
1315
|
+
/** Default ceiling for one model-discovery child. */
|
|
1316
|
+
const DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS = 15e3;
|
|
1317
|
+
/**
|
|
1318
|
+
* Default ceiling for one delegated `acp` turn. A turn is the CLI's whole agent
|
|
1319
|
+
* loop, not one model round, so the bound is generous; it exists to end a run
|
|
1320
|
+
* that never converges rather than to pace an ordinary one.
|
|
1321
|
+
*/
|
|
1322
|
+
const DEFAULT_TURN_TIMEOUT_MS = 6e5;
|
|
1323
|
+
/**
|
|
1324
|
+
* Read the current value behind every reference of a parsed {@link Config}.
|
|
1325
|
+
* Every schema field is volatile, so each entry is a reference by construction.
|
|
1326
|
+
* @param config - parsed plugin config.
|
|
1327
|
+
* @returns one detached value per option.
|
|
1328
|
+
*/
|
|
1329
|
+
function plainOptions(config) {
|
|
1330
|
+
const plain = {};
|
|
1331
|
+
for (const key of Object.keys(config)) plain[key] = config[key].get();
|
|
1332
|
+
return plain;
|
|
1333
|
+
}
|
|
1334
|
+
/** Advisory catalog entry as it appears in yml. */
|
|
1335
|
+
const catalogModel = z.object({
|
|
1336
|
+
id: z.string().required(),
|
|
1337
|
+
name: z.string(),
|
|
1338
|
+
contextWindow: z.natural().min(1)
|
|
1339
|
+
});
|
|
1340
|
+
/** Fields shared by the plugin entry config and the `llm-cli` settings section. */
|
|
1341
|
+
const cliConfigFields = {
|
|
1342
|
+
command: z.string().default(DEFAULT_COMMAND).volatile(),
|
|
1343
|
+
args: z.array(z.string()).default([...DEFAULT_ARGS]).volatile(),
|
|
1344
|
+
acpArgs: z.array(z.string()).default([...DEFAULT_ACP_ARGS]).volatile(),
|
|
1345
|
+
modelDiscoveryArgs: z.array(z.string()).default([...DEFAULT_MODEL_DISCOVERY_ARGS]).volatile(),
|
|
1346
|
+
modelDiscoveryTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS).volatile(),
|
|
1347
|
+
turnTimeoutMs: z.number().min(0).max(MAX_TIMER_DELAY_MS).default(DEFAULT_TURN_TIMEOUT_MS).volatile(),
|
|
1348
|
+
models: z.array(catalogModel).default([]).volatile(),
|
|
1349
|
+
cwd: z.string().volatile(),
|
|
1350
|
+
env: z.dict(z.string()).default({}).volatile(),
|
|
1351
|
+
sessionIdArg: z.string().default(DEFAULT_SESSION_ID_ARG).volatile(),
|
|
1352
|
+
transport: z.union(CLI_TRANSPORTS).default(DEFAULT_TRANSPORT).volatile(),
|
|
1353
|
+
permissionMode: z.union(CODEBUDDY_PERMISSION_MODES).default(DEFAULT_PERMISSION_MODE).volatile(),
|
|
1354
|
+
tools: z.string().volatile(),
|
|
1355
|
+
maxTurns: z.natural().min(1).volatile(),
|
|
1356
|
+
effort: z.union(CODEBUDDY_EFFORT_LEVELS).volatile(),
|
|
1357
|
+
disposeGraceMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_DISPOSE_GRACE_MS).volatile()
|
|
1358
|
+
};
|
|
1359
|
+
const Config = z.object(cliConfigFields);
|
|
1360
|
+
/**
|
|
1361
|
+
* Validate a millisecond bound that must be a positive finite number inside the
|
|
1362
|
+
* timer ceiling, which is what both the spawn grace and the discovery deadline
|
|
1363
|
+
* are handed to.
|
|
1364
|
+
* @param value - the resolved bound.
|
|
1365
|
+
* @param field - option name used in the diagnostic.
|
|
1366
|
+
* @returns the same value once it is accepted.
|
|
1367
|
+
*/
|
|
1368
|
+
function timerBound(value, field) {
|
|
1369
|
+
if (!Number.isFinite(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) throw new Error(`llm-cli: ${field} must be a positive finite number no greater than ${String(MAX_TIMER_DELAY_MS)}`);
|
|
1370
|
+
return value;
|
|
1371
|
+
}
|
|
1372
|
+
/** Reject a blank argument list entry, which would silently shift the prompt. */
|
|
1373
|
+
function assertNoBlankArg(args, field) {
|
|
1374
|
+
if (args.some((arg) => arg.length === 0)) throw new Error(`llm-cli: ${field} must not contain an empty argument`);
|
|
1375
|
+
}
|
|
1376
|
+
/** Validate and detach the advisory model catalog. */
|
|
1377
|
+
function resolveModels(models) {
|
|
1378
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1379
|
+
return (models ?? []).map((model) => {
|
|
1380
|
+
if (model.id.length === 0) throw new Error("llm-cli: catalog model ids must be non-empty");
|
|
1381
|
+
if (model.name !== void 0 && model.name.length === 0) throw new Error(`llm-cli: catalog model "${model.id}" has an empty name`);
|
|
1382
|
+
if (model.contextWindow !== void 0 && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) throw new Error(`llm-cli: catalog model "${model.id}" contextWindow must be a positive whole number`);
|
|
1383
|
+
if (seen.has(model.id)) throw new Error(`llm-cli: duplicate catalog model "${model.id}"`);
|
|
1384
|
+
seen.add(model.id);
|
|
1385
|
+
return {
|
|
1386
|
+
id: model.id,
|
|
1387
|
+
...model.name === void 0 ? {} : { name: model.name },
|
|
1388
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow }
|
|
1389
|
+
};
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* Merge what the CLI last advertised with the ids the deployment declared. The
|
|
1394
|
+
* advertised order leads — it is the CLI's own listing, which is what a picker
|
|
1395
|
+
* should show — and a declared entry keeps the fields it declares there, so a
|
|
1396
|
+
* curated label is not replaced by the bare id, while everything it leaves out
|
|
1397
|
+
* still comes from the CLI. Declared ids the CLI did not report follow, so an
|
|
1398
|
+
* advisory id stays selectable.
|
|
1399
|
+
* @param configured - catalog entries the deployment declared.
|
|
1400
|
+
* @param discovered - entries the last probe of the CLI reported.
|
|
1401
|
+
* @returns one entry per id, advertised order first.
|
|
1402
|
+
*/
|
|
1403
|
+
function mergeCatalog(configured, discovered) {
|
|
1404
|
+
const declared = new Map(configured.map((model) => [model.id, model]));
|
|
1405
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1406
|
+
const merged = [];
|
|
1407
|
+
for (const model of [...discovered, ...configured]) {
|
|
1408
|
+
if (seen.has(model.id)) continue;
|
|
1409
|
+
seen.add(model.id);
|
|
1410
|
+
const declaredEntry = declared.get(model.id);
|
|
1411
|
+
const name = declaredEntry?.name ?? model.name;
|
|
1412
|
+
const contextWindow = declaredEntry?.contextWindow ?? model.contextWindow;
|
|
1413
|
+
merged.push({
|
|
1414
|
+
id: model.id,
|
|
1415
|
+
...name === void 0 ? {} : { name },
|
|
1416
|
+
...contextWindow === void 0 ? {} : { contextWindow }
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
return merged;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* The one explicit resolve step from raw config to validated executable facts.
|
|
1423
|
+
* @param config - raw plugin config or a resolved settings snapshot.
|
|
1424
|
+
* @param defaultCwd - fallback working directory for the CLI child.
|
|
1425
|
+
* @returns validated executable facts for the adapter.
|
|
1426
|
+
*/
|
|
1427
|
+
function resolveAdapterOptions(config, defaultCwd) {
|
|
1428
|
+
const command = config.command ?? "codebuddy";
|
|
1429
|
+
if (command.trim().length === 0) throw new Error("llm-cli: command must be non-empty");
|
|
1430
|
+
const args = config.args ?? DEFAULT_ARGS;
|
|
1431
|
+
assertNoBlankArg(args, "args");
|
|
1432
|
+
const transport = config.transport ?? "print";
|
|
1433
|
+
if (!CLI_TRANSPORTS.includes(transport)) throw new Error(`llm-cli: unsupported transport "${transport}"`);
|
|
1434
|
+
const permissionMode = config.permissionMode ?? "bypassPermissions";
|
|
1435
|
+
if (!CODEBUDDY_PERMISSION_MODES.includes(permissionMode)) throw new Error(`llm-cli: unsupported permissionMode "${permissionMode}"`);
|
|
1436
|
+
const disposeGraceMs = timerBound(config.disposeGraceMs ?? 3e3, "disposeGraceMs");
|
|
1437
|
+
const acpArgs = config.acpArgs ?? DEFAULT_ACP_ARGS;
|
|
1438
|
+
assertNoBlankArg(acpArgs, "acpArgs");
|
|
1439
|
+
const discoveryArgs = config.modelDiscoveryArgs ?? DEFAULT_MODEL_DISCOVERY_ARGS;
|
|
1440
|
+
assertNoBlankArg(discoveryArgs, "modelDiscoveryArgs");
|
|
1441
|
+
const discoveryTimeoutMs = timerBound(config.modelDiscoveryTimeoutMs ?? 15e3, "modelDiscoveryTimeoutMs");
|
|
1442
|
+
const turnTimeoutMs = config.turnTimeoutMs ?? 6e5;
|
|
1443
|
+
if (!Number.isFinite(turnTimeoutMs) || turnTimeoutMs < 0 || turnTimeoutMs > MAX_TIMER_DELAY_MS) throw new Error(`llm-cli: turnTimeoutMs must be 0 or a positive finite number no greater than ${String(MAX_TIMER_DELAY_MS)}`);
|
|
1444
|
+
const sessionIdArg = config.sessionIdArg ?? "--session-id";
|
|
1445
|
+
const maxTurns = config.maxTurns;
|
|
1446
|
+
if (maxTurns !== void 0 && (!Number.isInteger(maxTurns) || maxTurns < 1)) throw new Error("llm-cli: maxTurns must be a positive whole number");
|
|
1447
|
+
const effort = config.effort;
|
|
1448
|
+
if (effort !== void 0 && !CODEBUDDY_EFFORT_LEVELS.includes(effort)) throw new Error(`llm-cli: unsupported effort "${effort}"`);
|
|
1449
|
+
return {
|
|
1450
|
+
argv: [command, ...args],
|
|
1451
|
+
cwd: config.cwd ?? defaultCwd,
|
|
1452
|
+
useSessionCwd: config.cwd === void 0,
|
|
1453
|
+
env: { ...config.env ?? {} },
|
|
1454
|
+
...sessionIdArg.length === 0 ? {} : { sessionIdArg },
|
|
1455
|
+
transport,
|
|
1456
|
+
permissionMode,
|
|
1457
|
+
...config.tools === void 0 ? {} : { tools: config.tools },
|
|
1458
|
+
...maxTurns === void 0 ? {} : { maxTurns },
|
|
1459
|
+
...effort === void 0 ? {} : { effort },
|
|
1460
|
+
disposeGraceMs,
|
|
1461
|
+
turnTimeoutMs,
|
|
1462
|
+
acpArgv: [command, ...acpArgs],
|
|
1463
|
+
discoveryArgv: [command, ...discoveryArgs],
|
|
1464
|
+
discoveryTimeoutMs,
|
|
1465
|
+
models: resolveModels(config.models)
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
//#endregion
|
|
1469
|
+
//#region lib/types/discovery.js
|
|
1470
|
+
/**
|
|
1471
|
+
* Host-side CLI model discovery. It runs the configured CLI's own listing
|
|
1472
|
+
* command (default `--help`) through the shared subprocess seam and parses the
|
|
1473
|
+
* model ids the CLI reports, so the standard Models page can offer the CLI's
|
|
1474
|
+
* real catalog without this package owning a remote surface of its own.
|
|
1475
|
+
*
|
|
1476
|
+
* Every child here goes through the seam, so discovery inherits process-tree
|
|
1477
|
+
* termination and environment scrubbing instead of managing a child itself.
|
|
1478
|
+
*
|
|
1479
|
+
* @module dsh-codebuddy-llm/discovery
|
|
1480
|
+
*/
|
|
1481
|
+
/** In-memory stdout cap for one discovery child; a larger listing is not a model catalog. */
|
|
1482
|
+
const DISCOVERY_OUTPUT_MAX_BYTES = 1024 * 1024;
|
|
1483
|
+
/**
|
|
1484
|
+
* Parse the model ids a CodeBuddy-style CLI lists for its `--model` option.
|
|
1485
|
+
* @param help - the CLI's own help output.
|
|
1486
|
+
* @returns the unique ids in the CLI's reported order, without display names.
|
|
1487
|
+
*/
|
|
1488
|
+
function parseCodeBuddyModels(help) {
|
|
1489
|
+
const listing = /--model <model>[\s\S]*?Currently supported:\s*\(([^)]*)\)/.exec(help)?.[1];
|
|
1490
|
+
if (listing === void 0) return [];
|
|
1491
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1492
|
+
for (const value of listing.split(",")) {
|
|
1493
|
+
const id = value.trim();
|
|
1494
|
+
if (id.length > 0) ids.add(id);
|
|
1495
|
+
}
|
|
1496
|
+
return [...ids].map((id) => ({ id }));
|
|
1497
|
+
}
|
|
1498
|
+
/** Run the listing child to completion and return its stdout, or undefined when it did not report. */
|
|
1499
|
+
async function captureListing(request) {
|
|
1500
|
+
if (request.signal?.aborted === true) return void 0;
|
|
1501
|
+
const controller = new AbortController();
|
|
1502
|
+
const abort = () => {
|
|
1503
|
+
controller.abort();
|
|
1504
|
+
};
|
|
1505
|
+
const timer = setTimeout(abort, request.timeoutMs);
|
|
1506
|
+
timer.unref();
|
|
1507
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
1508
|
+
try {
|
|
1509
|
+
const handle = request.spawn({
|
|
1510
|
+
argv: request.argv,
|
|
1511
|
+
cwd: request.cwd,
|
|
1512
|
+
stdio: {
|
|
1513
|
+
stdin: "ignore",
|
|
1514
|
+
stdout: { maxBytes: DISCOVERY_OUTPUT_MAX_BYTES },
|
|
1515
|
+
stderr: "inherit"
|
|
1516
|
+
},
|
|
1517
|
+
graceMs: request.timeoutMs,
|
|
1518
|
+
signal: controller.signal
|
|
1519
|
+
});
|
|
1520
|
+
if ((await handle.done).exitCode !== 0) return void 0;
|
|
1521
|
+
return handle.collected.stdout?.readFrom(0).text;
|
|
1522
|
+
} catch {
|
|
1523
|
+
return;
|
|
1524
|
+
} finally {
|
|
1525
|
+
clearTimeout(timer);
|
|
1526
|
+
request.signal?.removeEventListener("abort", abort);
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Interrogate an ACP route for the catalog its handshake reports.
|
|
1531
|
+
* @param request - the route's ACP connection, its bounds, and the seam's spawn.
|
|
1532
|
+
* @returns the advertised models, or undefined when the CLI never answered.
|
|
1533
|
+
*/
|
|
1534
|
+
async function discoverAcpModels(request) {
|
|
1535
|
+
const acp = request.acp;
|
|
1536
|
+
if (acp === void 0) return void 0;
|
|
1537
|
+
if (request.signal?.aborted === true) return void 0;
|
|
1538
|
+
const transport = new AcpTransport({
|
|
1539
|
+
spawn: request.spawn,
|
|
1540
|
+
argv: acp.argv,
|
|
1541
|
+
cwd: request.cwd,
|
|
1542
|
+
env: acp.env,
|
|
1543
|
+
graceMs: acp.graceMs
|
|
1544
|
+
});
|
|
1545
|
+
const abort = () => {
|
|
1546
|
+
transport.dispose();
|
|
1547
|
+
};
|
|
1548
|
+
const timer = setTimeout(abort, request.timeoutMs);
|
|
1549
|
+
timer.unref();
|
|
1550
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
1551
|
+
try {
|
|
1552
|
+
return await transport.describeModels(request.cwd);
|
|
1553
|
+
} catch {
|
|
1554
|
+
return;
|
|
1555
|
+
} finally {
|
|
1556
|
+
clearTimeout(timer);
|
|
1557
|
+
request.signal?.removeEventListener("abort", abort);
|
|
1558
|
+
await transport.dispose();
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Discover the models the configured CLI advertises.
|
|
1563
|
+
* @param request - the listing invocation, its bounds, and the route's own catalog.
|
|
1564
|
+
* @returns the discovered models first, then configured ids the CLI did not report.
|
|
1565
|
+
*/
|
|
1566
|
+
async function discoverCliModels(request) {
|
|
1567
|
+
const advertised = request.acp === void 0 ? void 0 : await discoverAcpModels(request);
|
|
1568
|
+
const help = advertised === void 0 || advertised.length === 0 ? await captureListing(request) : void 0;
|
|
1569
|
+
const discovered = advertised !== void 0 && advertised.length > 0 ? [...advertised] : help === void 0 ? [] : parseCodeBuddyModels(help);
|
|
1570
|
+
const seen = new Set(discovered.map((model) => model.id));
|
|
1571
|
+
const models = [...discovered];
|
|
1572
|
+
for (const model of request.configured) {
|
|
1573
|
+
if (seen.has(model.id)) continue;
|
|
1574
|
+
seen.add(model.id);
|
|
1575
|
+
models.push({
|
|
1576
|
+
id: model.id,
|
|
1577
|
+
...model.name === void 0 ? {} : { name: model.name },
|
|
1578
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow }
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
return models;
|
|
1582
|
+
}
|
|
1583
|
+
//#endregion
|
|
1584
|
+
//#region lib/types/index.js
|
|
1585
|
+
/**
|
|
1586
|
+
* `llm-cli`: a provider route answered by a CLI child instead of a provider HTTP
|
|
1587
|
+
* endpoint.
|
|
1588
|
+
*
|
|
1589
|
+
* The route exists so a deployment can run the harness with no API key at all:
|
|
1590
|
+
* the configured CLI (CodeBuddy by default) carries its own authentication and
|
|
1591
|
+
* runs its own agentic loop with its own tools. The harness forwards
|
|
1592
|
+
* conversation text and receives the final answer, so a model swap is a
|
|
1593
|
+
* configuration change rather than a harness change.
|
|
1594
|
+
*
|
|
1595
|
+
* The plugin depends on exactly two seams — `llm` (the adapter registry) and
|
|
1596
|
+
* `subprocess` (child lifecycle) — and reaches the session store, when a
|
|
1597
|
+
* deployment has one, through an untyped service lookup. Nothing here requires
|
|
1598
|
+
* `dsh-session`, a remote surface, or a client package, so mounting it changes
|
|
1599
|
+
* no shared package.
|
|
1600
|
+
*
|
|
1601
|
+
* ```yaml
|
|
1602
|
+
* - id: llm-cli
|
|
1603
|
+
* name: 'dsh-codebuddy-llm'
|
|
1604
|
+
* config:
|
|
1605
|
+
* command: codebuddy
|
|
1606
|
+
* args: ['--print', '--output-format', 'stream-json', '--include-partial-messages']
|
|
1607
|
+
* permissionMode: bypassPermissions
|
|
1608
|
+
* ```
|
|
1609
|
+
*
|
|
1610
|
+
* @module dsh-codebuddy-llm
|
|
1611
|
+
*/
|
|
1612
|
+
const name = "llm-cli";
|
|
1613
|
+
const inject = ["llm", "subprocess"];
|
|
1614
|
+
/** The single provider route this plugin owns; a registration-time fact. */
|
|
1615
|
+
const PROVIDER = "codebuddy-cli";
|
|
1616
|
+
/** Human-readable provider name for selectors. */
|
|
1617
|
+
const DISPLAY_NAME = "CodeBuddy CLI";
|
|
1618
|
+
/** Register the CLI route, its settings section, and its model discovery. */
|
|
1619
|
+
function apply(ctx, config) {
|
|
1620
|
+
ctx.inject(["settings"], (child) => {
|
|
1621
|
+
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber));
|
|
1622
|
+
});
|
|
1623
|
+
const settingsNs = ctx.fiber.entry?.options.id ?? "llm-cli";
|
|
1624
|
+
/**
|
|
1625
|
+
* Model entries the CLI last advertised. The picker reads this route's
|
|
1626
|
+
* catalog through `listModels`, so the CLI's own listing has to reach the
|
|
1627
|
+
* resolved facts; discovery is best-effort, and an unreachable CLI leaves the
|
|
1628
|
+
* configured catalog standing alone.
|
|
1629
|
+
*/
|
|
1630
|
+
let advertised = [];
|
|
1631
|
+
/**
|
|
1632
|
+
* Executable facts for the current configuration, re-resolved per operation
|
|
1633
|
+
* so a settings edit reaches the very next request without re-registering the
|
|
1634
|
+
* route. Nothing about this route is captured at registration time — the
|
|
1635
|
+
* provider id, its display name, and the settings namespace are all create-once
|
|
1636
|
+
* facts — so a volatile update needs no reload hook of its own: the loader
|
|
1637
|
+
* commits a snapshot only after {@link apply}'s `internal/config` listener has
|
|
1638
|
+
* accepted it, which is why a resolve here can no longer fail.
|
|
1639
|
+
*/
|
|
1640
|
+
const options = () => {
|
|
1641
|
+
const current = plainOptions(config);
|
|
1642
|
+
return resolveAdapterOptions({
|
|
1643
|
+
...current,
|
|
1644
|
+
models: mergeCatalog(current.models ?? [], advertised)
|
|
1645
|
+
}, process.cwd());
|
|
1646
|
+
};
|
|
1647
|
+
options();
|
|
1648
|
+
/**
|
|
1649
|
+
* Probe the CLI for the models it advertises and remember them for this
|
|
1650
|
+
* route, so a picker offers the CLI's real ids instead of an empty list.
|
|
1651
|
+
* @param signal - caller cancellation, honored beside the discovery bound.
|
|
1652
|
+
* @returns the advertised ids, declared entries the CLI did not report last.
|
|
1653
|
+
*/
|
|
1654
|
+
const probeCatalog = async (signal) => {
|
|
1655
|
+
const current = plainOptions(config);
|
|
1656
|
+
const facts = resolveAdapterOptions(current, process.cwd());
|
|
1657
|
+
// A missing CLI is a deployment fault, not an empty catalog: throwing here
|
|
1658
|
+
// reaches the Models page as a refusal carrying this text, while the load
|
|
1659
|
+
// probe logs it, so neither surface shows a silent zero-model route.
|
|
1660
|
+
const problem = executableProblem(facts.argv[0], facts.env);
|
|
1661
|
+
if (problem !== void 0) throw new Error(`CodeBuddy CLI is unavailable: ${problem}. Install the CodeBuddy CLI, or point \`command\` in this plugin's \`llm-cli\` config at its absolute path.`);
|
|
1662
|
+
const models = await discoverCliModels({
|
|
1663
|
+
argv: facts.discoveryArgv,
|
|
1664
|
+
cwd: facts.cwd,
|
|
1665
|
+
timeoutMs: facts.discoveryTimeoutMs,
|
|
1666
|
+
configured: current.models ?? [],
|
|
1667
|
+
spawn: (spec) => ctx.subprocess.spawn(spec),
|
|
1668
|
+
...facts.transport === "acp" ? { acp: {
|
|
1669
|
+
argv: facts.acpArgv,
|
|
1670
|
+
env: facts.env,
|
|
1671
|
+
graceMs: facts.disposeGraceMs
|
|
1672
|
+
} } : {},
|
|
1673
|
+
...signal === void 0 ? {} : { signal }
|
|
1674
|
+
});
|
|
1675
|
+
advertised = models.map((model) => ({
|
|
1676
|
+
id: model.id,
|
|
1677
|
+
...model.name === void 0 ? {} : { name: model.name },
|
|
1678
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow }
|
|
1679
|
+
}));
|
|
1680
|
+
return models;
|
|
1681
|
+
};
|
|
1682
|
+
probeCatalog().catch((error) => {
|
|
1683
|
+
ctx.logger.warn("llm-cli: model discovery failed: %s", error instanceof Error ? error.message : String(error));
|
|
1684
|
+
});
|
|
1685
|
+
ctx.on("internal/config", function(_raw, next) {
|
|
1686
|
+
const raw = next();
|
|
1687
|
+
if (this !== ctx.fiber) return raw;
|
|
1688
|
+
resolveAdapterOptions(plainOptions(Config(raw)), process.cwd());
|
|
1689
|
+
return raw;
|
|
1690
|
+
});
|
|
1691
|
+
const adapter = new CliAdapter({
|
|
1692
|
+
options,
|
|
1693
|
+
spawn: (spec) => ctx.subprocess.spawn(spec),
|
|
1694
|
+
resolveSessionCwd: (sessionId) => ctx.get("sessions")?.get(sessionId)?.header.cwd
|
|
1695
|
+
});
|
|
1696
|
+
ctx.llm.registerConfigurableProviders([{
|
|
1697
|
+
provider: PROVIDER,
|
|
1698
|
+
displayName: DISPLAY_NAME,
|
|
1699
|
+
settingsNs,
|
|
1700
|
+
settingsPath: []
|
|
1701
|
+
}]);
|
|
1702
|
+
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
1703
|
+
ctx.effect(() => () => adapter.dispose());
|
|
1704
|
+
ctx.llm.registerModelDiscovery(settingsNs, (_request, signal) => probeCatalog(signal));
|
|
1705
|
+
}
|
|
1706
|
+
//#endregion
|
|
1707
|
+
export { AcpTransport, CLI_TRANSPORTS, CODEBUDDY_EFFORT_LEVELS, CODEBUDDY_PERMISSION_MODES, CliAdapter, Config, DEFAULT_ARGS, DEFAULT_COMMAND, DEFAULT_DISPOSE_GRACE_MS, DEFAULT_MODEL_DISCOVERY_ARGS, DEFAULT_MODEL_DISCOVERY_TIMEOUT_MS, DEFAULT_PERMISSION_MODE, DEFAULT_SESSION_ID_ARG, DEFAULT_TRANSPORT, apply, conversationRecap, discoverCliModels, flattenConversation, inject, name, parseCliLine, parseCodeBuddyModels, plainOptions, resolveAdapterOptions, systemTextOf, textBlocksOf, trailingUserText };
|