dsh-packycode-api 1.0.6 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,19 +1,167 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { homedir, tmpdir } from "node:os";
2
3
  import { isAbsolute, join, posix, win32 } from "node:path";
3
- import { LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
4
- import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
5
- import { defineTool } from "@deepseek-ai/dsh-tools";
6
4
  import z from "@deepseek-ai/schemastery";
7
- import { homedir, tmpdir } from "node:os";
5
+ import { HarnessError, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
8
6
  import { spawn } from "node:child_process";
9
7
  import { existsSync, readFileSync, readdirSync } from "node:fs";
8
+ import { defineTool } from "@deepseek-ai/dsh-tools";
10
9
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
11
- import { readFile, readdir, realpath } from "node:fs/promises";
10
+ import { readFile, readdir, realpath, rename, writeFile } from "node:fs/promises";
12
11
  import { fileURLToPath } from "node:url";
12
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
13
13
  import { createProvider } from "@earendil-works/pi-ai";
14
14
  import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
15
15
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
16
16
  import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
17
+ import { AsyncLocalStorage } from "node:async_hooks";
18
+ //#region src/request.js
19
+ /**
20
+ * The host-side HTTP plumbing every reader shares: fetch resolved at call
21
+ * time, one request bounded by a timeout and the caller's cancellation with
22
+ * redirects refused, and a shared request one caller's cancellation cannot
23
+ * abort for the others.
24
+ *
25
+ * @module dsh-packycode-api/request
26
+ */
27
+ /**
28
+ * Resolve fetch at call time. A sibling plugin may wrap `globalThis.fetch`
29
+ * and later dismantle that wrapper; a binding captured during `apply()` would
30
+ * keep the dead wrapper for the process lifetime.
31
+ */
32
+ function ambientFetch(input, init) {
33
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
34
+ if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
35
+ return globalThis.fetch(input, {
36
+ ...init,
37
+ headers
38
+ });
39
+ }
40
+ /** One caller's cancellation must not abort a request other callers share. */
41
+ function waitForSharedRequest(promise, signal) {
42
+ if (signal === void 0) return promise;
43
+ signal.throwIfAborted();
44
+ return new Promise((resolve, reject) => {
45
+ const onAbort = () => reject(signal.reason);
46
+ signal.addEventListener("abort", onAbort, { once: true });
47
+ promise.then((value) => {
48
+ signal.removeEventListener("abort", onAbort);
49
+ resolve(value);
50
+ }, (error) => {
51
+ signal.removeEventListener("abort", onAbort);
52
+ reject(error);
53
+ });
54
+ });
55
+ }
56
+ /**
57
+ * A request that got no answer. Node's fetch reports every transport fault as
58
+ * a bare `fetch failed`; the message names the part a reader can act on.
59
+ */
60
+ var TransportError = class extends Error {
61
+ /**
62
+ * @param cause - what fetch threw.
63
+ * @param state - whether the timeout or the caller ended the request.
64
+ */
65
+ constructor(cause, { timedOut, aborted, timeoutMs }) {
66
+ const code = typeof cause?.cause?.code === "string" ? cause.cause.code : typeof cause?.code === "string" ? cause.code : void 0;
67
+ const text = cause instanceof Error ? cause.message : String(cause);
68
+ super(timedOut ? `no answer within ${Math.round(timeoutMs / 1e3)}s` : code === void 0 ? text : `${text} (${code})`, { cause });
69
+ this.name = "TransportError";
70
+ /** The system error code, when the transport reported one. */
71
+ this.code = code;
72
+ /** The timeout ended the request. */
73
+ this.timedOut = timedOut;
74
+ /** The caller ended the request. */
75
+ this.aborted = aborted;
76
+ }
77
+ };
78
+ /**
79
+ * One request, bounded by `timeoutMs` and the caller's `signal`, refusing
80
+ * redirects. A transport failure throws a {@link TransportError}.
81
+ * @param url - the target.
82
+ * @param init - fetch options; `redirect` and `signal` are set here.
83
+ * @param options - the fetch to use, the timeout, and the caller's signal.
84
+ * @returns the response, whatever its status.
85
+ */
86
+ async function boundedFetch(url, init, { fetchImpl = ambientFetch, timeoutMs, signal } = {}) {
87
+ const timeout = AbortSignal.timeout(timeoutMs);
88
+ const requestSignal = signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
89
+ try {
90
+ return await fetchImpl(url, {
91
+ ...init,
92
+ redirect: "error",
93
+ signal: requestSignal
94
+ });
95
+ } catch (error) {
96
+ const aborted = signal?.aborted === true;
97
+ throw new TransportError(error, {
98
+ aborted,
99
+ timedOut: !aborted && timeout.aborted,
100
+ timeoutMs
101
+ });
102
+ }
103
+ }
104
+ //#endregion
105
+ //#region src/cached-read.js
106
+ /**
107
+ * The read cache the balance, group, and version readers share.
108
+ *
109
+ * A read inside the TTL answers from the cache. Concurrent unforced reads
110
+ * share one request, and one caller cancelling does not abort it for the
111
+ * others. A forced read always goes out under the caller's own signal. A read
112
+ * that started before an invalidation never repopulates the cache.
113
+ *
114
+ * @module dsh-packycode-api/cached-read
115
+ */
116
+ const clone = (value) => structuredClone(value);
117
+ /**
118
+ * @param load - `(signal) => Promise<value>`; the value carries `fetchedAt`.
119
+ * @param options - the TTL and clock.
120
+ * @returns `{ read({ force, signal }), snapshot(), peek(), invalidate() }`.
121
+ */
122
+ function createCachedRead(load, { ttlMs, now = Date.now }) {
123
+ let cached;
124
+ let inFlight;
125
+ let generation = 0;
126
+ const start = (signal) => {
127
+ const observed = generation;
128
+ return load(signal).then((value) => {
129
+ if (generation === observed) cached = value;
130
+ return value;
131
+ });
132
+ };
133
+ return Object.freeze({
134
+ /** A copy of the current value; fresh from the network when forced or expired. */
135
+ async read({ force = false, signal } = {}) {
136
+ signal?.throwIfAborted();
137
+ if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone(cached);
138
+ if (force) return clone(await start(signal));
139
+ if (inFlight === void 0) {
140
+ const pending = start();
141
+ inFlight = pending;
142
+ const clear = () => {
143
+ if (inFlight === pending) inFlight = void 0;
144
+ };
145
+ pending.then(clear, clear);
146
+ }
147
+ return clone(await waitForSharedRequest(inFlight, signal));
148
+ },
149
+ /** A copy of the last value, however old, without a request. */
150
+ snapshot() {
151
+ return cached === void 0 ? void 0 : clone(cached);
152
+ },
153
+ /** The last value itself, for synchronous lookups that do not keep it. */
154
+ peek() {
155
+ return cached;
156
+ },
157
+ /** Drop the value; a read already in flight will not restore it. */
158
+ invalidate() {
159
+ generation += 1;
160
+ cached = void 0;
161
+ }
162
+ });
163
+ }
164
+ //#endregion
17
165
  //#region src/constants.js
18
166
  /**
19
167
  * Identities and defaults this plugin owns.
@@ -61,6 +209,63 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
61
209
  /** Effort level the route requests when a session names none. */
62
210
  const DEFAULT_REASONING_LEVEL = "high";
63
211
  //#endregion
212
+ //#region src/hosts.js
213
+ /**
214
+ * Which hosts a read goes to.
215
+ *
216
+ * PackyCode serves models and the billing endpoints from `cf.api.fan`, and
217
+ * the per-key `/api/*` family plus public pricing from the PackyAPI site
218
+ * (`cf.api.fan` answers 404 there). A saved legacy status host
219
+ * (`www.packyapi.com`, DNS-poisoned on mainland networks) is tried after the
220
+ * current default, never before it.
221
+ *
222
+ * @module dsh-packycode-api/hosts
223
+ */
224
+ /** Accept an absolute http(s) origin and drop a trailing slash. */
225
+ function normalizeHost(value, fallback) {
226
+ const text = typeof value === "string" ? value.trim() : "";
227
+ if (text === "") return fallback;
228
+ let url;
229
+ try {
230
+ url = new URL(text);
231
+ } catch {
232
+ return fallback;
233
+ }
234
+ if (url.protocol !== "https:" && url.protocol !== "http:") return fallback;
235
+ return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
236
+ }
237
+ const unique = (hosts) => [...new Set(hosts.filter((host) => typeof host === "string" && host !== ""))];
238
+ /**
239
+ * The status hosts one per-key read tries, in order: an explicit override,
240
+ * the current default, the legacy defaults, then the model hosts.
241
+ * @param hosts - the resolved host settings.
242
+ * @returns unique origins.
243
+ */
244
+ function statusHostOrder(hosts) {
245
+ const configured = hosts?.statusHost;
246
+ const override = configured === void 0 || LEGACY_STATUS_HOSTS.includes(configured) ? [] : [configured];
247
+ return unique([
248
+ ...override,
249
+ DEFAULT_STATUS_HOST,
250
+ ...LEGACY_STATUS_HOSTS,
251
+ hosts?.apiHost,
252
+ DEFAULT_API_HOST
253
+ ]);
254
+ }
255
+ /**
256
+ * The hosts the account read tries: the model hosts first (they serve the
257
+ * billing endpoints), then the status hosts.
258
+ * @param hosts - the resolved host settings.
259
+ * @returns unique origins.
260
+ */
261
+ function accountHostOrder(hosts) {
262
+ return unique([
263
+ hosts?.apiHost,
264
+ DEFAULT_API_HOST,
265
+ ...statusHostOrder(hosts)
266
+ ]);
267
+ }
268
+ //#endregion
64
269
  //#region src/balance.js
65
270
  /**
66
271
  * Account and per-key balance.
@@ -78,8 +283,7 @@ const DEFAULT_REASONING_LEVEL = "high";
78
283
  *
79
284
  * @module dsh-packycode-api/balance
80
285
  */
81
- const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
82
- const clone$1 = (value) => structuredClone(value);
286
+ const record$2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
83
287
  function number(value) {
84
288
  if (typeof value === "number" && Number.isFinite(value)) return value;
85
289
  if (typeof value === "string" && value.trim() !== "") {
@@ -107,8 +311,8 @@ function quotaFields(prefix, quota) {
107
311
  * @returns a small projection, or undefined when the shape is unrecognized.
108
312
  */
109
313
  function parseTokenUsage(payload) {
110
- const data = record(payload?.data) ? payload.data : payload;
111
- if (!record(data)) return void 0;
314
+ const data = record$2(payload?.data) ? payload.data : payload;
315
+ if (!record$2(data)) return void 0;
112
316
  if (![
113
317
  "name",
114
318
  "quota_limit",
@@ -132,7 +336,7 @@ function parseTokenUsage(payload) {
132
336
  }
133
337
  /** Normalize `/v1/dashboard/billing/subscription`. */
134
338
  function parseBillingSubscription(payload) {
135
- if (!record(payload)) return void 0;
339
+ if (!record$2(payload)) return void 0;
136
340
  return {
137
341
  ...number(payload.soft_limit_usd) === void 0 ? {} : { softLimitUsd: number(payload.soft_limit_usd) },
138
342
  ...number(payload.hard_limit_usd) === void 0 ? {} : { hardLimitUsd: number(payload.hard_limit_usd) },
@@ -142,7 +346,7 @@ function parseBillingSubscription(payload) {
142
346
  }
143
347
  /** Normalize `/v1/dashboard/billing/usage`. */
144
348
  function parseBillingUsage(payload) {
145
- if (!record(payload)) return void 0;
349
+ if (!record$2(payload)) return void 0;
146
350
  const total = number(payload.total_usage);
147
351
  return total === void 0 ? {} : { totalUsageUsd: Math.round(total * 1e6) / 1e6 };
148
352
  }
@@ -152,68 +356,24 @@ function statusMessage(status) {
152
356
  if (status === 429) return "the gateway is rate limiting balance reads (429)";
153
357
  return `the gateway answered HTTP ${status}`;
154
358
  }
155
- /**
156
- * Say why a request never got an answer. Node's fetch reports every transport
157
- * fault as a bare `fetch failed`; the cause carries the part a reader can act
158
- * on (a DNS failure, a refused or reset connection, a certificate problem).
159
- */
160
- function transportMessage(error, timeoutSignal, timeoutMs) {
161
- if (timeoutSignal.aborted) return `no answer within ${Math.round(timeoutMs / 1e3)}s`;
162
- const cause = error?.cause;
163
- const code = typeof cause?.code === "string" ? cause.code : typeof error?.code === "string" ? error.code : void 0;
164
- const message = error instanceof Error ? error.message : String(error);
165
- return code === void 0 ? message : `${message} (${code})`;
166
- }
167
359
  async function getJson(url, key, { fetchImpl, timeoutMs, signal }) {
168
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
169
- const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
170
360
  let response;
171
361
  try {
172
- response = await fetchImpl(url, {
173
- headers: {
174
- Authorization: `Bearer ${key}`,
175
- Accept: "application/json"
176
- },
177
- redirect: "error",
178
- signal: requestSignal
362
+ response = await boundedFetch(url, { headers: {
363
+ Authorization: `Bearer ${key}`,
364
+ Accept: "application/json"
365
+ } }, {
366
+ fetchImpl,
367
+ timeoutMs,
368
+ signal
179
369
  });
180
370
  } catch (error) {
181
- if (signal?.aborted === true) throw error;
182
- throw new Error(transportMessage(error, timeoutSignal, timeoutMs), { cause: error });
371
+ if (error.aborted) throw error.cause;
372
+ throw new Error(error.message, { cause: error });
183
373
  }
184
374
  if (!response.ok) throw new Error(statusMessage(response.status));
185
375
  return response.json();
186
376
  }
187
- /**
188
- * The status hosts one per-key read tries, in order: an explicit override,
189
- * the current default, the legacy defaults, then the model hosts.
190
- * @param hosts - the resolved host settings.
191
- * @returns unique origins.
192
- */
193
- function statusHostOrder(hosts) {
194
- const configured = hosts?.statusHost;
195
- const override = configured === void 0 || LEGACY_STATUS_HOSTS.includes(configured) ? [] : [configured];
196
- return [...new Set([
197
- ...override,
198
- DEFAULT_STATUS_HOST,
199
- ...LEGACY_STATUS_HOSTS,
200
- hosts?.apiHost,
201
- DEFAULT_API_HOST
202
- ].filter((host) => typeof host === "string" && host !== ""))];
203
- }
204
- /**
205
- * The hosts the account read tries: the model hosts first (they serve the
206
- * billing endpoints), then the status hosts.
207
- * @param hosts - the resolved host settings.
208
- * @returns unique origins.
209
- */
210
- function accountHostOrder(hosts) {
211
- return [...new Set([
212
- hosts?.apiHost,
213
- DEFAULT_API_HOST,
214
- ...statusHostOrder(hosts)
215
- ].filter((host) => typeof host === "string" && host !== ""))];
216
- }
217
377
  async function withFallback(hosts, path, key, options) {
218
378
  const tried = [];
219
379
  for (const host of hosts) try {
@@ -237,12 +397,9 @@ async function withFallback(hosts, path, key, options) {
237
397
  * @param options - key listing, host resolution, and transport knobs.
238
398
  * @returns `{ read({force, signal}), invalidate() }`.
239
399
  */
240
- function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date.now, ttlMs = DEFAULT_BALANCE_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
400
+ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now = Date.now, ttlMs = DEFAULT_BALANCE_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
241
401
  if (typeof listKeys !== "function") throw new Error("PackyCode balance reader requires listKeys");
242
402
  if (typeof getHosts !== "function") throw new Error("PackyCode balance reader requires getHosts");
243
- let cached;
244
- let inFlight;
245
- let generation = 0;
246
403
  /** Last successful per-key usage, by key id, and the last account figures. */
247
404
  const lastGood = /* @__PURE__ */ new Map();
248
405
  let lastAccount;
@@ -359,70 +516,225 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
359
516
  fetchedAt: now()
360
517
  };
361
518
  };
519
+ const cache = createCachedRead(load, {
520
+ ttlMs,
521
+ now
522
+ });
362
523
  return Object.freeze({
363
- async read({ force = false, signal } = {}) {
364
- signal?.throwIfAborted();
365
- if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone$1(cached);
366
- const createPending = (requestSignal) => {
367
- const observedGeneration = generation;
368
- return load(requestSignal).then((value) => {
369
- if (generation === observedGeneration) cached = value;
370
- return value;
371
- });
372
- };
373
- if (force) return clone$1(await createPending(signal));
374
- const pending = inFlight ?? createPending();
375
- if (inFlight === void 0) {
376
- inFlight = pending;
377
- const clear = () => {
378
- if (inFlight === pending) inFlight = void 0;
379
- };
380
- pending.then(clear, clear);
381
- }
382
- return clone$1(await pending);
383
- },
384
- invalidate() {
385
- generation += 1;
386
- cached = void 0;
387
- }
524
+ read: (options) => cache.read(options),
525
+ invalidate: () => cache.invalidate()
388
526
  });
389
527
  }
390
528
  //#endregion
391
- //#region src/failures.js
529
+ //#region src/catalog.js
392
530
  /**
393
- * Failure classification for the two conditions that move the key ring along.
531
+ * The built-in PackyCode model catalog.
394
532
  *
395
- * The gateway reports an exhausted key and a rejected key in Chinese, and the
396
- * harness classifies failures by fixed English/status wording it recognizes
397
- * (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
398
- * harness's retry policy re-run the step — and therefore what lets the retry
399
- * pick up the next key. Rewriting is honest about what happened: this plugin
400
- * knows the key's condition from the gateway's own words plus its balance
401
- * reader, so the sentence it substitutes says no more than that.
533
+ * The gateway's own guidance for these routes, which the base URLs and pinned
534
+ * protocols below exist to satisfy:
535
+ * - OpenAI/Codex traffic takes `/v1` in the base URL and never an endpoint
536
+ * path — the SDK appends `/responses` or `/chat/completions` itself.
537
+ * - Anthropic traffic takes `https://cf.api.fan` with **no** `/v1`: the
538
+ * Anthropic client appends `/v1/messages` itself.
539
+ * - A model asked over the wrong protocol does not fail cleanly; the stream
540
+ * ends early. So every model pins the protocol its vendor serves here.
402
541
  *
403
- * @module dsh-packycode-api/failures
542
+ * Effort levels come from each vendor's documentation, because an effort the
543
+ * model does not support is refused before the request is sent: Claude Opus
544
+ * 5.5 (platform.claude.com effort docs), GPT-6.1 Sol and GPT-6 Astra
545
+ * `reasoning.effort` (developers.openai.com model pages), DeepSeek thinking
546
+ * mode (`thinking` toggle plus `low|high|max` effort).
547
+ *
548
+ * @module dsh-packycode-api/catalog
404
549
  */
405
- /** Terminal quota, balance, or credit wording, in the gateway's own languages. */
406
- const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
407
- /** A rejected key names itself; a bare 403 does not, and must not disable one. */
408
- const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
409
- const INVALID_KEY_STATUS = /\b401\b/;
550
+ /** pi-ai never reads this gateway's pricing; the harness zeroes cost anyway. */
551
+ const NO_COST = Object.freeze({
552
+ input: 0,
553
+ output: 0,
554
+ cacheRead: 0,
555
+ cacheWrite: 0
556
+ });
557
+ const COMPLETIONS_BASE = `${DEFAULT_API_HOST}/v1`;
558
+ const RESPONSES_BASE = `${DEFAULT_API_HOST}/v1`;
559
+ const MESSAGES_BASE = DEFAULT_API_HOST;
410
560
  /**
411
- * A gateway group that only serves its vendor's official client. These arrive
412
- * as 403, which the harness reads as an authentication failure and renders as
413
- * an invalid-API-key problem — the opposite of what happened, and a condition
414
- * no retry can clear.
561
+ * pi-ai thinking-level maps. A level pinned to `null` is unsupported; an
562
+ * absent key keeps pi-ai's own default for that level, which is how `off`
563
+ * stays available for DeepSeek (`off` sends nothing and the deepseek dialect
564
+ * then emits `thinking: disabled`).
415
565
  */
416
- const CLIENT_POLICY_WORDING = /only accessible via the official|only accepts requests from the official|official\s+claude(?:\s+code)?\s+cli|官方\s*claude(?:\s*code)?\s*cli|请使用标准\s*codex\s*客户端|客户端存在异常|二次分发|API\s*转接/iu;
417
- /** The key's quota is exhausted; move to the next key. */
418
- const QUOTA_FAILURE = "quota";
419
- /** The gateway rejected the key itself; move to the next key. */
420
- const INVALID_KEY_FAILURE = "invalid-key";
566
+ const DEEPSEEK_LEVELS = Object.freeze({
567
+ minimal: null,
568
+ low: "low",
569
+ medium: null,
570
+ high: "high",
571
+ xhigh: null,
572
+ max: "max"
573
+ });
421
574
  /**
422
- * Whether the gateway is refusing this client rather than this credential.
423
- * @param message - provider failure text, as pi-ai carries it.
424
- * @returns true when the route is gated to a vendor's official client.
425
- */
575
+ * Claude 5.5 models, GPT-6.1 Sol, and GPT-6 Astra cannot disable reasoning, so `off` is
576
+ * pinned unsupported. The Claude models run through the CLI, which picks its
577
+ * own effort, so for them these levels only shape the picker.
578
+ */
579
+ const ALWAYS_THINKING_LEVELS = Object.freeze({
580
+ off: null,
581
+ minimal: null,
582
+ low: "low",
583
+ medium: "medium",
584
+ high: "high",
585
+ xhigh: "xhigh",
586
+ max: "max"
587
+ });
588
+ const PACKYCODE_MODELS = Object.freeze([
589
+ Object.freeze({
590
+ id: "deepseek-flash",
591
+ name: "DeepSeek Flash",
592
+ api: "openai-completions",
593
+ provider: PROVIDER,
594
+ baseUrl: COMPLETIONS_BASE,
595
+ reasoning: true,
596
+ input: Object.freeze(["text"]),
597
+ cost: NO_COST,
598
+ contextWindow: 1e6,
599
+ maxTokens: 384e3,
600
+ thinkingLevelMap: DEEPSEEK_LEVELS,
601
+ compat: Object.freeze({
602
+ thinkingFormat: "deepseek",
603
+ maxTokensField: "max_tokens",
604
+ requiresReasoningContentOnAssistantMessages: true,
605
+ supportsDeveloperRole: false
606
+ })
607
+ }),
608
+ Object.freeze({
609
+ id: "gpt-6.1-sol",
610
+ name: "GPT-6.1 Sol",
611
+ api: "openai-responses",
612
+ provider: PROVIDER,
613
+ baseUrl: RESPONSES_BASE,
614
+ reasoning: true,
615
+ input: Object.freeze(["text", "image"]),
616
+ cost: NO_COST,
617
+ contextWindow: 105e4,
618
+ maxTokens: 128e3,
619
+ thinkingLevelMap: ALWAYS_THINKING_LEVELS
620
+ }),
621
+ Object.freeze({
622
+ id: "gpt-6-astra",
623
+ name: "GPT-6 Astra",
624
+ api: "openai-responses",
625
+ provider: PROVIDER,
626
+ baseUrl: RESPONSES_BASE,
627
+ reasoning: true,
628
+ input: Object.freeze(["text", "image"]),
629
+ cost: NO_COST,
630
+ contextWindow: 105e4,
631
+ maxTokens: 128e3,
632
+ thinkingLevelMap: ALWAYS_THINKING_LEVELS
633
+ }),
634
+ Object.freeze({
635
+ id: "claude-opus-5-5",
636
+ name: "Claude Opus 5.5",
637
+ api: "anthropic-messages",
638
+ provider: PROVIDER,
639
+ baseUrl: MESSAGES_BASE,
640
+ reasoning: true,
641
+ input: Object.freeze(["text", "image"]),
642
+ cost: NO_COST,
643
+ contextWindow: 1e6,
644
+ maxTokens: 128e3,
645
+ thinkingLevelMap: ALWAYS_THINKING_LEVELS,
646
+ compat: Object.freeze({
647
+ forceAdaptiveThinking: true,
648
+ supportsTemperature: false
649
+ })
650
+ }),
651
+ Object.freeze({
652
+ id: "claude-sonnet-5-5",
653
+ name: "Claude Sonnet 5.5",
654
+ api: "anthropic-messages",
655
+ provider: PROVIDER,
656
+ baseUrl: MESSAGES_BASE,
657
+ reasoning: true,
658
+ input: Object.freeze(["text", "image"]),
659
+ cost: NO_COST,
660
+ contextWindow: 1e6,
661
+ maxTokens: 128e3,
662
+ thinkingLevelMap: ALWAYS_THINKING_LEVELS,
663
+ compat: Object.freeze({
664
+ forceAdaptiveThinking: true,
665
+ supportsTemperature: false
666
+ })
667
+ }),
668
+ Object.freeze({
669
+ id: "claude-haiku-5-5",
670
+ name: "Claude Haiku 5.5",
671
+ api: "anthropic-messages",
672
+ provider: PROVIDER,
673
+ baseUrl: MESSAGES_BASE,
674
+ reasoning: true,
675
+ input: Object.freeze(["text", "image"]),
676
+ cost: NO_COST,
677
+ contextWindow: 1e6,
678
+ maxTokens: 128e3,
679
+ thinkingLevelMap: ALWAYS_THINKING_LEVELS,
680
+ compat: Object.freeze({
681
+ forceAdaptiveThinking: true,
682
+ supportsTemperature: false
683
+ })
684
+ })
685
+ ]);
686
+ /** Every model id this catalog advertises, in picker order. */
687
+ const PACKYCODE_MODEL_IDS = Object.freeze(PACKYCODE_MODELS.map((model) => model.id));
688
+ //#endregion
689
+ //#region src/failures.js
690
+ /**
691
+ * Failure classification for the two conditions that move the key ring along.
692
+ *
693
+ * The gateway reports an exhausted key and a rejected key in Chinese, and the
694
+ * harness classifies failures by fixed English/status wording it recognizes
695
+ * (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
696
+ * harness's retry policy re-run the step — and therefore what lets the retry
697
+ * pick up the next key. Rewriting is honest about what happened: this plugin
698
+ * knows the key's condition from the gateway's own words plus its balance
699
+ * reader, so the sentence it substitutes says no more than that.
700
+ *
701
+ * @module dsh-packycode-api/failures
702
+ */
703
+ /** Terminal quota, balance, or credit wording, in the gateway's own languages. */
704
+ const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
705
+ /** A rejected key names itself; a bare 403 does not, and must not disable one. */
706
+ const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
707
+ const INVALID_KEY_STATUS = /\b401\b/;
708
+ /**
709
+ * A gateway group that only serves its vendor's official client. These arrive
710
+ * as 403, which the harness reads as an authentication failure and renders as
711
+ * an invalid-API-key problem — the opposite of what happened, and a condition
712
+ * no retry can clear.
713
+ */
714
+ const CLIENT_POLICY_WORDING = /only accessible via the official|only accepts requests from the official|official\s+claude(?:\s+code)?\s+cli|官方\s*claude(?:\s*code)?\s*cli|请使用标准\s*codex\s*客户端|客户端存在异常|二次分发|API\s*转接/iu;
715
+ /**
716
+ * The gateway's answer when a key's groups do not include the model's
717
+ * supplier: `model_not_found` / 当前令牌未覆盖供应商. The key is fine for other
718
+ * models, so it is never moved aside for this.
719
+ */
720
+ const COVERAGE_WORDING = /model_not_found|未覆盖供应商|无可用渠道|no available channel/iu;
721
+ /**
722
+ * Whether the gateway refused the model for this key's groups.
723
+ * @param message - the failure text.
724
+ * @returns true for a group-coverage refusal.
725
+ */
726
+ function classifyCoverage(message) {
727
+ return COVERAGE_WORDING.test(typeof message === "string" ? message : "");
728
+ }
729
+ /** The key's quota is exhausted; move to the next key. */
730
+ const QUOTA_FAILURE = "quota";
731
+ /** The gateway rejected the key itself; move to the next key. */
732
+ const INVALID_KEY_FAILURE = "invalid-key";
733
+ /**
734
+ * Whether the gateway is refusing this client rather than this credential.
735
+ * @param message - provider failure text, as pi-ai carries it.
736
+ * @returns true when the route is gated to a vendor's official client.
737
+ */
426
738
  function classifyClientPolicy(message) {
427
739
  return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
428
740
  }
@@ -507,6 +819,24 @@ function explainCliClientPolicyFailure(message) {
507
819
  return `PackyCode 的上游拒绝了这次 Claude Code CLI 请求,认为它不是官方 Claude Code CLI 发出的。这不是 API Key 失效。请先把插件和 Claude Code CLI 都更新到最新版本再试;仍然失败时,请把下面的原始错误(含 request id)发给 PackyCode 客服,确认所用分组是否支持 Claude Code 的 -p(非交互)模式。 / The gateway refused this Claude Code CLI request as not coming from the official CLI; the API key is fine. Gateway said: ${(typeof message === "string" ? message : "").replace(/^Claude Code CLI failed:\s*/u, "").replace(/\b(?:401|403)\b/gu, "HTTP").slice(0, 600)}`;
508
820
  }
509
821
  /**
822
+ * Explain a CLI refusal that came from the key, not the CLI: the key's groups
823
+ * cannot reach the requested model. PackyCode answers such a Claude request
824
+ * with the same "only the official Claude Code CLI" wording a real client
825
+ * fault gets, so without the key's groups it read as a CLI problem and sent
826
+ * the user to update the CLI.
827
+ * @param key - the run's key: label, groups, the model, and the groups that
828
+ * can serve it, as the group reader last saw them.
829
+ * @param message - the CLI failure text.
830
+ * @returns the replacement sentence, with the gateway's own words kept.
831
+ */
832
+ function explainCliGroupFailure(key, message) {
833
+ const detail = (typeof message === "string" ? message : "").replace(/^Claude Code CLI failed:\s*/u, "").replace(/\b(?:400|401|403)\b/gu, "HTTP").slice(0, 400);
834
+ const groups = Array.isArray(key?.groups) && key.groups.length > 0 ? key.groups.join("、") : "未读到";
835
+ const required = Array.isArray(key?.required) && key.required.length > 0 ? key.required.join(" / ") : "cc / cc-sale / cc-expensive / claude-officially";
836
+ const model = typeof key?.model === "string" && key.model !== "" ? key.model : "Claude";
837
+ return `PackyCode 拒绝了这次 ${model} 请求:本轮使用的 Key「${key?.label ?? "?"}」所在分组(${groups})不包含能用 ${model} 的分组(${required})。这不是 Claude Code CLI 的问题,不需要更新 CLI。请在 设置 → PackyCode 添加分组包含上述分组的 Key(每个 Key 后面会列出它的分组),插件会自动用它跑 Claude;或在 PackyCode 控制台给这个 Key 加上对应分组。 / The key "${key?.label ?? "?"}" is in no group that serves ${model} (needs one of: ${required}); the CLI is fine. Gateway said: ${detail}`;
838
+ }
839
+ /**
510
840
  * Rewrite a failure message so the harness classifies it as quota or auth.
511
841
  *
512
842
  * A bare status token is neutralized first: an exhausted balance arrives as
@@ -529,96 +859,444 @@ function neutralizeAuthStatus(text) {
529
859
  return text.replace(/\b(?:401|403)\b/gu, "HTTP");
530
860
  }
531
861
  //#endregion
532
- //#region src/claude-adapter.js
862
+ //#region src/groups.js
533
863
  /**
534
- * Route Claude models through the local Claude Code CLI.
864
+ * Which PackyCode token groups each stored key belongs to, and which catalog
865
+ * models those groups can serve.
535
866
  *
536
- * PackyCode's `cc` group serves its Claude models only to the official CLI, so
537
- * selecting one of those models runs that CLI instead of an HTTP request. The
538
- * other models on the route keep using the pi-ai adapter untouched — this
539
- * adapter only dispatches.
867
+ * PackyCode routes a request by the token's groups: a key without a Claude
868
+ * Code group (`cc`, `cc-sale`, …) is refused for Claude, a key without a Codex
869
+ * group for GPT. None of that is visible from the key itself, so a key that
870
+ * cannot serve the selected model looked exactly like a client or CLI fault.
540
871
  *
541
- * A CLI-backed turn is a *delegation*: the CLI runs its own agent loop with
542
- * its own tools and returns one answer. The CLI cannot call the harness's
543
- * tools, so `options.tools` is deliberately not forwarded, and the harness's
544
- * system prompt is dropped in favour of the CLI's own. The delegation
545
- * instructions travel as `--append-system-prompt`; the recent conversation
546
- * travels as one user message whose images are real image blocks, read from
547
- * the attachment store, so the CLI's model sees them as images. While it
548
- * works, its thinking and tool activity stream into a reasoning block and its
549
- * answer streams as text.
872
+ * Three host-side reads answer it, none of them billed:
873
+ * - the gateway refuses a model id it cannot route before contacting any
874
+ * upstream, and its refusal names the token's groups
875
+ * (`已选分组=[codex deepseek-officially kimi-sale]`);
876
+ * - `/v1/models` lists the models the key can reach;
877
+ * - the public `/api/pricing` lists each model's groups and each group's
878
+ * description.
550
879
  *
551
- * A session-title request is not a delegation: it runs on the small model,
552
- * read-only and outside the workspace, with the title instructions appended,
553
- * so naming a session does not cost an Opus agent turn.
880
+ * Every request is bounded by a timeout and refuses redirects. Nothing here
881
+ * returns key material: the projection carries group names, their public
882
+ * descriptions, and per-model booleans.
554
883
  *
555
- * @module dsh-packycode-api/claude-adapter
884
+ * @module dsh-packycode-api/groups
556
885
  */
557
- /** Models this route serves through the CLI rather than an HTTP API. */
558
- const CLI_BACKED_MODEL_IDS = Object.freeze([
559
- "claude-opus-5-5",
560
- "claude-sonnet-5-5",
561
- "claude-haiku-5-5"
562
- ]);
563
- /** The model a session title is generated with when the session runs on the CLI. */
564
- const TITLE_MODEL_ID = "claude-haiku-5-5";
565
- /** How much of the conversation a delegated prompt carries. */
566
- const MAX_PROMPT_CHARS$1 = 48e3;
567
- const MAX_BLOCK_CHARS = 8e3;
568
- const TRUNCATED_MARK = "…(较早对话已截断)";
886
+ /** A model id no gateway routes; its refusal is what names the groups. */
887
+ const GROUP_PROBE_MODEL = "dsh-packycode-group-probe";
888
+ /** Group membership changes rarely; a forced read bypasses this. */
889
+ const DEFAULT_GROUP_TTL_MS = 10 * 6e4;
890
+ const SELECTED_GROUPS = /已选分组\s*[==::]\s*\[([^\]]*)\]/u;
891
+ const GROUP_NAME = /^[A-Za-z0-9._-]{1,64}$/u;
892
+ const INVALID_KEY = /无效的令牌|令牌无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication/iu;
893
+ const MAX_GROUPS = 32;
894
+ const MAX_DESCRIPTION = 80;
895
+ /** Keys read at once. */
896
+ const KEY_CONCURRENCY = 4;
897
+ const record$1 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
898
+ /** Run `task` over `items`, at most `limit` at a time, keeping their order. */
899
+ async function mapLimited(items, limit, task) {
900
+ const results = new Array(items.length);
901
+ let next = 0;
902
+ const worker = async () => {
903
+ while (next < items.length) {
904
+ const index = next;
905
+ next += 1;
906
+ results[index] = await task(items[index], index);
907
+ }
908
+ };
909
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
910
+ return results;
911
+ }
912
+ /** The gateway refused the key itself; it reaches no model at all. */
913
+ var RejectedKeyError = class extends Error {
914
+ constructor() {
915
+ super("the gateway rejected this key");
916
+ }
917
+ };
569
918
  /**
570
- * Image bounds for one delegated turn. Each image is re-encoded by the
571
- * attachment store to the same request version the HTTP route sends (at most
572
- * 2048×2048 pixels, about 1 MiB); the oldest images are left out once the turn
573
- * would carry more than 20 of them or 20 MiB of base64.
919
+ * Read the token's groups out of the gateway's routing refusal.
920
+ * @param message - the refusal text.
921
+ * @returns the group names, or undefined when the text names none.
574
922
  */
575
- const CLI_IMAGE_LIMITS = Object.freeze({
576
- maxPixels: 2048 * 2048,
577
- maxBytesPerImage: 1024 * 1024,
578
- maxTotalBase64Bytes: 20 * 1024 * 1024,
579
- maxImages: 20
580
- });
581
- /** The vendor's per-image cap on base64 data; a fallback read above it is not sent. */
582
- const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024;
583
- const base64Length = (bytes) => Math.ceil(bytes / 3) * 4;
584
- /** A short name for an image or file a reader can match to the chat. */
585
- function attachmentName(ref) {
586
- if (typeof ref?.name === "string" && ref.name !== "") return ref.name;
587
- const id = String(ref?.attachmentId ?? "");
588
- return id.startsWith("sha256:") ? id.slice(7, 15) : id.slice(0, 8);
923
+ function parseSelectedGroups(message) {
924
+ const match = SELECTED_GROUPS.exec(typeof message === "string" ? message : "");
925
+ if (match === null) return void 0;
926
+ return [...new Set(match[1].split(/[\s,,、]+/u).filter((name) => GROUP_NAME.test(name)))].slice(0, MAX_GROUPS);
589
927
  }
590
- /** The host path of a stored attachment, when the store is file-backed. */
591
- function hostPath(lookup) {
928
+ /**
929
+ * Normalize `/api/pricing`: the groups each catalog model is enabled in, and
930
+ * each group's public description.
931
+ * @param payload - the parsed pricing body.
932
+ * @returns `{ modelGroups, descriptions }`, or undefined when unrecognized.
933
+ */
934
+ function parsePricing(payload) {
935
+ if (!record$1(payload) || !Array.isArray(payload.data)) return void 0;
936
+ const modelGroups = {};
937
+ for (const item of payload.data) {
938
+ if (!record$1(item) || !PACKYCODE_MODEL_IDS.includes(item.model_name) || !Array.isArray(item.enable_groups)) continue;
939
+ modelGroups[item.model_name] = [...new Set(item.enable_groups.filter((name) => typeof name === "string" && GROUP_NAME.test(name)))].sort();
940
+ }
941
+ const descriptions = {};
942
+ if (record$1(payload.usable_group)) for (const [name, description] of Object.entries(payload.usable_group)) {
943
+ if (!GROUP_NAME.test(name) || typeof description !== "string") continue;
944
+ const clean = description.replace(/\s+/gu, " ").trim().slice(0, MAX_DESCRIPTION);
945
+ if (clean !== "") descriptions[name] = clean;
946
+ }
947
+ return {
948
+ modelGroups,
949
+ descriptions
950
+ };
951
+ }
952
+ /**
953
+ * Which catalog models a key can reach. Either source decides: a model the
954
+ * key's groups are enabled for, or one its model list shows. A model is only
955
+ * marked unreachable on evidence; with neither source it is left unknown.
956
+ * @param options - the key's groups, its model ids, and pricing's model groups.
957
+ * @returns catalog model id → reachable, for the models with evidence.
958
+ */
959
+ function catalogCoverage({ groups, models, modelGroups }) {
960
+ const coverage = {};
961
+ for (const id of PACKYCODE_MODEL_IDS) {
962
+ const enabled = modelGroups?.[id];
963
+ const byGroups = groups === void 0 || enabled === void 0 ? void 0 : enabled.some((name) => groups.includes(name));
964
+ const byModels = models === void 0 ? void 0 : models.has(id);
965
+ if (byGroups === true || byModels === true) coverage[id] = true;
966
+ else if (byGroups === false || byModels === false) coverage[id] = false;
967
+ }
968
+ return coverage;
969
+ }
970
+ async function bodyText(response) {
592
971
  try {
593
- const value = lookup();
594
- return typeof value === "string" && value !== "" ? value : void 0;
972
+ return await response.text();
595
973
  } catch {
596
- return;
974
+ return "";
597
975
  }
598
976
  }
977
+ function errorMessage(text) {
978
+ try {
979
+ const parsed = JSON.parse(text);
980
+ const message = parsed?.error?.message ?? parsed?.message;
981
+ if (typeof message === "string") return message;
982
+ } catch {}
983
+ return text;
984
+ }
599
985
  /**
600
- * Flatten one message's content into ordered parts: text, image, and file.
601
- * Tool calls and results become short text so a turn that started on another
602
- * model still reads coherently; reasoning is not carried.
986
+ * Cached, single-flight group reader.
987
+ * @param options - key listing, host resolution, and transport knobs.
988
+ * @returns `{ read, snapshot, covers, requiredGroups, invalidate }`.
603
989
  */
604
- function collectParts(blocks, parts) {
605
- for (const block of Array.isArray(blocks) ? blocks : []) {
606
- if (block === null || typeof block !== "object") continue;
607
- switch (block.type) {
608
- case "text":
609
- if (typeof block.text === "string" && block.text !== "") parts.push({
610
- kind: "text",
611
- text: block.text
612
- });
613
- break;
614
- case "image":
615
- if (block.attachment !== void 0) parts.push({
616
- kind: "image",
617
- ref: block.attachment
618
- });
619
- else parts.push({
620
- kind: "text",
621
- text: "[图片]"
990
+ function createGroupReader({ listKeys, getHosts, fetchImpl = ambientFetch, now = Date.now, ttlMs = DEFAULT_GROUP_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
991
+ if (typeof listKeys !== "function") throw new Error("PackyCode group reader requires listKeys");
992
+ if (typeof getHosts !== "function") throw new Error("PackyCode group reader requires getHosts");
993
+ /** Last good reading per key id, kept when a later read of that key fails. */
994
+ const lastGood = /* @__PURE__ */ new Map();
995
+ let lastPricing;
996
+ const request = (url, init, signal) => boundedFetch(url, init, {
997
+ fetchImpl,
998
+ timeoutMs,
999
+ signal
1000
+ });
1001
+ const loadPricing = async (hosts, signal) => {
1002
+ const tried = [];
1003
+ for (const host of statusHostOrder(hosts)) try {
1004
+ const response = await request(`${host}/api/pricing`, { headers: { Accept: "application/json" } }, signal);
1005
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1006
+ const pricing = parsePricing(await response.json());
1007
+ if (pricing === void 0) throw new Error("unrecognized pricing payload");
1008
+ return pricing;
1009
+ } catch (error) {
1010
+ signal?.throwIfAborted();
1011
+ tried.push(`${host}: ${error.message}`);
1012
+ }
1013
+ throw new Error(tried.join("; "));
1014
+ };
1015
+ /** The token's groups, from the gateway's refusal of a model it cannot route. */
1016
+ const loadGroups = async (entry, hosts, signal) => {
1017
+ const response = await request(`${hosts.apiHost}/v1/chat/completions`, {
1018
+ method: "POST",
1019
+ headers: {
1020
+ Authorization: `Bearer ${entry.key}`,
1021
+ Accept: "application/json",
1022
+ "Content-Type": "application/json"
1023
+ },
1024
+ body: JSON.stringify({
1025
+ model: GROUP_PROBE_MODEL,
1026
+ max_tokens: 1,
1027
+ messages: [{
1028
+ role: "user",
1029
+ content: "."
1030
+ }]
1031
+ })
1032
+ }, signal);
1033
+ const message = errorMessage(await bodyText(response));
1034
+ if (response.status === 401 || INVALID_KEY.test(message)) throw new RejectedKeyError();
1035
+ return parseSelectedGroups(message);
1036
+ };
1037
+ /** The models the key can reach. */
1038
+ const loadModels = async (entry, hosts, signal) => {
1039
+ const response = await request(`${hosts.apiHost}/v1/models`, { headers: {
1040
+ Authorization: `Bearer ${entry.key}`,
1041
+ Accept: "application/json"
1042
+ } }, signal);
1043
+ if (response.status === 401) throw new RejectedKeyError();
1044
+ if (!response.ok) return void 0;
1045
+ const payload = await response.json();
1046
+ if (!Array.isArray(payload?.data)) return void 0;
1047
+ return new Set(payload.data.map((item) => item?.id).filter((id) => typeof id === "string"));
1048
+ };
1049
+ /**
1050
+ * One key's groups and model list. The two are independent evidence: one
1051
+ * failing in transit does not discard the other, and a rejection from
1052
+ * either means the gateway refuses the key itself.
1053
+ */
1054
+ const readKey = async (entry, hosts, signal) => {
1055
+ const results = await Promise.allSettled([loadGroups(entry, hosts, signal), loadModels(entry, hosts, signal)]);
1056
+ if (results.some((result) => result.status === "rejected" && result.reason instanceof RejectedKeyError)) return { rejected: true };
1057
+ const [groups, models] = results.map((result) => result.status === "fulfilled" ? result.value : void 0);
1058
+ if (groups === void 0 && models === void 0) throw results.find((result) => result.status === "rejected")?.reason ?? /* @__PURE__ */ new Error("the gateway named no groups for this key");
1059
+ return {
1060
+ groups,
1061
+ models
1062
+ };
1063
+ };
1064
+ /**
1065
+ * Pricing and every key are read concurrently (a few keys at a time), so a
1066
+ * slow or broken key does not hold up the reading for the others.
1067
+ */
1068
+ const load = async (signal) => {
1069
+ signal?.throwIfAborted();
1070
+ const hosts = getHosts();
1071
+ const keys = await listKeys();
1072
+ for (const id of lastGood.keys()) if (!keys.some((entry) => entry.id === id)) lastGood.delete(id);
1073
+ const pricingTask = loadPricing(hosts, signal).then((pricing) => {
1074
+ lastPricing = pricing;
1075
+ return pricing;
1076
+ }, () => {
1077
+ signal?.throwIfAborted();
1078
+ return lastPricing;
1079
+ });
1080
+ const readings = await mapLimited(keys, KEY_CONCURRENCY, (entry) => readKey(entry, hosts, signal).then((value) => ({ value }), (error) => ({ error })));
1081
+ const pricing = await pricingTask;
1082
+ signal?.throwIfAborted();
1083
+ return {
1084
+ keys: keys.map((entry, index) => {
1085
+ const { value, error } = readings[index];
1086
+ if (value?.rejected === true) {
1087
+ lastGood.delete(entry.id);
1088
+ return {
1089
+ id: entry.id,
1090
+ rejected: true,
1091
+ coverage: Object.fromEntries(PACKYCODE_MODEL_IDS.map((id) => [id, false])),
1092
+ error: new RejectedKeyError().message
1093
+ };
1094
+ }
1095
+ if (value !== void 0) {
1096
+ const reading = {
1097
+ id: entry.id,
1098
+ ...value.groups === void 0 ? {} : { groups: value.groups },
1099
+ coverage: catalogCoverage({
1100
+ groups: value.groups,
1101
+ models: value.models,
1102
+ modelGroups: pricing?.modelGroups
1103
+ })
1104
+ };
1105
+ lastGood.set(entry.id, {
1106
+ value: reading,
1107
+ fetchedAt: now()
1108
+ });
1109
+ return reading;
1110
+ }
1111
+ const message = error instanceof Error ? error.message : String(error);
1112
+ const previous = lastGood.get(entry.id);
1113
+ return previous === void 0 ? {
1114
+ id: entry.id,
1115
+ coverage: {},
1116
+ error: message
1117
+ } : {
1118
+ ...previous.value,
1119
+ stale: {
1120
+ fetchedAt: previous.fetchedAt,
1121
+ error: message
1122
+ }
1123
+ };
1124
+ }),
1125
+ descriptions: pricing?.descriptions ?? {},
1126
+ requirements: pricing?.modelGroups ?? {},
1127
+ fetchedAt: now()
1128
+ };
1129
+ };
1130
+ const cache = createCachedRead(load, {
1131
+ ttlMs,
1132
+ now
1133
+ });
1134
+ const keyEntry = (keyId) => cache.peek()?.keys.find((candidate) => candidate.id === keyId);
1135
+ return Object.freeze({
1136
+ /**
1137
+ * Read every key's groups; cached for the TTL unless forced.
1138
+ * @returns the projection the settings panel shows.
1139
+ */
1140
+ read: (options) => cache.read(options),
1141
+ /** The last reading, however old, without touching the network. */
1142
+ snapshot: () => cache.snapshot(),
1143
+ /** Whether any reading exists yet. */
1144
+ hasReading: () => cache.peek() !== void 0,
1145
+ /**
1146
+ * Whether a key's groups reach a model, from the last reading.
1147
+ * @returns true, false, or undefined when there is no evidence.
1148
+ */
1149
+ covers(keyId, modelId) {
1150
+ const value = keyEntry(keyId)?.coverage?.[modelId];
1151
+ return typeof value === "boolean" ? value : void 0;
1152
+ },
1153
+ /** The groups pricing lists for a model, from the last reading. */
1154
+ requiredGroups(modelId) {
1155
+ const groups = cache.peek()?.requirements?.[modelId];
1156
+ return Array.isArray(groups) ? [...groups] : void 0;
1157
+ },
1158
+ /** The groups the last reading found for a key. */
1159
+ groupsOf(keyId) {
1160
+ const groups = keyEntry(keyId)?.groups;
1161
+ return Array.isArray(groups) ? [...groups] : void 0;
1162
+ },
1163
+ /** Drop the reading so the next read goes to the network. */
1164
+ invalidate: () => cache.invalidate()
1165
+ });
1166
+ }
1167
+ /**
1168
+ * Pick the key a request for `modelId` runs with.
1169
+ *
1170
+ * The active key serves every model it is not known to be excluded from.
1171
+ * When the last reading shows its groups cannot reach the model, the next
1172
+ * usable key in ring order that can — or, failing that, one with no evidence
1173
+ * either way — serves this request instead. The active pointer itself does
1174
+ * not move; with no better key the active one is returned, marked uncovered.
1175
+ * @param modelId - the catalog model of this request.
1176
+ * @param active - the ring's active key.
1177
+ * @param usable - the ring's usable keys, in ring order.
1178
+ * @param covers - `(keyId, modelId) => true | false | undefined`.
1179
+ * @returns the chosen key, with `switchedFrom` or `uncovered` when it is not
1180
+ * simply the active key.
1181
+ */
1182
+ function pickKeyForModel(modelId, active, usable, covers) {
1183
+ if (active === void 0) return void 0;
1184
+ if (typeof modelId !== "string" || covers(active.id, modelId) !== false) return { ...active };
1185
+ const start = usable.findIndex((entry) => entry.id === active.id);
1186
+ const others = start < 0 ? usable.filter((entry) => entry.id !== active.id) : [...usable.slice(start + 1), ...usable.slice(0, start)];
1187
+ const pick = others.find((entry) => covers(entry.id, modelId) === true) ?? others.find((entry) => covers(entry.id, modelId) === void 0);
1188
+ if (pick === void 0) return {
1189
+ ...active,
1190
+ uncovered: true
1191
+ };
1192
+ return {
1193
+ id: pick.id,
1194
+ label: pick.label,
1195
+ key: pick.key,
1196
+ switchedFrom: {
1197
+ id: active.id,
1198
+ label: active.label
1199
+ }
1200
+ };
1201
+ }
1202
+ //#endregion
1203
+ //#region src/claude-adapter.js
1204
+ /**
1205
+ * Route Claude models through the local Claude Code CLI.
1206
+ *
1207
+ * PackyCode's `cc` group serves its Claude models only to the official CLI, so
1208
+ * selecting one of those models runs that CLI instead of an HTTP request. The
1209
+ * other models on the route keep using the pi-ai adapter untouched — this
1210
+ * adapter only dispatches.
1211
+ *
1212
+ * A CLI-backed turn is a *delegation*: the CLI runs its own agent loop with
1213
+ * its own tools and returns one answer. The CLI cannot call the harness's
1214
+ * tools, so `options.tools` is deliberately not forwarded, and the harness's
1215
+ * system prompt is dropped in favour of the CLI's own. The delegation
1216
+ * instructions travel as `--append-system-prompt`; the recent conversation
1217
+ * travels as one user message whose images are real image blocks, read from
1218
+ * the attachment store, so the CLI's model sees them as images. While it
1219
+ * works, its thinking and tool activity stream into a reasoning block and its
1220
+ * answer streams as text.
1221
+ *
1222
+ * A session-title request is not a delegation: it runs on the small model,
1223
+ * read-only and outside the workspace, with the title instructions appended,
1224
+ * so naming a session does not cost an Opus agent turn.
1225
+ *
1226
+ * @module dsh-packycode-api/claude-adapter
1227
+ */
1228
+ /** Models this route serves through the CLI rather than an HTTP API. */
1229
+ const CLI_BACKED_MODEL_IDS = Object.freeze([
1230
+ "claude-opus-5-5",
1231
+ "claude-sonnet-5-5",
1232
+ "claude-haiku-5-5"
1233
+ ]);
1234
+ /** The model a session title is generated with when the session runs on the CLI. */
1235
+ const TITLE_MODEL_ID = "claude-haiku-5-5";
1236
+ /**
1237
+ * HTTP models whose session titles run on another model of the same route.
1238
+ * A title is a few words, so the flagship `gpt-6-astra` hands it to
1239
+ * `gpt-6.1-sol`, which the same Codex group serves; the session's own turns
1240
+ * are untouched.
1241
+ */
1242
+ const TITLE_MODEL_ROUTES = Object.freeze({ "gpt-6-astra": "gpt-6.1-sol" });
1243
+ /** How much of the conversation a delegated prompt carries. */
1244
+ const MAX_PROMPT_CHARS$1 = 48e3;
1245
+ const MAX_BLOCK_CHARS = 8e3;
1246
+ const TRUNCATED_MARK = "…(较早对话已截断)";
1247
+ /**
1248
+ * Image bounds for one delegated turn. Each image is re-encoded by the
1249
+ * attachment store to the same request version the HTTP route sends (at most
1250
+ * 2048×2048 pixels, about 1 MiB); the oldest images are left out once the turn
1251
+ * would carry more than 20 of them or 20 MiB of base64.
1252
+ */
1253
+ const CLI_IMAGE_LIMITS = Object.freeze({
1254
+ maxPixels: 2048 * 2048,
1255
+ maxBytesPerImage: 1024 * 1024,
1256
+ maxTotalBase64Bytes: 20 * 1024 * 1024,
1257
+ maxImages: 20
1258
+ });
1259
+ /** The vendor's per-image cap on base64 data; a fallback read above it is not sent. */
1260
+ const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024;
1261
+ const base64Length = (bytes) => Math.ceil(bytes / 3) * 4;
1262
+ /** A short name for an image or file a reader can match to the chat. */
1263
+ function attachmentName(ref) {
1264
+ if (typeof ref?.name === "string" && ref.name !== "") return ref.name;
1265
+ const id = String(ref?.attachmentId ?? "");
1266
+ return id.startsWith("sha256:") ? id.slice(7, 15) : id.slice(0, 8);
1267
+ }
1268
+ /** The host path of a stored attachment, when the store is file-backed. */
1269
+ function hostPath(lookup) {
1270
+ try {
1271
+ const value = lookup();
1272
+ return typeof value === "string" && value !== "" ? value : void 0;
1273
+ } catch {
1274
+ return;
1275
+ }
1276
+ }
1277
+ /**
1278
+ * Flatten one message's content into ordered parts: text, image, and file.
1279
+ * Tool calls and results become short text so a turn that started on another
1280
+ * model still reads coherently; reasoning is not carried.
1281
+ */
1282
+ function collectParts(blocks, parts) {
1283
+ for (const block of Array.isArray(blocks) ? blocks : []) {
1284
+ if (block === null || typeof block !== "object") continue;
1285
+ switch (block.type) {
1286
+ case "text":
1287
+ if (typeof block.text === "string" && block.text !== "") parts.push({
1288
+ kind: "text",
1289
+ text: block.text
1290
+ });
1291
+ break;
1292
+ case "image":
1293
+ if (block.attachment !== void 0) parts.push({
1294
+ kind: "image",
1295
+ ref: block.attachment
1296
+ });
1297
+ else parts.push({
1298
+ kind: "text",
1299
+ text: "[图片]"
622
1300
  });
623
1301
  break;
624
1302
  case "file":
@@ -989,6 +1667,23 @@ function turnUsage(lastRequest, finished) {
989
1667
  cacheWriteTokens: finished?.cacheWriteTokens ?? 0
990
1668
  };
991
1669
  }
1670
+ /**
1671
+ * One line naming the key a CLI run uses — by its label only — so a key
1672
+ * switch is visible on the very next turn, and a key passed over for its
1673
+ * groups is never a silent change.
1674
+ * @param key - the runner's `key` event.
1675
+ * @returns the reasoning note, newline-terminated.
1676
+ */
1677
+ function keyNote(key) {
1678
+ const model = typeof key.model === "string" && key.model !== "" ? key.model : "Claude";
1679
+ const groups = Array.isArray(key.groups) && key.groups.length > 0 ? `(分组:${key.groups.join("、")})` : "";
1680
+ if (key.switchedFrom !== void 0) return `▸ 当前 Key「${key.switchedFrom}」的分组不能用 ${model},本轮改用 Key「${key.label}」${groups}\n`;
1681
+ if (key.uncovered === true) {
1682
+ const required = Array.isArray(key.required) && key.required.length > 0 ? `(需要 ${key.required.join(" / ")})` : "";
1683
+ return `▸ 使用 Key「${key.label}」${groups}——注意:已存的 Key 都没有能用 ${model} 的分组${required}\n`;
1684
+ }
1685
+ return `▸ 使用 Key「${key.label}」${groups}\n`;
1686
+ }
992
1687
  /** One line describing a retry the CLI is making on its own. */
993
1688
  function retryNote(event) {
994
1689
  return `网关返回 ${event.status === void 0 ? "出错" : `HTTP ${event.status}`}${typeof event.error === "string" && event.error !== "" ? `(${event.error})` : ""},CLI 自动重试${event.attempt === void 0 ? "" : event.maxRetries === void 0 ? ` ${event.attempt}` : ` ${event.attempt}/${event.maxRetries}`}${event.delayMs === void 0 ? "" : `,${(event.delayMs / 1e3).toFixed(1)} 秒后`}`;
@@ -1025,14 +1720,25 @@ async function cliCall(options, { titleModel, cwdOf, modeOf, attachmentsOf, imag
1025
1720
  * Wrap an adapter so the CLI-backed models run through the local CLI.
1026
1721
  *
1027
1722
  * @param options - the inner adapter, the CLI runner, the CLI-backed model ids,
1028
- * the model session titles run on, the mode and working directory each
1029
- * turn runs with, and the attachment store images are read from.
1723
+ * the model session titles run on, the HTTP models whose titles run on
1724
+ * another model, the mode and working directory each turn runs with, the
1725
+ * attachment store images are read from, and an optional refresh of a run's
1726
+ * key description (its groups) for explaining a refusal.
1030
1727
  * @returns an adapter with the same provider surface.
1031
1728
  */
1032
- function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, resolveMode, resolveCwd, resolveAttachments, imageLimits = CLI_IMAGE_LIMITS }) {
1729
+ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, titleRoutes = TITLE_MODEL_ROUTES, resolveMode, resolveCwd, resolveAttachments, imageLimits = CLI_IMAGE_LIMITS, explainKey }) {
1033
1730
  if (inner === void 0 || inner === null) throw new Error("the CLI adapter needs an inner adapter");
1034
1731
  if (runner === void 0 || runner === null) throw new Error("the CLI adapter needs a runner");
1035
1732
  const cliModels = new Set(modelIds);
1733
+ /** The model a title request for `model` runs on, when it is rerouted. */
1734
+ const titleRouteOf = (options) => options?.purpose === "session-title" && !cliModels.has(options.model) ? Object.hasOwn(titleRoutes, options.model) ? titleRoutes[options.model] : void 0 : void 0;
1735
+ /** Run a rerouted title on its own model, bound to that model's generation. */
1736
+ async function* reroutedTitle(options, model) {
1737
+ yield* (await inner.prepareCall(options.provider, model, options.signal)).stream({
1738
+ ...options,
1739
+ model
1740
+ });
1741
+ }
1036
1742
  /** The mode is read per turn so a settings change applies without a reload. */
1037
1743
  const modeOf = () => typeof resolveMode === "function" ? resolveMode() : void 0;
1038
1744
  const cwdOf = () => typeof resolveCwd === "function" ? resolveCwd() : void 0;
@@ -1043,6 +1749,34 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
1043
1749
  return;
1044
1750
  }
1045
1751
  };
1752
+ /**
1753
+ * Say why a CLI run failed. The request came from the official CLI, so a
1754
+ * client-policy refusal gets its own explanation rather than the HTTP
1755
+ * route's advice. PackyCode refuses a key without a Claude group either as
1756
+ * a routing miss (400 未覆盖供应商, which names the key's groups) or with the
1757
+ * official-CLI wording (403); either way the key is the cause, not the CLI.
1758
+ * When the run started before the key's groups were known, a fresh reading
1759
+ * is consulted first. A busy gateway is named as such after the CLI's own
1760
+ * retries ran out.
1761
+ */
1762
+ async function explainFailure(raw, runKey, { model, retries }) {
1763
+ const policy = classifyClientPolicy(raw);
1764
+ const coverage = classifyCoverage(raw);
1765
+ if (!policy && !coverage) return classifyCapacity(raw) ? explainCliCapacityFailure(raw, retries) : raw;
1766
+ let key = runKey === void 0 ? void 0 : {
1767
+ model,
1768
+ ...runKey
1769
+ };
1770
+ if (key !== void 0 && key.covers !== true && typeof explainKey === "function") key = await explainKey(key).catch(() => key) ?? key;
1771
+ if (coverage) return explainCliGroupFailure({
1772
+ model,
1773
+ ...key,
1774
+ groups: parseSelectedGroups(raw) ?? key?.groups
1775
+ }, raw);
1776
+ if (key?.covers === false) return explainCliGroupFailure(key, raw);
1777
+ const used = key === void 0 ? "" : `(本轮使用 Key「${key.label}」${Array.isArray(key.groups) ? `,分组:${key.groups.join("、")}` : ""})`;
1778
+ return `${explainCliClientPolicyFailure(raw)}${used}`;
1779
+ }
1046
1780
  async function* cliStream(options) {
1047
1781
  const quiet = options?.purpose === "session-title";
1048
1782
  const blocks = createBlockWriter();
@@ -1078,6 +1812,8 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
1078
1812
  return true;
1079
1813
  };
1080
1814
  let retries = 0;
1815
+ /** The key this run uses, as the runner described it (never the token). */
1816
+ let runKey;
1081
1817
  try {
1082
1818
  let finished;
1083
1819
  let lastRequest;
@@ -1085,6 +1821,10 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
1085
1821
  ...call,
1086
1822
  signal: options.signal
1087
1823
  })) switch (event.type) {
1824
+ case "key":
1825
+ runKey = event;
1826
+ if (!quiet) yield* blocks.note(keyNote(event));
1827
+ break;
1088
1828
  case "started":
1089
1829
  if (!quiet) {
1090
1830
  const detail = [event.version, event.model].filter((part) => typeof part === "string" && part !== "").join(" · ");
@@ -1129,14 +1869,16 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
1129
1869
  };
1130
1870
  } catch (error) {
1131
1871
  yield* blocks.close();
1132
- const raw = String(error?.message ?? error);
1133
1872
  yield {
1134
1873
  type: "finish",
1135
1874
  reason: {
1136
1875
  kind: "error",
1137
1876
  failure: {
1138
1877
  code: "PI_AI_ERROR",
1139
- message: classifyClientPolicy(raw) ? explainCliClientPolicyFailure(raw) : classifyCapacity(raw) ? explainCliCapacityFailure(raw, retries) : raw
1878
+ message: await explainFailure(String(error?.message ?? error), runKey, {
1879
+ model: options.model,
1880
+ retries
1881
+ })
1140
1882
  }
1141
1883
  }
1142
1884
  };
@@ -1156,14 +1898,26 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
1156
1898
  return inner.resolveModel(provider, model, signal);
1157
1899
  }
1158
1900
  async prepareCall(provider, model, signal) {
1159
- if (!cliModels.has(model)) return await inner.prepareCall(provider, model, signal);
1901
+ if (!cliModels.has(model)) {
1902
+ const prepared = await inner.prepareCall(provider, model, signal);
1903
+ if (!Object.hasOwn(titleRoutes, model)) return prepared;
1904
+ return {
1905
+ ...prepared,
1906
+ stream: (options) => {
1907
+ const routed = titleRouteOf(options);
1908
+ return routed === void 0 ? prepared.stream(options) : reroutedTitle(options, routed);
1909
+ }
1910
+ };
1911
+ }
1160
1912
  return {
1161
1913
  model: await inner.resolveModel(provider, model, signal),
1162
1914
  stream: (options) => cliStream(options)
1163
1915
  };
1164
1916
  }
1165
1917
  stream(options) {
1166
- return cliModels.has(options.model) ? cliStream(options) : inner.stream(options);
1918
+ if (cliModels.has(options.model)) return cliStream(options);
1919
+ const routed = titleRouteOf(options);
1920
+ return routed === void 0 ? inner.stream(options) : reroutedTitle(options, routed);
1167
1921
  }
1168
1922
  }();
1169
1923
  }
@@ -1692,6 +2446,31 @@ function readClaudeStreamEvents(event) {
1692
2446
  }];
1693
2447
  return [];
1694
2448
  }
2449
+ const nameList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string" && item !== "").slice(0, 32) : void 0;
2450
+ /**
2451
+ * What a run may say about the key it used: its ring id and label, why it was
2452
+ * chosen, and the groups involved. Never the token.
2453
+ * @param credential - what `resolveCredential` answered.
2454
+ * @returns the description, or undefined when the credential carries no label.
2455
+ */
2456
+ function describeRunKey(credential) {
2457
+ if (credential === null || typeof credential !== "object") return void 0;
2458
+ const label = typeof credential.label === "string" && credential.label !== "" ? credential.label.slice(0, 60) : void 0;
2459
+ if (label === void 0) return void 0;
2460
+ const groups = nameList(credential.groups);
2461
+ const required = nameList(credential.required);
2462
+ const from = typeof credential.switchedFrom?.label === "string" && credential.switchedFrom.label !== "" ? credential.switchedFrom.label.slice(0, 60) : void 0;
2463
+ return {
2464
+ ...typeof credential.id === "string" && credential.id !== "" ? { id: credential.id } : {},
2465
+ label,
2466
+ ...typeof credential.model === "string" && credential.model !== "" ? { model: credential.model } : {},
2467
+ ...groups === void 0 ? {} : { groups },
2468
+ ...required === void 0 ? {} : { required },
2469
+ ...typeof credential.covers === "boolean" ? { covers: credential.covers } : {},
2470
+ ...from === void 0 ? {} : { switchedFrom: from },
2471
+ ...credential.uncovered === true ? { uncovered: true } : {}
2472
+ };
2473
+ }
1695
2474
  /**
1696
2475
  * A Claude Code CLI runner.
1697
2476
  * @param options - binary (a path, or a function resolving one per run so a
@@ -1719,7 +2498,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
1719
2498
  content,
1720
2499
  stream
1721
2500
  });
1722
- const { baseUrl, token } = await resolveCredential();
2501
+ const credential = await resolveCredential({ model });
2502
+ const { baseUrl, token } = credential;
1723
2503
  const program = typeof binary === "function" ? await binary() : binary;
1724
2504
  const command = resolveSpawnCommand(program, { platform });
1725
2505
  const env = withProgramDirOnPath(buildClaudeEnvironment({
@@ -1823,7 +2603,8 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
1823
2603
  child,
1824
2604
  state,
1825
2605
  closed,
1826
- kill
2606
+ kill,
2607
+ key: describeRunKey(credential)
1827
2608
  };
1828
2609
  };
1829
2610
  return Object.freeze({
@@ -1872,7 +2653,7 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
1872
2653
  let buffer = "";
1873
2654
  let sawResult;
1874
2655
  const lines = [];
1875
- const { state, closed, kill } = await start({
2656
+ const { state, closed, kill, key } = await start({
1876
2657
  prompt,
1877
2658
  content,
1878
2659
  mode,
@@ -1891,6 +2672,10 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
1891
2672
  }
1892
2673
  }
1893
2674
  });
2675
+ if (key !== void 0) yield {
2676
+ type: "key",
2677
+ ...key
2678
+ };
1894
2679
  const drain = function* () {
1895
2680
  while (lines.length > 0) {
1896
2681
  const line = lines.shift().trim();
@@ -2326,67 +3111,350 @@ function createClaudeCliManager({ explicitPath = () => "", platform = process.pl
2326
3111
  });
2327
3112
  }
2328
3113
  //#endregion
2329
- //#region src/key-ring.js
3114
+ //#region src/permission.js
2330
3115
  /**
2331
- * The PackyCode key ring.
3116
+ * The Claude Code CLI mode a session's own permissions allow.
2332
3117
  *
2333
- * Keys live in one DSH credential *record* (`packycode-api/keys`), which is
2334
- * the cross-process-serialized read-decide-replace space the credential
2335
- * service owns. A key the gateway exhausts or rejects is marked unusable and
2336
- * the ring moves the active pointer to the next usable key, so the next
2337
- * attempt — the harness retry of the failed step — resolves a different key
2338
- * through the adapter's per-request `resolveApiKey`.
3118
+ * A CLI-backed turn runs the CLI's own tool loop, outside the harness sandbox
3119
+ * and approval flow. Deriving its mode from the session's permission preset
3120
+ * means the user grants it once, in the place they already grant the harness
3121
+ * itself, instead of in a separate plugin setting.
2339
3122
  *
2340
- * @module dsh-packycode-api/key-ring
3123
+ * @module dsh-packycode-api/permission
2341
3124
  */
2342
- /** Printable ASCII without spaces: what an HTTP header can carry unchanged. */
2343
- const LEGAL_KEY = /^[\x21-\x7E]+$/u;
2344
- const RECORD_VERSION = 1;
2345
- const MAX_KEYS = 32;
2346
- const RECORD_KEY = typeof credentialKey === "function" ? credentialKey(PLUGIN_ID, KEY_RECORD_ID) : void 0;
2347
- const text = (value) => typeof value === "string" ? value : "";
2348
- /** Validate a pasted key before it is stored; the gateway never sees a bad one from us. */
2349
- function assertApiKey(value) {
2350
- const key = text(value).trim();
2351
- if (key.length < 8 || key.length > 512 || !LEGAL_KEY.test(key)) throw new Error("PackyCode API key must be 8-512 printable ASCII characters without spaces");
2352
- return key;
2353
- }
2354
- function entry(value) {
2355
- if (value === null || typeof value !== "object") return void 0;
2356
- const key = text(value.key);
2357
- if (key === "" || !LEGAL_KEY.test(key)) return void 0;
2358
- const id = text(value.id);
2359
- return {
2360
- id: id === "" ? randomUUID().slice(0, 8) : id,
2361
- label: text(value.label),
2362
- key,
2363
- addedAt: Number.isFinite(value.addedAt) ? value.addedAt : 0,
2364
- ...Number.isFinite(value.disabledAt) ? { disabledAt: value.disabledAt } : {},
2365
- ...text(value.disabledReason) === "" ? {} : { disabledReason: text(value.disabledReason) }
2366
- };
2367
- }
2368
- /** Tolerate a hand-edited or older record instead of failing the whole route. */
2369
- function normalizeState(record) {
2370
- const payload = record !== null && typeof record === "object" && record.kind === "grant" && record.payload !== null && typeof record.payload === "object" ? record.payload : void 0;
2371
- const keys = (Array.isArray(payload?.keys) ? payload.keys : []).map(entry).filter((candidate) => candidate !== void 0);
2372
- const activeId = text(payload?.activeId);
2373
- return {
2374
- activeId: keys.some((candidate) => candidate.id === activeId) ? activeId : keys[0]?.id,
2375
- keys
2376
- };
2377
- }
3125
+ /** Configured value meaning "follow the session's permission preset". */
3126
+ const AUTO_CLAUDE_MODE = "auto";
3127
+ const RANK = Object.freeze({
3128
+ "read-only": 0,
3129
+ edit: 1,
3130
+ full: 2
3131
+ });
2378
3132
  /**
2379
- * The next free `Key N` name. The ring shrinks on removal, so numbering follows
2380
- * the highest name in use instead of the current key count — otherwise removing
2381
- * the first key and adding another produces a second "Key 2".
2382
- * @param keys - the stored keys.
2383
- * @returns an unused default label.
3133
+ * Map the harness's two permission knobs onto a CLI mode.
3134
+ *
3135
+ * The CLI cannot ask the harness user for approval, so it skips its own
3136
+ * permission checks only when the session itself neither sandboxes nor asks.
3137
+ * @param sandbox - the session's resolved sandbox mode.
3138
+ * @param approval - the session's effective approval policy.
3139
+ * @returns a CLI mode, or `undefined` for an unknown sandbox mode.
2384
3140
  */
2385
- function nextDefaultLabel(keys) {
2386
- const used = new Set(keys.map((candidate) => candidate.label));
2387
- let index = keys.length + 1;
2388
- while (used.has(`Key ${index}`)) index += 1;
2389
- return `Key ${index}`;
3141
+ function claudeModeFor(sandbox, approval) {
3142
+ switch (sandbox) {
3143
+ case "read-only": return "read-only";
3144
+ case "workspace-write": return "edit";
3145
+ case "danger-full-access": return approval === "never" ? "full" : "edit";
3146
+ default: return;
3147
+ }
3148
+ }
3149
+ /**
3150
+ * Read a session's CLI mode from the host's permission services.
3151
+ * @param ctx - the plugin context; services are read without an inject requirement.
3152
+ * @param session - the session whose permission state is read.
3153
+ * @returns a CLI mode, or `undefined` when the session or the services are absent.
3154
+ */
3155
+ function claudeModeForSession(ctx, session) {
3156
+ if (session === void 0 || session === null) return void 0;
3157
+ try {
3158
+ const sandbox = ctx.get?.("sandboxPolicy")?.resolve?.({ session })?.mode;
3159
+ const approval = ctx.get?.("approval")?.effectivePolicy?.(session);
3160
+ return claudeModeFor(sandbox, approval);
3161
+ } catch {
3162
+ return;
3163
+ }
3164
+ }
3165
+ /**
3166
+ * The narrower of two modes; an unknown ceiling leaves the request as it is.
3167
+ * @param requested - the mode a caller asked for.
3168
+ * @param ceiling - the most the session allows.
3169
+ * @returns the mode the CLI runs with.
3170
+ */
3171
+ function capClaudeMode(requested, ceiling) {
3172
+ if (ceiling === void 0 || RANK[ceiling] === void 0) return requested;
3173
+ if (requested === void 0 || RANK[requested] === void 0) return ceiling;
3174
+ return RANK[requested] > RANK[ceiling] ? ceiling : requested;
3175
+ }
3176
+ //#endregion
3177
+ //#region src/claude-tool.js
3178
+ /**
3179
+ * The explicit `claude_code` delegation tool, for asking Claude Code for
3180
+ * something while another model drives the session. It shares the runner the
3181
+ * CLI-backed models use.
3182
+ *
3183
+ * @module dsh-packycode-api/claude-tool
3184
+ */
3185
+ /** What a `claude_code` tool run is told about where its task came from. */
3186
+ const CLAUDE_TOOL_INSTRUCTIONS = "This task was delegated to Claude Code by another agent in DeepSeek Harness through its claude_code tool. The task text is self-contained; complete it with your own tools and reply with the final result only.";
3187
+ /**
3188
+ * @param options - the CLI runner, and the most a run may do for a session.
3189
+ * @returns the tool definition to register.
3190
+ */
3191
+ function createClaudeCodeTool({ runner, modeCeiling }) {
3192
+ return defineTool({
3193
+ name: "claude_code",
3194
+ description: "Delegate a task to the local Claude Code CLI (the official `claude` binary), which runs it with its own tools and returns its final answer. Use it when the user asks for Claude or when a task suits Claude Code. The child runs with this plugin's PackyCode endpoint and API key, in an isolated config home. `mode` bounds what it may do on its own: \"read-only\" (Read/Grep/Glob), \"edit\" (also Edit/Write), \"full\" (bypasses the CLI's permission checks, including shell commands); it defaults to, and is capped at, what this session's permission preset allows. It starts a fresh CLI session per call, takes tens of seconds, and bills through the same PackyCode key. Its internal tool calls are not part of this session's transcript — only the returned answer is.",
3195
+ parameters: {
3196
+ prompt: {
3197
+ type: "string",
3198
+ required: true,
3199
+ description: "The task for Claude Code, written as a self-contained instruction (it does not see this conversation)."
3200
+ },
3201
+ mode: {
3202
+ type: "string",
3203
+ enum: Object.keys(CLAUDE_CLI_MODES),
3204
+ description: "What the CLI may do on its own. Defaults to the most this session's permission preset allows; a wider mode is capped to it. Ask for \"full\" only when the task genuinely needs shell commands."
3205
+ },
3206
+ model: {
3207
+ type: "string",
3208
+ description: "Model id passed to the CLI (for example \"claude-opus-5-5\", \"claude-sonnet-5-5\", or \"claude-haiku-5-5\"). Omit to use the CLI's own default."
3209
+ },
3210
+ cwd: {
3211
+ type: "string",
3212
+ description: "Working directory for the CLI. Defaults to this session's workspace; a relative path resolves against it."
3213
+ }
3214
+ },
3215
+ output: {
3216
+ schema: {
3217
+ type: "object",
3218
+ additionalProperties: false,
3219
+ properties: {
3220
+ result: {
3221
+ type: "string",
3222
+ required: true
3223
+ },
3224
+ model: {
3225
+ type: "string",
3226
+ required: true
3227
+ },
3228
+ mode: {
3229
+ type: "string",
3230
+ required: true
3231
+ },
3232
+ costUsd: {
3233
+ type: "number",
3234
+ required: true
3235
+ },
3236
+ durationMs: {
3237
+ type: "number",
3238
+ required: true
3239
+ }
3240
+ }
3241
+ },
3242
+ render: (_args, value) => [{
3243
+ type: "text",
3244
+ text: `${value.result}\n\n— Claude Code CLI · ${value.model || "default model"} · ${value.mode} · ${Math.round(value.durationMs / 1e3)}s · $${value.costUsd.toFixed(4)}`
3245
+ }]
3246
+ },
3247
+ async execute(args, exec) {
3248
+ const sessionCwd = exec?.agent?.session?.header?.cwd;
3249
+ const requested = typeof args.cwd === "string" ? args.cwd.trim() : "";
3250
+ const cwd = requested === "" ? sessionCwd : isAbsolute(requested) ? requested : sessionCwd === void 0 ? requested : join(sessionCwd, requested);
3251
+ return await runner.run({
3252
+ prompt: args.prompt,
3253
+ mode: capClaudeMode(args.mode, modeCeiling(exec?.agent?.session)) ?? "edit",
3254
+ model: args.model,
3255
+ appendSystemPrompt: CLAUDE_TOOL_INSTRUCTIONS,
3256
+ cwd,
3257
+ signal: exec?.signal
3258
+ });
3259
+ }
3260
+ });
3261
+ }
3262
+ //#endregion
3263
+ //#region src/key-chooser.js
3264
+ /**
3265
+ * Which stored key a request runs with, and what happens when the gateway
3266
+ * rejects one.
3267
+ *
3268
+ * The ring's active key serves every request it can. A request for a model
3269
+ * the active key's groups cannot reach — as the last group reading shows —
3270
+ * runs on the next stored key that can; the active pointer itself does not
3271
+ * move. A key the gateway reports as exhausted or invalid is marked unusable
3272
+ * and the ring moves on, so the retry of the failed step resolves the next
3273
+ * key. Only keys stored in this plugin's ring are ever chosen.
3274
+ *
3275
+ * @module dsh-packycode-api/key-chooser
3276
+ */
3277
+ /** How long a request with a choice of keys waits for the first group reading. */
3278
+ const DEFAULT_GROUP_WAIT_MS = 6e3;
3279
+ /**
3280
+ * @param options - the key ring, the group reader, what to do after a key
3281
+ * moved aside, a logger, and how long to wait for the first group reading.
3282
+ * @returns the chooser.
3283
+ */
3284
+ function createKeyChooser({ ring, groups, onRotated, logger, waitMs = DEFAULT_GROUP_WAIT_MS }) {
3285
+ /**
3286
+ * Key material to ring id. A failure is reported from inside a stream, which
3287
+ * carries the request's key but not its id; this index is refreshed on every
3288
+ * resolution so the right key is the one that moves aside.
3289
+ */
3290
+ const idByKey = /* @__PURE__ */ new Map();
3291
+ /** The ring as of the last resolution, for the synchronous per-request choice. */
3292
+ let snapshot = [];
3293
+ const remember = async () => {
3294
+ const entries = await ring.secrets();
3295
+ snapshot = entries;
3296
+ for (const entry of entries) idByKey.set(entry.key, entry.id);
3297
+ };
3298
+ const usable = () => snapshot.filter((entry) => entry.disabled !== true);
3299
+ const covers = (keyId, modelId) => groups.covers(keyId, modelId);
3300
+ /**
3301
+ * Keep the group reading fresh for the choice. It only matters with two
3302
+ * usable keys or more; the first reading is awaited for at most `waitMs`,
3303
+ * later ones refresh in the background.
3304
+ */
3305
+ const warm = async () => {
3306
+ if (usable().length < 2) return;
3307
+ const pending = groups.read().catch(() => void 0);
3308
+ if (groups.hasReading()) return;
3309
+ let timer;
3310
+ await Promise.race([pending, new Promise((resolve) => {
3311
+ timer = setTimeout(resolve, waitMs);
3312
+ })]);
3313
+ clearTimeout(timer);
3314
+ };
3315
+ /** The active key, after refreshing the ring and, when it matters, the groups. */
3316
+ const active = async () => {
3317
+ await remember();
3318
+ const key = await ring.activeKey();
3319
+ if (key !== void 0) await warm();
3320
+ return key;
3321
+ };
3322
+ return Object.freeze({
3323
+ remember,
3324
+ covers,
3325
+ active,
3326
+ /**
3327
+ * The key one HTTP request for `modelId` runs with, given the active key
3328
+ * pi-ai resolved. Synchronous because pi-ai opens its stream synchronously;
3329
+ * it reads the ring and groups as of the last {@link active}.
3330
+ * @returns the key material to send.
3331
+ */
3332
+ forRequest(modelId, apiKey) {
3333
+ const current = snapshot.find((entry) => entry.key === apiKey);
3334
+ if (current === void 0) return apiKey;
3335
+ const chosen = pickKeyForModel(modelId, current, usable(), covers);
3336
+ if (chosen?.switchedFrom !== void 0) logger?.info?.("packycode-api: key %s cannot reach %s by its last group reading; this request uses key %s", current.id, modelId, chosen.id);
3337
+ return chosen?.key ?? apiKey;
3338
+ },
3339
+ /**
3340
+ * The key one CLI run for `modelId` uses, with what the run may say about
3341
+ * it: its label, its groups, the groups the model needs, and why it was
3342
+ * chosen.
3343
+ * @returns the choice, or undefined when no usable key is stored.
3344
+ */
3345
+ async forRun(modelId) {
3346
+ const current = await active();
3347
+ if (current === void 0) return void 0;
3348
+ const chosen = pickKeyForModel(modelId, current, usable(), covers);
3349
+ return {
3350
+ ...chosen,
3351
+ model: modelId,
3352
+ groups: groups.groupsOf(chosen.id),
3353
+ required: groups.requiredGroups(modelId),
3354
+ covers: covers(chosen.id, modelId)
3355
+ };
3356
+ },
3357
+ /**
3358
+ * Refresh what a run said about its key from the group reading, waiting
3359
+ * at most `waitMs` for a reading in flight.
3360
+ * @param runKey - the run's key description (`id`, `model`, …).
3361
+ * @returns the description with the reading's groups and coverage.
3362
+ */
3363
+ async describe(runKey) {
3364
+ let timer;
3365
+ await Promise.race([groups.read().catch(() => void 0), new Promise((resolve) => {
3366
+ timer = setTimeout(resolve, waitMs);
3367
+ })]);
3368
+ clearTimeout(timer);
3369
+ if (typeof runKey?.id !== "string") return runKey;
3370
+ return {
3371
+ ...runKey,
3372
+ groups: groups.groupsOf(runKey.id) ?? runKey.groups,
3373
+ required: groups.requiredGroups(runKey.model) ?? runKey.required,
3374
+ covers: covers(runKey.id, runKey.model) ?? runKey.covers
3375
+ };
3376
+ },
3377
+ /**
3378
+ * The gateway exhausted or rejected a key: mark it unusable so the ring
3379
+ * moves on, then let the caller drop what depended on it.
3380
+ * @param kind - `quota` or `invalid-key`.
3381
+ * @param _message - the failure text (unused; the kind decides).
3382
+ * @param key - the key material the failed request carried.
3383
+ * @param knownId - the key's ring id, when the caller has it.
3384
+ */
3385
+ onKeyFailure(kind, _message, key, knownId) {
3386
+ const id = knownId ?? (key === void 0 ? void 0 : idByKey.get(key));
3387
+ if (id === void 0) {
3388
+ logger?.warn?.("packycode-api: %s reported for an unidentified key; not rotating", kind);
3389
+ return;
3390
+ }
3391
+ logger?.warn?.("packycode-api: %s on key %s; the next key serves the retry", kind, id);
3392
+ ring.disable(id, kind).then(() => onRotated?.()).catch((error) => logger?.warn?.("packycode-api: could not move past key %s: %s", id, error.message));
3393
+ }
3394
+ });
3395
+ }
3396
+ //#endregion
3397
+ //#region src/key-ring.js
3398
+ /**
3399
+ * The PackyCode key ring.
3400
+ *
3401
+ * Keys live in one DSH credential *record* (`packycode-api/keys`), which is
3402
+ * the cross-process-serialized read-decide-replace space the credential
3403
+ * service owns. A key the gateway exhausts or rejects is marked unusable and
3404
+ * the ring moves the active pointer to the next usable key, so the next
3405
+ * attempt — the harness retry of the failed step — resolves a different key
3406
+ * through the adapter's per-request `resolveApiKey`.
3407
+ *
3408
+ * @module dsh-packycode-api/key-ring
3409
+ */
3410
+ /** Printable ASCII without spaces: what an HTTP header can carry unchanged. */
3411
+ const LEGAL_KEY = /^[\x21-\x7E]+$/u;
3412
+ const RECORD_VERSION = 1;
3413
+ const MAX_KEYS = 32;
3414
+ const RECORD_KEY = typeof credentialKey === "function" ? credentialKey(PLUGIN_ID, KEY_RECORD_ID) : void 0;
3415
+ const text$1 = (value) => typeof value === "string" ? value : "";
3416
+ /** Validate a pasted key before it is stored; the gateway never sees a bad one from us. */
3417
+ function assertApiKey(value) {
3418
+ const key = text$1(value).trim();
3419
+ if (key.length < 8 || key.length > 512 || !LEGAL_KEY.test(key)) throw new Error("PackyCode API key must be 8-512 printable ASCII characters without spaces");
3420
+ return key;
3421
+ }
3422
+ function entry(value) {
3423
+ if (value === null || typeof value !== "object") return void 0;
3424
+ const key = text$1(value.key);
3425
+ if (key === "" || !LEGAL_KEY.test(key)) return void 0;
3426
+ const id = text$1(value.id);
3427
+ return {
3428
+ id: id === "" ? randomUUID().slice(0, 8) : id,
3429
+ label: text$1(value.label),
3430
+ key,
3431
+ addedAt: Number.isFinite(value.addedAt) ? value.addedAt : 0,
3432
+ ...Number.isFinite(value.disabledAt) ? { disabledAt: value.disabledAt } : {},
3433
+ ...text$1(value.disabledReason) === "" ? {} : { disabledReason: text$1(value.disabledReason) }
3434
+ };
3435
+ }
3436
+ /** Tolerate a hand-edited or older record instead of failing the whole route. */
3437
+ function normalizeState(record) {
3438
+ const payload = record !== null && typeof record === "object" && record.kind === "grant" && record.payload !== null && typeof record.payload === "object" ? record.payload : void 0;
3439
+ const keys = (Array.isArray(payload?.keys) ? payload.keys : []).map(entry).filter((candidate) => candidate !== void 0);
3440
+ const activeId = text$1(payload?.activeId);
3441
+ return {
3442
+ activeId: keys.some((candidate) => candidate.id === activeId) ? activeId : keys[0]?.id,
3443
+ keys
3444
+ };
3445
+ }
3446
+ /**
3447
+ * The next free `Key N` name. The ring shrinks on removal, so numbering follows
3448
+ * the highest name in use instead of the current key count — otherwise removing
3449
+ * the first key and adding another produces a second "Key 2".
3450
+ * @param keys - the stored keys.
3451
+ * @returns an unused default label.
3452
+ */
3453
+ function nextDefaultLabel(keys) {
3454
+ const used = new Set(keys.map((candidate) => candidate.label));
3455
+ let index = keys.length + 1;
3456
+ while (used.has(`Key ${index}`)) index += 1;
3457
+ return `Key ${index}`;
2390
3458
  }
2391
3459
  /** The public shape: what a browser or a log may see, never key material. */
2392
3460
  function projection(state) {
@@ -2472,7 +3540,7 @@ var PackyCodeKeyRing = class {
2472
3540
  }
2473
3541
  async add(key, label) {
2474
3542
  const value = assertApiKey(key);
2475
- const name = text(label).trim().slice(0, 60);
3543
+ const name = text$1(label).trim().slice(0, 60);
2476
3544
  return projection(await this.#write((state) => {
2477
3545
  if (state.keys.length >= MAX_KEYS) throw new Error(`PackyCode accepts at most ${MAX_KEYS} keys`);
2478
3546
  if (state.keys.some((candidate) => candidate.key === value)) throw new Error("That PackyCode API key is already stored");
@@ -2489,7 +3557,7 @@ var PackyCodeKeyRing = class {
2489
3557
  }));
2490
3558
  }
2491
3559
  async rename(id, label) {
2492
- const name = text(label).trim().slice(0, 60);
3560
+ const name = text$1(label).trim().slice(0, 60);
2493
3561
  if (name === "") throw new Error("That PackyCode key name cannot be empty");
2494
3562
  return projection(await this.#write((state) => {
2495
3563
  if (!state.keys.some((candidate) => candidate.id === id)) throw new Error("Unknown PackyCode key");
@@ -2552,7 +3620,7 @@ var PackyCodeKeyRing = class {
2552
3620
  const keys = state.keys.map((candidate) => candidate.id === id ? {
2553
3621
  ...candidate,
2554
3622
  disabledAt: now,
2555
- disabledReason: text(reason) === "" ? "unusable" : text(reason)
3623
+ disabledReason: text$1(reason) === "" ? "unusable" : text$1(reason)
2556
3624
  } : candidate);
2557
3625
  if (!keys.some((candidate) => candidate.id === id)) return {
2558
3626
  activeId: state.activeId,
@@ -2566,69 +3634,6 @@ var PackyCodeKeyRing = class {
2566
3634
  }
2567
3635
  };
2568
3636
  //#endregion
2569
- //#region src/permission.js
2570
- /**
2571
- * The Claude Code CLI mode a session's own permissions allow.
2572
- *
2573
- * A CLI-backed turn runs the CLI's own tool loop, outside the harness sandbox
2574
- * and approval flow. Deriving its mode from the session's permission preset
2575
- * means the user grants it once, in the place they already grant the harness
2576
- * itself, instead of in a separate plugin setting.
2577
- *
2578
- * @module dsh-packycode-api/permission
2579
- */
2580
- /** Configured value meaning "follow the session's permission preset". */
2581
- const AUTO_CLAUDE_MODE = "auto";
2582
- const RANK = Object.freeze({
2583
- "read-only": 0,
2584
- edit: 1,
2585
- full: 2
2586
- });
2587
- /**
2588
- * Map the harness's two permission knobs onto a CLI mode.
2589
- *
2590
- * The CLI cannot ask the harness user for approval, so it skips its own
2591
- * permission checks only when the session itself neither sandboxes nor asks.
2592
- * @param sandbox - the session's resolved sandbox mode.
2593
- * @param approval - the session's effective approval policy.
2594
- * @returns a CLI mode, or `undefined` for an unknown sandbox mode.
2595
- */
2596
- function claudeModeFor(sandbox, approval) {
2597
- switch (sandbox) {
2598
- case "read-only": return "read-only";
2599
- case "workspace-write": return "edit";
2600
- case "danger-full-access": return approval === "never" ? "full" : "edit";
2601
- default: return;
2602
- }
2603
- }
2604
- /**
2605
- * Read a session's CLI mode from the host's permission services.
2606
- * @param ctx - the plugin context; services are read without an inject requirement.
2607
- * @param session - the session whose permission state is read.
2608
- * @returns a CLI mode, or `undefined` when the session or the services are absent.
2609
- */
2610
- function claudeModeForSession(ctx, session) {
2611
- if (session === void 0 || session === null) return void 0;
2612
- try {
2613
- const sandbox = ctx.get?.("sandboxPolicy")?.resolve?.({ session })?.mode;
2614
- const approval = ctx.get?.("approval")?.effectivePolicy?.(session);
2615
- return claudeModeFor(sandbox, approval);
2616
- } catch {
2617
- return;
2618
- }
2619
- }
2620
- /**
2621
- * The narrower of two modes; an unknown ceiling leaves the request as it is.
2622
- * @param requested - the mode a caller asked for.
2623
- * @param ceiling - the most the session allows.
2624
- * @returns the mode the CLI runs with.
2625
- */
2626
- function capClaudeMode(requested, ceiling) {
2627
- if (ceiling === void 0 || RANK[ceiling] === void 0) return requested;
2628
- if (requested === void 0 || RANK[requested] === void 0) return ceiling;
2629
- return RANK[requested] > RANK[ceiling] ? ceiling : requested;
2630
- }
2631
- //#endregion
2632
3637
  //#region src/plugin-version.js
2633
3638
  /**
2634
3639
  * Version self-knowledge for the settings panel.
@@ -2646,36 +3651,6 @@ const NPM_REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/late
2646
3651
  const DEFAULT_VERSION_TTL_MS = 5 * 6e4;
2647
3652
  const DEFAULT_VERSION_TIMEOUT_MS = 1e4;
2648
3653
  const DEFAULT_UPDATE_TIMEOUT_MS = 18e4;
2649
- const clone = (value) => structuredClone(value);
2650
- /**
2651
- * Resolve fetch at call time. A sibling plugin may wrap `globalThis.fetch`
2652
- * and later dismantle that wrapper; a binding captured during `apply()` would
2653
- * keep the dead wrapper for the process lifetime.
2654
- */
2655
- function ambientFetch(input, init) {
2656
- const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
2657
- if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
2658
- return globalThis.fetch(input, {
2659
- ...init,
2660
- headers
2661
- });
2662
- }
2663
- /** One caller's cancellation must not abort the shared registry read. */
2664
- function waitForSharedRequest(promise, signal) {
2665
- if (signal === void 0) return promise;
2666
- signal.throwIfAborted();
2667
- return new Promise((resolve, reject) => {
2668
- const onAbort = () => reject(signal.reason);
2669
- signal.addEventListener("abort", onAbort, { once: true });
2670
- promise.then((value) => {
2671
- signal.removeEventListener("abort", onAbort);
2672
- resolve(value);
2673
- }, (error) => {
2674
- signal.removeEventListener("abort", onAbort);
2675
- reject(error);
2676
- });
2677
- });
2678
- }
2679
3654
  /** Parse a strict `x.y.z[-pre][+build]` version; anything else is undefined. */
2680
3655
  function parseSemver(value) {
2681
3656
  if (typeof value !== "string") return void 0;
@@ -2877,22 +3852,17 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2877
3852
  argv,
2878
3853
  execPath
2879
3854
  });
2880
- let cached;
2881
- let inFlight;
2882
- let generation = 0;
2883
3855
  let updating = false;
2884
3856
  const fetchLatest = async (signal) => {
2885
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
2886
- const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
2887
3857
  let response;
2888
3858
  try {
2889
- response = await fetchImpl(registryUrl, {
2890
- headers: { Accept: "application/json" },
2891
- redirect: "error",
2892
- signal: requestSignal
3859
+ response = await boundedFetch(registryUrl, { headers: { Accept: "application/json" } }, {
3860
+ fetchImpl,
3861
+ timeoutMs,
3862
+ signal
2893
3863
  });
2894
3864
  } catch (error) {
2895
- if (signal?.aborted) throw error;
3865
+ if (error.aborted) throw error.cause;
2896
3866
  throw new Error("PackyCode plugin version check failed");
2897
3867
  }
2898
3868
  if (!response.ok) throw new Error("PackyCode plugin version check failed");
@@ -2928,28 +3898,12 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2928
3898
  fetchedAt: now()
2929
3899
  };
2930
3900
  };
3901
+ const cache = createCachedRead(load, {
3902
+ ttlMs,
3903
+ now
3904
+ });
2931
3905
  return Object.freeze({
2932
- async read({ force = false, signal } = {}) {
2933
- signal?.throwIfAborted();
2934
- if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone(cached);
2935
- const createPending = (requestSignal) => {
2936
- const observedGeneration = generation;
2937
- return load(requestSignal).then((value) => {
2938
- if (generation === observedGeneration) cached = value;
2939
- return value;
2940
- });
2941
- };
2942
- if (force) return clone(await createPending(signal));
2943
- const pending = inFlight ?? createPending();
2944
- if (inFlight === void 0) {
2945
- inFlight = pending;
2946
- const clear = () => {
2947
- if (inFlight === pending) inFlight = void 0;
2948
- };
2949
- pending.then(clear, clear);
2950
- }
2951
- return clone(await waitForSharedRequest(pending, signal));
2952
- },
3906
+ read: (options) => cache.read(options),
2953
3907
  async update({ signal } = {}) {
2954
3908
  signal?.throwIfAborted();
2955
3909
  if (updating) throw new Error("PackyCode plugin update is already running");
@@ -2989,8 +3943,7 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2989
3943
  expected: latest,
2990
3944
  settleDelayMs
2991
3945
  });
2992
- generation += 1;
2993
- cached = void 0;
3946
+ cache.invalidate();
2994
3947
  return {
2995
3948
  version: latest,
2996
3949
  profile: install.profile
@@ -2999,172 +3952,88 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2999
3952
  updating = false;
3000
3953
  }
3001
3954
  },
3002
- invalidate() {
3003
- generation += 1;
3004
- cached = void 0;
3005
- }
3955
+ invalidate: () => cache.invalidate()
3006
3956
  });
3007
3957
  }
3008
3958
  //#endregion
3009
- //#region src/catalog.js
3959
+ //#region src/preferences.js
3010
3960
  /**
3011
- * The built-in PackyCode model catalog.
3961
+ * Where the plugin's preferences are read from and written to, on both
3962
+ * settings generations DSH has shipped.
3012
3963
  *
3013
- * The gateway's own guidance for these routes, which the base URLs and pinned
3014
- * protocols below exist to satisfy:
3015
- * - OpenAI/Codex traffic takes `/v1` in the base URL and never an endpoint
3016
- * path — the SDK appends `/responses` or `/chat/completions` itself.
3017
- * - Anthropic traffic takes `https://cf.api.fan` with **no** `/v1`: the
3018
- * Anthropic client appends `/v1/messages` itself.
3019
- * - A model asked over the wrong protocol does not fail cleanly; the stream
3020
- * ends early. So every model pins the protocol its vendor serves here.
3964
+ * - DSH 0.1.5 installs a settings *section*: the section hands back a live
3965
+ * source and writes go through `settings.update(namespace, patch)`.
3966
+ * - DSH 0.2 has no sections: a preference is a *volatile* Config field of the
3967
+ * plugin's own row, read through a live reference, written through
3968
+ * `settings.update(rowId, patch)`, and announced by `loader/volatile-update`.
3969
+ * Only volatile fields are writable there, so the host overrides stay
3970
+ * read-only while the web search preference is editable.
3021
3971
  *
3022
- * Effort levels come from each vendor's documentation, because an effort the
3023
- * model does not support is refused before the request is sent: Claude Opus
3024
- * 5.5 (platform.claude.com effort docs), GPT-6.1 Sol and GPT-6 Astra
3025
- * `reasoning.effort` (developers.openai.com model pages), DeepSeek thinking
3026
- * mode (`thinking` toggle plus `low|high|max` effort).
3027
- *
3028
- * @module dsh-packycode-api/catalog
3972
+ * @module dsh-packycode-api/preferences
3029
3973
  */
3030
- /** pi-ai never reads this gateway's pricing; the harness zeroes cost anyway. */
3031
- const NO_COST = Object.freeze({
3032
- input: 0,
3033
- output: 0,
3034
- cacheRead: 0,
3035
- cacheWrite: 0
3036
- });
3037
- const COMPLETIONS_BASE = `${DEFAULT_API_HOST}/v1`;
3038
- const RESPONSES_BASE = `${DEFAULT_API_HOST}/v1`;
3039
- const MESSAGES_BASE = DEFAULT_API_HOST;
3040
3974
  /**
3041
- * pi-ai thinking-level maps. A level pinned to `null` is unsupported; an
3042
- * absent key keeps pi-ai's own default for that level, which is how `off`
3043
- * stays available for DeepSeek (`off` sends nothing and the deepseek dialect
3044
- * then emits `thinking: disabled`).
3975
+ * Mark a preference editable without restarting the plugin. DSH 0.2 stores
3976
+ * plugin preferences as volatile Config fields and writes only those; an older
3977
+ * schema library has no `volatile()` and keeps a plain field.
3045
3978
  */
3046
- const DEEPSEEK_LEVELS = Object.freeze({
3047
- minimal: null,
3048
- low: "low",
3049
- medium: null,
3050
- high: "high",
3051
- xhigh: null,
3052
- max: "max"
3053
- });
3979
+ const live = (schema) => typeof schema.volatile === "function" ? schema.volatile() : schema;
3980
+ /** Read a Config field that may arrive as a volatile reference. */
3981
+ const fieldValue = (value) => value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
3982
+ /** A preference target nothing can write to yet. */
3983
+ function unwritable(message) {
3984
+ return {
3985
+ writable: false,
3986
+ set: async () => {
3987
+ throw new Error(message);
3988
+ }
3989
+ };
3990
+ }
3054
3991
  /**
3055
- * Claude 5.5 models, GPT-6.1 Sol, and GPT-6 Astra cannot disable reasoning, so `off` is
3056
- * pinned unsupported. The Claude models run through the CLI, which picks its
3057
- * own effort, so for them these levels only shape the picker.
3992
+ * Connect the preferences to the settings service the host provides.
3993
+ * @param settingsCtx - the scope the `settings` inject handed over.
3994
+ * @param options - the plugin context and Config, the section namespace, the
3995
+ * current source and how to replace it, what to do when the section changes,
3996
+ * and the two preference targets this fills in (host overrides, search).
3058
3997
  */
3059
- const ALWAYS_THINKING_LEVELS = Object.freeze({
3060
- off: null,
3061
- minimal: null,
3062
- low: "low",
3063
- medium: "medium",
3064
- high: "high",
3065
- xhigh: "xhigh",
3066
- max: "max"
3067
- });
3068
- const PACKYCODE_MODELS = Object.freeze([
3069
- Object.freeze({
3070
- id: "deepseek-flash",
3071
- name: "DeepSeek Flash",
3072
- api: "openai-completions",
3073
- provider: PROVIDER,
3074
- baseUrl: COMPLETIONS_BASE,
3075
- reasoning: true,
3076
- input: Object.freeze(["text"]),
3077
- cost: NO_COST,
3078
- contextWindow: 1e6,
3079
- maxTokens: 384e3,
3080
- thinkingLevelMap: DEEPSEEK_LEVELS,
3081
- compat: Object.freeze({
3082
- thinkingFormat: "deepseek",
3083
- maxTokensField: "max_tokens",
3084
- requiresReasoningContentOnAssistantMessages: true,
3085
- supportsDeveloperRole: false
3086
- })
3087
- }),
3088
- Object.freeze({
3089
- id: "gpt-6.1-sol",
3090
- name: "GPT-6.1 Sol",
3091
- api: "openai-responses",
3092
- provider: PROVIDER,
3093
- baseUrl: RESPONSES_BASE,
3094
- reasoning: true,
3095
- input: Object.freeze(["text", "image"]),
3096
- cost: NO_COST,
3097
- contextWindow: 105e4,
3098
- maxTokens: 128e3,
3099
- thinkingLevelMap: ALWAYS_THINKING_LEVELS
3100
- }),
3101
- Object.freeze({
3102
- id: "gpt-6-astra",
3103
- name: "GPT-6 Astra",
3104
- api: "openai-responses",
3105
- provider: PROVIDER,
3106
- baseUrl: RESPONSES_BASE,
3107
- reasoning: true,
3108
- input: Object.freeze(["text", "image"]),
3109
- cost: NO_COST,
3110
- contextWindow: 105e4,
3111
- maxTokens: 128e3,
3112
- thinkingLevelMap: ALWAYS_THINKING_LEVELS
3113
- }),
3114
- Object.freeze({
3115
- id: "claude-opus-5-5",
3116
- name: "Claude Opus 5.5",
3117
- api: "anthropic-messages",
3118
- provider: PROVIDER,
3119
- baseUrl: MESSAGES_BASE,
3120
- reasoning: true,
3121
- input: Object.freeze(["text", "image"]),
3122
- cost: NO_COST,
3123
- contextWindow: 1e6,
3124
- maxTokens: 128e3,
3125
- thinkingLevelMap: ALWAYS_THINKING_LEVELS,
3126
- compat: Object.freeze({
3127
- forceAdaptiveThinking: true,
3128
- supportsTemperature: false
3129
- })
3130
- }),
3131
- Object.freeze({
3132
- id: "claude-sonnet-5-5",
3133
- name: "Claude Sonnet 5.5",
3134
- api: "anthropic-messages",
3135
- provider: PROVIDER,
3136
- baseUrl: MESSAGES_BASE,
3137
- reasoning: true,
3138
- input: Object.freeze(["text", "image"]),
3139
- cost: NO_COST,
3140
- contextWindow: 1e6,
3141
- maxTokens: 128e3,
3142
- thinkingLevelMap: ALWAYS_THINKING_LEVELS,
3143
- compat: Object.freeze({
3144
- forceAdaptiveThinking: true,
3145
- supportsTemperature: false
3146
- })
3147
- }),
3148
- Object.freeze({
3149
- id: "claude-haiku-5-5",
3150
- name: "Claude Haiku 5.5",
3151
- api: "anthropic-messages",
3152
- provider: PROVIDER,
3153
- baseUrl: MESSAGES_BASE,
3154
- reasoning: true,
3155
- input: Object.freeze(["text", "image"]),
3156
- cost: NO_COST,
3157
- contextWindow: 2e5,
3158
- maxTokens: 64e3,
3159
- thinkingLevelMap: ALWAYS_THINKING_LEVELS,
3160
- compat: Object.freeze({
3161
- forceAdaptiveThinking: true,
3162
- supportsTemperature: false
3163
- })
3164
- })
3165
- ]);
3166
- /** Every model id this catalog advertises, in picker order. */
3167
- const PACKYCODE_MODEL_IDS = Object.freeze(PACKYCODE_MODELS.map((model) => model.id));
3998
+ function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSource, onChange, hostPreference, searchPreference }) {
3999
+ const settings = settingsCtx.settings;
4000
+ if (typeof settings?.installSection === "function") {
4001
+ try {
4002
+ settings.installSection(ctx, namespace, Config, source(), {
4003
+ setSource: (current) => setSource(typeof current === "function" ? () => current() ?? {} : () => current ?? {}),
4004
+ onChange
4005
+ });
4006
+ if (typeof settings.update === "function") {
4007
+ const write = (patch) => settings.update(namespace, patch);
4008
+ Object.assign(hostPreference, {
4009
+ writable: true,
4010
+ set: write
4011
+ });
4012
+ Object.assign(searchPreference, {
4013
+ writable: true,
4014
+ set: write
4015
+ });
4016
+ }
4017
+ } catch (error) {
4018
+ ctx.logger?.warn?.("packycode-api: host overrides unavailable, defaults stand: %s", error.message);
4019
+ }
4020
+ return;
4021
+ }
4022
+ if (typeof settings?.update !== "function") return;
4023
+ const rowId = ctx.fiber?.entry?.options?.id ?? namespace;
4024
+ Object.assign(searchPreference, {
4025
+ writable: settings.writable !== false,
4026
+ set: (patch) => settings.update(rowId, patch)
4027
+ });
4028
+ if (typeof settings.configure === "function") {
4029
+ const scope = typeof settingsCtx.effect === "function" ? settingsCtx : ctx;
4030
+ try {
4031
+ scope.effect(() => settings.configure({ auto: false }, ctx.fiber), "packycode-api: settings page policy");
4032
+ } catch (error) {
4033
+ ctx.logger?.warn?.("packycode-api: could not hide the generated settings page: %s", error.message);
4034
+ }
4035
+ }
4036
+ }
3168
4037
  //#endregion
3169
4038
  //#region src/provider.js
3170
4039
  /**
@@ -3284,149 +4153,1159 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
3284
4153
  } });
3285
4154
  }
3286
4155
  //#endregion
3287
- //#region src/rpc.js
4156
+ //#region src/route.js
4157
+ /**
4158
+ * The HTTP model route: the pi-ai provider profile and the adapter that
4159
+ * serves the catalog's HTTP models (DeepSeek and GPT) with the ring's keys.
4160
+ *
4161
+ * @module dsh-packycode-api/route
4162
+ */
4163
+ /**
4164
+ * Request-level bounds from the shipped pi-ai adapter: a bounded image payload
4165
+ * keeps a long session completing requests instead of being refused by a
4166
+ * gateway size cap.
4167
+ */
4168
+ const REQUEST_LIMITS = Object.freeze({
4169
+ streamIdleTimeoutMs: 600 * 1e3,
4170
+ maxRequestImageBytes: 20 * 1024 * 1024,
4171
+ requestImagePixelBudget: 2048 * 2048,
4172
+ requestImageMaxBytes: 1024 * 1024
4173
+ });
4174
+ /**
4175
+ * pi-ai's credential store over the key ring.
4176
+ *
4177
+ * Reads answer with the active key so pi-ai's own auth reporting stays honest,
4178
+ * while writes are refused: the ring owns them, because only the ring can move
4179
+ * the active pointer past a key the gateway rejected.
4180
+ * @param ring - the plugin's key ring.
4181
+ * @returns a pi-ai `CredentialStore`.
4182
+ */
4183
+ function ringCredentialStore(ring) {
4184
+ const managed = () => {
4185
+ throw new Error("PackyCode API keys are managed in the PackyCode settings section");
4186
+ };
4187
+ return {
4188
+ async read() {
4189
+ const active = await ring.activeKey();
4190
+ return active === void 0 ? void 0 : {
4191
+ type: "api_key",
4192
+ key: active.key
4193
+ };
4194
+ },
4195
+ async list() {
4196
+ return await ring.activeKey() === void 0 ? [] : [{
4197
+ providerId: PROVIDER,
4198
+ type: "api_key"
4199
+ }];
4200
+ },
4201
+ modify: managed,
4202
+ delete: managed
4203
+ };
4204
+ }
4205
+ /**
4206
+ * The provider profile this route runs on.
4207
+ *
4208
+ * `modelErrors` and `configuredMaxTokens` are required by the host adapter;
4209
+ * they stay empty because the catalog is fixed and no model carries a
4210
+ * per-request output cap of its own.
4211
+ * @param provider - the route's pi-ai provider.
4212
+ * @returns the frozen profile handed to `PiAiAdapter`.
4213
+ */
4214
+ function createPackyCodeProfile(provider) {
4215
+ return Object.freeze({
4216
+ provider: PROVIDER,
4217
+ displayName: DISPLAY_NAME,
4218
+ piProvider: provider,
4219
+ reasoning: DEFAULT_REASONING_LEVEL,
4220
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
4221
+ modelErrors: /* @__PURE__ */ new Map(),
4222
+ ...REQUEST_LIMITS,
4223
+ cacheRetention: "short",
4224
+ retryPolicy: resolveRetryPolicy({
4225
+ mode: "normal",
4226
+ maxRetries: 20,
4227
+ retryableCodes: [
4228
+ "EMPTY_RESPONSE",
4229
+ "RATE_LIMIT",
4230
+ "SERVER",
4231
+ "TIMEOUT",
4232
+ "TRANSPORT",
4233
+ QUOTA_EXCEEDED_CODE,
4234
+ "AUTH",
4235
+ "INVALID_REQUEST"
4236
+ ]
4237
+ }, `${PLUGIN_ID}.retryPolicy`)
4238
+ });
4239
+ }
4240
+ /**
4241
+ * The HTTP route's adapter.
4242
+ *
4243
+ * Each request resolves the active key, then runs on the key the chooser picks
4244
+ * for its model; a key-level failure in the stream moves that key aside.
4245
+ * @param options - the key ring and chooser, the session id used when a caller
4246
+ * supplies none, and the attachment store image input is read from.
4247
+ * @returns a `PiAiAdapter` serving the `packycode` route.
4248
+ */
4249
+ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments }) {
4250
+ const raw = createPackyCodeProvider();
4251
+ /**
4252
+ * The gateway's Codex-group check refuses a Responses request that carries
4253
+ * no `prompt_cache_key` with a 403, which pi-ai derives from the session id.
4254
+ * The harness supplies one for every agent turn; the fallback covers a
4255
+ * caller that does not, so the route never fails for a missing cache key.
4256
+ */
4257
+ const requestOptions = (model, options) => {
4258
+ const withId = options === void 0 || options.sessionId !== void 0 ? options : {
4259
+ ...options,
4260
+ sessionId: fallbackSessionId
4261
+ };
4262
+ if (typeof withId?.apiKey !== "string") return withId;
4263
+ const key = keys.forRequest(model?.id, withId.apiKey);
4264
+ return key === withId.apiKey ? withId : {
4265
+ ...withId,
4266
+ apiKey: key
4267
+ };
4268
+ };
4269
+ const guarded = (method) => (model, context, options) => {
4270
+ const routed = requestOptions(model, options);
4271
+ return guardKeyRotation(raw[method](model, context, routed), {
4272
+ key: routed?.apiKey,
4273
+ onKeyFailure: keys.onKeyFailure
4274
+ });
4275
+ };
4276
+ const provider = Object.freeze({
4277
+ ...raw,
4278
+ stream: guarded("stream"),
4279
+ streamSimple: guarded("streamSimple")
4280
+ });
4281
+ const profiles = /* @__PURE__ */ new Map([[PROVIDER, createPackyCodeProfile(provider)]]);
4282
+ return new PiAiAdapter({
4283
+ profiles: () => profiles,
4284
+ resolveApiKey: async () => {
4285
+ const active = await keys.active();
4286
+ if (active === void 0) throw new LlmError("No usable PackyCode API key: add one under Settings → PackyCode, or re-enable the keys this plugin moved past.", "MISSING_CREDENTIAL");
4287
+ return active.key;
4288
+ },
4289
+ auth: Object.freeze({
4290
+ credentials: ringCredentialStore(ring),
4291
+ authContext: Object.freeze({
4292
+ env: async () => void 0,
4293
+ fileExists: async () => false
4294
+ })
4295
+ }),
4296
+ resolveAttachments
4297
+ });
4298
+ }
4299
+ //#endregion
4300
+ //#region src/rpc.js
4301
+ /**
4302
+ * The loopback RPC surface behind the settings panel and the composer chip.
4303
+ *
4304
+ * Every reply carries only what this plugin owns: key labels and state, the
4305
+ * balance and group projections the host built, host overrides, whether this
4306
+ * device has the Claude Code CLI, the plugin version, and the web search
4307
+ * preference. Key material never crosses this boundary.
4308
+ *
4309
+ * @module dsh-packycode-api/rpc
4310
+ */
4311
+ const ok = (value) => ({
4312
+ ok: true,
4313
+ value
4314
+ });
4315
+ const failed = (message) => ({
4316
+ ok: false,
4317
+ error: { message }
4318
+ });
4319
+ /** Only sentences this plugin authored may reach the browser. */
4320
+ const SAFE_MESSAGE = /^(?:PackyCode|That |Unknown |PackyCode's |add |the )[\s\S]{0,180}$/u;
4321
+ const keyId = (payload) => String(payload?.id ?? "");
4322
+ /**
4323
+ * Build the RPC handler.
4324
+ * @param options - the key ring, the balance and group readers, the host
4325
+ * preference, and the optional Claude Code CLI manager, plugin manager, and
4326
+ * web search controller (without one, its endpoints refuse).
4327
+ * @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
4328
+ */
4329
+ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claudeCli, pluginManager, search }) {
4330
+ const need = (service, message) => {
4331
+ if (service === void 0) throw new Error(message);
4332
+ return service;
4333
+ };
4334
+ const cli = () => need(claudeCli, "PackyCode Claude Code CLI support is unavailable in this runtime");
4335
+ const manager = () => need(pluginManager, "PackyCode plugin version is unavailable");
4336
+ const webSearch = () => need(search, "PackyCode web search is unavailable in this runtime");
4337
+ const groupReader = () => need(groups, "PackyCode key groups are unavailable in this runtime");
4338
+ const status = async () => ({
4339
+ ...await ring.list(),
4340
+ hosts: hosts(),
4341
+ writable: preference.writable === true,
4342
+ defaults: {
4343
+ apiHost: DEFAULT_API_HOST,
4344
+ statusHost: DEFAULT_STATUS_HOST
4345
+ }
4346
+ });
4347
+ /**
4348
+ * Key mutations, each answered with the new status. Every one drops the
4349
+ * cached balance; one that changes which keys exist also drops the groups.
4350
+ */
4351
+ const keyMutations = {
4352
+ "keys/add": {
4353
+ run: (payload) => ring.add(payload?.key, payload?.label),
4354
+ changesKeys: true
4355
+ },
4356
+ "keys/rename": { run: (payload) => ring.rename(keyId(payload), payload?.label) },
4357
+ "keys/remove": {
4358
+ run: (payload) => ring.remove(keyId(payload)),
4359
+ changesKeys: true
4360
+ },
4361
+ "keys/remove-all": {
4362
+ run: () => ring.removeAll(),
4363
+ changesKeys: true
4364
+ },
4365
+ "keys/select": { run: (payload) => ring.select(keyId(payload)) },
4366
+ "keys/enable": { run: (payload) => ring.enable(keyId(payload)) }
4367
+ };
4368
+ const endpoints = {
4369
+ status: () => status(),
4370
+ balance: async (payload) => {
4371
+ const [value, state] = await Promise.all([balance.read({ force: payload?.force === true }), ring.list()]);
4372
+ return {
4373
+ ...value,
4374
+ activeId: state.activeId
4375
+ };
4376
+ },
4377
+ groups: (payload) => groupReader().read({ force: payload?.force === true }),
4378
+ "hosts/update": async (payload) => {
4379
+ if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
4380
+ await preference.set({
4381
+ apiHost: normalizeHost(payload?.apiHost, hosts().apiHost),
4382
+ statusHost: normalizeHost(payload?.statusHost, hosts().statusHost)
4383
+ });
4384
+ balance.invalidate();
4385
+ groups?.invalidate();
4386
+ return status();
4387
+ },
4388
+ "claude-cli/status": (payload) => cli().status({ force: payload?.force === true }),
4389
+ "claude-cli/install": () => {
4390
+ cli().install();
4391
+ return cli().status();
4392
+ },
4393
+ "plugin/version": (payload, signal) => manager().read({
4394
+ force: payload?.force === true,
4395
+ signal
4396
+ }),
4397
+ "plugin/update": (_payload, signal) => manager().update({ signal }),
4398
+ "search/status": () => webSearch().status(),
4399
+ "search/update": (payload) => webSearch().update({
4400
+ searchProvider: payload?.searchProvider,
4401
+ searchBackend: payload?.searchBackend
4402
+ })
4403
+ };
4404
+ return async (endpoint, payload, signal) => {
4405
+ try {
4406
+ const mutation = Object.hasOwn(keyMutations, endpoint) ? keyMutations[endpoint] : void 0;
4407
+ if (mutation !== void 0) {
4408
+ await mutation.run(payload);
4409
+ balance.invalidate();
4410
+ if (mutation.changesKeys) groups?.invalidate();
4411
+ return ok(await status());
4412
+ }
4413
+ const handle = Object.hasOwn(endpoints, endpoint) ? endpoints[endpoint] : void 0;
4414
+ if (handle === void 0) return failed("Unknown PackyCode endpoint");
4415
+ return ok(await handle(payload, signal));
4416
+ } catch (error) {
4417
+ const message = error instanceof Error ? error.message : String(error);
4418
+ return failed(SAFE_MESSAGE.test(message) ? message : "PackyCode request failed");
4419
+ }
4420
+ };
4421
+ }
4422
+ //#endregion
4423
+ //#region src/search.js
4424
+ /**
4425
+ * PackyCode web search for the DSH `web` seam.
4426
+ *
4427
+ * Two search providers register with `ctx.web`:
4428
+ * - `packycode-api` searches through PackyCode for every request.
4429
+ * - `packycode-api-auto` searches through PackyCode only when the initiating
4430
+ * session runs on a PackyCode model, and hands every other request to the
4431
+ * provider DSH would have used without this plugin.
4432
+ *
4433
+ * PackyCode has no dedicated search endpoint; a search is one model call with
4434
+ * the vendor's server-side search tool. The user picks the backend:
4435
+ * - `deepseek`: Anthropic Messages at `/v1/messages`, `deepseek-flash`, with
4436
+ * the `web_search_20250305` server tool. Results come from its structured
4437
+ * `web_search_tool_result` blocks, never from prose.
4438
+ * - `gpt`: OpenAI Responses at `/v1/responses`, `gpt-6.1-sol`, with the
4439
+ * hosted `web_search` tool. Results come from `url_citation` annotations and
4440
+ * the `web_search_call` actions.
4441
+ *
4442
+ * Only keys stored in this plugin's key ring authenticate a search; there is no
4443
+ * fallback to another credential. A key the gateway reports as exhausted or
4444
+ * rejected is moved aside exactly as on the model route, and the search tries
4445
+ * the next usable key.
4446
+ *
4447
+ * @module dsh-packycode-api/search
4448
+ */
4449
+ /** Search provider that always searches through PackyCode. */
4450
+ const PACKYCODE_SEARCH_PROVIDER_ID = "packycode-api";
4451
+ /** Search provider that routes by the initiating session's model provider. */
4452
+ const PACKYCODE_AUTO_SEARCH_PROVIDER_ID = "packycode-api-auto";
4453
+ /** Both ids this plugin registers; neither may serve as its own fallback. */
4454
+ const OWN_SEARCH_PROVIDER_IDS = Object.freeze([PACKYCODE_SEARCH_PROVIDER_ID, PACKYCODE_AUTO_SEARCH_PROVIDER_ID]);
4455
+ /** DSH's built-in search provider, the fallback when nothing else is known. */
4456
+ const DSH_SEARCH_PROVIDER_FALLBACK = "deepseek-official";
4457
+ /** Search routing the user picks. */
4458
+ const SEARCH_MODE_DEFAULT = "default";
4459
+ const SEARCH_MODES = Object.freeze([
4460
+ SEARCH_MODE_DEFAULT,
4461
+ "auto",
4462
+ "packycode"
4463
+ ]);
4464
+ /** Search backends the user picks between. */
4465
+ const SEARCH_BACKEND_DEEPSEEK = "deepseek";
4466
+ const SEARCH_BACKENDS = Object.freeze([SEARCH_BACKEND_DEEPSEEK, "gpt"]);
4467
+ /**
4468
+ * DeepSeek is the default: a search costs a few thousand tokens there, and its
4469
+ * group answered the server tool in the live check. GPT search reads far more
4470
+ * (about 26K input tokens per search) and needs a key whose group the gateway
4471
+ * currently routes GPT models to.
4472
+ */
4473
+ const DEFAULT_SEARCH_BACKEND = SEARCH_BACKEND_DEEPSEEK;
4474
+ /** The model each backend searches with. */
4475
+ const SEARCH_BACKEND_MODELS = Object.freeze({
4476
+ [SEARCH_BACKEND_DEEPSEEK]: "deepseek-flash",
4477
+ ["gpt"]: "gpt-6.1-sol"
4478
+ });
4479
+ /** Bound on one search request, below the `web_search` tool's 60 s budget. */
4480
+ const DEFAULT_SEARCH_TIMEOUT_MS = 55e3;
4481
+ /** Upper bound on server-side searches inside one DeepSeek request. */
4482
+ const DEEPSEEK_MAX_USES = 3;
4483
+ const MAX_OUTPUT_TOKENS = 4096;
4484
+ const ANTHROPIC_VERSION = "2023-06-01";
4485
+ /** Distinct keys one search may try before reporting the last failure. */
4486
+ const MAX_KEY_ATTEMPTS = 4;
4487
+ const MAX_CONTENT_CHARS = 4e3;
4488
+ const MAX_SNIPPET_CHARS = 500;
4489
+ const MAX_TITLE_CHARS = 200;
4490
+ const MAX_SOURCES = 20;
4491
+ const MAX_ERROR_DETAIL_CHARS = 400;
4492
+ /**
4493
+ * Error shape of the DSH web seam. `@deepseek-ai/dsh-web` defines `WebError`
4494
+ * as a bare `HarnessError` subclass with a machine-routable `code`; consumers
4495
+ * route on that code, so this mirror avoids a peer dependency on the package.
4496
+ */
4497
+ var WebError = class extends HarnessError {};
4498
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
4499
+ const text = (value) => typeof value === "string" ? value : "";
4500
+ function bounded(value, maximum) {
4501
+ const clean = text(value).replace(/\s+/gu, " ").trim();
4502
+ if (clean === "") return void 0;
4503
+ return clean.length <= maximum ? clean : `${clean.slice(0, maximum - 1)}…`;
4504
+ }
4505
+ function boundedBlock(value, maximum) {
4506
+ const clean = text(value).trim();
4507
+ if (clean === "") return void 0;
4508
+ return clean.length <= maximum ? clean : `${clean.slice(0, maximum - 1)}…`;
4509
+ }
4510
+ function httpUrl(value) {
4511
+ const url = text(value);
4512
+ if (url === "") return void 0;
4513
+ try {
4514
+ const parsed = new URL(url);
4515
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : void 0;
4516
+ } catch {
4517
+ return;
4518
+ }
4519
+ }
4520
+ /** Accumulates sources by URL, first sighting wins, missing fields filled later. */
4521
+ function sourceList() {
4522
+ const byUrl = /* @__PURE__ */ new Map();
4523
+ return {
4524
+ add({ url, title, snippet, publishedAt }) {
4525
+ const parsed = httpUrl(url);
4526
+ if (parsed === void 0) return;
4527
+ const key = parsed.href;
4528
+ const existing = byUrl.get(key);
4529
+ if (existing === void 0) {
4530
+ if (byUrl.size >= MAX_SOURCES) return;
4531
+ byUrl.set(key, {
4532
+ url: text(url),
4533
+ title: bounded(title, MAX_TITLE_CHARS) ?? parsed.hostname,
4534
+ ...bounded(snippet, MAX_SNIPPET_CHARS) === void 0 ? {} : { snippet: bounded(snippet, MAX_SNIPPET_CHARS) },
4535
+ ...bounded(publishedAt, 64) === void 0 ? {} : { publishedAt: bounded(publishedAt, 64) }
4536
+ });
4537
+ return;
4538
+ }
4539
+ if (existing.title === parsed.hostname && bounded(title, MAX_TITLE_CHARS) !== void 0) existing.title = bounded(title, MAX_TITLE_CHARS);
4540
+ if (existing.snippet === void 0 && bounded(snippet, MAX_SNIPPET_CHARS) !== void 0) existing.snippet = bounded(snippet, MAX_SNIPPET_CHARS);
4541
+ if (existing.publishedAt === void 0 && bounded(publishedAt, 64) !== void 0) existing.publishedAt = bounded(publishedAt, 64);
4542
+ },
4543
+ values: () => [...byUrl.values()]
4544
+ };
4545
+ }
4546
+ function result(sources, content) {
4547
+ return {
4548
+ ...content === void 0 ? {} : { content },
4549
+ sources,
4550
+ truncated: false
4551
+ };
4552
+ }
4553
+ /**
4554
+ * Map an Anthropic Messages response with native search to a search result.
4555
+ * Sources come from `web_search_tool_result` blocks; a text block's citation
4556
+ * excerpt becomes its source's snippet; the answer text becomes `content`.
4557
+ * @param response - parsed Messages response body.
4558
+ * @returns the normalized search result.
4559
+ * @throws {WebError} when the response carries no search result block.
4560
+ */
4561
+ function parseMessagesSearchResponse(response) {
4562
+ const blocks = Array.isArray(response?.content) ? response.content : [];
4563
+ const resultBlocks = blocks.filter((block) => block?.type === "web_search_tool_result");
4564
+ if (resultBlocks.length === 0) throw new WebError("PackyCode search returned no web_search_tool_result block; the model did not run a web search", "WEB_PROVIDER_ERROR");
4565
+ const sources = sourceList();
4566
+ const errors = [];
4567
+ for (const block of resultBlocks) {
4568
+ if (!Array.isArray(block.content)) {
4569
+ if (record(block.content) && text(block.content.error_code) !== "") errors.push(block.content.error_code);
4570
+ continue;
4571
+ }
4572
+ for (const item of block.content) {
4573
+ if (item?.type !== "web_search_result") continue;
4574
+ sources.add({
4575
+ url: item.url,
4576
+ title: item.title,
4577
+ publishedAt: item.page_age
4578
+ });
4579
+ }
4580
+ }
4581
+ for (const block of blocks) {
4582
+ if (block?.type !== "text" || !Array.isArray(block.citations)) continue;
4583
+ for (const cite of block.citations) sources.add({
4584
+ url: cite?.url,
4585
+ title: cite?.title,
4586
+ snippet: cite?.cited_text
4587
+ });
4588
+ }
4589
+ const found = sources.values();
4590
+ if (found.length === 0 && errors.length > 0) throw new WebError(`PackyCode search failed upstream (${errors.join(", ")})`, "WEB_PROVIDER_ERROR");
4591
+ return result(found, boundedBlock(blocks.filter((block) => block?.type === "text").map((block) => text(block.text)).join(""), MAX_CONTENT_CHARS));
4592
+ }
4593
+ /**
4594
+ * Map an OpenAI Responses response with the hosted search tool to a search
4595
+ * result. Cited URLs come first with their titles, then every other URL the
4596
+ * search actions surfaced; the final message becomes `content`.
4597
+ * @param response - parsed Responses body.
4598
+ * @returns the normalized search result.
4599
+ * @throws {WebError} when the model ran no web search.
4600
+ */
4601
+ function parseResponsesSearchResponse(response) {
4602
+ const output = Array.isArray(response?.output) ? response.output : [];
4603
+ const calls = output.filter((item) => item?.type === "web_search_call");
4604
+ if (calls.length === 0) throw new WebError("PackyCode search returned no web_search_call; the model did not run a web search", "WEB_PROVIDER_ERROR");
4605
+ const sources = sourceList();
4606
+ let answer;
4607
+ for (const item of output) {
4608
+ if (item?.type !== "message" || !Array.isArray(item.content)) continue;
4609
+ const parts = [];
4610
+ for (const part of item.content) {
4611
+ if (part?.type !== "output_text") continue;
4612
+ parts.push(text(part.text));
4613
+ for (const annotation of Array.isArray(part.annotations) ? part.annotations : []) if (annotation?.type === "url_citation") sources.add({
4614
+ url: annotation.url,
4615
+ title: annotation.title
4616
+ });
4617
+ }
4618
+ const joined = parts.join("");
4619
+ if (joined.trim() !== "") answer = joined;
4620
+ }
4621
+ for (const call of calls) {
4622
+ const action = record(call.action) ? call.action : {};
4623
+ if (text(action.url) !== "") sources.add({ url: action.url });
4624
+ for (const source of Array.isArray(action.sources) ? action.sources : []) sources.add({
4625
+ url: source?.url,
4626
+ title: source?.title
4627
+ });
4628
+ }
4629
+ return result(sources.values(), boundedBlock(answer, MAX_CONTENT_CHARS));
4630
+ }
4631
+ /** Build one backend's request; the key is attached by the caller. */
4632
+ function buildSearchRequest(backend, { apiHost, query, sessionId }) {
4633
+ const model = SEARCH_BACKEND_MODELS[backend];
4634
+ if (backend === "gpt") return {
4635
+ url: `${apiHost}/v1/responses`,
4636
+ model,
4637
+ headers: (key) => ({ authorization: `Bearer ${key}` }),
4638
+ body: {
4639
+ model,
4640
+ input: [{
4641
+ role: "user",
4642
+ content: [{
4643
+ type: "input_text",
4644
+ text: `Search the web for: ${query}\n\nReply with a concise factual summary of what the sources say, citing them.`
4645
+ }]
4646
+ }],
4647
+ tools: [{ type: "web_search" }],
4648
+ tool_choice: "required",
4649
+ include: ["web_search_call.action.sources"],
4650
+ reasoning: { effort: "low" },
4651
+ max_output_tokens: MAX_OUTPUT_TOKENS,
4652
+ store: false,
4653
+ ...typeof sessionId === "string" && sessionId !== "" ? { prompt_cache_key: sessionId } : {}
4654
+ },
4655
+ parse: parseResponsesSearchResponse
4656
+ };
4657
+ if (backend === "deepseek") return {
4658
+ url: `${apiHost}/v1/messages`,
4659
+ model,
4660
+ headers: (key) => ({
4661
+ "x-api-key": key,
4662
+ "anthropic-version": ANTHROPIC_VERSION
4663
+ }),
4664
+ body: {
4665
+ model,
4666
+ max_tokens: MAX_OUTPUT_TOKENS,
4667
+ messages: [{
4668
+ role: "user",
4669
+ content: `Perform a web search for the query: ${query}\n\nThen reply with a concise factual summary of what the results say.`
4670
+ }],
4671
+ tools: [{
4672
+ type: "web_search_20250305",
4673
+ name: "web_search",
4674
+ max_uses: DEEPSEEK_MAX_USES
4675
+ }]
4676
+ },
4677
+ parse: parseMessagesSearchResponse
4678
+ };
4679
+ throw new WebError(`PackyCode search backend "${String(backend)}" is not supported`, "WEB_PROVIDER_UNAVAILABLE");
4680
+ }
4681
+ /** Read a failure body as bounded text, preferring the gateway's own message. */
4682
+ async function failureText(response) {
4683
+ let raw = "";
4684
+ try {
4685
+ raw = await response.text();
4686
+ } catch {
4687
+ return "";
4688
+ }
4689
+ try {
4690
+ const parsed = JSON.parse(raw);
4691
+ const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message ?? parsed?.message;
4692
+ if (typeof message === "string" && message !== "") return `${typeof parsed?.error?.code === "string" && parsed.error.code !== "" ? `${parsed.error.code}: ` : ""}${message}`.slice(0, MAX_ERROR_DETAIL_CHARS);
4693
+ } catch {}
4694
+ return raw.replace(/\s+/gu, " ").trim().slice(0, MAX_ERROR_DETAIL_CHARS);
4695
+ }
4696
+ const mask = (message, key) => typeof key === "string" && key.length >= 8 ? message.split(key).join(`…${key.slice(-4)}`) : message;
4697
+ function isAbort(error, signal) {
4698
+ return signal?.aborted === true || error?.name === "AbortError";
4699
+ }
4700
+ /**
4701
+ * Create the provider that searches through PackyCode on every request.
4702
+ * @param options - the key ring, the host resolution, the backend preference,
4703
+ * the key-failure sink shared with the model route, and test seams.
4704
+ * @returns a DSH web search provider.
4705
+ */
4706
+ function createPackyCodeSearchProvider({ ring, hosts, backend = () => DEFAULT_SEARCH_BACKEND, onKeyFailure, covers, resolveSessionId, fetchImpl = ambientFetch, timeoutMs = DEFAULT_SEARCH_TIMEOUT_MS } = {}) {
4707
+ if (ring === void 0 || typeof ring.activeKey !== "function") throw new Error("PackyCode search requires the key ring");
4708
+ if (typeof hosts !== "function") throw new Error("PackyCode search requires the host resolution");
4709
+ /** One request with one key; classified failures come back as values. */
4710
+ const attempt = async (request, key, signal) => {
4711
+ if (signal?.aborted === true) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: signal.reason });
4712
+ let response;
4713
+ try {
4714
+ response = await boundedFetch(request.url, {
4715
+ method: "POST",
4716
+ headers: {
4717
+ ...request.headers(key),
4718
+ accept: "application/json",
4719
+ "content-type": "application/json"
4720
+ },
4721
+ body: JSON.stringify(request.body)
4722
+ }, {
4723
+ fetchImpl,
4724
+ timeoutMs,
4725
+ signal
4726
+ });
4727
+ } catch (error) {
4728
+ if (error.aborted) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: error });
4729
+ if (error.timedOut) throw new WebError(`PackyCode search got no answer within ${Math.round(timeoutMs / 1e3)}s`, "WEB_PROVIDER_ERROR", { cause: error });
4730
+ throw new WebError(`PackyCode search request failed (${mask(error.code ?? error.message, key)})`, "WEB_PROVIDER_ERROR", { cause: error });
4731
+ }
4732
+ if (!response.ok) return {
4733
+ failure: mask(await failureText(response), key),
4734
+ status: response.status
4735
+ };
4736
+ let body;
4737
+ try {
4738
+ body = await response.json();
4739
+ } catch (error) {
4740
+ if (isAbort(error, signal)) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: error });
4741
+ throw new WebError("PackyCode search returned an unreadable response", "WEB_PROVIDER_ERROR", { cause: error });
4742
+ }
4743
+ if (record(body?.error)) return {
4744
+ failure: mask(text(body.error.message) || JSON.stringify(body.error).slice(0, MAX_ERROR_DETAIL_CHARS), key),
4745
+ status: response.status
4746
+ };
4747
+ return { value: request.parse(body) };
4748
+ };
4749
+ return Object.freeze({
4750
+ id: PACKYCODE_SEARCH_PROVIDER_ID,
4751
+ available: () => true,
4752
+ async search(searchRequest, signal) {
4753
+ if (signal?.aborted === true) throw new WebError("PackyCode search aborted", "WEB_ABORTED", { cause: signal.reason });
4754
+ const query = text(searchRequest?.query).trim();
4755
+ if (query === "") throw new WebError("PackyCode search needs a non-empty query", "WEB_PROVIDER_ERROR");
4756
+ const chosen = SEARCH_BACKENDS.includes(backend()) ? backend() : DEFAULT_SEARCH_BACKEND;
4757
+ const request = buildSearchRequest(chosen, {
4758
+ apiHost: hosts().apiHost,
4759
+ query,
4760
+ sessionId: resolveSessionId?.()
4761
+ });
4762
+ const candidates = await orderedKeys(ring, request.model, covers);
4763
+ let lastFailure;
4764
+ let uncovered = false;
4765
+ for (const key of candidates.slice(0, MAX_KEY_ATTEMPTS)) {
4766
+ const outcome = await attempt(request, key.key, signal);
4767
+ if (outcome.value !== void 0) return outcome.value;
4768
+ const failure = outcome.failure === "" ? `HTTP ${outcome.status}` : outcome.failure;
4769
+ lastFailure = failure;
4770
+ if (classifyClientPolicy(failure)) throw new WebError(explainClientPolicyFailure(), "WEB_PROVIDER_ERROR");
4771
+ const kind = classifyGatewayFailure(failure) ?? (outcome.status === 401 ? "invalid-key" : void 0);
4772
+ if (kind !== void 0) {
4773
+ onKeyFailure?.(kind, failure, key.key, key.id);
4774
+ continue;
4775
+ }
4776
+ if (classifyCoverage(failure)) {
4777
+ uncovered = true;
4778
+ continue;
4779
+ }
4780
+ throw new WebError(`PackyCode search failed (HTTP ${outcome.status}): ${failure}`, "WEB_PROVIDER_ERROR");
4781
+ }
4782
+ if (candidates.length === 0) throw new WebError("PackyCode search has no usable API key; add one under Settings → PackyCode", "WEB_PROVIDER_CREDENTIAL_MISSING");
4783
+ if (uncovered) {
4784
+ const other = chosen === "gpt" ? SEARCH_BACKEND_DEEPSEEK : "gpt";
4785
+ throw new WebError(`PackyCode search: no stored API key's group covers ${request.model} (${chosen} backend). Switch the search backend to "${other}" under Settings → PackyCode → Web search, or add a key whose group includes it. Gateway said: ${lastFailure}`, "WEB_PROVIDER_ERROR");
4786
+ }
4787
+ throw new WebError(`PackyCode search: every usable key was refused (${lastFailure ?? "no detail"})`, "WEB_PROVIDER_CREDENTIAL_MISSING");
4788
+ }
4789
+ });
4790
+ }
4791
+ /**
4792
+ * The keys one search tries, in order: the active key, then the other usable
4793
+ * keys in ring order — except that a key the group reading shows cannot reach
4794
+ * the backend's model goes last, behind every key that can or may.
4795
+ * @param ring - the key ring.
4796
+ * @param modelId - the backend's model.
4797
+ * @param covers - `(keyId, modelId) => true | false | undefined`, optional.
4798
+ * @returns the candidates, without key material beyond what the request needs.
4799
+ */
4800
+ async function orderedKeys(ring, modelId, covers) {
4801
+ const active = await ring.activeKey();
4802
+ if (active === void 0) return [];
4803
+ const usable = typeof ring.secrets === "function" ? (await ring.secrets()).filter((entry) => !entry.disabled) : [];
4804
+ const start = usable.findIndex((entry) => entry.id === active.id);
4805
+ const rest = start < 0 ? usable.filter((entry) => entry.id !== active.id) : [...usable.slice(start + 1), ...usable.slice(0, start)];
4806
+ const rank = (entry) => {
4807
+ const value = covers?.(entry.id, modelId);
4808
+ return value === true ? 0 : value === false ? 2 : 1;
4809
+ };
4810
+ return [active, ...rest].map((entry, index) => ({
4811
+ entry,
4812
+ index,
4813
+ rank: index === 0 && rank(entry) !== 2 ? -1 : rank(entry)
4814
+ })).sort((left, right) => left.rank - right.rank || left.index - right.index).map(({ entry }) => ({
4815
+ id: entry.id,
4816
+ label: entry.label,
4817
+ key: entry.key
4818
+ }));
4819
+ }
4820
+ /** Marks a request already passing through the auto provider's fallback. */
4821
+ const delegating = new AsyncLocalStorage();
4822
+ /**
4823
+ * Create the provider that searches through PackyCode for PackyCode sessions
4824
+ * only. Every other request goes to the fallback provider: the search provider
4825
+ * the web row selected before this plugin's patch, or DSH's built-in search.
4826
+ *
4827
+ * Another plugin's router may delegate back to this one (its own fallback is
4828
+ * the web row's base, which this plugin's patch points here). A request that
4829
+ * re-enters while already delegating goes straight to DSH's built-in search
4830
+ * instead of looping.
4831
+ * @param options - the PackyCode provider, the initiating model provider, a
4832
+ * lookup of registered search providers, and the fallback id.
4833
+ * @returns a DSH web search provider.
4834
+ */
4835
+ function createPackyCodeAutoSearchProvider({ packycode, resolveModelProvider, resolveProvider, resolveFallbackId }) {
4836
+ return Object.freeze({
4837
+ id: PACKYCODE_AUTO_SEARCH_PROVIDER_ID,
4838
+ available: () => true,
4839
+ async search(request, signal) {
4840
+ if (resolveModelProvider?.() === "packycode") return packycode.search(request, signal);
4841
+ const wanted = delegating.getStore() === true ? DSH_SEARCH_PROVIDER_FALLBACK : resolveFallbackId?.() ?? "deepseek-official";
4842
+ const id = OWN_SEARCH_PROVIDER_IDS.includes(wanted) ? DSH_SEARCH_PROVIDER_FALLBACK : wanted;
4843
+ const provider = resolveProvider?.(id);
4844
+ if (provider === void 0 || OWN_SEARCH_PROVIDER_IDS.includes(provider.id) || provider.available?.() !== true) throw new WebError(`PackyCode auto search: the fallback search provider "${id}" is unavailable`, "WEB_PROVIDER_UNAVAILABLE");
4845
+ return delegating.run(true, () => provider.search(request, signal));
4846
+ }
4847
+ });
4848
+ }
4849
+ //#endregion
4850
+ //#region src/search-composition.js
4851
+ /**
4852
+ * Point the DSH web row's base search provider at this plugin.
4853
+ *
4854
+ * The web runtime has one search slot. Subscription plugins (Codex, Kimi)
4855
+ * rewrite that slot at runtime whenever the web row restarts, and fall back to
4856
+ * the row's *base* config for models they do not own. Writing the runtime slot
4857
+ * here as well would make two switchers restart each other forever. Instead
4858
+ * this plugin keeps one marked `- id: web` entry in the owning profile's
4859
+ * `cordis.patch.yml`: the boot layer hot-applies the file, the base config then
4860
+ * names a PackyCode provider, and every router — or the bare slot, when no
4861
+ * router holds it — reaches PackyCode for the requests it does not own.
4862
+ *
4863
+ * Only the marked entry is ever touched. It is rewritten in place, so a
4864
+ * sibling plugin that appends its own entry after it never sees this file
4865
+ * reorder; an unchanged entry is never rewritten. A profile patch replaces the
4866
+ * whole matched config, so the entry restates the web row's other scalar
4867
+ * fields. It also records, as a comment, the provider the base named before
4868
+ * the entry existed, which is where the auto provider sends requests it does
4869
+ * not serve.
4870
+ *
4871
+ * @module dsh-packycode-api/search-composition
4872
+ */
4873
+ const SEARCH_PATCH_FILE = "cordis.patch.yml";
4874
+ const BLOCK_BEGIN = "# >>> dsh-packycode-api: web search provider (managed in Settings → PackyCode → Web search)";
4875
+ const BLOCK_END = "# <<< dsh-packycode-api: web search provider";
4876
+ const BEGIN_PREFIX = "# >>> dsh-packycode-api: web search provider";
4877
+ const FALLBACK_LINE = /^#\s*fallback:\s*(\S+)\s*$/u;
4878
+ const PROVIDER_ID = /^[A-Za-z0-9._-]+$/u;
4879
+ const EMPTY_LIST = /^\[\]\s*$/u;
4880
+ const LIST_ITEM = /^-(?:\s|$)/u;
4881
+ const ITEM_HEAD = /^- id: web\s*$/u;
4882
+ const CONTINUATION = /^\s+\S/u;
4883
+ const isBegin = (line) => line.trim().startsWith(BEGIN_PREFIX);
4884
+ const isEnd = (line) => line.trim() === BLOCK_END;
4885
+ /**
4886
+ * Find this plugin's entry: the begin marker, its comment lines, the single
4887
+ * `- id: web` item with its indented lines, and an end marker directly after.
4888
+ * Anything else — including another entry a YAML rewrite moved inside the
4889
+ * markers — is not part of it.
4890
+ * @param lines - the file split into lines.
4891
+ * @returns the half-open line range and the recorded fallback, or undefined.
4892
+ */
4893
+ function locate(lines) {
4894
+ const start = lines.findIndex(isBegin);
4895
+ if (start < 0) return void 0;
4896
+ let index = start + 1;
4897
+ let fallback;
4898
+ while (index < lines.length && lines[index].startsWith("#") && !isEnd(lines[index]) && !isBegin(lines[index])) {
4899
+ const match = FALLBACK_LINE.exec(lines[index]);
4900
+ if (match !== null) fallback = match[1];
4901
+ index += 1;
4902
+ }
4903
+ let provider;
4904
+ if (index < lines.length && ITEM_HEAD.test(lines[index])) {
4905
+ index += 1;
4906
+ while (index < lines.length && CONTINUATION.test(lines[index])) {
4907
+ const match = /^\s+searchProvider:\s*['"]?([A-Za-z0-9._-]+)['"]?\s*$/u.exec(lines[index]);
4908
+ if (match !== null) provider = match[1];
4909
+ index += 1;
4910
+ }
4911
+ }
4912
+ if (index < lines.length && isEnd(lines[index])) index += 1;
4913
+ return {
4914
+ start,
4915
+ end: index,
4916
+ fallback,
4917
+ provider
4918
+ };
4919
+ }
4920
+ /** Drop the located entry and any stray end marker left elsewhere. */
4921
+ function strip(lines) {
4922
+ const found = locate(lines);
4923
+ return (found === void 0 ? [...lines] : [...lines.slice(0, found.start), ...lines.slice(found.end)]).filter((line) => !isEnd(line));
4924
+ }
4925
+ function yamlScalar(value) {
4926
+ if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return String(value);
4927
+ return /^[A-Za-z0-9._-]+$/u.test(value) ? value : JSON.stringify(value);
4928
+ }
4929
+ /** The entry's lines; the web row's other scalar fields are restated, sorted. */
4930
+ function entryLines(providerId, fallback, baseConfig) {
4931
+ const fields = {};
4932
+ for (const [key, value] of Object.entries(baseConfig ?? {})) {
4933
+ if (key === "searchProvider" || !/^[A-Za-z0-9_]+$/u.test(key)) continue;
4934
+ if (typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) fields[key] = value;
4935
+ }
4936
+ fields.searchProvider = providerId;
4937
+ return [
4938
+ BLOCK_BEGIN,
4939
+ `# fallback: ${fallback}`,
4940
+ "- id: web",
4941
+ " config:",
4942
+ ...Object.keys(fields).sort().map((key) => ` ${key}: ${yamlScalar(fields[key])}`),
4943
+ BLOCK_END
4944
+ ];
4945
+ }
4946
+ /** A provider id the auto provider may fall back to. */
4947
+ function usableFallback(id) {
4948
+ return typeof id === "string" && PROVIDER_ID.test(id) && !OWN_SEARCH_PROVIDER_IDS.includes(id) ? id : DSH_SEARCH_PROVIDER_FALLBACK;
4949
+ }
4950
+ /**
4951
+ * Refuse a file this line-level editor cannot extend safely: a flow sequence
4952
+ * with entries, or a top-level line that is not a list item or comment.
4953
+ */
4954
+ function assertBlockSequence(lines) {
4955
+ for (const line of lines) {
4956
+ if (line.trim() === "" || line.trimStart().startsWith("#") || CONTINUATION.test(line) || LIST_ITEM.test(line) || EMPTY_LIST.test(line)) continue;
4957
+ throw new Error("PackyCode web search could not edit the profile patch: it is not a plain YAML list; set the web search provider by hand");
4958
+ }
4959
+ }
4960
+ /**
4961
+ * Maintain this plugin's entry in the owning profile's patch file.
4962
+ * @param options - where the file is, the web row's current base config, an
4963
+ * optional exclusive section (DSH's reload lock), and file-system seams.
4964
+ * @returns the composition: read the entry, apply a provider, remove it.
4965
+ */
4966
+ function createSearchComposition({ resolvePatchPath, readBaseConfig, runExclusive = (run) => run(), fs = {
4967
+ readFile,
4968
+ writeFile,
4969
+ rename
4970
+ } } = {}) {
4971
+ if (typeof resolvePatchPath !== "function") throw new Error("PackyCode search composition requires resolvePatchPath");
4972
+ if (typeof readBaseConfig !== "function") throw new Error("PackyCode search composition requires readBaseConfig");
4973
+ let chain = Promise.resolve();
4974
+ const serialized = (run) => {
4975
+ const next = chain.then(() => runExclusive(run));
4976
+ chain = next.then(() => void 0, () => void 0);
4977
+ return next;
4978
+ };
4979
+ const load = async () => {
4980
+ const path = await resolvePatchPath();
4981
+ if (typeof path !== "string" || path === "") throw new Error("PackyCode web search could not find the profile patch file");
4982
+ let content = "";
4983
+ try {
4984
+ content = await fs.readFile(path, "utf8");
4985
+ } catch (error) {
4986
+ if (error?.code !== "ENOENT") throw error;
4987
+ }
4988
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
4989
+ const lines = content === "" ? [] : content.split(/\r?\n/u);
4990
+ if (lines.length > 0 && lines.at(-1) === "") lines.pop();
4991
+ return {
4992
+ path,
4993
+ content,
4994
+ eol,
4995
+ lines
4996
+ };
4997
+ };
4998
+ const render = ({ eol }, lines) => lines.length === 0 ? "" : `${lines.join(eol)}${eol}`;
4999
+ const save = async (file, lines) => {
5000
+ const next = render(file, lines);
5001
+ if (next === file.content) return false;
5002
+ const temporary = `${file.path}.packycode-api.tmp`;
5003
+ await fs.writeFile(temporary, next, { mode: 384 });
5004
+ await fs.rename(temporary, file.path);
5005
+ return true;
5006
+ };
5007
+ /** The file with this plugin's entry pointing at `providerId`. */
5008
+ const applied = (file, providerId) => {
5009
+ const found = locate(file.lines);
5010
+ assertBlockSequence(found === void 0 ? file.lines : [...file.lines.slice(0, found.start), ...file.lines.slice(found.end)]);
5011
+ const base = readBaseConfig() ?? {};
5012
+ const fallback = found?.fallback !== void 0 ? usableFallback(found.fallback) : usableFallback(base.searchProvider);
5013
+ const entry = entryLines(providerId, fallback, base);
5014
+ if (found !== void 0) {
5015
+ const before = file.lines.slice(0, found.start).filter((line) => !isEnd(line));
5016
+ const after = file.lines.slice(found.end).filter((line) => !isEnd(line));
5017
+ while (after.length > 0 && after.at(-1).trim() === "") after.pop();
5018
+ return {
5019
+ fallback,
5020
+ lines: [
5021
+ ...before,
5022
+ ...entry,
5023
+ ...after
5024
+ ]
5025
+ };
5026
+ }
5027
+ const lines = file.lines.filter((line) => !EMPTY_LIST.test(line) && !isEnd(line));
5028
+ while (lines.length > 0 && lines.at(-1).trim() === "") lines.pop();
5029
+ return {
5030
+ fallback,
5031
+ lines: [...lines, ...entry]
5032
+ };
5033
+ };
5034
+ /** The file without this plugin's entry, or undefined when it holds none. */
5035
+ const removed = (file) => {
5036
+ if (locate(file.lines) === void 0 && !file.lines.some(isEnd)) return void 0;
5037
+ const lines = strip(file.lines);
5038
+ while (lines.length > 0 && lines.at(-1).trim() === "") lines.pop();
5039
+ if (!lines.some((line) => LIST_ITEM.test(line)) && !lines.some((line) => EMPTY_LIST.test(line))) lines.push("[]");
5040
+ return lines;
5041
+ };
5042
+ return Object.freeze({
5043
+ /** The entry as the file holds it now. */
5044
+ async read() {
5045
+ const { lines } = await load();
5046
+ const found = locate(lines);
5047
+ return found === void 0 ? { present: false } : {
5048
+ present: true,
5049
+ provider: found.provider,
5050
+ fallback: usableFallback(found.fallback)
5051
+ };
5052
+ },
5053
+ /**
5054
+ * Point the base search provider at `providerId`, keeping the recorded
5055
+ * fallback when the entry already exists.
5056
+ * @returns whether the file changed, and the fallback the entry records.
5057
+ */
5058
+ async apply(providerId) {
5059
+ if (!OWN_SEARCH_PROVIDER_IDS.includes(providerId)) throw new Error(`PackyCode web search cannot select "${String(providerId)}"`);
5060
+ const current = await load();
5061
+ const planned = applied(current, providerId);
5062
+ if (render(current, planned.lines) === current.content) return {
5063
+ changed: false,
5064
+ fallback: planned.fallback
5065
+ };
5066
+ return serialized(async () => {
5067
+ const file = await load();
5068
+ const next = applied(file, providerId);
5069
+ return {
5070
+ changed: await save(file, next.lines),
5071
+ fallback: next.fallback
5072
+ };
5073
+ });
5074
+ },
5075
+ /** Remove the entry, leaving an empty list when nothing else remains. */
5076
+ async remove() {
5077
+ if (removed(await load()) === void 0) return { changed: false };
5078
+ return serialized(async () => {
5079
+ const file = await load();
5080
+ const lines = removed(file);
5081
+ return { changed: lines === void 0 ? false : await save(file, lines) };
5082
+ });
5083
+ }
5084
+ });
5085
+ }
5086
+ /**
5087
+ * Run `operation` inside DSH's reload transaction, so a patch write never
5088
+ * overlaps a profile reload or a settings write. A reconcile started from
5089
+ * inside one of those (a web restart during a reload, a volatile update
5090
+ * during a settings write) inherits its context, and DSH refuses to nest the
5091
+ * transaction; that one runs unlocked, after the transaction that started it.
5092
+ * @param hmr - DSH's `hmr` service, when the host has one.
5093
+ * @param operation - the write.
5094
+ * @returns the operation's result.
5095
+ */
5096
+ async function runInReloadTransaction(hmr, operation) {
5097
+ if (typeof hmr?.runExclusive !== "function") return operation();
5098
+ let started = false;
5099
+ try {
5100
+ return await hmr.runExclusive(() => {
5101
+ started = true;
5102
+ return operation();
5103
+ });
5104
+ } catch (error) {
5105
+ if (!started && /cannot be nested/u.test(error?.message ?? "")) return operation();
5106
+ throw error;
5107
+ }
5108
+ }
5109
+ /**
5110
+ * The composition for the profile this plugin runs in.
5111
+ * @param options - the plugin context (for `profileContext` and `hmr`), the
5112
+ * web row's Loader entry, and where to look for the owning profile when the
5113
+ * host does not name its patch file.
5114
+ * @returns the composition.
5115
+ */
5116
+ function createProfileSearchComposition({ ctx, webEntry, dshHome, ownPackageJsonUrl }) {
5117
+ return createSearchComposition({
5118
+ resolvePatchPath: async () => {
5119
+ const profile = ctx.get?.("profileContext");
5120
+ if (typeof profile?.patchPath === "string" && profile.patchPath !== "") return profile.patchPath;
5121
+ const install = await findInstall({
5122
+ dshHome,
5123
+ ownPackageJsonUrl
5124
+ });
5125
+ return install.profile === void 0 ? void 0 : join(dshHome, "profiles", install.profile, SEARCH_PATCH_FILE);
5126
+ },
5127
+ readBaseConfig: () => webEntry()?.options?.config ?? {},
5128
+ runExclusive: (operation) => runInReloadTransaction(ctx.get?.("hmr"), operation)
5129
+ });
5130
+ }
5131
+ //#endregion
5132
+ //#region src/search-controller.js
3288
5133
  /**
3289
- * The loopback RPC surface behind the settings panel and the composer chip.
3290
- *
3291
- * Every reply carries only what this plugin owns: key labels and state, the
3292
- * balance projection the host built, host overrides, and whether this device
3293
- * has the Claude Code CLI. Key material never crosses this boundary.
5134
+ * Web search wiring: the two search providers, the user's routing and backend
5135
+ * preference, the profile-patch entry that makes DSH reach them, and the
5136
+ * projection the settings panel reads.
3294
5137
  *
3295
- * @module dsh-packycode-api/rpc
5138
+ * @module dsh-packycode-api/search-controller
3296
5139
  */
3297
- const ok = (value) => ({
3298
- ok: true,
3299
- value
3300
- });
3301
- const failed = (message) => ({
3302
- ok: false,
3303
- error: { message }
5140
+ /** Routers other subscription plugins register; shown so the user knows who holds the slot. */
5141
+ const FOREIGN_ROUTERS = Object.freeze({
5142
+ codex: "codex-subscription-auto",
5143
+ kimi: "kimi-subscription-auto"
3304
5144
  });
3305
- /** Only sentences this plugin authored may reach the browser. */
3306
- const SAFE_MESSAGE = /^(?:PackyCode|That |Unknown |PackyCode's |add |the )[\s\S]{0,180}$/u;
3307
- /** Accept an absolute http(s) origin and drop a trailing slash. */
3308
- function normalizeHost(value, fallback) {
3309
- const text = typeof value === "string" ? value.trim() : "";
3310
- if (text === "") return fallback;
3311
- let url;
3312
- try {
3313
- url = new URL(text);
3314
- } catch {
3315
- return fallback;
3316
- }
3317
- if (url.protocol !== "https:" && url.protocol !== "http:") return fallback;
3318
- return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
5145
+ /** Normalize a stored preference; anything unknown reads as the default. */
5146
+ function readSearchChoice(value) {
5147
+ return {
5148
+ mode: SEARCH_MODES.includes(value?.searchProvider) ? value.searchProvider : SEARCH_MODE_DEFAULT,
5149
+ backend: SEARCH_BACKENDS.includes(value?.searchBackend) ? value.searchBackend : DEFAULT_SEARCH_BACKEND
5150
+ };
5151
+ }
5152
+ /** Only sentences this plugin wrote reach the browser. */
5153
+ function safeMessage(error) {
5154
+ const message = error instanceof Error ? error.message : String(error);
5155
+ return /^PackyCode /u.test(message) ? message.slice(0, 240) : "PackyCode web search could not update the profile patch";
3319
5156
  }
3320
5157
  /**
3321
- * Build the RPC handler.
3322
- * @param options - the key ring, balance reader, host preference, and the
3323
- * Claude Code CLI manager (optional: without it the CLI endpoints refuse).
3324
- * @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
5158
+ * @param options - the key ring and hosts the search runs with, the key-failure
5159
+ * sink and group coverage shared with the model route, the preference reader
5160
+ * and writer, the initiating session's provider and id, the web row's Loader
5161
+ * entry, the profile-patch composition, a logger, and an optional fetch.
3325
5162
  */
3326
- function createPackyCodeRpc({ ring, balance, hosts, preference, claudeCli, pluginManager }) {
3327
- const requireCli = () => {
3328
- if (claudeCli === void 0) throw new Error("PackyCode Claude Code CLI support is unavailable in this runtime");
3329
- return claudeCli;
3330
- };
3331
- const requireManager = () => {
3332
- if (pluginManager === void 0) throw new Error("PackyCode plugin version is unavailable");
3333
- return pluginManager;
3334
- };
3335
- const status = async () => ({
3336
- ...await ring.list(),
3337
- hosts: hosts(),
3338
- writable: preference.writable === true,
3339
- defaults: {
3340
- apiHost: DEFAULT_API_HOST,
3341
- statusHost: DEFAULT_STATUS_HOST
3342
- }
5163
+ function createSearchController({ ring, hosts, onKeyFailure, covers, readPreference, writePreference, isWritable = () => typeof writePreference === "function", resolveModelProvider, resolveSessionId, webEntry = () => void 0, composition, logger, fetchImpl }) {
5164
+ let web;
5165
+ let fallbackId;
5166
+ let applied = { state: "idle" };
5167
+ let chain = Promise.resolve();
5168
+ const preference = () => readSearchChoice(readPreference?.());
5169
+ const packycode = createPackyCodeSearchProvider({
5170
+ ring,
5171
+ hosts,
5172
+ backend: () => preference().backend,
5173
+ onKeyFailure,
5174
+ covers,
5175
+ resolveSessionId,
5176
+ ...fetchImpl === void 0 ? {} : { fetchImpl }
3343
5177
  });
3344
- return async (endpoint, payload, signal) => {
3345
- try {
3346
- switch (endpoint) {
3347
- case "status": return ok(await status());
3348
- case "keys/add":
3349
- await ring.add(payload?.key, payload?.label);
3350
- balance.invalidate();
3351
- return ok(await status());
3352
- case "keys/rename":
3353
- await ring.rename(String(payload?.id ?? ""), payload?.label);
3354
- balance.invalidate();
3355
- return ok(await status());
3356
- case "keys/remove":
3357
- await ring.remove(String(payload?.id ?? ""));
3358
- balance.invalidate();
3359
- return ok(await status());
3360
- case "keys/remove-all":
3361
- await ring.removeAll();
3362
- balance.invalidate();
3363
- return ok(await status());
3364
- case "keys/select":
3365
- await ring.select(String(payload?.id ?? ""));
3366
- balance.invalidate();
3367
- return ok(await status());
3368
- case "keys/enable":
3369
- await ring.enable(String(payload?.id ?? ""));
3370
- balance.invalidate();
3371
- return ok(await status());
3372
- case "balance": {
3373
- const [value, state] = await Promise.all([balance.read({ force: payload?.force === true }), ring.list()]);
3374
- return ok({
3375
- ...value,
3376
- activeId: state.activeId
3377
- });
3378
- }
3379
- case "hosts/update":
3380
- if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
3381
- await preference.set({
3382
- apiHost: normalizeHost(payload?.apiHost, hosts().apiHost),
3383
- statusHost: normalizeHost(payload?.statusHost, hosts().statusHost)
3384
- });
3385
- balance.invalidate();
3386
- return ok(await status());
3387
- case "claude-cli/status": return ok(await requireCli().status({ force: payload?.force === true }));
3388
- case "claude-cli/install": {
3389
- const cli = requireCli();
3390
- cli.install();
3391
- return ok(await cli.status());
5178
+ const auto = createPackyCodeAutoSearchProvider({
5179
+ packycode,
5180
+ resolveModelProvider,
5181
+ resolveProvider: (id) => web?.searchProviders?.get?.(id),
5182
+ resolveFallbackId: () => fallbackId ?? usableFallback(webEntry()?.options?.config?.searchProvider)
5183
+ });
5184
+ /** Make the profile patch match the preference; serialized, idempotent. */
5185
+ const reconcile = () => {
5186
+ const run = async () => {
5187
+ if (web === void 0 || composition === void 0) {
5188
+ applied = { state: "unavailable" };
5189
+ return;
5190
+ }
5191
+ const { mode } = preference();
5192
+ try {
5193
+ if (mode === "default") {
5194
+ await composition.remove();
5195
+ fallbackId = void 0;
5196
+ applied = { state: "removed" };
5197
+ } else {
5198
+ const outcome = await composition.apply(mode === "auto" ? PACKYCODE_AUTO_SEARCH_PROVIDER_ID : PACKYCODE_SEARCH_PROVIDER_ID);
5199
+ fallbackId = outcome.fallback;
5200
+ applied = {
5201
+ state: "applied",
5202
+ fallback: outcome.fallback
5203
+ };
3392
5204
  }
3393
- case "plugin/version": return ok(await requireManager().read({
3394
- force: payload?.force === true,
3395
- signal
3396
- }));
3397
- case "plugin/update": return ok(await requireManager().update({ signal }));
3398
- default: return failed("Unknown PackyCode endpoint");
5205
+ } catch (error) {
5206
+ applied = {
5207
+ state: "failed",
5208
+ error: safeMessage(error)
5209
+ };
5210
+ logger?.warn?.("packycode-api: could not apply the web search selection: %s", error?.message ?? String(error));
3399
5211
  }
3400
- } catch (error) {
3401
- const message = error instanceof Error ? error.message : String(error);
3402
- return failed(SAFE_MESSAGE.test(message) ? message : "PackyCode request failed");
3403
- }
5212
+ };
5213
+ const next = chain.then(run);
5214
+ chain = next.then(() => void 0, () => void 0);
5215
+ return next;
5216
+ };
5217
+ const status = () => {
5218
+ const { mode, backend } = preference();
5219
+ const entry = webEntry();
5220
+ const base = entry?.options?.config?.searchProvider;
5221
+ const runtime = entry?.fiber?.config?.searchProvider;
5222
+ return {
5223
+ searchProvider: mode,
5224
+ searchBackend: backend,
5225
+ writable: isWritable() === true,
5226
+ available: web !== void 0,
5227
+ models: { ...SEARCH_BACKEND_MODELS },
5228
+ effective: {
5229
+ ...typeof base === "string" ? { base } : {},
5230
+ ...typeof runtime === "string" ? { runtime } : {}
5231
+ },
5232
+ applied: { ...applied },
5233
+ foreign: Object.fromEntries(Object.entries(FOREIGN_ROUTERS).map(([name, id]) => [name, web?.searchProviders?.has?.(id) === true]))
5234
+ };
3404
5235
  };
5236
+ return Object.freeze({
5237
+ packycode,
5238
+ auto,
5239
+ /**
5240
+ * Register both providers with the web service and reconcile once.
5241
+ * @param service - the `web` service, as the inject scope hands it over.
5242
+ * @param effect - that scope's effect registrar.
5243
+ */
5244
+ register(service, effect) {
5245
+ effect(() => {
5246
+ web = service;
5247
+ const disposePackycode = service.registerSearchProvider(packycode);
5248
+ const disposeAuto = service.registerSearchProvider(auto);
5249
+ return () => {
5250
+ disposeAuto?.();
5251
+ disposePackycode?.();
5252
+ if (web === service) web = void 0;
5253
+ };
5254
+ }, "packycode-api: web search providers");
5255
+ reconcile();
5256
+ },
5257
+ reconcile,
5258
+ status,
5259
+ /** Validate, persist, and apply a preference change. */
5260
+ async update(patch) {
5261
+ const next = {};
5262
+ if (patch?.searchProvider !== void 0) {
5263
+ if (!SEARCH_MODES.includes(patch.searchProvider)) throw new Error("That PackyCode search option is not supported");
5264
+ next.searchProvider = patch.searchProvider;
5265
+ }
5266
+ if (patch?.searchBackend !== void 0) {
5267
+ if (!SEARCH_BACKENDS.includes(patch.searchBackend)) throw new Error("That PackyCode search backend is not supported");
5268
+ next.searchBackend = patch.searchBackend;
5269
+ }
5270
+ if (Object.keys(next).length === 0) return status();
5271
+ if (isWritable() !== true || typeof writePreference !== "function") throw new Error("PackyCode settings are not writable in this profile");
5272
+ try {
5273
+ await writePreference(next);
5274
+ } catch (error) {
5275
+ logger?.warn?.("packycode-api: could not save the web search preference: %s", error?.message ?? String(error));
5276
+ throw new Error("PackyCode could not save the web search preference", { cause: error });
5277
+ }
5278
+ await reconcile();
5279
+ return status();
5280
+ }
5281
+ });
3405
5282
  }
3406
5283
  //#endregion
3407
5284
  //#region src/index.js
3408
5285
  /**
3409
5286
  * dsh-packycode-api — a self-contained PackyCode route for DeepSeek Harness.
3410
5287
  *
3411
- * The plugin builds its own pi-ai provider, model catalog, key ring, and
3412
- * balance reader, then registers the route through the same public
5288
+ * The plugin builds its own pi-ai provider, model catalog, key ring, balance
5289
+ * and group readers, then registers the route through the same public
3413
5290
  * `llm.registerAdapter` API the shipped adapters use. Nothing here reads the
3414
5291
  * profile's composition config, so installing the package is the whole setup:
3415
5292
  * the picker shows the models and the user only pastes API keys.
3416
5293
  *
5294
+ * This module only composes; each concern lives in its own module.
5295
+ *
3417
5296
  * @module dsh-packycode-api
3418
5297
  */
3419
5298
  const name = PLUGIN_ID;
3420
5299
  /**
3421
- * Only the model route is mandatory. `connection` carries the browser RPC
3422
- * surface, and `settings` the host overrides: a profile without them (a
5300
+ * Only the model route is mandatory. `settings`, `web`, `connection`, and
5301
+ * `tools` are requested through `ctx.inject`: a profile without them (a
3423
5302
  * headless run, for one) must still boot with a working route instead of
3424
5303
  * blocking on a service it will never see.
3425
5304
  */
3426
5305
  const inject = ["llm", "credentials"];
3427
- /** What a `claude_code` tool run is told about where its task came from. */
3428
- const CLAUDE_TOOL_INSTRUCTIONS = "This task was delegated to Claude Code by another agent in DeepSeek Harness through its claude_code tool. The task text is self-contained; complete it with your own tools and reply with the final result only.";
3429
- /** Host overrides; a profile that never installs this section keeps the defaults. */
5306
+ /** The model a CLI run without one is matched against for its key's groups. */
5307
+ const CLI_GROUP_MODEL = "claude-opus-5-5";
5308
+ /** Host overrides and preferences; a profile that never sets them keeps the defaults. */
3430
5309
  const Config = z.object({
3431
5310
  apiHost: z.string().default(DEFAULT_API_HOST),
3432
5311
  statusHost: z.string().default(DEFAULT_STATUS_HOST),
@@ -3443,100 +5322,23 @@ const Config = z.object({
3443
5322
  "full"
3444
5323
  ]).default(AUTO_CLAUDE_MODE),
3445
5324
  /** Working directory for CLI-backed turns; empty means "the session's workspace". */
3446
- claudeCliCwd: z.string().default("")
3447
- });
3448
- /**
3449
- * Request-level bounds from the shipped pi-ai adapter: a bounded image payload
3450
- * keeps a long session completing requests instead of being refused by a
3451
- * gateway size cap.
3452
- */
3453
- const REQUEST_LIMITS = Object.freeze({
3454
- streamIdleTimeoutMs: 600 * 1e3,
3455
- maxRequestImageBytes: 20 * 1024 * 1024,
3456
- requestImagePixelBudget: 2048 * 2048,
3457
- requestImageMaxBytes: 1024 * 1024
5325
+ claudeCliCwd: z.string().default(""),
5326
+ /**
5327
+ * Web search routing: `default` leaves DSH's search alone, `auto` searches
5328
+ * through PackyCode for PackyCode sessions only, `packycode` for every
5329
+ * request DSH's own search would have served.
5330
+ */
5331
+ searchProvider: live(z.union([...SEARCH_MODES]).default(SEARCH_MODE_DEFAULT)),
5332
+ /** Which PackyCode model runs a search: `deepseek` or `gpt`. */
5333
+ searchBackend: live(z.union([...SEARCH_BACKENDS]).default(DEFAULT_SEARCH_BACKEND))
3458
5334
  });
3459
- /**
3460
- * pi-ai's credential store over the key ring.
3461
- *
3462
- * Reads answer with the active key so pi-ai's own auth reporting stays honest,
3463
- * while writes are refused: the ring owns them, because only the ring can move
3464
- * the active pointer past a key the gateway rejected.
3465
- * @param ring - the plugin's key ring.
3466
- * @returns a pi-ai `CredentialStore`.
3467
- */
3468
- function ringCredentialStore(ring) {
3469
- const managed = () => {
3470
- throw new Error("PackyCode API keys are managed in the PackyCode settings section");
3471
- };
3472
- return {
3473
- async read() {
3474
- const active = await ring.activeKey();
3475
- return active === void 0 ? void 0 : {
3476
- type: "api_key",
3477
- key: active.key
3478
- };
3479
- },
3480
- async list() {
3481
- return await ring.activeKey() === void 0 ? [] : [{
3482
- providerId: PROVIDER,
3483
- type: "api_key"
3484
- }];
3485
- },
3486
- modify: managed,
3487
- delete: managed
3488
- };
3489
- }
3490
- /**
3491
- * The provider profile this route runs on.
3492
- *
3493
- * `modelErrors` and `configuredMaxTokens` are required by the host adapter;
3494
- * they stay empty because the catalog is fixed and no model carries a
3495
- * per-request output cap of its own.
3496
- * @param provider - the route's pi-ai provider.
3497
- * @returns the frozen profile handed to `PiAiAdapter`.
3498
- */
3499
- function createPackyCodeProfile(provider) {
3500
- return Object.freeze({
3501
- provider: PROVIDER,
3502
- displayName: DISPLAY_NAME,
3503
- piProvider: provider,
3504
- reasoning: DEFAULT_REASONING_LEVEL,
3505
- configuredMaxTokens: /* @__PURE__ */ new Map(),
3506
- modelErrors: /* @__PURE__ */ new Map(),
3507
- ...REQUEST_LIMITS,
3508
- cacheRetention: "short",
3509
- retryPolicy: resolveRetryPolicy({
3510
- mode: "normal",
3511
- maxRetries: 20,
3512
- retryableCodes: [
3513
- "EMPTY_RESPONSE",
3514
- "RATE_LIMIT",
3515
- "SERVER",
3516
- "TIMEOUT",
3517
- "TRANSPORT",
3518
- QUOTA_EXCEEDED_CODE,
3519
- "AUTH",
3520
- "INVALID_REQUEST"
3521
- ]
3522
- }, `${PLUGIN_ID}.retryPolicy`)
3523
- });
3524
- }
3525
5335
  function apply(ctx, config) {
3526
- const balanceReaderOptions = {};
3527
5336
  const ring = new PackyCodeKeyRing(ctx.credentials);
3528
- /**
3529
- * Key material to ring id. A failure is reported from inside a stream, which
3530
- * carries the request's key but not its id; this index is refreshed on every
3531
- * resolution so the right key is the one that moves aside.
3532
- */
3533
- const idByKey = /* @__PURE__ */ new Map();
3534
- const rememberKeys = async () => {
3535
- for (const entry of await ring.secrets()) idByKey.set(entry.key, entry.id);
3536
- };
3537
5337
  let source = () => ({
3538
5338
  apiHost: normalizeHost(config?.apiHost, DEFAULT_API_HOST),
3539
- statusHost: normalizeHost(config?.statusHost, DEFAULT_STATUS_HOST)
5339
+ statusHost: normalizeHost(config?.statusHost, DEFAULT_STATUS_HOST),
5340
+ searchProvider: fieldValue(config?.searchProvider),
5341
+ searchBackend: fieldValue(config?.searchBackend)
3540
5342
  });
3541
5343
  const hosts = () => {
3542
5344
  const current = source() ?? {};
@@ -3546,69 +5348,63 @@ function apply(ctx, config) {
3546
5348
  };
3547
5349
  };
3548
5350
  const balance = createBalanceReader({
3549
- ...balanceReaderOptions,
3550
5351
  listKeys: () => ring.secrets(),
3551
5352
  getHosts: hosts
3552
5353
  });
3553
- const onKeyFailure = (kind, _message, key) => {
3554
- const id = key === void 0 ? void 0 : idByKey.get(key);
3555
- if (id === void 0) {
3556
- ctx.logger?.warn?.("packycode-api: %s reported for an unidentified key; not rotating", kind);
5354
+ /**
5355
+ * Which token groups each key is in, and so which models it can reach.
5356
+ * Nothing reads it at boot: the settings panel asks for it, and a request
5357
+ * with a choice of keys waits briefly for the first reading.
5358
+ */
5359
+ const groups = createGroupReader({
5360
+ listKeys: () => ring.secrets(),
5361
+ getHosts: hosts
5362
+ });
5363
+ const keys = createKeyChooser({
5364
+ ring,
5365
+ groups,
5366
+ logger: ctx.logger,
5367
+ onRotated: () => balance.invalidate()
5368
+ });
5369
+ /** The session of the agent this turn runs for, when one is in scope. */
5370
+ const currentSession = () => {
5371
+ try {
5372
+ return ctx.get?.("agents")?.currentInitiator?.()?.session;
5373
+ } catch {
3557
5374
  return;
3558
5375
  }
3559
- ctx.logger?.warn?.("packycode-api: %s on key %s; the next key serves the retry", kind, id);
3560
- ring.disable(id, kind).then(() => balance.invalidate()).catch((error) => ctx.logger?.warn?.("packycode-api: could not move past key %s: %s", id, error.message));
3561
5376
  };
3562
- const raw = createPackyCodeProvider();
3563
- /**
3564
- * The gateway's Codex-group check refuses a Responses request that carries no
3565
- * `prompt_cache_key` with a 403, which pi-ai derives from the session id. The
3566
- * harness supplies one for every agent turn; this fallback covers a caller
3567
- * that does not, so the route never fails for a missing cache key.
3568
- */
3569
- const fallbackSessionId = `${PLUGIN_ID}-${randomUUID()}`;
3570
- const withSessionId = (options) => options === void 0 || options.sessionId !== void 0 ? options : {
3571
- ...options,
3572
- sessionId: fallbackSessionId
5377
+ /** One Loader entry by row id, or undefined outside a Loader composition. */
5378
+ const loaderEntry = (id) => {
5379
+ try {
5380
+ return [...ctx.get?.("loader")?.entries?.() ?? []].find((entry) => entry?.options?.id === id);
5381
+ } catch {
5382
+ return;
5383
+ }
3573
5384
  };
3574
- const profile = createPackyCodeProfile(Object.freeze({
3575
- ...raw,
3576
- stream: (model, context, options) => guardKeyRotation(raw.stream(model, context, withSessionId(options)), {
3577
- key: options?.apiKey,
3578
- onKeyFailure
3579
- }),
3580
- streamSimple: (model, context, options) => guardKeyRotation(raw.streamSimple(model, context, withSessionId(options)), {
3581
- key: options?.apiKey,
3582
- onKeyFailure
3583
- })
3584
- }));
3585
- const profiles = /* @__PURE__ */ new Map([[PROVIDER, profile]]);
5385
+ const fallbackSessionId = `${PLUGIN_ID}-${randomUUID()}`;
5386
+ const attachments = () => ctx.get?.("attachments") ?? ctx.attachments;
3586
5387
  /**
3587
- * The Claude Code CLI runner. PackyCode's `cc` group serves its Claude
3588
- * models only to the official CLI, so CLI-backed models below are run by
3589
- * that CLI itself — with this plugin's endpoint and key, in an isolated
3590
- * config home.
5388
+ * The Claude Code CLI runner. PackyCode serves its Claude models only to the
5389
+ * official CLI, so CLI-backed models are run by that CLI itself — with this
5390
+ * plugin's endpoint and a ring key, in an isolated config home. Each run
5391
+ * uses the active key unless its groups cannot reach the run's Claude model
5392
+ * and another stored key's can, and reports which key it used by label.
3591
5393
  */
3592
5394
  const claudeCli = createClaudeCliManager({ explicitPath: () => config?.claudeCliPath });
3593
5395
  const claudeRunner = createClaudeRunner({
3594
5396
  binary: () => claudeCli.binary(),
3595
- resolveCredential: async () => {
3596
- const active = await ring.activeKey();
3597
- if (active === void 0) throw new Error("Claude Code CLI needs a usable PackyCode API key; add one under Settings → PackyCode");
5397
+ resolveCredential: async ({ model } = {}) => {
5398
+ const choice = await keys.forRun(PACKYCODE_MODEL_IDS.includes(model) ? model : CLI_GROUP_MODEL);
5399
+ if (choice === void 0) throw new Error("Claude Code CLI needs a usable PackyCode API key; add one under Settings → PackyCode");
5400
+ const { key, ...described } = choice;
3598
5401
  return {
5402
+ ...described,
3599
5403
  baseUrl: hosts().apiHost,
3600
- token: active.key
5404
+ token: key
3601
5405
  };
3602
5406
  }
3603
5407
  });
3604
- /** The session of the agent this turn runs for, when one is in scope. */
3605
- const currentSession = () => {
3606
- try {
3607
- return ctx.get?.("agents")?.currentInitiator?.()?.session;
3608
- } catch {
3609
- return;
3610
- }
3611
- };
3612
5408
  /** The session's own workspace, so a CLI turn edits the project in view. */
3613
5409
  const resolveCliCwd = () => {
3614
5410
  const configured = typeof config?.claudeCliCwd === "string" ? config.claudeCliCwd.trim() : "";
@@ -3620,76 +5416,102 @@ function apply(ctx, config) {
3620
5416
  * The most a CLI turn may do: an explicit setting when the profile names
3621
5417
  * one, otherwise what the session's permission preset allows.
3622
5418
  */
3623
- const configuredCliMode = () => {
5419
+ const cliModeCeiling = (session) => {
3624
5420
  const configured = config?.claudeCliMode;
3625
- return configured === void 0 || configured === "auto" ? void 0 : configured;
5421
+ return configured === void 0 || configured === "auto" ? claudeModeForSession(ctx, session) : configured;
3626
5422
  };
3627
- const cliModeCeiling = (session) => configuredCliMode() ?? claudeModeForSession(ctx, session);
3628
- const resolveCliMode = () => cliModeCeiling(currentSession()) ?? "edit";
3629
- /**
3630
- * Dispatch per model: CLI-backed ids run the Claude Code CLI, everything
3631
- * else keeps the pi-ai path.
3632
- */
3633
- const adapter = createClaudeCliAdapter({
3634
- inner: new PiAiAdapter({
3635
- profiles: () => profiles,
3636
- resolveApiKey: async () => {
3637
- await rememberKeys();
3638
- const active = await ring.activeKey();
3639
- if (active === void 0) throw new LlmError("No usable PackyCode API key: add one under Settings → PackyCode, or re-enable the keys this plugin moved past.", "MISSING_CREDENTIAL");
3640
- return active.key;
3641
- },
3642
- auth: Object.freeze({
3643
- credentials: ringCredentialStore(ring),
3644
- authContext: Object.freeze({
3645
- env: async () => void 0,
3646
- fileExists: async () => false
3647
- })
3648
- }),
3649
- resolveAttachments: () => ctx.get?.("attachments") ?? ctx.attachments
5423
+ ctx.llm.registerAdapter([PROVIDER], createClaudeCliAdapter({
5424
+ inner: createRouteAdapter({
5425
+ ring,
5426
+ keys,
5427
+ fallbackSessionId,
5428
+ resolveAttachments: attachments
3650
5429
  }),
3651
5430
  runner: claudeRunner,
3652
- resolveMode: resolveCliMode,
5431
+ resolveMode: () => cliModeCeiling(currentSession()) ?? "edit",
3653
5432
  resolveCwd: resolveCliCwd,
3654
- resolveAttachments: () => ctx.get?.("attachments") ?? ctx.attachments
3655
- });
3656
- ctx.llm.registerAdapter([PROVIDER], adapter);
5433
+ resolveAttachments: attachments,
5434
+ explainKey: (runKey) => keys.describe(runKey)
5435
+ }));
3657
5436
  ctx.llm.registerConfigurableProviders([{
3658
5437
  provider: PROVIDER,
3659
5438
  displayName: DISPLAY_NAME,
3660
5439
  settingsNs: SETTINGS_NAMESPACE,
3661
5440
  settingsPath: []
3662
5441
  }]);
3663
- const preference = {
5442
+ const hostPreference = unwritable("PackyCode host overrides are not writable in this profile");
5443
+ const searchPreference = {
3664
5444
  writable: false,
3665
- set: async () => {
3666
- throw new Error("PackyCode host overrides are not writable in this profile");
3667
- }
5445
+ set: void 0
3668
5446
  };
3669
- ctx.inject(["settings"], (settingsCtx) => {
3670
- try {
3671
- settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, source(), {
3672
- setSource: (current) => {
3673
- source = typeof current === "function" ? () => current() ?? {} : () => current ?? {};
3674
- },
3675
- onChange: () => balance.invalidate()
3676
- });
3677
- if (typeof settingsCtx.settings.update === "function") {
3678
- preference.writable = true;
3679
- preference.set = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch);
5447
+ const dshHome = typeof process.env.DSH_HOME === "string" && process.env.DSH_HOME !== "" ? process.env.DSH_HOME : join(homedir(), ".dsh");
5448
+ const search = createSearchController({
5449
+ ring,
5450
+ hosts,
5451
+ onKeyFailure: keys.onKeyFailure,
5452
+ covers: keys.covers,
5453
+ readPreference: () => source() ?? {},
5454
+ writePreference: (patch) => searchPreference.set(patch),
5455
+ isWritable: () => searchPreference.writable === true && typeof searchPreference.set === "function",
5456
+ resolveModelProvider: () => {
5457
+ try {
5458
+ return currentSession()?.requestContext?.()?.provider;
5459
+ } catch {
5460
+ return;
3680
5461
  }
3681
- } catch (error) {
3682
- ctx.logger?.warn?.("packycode-api: host overrides unavailable, defaults stand: %s", error.message);
3683
- }
5462
+ },
5463
+ resolveSessionId: () => {
5464
+ const id = currentSession()?.id;
5465
+ return typeof id === "string" && id !== "" ? id : fallbackSessionId;
5466
+ },
5467
+ webEntry: () => loaderEntry("web"),
5468
+ composition: createProfileSearchComposition({
5469
+ ctx,
5470
+ webEntry: () => loaderEntry("web"),
5471
+ dshHome,
5472
+ ownPackageJsonUrl: new URL("../package.json", import.meta.url)
5473
+ }),
5474
+ logger: ctx.logger
5475
+ });
5476
+ ctx.inject(["settings"], (settingsCtx) => connectSettings(settingsCtx, {
5477
+ ctx,
5478
+ Config,
5479
+ namespace: SETTINGS_NAMESPACE,
5480
+ source: () => source(),
5481
+ setSource: (next) => {
5482
+ source = next;
5483
+ },
5484
+ onChange: () => {
5485
+ balance.invalidate();
5486
+ groups.invalidate();
5487
+ search.reconcile();
5488
+ },
5489
+ hostPreference,
5490
+ searchPreference
5491
+ }));
5492
+ ctx.on?.("loader/volatile-update", () => {
5493
+ search.reconcile();
5494
+ });
5495
+ /**
5496
+ * Both search providers register with the web service whenever one exists.
5497
+ * Registration alone changes nothing; the preference decides whether the
5498
+ * profile patch points DSH at them.
5499
+ */
5500
+ ctx.inject(["web"], (webCtx) => {
5501
+ if (typeof webCtx.web?.registerSearchProvider !== "function") return;
5502
+ const scope = typeof webCtx.effect === "function" ? webCtx : ctx;
5503
+ search.register(webCtx.web, (callback, label) => scope.effect(callback, label));
3684
5504
  });
3685
- rememberKeys().catch(() => void 0);
5505
+ keys.remember().catch(() => void 0);
3686
5506
  const handler = createPackyCodeRpc({
3687
5507
  ring,
3688
5508
  balance,
5509
+ groups,
3689
5510
  hosts,
3690
- preference,
5511
+ preference: hostPreference,
3691
5512
  claudeCli,
3692
- pluginManager: createPackyCodePluginManager()
5513
+ pluginManager: createPackyCodePluginManager(),
5514
+ search
3693
5515
  });
3694
5516
  /**
3695
5517
  * Connection registers a channel through `owner.webServer`, read on its own
@@ -3702,82 +5524,12 @@ function apply(ctx, config) {
3702
5524
  ctx.inject(["connection", "webServer"], (connectionCtx) => {
3703
5525
  ctx.effect(() => connectionCtx.connection.rpc.handle(CHANNEL, handler), "packycode-api: loopback account RPC");
3704
5526
  });
3705
- /**
3706
- * The explicit delegation tool, for asking Claude Code for something while
3707
- * another model drives the session. It shares the runner the CLI-backed
3708
- * models use.
3709
- */
3710
5527
  ctx.inject(["tools"], (toolsCtx) => {
3711
- ctx.effect(() => toolsCtx.tools.register(defineTool({
3712
- name: "claude_code",
3713
- description: "Delegate a task to the local Claude Code CLI (the official `claude` binary), which runs it with its own tools and returns its final answer. Use it when the user asks for Claude or when a task suits Claude Code. The child runs with this plugin's PackyCode endpoint and API key, in an isolated config home. `mode` bounds what it may do on its own: \"read-only\" (Read/Grep/Glob), \"edit\" (also Edit/Write), \"full\" (bypasses the CLI's permission checks, including shell commands); it defaults to, and is capped at, what this session's permission preset allows. It starts a fresh CLI session per call, takes tens of seconds, and bills through the same PackyCode key. Its internal tool calls are not part of this session's transcript — only the returned answer is.",
3714
- parameters: {
3715
- prompt: {
3716
- type: "string",
3717
- required: true,
3718
- description: "The task for Claude Code, written as a self-contained instruction (it does not see this conversation)."
3719
- },
3720
- mode: {
3721
- type: "string",
3722
- enum: Object.keys(CLAUDE_CLI_MODES),
3723
- description: "What the CLI may do on its own. Defaults to the most this session's permission preset allows; a wider mode is capped to it. Ask for \"full\" only when the task genuinely needs shell commands."
3724
- },
3725
- model: {
3726
- type: "string",
3727
- description: "Model id passed to the CLI (for example \"claude-opus-5-5\", \"claude-sonnet-5-5\", or \"claude-haiku-5-5\"). Omit to use the CLI's own default."
3728
- },
3729
- cwd: {
3730
- type: "string",
3731
- description: "Working directory for the CLI. Defaults to this session's workspace; a relative path resolves against it."
3732
- }
3733
- },
3734
- output: {
3735
- schema: {
3736
- type: "object",
3737
- additionalProperties: false,
3738
- properties: {
3739
- result: {
3740
- type: "string",
3741
- required: true
3742
- },
3743
- model: {
3744
- type: "string",
3745
- required: true
3746
- },
3747
- mode: {
3748
- type: "string",
3749
- required: true
3750
- },
3751
- costUsd: {
3752
- type: "number",
3753
- required: true
3754
- },
3755
- durationMs: {
3756
- type: "number",
3757
- required: true
3758
- }
3759
- }
3760
- },
3761
- render: (_args, value) => [{
3762
- type: "text",
3763
- text: `${value.result}\n\n— Claude Code CLI · ${value.model || "default model"} · ${value.mode} · ${Math.round(value.durationMs / 1e3)}s · $${value.costUsd.toFixed(4)}`
3764
- }]
3765
- },
3766
- async execute(args, exec) {
3767
- const sessionCwd = exec?.agent?.session?.header?.cwd;
3768
- const requested = typeof args.cwd === "string" ? args.cwd.trim() : "";
3769
- const cwd = requested === "" ? sessionCwd : isAbsolute(requested) ? requested : sessionCwd === void 0 ? requested : join(sessionCwd, requested);
3770
- return await claudeRunner.run({
3771
- prompt: args.prompt,
3772
- mode: capClaudeMode(args.mode, cliModeCeiling(exec?.agent?.session)) ?? "edit",
3773
- model: args.model,
3774
- appendSystemPrompt: CLAUDE_TOOL_INSTRUCTIONS,
3775
- cwd,
3776
- signal: exec?.signal
3777
- });
3778
- }
5528
+ ctx.effect(() => toolsCtx.tools.register(createClaudeCodeTool({
5529
+ runner: claudeRunner,
5530
+ modeCeiling: cliModeCeiling
3779
5531
  })), "packycode-api: claude_code tool");
3780
5532
  });
3781
5533
  }
3782
5534
  //#endregion
3783
- export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SETTINGS_NAMESPACE, TITLE_MODEL_ID, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, capClaudeMode, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeRunner, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, delegationInstructions, explainCliClientPolicyFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseTokenUsage, readClaudeStreamEvents, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };
5535
+ export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLAUDE_TOOL_INSTRUCTIONS, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TITLE_MODEL_ID, TITLE_MODEL_ROUTES, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, buildSearchRequest, capClaudeMode, catalogCoverage, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeCodeTool, createClaudeRunner, createGroupReader, createKeyChooser, createPackyCodeAutoSearchProvider, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, delegationInstructions, describeRunKey, explainCliClientPolicyFailure, explainCliGroupFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, keyNote, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, readClaudeStreamEvents, readSearchChoice, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };