dsh-packycode-api 0.0.0-stage → 1.0.3
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/AGENTS.md +97 -0
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.en.md +159 -0
- package/README.md +158 -2
- package/SECURITY.md +18 -0
- package/compatibility.json +13 -0
- package/cordis.patch.yml +23 -0
- package/docs/assets/dsh-packycode-mascot.png +0 -0
- package/icon-subscription.webp +0 -0
- package/lib/client.js +911 -0
- package/lib/index.js +2814 -0
- package/package.json +99 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,2814 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
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
|
+
import z from "@deepseek-ai/schemastery";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
|
+
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
11
|
+
import { createProvider } from "@earendil-works/pi-ai";
|
|
12
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
13
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
14
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
15
|
+
//#region src/constants.js
|
|
16
|
+
/**
|
|
17
|
+
* Identities and defaults this plugin owns.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-packycode-api/constants
|
|
20
|
+
*/
|
|
21
|
+
/** LLM provider route key; the model picker groups models under this id. */
|
|
22
|
+
const PROVIDER = "packycode";
|
|
23
|
+
/** Display name of the route in the picker and settings. */
|
|
24
|
+
const DISPLAY_NAME = "PackyCode";
|
|
25
|
+
/** Loader row id, settings namespace, and credential-record scope. */
|
|
26
|
+
const PLUGIN_ID = "packycode-api";
|
|
27
|
+
/** Loopback RPC channel the browser half talks to. */
|
|
28
|
+
const CHANNEL = "/packycode-api";
|
|
29
|
+
/** Settings namespace carrying host overrides. */
|
|
30
|
+
const SETTINGS_NAMESPACE = "packycode-api";
|
|
31
|
+
/** Credential record id inside the plugin's own scope. */
|
|
32
|
+
const KEY_RECORD_ID = "keys";
|
|
33
|
+
/**
|
|
34
|
+
* Model-traffic host. Each model declares the protocol its gateway route
|
|
35
|
+
* actually serves: `cf.api.fan/v1` answers chat/completions and responses,
|
|
36
|
+
* `cf.api.fan` answers the Anthropic Messages protocol at `/v1/messages`.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_API_HOST = "https://cf.api.fan";
|
|
39
|
+
/**
|
|
40
|
+
* Host carrying `/api/usage/token/`. `cf.api.fan` answers 404 for the whole
|
|
41
|
+
* `/api/*` family, so per-key quota is read from the PackyAPI host instead.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_STATUS_HOST = "https://www.packyapi.com";
|
|
44
|
+
/** PackyAPI quotes balances in quota units of 1/500000 USD (new-api convention). */
|
|
45
|
+
const QUOTA_PER_USD = 5e5;
|
|
46
|
+
/** Balance cache lifetime; a forced read bypasses it. */
|
|
47
|
+
const DEFAULT_BALANCE_TTL_MS = 6e4;
|
|
48
|
+
/** Bound on every account request this plugin makes. */
|
|
49
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 2e4;
|
|
50
|
+
/** Effort level the route requests when a session names none. */
|
|
51
|
+
const DEFAULT_REASONING_LEVEL = "high";
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/balance.js
|
|
54
|
+
/**
|
|
55
|
+
* Account and per-key balance.
|
|
56
|
+
*
|
|
57
|
+
* PackyCode is a new-api style relay: `/api/usage/token/` reports the quota of
|
|
58
|
+
* the key that authorized the request, and `/v1/dashboard/billing/*` reports
|
|
59
|
+
* the account. Only the second family exists on the model host (`cf.api.fan`
|
|
60
|
+
* answers 404 for `/api/*`), so a read tries the configured host first and
|
|
61
|
+
* falls back to the PackyAPI host — the same fallback the PackyCode status
|
|
62
|
+
* tooling uses.
|
|
63
|
+
*
|
|
64
|
+
* Credentials and response bodies stay host-side; the browser receives only
|
|
65
|
+
* the projection built here.
|
|
66
|
+
*
|
|
67
|
+
* @module dsh-packycode-api/balance
|
|
68
|
+
*/
|
|
69
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
70
|
+
const clone = (value) => structuredClone(value);
|
|
71
|
+
function number(value) {
|
|
72
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
73
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
74
|
+
const parsed = Number(value);
|
|
75
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function money(quota) {
|
|
79
|
+
const value = number(quota);
|
|
80
|
+
if (value === void 0) return void 0;
|
|
81
|
+
return Math.round(value / QUOTA_PER_USD * 1e6) / 1e6;
|
|
82
|
+
}
|
|
83
|
+
/** Normalize one quoted amount, treating the "no period limit" sentinel as absent. */
|
|
84
|
+
function quotaFields(prefix, quota) {
|
|
85
|
+
const value = number(quota);
|
|
86
|
+
if (value === void 0 || value === -1) return {};
|
|
87
|
+
return {
|
|
88
|
+
[`${prefix}Quota`]: value,
|
|
89
|
+
[`${prefix}Usd`]: money(value)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Normalize `/api/usage/token/`.
|
|
94
|
+
* @param payload - the endpoint's payload, wrapped or bare.
|
|
95
|
+
* @returns a small projection, or undefined when the shape is unrecognized.
|
|
96
|
+
*/
|
|
97
|
+
function parseTokenUsage(payload) {
|
|
98
|
+
const data = record(payload?.data) ? payload.data : payload;
|
|
99
|
+
if (!record(data)) return void 0;
|
|
100
|
+
if (![
|
|
101
|
+
"name",
|
|
102
|
+
"quota_limit",
|
|
103
|
+
"total_granted",
|
|
104
|
+
"total_used",
|
|
105
|
+
"total_available",
|
|
106
|
+
"unlimited_quota",
|
|
107
|
+
"quota_reset_period"
|
|
108
|
+
].some((field) => data[field] !== void 0)) return void 0;
|
|
109
|
+
const expiresAt = number(data.expires_at);
|
|
110
|
+
return {
|
|
111
|
+
unlimitedQuota: data.unlimited_quota === true,
|
|
112
|
+
...typeof data.name === "string" && data.name !== "" ? { name: data.name } : {},
|
|
113
|
+
...quotaFields("limit", data.quota_limit),
|
|
114
|
+
...quotaFields("granted", data.total_granted),
|
|
115
|
+
...quotaFields("used", data.total_used),
|
|
116
|
+
...quotaFields("available", data.total_available),
|
|
117
|
+
...typeof data.quota_reset_period === "string" && data.quota_reset_period !== "" ? { resetPeriod: data.quota_reset_period } : {},
|
|
118
|
+
...expiresAt === void 0 || expiresAt <= 0 ? {} : { expiresAt }
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Normalize `/v1/dashboard/billing/subscription`. */
|
|
122
|
+
function parseBillingSubscription(payload) {
|
|
123
|
+
if (!record(payload)) return void 0;
|
|
124
|
+
return {
|
|
125
|
+
...number(payload.soft_limit_usd) === void 0 ? {} : { softLimitUsd: number(payload.soft_limit_usd) },
|
|
126
|
+
...number(payload.hard_limit_usd) === void 0 ? {} : { hardLimitUsd: number(payload.hard_limit_usd) },
|
|
127
|
+
...number(payload.system_hard_limit_usd) === void 0 ? {} : { systemHardLimitUsd: number(payload.system_hard_limit_usd) },
|
|
128
|
+
...number(payload.access_until) === void 0 || number(payload.access_until) <= 0 ? {} : { accessUntil: number(payload.access_until) }
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Normalize `/v1/dashboard/billing/usage`. */
|
|
132
|
+
function parseBillingUsage(payload) {
|
|
133
|
+
if (!record(payload)) return void 0;
|
|
134
|
+
const total = number(payload.total_usage);
|
|
135
|
+
return total === void 0 ? {} : { totalUsageUsd: Math.round(total * 1e6) / 1e6 };
|
|
136
|
+
}
|
|
137
|
+
function statusMessage(status) {
|
|
138
|
+
if (status === 401) return "this API key was rejected (401)";
|
|
139
|
+
if (status === 402 || status === 403) return "the account cannot be read with this key (402/403)";
|
|
140
|
+
return `the gateway answered HTTP ${status}`;
|
|
141
|
+
}
|
|
142
|
+
async function getJson(url, key, { fetchImpl, timeoutMs, signal }) {
|
|
143
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
144
|
+
const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
|
|
145
|
+
const response = await fetchImpl(url, {
|
|
146
|
+
headers: {
|
|
147
|
+
Authorization: `Bearer ${key}`,
|
|
148
|
+
Accept: "application/json"
|
|
149
|
+
},
|
|
150
|
+
redirect: "error",
|
|
151
|
+
signal: requestSignal
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) throw new Error(statusMessage(response.status));
|
|
154
|
+
return response.json();
|
|
155
|
+
}
|
|
156
|
+
async function withFallback(hosts, path, key, options) {
|
|
157
|
+
const tried = [];
|
|
158
|
+
for (const host of hosts) try {
|
|
159
|
+
return {
|
|
160
|
+
payload: await getJson(`${host}${path}`, key, options),
|
|
161
|
+
host
|
|
162
|
+
};
|
|
163
|
+
} catch (error) {
|
|
164
|
+
tried.push(`${host}: ${error instanceof Error ? error.message : String(error)}`);
|
|
165
|
+
}
|
|
166
|
+
throw new Error(tried.join("; "));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Cached, single-flight balance reader.
|
|
170
|
+
* @param options - key listing, host resolution, and transport knobs.
|
|
171
|
+
* @returns `{ read({force, signal}), invalidate() }`.
|
|
172
|
+
*/
|
|
173
|
+
function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date.now, ttlMs = DEFAULT_BALANCE_TTL_MS, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
|
|
174
|
+
if (typeof listKeys !== "function") throw new Error("PackyCode balance reader requires listKeys");
|
|
175
|
+
if (typeof getHosts !== "function") throw new Error("PackyCode balance reader requires getHosts");
|
|
176
|
+
let cached;
|
|
177
|
+
let inFlight;
|
|
178
|
+
let generation = 0;
|
|
179
|
+
const loadKey = async (entry, hosts, signal) => {
|
|
180
|
+
const { payload } = await withFallback([.../* @__PURE__ */ new Set([
|
|
181
|
+
hosts.statusHost,
|
|
182
|
+
DEFAULT_STATUS_HOST,
|
|
183
|
+
hosts.apiHost,
|
|
184
|
+
DEFAULT_API_HOST
|
|
185
|
+
])], "/api/usage/token/", entry.key, {
|
|
186
|
+
fetchImpl,
|
|
187
|
+
timeoutMs,
|
|
188
|
+
signal
|
|
189
|
+
});
|
|
190
|
+
const usage = parseTokenUsage(payload);
|
|
191
|
+
if (usage === void 0) throw new Error("the gateway returned an unrecognized usage payload");
|
|
192
|
+
return usage;
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* The account endpoints need one working key. Candidates are ordered by
|
|
196
|
+
* evidence: a key whose per-key read just succeeded, then the key that
|
|
197
|
+
* answered this read last time, then the rest — so an invalid key sitting
|
|
198
|
+
* first in the ring cannot blank the account total.
|
|
199
|
+
*/
|
|
200
|
+
let accountKeyId;
|
|
201
|
+
const loadAccount = async (keys, hosts, signal, usableIds = []) => {
|
|
202
|
+
const candidates = [.../* @__PURE__ */ new Set([
|
|
203
|
+
...usableIds,
|
|
204
|
+
accountKeyId,
|
|
205
|
+
...keys.filter((entry) => entry.disabled !== true).map((entry) => entry.id)
|
|
206
|
+
])].map((id) => keys.find((entry) => entry.id === id)).filter((entry) => entry !== void 0);
|
|
207
|
+
if (candidates.length === 0) throw new Error("add a PackyCode API key to read the account balance");
|
|
208
|
+
const hostsToTry = [.../* @__PURE__ */ new Set([
|
|
209
|
+
hosts.apiHost,
|
|
210
|
+
DEFAULT_API_HOST,
|
|
211
|
+
hosts.statusHost,
|
|
212
|
+
DEFAULT_STATUS_HOST
|
|
213
|
+
])];
|
|
214
|
+
const options = {
|
|
215
|
+
fetchImpl,
|
|
216
|
+
timeoutMs,
|
|
217
|
+
signal
|
|
218
|
+
};
|
|
219
|
+
let lastError;
|
|
220
|
+
for (const key of candidates) try {
|
|
221
|
+
const subscription = parseBillingSubscription((await withFallback(hostsToTry, "/v1/dashboard/billing/subscription", key.key, options)).payload);
|
|
222
|
+
const usage = parseBillingUsage((await withFallback(hostsToTry, "/v1/dashboard/billing/usage", key.key, options)).payload);
|
|
223
|
+
const remaining = subscription?.hardLimitUsd === void 0 || usage?.totalUsageUsd === void 0 ? void 0 : Math.round((subscription.hardLimitUsd - usage.totalUsageUsd) * 1e6) / 1e6;
|
|
224
|
+
accountKeyId = key.id;
|
|
225
|
+
return {
|
|
226
|
+
...subscription,
|
|
227
|
+
...usage,
|
|
228
|
+
...remaining === void 0 ? {} : { remainingUsd: remaining },
|
|
229
|
+
keyId: key.id
|
|
230
|
+
};
|
|
231
|
+
} catch (error) {
|
|
232
|
+
lastError = error;
|
|
233
|
+
}
|
|
234
|
+
throw lastError;
|
|
235
|
+
};
|
|
236
|
+
const load = async (signal) => {
|
|
237
|
+
signal?.throwIfAborted();
|
|
238
|
+
const hosts = getHosts();
|
|
239
|
+
const keys = await listKeys();
|
|
240
|
+
const entries = [];
|
|
241
|
+
const usableIds = [];
|
|
242
|
+
for (const entry of keys) {
|
|
243
|
+
const base = {
|
|
244
|
+
id: entry.id,
|
|
245
|
+
label: entry.label,
|
|
246
|
+
disabled: entry.disabled === true
|
|
247
|
+
};
|
|
248
|
+
try {
|
|
249
|
+
entries.push({
|
|
250
|
+
...base,
|
|
251
|
+
usage: await loadKey(entry, hosts, signal)
|
|
252
|
+
});
|
|
253
|
+
usableIds.push(entry.id);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
entries.push({
|
|
256
|
+
...base,
|
|
257
|
+
error: error instanceof Error ? error.message : String(error)
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
let account;
|
|
262
|
+
try {
|
|
263
|
+
account = await loadAccount(keys, hosts, signal, usableIds);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
account = { error: error instanceof Error ? error.message : String(error) };
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
keys: entries,
|
|
269
|
+
account,
|
|
270
|
+
hosts,
|
|
271
|
+
fetchedAt: now()
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
return Object.freeze({
|
|
275
|
+
async read({ force = false, signal } = {}) {
|
|
276
|
+
signal?.throwIfAborted();
|
|
277
|
+
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone(cached);
|
|
278
|
+
const createPending = (requestSignal) => {
|
|
279
|
+
const observedGeneration = generation;
|
|
280
|
+
return load(requestSignal).then((value) => {
|
|
281
|
+
if (generation === observedGeneration) cached = value;
|
|
282
|
+
return value;
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
if (force) return clone(await createPending(signal));
|
|
286
|
+
const pending = inFlight ?? createPending();
|
|
287
|
+
if (inFlight === void 0) {
|
|
288
|
+
inFlight = pending;
|
|
289
|
+
const clear = () => {
|
|
290
|
+
if (inFlight === pending) inFlight = void 0;
|
|
291
|
+
};
|
|
292
|
+
pending.then(clear, clear);
|
|
293
|
+
}
|
|
294
|
+
return clone(await pending);
|
|
295
|
+
},
|
|
296
|
+
invalidate() {
|
|
297
|
+
generation += 1;
|
|
298
|
+
cached = void 0;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/claude-adapter.js
|
|
304
|
+
/**
|
|
305
|
+
* Route Claude models through the local Claude Code CLI.
|
|
306
|
+
*
|
|
307
|
+
* PackyCode's `cc` group serves its Claude models only to the official CLI, so
|
|
308
|
+
* selecting one of those models runs that CLI instead of an HTTP request. The
|
|
309
|
+
* other models on the route keep using the pi-ai adapter untouched — this
|
|
310
|
+
* adapter only dispatches.
|
|
311
|
+
*
|
|
312
|
+
* A CLI-backed turn is a *delegation*: the CLI runs its own agent loop with
|
|
313
|
+
* its own tools and returns one answer. The CLI cannot call the harness's
|
|
314
|
+
* tools, so `options.tools` is deliberately not forwarded, and the harness's
|
|
315
|
+
* system prompt is dropped in favour of the CLI's own. While it works, its
|
|
316
|
+
* thinking and tool activity stream into a reasoning block and its answer
|
|
317
|
+
* streams as text.
|
|
318
|
+
*
|
|
319
|
+
* A session-title request is not a delegation: it runs on the small model,
|
|
320
|
+
* read-only and outside the workspace, with the title instructions appended,
|
|
321
|
+
* so naming a session does not cost an Opus agent turn.
|
|
322
|
+
*
|
|
323
|
+
* @module dsh-packycode-api/claude-adapter
|
|
324
|
+
*/
|
|
325
|
+
/** Models this route serves through the CLI rather than an HTTP API. */
|
|
326
|
+
const CLI_BACKED_MODEL_IDS = Object.freeze([
|
|
327
|
+
"claude-opus-5-5",
|
|
328
|
+
"claude-sonnet-5-5",
|
|
329
|
+
"claude-haiku-5-5"
|
|
330
|
+
]);
|
|
331
|
+
/** The model a session title is generated with when the session runs on the CLI. */
|
|
332
|
+
const TITLE_MODEL_ID = "claude-haiku-5-5";
|
|
333
|
+
/** How much of the conversation a delegated prompt carries. */
|
|
334
|
+
const MAX_PROMPT_CHARS$1 = 48e3;
|
|
335
|
+
const MAX_BLOCK_CHARS = 8e3;
|
|
336
|
+
/** Render one content block as text a CLI prompt can carry. */
|
|
337
|
+
function renderBlock(block) {
|
|
338
|
+
if (block === null || typeof block !== "object") return "";
|
|
339
|
+
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
340
|
+
if (block.type === "image") return "[图片]";
|
|
341
|
+
if (block.type === "tool-call" || block.type === "tool-call-delta") return `[调用工具 ${block.name ?? block.id ?? ""}]`;
|
|
342
|
+
if (block.type === "tool-result") return `[工具结果] ${(typeof block.content === "string" ? block.content : "").slice(0, MAX_BLOCK_CHARS)}`;
|
|
343
|
+
if (block.type === "reasoning") return "";
|
|
344
|
+
return "";
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Emit text and reasoning as well-formed harness blocks.
|
|
348
|
+
*
|
|
349
|
+
* The harness's stream grammar requires every delta to address an open block
|
|
350
|
+
* of its own type, so each run of one kind is bracketed by `block-start` and
|
|
351
|
+
* `block-end`. Tool progress goes into reasoning: the UI shows it live as the
|
|
352
|
+
* turn's working process, and it never re-enters a later delegated prompt.
|
|
353
|
+
*/
|
|
354
|
+
function createBlockWriter() {
|
|
355
|
+
let index = -1;
|
|
356
|
+
let open;
|
|
357
|
+
const close = function* () {
|
|
358
|
+
if (open === void 0) return;
|
|
359
|
+
yield {
|
|
360
|
+
type: "block-end",
|
|
361
|
+
index,
|
|
362
|
+
block: {
|
|
363
|
+
type: open.kind,
|
|
364
|
+
text: open.text
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
open = void 0;
|
|
368
|
+
};
|
|
369
|
+
const write = function* (kind, text) {
|
|
370
|
+
if (typeof text !== "string" || text === "") return;
|
|
371
|
+
if (open?.kind !== kind) {
|
|
372
|
+
yield* close();
|
|
373
|
+
index += 1;
|
|
374
|
+
open = {
|
|
375
|
+
kind,
|
|
376
|
+
text: ""
|
|
377
|
+
};
|
|
378
|
+
yield {
|
|
379
|
+
type: "block-start",
|
|
380
|
+
index,
|
|
381
|
+
blockType: kind
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
open.text += text;
|
|
385
|
+
yield {
|
|
386
|
+
type: kind === "text" ? "text-delta" : "reasoning-delta",
|
|
387
|
+
index,
|
|
388
|
+
text
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
return {
|
|
392
|
+
/** A CLI content block started; the next write opens a fresh harness block. */
|
|
393
|
+
boundary(kind) {
|
|
394
|
+
if (open !== void 0 && open.kind === kind) open.sealed = true;
|
|
395
|
+
},
|
|
396
|
+
*write(kind, text) {
|
|
397
|
+
if (open?.sealed === true) yield* close();
|
|
398
|
+
yield* write(kind, text);
|
|
399
|
+
},
|
|
400
|
+
*note(line) {
|
|
401
|
+
const separator = open?.kind === "reasoning" && open.text !== "" && !open.text.endsWith("\n") ? "\n" : "";
|
|
402
|
+
if (open?.kind === "reasoning") open.sealed = false;
|
|
403
|
+
yield* write("reasoning", `${separator}${line}`);
|
|
404
|
+
},
|
|
405
|
+
close
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The turn's usage in the harness's disjoint buckets.
|
|
410
|
+
*
|
|
411
|
+
* The prompt side is the CLI's *last* request — the context it actually
|
|
412
|
+
* occupied and how much of it hit the cache — because the harness reads the
|
|
413
|
+
* prompt side as context pressure, and summing a multi-step CLI turn would
|
|
414
|
+
* count the same context once per step. Output is the CLI's whole-turn total.
|
|
415
|
+
*/
|
|
416
|
+
function turnUsage(lastRequest, finished) {
|
|
417
|
+
const outputTokens = finished?.outputTokens ?? 0;
|
|
418
|
+
if (lastRequest !== void 0) return {
|
|
419
|
+
inputTokens: lastRequest.inputTokens,
|
|
420
|
+
outputTokens,
|
|
421
|
+
cacheReadTokens: lastRequest.cacheReadTokens,
|
|
422
|
+
cacheWriteTokens: lastRequest.cacheWriteTokens
|
|
423
|
+
};
|
|
424
|
+
return {
|
|
425
|
+
inputTokens: finished?.inputTokens ?? 0,
|
|
426
|
+
outputTokens,
|
|
427
|
+
cacheReadTokens: finished?.cacheReadTokens ?? 0,
|
|
428
|
+
cacheWriteTokens: finished?.cacheWriteTokens ?? 0
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Turn an assembled request into one CLI prompt.
|
|
433
|
+
*
|
|
434
|
+
* The leading system message carries the harness's system prompt, which
|
|
435
|
+
* describes tools this model cannot call; the CLI brings its own instead.
|
|
436
|
+
* @param options - the assembled request.
|
|
437
|
+
* @param cwd - the working directory the CLI will run in, when known.
|
|
438
|
+
* @returns the prompt text.
|
|
439
|
+
*/
|
|
440
|
+
function renderDelegatedPrompt(options, cwd) {
|
|
441
|
+
const messages = Array.isArray(options?.messages) ? options.messages : [];
|
|
442
|
+
const turns = [];
|
|
443
|
+
for (const message of messages) {
|
|
444
|
+
if (message === null || typeof message !== "object") continue;
|
|
445
|
+
if (message.role === "system") continue;
|
|
446
|
+
const text = (Array.isArray(message.content) ? message.content : []).map(renderBlock).filter((part) => part !== "").join("\n").trim();
|
|
447
|
+
if (text === "") continue;
|
|
448
|
+
const label = message.role === "user" ? "用户" : message.role === "assistant" ? "助手" : String(message.role);
|
|
449
|
+
turns.push(`${label}:${text}`);
|
|
450
|
+
}
|
|
451
|
+
let transcript = turns.join("\n\n");
|
|
452
|
+
if (transcript.length > MAX_PROMPT_CHARS$1) transcript = `…(较早对话已截断)\n\n${transcript.slice(-48e3)}`;
|
|
453
|
+
const system = typeof options?.system === "string" && options.system.trim() !== "" ? options.system.trim() : "";
|
|
454
|
+
return `${[
|
|
455
|
+
"[本回合由 DeepSeek Harness 委派给 Claude Code CLI 执行。请用你自己的工具完成任务,直接输出最终结果;不要反问、不要复述流程。]",
|
|
456
|
+
cwd === void 0 ? void 0 : `[工作目录:${cwd}]`,
|
|
457
|
+
system === "" ? void 0 : `[补充说明:${system.slice(0, 2e3)}]`
|
|
458
|
+
].filter((part) => part !== void 0).join("\n")}\n\n${transcript}`.trim();
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* The prompt of a one-shot auxiliary request: its messages' text, verbatim.
|
|
462
|
+
* The caller frames its own input, so nothing is added around it.
|
|
463
|
+
* @param options - the assembled request.
|
|
464
|
+
* @returns the prompt text.
|
|
465
|
+
*/
|
|
466
|
+
function renderAuxiliaryPrompt(options) {
|
|
467
|
+
return (Array.isArray(options?.messages) ? options.messages : []).filter((message) => message !== null && typeof message === "object" && message.role !== "system").flatMap((message) => Array.isArray(message.content) ? message.content : []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n").trim();
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* How one request runs on the CLI: a title request on the small model with
|
|
471
|
+
* the caller's instructions appended, anything else as a delegated turn.
|
|
472
|
+
*
|
|
473
|
+
* The CLI's own system prompt and tool set stay in place even for a title:
|
|
474
|
+
* they are part of what the gateway sees from the official client.
|
|
475
|
+
*/
|
|
476
|
+
function cliCall(options, { titleModel, cwdOf, modeOf }) {
|
|
477
|
+
if (options?.purpose === "session-title") return {
|
|
478
|
+
prompt: renderAuxiliaryPrompt(options),
|
|
479
|
+
model: titleModel,
|
|
480
|
+
mode: "read-only",
|
|
481
|
+
appendSystemPrompt: typeof options.system === "string" ? options.system : void 0,
|
|
482
|
+
cwd: tmpdir()
|
|
483
|
+
};
|
|
484
|
+
const cwd = cwdOf();
|
|
485
|
+
return {
|
|
486
|
+
prompt: renderDelegatedPrompt(options, cwd),
|
|
487
|
+
model: options.model,
|
|
488
|
+
mode: modeOf(),
|
|
489
|
+
cwd
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Wrap an adapter so the CLI-backed models run through the local CLI.
|
|
494
|
+
*
|
|
495
|
+
* @param options - the inner adapter, the CLI runner, the CLI-backed model ids,
|
|
496
|
+
* the model session titles run on, and the mode and working directory each
|
|
497
|
+
* turn runs with.
|
|
498
|
+
* @returns an adapter with the same provider surface.
|
|
499
|
+
*/
|
|
500
|
+
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, resolveMode, resolveCwd }) {
|
|
501
|
+
if (inner === void 0 || inner === null) throw new Error("the CLI adapter needs an inner adapter");
|
|
502
|
+
if (runner === void 0 || runner === null) throw new Error("the CLI adapter needs a runner");
|
|
503
|
+
const cliModels = new Set(modelIds);
|
|
504
|
+
/** The mode is read per turn so a settings change applies without a reload. */
|
|
505
|
+
const modeOf = () => typeof resolveMode === "function" ? resolveMode() : void 0;
|
|
506
|
+
const cwdOf = () => typeof resolveCwd === "function" ? resolveCwd() : void 0;
|
|
507
|
+
async function* cliStream(options) {
|
|
508
|
+
let call;
|
|
509
|
+
try {
|
|
510
|
+
call = cliCall(options, {
|
|
511
|
+
titleModel,
|
|
512
|
+
cwdOf,
|
|
513
|
+
modeOf
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
yield {
|
|
517
|
+
type: "finish",
|
|
518
|
+
reason: {
|
|
519
|
+
kind: "error",
|
|
520
|
+
failure: {
|
|
521
|
+
code: "PI_AI_ERROR",
|
|
522
|
+
message: String(error?.message ?? error)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const blocks = createBlockWriter();
|
|
529
|
+
const seen = /* @__PURE__ */ new Set();
|
|
530
|
+
const firstSighting = (key) => {
|
|
531
|
+
if (key.endsWith(":")) return true;
|
|
532
|
+
if (seen.has(key)) return false;
|
|
533
|
+
seen.add(key);
|
|
534
|
+
return true;
|
|
535
|
+
};
|
|
536
|
+
try {
|
|
537
|
+
let finished;
|
|
538
|
+
let lastRequest;
|
|
539
|
+
for await (const event of runner.stream({
|
|
540
|
+
...call,
|
|
541
|
+
signal: options.signal
|
|
542
|
+
})) switch (event.type) {
|
|
543
|
+
case "block":
|
|
544
|
+
blocks.boundary(event.kind);
|
|
545
|
+
break;
|
|
546
|
+
case "text":
|
|
547
|
+
yield* blocks.write("text", event.text);
|
|
548
|
+
break;
|
|
549
|
+
case "reasoning":
|
|
550
|
+
yield* blocks.write("reasoning", event.text);
|
|
551
|
+
break;
|
|
552
|
+
case "tool":
|
|
553
|
+
if (firstSighting(`tool:${event.id}`)) yield* blocks.note(`▸ ${event.summary}\n`);
|
|
554
|
+
break;
|
|
555
|
+
case "tool-error":
|
|
556
|
+
if (firstSighting(`error:${event.id}`)) yield* blocks.note(` ✗ ${event.text === "" ? "失败" : event.text}\n`);
|
|
557
|
+
break;
|
|
558
|
+
case "request-usage":
|
|
559
|
+
lastRequest = event.usage;
|
|
560
|
+
break;
|
|
561
|
+
case "result":
|
|
562
|
+
finished = event.result;
|
|
563
|
+
break;
|
|
564
|
+
default: break;
|
|
565
|
+
}
|
|
566
|
+
yield* blocks.close();
|
|
567
|
+
yield {
|
|
568
|
+
type: "usage",
|
|
569
|
+
usage: turnUsage(lastRequest, finished)
|
|
570
|
+
};
|
|
571
|
+
yield {
|
|
572
|
+
type: "finish",
|
|
573
|
+
reason: { kind: "stop" }
|
|
574
|
+
};
|
|
575
|
+
} catch (error) {
|
|
576
|
+
yield {
|
|
577
|
+
type: "finish",
|
|
578
|
+
reason: {
|
|
579
|
+
kind: "error",
|
|
580
|
+
failure: {
|
|
581
|
+
code: "PI_AI_ERROR",
|
|
582
|
+
message: String(error?.message ?? error)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return new class extends LlmAdapter {
|
|
589
|
+
providerInfo(provider) {
|
|
590
|
+
return inner.providerInfo(provider);
|
|
591
|
+
}
|
|
592
|
+
providerRetryPolicy(provider) {
|
|
593
|
+
return inner.providerRetryPolicy?.(provider);
|
|
594
|
+
}
|
|
595
|
+
listModels(provider) {
|
|
596
|
+
return inner.listModels(provider);
|
|
597
|
+
}
|
|
598
|
+
resolveModel(provider, model, signal) {
|
|
599
|
+
return inner.resolveModel(provider, model, signal);
|
|
600
|
+
}
|
|
601
|
+
async prepareCall(provider, model, signal) {
|
|
602
|
+
if (!cliModels.has(model)) return await inner.prepareCall(provider, model, signal);
|
|
603
|
+
return {
|
|
604
|
+
model: await inner.resolveModel(provider, model, signal),
|
|
605
|
+
stream: (options) => cliStream(options)
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
stream(options) {
|
|
609
|
+
return cliModels.has(options.model) ? cliStream(options) : inner.stream(options);
|
|
610
|
+
}
|
|
611
|
+
}();
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region src/locate.js
|
|
615
|
+
/**
|
|
616
|
+
* Finding programs on macOS, Linux, and Windows.
|
|
617
|
+
*
|
|
618
|
+
* A desktop app launched from Finder or the Start menu inherits a much shorter
|
|
619
|
+
* PATH than a terminal, so a lookup that trusts PATH alone misses an npm or
|
|
620
|
+
* Claude Code install the user can run from their shell. These helpers walk
|
|
621
|
+
* PATH the way the platform's own shell would, and know the usual install
|
|
622
|
+
* directories it may lack.
|
|
623
|
+
*
|
|
624
|
+
* @module dsh-packycode-api/locate
|
|
625
|
+
*/
|
|
626
|
+
/** The path flavour of `platform`, so a Windows layout is testable on macOS. */
|
|
627
|
+
const pathFor = (platform) => platform === "win32" ? win32 : posix;
|
|
628
|
+
const listDirectory = (directory) => {
|
|
629
|
+
try {
|
|
630
|
+
return readdirSync(directory);
|
|
631
|
+
} catch {
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
/** The environment's PATH key: Windows spells it `Path` and compares case-insensitively. */
|
|
636
|
+
function pathKey(env, platform = process.platform) {
|
|
637
|
+
if (platform !== "win32") return "PATH";
|
|
638
|
+
return Object.keys(env ?? {}).find((name) => name.toUpperCase() === "PATH") ?? "Path";
|
|
639
|
+
}
|
|
640
|
+
/** Numeric `vX.Y.Z` ordering, newest first, for nvm's per-version directories. */
|
|
641
|
+
function newestFirst(a, b) {
|
|
642
|
+
const parts = (value) => value.replace(/^v/u, "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
643
|
+
const left = parts(a);
|
|
644
|
+
const right = parts(b);
|
|
645
|
+
for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
|
|
646
|
+
const delta = (right[index] ?? 0) - (left[index] ?? 0);
|
|
647
|
+
if (delta !== 0) return delta;
|
|
648
|
+
}
|
|
649
|
+
return 0;
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* The `bin` directories of every Node version nvm installed, newest first.
|
|
653
|
+
* @param options - home directory, environment, and a directory reader.
|
|
654
|
+
* @returns absolute directories; empty when nvm is absent.
|
|
655
|
+
*/
|
|
656
|
+
function nvmBinDirs({ home, env = process.env, platform = process.platform, readdir = listDirectory } = {}) {
|
|
657
|
+
if (platform === "win32") return [];
|
|
658
|
+
const path = pathFor(platform);
|
|
659
|
+
const root = path.join(env.NVM_DIR ?? path.join(home, ".nvm"), "versions", "node");
|
|
660
|
+
return readdir(root).filter((name) => /^v\d/u.test(name)).sort(newestFirst).map((name) => path.join(root, name, "bin"));
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Look a program up on PATH.
|
|
664
|
+
* @param names - file names to try in each directory, in order (on Windows
|
|
665
|
+
* pass the real file names, e.g. `claude.exe` and `claude.cmd`).
|
|
666
|
+
* @param options - platform, environment, and an existence check.
|
|
667
|
+
* @returns the first existing absolute path, or undefined.
|
|
668
|
+
*/
|
|
669
|
+
function searchPath(names, { platform = process.platform, env = process.env, exists = existsSync } = {}) {
|
|
670
|
+
const path = pathFor(platform);
|
|
671
|
+
const value = env?.[pathKey(env, platform)];
|
|
672
|
+
if (typeof value !== "string" || value === "") return void 0;
|
|
673
|
+
for (const raw of value.split(platform === "win32" ? ";" : ":")) {
|
|
674
|
+
const directory = raw.trim().replace(/^"(.*)"$/u, "$1");
|
|
675
|
+
if (directory === "") continue;
|
|
676
|
+
for (const name of names) {
|
|
677
|
+
const candidate = path.join(directory, name);
|
|
678
|
+
if (exists(candidate)) return candidate;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* An environment whose PATH starts with the directory a program lives in.
|
|
684
|
+
*
|
|
685
|
+
* npm's and the npm-installed CLI's launchers start with `#!/usr/bin/env node`,
|
|
686
|
+
* and nvm, Homebrew, and the official installers put `node` beside them; a
|
|
687
|
+
* GUI-launched host whose PATH lacks that directory would otherwise fail with
|
|
688
|
+
* `env: node: No such file or directory`.
|
|
689
|
+
* @param env - the environment to extend; not modified.
|
|
690
|
+
* @param program - the program's path; a bare name leaves PATH alone.
|
|
691
|
+
* @param platform - which path rules apply.
|
|
692
|
+
* @returns a new environment.
|
|
693
|
+
*/
|
|
694
|
+
function withProgramDirOnPath(env, program, platform = process.platform) {
|
|
695
|
+
const path = pathFor(platform);
|
|
696
|
+
if (typeof program !== "string" || !path.isAbsolute(program)) return { ...env };
|
|
697
|
+
const key = pathKey(env, platform);
|
|
698
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
699
|
+
const directory = path.dirname(program);
|
|
700
|
+
const entries = (typeof env?.[key] === "string" ? env[key] : "").split(separator).filter((entry) => entry !== "");
|
|
701
|
+
if (entries.includes(directory)) return { ...env };
|
|
702
|
+
return {
|
|
703
|
+
...env,
|
|
704
|
+
[key]: [directory, ...entries].join(separator)
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
//#endregion
|
|
708
|
+
//#region src/claude-cli.js
|
|
709
|
+
/**
|
|
710
|
+
* Bridge to the local Claude Code CLI.
|
|
711
|
+
*
|
|
712
|
+
* The gateway's `cc` group serves its Claude models only to the official CLI,
|
|
713
|
+
* so this module runs that CLI — the real binary, not an imitation of it — as
|
|
714
|
+
* a subprocess and hands its final answer back to the harness. The child gets
|
|
715
|
+
* the plugin's own endpoint and key through the environment, plus an isolated
|
|
716
|
+
* `CLAUDE_CONFIG_DIR`: `~/.claude/settings.json` env wins over an inherited
|
|
717
|
+
* environment, so without that isolation the user's own CLI configuration
|
|
718
|
+
* would silently override both.
|
|
719
|
+
*
|
|
720
|
+
* @module dsh-packycode-api/claude-cli
|
|
721
|
+
*/
|
|
722
|
+
/**
|
|
723
|
+
* What the delegated CLI may do on its own. The CLI runs its own tool loop;
|
|
724
|
+
* these are its permission switches, not the harness's sandbox.
|
|
725
|
+
*/
|
|
726
|
+
const CLAUDE_CLI_MODES = Object.freeze({
|
|
727
|
+
"read-only": Object.freeze({ allowedTools: Object.freeze([
|
|
728
|
+
"Read",
|
|
729
|
+
"Grep",
|
|
730
|
+
"Glob"
|
|
731
|
+
]) }),
|
|
732
|
+
edit: Object.freeze({ allowedTools: Object.freeze([
|
|
733
|
+
"Read",
|
|
734
|
+
"Grep",
|
|
735
|
+
"Glob",
|
|
736
|
+
"Edit",
|
|
737
|
+
"Write",
|
|
738
|
+
"NotebookEdit"
|
|
739
|
+
]) }),
|
|
740
|
+
full: Object.freeze({ skipPermissions: true })
|
|
741
|
+
});
|
|
742
|
+
const DEFAULT_CLAUDE_MODE = "edit";
|
|
743
|
+
/** Isolated CLI home; the plugin owns the endpoint and key it runs with. */
|
|
744
|
+
const DEFAULT_CLAUDE_CONFIG_DIR = join(homedir(), ".dsh", "packycode-claude-cli");
|
|
745
|
+
const DEFAULT_CLAUDE_TIMEOUT_MS = 1800 * 1e3;
|
|
746
|
+
/** Argument lists travel through argv, which the kernel bounds. */
|
|
747
|
+
const MAX_PROMPT_CHARS = 1e5;
|
|
748
|
+
const KILL_GRACE_MS = 3e3;
|
|
749
|
+
/** The npm package the official CLI ships as. */
|
|
750
|
+
const CLAUDE_NPM_PACKAGE = "@anthropic-ai/claude-code";
|
|
751
|
+
/** What the turn reports when no CLI could be started at all. */
|
|
752
|
+
const CLAUDE_MISSING_MESSAGE = "Claude Code CLI is not installed on this device, and Claude models on the PackyCode route run through it. Install it under Settings → PackyCode, or run: npm install -g @anthropic-ai/claude-code";
|
|
753
|
+
/** The CLI's file names on PATH: the native installer ships an `.exe`, npm a `.cmd` launcher. */
|
|
754
|
+
const claudeFileNames = (platform) => platform === "win32" ? ["claude.exe", "claude.cmd"] : ["claude"];
|
|
755
|
+
/**
|
|
756
|
+
* Where the official installer, npm, and the common Node managers put the CLI,
|
|
757
|
+
* in the order they are tried. PATH is searched after these.
|
|
758
|
+
* @param options - platform, home directory, environment, and a directory reader.
|
|
759
|
+
* @returns absolute candidate paths.
|
|
760
|
+
*/
|
|
761
|
+
function claudeBinaryCandidates({ platform = process.platform, home = homedir(), env = process.env, readdir } = {}) {
|
|
762
|
+
const path = pathFor(platform);
|
|
763
|
+
if (platform === "win32") {
|
|
764
|
+
const appData = env.APPDATA ?? path.join(home, "AppData", "Roaming");
|
|
765
|
+
return [
|
|
766
|
+
path.join(home, ".local", "bin", "claude.exe"),
|
|
767
|
+
path.join(appData, "npm", "claude.cmd"),
|
|
768
|
+
...env.NVM_SYMLINK ? [path.join(env.NVM_SYMLINK, "claude.cmd")] : [],
|
|
769
|
+
path.join(env.ProgramFiles ?? "C:\\Program Files", "nodejs", "claude.cmd")
|
|
770
|
+
];
|
|
771
|
+
}
|
|
772
|
+
return [
|
|
773
|
+
path.join(home, ".local", "bin", "claude"),
|
|
774
|
+
path.join(home, ".claude", "local", "claude"),
|
|
775
|
+
"/opt/homebrew/bin/claude",
|
|
776
|
+
"/usr/local/bin/claude",
|
|
777
|
+
path.join(home, ".npm-global", "bin", "claude"),
|
|
778
|
+
path.join(home, ".volta", "bin", "claude"),
|
|
779
|
+
...nvmBinDirs({
|
|
780
|
+
home,
|
|
781
|
+
env,
|
|
782
|
+
platform,
|
|
783
|
+
readdir
|
|
784
|
+
}).map((directory) => path.join(directory, "claude"))
|
|
785
|
+
];
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* Locate an installed Claude Code CLI.
|
|
789
|
+
* @param options - a configured path (wins when it exists), plus platform,
|
|
790
|
+
* home, environment, and file-system seams.
|
|
791
|
+
* @returns an absolute path, or undefined when none is installed.
|
|
792
|
+
*/
|
|
793
|
+
function locateClaudeBinary({ explicit, platform = process.platform, home = homedir(), env = process.env, exists = existsSync, readdir } = {}) {
|
|
794
|
+
const configured = typeof explicit === "string" ? explicit.trim() : "";
|
|
795
|
+
if (configured !== "") {
|
|
796
|
+
if (pathFor(platform).isAbsolute(configured)) return exists(configured) ? configured : void 0;
|
|
797
|
+
return searchPath(platform === "win32" && !/\.[a-z]+$/iu.test(configured) ? [`${configured}.exe`, `${configured}.cmd`] : [configured], {
|
|
798
|
+
platform,
|
|
799
|
+
env,
|
|
800
|
+
exists
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
for (const candidate of claudeBinaryCandidates({
|
|
804
|
+
platform,
|
|
805
|
+
home,
|
|
806
|
+
env,
|
|
807
|
+
readdir
|
|
808
|
+
})) if (exists(candidate)) return candidate;
|
|
809
|
+
return searchPath(claudeFileNames(platform), {
|
|
810
|
+
platform,
|
|
811
|
+
env,
|
|
812
|
+
exists
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Locate the Claude Code CLI.
|
|
817
|
+
* @param explicit - a configured path, when the deployment names one.
|
|
818
|
+
* @returns an absolute path when one exists, otherwise the configured value or
|
|
819
|
+
* the bare name, so starting it reports what is missing.
|
|
820
|
+
*/
|
|
821
|
+
function findClaudeBinary(explicit) {
|
|
822
|
+
const configured = typeof explicit === "string" ? explicit.trim() : "";
|
|
823
|
+
return locateClaudeBinary({ explicit: configured }) ?? (configured === "" ? "claude" : configured);
|
|
824
|
+
}
|
|
825
|
+
/** The program an npm `.cmd` launcher runs, relative to the launcher's own directory. */
|
|
826
|
+
const SHIM_TARGET = /"%(?:~dp0|dp0%)\\([^"%]+?\.(?:c?js|mjs|exe))"/giu;
|
|
827
|
+
/**
|
|
828
|
+
* Turn a located CLI into something `spawn` can start without a shell.
|
|
829
|
+
*
|
|
830
|
+
* On Windows npm installs `claude.cmd`, and Node refuses to start a `.cmd`
|
|
831
|
+
* without `shell: true` — which would hand the prompt to `cmd.exe` to parse.
|
|
832
|
+
* The launcher only forwards to a program beside it, so that program is
|
|
833
|
+
* started directly instead: `node <cli.js>` or the packaged `.exe`.
|
|
834
|
+
* @param binary - the located CLI.
|
|
835
|
+
* @param options - platform and file-system seams.
|
|
836
|
+
* @returns the command and the arguments that precede the CLI's own.
|
|
837
|
+
*/
|
|
838
|
+
function resolveSpawnCommand(binary, { platform = process.platform, exists = existsSync, readFile = (file) => readFileSync(file, "utf8") } = {}) {
|
|
839
|
+
if (platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(binary)) return {
|
|
840
|
+
command: binary,
|
|
841
|
+
args: []
|
|
842
|
+
};
|
|
843
|
+
const path = pathFor(platform);
|
|
844
|
+
const directory = path.dirname(binary);
|
|
845
|
+
let relative;
|
|
846
|
+
try {
|
|
847
|
+
relative = [...readFile(binary).matchAll(SHIM_TARGET)].map((match) => match[1]).find((target) => !/^node\.exe$/iu.test(target));
|
|
848
|
+
} catch {}
|
|
849
|
+
const target = relative === void 0 ? path.join(directory, "node_modules", ...CLAUDE_NPM_PACKAGE.split("/"), "cli.js") : path.join(directory, relative);
|
|
850
|
+
if (!exists(target)) throw new Error(`Claude Code CLI launcher ${binary} points at a missing program; reinstall it with npm install -g ${CLAUDE_NPM_PACKAGE}`);
|
|
851
|
+
if (/\.exe$/iu.test(target)) return {
|
|
852
|
+
command: target,
|
|
853
|
+
args: []
|
|
854
|
+
};
|
|
855
|
+
const bundledNode = path.join(directory, "node.exe");
|
|
856
|
+
return {
|
|
857
|
+
command: exists(bundledNode) ? bundledNode : "node",
|
|
858
|
+
args: [target]
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Build the CLI arguments for one delegated task.
|
|
863
|
+
* @param options - the prompt, the permission mode, an optional model, whether
|
|
864
|
+
* the caller consumes the streaming protocol, and optional instructions
|
|
865
|
+
* appended to the CLI's own system prompt. The CLI's system prompt and tool
|
|
866
|
+
* set are never replaced: they are part of what the official client sends.
|
|
867
|
+
* @returns argv after the binary name.
|
|
868
|
+
*/
|
|
869
|
+
function buildClaudeArgs({ prompt, mode = DEFAULT_CLAUDE_MODE, model, stream = false, appendSystemPrompt } = {}) {
|
|
870
|
+
const text = typeof prompt === "string" ? prompt : "";
|
|
871
|
+
if (text.trim() === "") throw new Error("claude_code needs a non-empty prompt");
|
|
872
|
+
if (text.length > MAX_PROMPT_CHARS) throw new Error(`claude_code prompt is too long (${text.length} > ${MAX_PROMPT_CHARS} characters)`);
|
|
873
|
+
const spec = CLAUDE_CLI_MODES[mode];
|
|
874
|
+
if (spec === void 0) throw new Error(`claude_code mode must be one of ${Object.keys(CLAUDE_CLI_MODES).join(", ")}`);
|
|
875
|
+
const args = [
|
|
876
|
+
"-p",
|
|
877
|
+
text,
|
|
878
|
+
"--output-format",
|
|
879
|
+
stream ? "stream-json" : "json"
|
|
880
|
+
];
|
|
881
|
+
if (stream) args.push("--include-partial-messages", "--verbose");
|
|
882
|
+
const named = typeof model === "string" ? model.trim() : "";
|
|
883
|
+
if (named !== "") args.push("--model", named);
|
|
884
|
+
const appended = typeof appendSystemPrompt === "string" ? appendSystemPrompt.trim() : "";
|
|
885
|
+
if (appended !== "") args.push("--append-system-prompt", appended);
|
|
886
|
+
if (spec.skipPermissions === true) args.push("--dangerously-skip-permissions");
|
|
887
|
+
else args.push("--allowedTools", ...spec.allowedTools);
|
|
888
|
+
return args;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* The child's environment: this plugin's endpoint and key, an isolated config
|
|
892
|
+
* home, and the CLI's own network-noise switches off.
|
|
893
|
+
* @param options - credential, config home, and the parent environment.
|
|
894
|
+
* @returns the environment for the child process.
|
|
895
|
+
*/
|
|
896
|
+
function buildClaudeEnvironment({ baseUrl, token, configDir = DEFAULT_CLAUDE_CONFIG_DIR, parent = process.env } = {}) {
|
|
897
|
+
if (typeof token !== "string" || token === "") throw new Error("claude_code has no PackyCode API key to run with");
|
|
898
|
+
const env = { ...parent };
|
|
899
|
+
delete env.ANTHROPIC_API_KEY;
|
|
900
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
901
|
+
delete env.CLAUDE_CODE_USE_BEDROCK;
|
|
902
|
+
delete env.CLAUDE_CODE_USE_VERTEX;
|
|
903
|
+
return {
|
|
904
|
+
...env,
|
|
905
|
+
ANTHROPIC_BASE_URL: baseUrl,
|
|
906
|
+
ANTHROPIC_AUTH_TOKEN: token,
|
|
907
|
+
CLAUDE_CONFIG_DIR: configDir,
|
|
908
|
+
DISABLE_AUTOUPDATER: "1",
|
|
909
|
+
DISABLE_TELEMETRY: "1",
|
|
910
|
+
DISABLE_ERROR_REPORTING: "1",
|
|
911
|
+
DISABLE_BUG_COMMAND: "1",
|
|
912
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Read the CLI's `--output-format json` payload.
|
|
917
|
+
* @param stdout - the child's stdout.
|
|
918
|
+
* @returns the parsed payload, or undefined when nothing usable was printed.
|
|
919
|
+
*/
|
|
920
|
+
function parseClaudeResult(stdout) {
|
|
921
|
+
const text = typeof stdout === "string" ? stdout : "";
|
|
922
|
+
for (const line of text.split("\n").reverse()) {
|
|
923
|
+
const trimmed = line.trim();
|
|
924
|
+
if (!trimmed.startsWith("{")) continue;
|
|
925
|
+
try {
|
|
926
|
+
const value = JSON.parse(trimmed);
|
|
927
|
+
if (value !== null && typeof value === "object" && ("result" in value || "is_error" in value)) return value;
|
|
928
|
+
} catch {}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/** Turn a CLI failure into a message that names what the CLI reported. */
|
|
932
|
+
function failureMessage(parsed, exitCode, stderr) {
|
|
933
|
+
const reported = typeof parsed?.result === "string" && parsed.result.trim() !== "" ? parsed.result.trim() : void 0;
|
|
934
|
+
if (reported !== void 0) return `Claude Code CLI failed: ${reported}`;
|
|
935
|
+
const detail = (typeof stderr === "string" ? stderr : "").trim().split("\n").slice(-4).join(" ").slice(0, 400);
|
|
936
|
+
return detail === "" ? `Claude Code CLI exited with code ${exitCode ?? "unknown"}` : `Claude Code CLI exited with code ${exitCode ?? "unknown"}: ${detail}`;
|
|
937
|
+
}
|
|
938
|
+
const MAX_TOOL_SUMMARY_CHARS = 160;
|
|
939
|
+
/** Tool inputs worth showing, in the order a reader looks for them. */
|
|
940
|
+
const TOOL_SUMMARY_FIELDS = Object.freeze([
|
|
941
|
+
"command",
|
|
942
|
+
"file_path",
|
|
943
|
+
"notebook_path",
|
|
944
|
+
"pattern",
|
|
945
|
+
"url",
|
|
946
|
+
"query",
|
|
947
|
+
"description",
|
|
948
|
+
"prompt",
|
|
949
|
+
"path"
|
|
950
|
+
]);
|
|
951
|
+
function oneLine(text, limit = MAX_TOOL_SUMMARY_CHARS) {
|
|
952
|
+
const flat = String(text).replace(/\s+/gu, " ").trim();
|
|
953
|
+
return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* A one-line description of one tool call, for progress display.
|
|
957
|
+
* @param name - the CLI tool name.
|
|
958
|
+
* @param input - the tool call's input object.
|
|
959
|
+
* @returns e.g. `Bash · git status`.
|
|
960
|
+
*/
|
|
961
|
+
function summarizeToolUse(name, input) {
|
|
962
|
+
const label = typeof name === "string" && name !== "" ? name : "tool";
|
|
963
|
+
if (input === null || typeof input !== "object") return label;
|
|
964
|
+
for (const field of TOOL_SUMMARY_FIELDS) {
|
|
965
|
+
const value = input[field];
|
|
966
|
+
if (typeof value === "string" && value.trim() !== "") return `${label} · ${oneLine(value)}`;
|
|
967
|
+
}
|
|
968
|
+
return label;
|
|
969
|
+
}
|
|
970
|
+
/** One API request's prompt side, in the harness's disjoint buckets. */
|
|
971
|
+
function promptUsageOf(usage) {
|
|
972
|
+
if (usage === null || typeof usage !== "object" || typeof usage.input_tokens !== "number") return void 0;
|
|
973
|
+
return {
|
|
974
|
+
inputTokens: usage.input_tokens,
|
|
975
|
+
cacheReadTokens: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : 0,
|
|
976
|
+
cacheWriteTokens: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : 0
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function toolResultText(content) {
|
|
980
|
+
if (typeof content === "string") return content;
|
|
981
|
+
if (!Array.isArray(content)) return "";
|
|
982
|
+
return content.map((part) => part?.type === "text" && typeof part.text === "string" ? part.text : "").join(" ");
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Read one `--output-format stream-json` line.
|
|
986
|
+
*
|
|
987
|
+
* The CLI prints both partial deltas (`stream_event`) and each finished
|
|
988
|
+
* message (`assistant`/`user`); deltas carry the live text and thinking, the
|
|
989
|
+
* finished messages carry complete tool inputs and results.
|
|
990
|
+
* @param event - a parsed stream-json line.
|
|
991
|
+
* @returns what the caller can act on; empty for noise.
|
|
992
|
+
*/
|
|
993
|
+
function readClaudeStreamEvents(event) {
|
|
994
|
+
if (event === null || typeof event !== "object") return [];
|
|
995
|
+
if (event.type === "stream_event") {
|
|
996
|
+
const inner = event.event;
|
|
997
|
+
switch (inner?.type) {
|
|
998
|
+
case "message_start": {
|
|
999
|
+
const usage = promptUsageOf(inner.message?.usage);
|
|
1000
|
+
return usage === void 0 ? [] : [{
|
|
1001
|
+
type: "request-usage",
|
|
1002
|
+
usage
|
|
1003
|
+
}];
|
|
1004
|
+
}
|
|
1005
|
+
case "content_block_start": {
|
|
1006
|
+
const kind = inner.content_block?.type;
|
|
1007
|
+
if (kind === "text") return [{
|
|
1008
|
+
type: "block",
|
|
1009
|
+
kind: "text"
|
|
1010
|
+
}];
|
|
1011
|
+
if (kind === "thinking") return [{
|
|
1012
|
+
type: "block",
|
|
1013
|
+
kind: "reasoning"
|
|
1014
|
+
}];
|
|
1015
|
+
return [];
|
|
1016
|
+
}
|
|
1017
|
+
case "content_block_delta": {
|
|
1018
|
+
const delta = inner.delta;
|
|
1019
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") return [{
|
|
1020
|
+
type: "text",
|
|
1021
|
+
text: delta.text
|
|
1022
|
+
}];
|
|
1023
|
+
if (delta?.type === "thinking_delta" && typeof delta.thinking === "string") return [{
|
|
1024
|
+
type: "reasoning",
|
|
1025
|
+
text: delta.thinking
|
|
1026
|
+
}];
|
|
1027
|
+
return [];
|
|
1028
|
+
}
|
|
1029
|
+
default: return [];
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (event.type === "assistant") {
|
|
1033
|
+
const events = (Array.isArray(event.message?.content) ? event.message.content : []).filter((block) => block?.type === "tool_use").map((block) => ({
|
|
1034
|
+
type: "tool",
|
|
1035
|
+
id: String(block.id ?? ""),
|
|
1036
|
+
summary: summarizeToolUse(block.name, block.input)
|
|
1037
|
+
}));
|
|
1038
|
+
const usage = promptUsageOf(event.message?.usage);
|
|
1039
|
+
return usage === void 0 ? events : [{
|
|
1040
|
+
type: "request-usage",
|
|
1041
|
+
usage
|
|
1042
|
+
}, ...events];
|
|
1043
|
+
}
|
|
1044
|
+
if (event.type === "user") return (Array.isArray(event.message?.content) ? event.message.content : []).filter((block) => block?.type === "tool_result" && block.is_error === true).map((block) => ({
|
|
1045
|
+
type: "tool-error",
|
|
1046
|
+
id: String(block.tool_use_id ?? ""),
|
|
1047
|
+
text: oneLine(toolResultText(block.content))
|
|
1048
|
+
}));
|
|
1049
|
+
if (event.type === "result") return [{
|
|
1050
|
+
type: "result",
|
|
1051
|
+
result: event
|
|
1052
|
+
}];
|
|
1053
|
+
return [];
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* A Claude Code CLI runner.
|
|
1057
|
+
* @param options - binary (a path, or a function resolving one per run so a
|
|
1058
|
+
* CLI installed after startup is picked up), config home, credential
|
|
1059
|
+
* resolution, and process seams.
|
|
1060
|
+
* @returns `{ run({ prompt, mode, model, cwd, signal }) }`.
|
|
1061
|
+
*/
|
|
1062
|
+
function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_CLAUDE_CONFIG_DIR, resolveCredential, spawnImpl = spawn, timeoutMs = DEFAULT_CLAUDE_TIMEOUT_MS, platform = process.platform } = {}) {
|
|
1063
|
+
if (typeof resolveCredential !== "function") throw new Error("the Claude Code CLI runner needs resolveCredential");
|
|
1064
|
+
/**
|
|
1065
|
+
* Spawn one CLI process and own its lifetime: capture both pipes, enforce the
|
|
1066
|
+
* timeout, and kill the process — with a grace period — when the caller
|
|
1067
|
+
* aborts. Both public entry points share it.
|
|
1068
|
+
*/
|
|
1069
|
+
const start = async ({ prompt, mode, model, appendSystemPrompt, cwd, signal, stream, onStdout }) => {
|
|
1070
|
+
const args = buildClaudeArgs({
|
|
1071
|
+
prompt,
|
|
1072
|
+
mode,
|
|
1073
|
+
model,
|
|
1074
|
+
stream,
|
|
1075
|
+
appendSystemPrompt
|
|
1076
|
+
});
|
|
1077
|
+
const { baseUrl, token } = await resolveCredential();
|
|
1078
|
+
const program = typeof binary === "function" ? await binary() : binary;
|
|
1079
|
+
const command = resolveSpawnCommand(program, { platform });
|
|
1080
|
+
const env = withProgramDirOnPath(buildClaudeEnvironment({
|
|
1081
|
+
baseUrl,
|
|
1082
|
+
token,
|
|
1083
|
+
configDir
|
|
1084
|
+
}), program, platform);
|
|
1085
|
+
const child = spawnImpl(command.command, [...command.args, ...args], {
|
|
1086
|
+
cwd,
|
|
1087
|
+
env,
|
|
1088
|
+
stdio: [
|
|
1089
|
+
"ignore",
|
|
1090
|
+
"pipe",
|
|
1091
|
+
"pipe"
|
|
1092
|
+
],
|
|
1093
|
+
windowsHide: true
|
|
1094
|
+
});
|
|
1095
|
+
const state = {
|
|
1096
|
+
stdout: "",
|
|
1097
|
+
stderr: "",
|
|
1098
|
+
timedOut: false,
|
|
1099
|
+
settled: false
|
|
1100
|
+
};
|
|
1101
|
+
const kill = () => {
|
|
1102
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
1103
|
+
child.kill("SIGTERM");
|
|
1104
|
+
setTimeout(() => {
|
|
1105
|
+
try {
|
|
1106
|
+
child.kill("SIGKILL");
|
|
1107
|
+
} catch {}
|
|
1108
|
+
}, KILL_GRACE_MS).unref?.();
|
|
1109
|
+
};
|
|
1110
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
1111
|
+
state.timedOut = true;
|
|
1112
|
+
kill();
|
|
1113
|
+
}, timeoutMs) : void 0;
|
|
1114
|
+
timer?.unref?.();
|
|
1115
|
+
const onAbort = () => kill();
|
|
1116
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1117
|
+
const release = () => {
|
|
1118
|
+
if (state.settled) return;
|
|
1119
|
+
state.settled = true;
|
|
1120
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1121
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1122
|
+
};
|
|
1123
|
+
child.stdout?.setEncoding?.("utf8");
|
|
1124
|
+
child.stderr?.setEncoding?.("utf8");
|
|
1125
|
+
child.stdout?.on("data", (chunk) => {
|
|
1126
|
+
const text = String(chunk);
|
|
1127
|
+
state.stdout = (state.stdout + text).slice(-4194304);
|
|
1128
|
+
onStdout?.(text);
|
|
1129
|
+
});
|
|
1130
|
+
child.stderr?.on("data", (chunk) => {
|
|
1131
|
+
state.stderr = (state.stderr + String(chunk)).slice(-4194304);
|
|
1132
|
+
});
|
|
1133
|
+
const closed = new Promise((resolve, reject) => {
|
|
1134
|
+
child.on("error", (error) => {
|
|
1135
|
+
release();
|
|
1136
|
+
reject(error.code === "ENOENT" && !(pathFor(platform).isAbsolute(program) && existsSync(program)) ? new Error(CLAUDE_MISSING_MESSAGE, { cause: error }) : new Error(`claude_code could not run ${program}: ${error.message}`, { cause: error }));
|
|
1137
|
+
});
|
|
1138
|
+
child.on("close", (code, killSignal) => {
|
|
1139
|
+
release();
|
|
1140
|
+
if (state.timedOut) {
|
|
1141
|
+
reject(/* @__PURE__ */ new Error(`claude_code timed out after ${Math.round(timeoutMs / 1e3)}s`));
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (signal?.aborted === true) {
|
|
1145
|
+
reject(/* @__PURE__ */ new Error("claude_code was aborted"));
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
resolve({
|
|
1149
|
+
code,
|
|
1150
|
+
killSignal
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
});
|
|
1154
|
+
closed.catch(() => void 0);
|
|
1155
|
+
return {
|
|
1156
|
+
child,
|
|
1157
|
+
state,
|
|
1158
|
+
closed,
|
|
1159
|
+
kill
|
|
1160
|
+
};
|
|
1161
|
+
};
|
|
1162
|
+
return Object.freeze({
|
|
1163
|
+
/**
|
|
1164
|
+
* Run one delegated task to completion, reading only its final result.
|
|
1165
|
+
* @param options - prompt, mode, model, working directory, and cancellation.
|
|
1166
|
+
* @returns the CLI's answer plus its own accounting.
|
|
1167
|
+
*/
|
|
1168
|
+
async run({ prompt, mode = DEFAULT_CLAUDE_MODE, model, cwd, signal } = {}) {
|
|
1169
|
+
const startedAt = Date.now();
|
|
1170
|
+
const { state, closed } = await start({
|
|
1171
|
+
prompt,
|
|
1172
|
+
mode,
|
|
1173
|
+
model,
|
|
1174
|
+
cwd,
|
|
1175
|
+
signal,
|
|
1176
|
+
stream: false
|
|
1177
|
+
});
|
|
1178
|
+
const { code, killSignal } = await closed;
|
|
1179
|
+
const parsed = parseClaudeResult(state.stdout);
|
|
1180
|
+
if (parsed === void 0) throw new Error(failureMessage(void 0, code ?? killSignal, state.stderr));
|
|
1181
|
+
if (parsed.is_error === true || code !== 0) throw new Error(failureMessage(parsed, code, state.stderr));
|
|
1182
|
+
const usage = parsed.usage ?? {};
|
|
1183
|
+
return {
|
|
1184
|
+
result: typeof parsed.result === "string" ? parsed.result : "",
|
|
1185
|
+
model: typeof parsed.model === "string" ? parsed.model : model ?? "",
|
|
1186
|
+
mode,
|
|
1187
|
+
costUsd: typeof parsed.total_cost_usd === "number" ? parsed.total_cost_usd : 0,
|
|
1188
|
+
durationMs: typeof parsed.duration_ms === "number" ? parsed.duration_ms : Date.now() - startedAt,
|
|
1189
|
+
inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : 0,
|
|
1190
|
+
outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : 0
|
|
1191
|
+
};
|
|
1192
|
+
},
|
|
1193
|
+
/**
|
|
1194
|
+
* Run one delegated task, yielding its answer as the CLI produces it.
|
|
1195
|
+
* The final `result` event carries the CLI's own accounting, summed over
|
|
1196
|
+
* every request the CLI made.
|
|
1197
|
+
* @param options - as {@link run}, plus optional `appendSystemPrompt`
|
|
1198
|
+
* instructions.
|
|
1199
|
+
* @returns an async generator of {@link readClaudeStreamEvents} events and
|
|
1200
|
+
* one closing `result` event.
|
|
1201
|
+
*/
|
|
1202
|
+
async *stream({ prompt, mode = DEFAULT_CLAUDE_MODE, model, appendSystemPrompt, cwd, signal } = {}) {
|
|
1203
|
+
const startedAt = Date.now();
|
|
1204
|
+
let buffer = "";
|
|
1205
|
+
let sawResult;
|
|
1206
|
+
const lines = [];
|
|
1207
|
+
const { state, closed, kill } = await start({
|
|
1208
|
+
prompt,
|
|
1209
|
+
mode,
|
|
1210
|
+
model,
|
|
1211
|
+
appendSystemPrompt,
|
|
1212
|
+
cwd,
|
|
1213
|
+
signal,
|
|
1214
|
+
stream: true,
|
|
1215
|
+
onStdout: (chunk) => {
|
|
1216
|
+
buffer += chunk;
|
|
1217
|
+
let index = buffer.indexOf("\n");
|
|
1218
|
+
while (index !== -1) {
|
|
1219
|
+
lines.push(buffer.slice(0, index));
|
|
1220
|
+
buffer = buffer.slice(index + 1);
|
|
1221
|
+
index = buffer.indexOf("\n");
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
const drain = function* () {
|
|
1226
|
+
while (lines.length > 0) {
|
|
1227
|
+
const line = lines.shift().trim();
|
|
1228
|
+
if (line === "" || !line.startsWith("{")) continue;
|
|
1229
|
+
let parsed;
|
|
1230
|
+
try {
|
|
1231
|
+
parsed = JSON.parse(line);
|
|
1232
|
+
} catch {
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
for (const event of readClaudeStreamEvents(parsed)) if (event.type === "result") sawResult = event.result;
|
|
1236
|
+
else yield event;
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
let finished = false;
|
|
1240
|
+
const stopIfAbandoned = () => {
|
|
1241
|
+
if (!finished) kill();
|
|
1242
|
+
};
|
|
1243
|
+
const completion = closed.then(() => {
|
|
1244
|
+
finished = true;
|
|
1245
|
+
}, (error) => {
|
|
1246
|
+
finished = true;
|
|
1247
|
+
throw error;
|
|
1248
|
+
});
|
|
1249
|
+
completion.catch(() => void 0);
|
|
1250
|
+
try {
|
|
1251
|
+
while (!finished || lines.length > 0) {
|
|
1252
|
+
if (lines.length === 0) {
|
|
1253
|
+
if (finished) break;
|
|
1254
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
1255
|
+
continue;
|
|
1256
|
+
}
|
|
1257
|
+
yield* drain();
|
|
1258
|
+
}
|
|
1259
|
+
} finally {
|
|
1260
|
+
stopIfAbandoned();
|
|
1261
|
+
}
|
|
1262
|
+
await completion;
|
|
1263
|
+
if (buffer.trim().startsWith("{")) try {
|
|
1264
|
+
const event = readClaudeStreamEvents(JSON.parse(buffer.trim())).find((item) => item.type === "result");
|
|
1265
|
+
if (event !== void 0) sawResult = event.result;
|
|
1266
|
+
} catch {}
|
|
1267
|
+
if (sawResult === void 0) throw new Error(failureMessage(void 0, void 0, state.stderr));
|
|
1268
|
+
if (sawResult.is_error === true) throw new Error(failureMessage(sawResult, 0, state.stderr));
|
|
1269
|
+
const usage = sawResult.usage ?? {};
|
|
1270
|
+
yield {
|
|
1271
|
+
type: "result",
|
|
1272
|
+
result: {
|
|
1273
|
+
result: typeof sawResult.result === "string" ? sawResult.result : "",
|
|
1274
|
+
model: typeof sawResult.model === "string" ? sawResult.model : model ?? "",
|
|
1275
|
+
mode,
|
|
1276
|
+
costUsd: typeof sawResult.total_cost_usd === "number" ? sawResult.total_cost_usd : 0,
|
|
1277
|
+
durationMs: typeof sawResult.duration_ms === "number" ? sawResult.duration_ms : Date.now() - startedAt,
|
|
1278
|
+
inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : 0,
|
|
1279
|
+
outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : 0,
|
|
1280
|
+
cacheReadTokens: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : 0,
|
|
1281
|
+
cacheWriteTokens: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : 0
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
//#endregion
|
|
1288
|
+
//#region src/claude-install.js
|
|
1289
|
+
/**
|
|
1290
|
+
* Detecting, and installing, the local Claude Code CLI.
|
|
1291
|
+
*
|
|
1292
|
+
* Claude models on this route run through the official CLI, so the settings
|
|
1293
|
+
* panel shows whether this device has one (and which version), and offers to
|
|
1294
|
+
* install it with `npm install -g @anthropic-ai/claude-code` — the same command
|
|
1295
|
+
* on macOS and Windows. Installation runs on the host, never in a shell built
|
|
1296
|
+
* from user text: npm's arguments are constants.
|
|
1297
|
+
*
|
|
1298
|
+
* @module dsh-packycode-api/claude-install
|
|
1299
|
+
*/
|
|
1300
|
+
const CLAUDE_INSTALL_COMMAND = `npm install -g ${CLAUDE_NPM_PACKAGE}`;
|
|
1301
|
+
const INSTALL_ARGS = Object.freeze([
|
|
1302
|
+
"install",
|
|
1303
|
+
"-g",
|
|
1304
|
+
CLAUDE_NPM_PACKAGE
|
|
1305
|
+
]);
|
|
1306
|
+
const PREFIX_ARGS = Object.freeze(["prefix", "-g"]);
|
|
1307
|
+
const VERSION_TIMEOUT_MS = 2e4;
|
|
1308
|
+
const PREFIX_TIMEOUT_MS = 2e4;
|
|
1309
|
+
const INSTALL_TIMEOUT_MS = 900 * 1e3;
|
|
1310
|
+
/** A detection is reused this long; the panel's refresh and an install bypass it. */
|
|
1311
|
+
const DETECTION_TTL_MS = 300 * 1e3;
|
|
1312
|
+
const DETAIL_CHARS = 800;
|
|
1313
|
+
/**
|
|
1314
|
+
* The version `claude --version` prints, e.g. `2.1.3 (Claude Code)`.
|
|
1315
|
+
* @param output - the command's stdout.
|
|
1316
|
+
* @returns the semantic version, or undefined.
|
|
1317
|
+
*/
|
|
1318
|
+
function parseClaudeVersion(output) {
|
|
1319
|
+
return /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/u.exec(typeof output === "string" ? output : "")?.[1];
|
|
1320
|
+
}
|
|
1321
|
+
/** The last lines of a command's output, bounded for display. */
|
|
1322
|
+
function detailOf(...texts) {
|
|
1323
|
+
const tail = texts.filter((text) => typeof text === "string").join("\n").split(/\r?\n/u).map((line) => line.trimEnd()).filter((line) => line.trim() !== "").slice(-8).join("\n");
|
|
1324
|
+
return tail.length > DETAIL_CHARS ? tail.slice(-800) : tail;
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Where npm usually lives, in the order tried after PATH.
|
|
1328
|
+
* @param options - platform, home, environment, the host's own Node, and a directory reader.
|
|
1329
|
+
* @returns absolute candidate paths.
|
|
1330
|
+
*/
|
|
1331
|
+
function npmCandidates({ platform = process.platform, home = homedir(), env = process.env, execPath = process.execPath, readdir } = {}) {
|
|
1332
|
+
const path = pathFor(platform);
|
|
1333
|
+
const name = platform === "win32" ? "npm.cmd" : "npm";
|
|
1334
|
+
const beside = typeof execPath === "string" && execPath !== "" ? [path.join(path.dirname(execPath), name)] : [];
|
|
1335
|
+
if (platform === "win32") return [
|
|
1336
|
+
...beside,
|
|
1337
|
+
...env.NVM_SYMLINK ? [path.join(env.NVM_SYMLINK, name)] : [],
|
|
1338
|
+
path.join(env.ProgramFiles ?? "C:\\Program Files", "nodejs", name)
|
|
1339
|
+
];
|
|
1340
|
+
return [
|
|
1341
|
+
...beside,
|
|
1342
|
+
"/opt/homebrew/bin/npm",
|
|
1343
|
+
"/usr/local/bin/npm",
|
|
1344
|
+
path.join(home, ".volta", "bin", "npm"),
|
|
1345
|
+
...nvmBinDirs({
|
|
1346
|
+
home,
|
|
1347
|
+
env,
|
|
1348
|
+
platform,
|
|
1349
|
+
readdir
|
|
1350
|
+
}).map((directory) => path.join(directory, "npm"))
|
|
1351
|
+
];
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Locate npm: PATH first (the one the user's terminal runs), then the usual places.
|
|
1355
|
+
* @param options - as {@link npmCandidates}, plus an existence check.
|
|
1356
|
+
* @returns an absolute path, or undefined.
|
|
1357
|
+
*/
|
|
1358
|
+
function locateNpm({ platform = process.platform, home = homedir(), env = process.env, execPath = process.execPath, exists = existsSync, readdir } = {}) {
|
|
1359
|
+
const onPath = searchPath(platform === "win32" ? ["npm.cmd"] : ["npm"], {
|
|
1360
|
+
platform,
|
|
1361
|
+
env,
|
|
1362
|
+
exists
|
|
1363
|
+
});
|
|
1364
|
+
if (onPath !== void 0) return onPath;
|
|
1365
|
+
return npmCandidates({
|
|
1366
|
+
platform,
|
|
1367
|
+
home,
|
|
1368
|
+
env,
|
|
1369
|
+
execPath,
|
|
1370
|
+
readdir
|
|
1371
|
+
}).find((candidate) => exists(candidate));
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Classify an npm failure so the panel can say what to do about it.
|
|
1375
|
+
* @param result - the captured npm run.
|
|
1376
|
+
* @returns `{ code, detail }`.
|
|
1377
|
+
*/
|
|
1378
|
+
function explainNpmFailure(result) {
|
|
1379
|
+
const detail = detailOf(result?.error?.message, result?.stderr, result?.stdout);
|
|
1380
|
+
if (/\bEACCES\b|\bEPERM\b|permission denied|operation not permitted/iu.test(detail)) return {
|
|
1381
|
+
code: "permission",
|
|
1382
|
+
detail
|
|
1383
|
+
};
|
|
1384
|
+
if (/\bENOTFOUND\b|\bETIMEDOUT\b|\bECONNRESET\b|\bECONNREFUSED\b|network/iu.test(detail)) return {
|
|
1385
|
+
code: "network",
|
|
1386
|
+
detail
|
|
1387
|
+
};
|
|
1388
|
+
if (/timed out after/iu.test(detail)) return {
|
|
1389
|
+
code: "timeout",
|
|
1390
|
+
detail
|
|
1391
|
+
};
|
|
1392
|
+
return {
|
|
1393
|
+
code: "failed",
|
|
1394
|
+
detail: detail === "" ? `npm exited with code ${result?.code ?? "unknown"}` : detail
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Run a command to completion and capture its output. Never rejects: a
|
|
1399
|
+
* missing program or a timeout comes back as `error`.
|
|
1400
|
+
*/
|
|
1401
|
+
function runCaptured(spawnImpl, command, args, options, timeoutMs) {
|
|
1402
|
+
return new Promise((resolve) => {
|
|
1403
|
+
let child;
|
|
1404
|
+
try {
|
|
1405
|
+
child = spawnImpl(command, args, {
|
|
1406
|
+
...options,
|
|
1407
|
+
stdio: [
|
|
1408
|
+
"ignore",
|
|
1409
|
+
"pipe",
|
|
1410
|
+
"pipe"
|
|
1411
|
+
],
|
|
1412
|
+
windowsHide: true
|
|
1413
|
+
});
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
resolve({
|
|
1416
|
+
code: null,
|
|
1417
|
+
stdout: "",
|
|
1418
|
+
stderr: "",
|
|
1419
|
+
error
|
|
1420
|
+
});
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
let stdout = "";
|
|
1424
|
+
let stderr = "";
|
|
1425
|
+
let done = false;
|
|
1426
|
+
const finish = (outcome) => {
|
|
1427
|
+
if (done) return;
|
|
1428
|
+
done = true;
|
|
1429
|
+
clearTimeout(timer);
|
|
1430
|
+
resolve({
|
|
1431
|
+
stdout,
|
|
1432
|
+
stderr,
|
|
1433
|
+
...outcome
|
|
1434
|
+
});
|
|
1435
|
+
};
|
|
1436
|
+
const timer = setTimeout(() => {
|
|
1437
|
+
try {
|
|
1438
|
+
child.kill();
|
|
1439
|
+
} catch {}
|
|
1440
|
+
finish({
|
|
1441
|
+
code: null,
|
|
1442
|
+
error: /* @__PURE__ */ new Error(`timed out after ${Math.round(timeoutMs / 1e3)}s`)
|
|
1443
|
+
});
|
|
1444
|
+
}, timeoutMs);
|
|
1445
|
+
timer.unref?.();
|
|
1446
|
+
child.stdout?.setEncoding?.("utf8");
|
|
1447
|
+
child.stderr?.setEncoding?.("utf8");
|
|
1448
|
+
child.stdout?.on("data", (chunk) => {
|
|
1449
|
+
stdout = (stdout + String(chunk)).slice(-65536);
|
|
1450
|
+
});
|
|
1451
|
+
child.stderr?.on("data", (chunk) => {
|
|
1452
|
+
stderr = (stderr + String(chunk)).slice(-65536);
|
|
1453
|
+
});
|
|
1454
|
+
child.on("error", (error) => finish({
|
|
1455
|
+
code: null,
|
|
1456
|
+
error
|
|
1457
|
+
}));
|
|
1458
|
+
child.on("close", (code) => finish({
|
|
1459
|
+
code,
|
|
1460
|
+
error: void 0
|
|
1461
|
+
}));
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
/**
|
|
1465
|
+
* The CLI manager behind the settings panel and the runner.
|
|
1466
|
+
* @param options - the configured CLI path (a function, so a settings change
|
|
1467
|
+
* applies without a restart), plus platform, environment, and process seams.
|
|
1468
|
+
* @returns `{ binary, detect, install, status }`.
|
|
1469
|
+
*/
|
|
1470
|
+
function createClaudeCliManager({ explicitPath = () => "", platform = process.platform, home = homedir(), env = process.env, execPath = process.execPath, configDir = DEFAULT_CLAUDE_CONFIG_DIR, spawnImpl = spawn, exists = existsSync, readFile = (file) => readFileSync(file, "utf8"), readdir, now = () => Date.now() } = {}) {
|
|
1471
|
+
const path = pathFor(platform);
|
|
1472
|
+
const configured = () => {
|
|
1473
|
+
const value = explicitPath();
|
|
1474
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1475
|
+
};
|
|
1476
|
+
const locateOptions = () => ({
|
|
1477
|
+
platform,
|
|
1478
|
+
home,
|
|
1479
|
+
env,
|
|
1480
|
+
exists,
|
|
1481
|
+
readdir
|
|
1482
|
+
});
|
|
1483
|
+
const npm = () => locateNpm({
|
|
1484
|
+
...locateOptions(),
|
|
1485
|
+
execPath
|
|
1486
|
+
});
|
|
1487
|
+
/**
|
|
1488
|
+
* Run npm. On Windows npm is a `.cmd` launcher, which Node starts only
|
|
1489
|
+
* through `cmd.exe`; that is safe here because every argument is a constant.
|
|
1490
|
+
*/
|
|
1491
|
+
const runNpm = (program, args, timeoutMs) => runCaptured(spawnImpl, platform === "win32" ? `"${program}"` : program, args, {
|
|
1492
|
+
env: withProgramDirOnPath(env, program, platform),
|
|
1493
|
+
shell: platform === "win32"
|
|
1494
|
+
}, timeoutMs);
|
|
1495
|
+
/** npm's global bin directory, read once; a GUI PATH often lacks it. */
|
|
1496
|
+
let globalBin;
|
|
1497
|
+
const npmGlobalBin = async () => {
|
|
1498
|
+
if (globalBin !== void 0) return globalBin === "" ? void 0 : globalBin;
|
|
1499
|
+
const program = npm();
|
|
1500
|
+
if (program === void 0) return void 0;
|
|
1501
|
+
const result = await runNpm(program, PREFIX_ARGS, PREFIX_TIMEOUT_MS);
|
|
1502
|
+
const prefix = result.code === 0 ? result.stdout.trim().split(/\r?\n/u).at(-1)?.trim() ?? "" : "";
|
|
1503
|
+
globalBin = prefix === "" ? "" : platform === "win32" ? prefix : path.join(prefix, "bin");
|
|
1504
|
+
return globalBin === "" ? void 0 : globalBin;
|
|
1505
|
+
};
|
|
1506
|
+
const locate = async () => {
|
|
1507
|
+
const found = locateClaudeBinary({
|
|
1508
|
+
explicit: configured(),
|
|
1509
|
+
...locateOptions()
|
|
1510
|
+
});
|
|
1511
|
+
if (found !== void 0 || configured() !== "") return found;
|
|
1512
|
+
const bin = await npmGlobalBin();
|
|
1513
|
+
if (bin === void 0) return void 0;
|
|
1514
|
+
return claudeFileNames(platform).map((name) => path.join(bin, name)).find((candidate) => exists(candidate));
|
|
1515
|
+
};
|
|
1516
|
+
const probe = async () => {
|
|
1517
|
+
const found = await locate();
|
|
1518
|
+
if (found === void 0) return configured() === "" ? { installed: false } : {
|
|
1519
|
+
installed: false,
|
|
1520
|
+
configuredPath: configured()
|
|
1521
|
+
};
|
|
1522
|
+
let command;
|
|
1523
|
+
try {
|
|
1524
|
+
command = resolveSpawnCommand(found, {
|
|
1525
|
+
platform,
|
|
1526
|
+
exists,
|
|
1527
|
+
readFile
|
|
1528
|
+
});
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
return {
|
|
1531
|
+
installed: true,
|
|
1532
|
+
path: found,
|
|
1533
|
+
error: error.message
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
const result = await runCaptured(spawnImpl, command.command, [...command.args, "--version"], { env: {
|
|
1537
|
+
...withProgramDirOnPath(env, found, platform),
|
|
1538
|
+
CLAUDE_CONFIG_DIR: configDir,
|
|
1539
|
+
DISABLE_AUTOUPDATER: "1",
|
|
1540
|
+
DISABLE_TELEMETRY: "1",
|
|
1541
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
|
1542
|
+
} }, VERSION_TIMEOUT_MS);
|
|
1543
|
+
const version = result.code === 0 ? parseClaudeVersion(result.stdout) : void 0;
|
|
1544
|
+
if (version !== void 0) return {
|
|
1545
|
+
installed: true,
|
|
1546
|
+
path: found,
|
|
1547
|
+
version
|
|
1548
|
+
};
|
|
1549
|
+
return {
|
|
1550
|
+
installed: true,
|
|
1551
|
+
path: found,
|
|
1552
|
+
error: detailOf(result.error?.message, result.stderr, result.stdout) || `exited with code ${result.code ?? "unknown"}`
|
|
1553
|
+
};
|
|
1554
|
+
};
|
|
1555
|
+
let detection;
|
|
1556
|
+
let detecting;
|
|
1557
|
+
const detect = async ({ force = false } = {}) => {
|
|
1558
|
+
if (!force && detection !== void 0 && now() - detection.checkedAt < DETECTION_TTL_MS) return detection;
|
|
1559
|
+
if (detecting !== void 0) return detecting;
|
|
1560
|
+
detecting = probe().catch((error) => ({
|
|
1561
|
+
installed: false,
|
|
1562
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1563
|
+
})).then((value) => {
|
|
1564
|
+
detection = {
|
|
1565
|
+
...value,
|
|
1566
|
+
checkedAt: now()
|
|
1567
|
+
};
|
|
1568
|
+
return detection;
|
|
1569
|
+
}).finally(() => {
|
|
1570
|
+
detecting = void 0;
|
|
1571
|
+
});
|
|
1572
|
+
return detecting;
|
|
1573
|
+
};
|
|
1574
|
+
let installation = { state: "idle" };
|
|
1575
|
+
let installing;
|
|
1576
|
+
const install = () => {
|
|
1577
|
+
if (installing !== void 0) return installation;
|
|
1578
|
+
const program = npm();
|
|
1579
|
+
if (program === void 0) {
|
|
1580
|
+
installation = {
|
|
1581
|
+
state: "failed",
|
|
1582
|
+
code: "npm-missing",
|
|
1583
|
+
finishedAt: now()
|
|
1584
|
+
};
|
|
1585
|
+
return installation;
|
|
1586
|
+
}
|
|
1587
|
+
installation = {
|
|
1588
|
+
state: "running",
|
|
1589
|
+
startedAt: now(),
|
|
1590
|
+
npm: program
|
|
1591
|
+
};
|
|
1592
|
+
installing = runNpm(program, INSTALL_ARGS, INSTALL_TIMEOUT_MS).then(async (result) => {
|
|
1593
|
+
globalBin = void 0;
|
|
1594
|
+
if (result.code !== 0) {
|
|
1595
|
+
installation = {
|
|
1596
|
+
state: "failed",
|
|
1597
|
+
...explainNpmFailure(result),
|
|
1598
|
+
finishedAt: now()
|
|
1599
|
+
};
|
|
1600
|
+
await detect({ force: true });
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const found = await detect({ force: true });
|
|
1604
|
+
installation = found.installed && found.version !== void 0 ? {
|
|
1605
|
+
state: "succeeded",
|
|
1606
|
+
finishedAt: now()
|
|
1607
|
+
} : {
|
|
1608
|
+
state: "failed",
|
|
1609
|
+
code: "not-found-after-install",
|
|
1610
|
+
detail: detailOf(result.stdout, result.stderr),
|
|
1611
|
+
finishedAt: now()
|
|
1612
|
+
};
|
|
1613
|
+
}).catch((error) => {
|
|
1614
|
+
installation = {
|
|
1615
|
+
state: "failed",
|
|
1616
|
+
code: "failed",
|
|
1617
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
1618
|
+
finishedAt: now()
|
|
1619
|
+
};
|
|
1620
|
+
}).finally(() => {
|
|
1621
|
+
installing = void 0;
|
|
1622
|
+
});
|
|
1623
|
+
return installation;
|
|
1624
|
+
};
|
|
1625
|
+
return Object.freeze({
|
|
1626
|
+
/**
|
|
1627
|
+
* The CLI the runner should start: the last detected one while it still
|
|
1628
|
+
* exists, otherwise a fresh lookup — so a CLI installed after startup,
|
|
1629
|
+
* from the panel or a terminal, is used without restarting.
|
|
1630
|
+
*/
|
|
1631
|
+
async binary() {
|
|
1632
|
+
if (detection?.path !== void 0 && exists(detection.path)) return detection.path;
|
|
1633
|
+
return await locate() ?? (configured() === "" ? "claude" : configured());
|
|
1634
|
+
},
|
|
1635
|
+
detect,
|
|
1636
|
+
install,
|
|
1637
|
+
/** Wait for an install in flight; for tests and scripts. */
|
|
1638
|
+
settled: () => installing ?? Promise.resolve(),
|
|
1639
|
+
/**
|
|
1640
|
+
* What the settings panel shows. While an install runs, the last detection
|
|
1641
|
+
* stands instead of probing a half-written package.
|
|
1642
|
+
*/
|
|
1643
|
+
async status({ force = false } = {}) {
|
|
1644
|
+
const value = installing === void 0 ? await detect({ force }) : detection;
|
|
1645
|
+
return {
|
|
1646
|
+
installed: value?.installed === true,
|
|
1647
|
+
...value?.version === void 0 ? {} : { version: value.version },
|
|
1648
|
+
...value?.path === void 0 ? {} : { path: value.path },
|
|
1649
|
+
...value?.configuredPath === void 0 ? {} : { configuredPath: value.configuredPath },
|
|
1650
|
+
...value?.error === void 0 ? {} : { error: value.error },
|
|
1651
|
+
...value?.checkedAt === void 0 ? {} : { checkedAt: value.checkedAt },
|
|
1652
|
+
platform,
|
|
1653
|
+
command: CLAUDE_INSTALL_COMMAND,
|
|
1654
|
+
install: { ...installation }
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
//#endregion
|
|
1660
|
+
//#region src/key-ring.js
|
|
1661
|
+
/**
|
|
1662
|
+
* The PackyCode key ring.
|
|
1663
|
+
*
|
|
1664
|
+
* Keys live in one DSH credential *record* (`packycode-api/keys`), which is
|
|
1665
|
+
* the cross-process-serialized read-decide-replace space the credential
|
|
1666
|
+
* service owns. A key the gateway exhausts or rejects is marked unusable and
|
|
1667
|
+
* the ring moves the active pointer to the next usable key, so the next
|
|
1668
|
+
* attempt — the harness retry of the failed step — resolves a different key
|
|
1669
|
+
* through the adapter's per-request `resolveApiKey`.
|
|
1670
|
+
*
|
|
1671
|
+
* @module dsh-packycode-api/key-ring
|
|
1672
|
+
*/
|
|
1673
|
+
/** Printable ASCII without spaces: what an HTTP header can carry unchanged. */
|
|
1674
|
+
const LEGAL_KEY = /^[\x21-\x7E]+$/u;
|
|
1675
|
+
const RECORD_VERSION = 1;
|
|
1676
|
+
const MAX_KEYS = 32;
|
|
1677
|
+
const RECORD_KEY = typeof credentialKey === "function" ? credentialKey(PLUGIN_ID, KEY_RECORD_ID) : void 0;
|
|
1678
|
+
const text = (value) => typeof value === "string" ? value : "";
|
|
1679
|
+
/** Validate a pasted key before it is stored; the gateway never sees a bad one from us. */
|
|
1680
|
+
function assertApiKey(value) {
|
|
1681
|
+
const key = text(value).trim();
|
|
1682
|
+
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");
|
|
1683
|
+
return key;
|
|
1684
|
+
}
|
|
1685
|
+
function entry(value) {
|
|
1686
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
1687
|
+
const key = text(value.key);
|
|
1688
|
+
if (key === "" || !LEGAL_KEY.test(key)) return void 0;
|
|
1689
|
+
const id = text(value.id);
|
|
1690
|
+
return {
|
|
1691
|
+
id: id === "" ? randomUUID().slice(0, 8) : id,
|
|
1692
|
+
label: text(value.label),
|
|
1693
|
+
key,
|
|
1694
|
+
addedAt: Number.isFinite(value.addedAt) ? value.addedAt : 0,
|
|
1695
|
+
...Number.isFinite(value.disabledAt) ? { disabledAt: value.disabledAt } : {},
|
|
1696
|
+
...text(value.disabledReason) === "" ? {} : { disabledReason: text(value.disabledReason) }
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
/** Tolerate a hand-edited or older record instead of failing the whole route. */
|
|
1700
|
+
function normalizeState(record) {
|
|
1701
|
+
const payload = record !== null && typeof record === "object" && record.kind === "grant" && record.payload !== null && typeof record.payload === "object" ? record.payload : void 0;
|
|
1702
|
+
const keys = (Array.isArray(payload?.keys) ? payload.keys : []).map(entry).filter((candidate) => candidate !== void 0);
|
|
1703
|
+
const activeId = text(payload?.activeId);
|
|
1704
|
+
return {
|
|
1705
|
+
activeId: keys.some((candidate) => candidate.id === activeId) ? activeId : keys[0]?.id,
|
|
1706
|
+
keys
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* The next free `Key N` name. The ring shrinks on removal, so numbering follows
|
|
1711
|
+
* the highest name in use instead of the current key count — otherwise removing
|
|
1712
|
+
* the first key and adding another produces a second "Key 2".
|
|
1713
|
+
* @param keys - the stored keys.
|
|
1714
|
+
* @returns an unused default label.
|
|
1715
|
+
*/
|
|
1716
|
+
function nextDefaultLabel(keys) {
|
|
1717
|
+
const used = new Set(keys.map((candidate) => candidate.label));
|
|
1718
|
+
let index = keys.length + 1;
|
|
1719
|
+
while (used.has(`Key ${index}`)) index += 1;
|
|
1720
|
+
return `Key ${index}`;
|
|
1721
|
+
}
|
|
1722
|
+
/** The public shape: what a browser or a log may see, never key material. */
|
|
1723
|
+
function projection(state) {
|
|
1724
|
+
return {
|
|
1725
|
+
activeId: state.activeId,
|
|
1726
|
+
keys: state.keys.map((candidate) => ({
|
|
1727
|
+
id: candidate.id,
|
|
1728
|
+
label: candidate.label,
|
|
1729
|
+
addedAt: candidate.addedAt,
|
|
1730
|
+
active: candidate.id === state.activeId,
|
|
1731
|
+
disabled: candidate.disabledAt !== void 0,
|
|
1732
|
+
...candidate.disabledAt === void 0 ? {} : {
|
|
1733
|
+
disabledAt: candidate.disabledAt,
|
|
1734
|
+
disabledReason: candidate.disabledReason
|
|
1735
|
+
}
|
|
1736
|
+
}))
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
var PackyCodeKeyRing = class {
|
|
1740
|
+
#credentials;
|
|
1741
|
+
constructor(credentials) {
|
|
1742
|
+
if (RECORD_KEY === void 0) throw new Error("dsh-packycode-api requires @deepseek-ai/dsh-credentials with credentialKey()");
|
|
1743
|
+
if (credentials === null || typeof credentials !== "object" || typeof credentials.readRecord !== "function" || typeof credentials.modifyRecord !== "function") throw new Error("dsh-packycode-api requires the DSH credential record API");
|
|
1744
|
+
this.#credentials = credentials;
|
|
1745
|
+
}
|
|
1746
|
+
async #read() {
|
|
1747
|
+
return normalizeState(await this.#credentials.readRecord(RECORD_KEY));
|
|
1748
|
+
}
|
|
1749
|
+
async #write(mutate) {
|
|
1750
|
+
return normalizeState(await this.#credentials.modifyRecord(RECORD_KEY, async (current) => {
|
|
1751
|
+
const updated = await mutate(normalizeState(current));
|
|
1752
|
+
if (updated === void 0) return current;
|
|
1753
|
+
return {
|
|
1754
|
+
kind: "grant",
|
|
1755
|
+
payload: {
|
|
1756
|
+
version: RECORD_VERSION,
|
|
1757
|
+
activeId: updated.activeId,
|
|
1758
|
+
keys: updated.keys.map((candidate) => ({
|
|
1759
|
+
id: candidate.id,
|
|
1760
|
+
label: candidate.label,
|
|
1761
|
+
key: candidate.key,
|
|
1762
|
+
addedAt: candidate.addedAt,
|
|
1763
|
+
...candidate.disabledAt === void 0 ? {} : {
|
|
1764
|
+
disabledAt: candidate.disabledAt,
|
|
1765
|
+
disabledReason: candidate.disabledReason
|
|
1766
|
+
}
|
|
1767
|
+
}))
|
|
1768
|
+
}
|
|
1769
|
+
};
|
|
1770
|
+
}));
|
|
1771
|
+
}
|
|
1772
|
+
/** The keys as the browser may see them. */
|
|
1773
|
+
async list() {
|
|
1774
|
+
return projection(await this.#read());
|
|
1775
|
+
}
|
|
1776
|
+
/** Raw key material for the host-side balance reader; never crosses the RPC boundary. */
|
|
1777
|
+
async secrets() {
|
|
1778
|
+
return (await this.#read()).keys.map((candidate) => ({
|
|
1779
|
+
id: candidate.id,
|
|
1780
|
+
label: candidate.label,
|
|
1781
|
+
key: candidate.key,
|
|
1782
|
+
disabled: candidate.disabledAt !== void 0
|
|
1783
|
+
}));
|
|
1784
|
+
}
|
|
1785
|
+
/**
|
|
1786
|
+
* The key a request should use: the active one when it is usable, otherwise
|
|
1787
|
+
* the next usable key in insertion order, wrapping once.
|
|
1788
|
+
* @returns the chosen entry, or undefined when every key is unusable.
|
|
1789
|
+
*/
|
|
1790
|
+
async activeKey() {
|
|
1791
|
+
const state = await this.#read();
|
|
1792
|
+
if (state.keys.length === 0) return void 0;
|
|
1793
|
+
const usable = (candidate) => candidate.disabledAt === void 0;
|
|
1794
|
+
const start = Math.max(0, state.keys.findIndex((candidate) => candidate.id === state.activeId));
|
|
1795
|
+
for (let offset = 0; offset < state.keys.length; offset += 1) {
|
|
1796
|
+
const candidate = state.keys[(start + offset) % state.keys.length];
|
|
1797
|
+
if (usable(candidate)) return {
|
|
1798
|
+
id: candidate.id,
|
|
1799
|
+
label: candidate.label,
|
|
1800
|
+
key: candidate.key
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
async add(key, label) {
|
|
1805
|
+
const value = assertApiKey(key);
|
|
1806
|
+
const name = text(label).trim().slice(0, 60);
|
|
1807
|
+
return projection(await this.#write((state) => {
|
|
1808
|
+
if (state.keys.length >= MAX_KEYS) throw new Error(`PackyCode accepts at most ${MAX_KEYS} keys`);
|
|
1809
|
+
if (state.keys.some((candidate) => candidate.key === value)) throw new Error("That PackyCode API key is already stored");
|
|
1810
|
+
const added = {
|
|
1811
|
+
id: randomUUID().slice(0, 8),
|
|
1812
|
+
label: name === "" ? nextDefaultLabel(state.keys) : name,
|
|
1813
|
+
key: value,
|
|
1814
|
+
addedAt: Date.now()
|
|
1815
|
+
};
|
|
1816
|
+
return {
|
|
1817
|
+
activeId: state.activeId ?? added.id,
|
|
1818
|
+
keys: [...state.keys, added]
|
|
1819
|
+
};
|
|
1820
|
+
}));
|
|
1821
|
+
}
|
|
1822
|
+
async rename(id, label) {
|
|
1823
|
+
const name = text(label).trim().slice(0, 60);
|
|
1824
|
+
if (name === "") throw new Error("That PackyCode key name cannot be empty");
|
|
1825
|
+
return projection(await this.#write((state) => {
|
|
1826
|
+
if (!state.keys.some((candidate) => candidate.id === id)) throw new Error("Unknown PackyCode key");
|
|
1827
|
+
return {
|
|
1828
|
+
activeId: state.activeId,
|
|
1829
|
+
keys: state.keys.map((candidate) => candidate.id === id ? {
|
|
1830
|
+
...candidate,
|
|
1831
|
+
label: name
|
|
1832
|
+
} : candidate)
|
|
1833
|
+
};
|
|
1834
|
+
}));
|
|
1835
|
+
}
|
|
1836
|
+
async remove(id) {
|
|
1837
|
+
return projection(await this.#write((state) => {
|
|
1838
|
+
const remaining = state.keys.filter((candidate) => candidate.id !== id);
|
|
1839
|
+
if (remaining.length === state.keys.length) throw new Error("Unknown PackyCode key");
|
|
1840
|
+
return {
|
|
1841
|
+
activeId: state.activeId === id ? (remaining.find((candidate) => candidate.disabledAt === void 0) ?? remaining[0])?.id : state.activeId,
|
|
1842
|
+
keys: remaining
|
|
1843
|
+
};
|
|
1844
|
+
}));
|
|
1845
|
+
}
|
|
1846
|
+
async removeAll() {
|
|
1847
|
+
return projection(await this.#write(() => ({
|
|
1848
|
+
activeId: void 0,
|
|
1849
|
+
keys: []
|
|
1850
|
+
})));
|
|
1851
|
+
}
|
|
1852
|
+
async select(id) {
|
|
1853
|
+
return projection(await this.#write((state) => {
|
|
1854
|
+
if (!state.keys.some((candidate) => candidate.id === id)) throw new Error("Unknown PackyCode key");
|
|
1855
|
+
return {
|
|
1856
|
+
activeId: id,
|
|
1857
|
+
keys: state.keys
|
|
1858
|
+
};
|
|
1859
|
+
}));
|
|
1860
|
+
}
|
|
1861
|
+
/** Clear a rotation mark so a topped-up or replaced key is tried again. */
|
|
1862
|
+
async enable(id) {
|
|
1863
|
+
return projection(await this.#write((state) => {
|
|
1864
|
+
if (!state.keys.some((candidate) => candidate.id === id)) throw new Error("Unknown PackyCode key");
|
|
1865
|
+
return {
|
|
1866
|
+
activeId: state.activeId,
|
|
1867
|
+
keys: state.keys.map((candidate) => candidate.id === id ? {
|
|
1868
|
+
id: candidate.id,
|
|
1869
|
+
label: candidate.label,
|
|
1870
|
+
key: candidate.key,
|
|
1871
|
+
addedAt: candidate.addedAt
|
|
1872
|
+
} : candidate)
|
|
1873
|
+
};
|
|
1874
|
+
}));
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Mark a key unusable and move the active pointer on. Called from the stream
|
|
1878
|
+
* guard, so the retry of the failed step resolves the next key.
|
|
1879
|
+
*/
|
|
1880
|
+
async disable(id, reason) {
|
|
1881
|
+
return projection(await this.#write((state) => {
|
|
1882
|
+
const now = Date.now();
|
|
1883
|
+
const keys = state.keys.map((candidate) => candidate.id === id ? {
|
|
1884
|
+
...candidate,
|
|
1885
|
+
disabledAt: now,
|
|
1886
|
+
disabledReason: text(reason) === "" ? "unusable" : text(reason)
|
|
1887
|
+
} : candidate);
|
|
1888
|
+
if (!keys.some((candidate) => candidate.id === id)) return {
|
|
1889
|
+
activeId: state.activeId,
|
|
1890
|
+
keys: state.keys
|
|
1891
|
+
};
|
|
1892
|
+
return {
|
|
1893
|
+
activeId: state.activeId === id ? (keys.find((candidate) => candidate.id !== id && candidate.disabledAt === void 0) ?? keys.find((candidate) => candidate.id !== id))?.id : state.activeId,
|
|
1894
|
+
keys
|
|
1895
|
+
};
|
|
1896
|
+
}));
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
//#endregion
|
|
1900
|
+
//#region src/permission.js
|
|
1901
|
+
/**
|
|
1902
|
+
* The Claude Code CLI mode a session's own permissions allow.
|
|
1903
|
+
*
|
|
1904
|
+
* A CLI-backed turn runs the CLI's own tool loop, outside the harness sandbox
|
|
1905
|
+
* and approval flow. Deriving its mode from the session's permission preset
|
|
1906
|
+
* means the user grants it once, in the place they already grant the harness
|
|
1907
|
+
* itself, instead of in a separate plugin setting.
|
|
1908
|
+
*
|
|
1909
|
+
* @module dsh-packycode-api/permission
|
|
1910
|
+
*/
|
|
1911
|
+
/** Configured value meaning "follow the session's permission preset". */
|
|
1912
|
+
const AUTO_CLAUDE_MODE = "auto";
|
|
1913
|
+
const RANK = Object.freeze({
|
|
1914
|
+
"read-only": 0,
|
|
1915
|
+
edit: 1,
|
|
1916
|
+
full: 2
|
|
1917
|
+
});
|
|
1918
|
+
/**
|
|
1919
|
+
* Map the harness's two permission knobs onto a CLI mode.
|
|
1920
|
+
*
|
|
1921
|
+
* The CLI cannot ask the harness user for approval, so it skips its own
|
|
1922
|
+
* permission checks only when the session itself neither sandboxes nor asks.
|
|
1923
|
+
* @param sandbox - the session's resolved sandbox mode.
|
|
1924
|
+
* @param approval - the session's effective approval policy.
|
|
1925
|
+
* @returns a CLI mode, or `undefined` for an unknown sandbox mode.
|
|
1926
|
+
*/
|
|
1927
|
+
function claudeModeFor(sandbox, approval) {
|
|
1928
|
+
switch (sandbox) {
|
|
1929
|
+
case "read-only": return "read-only";
|
|
1930
|
+
case "workspace-write": return "edit";
|
|
1931
|
+
case "danger-full-access": return approval === "never" ? "full" : "edit";
|
|
1932
|
+
default: return;
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* Read a session's CLI mode from the host's permission services.
|
|
1937
|
+
* @param ctx - the plugin context; services are read without an inject requirement.
|
|
1938
|
+
* @param session - the session whose permission state is read.
|
|
1939
|
+
* @returns a CLI mode, or `undefined` when the session or the services are absent.
|
|
1940
|
+
*/
|
|
1941
|
+
function claudeModeForSession(ctx, session) {
|
|
1942
|
+
if (session === void 0 || session === null) return void 0;
|
|
1943
|
+
try {
|
|
1944
|
+
const sandbox = ctx.get?.("sandboxPolicy")?.resolve?.({ session })?.mode;
|
|
1945
|
+
const approval = ctx.get?.("approval")?.effectivePolicy?.(session);
|
|
1946
|
+
return claudeModeFor(sandbox, approval);
|
|
1947
|
+
} catch {
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* The narrower of two modes; an unknown ceiling leaves the request as it is.
|
|
1953
|
+
* @param requested - the mode a caller asked for.
|
|
1954
|
+
* @param ceiling - the most the session allows.
|
|
1955
|
+
* @returns the mode the CLI runs with.
|
|
1956
|
+
*/
|
|
1957
|
+
function capClaudeMode(requested, ceiling) {
|
|
1958
|
+
if (ceiling === void 0 || RANK[ceiling] === void 0) return requested;
|
|
1959
|
+
if (requested === void 0 || RANK[requested] === void 0) return ceiling;
|
|
1960
|
+
return RANK[requested] > RANK[ceiling] ? ceiling : requested;
|
|
1961
|
+
}
|
|
1962
|
+
//#endregion
|
|
1963
|
+
//#region src/catalog.js
|
|
1964
|
+
/**
|
|
1965
|
+
* The built-in PackyCode model catalog.
|
|
1966
|
+
*
|
|
1967
|
+
* The gateway's own guidance for these routes, which the base URLs and pinned
|
|
1968
|
+
* protocols below exist to satisfy:
|
|
1969
|
+
* - OpenAI/Codex traffic takes `/v1` in the base URL and never an endpoint
|
|
1970
|
+
* path — the SDK appends `/responses` or `/chat/completions` itself.
|
|
1971
|
+
* - Anthropic traffic takes `https://cf.api.fan` with **no** `/v1`: the
|
|
1972
|
+
* Anthropic client appends `/v1/messages` itself.
|
|
1973
|
+
* - A model asked over the wrong protocol does not fail cleanly; the stream
|
|
1974
|
+
* ends early. So every model pins the protocol its vendor serves here.
|
|
1975
|
+
*
|
|
1976
|
+
* Effort levels come from each vendor's documentation, because an effort the
|
|
1977
|
+
* model does not support is refused before the request is sent: Claude Opus
|
|
1978
|
+
* 5.5 (platform.claude.com effort docs), GPT-6.1 Sol `reasoning.effort`
|
|
1979
|
+
* (developers.openai.com model page), DeepSeek thinking mode (`thinking`
|
|
1980
|
+
* toggle plus `low|high|max` effort).
|
|
1981
|
+
*
|
|
1982
|
+
* @module dsh-packycode-api/catalog
|
|
1983
|
+
*/
|
|
1984
|
+
/** pi-ai never reads this gateway's pricing; the harness zeroes cost anyway. */
|
|
1985
|
+
const NO_COST = Object.freeze({
|
|
1986
|
+
input: 0,
|
|
1987
|
+
output: 0,
|
|
1988
|
+
cacheRead: 0,
|
|
1989
|
+
cacheWrite: 0
|
|
1990
|
+
});
|
|
1991
|
+
const COMPLETIONS_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
1992
|
+
const RESPONSES_BASE = `${DEFAULT_API_HOST}/v1`;
|
|
1993
|
+
const MESSAGES_BASE = DEFAULT_API_HOST;
|
|
1994
|
+
/**
|
|
1995
|
+
* pi-ai thinking-level maps. A level pinned to `null` is unsupported; an
|
|
1996
|
+
* absent key keeps pi-ai's own default for that level, which is how `off`
|
|
1997
|
+
* stays available for DeepSeek (`off` sends nothing and the deepseek dialect
|
|
1998
|
+
* then emits `thinking: disabled`).
|
|
1999
|
+
*/
|
|
2000
|
+
const DEEPSEEK_LEVELS = Object.freeze({
|
|
2001
|
+
minimal: null,
|
|
2002
|
+
low: "low",
|
|
2003
|
+
medium: null,
|
|
2004
|
+
high: "high",
|
|
2005
|
+
xhigh: null,
|
|
2006
|
+
max: "max"
|
|
2007
|
+
});
|
|
2008
|
+
/**
|
|
2009
|
+
* Claude 5.5 models and GPT-6.1 Sol cannot disable reasoning, so `off` is
|
|
2010
|
+
* pinned unsupported. The Claude models run through the CLI, which picks its
|
|
2011
|
+
* own effort, so for them these levels only shape the picker.
|
|
2012
|
+
*/
|
|
2013
|
+
const ALWAYS_THINKING_LEVELS = Object.freeze({
|
|
2014
|
+
off: null,
|
|
2015
|
+
minimal: null,
|
|
2016
|
+
low: "low",
|
|
2017
|
+
medium: "medium",
|
|
2018
|
+
high: "high",
|
|
2019
|
+
xhigh: "xhigh",
|
|
2020
|
+
max: "max"
|
|
2021
|
+
});
|
|
2022
|
+
const PACKYCODE_MODELS = Object.freeze([
|
|
2023
|
+
Object.freeze({
|
|
2024
|
+
id: "deepseek-flash",
|
|
2025
|
+
name: "DeepSeek Flash",
|
|
2026
|
+
api: "openai-completions",
|
|
2027
|
+
provider: PROVIDER,
|
|
2028
|
+
baseUrl: COMPLETIONS_BASE,
|
|
2029
|
+
reasoning: true,
|
|
2030
|
+
input: Object.freeze(["text"]),
|
|
2031
|
+
cost: NO_COST,
|
|
2032
|
+
contextWindow: 1e6,
|
|
2033
|
+
maxTokens: 384e3,
|
|
2034
|
+
thinkingLevelMap: DEEPSEEK_LEVELS,
|
|
2035
|
+
compat: Object.freeze({
|
|
2036
|
+
thinkingFormat: "deepseek",
|
|
2037
|
+
maxTokensField: "max_tokens",
|
|
2038
|
+
requiresReasoningContentOnAssistantMessages: true,
|
|
2039
|
+
supportsDeveloperRole: false
|
|
2040
|
+
})
|
|
2041
|
+
}),
|
|
2042
|
+
Object.freeze({
|
|
2043
|
+
id: "gpt-6.1-sol",
|
|
2044
|
+
name: "GPT-6.1 Sol",
|
|
2045
|
+
api: "openai-responses",
|
|
2046
|
+
provider: PROVIDER,
|
|
2047
|
+
baseUrl: RESPONSES_BASE,
|
|
2048
|
+
reasoning: true,
|
|
2049
|
+
input: Object.freeze(["text", "image"]),
|
|
2050
|
+
cost: NO_COST,
|
|
2051
|
+
contextWindow: 105e4,
|
|
2052
|
+
maxTokens: 128e3,
|
|
2053
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS
|
|
2054
|
+
}),
|
|
2055
|
+
Object.freeze({
|
|
2056
|
+
id: "claude-opus-5-5",
|
|
2057
|
+
name: "Claude Opus 5.5",
|
|
2058
|
+
api: "anthropic-messages",
|
|
2059
|
+
provider: PROVIDER,
|
|
2060
|
+
baseUrl: MESSAGES_BASE,
|
|
2061
|
+
reasoning: true,
|
|
2062
|
+
input: Object.freeze(["text", "image"]),
|
|
2063
|
+
cost: NO_COST,
|
|
2064
|
+
contextWindow: 1e6,
|
|
2065
|
+
maxTokens: 128e3,
|
|
2066
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
2067
|
+
compat: Object.freeze({
|
|
2068
|
+
forceAdaptiveThinking: true,
|
|
2069
|
+
supportsTemperature: false
|
|
2070
|
+
})
|
|
2071
|
+
}),
|
|
2072
|
+
Object.freeze({
|
|
2073
|
+
id: "claude-sonnet-5-5",
|
|
2074
|
+
name: "Claude Sonnet 5.5",
|
|
2075
|
+
api: "anthropic-messages",
|
|
2076
|
+
provider: PROVIDER,
|
|
2077
|
+
baseUrl: MESSAGES_BASE,
|
|
2078
|
+
reasoning: true,
|
|
2079
|
+
input: Object.freeze(["text", "image"]),
|
|
2080
|
+
cost: NO_COST,
|
|
2081
|
+
contextWindow: 1e6,
|
|
2082
|
+
maxTokens: 128e3,
|
|
2083
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
2084
|
+
compat: Object.freeze({
|
|
2085
|
+
forceAdaptiveThinking: true,
|
|
2086
|
+
supportsTemperature: false
|
|
2087
|
+
})
|
|
2088
|
+
}),
|
|
2089
|
+
Object.freeze({
|
|
2090
|
+
id: "claude-haiku-5-5",
|
|
2091
|
+
name: "Claude Haiku 5.5",
|
|
2092
|
+
api: "anthropic-messages",
|
|
2093
|
+
provider: PROVIDER,
|
|
2094
|
+
baseUrl: MESSAGES_BASE,
|
|
2095
|
+
reasoning: true,
|
|
2096
|
+
input: Object.freeze(["text", "image"]),
|
|
2097
|
+
cost: NO_COST,
|
|
2098
|
+
contextWindow: 2e5,
|
|
2099
|
+
maxTokens: 64e3,
|
|
2100
|
+
thinkingLevelMap: ALWAYS_THINKING_LEVELS,
|
|
2101
|
+
compat: Object.freeze({
|
|
2102
|
+
forceAdaptiveThinking: true,
|
|
2103
|
+
supportsTemperature: false
|
|
2104
|
+
})
|
|
2105
|
+
})
|
|
2106
|
+
]);
|
|
2107
|
+
/** Every model id this catalog advertises, in picker order. */
|
|
2108
|
+
const PACKYCODE_MODEL_IDS = Object.freeze(PACKYCODE_MODELS.map((model) => model.id));
|
|
2109
|
+
//#endregion
|
|
2110
|
+
//#region src/failures.js
|
|
2111
|
+
/**
|
|
2112
|
+
* Failure classification for the two conditions that move the key ring along.
|
|
2113
|
+
*
|
|
2114
|
+
* The gateway reports an exhausted key and a rejected key in Chinese, and the
|
|
2115
|
+
* harness classifies failures by fixed English/status wording it recognizes
|
|
2116
|
+
* (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
|
|
2117
|
+
* harness's retry policy re-run the step — and therefore what lets the retry
|
|
2118
|
+
* pick up the next key. Rewriting is honest about what happened: this plugin
|
|
2119
|
+
* knows the key's condition from the gateway's own words plus its balance
|
|
2120
|
+
* reader, so the sentence it substitutes says no more than that.
|
|
2121
|
+
*
|
|
2122
|
+
* @module dsh-packycode-api/failures
|
|
2123
|
+
*/
|
|
2124
|
+
/** Terminal quota, balance, or credit wording, in the gateway's own languages. */
|
|
2125
|
+
const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
|
|
2126
|
+
/** A rejected key names itself; a bare 403 does not, and must not disable one. */
|
|
2127
|
+
const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
|
|
2128
|
+
const INVALID_KEY_STATUS = /\b401\b/;
|
|
2129
|
+
/**
|
|
2130
|
+
* A gateway group that only serves its vendor's official client. These arrive
|
|
2131
|
+
* as 403, which the harness reads as an authentication failure and renders as
|
|
2132
|
+
* an invalid-API-key problem — the opposite of what happened, and a condition
|
|
2133
|
+
* no retry can clear.
|
|
2134
|
+
*/
|
|
2135
|
+
const CLIENT_POLICY_WORDING = /only accessible via the official|official claude cli|官方\s*claude\s*cli|请使用标准\s*codex\s*客户端|客户端存在异常|二次分发|API\s*转接/iu;
|
|
2136
|
+
/** The key's quota is exhausted; move to the next key. */
|
|
2137
|
+
const QUOTA_FAILURE = "quota";
|
|
2138
|
+
/** The gateway rejected the key itself; move to the next key. */
|
|
2139
|
+
const INVALID_KEY_FAILURE = "invalid-key";
|
|
2140
|
+
/**
|
|
2141
|
+
* Whether the gateway is refusing this client rather than this credential.
|
|
2142
|
+
* @param message - provider failure text, as pi-ai carries it.
|
|
2143
|
+
* @returns true when the route is gated to a vendor's official client.
|
|
2144
|
+
*/
|
|
2145
|
+
function classifyClientPolicy(message) {
|
|
2146
|
+
return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Classify a gateway failure message.
|
|
2150
|
+
*
|
|
2151
|
+
* Quota wording wins over a status token: an exhausted balance is often
|
|
2152
|
+
* reported as 402/403, and only the key's own rejection may read as 401. A
|
|
2153
|
+
* client-policy refusal is not a key fault and is classified separately.
|
|
2154
|
+
* @param message - provider failure text, as pi-ai carries it.
|
|
2155
|
+
* @returns the failure kind, or undefined when the key is not at fault.
|
|
2156
|
+
*/
|
|
2157
|
+
function classifyGatewayFailure(message) {
|
|
2158
|
+
const text = typeof message === "string" ? message : "";
|
|
2159
|
+
if (text === "") return void 0;
|
|
2160
|
+
if (classifyClientPolicy(text)) return void 0;
|
|
2161
|
+
if (QUOTA_WORDING.test(text)) return QUOTA_FAILURE;
|
|
2162
|
+
if (INVALID_KEY_WORDING.test(text)) return INVALID_KEY_FAILURE;
|
|
2163
|
+
if (INVALID_KEY_STATUS.test(text)) return INVALID_KEY_FAILURE;
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
2167
|
+
*
|
|
2168
|
+
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
2169
|
+
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
2170
|
+
* 401/403 before it reads any wording, which would send the failure into a
|
|
2171
|
+
* category the retry policy does not re-run.
|
|
2172
|
+
* @param message - the original provider failure text.
|
|
2173
|
+
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
2174
|
+
* @returns text the harness routes to QUOTA or AUTH.
|
|
2175
|
+
*/
|
|
2176
|
+
/**
|
|
2177
|
+
* Explain a client-policy refusal so the user reads the real cause.
|
|
2178
|
+
*
|
|
2179
|
+
* The sentence must not read as any other failure the harness knows: it
|
|
2180
|
+
* deliberately carries no HTTP status and none of the words the classifier
|
|
2181
|
+
* matches for auth, quota, rate limits, timeouts, or transport faults. That
|
|
2182
|
+
* keeps the failure out of the retry policy — no retry can talk a group into
|
|
2183
|
+
* serving an API client — and lets the sentence reach the user verbatim, in
|
|
2184
|
+
* place of the "API key is invalid" the bare 403 would have produced.
|
|
2185
|
+
* @param keyName - the account name the balance reader saw, when known.
|
|
2186
|
+
* @returns the replacement sentence.
|
|
2187
|
+
*/
|
|
2188
|
+
function explainClientPolicyFailure() {
|
|
2189
|
+
return "PackyCode 拒绝了这个客户端:所选分组只允许厂商官方客户端调用(Claude 分组要求官方 Claude CLI,Codex 分组要求标准 Codex 客户端)。这不是 API Key 失效,重试也不会成功。请改用允许 API 调用的分组(例如 cc-sale 或 claude-officially),或改用 PackyCode 的 gpt-6.1-sol / deepseek-flash。 / This group only serves the vendor’s official client; the API key is fine and retrying cannot help.";
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
2193
|
+
*
|
|
2194
|
+
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
2195
|
+
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
2196
|
+
* 401/403 before it reads any wording, which would send the failure into a
|
|
2197
|
+
* category the retry policy does not re-run.
|
|
2198
|
+
* @param message - the original provider failure text.
|
|
2199
|
+
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
2200
|
+
* @param options - optional account name for the policy explanation.
|
|
2201
|
+
* @returns text the harness routes to QUOTA or AUTH, or a policy explanation.
|
|
2202
|
+
*/
|
|
2203
|
+
function normalizeFailureMessage(message, kind, options = {}) {
|
|
2204
|
+
const text = typeof message === "string" && message.length > 0 ? message : "PackyCode request failed";
|
|
2205
|
+
if (kind === "client-policy") return explainClientPolicyFailure(options.keyName);
|
|
2206
|
+
if (kind === "quota") return `${neutralizeAuthStatus(text)} — PackyCode: insufficient quota for this API key; trying the next key`;
|
|
2207
|
+
if (kind === "invalid-key") return `${neutralizeAuthStatus(text)} — PackyCode: 401 invalid_authentication for this API key; trying the next key`;
|
|
2208
|
+
return text;
|
|
2209
|
+
}
|
|
2210
|
+
function neutralizeAuthStatus(text) {
|
|
2211
|
+
return text.replace(/\b(?:401|403)\b/gu, "HTTP");
|
|
2212
|
+
}
|
|
2213
|
+
//#endregion
|
|
2214
|
+
//#region src/provider.js
|
|
2215
|
+
/**
|
|
2216
|
+
* The PackyCode pi-ai provider, built in code so the user configures no route.
|
|
2217
|
+
*
|
|
2218
|
+
* `createProvider` dispatches on `model.api`, which is what lets one route
|
|
2219
|
+
* carry a chat/completions model, a Responses model, and an Anthropic Messages
|
|
2220
|
+
* model — the gateway refuses each of them over the other protocols.
|
|
2221
|
+
*
|
|
2222
|
+
* @module dsh-packycode-api/provider
|
|
2223
|
+
*/
|
|
2224
|
+
const TERMINAL_EVENTS = /* @__PURE__ */ new Set(["error", "done"]);
|
|
2225
|
+
/**
|
|
2226
|
+
* The api-key method pi-ai consults before it honours a request's `apiKey`
|
|
2227
|
+
* override. Resolution itself stays empty: this plugin authenticates from its
|
|
2228
|
+
* own key ring through the adapter, and ambient `PACKY_CODE_API_KEY` discovery
|
|
2229
|
+
* is disabled at the adapter's auth context.
|
|
2230
|
+
* @returns the provider auth for this route.
|
|
2231
|
+
*/
|
|
2232
|
+
function keyRingAuth() {
|
|
2233
|
+
return { apiKey: {
|
|
2234
|
+
name: DISPLAY_NAME,
|
|
2235
|
+
resolve: () => Promise.resolve({
|
|
2236
|
+
auth: {},
|
|
2237
|
+
source: DISPLAY_NAME
|
|
2238
|
+
})
|
|
2239
|
+
} };
|
|
2240
|
+
}
|
|
2241
|
+
/** Build the route's pi-ai provider (unguarded; the plugin wraps its streams). */
|
|
2242
|
+
function createPackyCodeProvider() {
|
|
2243
|
+
return createProvider({
|
|
2244
|
+
id: PROVIDER,
|
|
2245
|
+
name: DISPLAY_NAME,
|
|
2246
|
+
baseUrl: DEFAULT_API_HOST,
|
|
2247
|
+
auth: keyRingAuth(),
|
|
2248
|
+
models: PACKYCODE_MODELS,
|
|
2249
|
+
api: {
|
|
2250
|
+
"openai-completions": openAICompletionsApi(),
|
|
2251
|
+
"openai-responses": openAIResponsesApi(),
|
|
2252
|
+
"anthropic-messages": anthropicMessagesApi()
|
|
2253
|
+
}
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Observe a pi-ai event stream and hand a key-level failure to the plugin.
|
|
2258
|
+
*
|
|
2259
|
+
* pi-ai reports request failures as terminal events carrying the assistant
|
|
2260
|
+
* message's `errorMessage`, and transport failures as thrown errors. Both are
|
|
2261
|
+
* classified here; the substituted sentence is what makes the harness's retry
|
|
2262
|
+
* policy re-run the step, which is the mechanism that reaches the next key. A
|
|
2263
|
+
* thrown failure is only attributed to the key when no chunk arrived: a stream
|
|
2264
|
+
* that broke mid-answer had already been accepted by the gateway.
|
|
2265
|
+
* @param stream - the pi-ai event stream.
|
|
2266
|
+
* @param options - the request's key plus the failure sink.
|
|
2267
|
+
* @returns the same stream, with key-level failures classified and rewritten.
|
|
2268
|
+
*/
|
|
2269
|
+
function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
|
|
2270
|
+
if (stream === null || typeof stream !== "object") return stream;
|
|
2271
|
+
let observed = false;
|
|
2272
|
+
const report = (kind, message) => onKeyFailure?.(kind, message, key);
|
|
2273
|
+
const inspect = (value) => {
|
|
2274
|
+
if (value === null || typeof value !== "object" || !TERMINAL_EVENTS.has(value.type)) return value;
|
|
2275
|
+
for (const carrierKey of ["error", "message"]) {
|
|
2276
|
+
const carrier = value[carrierKey];
|
|
2277
|
+
if (carrier === null || typeof carrier !== "object") continue;
|
|
2278
|
+
if (classifyClientPolicy(carrier.errorMessage)) return {
|
|
2279
|
+
...value,
|
|
2280
|
+
[carrierKey]: {
|
|
2281
|
+
...carrier,
|
|
2282
|
+
errorMessage: explainClientPolicyFailure()
|
|
2283
|
+
}
|
|
2284
|
+
};
|
|
2285
|
+
const kind = classifyGatewayFailure(carrier.errorMessage);
|
|
2286
|
+
if (kind === void 0) continue;
|
|
2287
|
+
report(kind, carrier.errorMessage);
|
|
2288
|
+
return {
|
|
2289
|
+
...value,
|
|
2290
|
+
[carrierKey]: {
|
|
2291
|
+
...carrier,
|
|
2292
|
+
errorMessage: normalizeFailureMessage(carrier.errorMessage, kind)
|
|
2293
|
+
}
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
return value;
|
|
2297
|
+
};
|
|
2298
|
+
return new Proxy(stream, { get(target, prop, receiver) {
|
|
2299
|
+
if (prop === Symbol.asyncIterator) return () => {
|
|
2300
|
+
const iterator = Reflect.get(target, Symbol.asyncIterator).call(target);
|
|
2301
|
+
return {
|
|
2302
|
+
next: async (...args) => {
|
|
2303
|
+
try {
|
|
2304
|
+
const item = await iterator.next(...args);
|
|
2305
|
+
observed = true;
|
|
2306
|
+
if (item === null || typeof item !== "object" || item.done === true) return item;
|
|
2307
|
+
const value = inspect(item.value);
|
|
2308
|
+
return value === item.value ? item : {
|
|
2309
|
+
...item,
|
|
2310
|
+
value
|
|
2311
|
+
};
|
|
2312
|
+
} catch (error) {
|
|
2313
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
2314
|
+
if (classifyClientPolicy(text)) throw new Error(explainClientPolicyFailure(), { cause: error });
|
|
2315
|
+
const kind = observed ? void 0 : classifyGatewayFailure(text);
|
|
2316
|
+
if (kind !== void 0) {
|
|
2317
|
+
report(kind, text);
|
|
2318
|
+
throw new Error(normalizeFailureMessage(text, kind), { cause: error });
|
|
2319
|
+
}
|
|
2320
|
+
throw error;
|
|
2321
|
+
}
|
|
2322
|
+
},
|
|
2323
|
+
...typeof iterator.return === "function" ? { return: (...args) => iterator.return(...args) } : {},
|
|
2324
|
+
...typeof iterator.throw === "function" ? { throw: (...args) => iterator.throw(...args) } : {}
|
|
2325
|
+
};
|
|
2326
|
+
};
|
|
2327
|
+
const value = Reflect.get(target, prop, receiver);
|
|
2328
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
2329
|
+
} });
|
|
2330
|
+
}
|
|
2331
|
+
//#endregion
|
|
2332
|
+
//#region src/rpc.js
|
|
2333
|
+
/**
|
|
2334
|
+
* The loopback RPC surface behind the settings panel and the composer chip.
|
|
2335
|
+
*
|
|
2336
|
+
* Every reply carries only what this plugin owns: key labels and state, the
|
|
2337
|
+
* balance projection the host built, host overrides, and whether this device
|
|
2338
|
+
* has the Claude Code CLI. Key material never crosses this boundary.
|
|
2339
|
+
*
|
|
2340
|
+
* @module dsh-packycode-api/rpc
|
|
2341
|
+
*/
|
|
2342
|
+
const ok = (value) => ({
|
|
2343
|
+
ok: true,
|
|
2344
|
+
value
|
|
2345
|
+
});
|
|
2346
|
+
const failed = (message) => ({
|
|
2347
|
+
ok: false,
|
|
2348
|
+
error: { message }
|
|
2349
|
+
});
|
|
2350
|
+
/** Only sentences this plugin authored may reach the browser. */
|
|
2351
|
+
const SAFE_MESSAGE = /^(?:PackyCode|That |Unknown |PackyCode's |add |the )[\s\S]{0,180}$/u;
|
|
2352
|
+
/** Accept an absolute http(s) origin and drop a trailing slash. */
|
|
2353
|
+
function normalizeHost(value, fallback) {
|
|
2354
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
2355
|
+
if (text === "") return fallback;
|
|
2356
|
+
let url;
|
|
2357
|
+
try {
|
|
2358
|
+
url = new URL(text);
|
|
2359
|
+
} catch {
|
|
2360
|
+
return fallback;
|
|
2361
|
+
}
|
|
2362
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return fallback;
|
|
2363
|
+
return `${url.origin}${url.pathname.replace(/\/+$/u, "")}`;
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* Build the RPC handler.
|
|
2367
|
+
* @param options - the key ring, balance reader, host preference, and the
|
|
2368
|
+
* Claude Code CLI manager (optional: without it the CLI endpoints refuse).
|
|
2369
|
+
* @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
|
|
2370
|
+
*/
|
|
2371
|
+
function createPackyCodeRpc({ ring, balance, hosts, preference, claudeCli }) {
|
|
2372
|
+
const requireCli = () => {
|
|
2373
|
+
if (claudeCli === void 0) throw new Error("PackyCode Claude Code CLI support is unavailable in this runtime");
|
|
2374
|
+
return claudeCli;
|
|
2375
|
+
};
|
|
2376
|
+
const status = async () => ({
|
|
2377
|
+
...await ring.list(),
|
|
2378
|
+
hosts: hosts(),
|
|
2379
|
+
writable: preference.writable === true,
|
|
2380
|
+
defaults: {
|
|
2381
|
+
apiHost: DEFAULT_API_HOST,
|
|
2382
|
+
statusHost: DEFAULT_STATUS_HOST
|
|
2383
|
+
}
|
|
2384
|
+
});
|
|
2385
|
+
return async (endpoint, payload) => {
|
|
2386
|
+
try {
|
|
2387
|
+
switch (endpoint) {
|
|
2388
|
+
case "status": return ok(await status());
|
|
2389
|
+
case "keys/add":
|
|
2390
|
+
await ring.add(payload?.key, payload?.label);
|
|
2391
|
+
balance.invalidate();
|
|
2392
|
+
return ok(await status());
|
|
2393
|
+
case "keys/rename":
|
|
2394
|
+
await ring.rename(String(payload?.id ?? ""), payload?.label);
|
|
2395
|
+
balance.invalidate();
|
|
2396
|
+
return ok(await status());
|
|
2397
|
+
case "keys/remove":
|
|
2398
|
+
await ring.remove(String(payload?.id ?? ""));
|
|
2399
|
+
balance.invalidate();
|
|
2400
|
+
return ok(await status());
|
|
2401
|
+
case "keys/remove-all":
|
|
2402
|
+
await ring.removeAll();
|
|
2403
|
+
balance.invalidate();
|
|
2404
|
+
return ok(await status());
|
|
2405
|
+
case "keys/select":
|
|
2406
|
+
await ring.select(String(payload?.id ?? ""));
|
|
2407
|
+
balance.invalidate();
|
|
2408
|
+
return ok(await status());
|
|
2409
|
+
case "keys/enable":
|
|
2410
|
+
await ring.enable(String(payload?.id ?? ""));
|
|
2411
|
+
balance.invalidate();
|
|
2412
|
+
return ok(await status());
|
|
2413
|
+
case "balance": {
|
|
2414
|
+
const [value, state] = await Promise.all([balance.read({ force: payload?.force === true }), ring.list()]);
|
|
2415
|
+
return ok({
|
|
2416
|
+
...value,
|
|
2417
|
+
activeId: state.activeId
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
case "hosts/update":
|
|
2421
|
+
if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
|
|
2422
|
+
await preference.set({
|
|
2423
|
+
apiHost: normalizeHost(payload?.apiHost, hosts().apiHost),
|
|
2424
|
+
statusHost: normalizeHost(payload?.statusHost, hosts().statusHost)
|
|
2425
|
+
});
|
|
2426
|
+
balance.invalidate();
|
|
2427
|
+
return ok(await status());
|
|
2428
|
+
case "claude-cli/status": return ok(await requireCli().status({ force: payload?.force === true }));
|
|
2429
|
+
case "claude-cli/install": {
|
|
2430
|
+
const cli = requireCli();
|
|
2431
|
+
cli.install();
|
|
2432
|
+
return ok(await cli.status());
|
|
2433
|
+
}
|
|
2434
|
+
default: return failed("Unknown PackyCode endpoint");
|
|
2435
|
+
}
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2438
|
+
return failed(SAFE_MESSAGE.test(message) ? message : "PackyCode request failed");
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
//#endregion
|
|
2443
|
+
//#region src/index.js
|
|
2444
|
+
/**
|
|
2445
|
+
* dsh-packycode-api — a self-contained PackyCode route for DeepSeek Harness.
|
|
2446
|
+
*
|
|
2447
|
+
* The plugin builds its own pi-ai provider, model catalog, key ring, and
|
|
2448
|
+
* balance reader, then registers the route through the same public
|
|
2449
|
+
* `llm.registerAdapter` API the shipped adapters use. Nothing here reads the
|
|
2450
|
+
* profile's composition config, so installing the package is the whole setup:
|
|
2451
|
+
* the picker shows the models and the user only pastes API keys.
|
|
2452
|
+
*
|
|
2453
|
+
* @module dsh-packycode-api
|
|
2454
|
+
*/
|
|
2455
|
+
const name = PLUGIN_ID;
|
|
2456
|
+
/**
|
|
2457
|
+
* Only the model route is mandatory. `connection` carries the browser RPC
|
|
2458
|
+
* surface, and `settings` the host overrides: a profile without them (a
|
|
2459
|
+
* headless run, for one) must still boot with a working route instead of
|
|
2460
|
+
* blocking on a service it will never see.
|
|
2461
|
+
*/
|
|
2462
|
+
const inject = ["llm", "credentials"];
|
|
2463
|
+
/** Host overrides; a profile that never installs this section keeps the defaults. */
|
|
2464
|
+
const Config = z.object({
|
|
2465
|
+
apiHost: z.string().default(DEFAULT_API_HOST),
|
|
2466
|
+
statusHost: z.string().default(DEFAULT_STATUS_HOST),
|
|
2467
|
+
/** Path to the Claude Code CLI binary; empty means "look in the usual places, then PATH". */
|
|
2468
|
+
claudeCliPath: z.string().default(""),
|
|
2469
|
+
/**
|
|
2470
|
+
* What the CLI may do on its own during a CLI-backed turn. `auto` follows the
|
|
2471
|
+
* session's permission preset, so nothing needs configuring here.
|
|
2472
|
+
*/
|
|
2473
|
+
claudeCliMode: z.union([
|
|
2474
|
+
AUTO_CLAUDE_MODE,
|
|
2475
|
+
"read-only",
|
|
2476
|
+
"edit",
|
|
2477
|
+
"full"
|
|
2478
|
+
]).default(AUTO_CLAUDE_MODE),
|
|
2479
|
+
/** Working directory for CLI-backed turns; empty means "the session's workspace". */
|
|
2480
|
+
claudeCliCwd: z.string().default("")
|
|
2481
|
+
});
|
|
2482
|
+
/**
|
|
2483
|
+
* Request-level bounds from the shipped pi-ai adapter: a bounded image payload
|
|
2484
|
+
* keeps a long session completing requests instead of being refused by a
|
|
2485
|
+
* gateway size cap.
|
|
2486
|
+
*/
|
|
2487
|
+
const REQUEST_LIMITS = Object.freeze({
|
|
2488
|
+
streamIdleTimeoutMs: 600 * 1e3,
|
|
2489
|
+
maxRequestImageBytes: 20 * 1024 * 1024,
|
|
2490
|
+
requestImagePixelBudget: 2048 * 2048,
|
|
2491
|
+
requestImageMaxBytes: 1024 * 1024
|
|
2492
|
+
});
|
|
2493
|
+
/**
|
|
2494
|
+
* pi-ai's credential store over the key ring.
|
|
2495
|
+
*
|
|
2496
|
+
* Reads answer with the active key so pi-ai's own auth reporting stays honest,
|
|
2497
|
+
* while writes are refused: the ring owns them, because only the ring can move
|
|
2498
|
+
* the active pointer past a key the gateway rejected.
|
|
2499
|
+
* @param ring - the plugin's key ring.
|
|
2500
|
+
* @returns a pi-ai `CredentialStore`.
|
|
2501
|
+
*/
|
|
2502
|
+
function ringCredentialStore(ring) {
|
|
2503
|
+
const managed = () => {
|
|
2504
|
+
throw new Error("PackyCode API keys are managed in the PackyCode settings section");
|
|
2505
|
+
};
|
|
2506
|
+
return {
|
|
2507
|
+
async read() {
|
|
2508
|
+
const active = await ring.activeKey();
|
|
2509
|
+
return active === void 0 ? void 0 : {
|
|
2510
|
+
type: "api_key",
|
|
2511
|
+
key: active.key
|
|
2512
|
+
};
|
|
2513
|
+
},
|
|
2514
|
+
async list() {
|
|
2515
|
+
return await ring.activeKey() === void 0 ? [] : [{
|
|
2516
|
+
providerId: PROVIDER,
|
|
2517
|
+
type: "api_key"
|
|
2518
|
+
}];
|
|
2519
|
+
},
|
|
2520
|
+
modify: managed,
|
|
2521
|
+
delete: managed
|
|
2522
|
+
};
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* The provider profile this route runs on.
|
|
2526
|
+
*
|
|
2527
|
+
* `modelErrors` and `configuredMaxTokens` are required by the host adapter;
|
|
2528
|
+
* they stay empty because the catalog is fixed and no model carries a
|
|
2529
|
+
* per-request output cap of its own.
|
|
2530
|
+
* @param provider - the route's pi-ai provider.
|
|
2531
|
+
* @returns the frozen profile handed to `PiAiAdapter`.
|
|
2532
|
+
*/
|
|
2533
|
+
function createPackyCodeProfile(provider) {
|
|
2534
|
+
return Object.freeze({
|
|
2535
|
+
provider: PROVIDER,
|
|
2536
|
+
displayName: DISPLAY_NAME,
|
|
2537
|
+
piProvider: provider,
|
|
2538
|
+
reasoning: DEFAULT_REASONING_LEVEL,
|
|
2539
|
+
configuredMaxTokens: /* @__PURE__ */ new Map(),
|
|
2540
|
+
modelErrors: /* @__PURE__ */ new Map(),
|
|
2541
|
+
...REQUEST_LIMITS,
|
|
2542
|
+
cacheRetention: "short",
|
|
2543
|
+
retryPolicy: resolveRetryPolicy({
|
|
2544
|
+
mode: "normal",
|
|
2545
|
+
maxRetries: 20,
|
|
2546
|
+
retryableCodes: [
|
|
2547
|
+
"EMPTY_RESPONSE",
|
|
2548
|
+
"RATE_LIMIT",
|
|
2549
|
+
"SERVER",
|
|
2550
|
+
"TIMEOUT",
|
|
2551
|
+
"TRANSPORT",
|
|
2552
|
+
QUOTA_EXCEEDED_CODE,
|
|
2553
|
+
"AUTH",
|
|
2554
|
+
"INVALID_REQUEST"
|
|
2555
|
+
]
|
|
2556
|
+
}, `${PLUGIN_ID}.retryPolicy`)
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
function apply(ctx, config) {
|
|
2560
|
+
const balanceReaderOptions = {};
|
|
2561
|
+
const ring = new PackyCodeKeyRing(ctx.credentials);
|
|
2562
|
+
/**
|
|
2563
|
+
* Key material to ring id. A failure is reported from inside a stream, which
|
|
2564
|
+
* carries the request's key but not its id; this index is refreshed on every
|
|
2565
|
+
* resolution so the right key is the one that moves aside.
|
|
2566
|
+
*/
|
|
2567
|
+
const idByKey = /* @__PURE__ */ new Map();
|
|
2568
|
+
const rememberKeys = async () => {
|
|
2569
|
+
for (const entry of await ring.secrets()) idByKey.set(entry.key, entry.id);
|
|
2570
|
+
};
|
|
2571
|
+
let source = () => ({
|
|
2572
|
+
apiHost: normalizeHost(config?.apiHost, DEFAULT_API_HOST),
|
|
2573
|
+
statusHost: normalizeHost(config?.statusHost, DEFAULT_STATUS_HOST)
|
|
2574
|
+
});
|
|
2575
|
+
const hosts = () => {
|
|
2576
|
+
const current = source() ?? {};
|
|
2577
|
+
return {
|
|
2578
|
+
apiHost: normalizeHost(current.apiHost, DEFAULT_API_HOST),
|
|
2579
|
+
statusHost: normalizeHost(current.statusHost, DEFAULT_STATUS_HOST)
|
|
2580
|
+
};
|
|
2581
|
+
};
|
|
2582
|
+
const balance = createBalanceReader({
|
|
2583
|
+
...balanceReaderOptions,
|
|
2584
|
+
listKeys: () => ring.secrets(),
|
|
2585
|
+
getHosts: hosts
|
|
2586
|
+
});
|
|
2587
|
+
const onKeyFailure = (kind, _message, key) => {
|
|
2588
|
+
const id = key === void 0 ? void 0 : idByKey.get(key);
|
|
2589
|
+
if (id === void 0) {
|
|
2590
|
+
ctx.logger?.warn?.("packycode-api: %s reported for an unidentified key; not rotating", kind);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
ctx.logger?.warn?.("packycode-api: %s on key %s; the next key serves the retry", kind, id);
|
|
2594
|
+
ring.disable(id, kind).then(() => balance.invalidate()).catch((error) => ctx.logger?.warn?.("packycode-api: could not move past key %s: %s", id, error.message));
|
|
2595
|
+
};
|
|
2596
|
+
const raw = createPackyCodeProvider();
|
|
2597
|
+
/**
|
|
2598
|
+
* The gateway's Codex-group check refuses a Responses request that carries no
|
|
2599
|
+
* `prompt_cache_key` with a 403, which pi-ai derives from the session id. The
|
|
2600
|
+
* harness supplies one for every agent turn; this fallback covers a caller
|
|
2601
|
+
* that does not, so the route never fails for a missing cache key.
|
|
2602
|
+
*/
|
|
2603
|
+
const fallbackSessionId = `${PLUGIN_ID}-${randomUUID()}`;
|
|
2604
|
+
const withSessionId = (options) => options === void 0 || options.sessionId !== void 0 ? options : {
|
|
2605
|
+
...options,
|
|
2606
|
+
sessionId: fallbackSessionId
|
|
2607
|
+
};
|
|
2608
|
+
const profile = createPackyCodeProfile(Object.freeze({
|
|
2609
|
+
...raw,
|
|
2610
|
+
stream: (model, context, options) => guardKeyRotation(raw.stream(model, context, withSessionId(options)), {
|
|
2611
|
+
key: options?.apiKey,
|
|
2612
|
+
onKeyFailure
|
|
2613
|
+
}),
|
|
2614
|
+
streamSimple: (model, context, options) => guardKeyRotation(raw.streamSimple(model, context, withSessionId(options)), {
|
|
2615
|
+
key: options?.apiKey,
|
|
2616
|
+
onKeyFailure
|
|
2617
|
+
})
|
|
2618
|
+
}));
|
|
2619
|
+
const profiles = /* @__PURE__ */ new Map([[PROVIDER, profile]]);
|
|
2620
|
+
/**
|
|
2621
|
+
* The Claude Code CLI runner. PackyCode's `cc` group serves its Claude
|
|
2622
|
+
* models only to the official CLI, so CLI-backed models below are run by
|
|
2623
|
+
* that CLI itself — with this plugin's endpoint and key, in an isolated
|
|
2624
|
+
* config home.
|
|
2625
|
+
*/
|
|
2626
|
+
const claudeCli = createClaudeCliManager({ explicitPath: () => config?.claudeCliPath });
|
|
2627
|
+
const claudeRunner = createClaudeRunner({
|
|
2628
|
+
binary: () => claudeCli.binary(),
|
|
2629
|
+
resolveCredential: async () => {
|
|
2630
|
+
const active = await ring.activeKey();
|
|
2631
|
+
if (active === void 0) throw new Error("Claude Code CLI needs a usable PackyCode API key; add one under Settings → PackyCode");
|
|
2632
|
+
return {
|
|
2633
|
+
baseUrl: hosts().apiHost,
|
|
2634
|
+
token: active.key
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
});
|
|
2638
|
+
/** The session of the agent this turn runs for, when one is in scope. */
|
|
2639
|
+
const currentSession = () => {
|
|
2640
|
+
try {
|
|
2641
|
+
return ctx.get?.("agents")?.currentInitiator?.()?.session;
|
|
2642
|
+
} catch {
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
};
|
|
2646
|
+
/** The session's own workspace, so a CLI turn edits the project in view. */
|
|
2647
|
+
const resolveCliCwd = () => {
|
|
2648
|
+
const configured = typeof config?.claudeCliCwd === "string" ? config.claudeCliCwd.trim() : "";
|
|
2649
|
+
if (configured !== "") return configured;
|
|
2650
|
+
const cwd = currentSession()?.header?.cwd;
|
|
2651
|
+
return typeof cwd === "string" && cwd !== "" ? cwd : process.cwd();
|
|
2652
|
+
};
|
|
2653
|
+
/**
|
|
2654
|
+
* The most a CLI turn may do: an explicit setting when the profile names
|
|
2655
|
+
* one, otherwise what the session's permission preset allows.
|
|
2656
|
+
*/
|
|
2657
|
+
const configuredCliMode = () => {
|
|
2658
|
+
const configured = config?.claudeCliMode;
|
|
2659
|
+
return configured === void 0 || configured === "auto" ? void 0 : configured;
|
|
2660
|
+
};
|
|
2661
|
+
const cliModeCeiling = (session) => configuredCliMode() ?? claudeModeForSession(ctx, session);
|
|
2662
|
+
const resolveCliMode = () => cliModeCeiling(currentSession()) ?? "edit";
|
|
2663
|
+
/**
|
|
2664
|
+
* Dispatch per model: CLI-backed ids run the Claude Code CLI, everything
|
|
2665
|
+
* else keeps the pi-ai path.
|
|
2666
|
+
*/
|
|
2667
|
+
const adapter = createClaudeCliAdapter({
|
|
2668
|
+
inner: new PiAiAdapter({
|
|
2669
|
+
profiles: () => profiles,
|
|
2670
|
+
resolveApiKey: async () => {
|
|
2671
|
+
await rememberKeys();
|
|
2672
|
+
const active = await ring.activeKey();
|
|
2673
|
+
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");
|
|
2674
|
+
return active.key;
|
|
2675
|
+
},
|
|
2676
|
+
auth: Object.freeze({
|
|
2677
|
+
credentials: ringCredentialStore(ring),
|
|
2678
|
+
authContext: Object.freeze({
|
|
2679
|
+
env: async () => void 0,
|
|
2680
|
+
fileExists: async () => false
|
|
2681
|
+
})
|
|
2682
|
+
}),
|
|
2683
|
+
resolveAttachments: () => ctx.get?.("attachments") ?? ctx.attachments
|
|
2684
|
+
}),
|
|
2685
|
+
runner: claudeRunner,
|
|
2686
|
+
resolveMode: resolveCliMode,
|
|
2687
|
+
resolveCwd: resolveCliCwd
|
|
2688
|
+
});
|
|
2689
|
+
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
2690
|
+
ctx.llm.registerConfigurableProviders([{
|
|
2691
|
+
provider: PROVIDER,
|
|
2692
|
+
displayName: DISPLAY_NAME,
|
|
2693
|
+
settingsNs: SETTINGS_NAMESPACE,
|
|
2694
|
+
settingsPath: []
|
|
2695
|
+
}]);
|
|
2696
|
+
const preference = {
|
|
2697
|
+
writable: false,
|
|
2698
|
+
set: async () => {
|
|
2699
|
+
throw new Error("PackyCode host overrides are not writable in this profile");
|
|
2700
|
+
}
|
|
2701
|
+
};
|
|
2702
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
2703
|
+
try {
|
|
2704
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, source(), {
|
|
2705
|
+
setSource: (current) => {
|
|
2706
|
+
source = typeof current === "function" ? () => current() ?? {} : () => current ?? {};
|
|
2707
|
+
},
|
|
2708
|
+
onChange: () => balance.invalidate()
|
|
2709
|
+
});
|
|
2710
|
+
if (typeof settingsCtx.settings.update === "function") {
|
|
2711
|
+
preference.writable = true;
|
|
2712
|
+
preference.set = (patch) => settingsCtx.settings.update(SETTINGS_NAMESPACE, patch);
|
|
2713
|
+
}
|
|
2714
|
+
} catch (error) {
|
|
2715
|
+
ctx.logger?.warn?.("packycode-api: host overrides unavailable, defaults stand: %s", error.message);
|
|
2716
|
+
}
|
|
2717
|
+
});
|
|
2718
|
+
rememberKeys().catch(() => void 0);
|
|
2719
|
+
const handler = createPackyCodeRpc({
|
|
2720
|
+
ring,
|
|
2721
|
+
balance,
|
|
2722
|
+
hosts,
|
|
2723
|
+
preference,
|
|
2724
|
+
claudeCli
|
|
2725
|
+
});
|
|
2726
|
+
/**
|
|
2727
|
+
* Connection registers a channel through `owner.webServer`, read on its own
|
|
2728
|
+
* row's fiber — which is why this plugin ships the `connection` inject patch
|
|
2729
|
+
* in `cordis.patch.yml`. Without that service the read is refused, the
|
|
2730
|
+
* channel never reaches the web server, and the browser answers HTTP 405
|
|
2731
|
+
* while the boot log stays clean. A profile with neither service (a headless
|
|
2732
|
+
* run) simply never runs this callback.
|
|
2733
|
+
*/
|
|
2734
|
+
ctx.inject(["connection", "webServer"], (connectionCtx) => {
|
|
2735
|
+
ctx.effect(() => connectionCtx.connection.rpc.handle(CHANNEL, handler), "packycode-api: loopback account RPC");
|
|
2736
|
+
});
|
|
2737
|
+
/**
|
|
2738
|
+
* The explicit delegation tool, for asking Claude Code for something while
|
|
2739
|
+
* another model drives the session. It shares the runner the CLI-backed
|
|
2740
|
+
* models use.
|
|
2741
|
+
*/
|
|
2742
|
+
ctx.inject(["tools"], (toolsCtx) => {
|
|
2743
|
+
ctx.effect(() => toolsCtx.tools.register(defineTool({
|
|
2744
|
+
name: "claude_code",
|
|
2745
|
+
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.",
|
|
2746
|
+
parameters: {
|
|
2747
|
+
prompt: {
|
|
2748
|
+
type: "string",
|
|
2749
|
+
required: true,
|
|
2750
|
+
description: "The task for Claude Code, written as a self-contained instruction (it does not see this conversation)."
|
|
2751
|
+
},
|
|
2752
|
+
mode: {
|
|
2753
|
+
type: "string",
|
|
2754
|
+
enum: Object.keys(CLAUDE_CLI_MODES),
|
|
2755
|
+
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."
|
|
2756
|
+
},
|
|
2757
|
+
model: {
|
|
2758
|
+
type: "string",
|
|
2759
|
+
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."
|
|
2760
|
+
},
|
|
2761
|
+
cwd: {
|
|
2762
|
+
type: "string",
|
|
2763
|
+
description: "Working directory for the CLI. Defaults to this session's workspace; a relative path resolves against it."
|
|
2764
|
+
}
|
|
2765
|
+
},
|
|
2766
|
+
output: {
|
|
2767
|
+
schema: {
|
|
2768
|
+
type: "object",
|
|
2769
|
+
additionalProperties: false,
|
|
2770
|
+
properties: {
|
|
2771
|
+
result: {
|
|
2772
|
+
type: "string",
|
|
2773
|
+
required: true
|
|
2774
|
+
},
|
|
2775
|
+
model: {
|
|
2776
|
+
type: "string",
|
|
2777
|
+
required: true
|
|
2778
|
+
},
|
|
2779
|
+
mode: {
|
|
2780
|
+
type: "string",
|
|
2781
|
+
required: true
|
|
2782
|
+
},
|
|
2783
|
+
costUsd: {
|
|
2784
|
+
type: "number",
|
|
2785
|
+
required: true
|
|
2786
|
+
},
|
|
2787
|
+
durationMs: {
|
|
2788
|
+
type: "number",
|
|
2789
|
+
required: true
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
},
|
|
2793
|
+
render: (_args, value) => [{
|
|
2794
|
+
type: "text",
|
|
2795
|
+
text: `${value.result}\n\n— Claude Code CLI · ${value.model || "default model"} · ${value.mode} · ${Math.round(value.durationMs / 1e3)}s · $${value.costUsd.toFixed(4)}`
|
|
2796
|
+
}]
|
|
2797
|
+
},
|
|
2798
|
+
async execute(args, exec) {
|
|
2799
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd;
|
|
2800
|
+
const requested = typeof args.cwd === "string" ? args.cwd.trim() : "";
|
|
2801
|
+
const cwd = requested === "" ? sessionCwd : isAbsolute(requested) ? requested : sessionCwd === void 0 ? requested : join(sessionCwd, requested);
|
|
2802
|
+
return await claudeRunner.run({
|
|
2803
|
+
prompt: args.prompt,
|
|
2804
|
+
mode: capClaudeMode(args.mode, cliModeCeiling(exec?.agent?.session)) ?? "edit",
|
|
2805
|
+
model: args.model,
|
|
2806
|
+
cwd,
|
|
2807
|
+
signal: exec?.signal
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
})), "packycode-api: claude_code tool");
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
//#endregion
|
|
2814
|
+
export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLI_BACKED_MODEL_IDS, Config, DEFAULT_API_HOST, DEFAULT_STATUS_HOST, DISPLAY_NAME, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SETTINGS_NAMESPACE, TITLE_MODEL_ID, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, capClaudeMode, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeRunner, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseTokenUsage, readClaudeStreamEvents, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, summarizeToolUse };
|