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/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 };