dsh-packycode-api 1.0.3 → 1.0.5
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/CHANGELOG.md +13 -0
- package/README.en.md +13 -4
- package/README.md +13 -4
- package/lib/client.js +253 -24
- package/lib/index.js +1177 -221
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -8,6 +8,8 @@ import { homedir, tmpdir } from "node:os";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
10
10
|
import { credentialKey } from "@deepseek-ai/dsh-credentials";
|
|
11
|
+
import { readFile, readdir, realpath } from "node:fs/promises";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
11
13
|
import { createProvider } from "@earendil-works/pi-ai";
|
|
12
14
|
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
13
15
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
@@ -39,8 +41,17 @@ const DEFAULT_API_HOST = "https://cf.api.fan";
|
|
|
39
41
|
/**
|
|
40
42
|
* Host carrying `/api/usage/token/`. `cf.api.fan` answers 404 for the whole
|
|
41
43
|
* `/api/*` family, so per-key quota is read from the PackyAPI host instead.
|
|
44
|
+
* PackyAPI now runs on `.ai` (its site and docs moved there); the `.com` name
|
|
45
|
+
* is DNS-poisoned on mainland networks, so a host whose fetch does not go
|
|
46
|
+
* through a proxy reads `fetch failed` from it.
|
|
42
47
|
*/
|
|
43
|
-
const DEFAULT_STATUS_HOST = "https://www.packyapi.
|
|
48
|
+
const DEFAULT_STATUS_HOST = "https://www.packyapi.ai";
|
|
49
|
+
/**
|
|
50
|
+
* Earlier default status hosts. A profile that saved one keeps working, but
|
|
51
|
+
* the current default is tried before it, so an unreachable legacy name
|
|
52
|
+
* cannot hold a read up for its whole timeout first.
|
|
53
|
+
*/
|
|
54
|
+
const LEGACY_STATUS_HOSTS = Object.freeze(["https://www.packyapi.com"]);
|
|
44
55
|
/** PackyAPI quotes balances in quota units of 1/500000 USD (new-api convention). */
|
|
45
56
|
const QUOTA_PER_USD = 5e5;
|
|
46
57
|
/** Balance cache lifetime; a forced read bypasses it. */
|
|
@@ -59,7 +70,8 @@ const DEFAULT_REASONING_LEVEL = "high";
|
|
|
59
70
|
* the account. Only the second family exists on the model host (`cf.api.fan`
|
|
60
71
|
* answers 404 for `/api/*`), so a read tries the configured host first and
|
|
61
72
|
* falls back to the PackyAPI host — the same fallback the PackyCode status
|
|
62
|
-
* tooling uses.
|
|
73
|
+
* tooling uses. A saved legacy status host (`www.packyapi.com`, DNS-poisoned
|
|
74
|
+
* on mainland networks) is tried after the current default, not before it.
|
|
63
75
|
*
|
|
64
76
|
* Credentials and response bodies stay host-side; the browser receives only
|
|
65
77
|
* the projection built here.
|
|
@@ -67,7 +79,7 @@ const DEFAULT_REASONING_LEVEL = "high";
|
|
|
67
79
|
* @module dsh-packycode-api/balance
|
|
68
80
|
*/
|
|
69
81
|
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
70
|
-
const clone = (value) => structuredClone(value);
|
|
82
|
+
const clone$1 = (value) => structuredClone(value);
|
|
71
83
|
function number(value) {
|
|
72
84
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
73
85
|
if (typeof value === "string" && value.trim() !== "") {
|
|
@@ -137,22 +149,71 @@ function parseBillingUsage(payload) {
|
|
|
137
149
|
function statusMessage(status) {
|
|
138
150
|
if (status === 401) return "this API key was rejected (401)";
|
|
139
151
|
if (status === 402 || status === 403) return "the account cannot be read with this key (402/403)";
|
|
152
|
+
if (status === 429) return "the gateway is rate limiting balance reads (429)";
|
|
140
153
|
return `the gateway answered HTTP ${status}`;
|
|
141
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Say why a request never got an answer. Node's fetch reports every transport
|
|
157
|
+
* fault as a bare `fetch failed`; the cause carries the part a reader can act
|
|
158
|
+
* on (a DNS failure, a refused or reset connection, a certificate problem).
|
|
159
|
+
*/
|
|
160
|
+
function transportMessage(error, timeoutSignal, timeoutMs) {
|
|
161
|
+
if (timeoutSignal.aborted) return `no answer within ${Math.round(timeoutMs / 1e3)}s`;
|
|
162
|
+
const cause = error?.cause;
|
|
163
|
+
const code = typeof cause?.code === "string" ? cause.code : typeof error?.code === "string" ? error.code : void 0;
|
|
164
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
165
|
+
return code === void 0 ? message : `${message} (${code})`;
|
|
166
|
+
}
|
|
142
167
|
async function getJson(url, key, { fetchImpl, timeoutMs, signal }) {
|
|
143
168
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
144
169
|
const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
170
|
+
let response;
|
|
171
|
+
try {
|
|
172
|
+
response = await fetchImpl(url, {
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: `Bearer ${key}`,
|
|
175
|
+
Accept: "application/json"
|
|
176
|
+
},
|
|
177
|
+
redirect: "error",
|
|
178
|
+
signal: requestSignal
|
|
179
|
+
});
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (signal?.aborted === true) throw error;
|
|
182
|
+
throw new Error(transportMessage(error, timeoutSignal, timeoutMs), { cause: error });
|
|
183
|
+
}
|
|
153
184
|
if (!response.ok) throw new Error(statusMessage(response.status));
|
|
154
185
|
return response.json();
|
|
155
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The status hosts one per-key read tries, in order: an explicit override,
|
|
189
|
+
* the current default, the legacy defaults, then the model hosts.
|
|
190
|
+
* @param hosts - the resolved host settings.
|
|
191
|
+
* @returns unique origins.
|
|
192
|
+
*/
|
|
193
|
+
function statusHostOrder(hosts) {
|
|
194
|
+
const configured = hosts?.statusHost;
|
|
195
|
+
const override = configured === void 0 || LEGACY_STATUS_HOSTS.includes(configured) ? [] : [configured];
|
|
196
|
+
return [...new Set([
|
|
197
|
+
...override,
|
|
198
|
+
DEFAULT_STATUS_HOST,
|
|
199
|
+
...LEGACY_STATUS_HOSTS,
|
|
200
|
+
hosts?.apiHost,
|
|
201
|
+
DEFAULT_API_HOST
|
|
202
|
+
].filter((host) => typeof host === "string" && host !== ""))];
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The hosts the account read tries: the model hosts first (they serve the
|
|
206
|
+
* billing endpoints), then the status hosts.
|
|
207
|
+
* @param hosts - the resolved host settings.
|
|
208
|
+
* @returns unique origins.
|
|
209
|
+
*/
|
|
210
|
+
function accountHostOrder(hosts) {
|
|
211
|
+
return [...new Set([
|
|
212
|
+
hosts?.apiHost,
|
|
213
|
+
DEFAULT_API_HOST,
|
|
214
|
+
...statusHostOrder(hosts)
|
|
215
|
+
].filter((host) => typeof host === "string" && host !== ""))];
|
|
216
|
+
}
|
|
156
217
|
async function withFallback(hosts, path, key, options) {
|
|
157
218
|
const tried = [];
|
|
158
219
|
for (const host of hosts) try {
|
|
@@ -167,6 +228,12 @@ async function withFallback(hosts, path, key, options) {
|
|
|
167
228
|
}
|
|
168
229
|
/**
|
|
169
230
|
* Cached, single-flight balance reader.
|
|
231
|
+
*
|
|
232
|
+
* A read that fails for one key — the gateway throttled it, a host was
|
|
233
|
+
* unreachable — keeps showing the last balance that key did report, marked
|
|
234
|
+
* `stale` with when it was read and why this read failed, instead of blanking
|
|
235
|
+
* the row. The same holds for the account figures. These last-good values
|
|
236
|
+
* live in the host process: a page refresh keeps them, a restart does not.
|
|
170
237
|
* @param options - key listing, host resolution, and transport knobs.
|
|
171
238
|
* @returns `{ read({force, signal}), invalidate() }`.
|
|
172
239
|
*/
|
|
@@ -176,13 +243,11 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
176
243
|
let cached;
|
|
177
244
|
let inFlight;
|
|
178
245
|
let generation = 0;
|
|
246
|
+
/** Last successful per-key usage, by key id, and the last account figures. */
|
|
247
|
+
const lastGood = /* @__PURE__ */ new Map();
|
|
248
|
+
let lastAccount;
|
|
179
249
|
const loadKey = async (entry, hosts, signal) => {
|
|
180
|
-
const { payload } = await withFallback(
|
|
181
|
-
hosts.statusHost,
|
|
182
|
-
DEFAULT_STATUS_HOST,
|
|
183
|
-
hosts.apiHost,
|
|
184
|
-
DEFAULT_API_HOST
|
|
185
|
-
])], "/api/usage/token/", entry.key, {
|
|
250
|
+
const { payload } = await withFallback(statusHostOrder(hosts), "/api/usage/token/", entry.key, {
|
|
186
251
|
fetchImpl,
|
|
187
252
|
timeoutMs,
|
|
188
253
|
signal
|
|
@@ -205,12 +270,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
205
270
|
...keys.filter((entry) => entry.disabled !== true).map((entry) => entry.id)
|
|
206
271
|
])].map((id) => keys.find((entry) => entry.id === id)).filter((entry) => entry !== void 0);
|
|
207
272
|
if (candidates.length === 0) throw new Error("add a PackyCode API key to read the account balance");
|
|
208
|
-
const hostsToTry =
|
|
209
|
-
hosts.apiHost,
|
|
210
|
-
DEFAULT_API_HOST,
|
|
211
|
-
hosts.statusHost,
|
|
212
|
-
DEFAULT_STATUS_HOST
|
|
213
|
-
])];
|
|
273
|
+
const hostsToTry = accountHostOrder(hosts);
|
|
214
274
|
const options = {
|
|
215
275
|
fetchImpl,
|
|
216
276
|
timeoutMs,
|
|
@@ -237,6 +297,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
237
297
|
signal?.throwIfAborted();
|
|
238
298
|
const hosts = getHosts();
|
|
239
299
|
const keys = await listKeys();
|
|
300
|
+
for (const id of lastGood.keys()) if (!keys.some((entry) => entry.id === id)) lastGood.delete(id);
|
|
240
301
|
const entries = [];
|
|
241
302
|
const usableIds = [];
|
|
242
303
|
for (const entry of keys) {
|
|
@@ -246,23 +307,50 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
246
307
|
disabled: entry.disabled === true
|
|
247
308
|
};
|
|
248
309
|
try {
|
|
310
|
+
const usage = await loadKey(entry, hosts, signal);
|
|
311
|
+
lastGood.set(entry.id, {
|
|
312
|
+
usage,
|
|
313
|
+
fetchedAt: now()
|
|
314
|
+
});
|
|
249
315
|
entries.push({
|
|
250
316
|
...base,
|
|
251
|
-
usage
|
|
317
|
+
usage
|
|
252
318
|
});
|
|
253
319
|
usableIds.push(entry.id);
|
|
254
320
|
} catch (error) {
|
|
255
|
-
|
|
321
|
+
signal?.throwIfAborted();
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
const previous = lastGood.get(entry.id);
|
|
324
|
+
entries.push(previous === void 0 ? {
|
|
256
325
|
...base,
|
|
257
|
-
error:
|
|
326
|
+
error: message
|
|
327
|
+
} : {
|
|
328
|
+
...base,
|
|
329
|
+
usage: previous.usage,
|
|
330
|
+
stale: {
|
|
331
|
+
fetchedAt: previous.fetchedAt,
|
|
332
|
+
error: message
|
|
333
|
+
}
|
|
258
334
|
});
|
|
259
335
|
}
|
|
260
336
|
}
|
|
261
337
|
let account;
|
|
262
338
|
try {
|
|
263
339
|
account = await loadAccount(keys, hosts, signal, usableIds);
|
|
340
|
+
lastAccount = {
|
|
341
|
+
account,
|
|
342
|
+
fetchedAt: now()
|
|
343
|
+
};
|
|
264
344
|
} catch (error) {
|
|
265
|
-
|
|
345
|
+
signal?.throwIfAborted();
|
|
346
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
347
|
+
account = lastAccount === void 0 || !keys.some((entry) => entry.id === lastAccount.account.keyId) ? { error: message } : {
|
|
348
|
+
...lastAccount.account,
|
|
349
|
+
stale: {
|
|
350
|
+
fetchedAt: lastAccount.fetchedAt,
|
|
351
|
+
error: message
|
|
352
|
+
}
|
|
353
|
+
};
|
|
266
354
|
}
|
|
267
355
|
return {
|
|
268
356
|
keys: entries,
|
|
@@ -274,7 +362,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
274
362
|
return Object.freeze({
|
|
275
363
|
async read({ force = false, signal } = {}) {
|
|
276
364
|
signal?.throwIfAborted();
|
|
277
|
-
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone(cached);
|
|
365
|
+
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone$1(cached);
|
|
278
366
|
const createPending = (requestSignal) => {
|
|
279
367
|
const observedGeneration = generation;
|
|
280
368
|
return load(requestSignal).then((value) => {
|
|
@@ -282,7 +370,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
282
370
|
return value;
|
|
283
371
|
});
|
|
284
372
|
};
|
|
285
|
-
if (force) return clone(await createPending(signal));
|
|
373
|
+
if (force) return clone$1(await createPending(signal));
|
|
286
374
|
const pending = inFlight ?? createPending();
|
|
287
375
|
if (inFlight === void 0) {
|
|
288
376
|
inFlight = pending;
|
|
@@ -291,7 +379,7 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
291
379
|
};
|
|
292
380
|
pending.then(clear, clear);
|
|
293
381
|
}
|
|
294
|
-
return clone(await pending);
|
|
382
|
+
return clone$1(await pending);
|
|
295
383
|
},
|
|
296
384
|
invalidate() {
|
|
297
385
|
generation += 1;
|
|
@@ -300,6 +388,147 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
300
388
|
});
|
|
301
389
|
}
|
|
302
390
|
//#endregion
|
|
391
|
+
//#region src/failures.js
|
|
392
|
+
/**
|
|
393
|
+
* Failure classification for the two conditions that move the key ring along.
|
|
394
|
+
*
|
|
395
|
+
* The gateway reports an exhausted key and a rejected key in Chinese, and the
|
|
396
|
+
* harness classifies failures by fixed English/status wording it recognizes
|
|
397
|
+
* (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
|
|
398
|
+
* harness's retry policy re-run the step — and therefore what lets the retry
|
|
399
|
+
* pick up the next key. Rewriting is honest about what happened: this plugin
|
|
400
|
+
* knows the key's condition from the gateway's own words plus its balance
|
|
401
|
+
* reader, so the sentence it substitutes says no more than that.
|
|
402
|
+
*
|
|
403
|
+
* @module dsh-packycode-api/failures
|
|
404
|
+
*/
|
|
405
|
+
/** Terminal quota, balance, or credit wording, in the gateway's own languages. */
|
|
406
|
+
const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
|
|
407
|
+
/** A rejected key names itself; a bare 403 does not, and must not disable one. */
|
|
408
|
+
const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
|
|
409
|
+
const INVALID_KEY_STATUS = /\b401\b/;
|
|
410
|
+
/**
|
|
411
|
+
* A gateway group that only serves its vendor's official client. These arrive
|
|
412
|
+
* as 403, which the harness reads as an authentication failure and renders as
|
|
413
|
+
* an invalid-API-key problem — the opposite of what happened, and a condition
|
|
414
|
+
* no retry can clear.
|
|
415
|
+
*/
|
|
416
|
+
const CLIENT_POLICY_WORDING = /only accessible via the official|only accepts requests from the official|official\s+claude(?:\s+code)?\s+cli|官方\s*claude(?:\s*code)?\s*cli|请使用标准\s*codex\s*客户端|客户端存在异常|二次分发|API\s*转接/iu;
|
|
417
|
+
/** The key's quota is exhausted; move to the next key. */
|
|
418
|
+
const QUOTA_FAILURE = "quota";
|
|
419
|
+
/** The gateway rejected the key itself; move to the next key. */
|
|
420
|
+
const INVALID_KEY_FAILURE = "invalid-key";
|
|
421
|
+
/**
|
|
422
|
+
* Whether the gateway is refusing this client rather than this credential.
|
|
423
|
+
* @param message - provider failure text, as pi-ai carries it.
|
|
424
|
+
* @returns true when the route is gated to a vendor's official client.
|
|
425
|
+
*/
|
|
426
|
+
function classifyClientPolicy(message) {
|
|
427
|
+
return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Classify a gateway failure message.
|
|
431
|
+
*
|
|
432
|
+
* Quota wording wins over a status token: an exhausted balance is often
|
|
433
|
+
* reported as 402/403, and only the key's own rejection may read as 401. A
|
|
434
|
+
* client-policy refusal is not a key fault and is classified separately.
|
|
435
|
+
* @param message - provider failure text, as pi-ai carries it.
|
|
436
|
+
* @returns the failure kind, or undefined when the key is not at fault.
|
|
437
|
+
*/
|
|
438
|
+
function classifyGatewayFailure(message) {
|
|
439
|
+
const text = typeof message === "string" ? message : "";
|
|
440
|
+
if (text === "") return void 0;
|
|
441
|
+
if (classifyClientPolicy(text)) return void 0;
|
|
442
|
+
if (QUOTA_WORDING.test(text)) return QUOTA_FAILURE;
|
|
443
|
+
if (INVALID_KEY_WORDING.test(text)) return INVALID_KEY_FAILURE;
|
|
444
|
+
if (INVALID_KEY_STATUS.test(text)) return INVALID_KEY_FAILURE;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
448
|
+
*
|
|
449
|
+
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
450
|
+
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
451
|
+
* 401/403 before it reads any wording, which would send the failure into a
|
|
452
|
+
* category the retry policy does not re-run.
|
|
453
|
+
* @param message - the original provider failure text.
|
|
454
|
+
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
455
|
+
* @returns text the harness routes to QUOTA or AUTH.
|
|
456
|
+
*/
|
|
457
|
+
/**
|
|
458
|
+
* Explain a client-policy refusal so the user reads the real cause.
|
|
459
|
+
*
|
|
460
|
+
* The sentence must not read as any other failure the harness knows: it
|
|
461
|
+
* deliberately carries no HTTP status and none of the words the classifier
|
|
462
|
+
* matches for auth, quota, rate limits, timeouts, or transport faults. That
|
|
463
|
+
* keeps the failure out of the retry policy — no retry can talk a group into
|
|
464
|
+
* serving an API client — and lets the sentence reach the user verbatim, in
|
|
465
|
+
* place of the "API key is invalid" the bare 403 would have produced.
|
|
466
|
+
* @param keyName - the account name the balance reader saw, when known.
|
|
467
|
+
* @returns the replacement sentence.
|
|
468
|
+
*/
|
|
469
|
+
function explainClientPolicyFailure() {
|
|
470
|
+
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.";
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* The gateway is out of capacity for the moment: its Claude pool's
|
|
474
|
+
* per-account wait queue is full, or it throttled or shed the request. The
|
|
475
|
+
* CLI retries these on its own; this matches what is left when it gave up.
|
|
476
|
+
*/
|
|
477
|
+
const CAPACITY_WORDING = /concurrency_queue_full|并发排队已满|wait queues? (?:are|is) full|负载已饱和|overloaded|rate[_\s-]?limit|API Error:\s*(?:429|502|503|504|529)\b/iu;
|
|
478
|
+
/**
|
|
479
|
+
* Whether a CLI failure is the gateway being busy rather than a fault of the
|
|
480
|
+
* key, the client, or the request.
|
|
481
|
+
* @param message - the CLI failure text.
|
|
482
|
+
* @returns true for throttling and capacity refusals.
|
|
483
|
+
*/
|
|
484
|
+
function classifyCapacity(message) {
|
|
485
|
+
return CAPACITY_WORDING.test(typeof message === "string" ? message : "");
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Explain a CLI turn that failed because the gateway stayed busy.
|
|
489
|
+
* @param message - the CLI failure text.
|
|
490
|
+
* @param retries - how many retries the CLI reported before giving up.
|
|
491
|
+
* @returns the replacement sentence, with the gateway's own words kept.
|
|
492
|
+
*/
|
|
493
|
+
function explainCliCapacityFailure(message, retries) {
|
|
494
|
+
const detail = (typeof message === "string" ? message : "").replace(/^Claude Code CLI failed:\s*/u, "").slice(0, 600);
|
|
495
|
+
return `PackyCode 的 Claude 上游暂时繁忙(并发排队已满或被限流)。${typeof retries === "number" && retries > 0 ? `Claude Code CLI 已自动重试 ${retries} 次仍未成功。` : ""}这不是 API Key 或插件的问题,稍后再发一次即可;着急的话可以先切到 gpt-6.1-sol 或 deepseek-flash。 / PackyCode's Claude upstream is busy right now; retry shortly. Gateway said: ${detail}`;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Explain a client-policy refusal of a request the local Claude Code CLI made.
|
|
499
|
+
*
|
|
500
|
+
* The request did come from the official CLI, so the HTTP-route advice above
|
|
501
|
+
* (switch to a CLI) does not apply. The gateway's own words are kept, without
|
|
502
|
+
* their HTTP status, so a support request can quote the request ids.
|
|
503
|
+
* @param message - the CLI failure text.
|
|
504
|
+
* @returns the replacement sentence.
|
|
505
|
+
*/
|
|
506
|
+
function explainCliClientPolicyFailure(message) {
|
|
507
|
+
return `PackyCode 的上游拒绝了这次 Claude Code CLI 请求,认为它不是官方 Claude Code CLI 发出的。这不是 API Key 失效。请先把插件和 Claude Code CLI 都更新到最新版本再试;仍然失败时,请把下面的原始错误(含 request id)发给 PackyCode 客服,确认所用分组是否支持 Claude Code 的 -p(非交互)模式。 / The gateway refused this Claude Code CLI request as not coming from the official CLI; the API key is fine. Gateway said: ${(typeof message === "string" ? message : "").replace(/^Claude Code CLI failed:\s*/u, "").replace(/\b(?:401|403)\b/gu, "HTTP").slice(0, 600)}`;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Rewrite a failure message so the harness classifies it as quota or auth.
|
|
511
|
+
*
|
|
512
|
+
* A bare status token is neutralized first: an exhausted balance arrives as
|
|
513
|
+
* 402 or 403 on some gateway routes, and the harness's auth rule matches
|
|
514
|
+
* 401/403 before it reads any wording, which would send the failure into a
|
|
515
|
+
* category the retry policy does not re-run.
|
|
516
|
+
* @param message - the original provider failure text.
|
|
517
|
+
* @param kind - the classification of {@link classifyGatewayFailure}.
|
|
518
|
+
* @param options - optional account name for the policy explanation.
|
|
519
|
+
* @returns text the harness routes to QUOTA or AUTH, or a policy explanation.
|
|
520
|
+
*/
|
|
521
|
+
function normalizeFailureMessage(message, kind, options = {}) {
|
|
522
|
+
const text = typeof message === "string" && message.length > 0 ? message : "PackyCode request failed";
|
|
523
|
+
if (kind === "client-policy") return explainClientPolicyFailure(options.keyName);
|
|
524
|
+
if (kind === "quota") return `${neutralizeAuthStatus(text)} — PackyCode: insufficient quota for this API key; trying the next key`;
|
|
525
|
+
if (kind === "invalid-key") return `${neutralizeAuthStatus(text)} — PackyCode: 401 invalid_authentication for this API key; trying the next key`;
|
|
526
|
+
return text;
|
|
527
|
+
}
|
|
528
|
+
function neutralizeAuthStatus(text) {
|
|
529
|
+
return text.replace(/\b(?:401|403)\b/gu, "HTTP");
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
303
532
|
//#region src/claude-adapter.js
|
|
304
533
|
/**
|
|
305
534
|
* Route Claude models through the local Claude Code CLI.
|
|
@@ -312,9 +541,12 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = fetch, now = Date
|
|
|
312
541
|
* A CLI-backed turn is a *delegation*: the CLI runs its own agent loop with
|
|
313
542
|
* its own tools and returns one answer. The CLI cannot call the harness's
|
|
314
543
|
* tools, so `options.tools` is deliberately not forwarded, and the harness's
|
|
315
|
-
* system prompt is dropped in favour of the CLI's own.
|
|
316
|
-
*
|
|
317
|
-
*
|
|
544
|
+
* system prompt is dropped in favour of the CLI's own. The delegation
|
|
545
|
+
* instructions travel as `--append-system-prompt`; the recent conversation
|
|
546
|
+
* travels as one user message whose images are real image blocks, read from
|
|
547
|
+
* the attachment store, so the CLI's model sees them as images. While it
|
|
548
|
+
* works, its thinking and tool activity stream into a reasoning block and its
|
|
549
|
+
* answer streams as text.
|
|
318
550
|
*
|
|
319
551
|
* A session-title request is not a delegation: it runs on the small model,
|
|
320
552
|
* read-only and outside the workspace, with the title instructions appended,
|
|
@@ -333,15 +565,344 @@ const TITLE_MODEL_ID = "claude-haiku-5-5";
|
|
|
333
565
|
/** How much of the conversation a delegated prompt carries. */
|
|
334
566
|
const MAX_PROMPT_CHARS$1 = 48e3;
|
|
335
567
|
const MAX_BLOCK_CHARS = 8e3;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
568
|
+
const TRUNCATED_MARK = "…(较早对话已截断)";
|
|
569
|
+
/**
|
|
570
|
+
* Image bounds for one delegated turn. Each image is re-encoded by the
|
|
571
|
+
* attachment store to the same request version the HTTP route sends (at most
|
|
572
|
+
* 2048×2048 pixels, about 1 MiB); the oldest images are left out once the turn
|
|
573
|
+
* would carry more than 20 of them or 20 MiB of base64.
|
|
574
|
+
*/
|
|
575
|
+
const CLI_IMAGE_LIMITS = Object.freeze({
|
|
576
|
+
maxPixels: 2048 * 2048,
|
|
577
|
+
maxBytesPerImage: 1024 * 1024,
|
|
578
|
+
maxTotalBase64Bytes: 20 * 1024 * 1024,
|
|
579
|
+
maxImages: 20
|
|
580
|
+
});
|
|
581
|
+
/** The vendor's per-image cap on base64 data; a fallback read above it is not sent. */
|
|
582
|
+
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024;
|
|
583
|
+
const base64Length = (bytes) => Math.ceil(bytes / 3) * 4;
|
|
584
|
+
/** A short name for an image or file a reader can match to the chat. */
|
|
585
|
+
function attachmentName(ref) {
|
|
586
|
+
if (typeof ref?.name === "string" && ref.name !== "") return ref.name;
|
|
587
|
+
const id = String(ref?.attachmentId ?? "");
|
|
588
|
+
return id.startsWith("sha256:") ? id.slice(7, 15) : id.slice(0, 8);
|
|
589
|
+
}
|
|
590
|
+
/** The host path of a stored attachment, when the store is file-backed. */
|
|
591
|
+
function hostPath(lookup) {
|
|
592
|
+
try {
|
|
593
|
+
const value = lookup();
|
|
594
|
+
return typeof value === "string" && value !== "" ? value : void 0;
|
|
595
|
+
} catch {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Flatten one message's content into ordered parts: text, image, and file.
|
|
601
|
+
* Tool calls and results become short text so a turn that started on another
|
|
602
|
+
* model still reads coherently; reasoning is not carried.
|
|
603
|
+
*/
|
|
604
|
+
function collectParts(blocks, parts) {
|
|
605
|
+
for (const block of Array.isArray(blocks) ? blocks : []) {
|
|
606
|
+
if (block === null || typeof block !== "object") continue;
|
|
607
|
+
switch (block.type) {
|
|
608
|
+
case "text":
|
|
609
|
+
if (typeof block.text === "string" && block.text !== "") parts.push({
|
|
610
|
+
kind: "text",
|
|
611
|
+
text: block.text
|
|
612
|
+
});
|
|
613
|
+
break;
|
|
614
|
+
case "image":
|
|
615
|
+
if (block.attachment !== void 0) parts.push({
|
|
616
|
+
kind: "image",
|
|
617
|
+
ref: block.attachment
|
|
618
|
+
});
|
|
619
|
+
else parts.push({
|
|
620
|
+
kind: "text",
|
|
621
|
+
text: "[图片]"
|
|
622
|
+
});
|
|
623
|
+
break;
|
|
624
|
+
case "file":
|
|
625
|
+
if (block.attachment !== void 0) parts.push({
|
|
626
|
+
kind: "file",
|
|
627
|
+
ref: block.attachment
|
|
628
|
+
});
|
|
629
|
+
break;
|
|
630
|
+
case "tool-call":
|
|
631
|
+
case "tool-call-delta":
|
|
632
|
+
parts.push({
|
|
633
|
+
kind: "text",
|
|
634
|
+
text: `[调用工具 ${block.name ?? block.id ?? ""}]`
|
|
635
|
+
});
|
|
636
|
+
break;
|
|
637
|
+
case "tool-result": {
|
|
638
|
+
if (typeof block.content === "string") {
|
|
639
|
+
parts.push({
|
|
640
|
+
kind: "text",
|
|
641
|
+
text: `[工具结果] ${block.content.slice(0, MAX_BLOCK_CHARS)}`
|
|
642
|
+
});
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
const nested = [];
|
|
646
|
+
collectParts(block.content, nested);
|
|
647
|
+
let budget = MAX_BLOCK_CHARS;
|
|
648
|
+
parts.push({
|
|
649
|
+
kind: "text",
|
|
650
|
+
text: "[工具结果]"
|
|
651
|
+
});
|
|
652
|
+
for (const part of nested) {
|
|
653
|
+
if (part.kind !== "text") {
|
|
654
|
+
parts.push(part);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (budget <= 0) continue;
|
|
658
|
+
parts.push({
|
|
659
|
+
kind: "text",
|
|
660
|
+
text: part.text.slice(0, budget)
|
|
661
|
+
});
|
|
662
|
+
budget -= part.text.length;
|
|
663
|
+
}
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
default: break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return parts;
|
|
670
|
+
}
|
|
671
|
+
const textLength = (parts) => parts.reduce((sum, part) => sum + (part.kind === "text" ? part.text.length : 0), 0);
|
|
672
|
+
/**
|
|
673
|
+
* The conversation as labelled turns, newest last, trimmed to the prompt
|
|
674
|
+
* budget by dropping whole older turns; a single newest turn that is larger
|
|
675
|
+
* than the budget keeps its tail.
|
|
676
|
+
*/
|
|
677
|
+
function delegatedTurns(options) {
|
|
678
|
+
const messages = Array.isArray(options?.messages) ? options.messages : [];
|
|
679
|
+
const turns = [];
|
|
680
|
+
for (const message of messages) {
|
|
681
|
+
if (message === null || typeof message !== "object" || message.role === "system") continue;
|
|
682
|
+
const parts = collectParts(message.content, []);
|
|
683
|
+
if (parts.length === 0 || textLength(parts) === 0 && !parts.some((part) => part.kind !== "text")) continue;
|
|
684
|
+
const label = message.role === "user" ? "用户" : message.role === "assistant" ? "助手" : String(message.role);
|
|
685
|
+
turns.push({
|
|
686
|
+
label,
|
|
687
|
+
parts
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
const kept = [];
|
|
691
|
+
let used = 0;
|
|
692
|
+
for (let index = turns.length - 1; index >= 0; index -= 1) {
|
|
693
|
+
const turn = turns[index];
|
|
694
|
+
const size = textLength(turn.parts) + turn.label.length + 3;
|
|
695
|
+
if (used + size <= MAX_PROMPT_CHARS$1) {
|
|
696
|
+
kept.unshift(turn);
|
|
697
|
+
used += size;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (kept.length === 0) kept.unshift({
|
|
701
|
+
label: turn.label,
|
|
702
|
+
parts: tailOf(turn.parts, MAX_PROMPT_CHARS$1 - turn.label.length - 3),
|
|
703
|
+
clipped: true
|
|
704
|
+
});
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
return {
|
|
708
|
+
turns: kept,
|
|
709
|
+
truncated: kept.length < turns.length || kept[0]?.clipped === true
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/** Keep the last `budget` characters of a turn's text, and every part after them. */
|
|
713
|
+
function tailOf(parts, budget) {
|
|
714
|
+
const result = [];
|
|
715
|
+
let remaining = budget;
|
|
716
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
717
|
+
const part = parts[index];
|
|
718
|
+
if (part.kind !== "text") {
|
|
719
|
+
result.unshift(part);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (remaining <= 0) continue;
|
|
723
|
+
result.unshift({
|
|
724
|
+
kind: "text",
|
|
725
|
+
text: part.text.length > remaining ? part.text.slice(-remaining) : part.text
|
|
726
|
+
});
|
|
727
|
+
remaining -= part.text.length;
|
|
728
|
+
}
|
|
729
|
+
return result;
|
|
730
|
+
}
|
|
731
|
+
/** How a part reads when it is carried as text only. */
|
|
732
|
+
function partText(part, describe) {
|
|
733
|
+
if (part.kind === "text") return part.text;
|
|
734
|
+
return describe(part);
|
|
735
|
+
}
|
|
736
|
+
/** Render turns as one transcript, with non-text parts described by `describe`. */
|
|
737
|
+
function renderTurns({ turns, truncated }, describe) {
|
|
738
|
+
const transcript = turns.map((turn) => `${turn.label}:${turn.parts.map((part) => partText(part, describe)).join("\n").trim()}`).join("\n\n");
|
|
739
|
+
return truncated ? `${TRUNCATED_MARK}\n\n${transcript}` : transcript;
|
|
740
|
+
}
|
|
741
|
+
const plainDescription = (part) => part.kind === "image" ? `[图片 ${attachmentName(part.ref)}]` : `[文件 ${attachmentName(part.ref)}]`;
|
|
742
|
+
/**
|
|
743
|
+
* The delegation instructions, appended to the CLI's own system prompt.
|
|
744
|
+
*
|
|
745
|
+
* The harness system prompt describes tools this model cannot call, so only
|
|
746
|
+
* its opening is passed on, marked as context.
|
|
747
|
+
* @param options - the assembled request.
|
|
748
|
+
* @param cwd - the working directory the CLI will run in, when known.
|
|
749
|
+
* @returns the instructions text.
|
|
750
|
+
*/
|
|
751
|
+
function delegationInstructions(options, cwd) {
|
|
752
|
+
const system = typeof options?.system === "string" && options.system.trim() !== "" ? options.system.trim() : "";
|
|
753
|
+
return [
|
|
754
|
+
"本回合由 DeepSeek Harness 委派给 Claude Code 执行。用户消息是 DeepSeek Harness 会话的近期对话记录,最后一条是当前请求;其中的图片已作为图片随消息附上,「只读副本」路径也可以用 Read 工具读取。请用你自己的工具完成任务,直接输出最终结果;不要反问、不要复述流程。",
|
|
755
|
+
cwd === void 0 ? void 0 : `工作目录:${cwd}`,
|
|
756
|
+
system === "" ? void 0 : `补充说明(来自 DeepSeek Harness,其中提到的工具你未必拥有):${system.slice(0, 2e3)}`
|
|
757
|
+
].filter((part) => part !== void 0).join("\n");
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* The recent conversation as one text transcript. Images and files are named
|
|
761
|
+
* in place; {@link buildDelegatedContent} is what carries the images.
|
|
762
|
+
* @param options - the assembled request.
|
|
763
|
+
* @returns the transcript text.
|
|
764
|
+
*/
|
|
765
|
+
function renderDelegatedPrompt(options) {
|
|
766
|
+
return renderTurns(delegatedTurns(options), plainDescription).trim();
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* The recent conversation as content blocks for the CLI's stream-json input:
|
|
770
|
+
* the transcript, with every image that fits the turn's bounds as an image
|
|
771
|
+
* block at the place it was sent, labelled with its read-only host copy.
|
|
772
|
+
* An image that is left out, or that cannot be read, is described in text
|
|
773
|
+
* instead; it never fails the turn.
|
|
774
|
+
* @param options - the assembled request.
|
|
775
|
+
* @param context - the attachment store (`ctx.attachments`), image bounds,
|
|
776
|
+
* and cancellation.
|
|
777
|
+
* @returns text and image blocks, in conversation order.
|
|
778
|
+
*/
|
|
779
|
+
async function buildDelegatedContent(options, { attachments, limits = CLI_IMAGE_LIMITS, signal } = {}) {
|
|
780
|
+
const shape = delegatedTurns(options);
|
|
781
|
+
const images = shape.turns.flatMap((turn) => turn.parts.filter((part) => part.kind === "image"));
|
|
782
|
+
const pathOf = (ref) => hostPath(() => attachments?.imageHostPath?.(ref));
|
|
783
|
+
const filePathOf = (ref) => hostPath(() => attachments?.fileHostPath?.(ref));
|
|
784
|
+
const carried = /* @__PURE__ */ new Map();
|
|
785
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
786
|
+
const reads = /* @__PURE__ */ new Map();
|
|
787
|
+
let total = 0;
|
|
788
|
+
let count = 0;
|
|
789
|
+
let full = false;
|
|
790
|
+
for (const part of [...images].reverse()) {
|
|
791
|
+
if (attachments === void 0 || attachments === null) {
|
|
792
|
+
reasons.set(part, "unavailable");
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
if (full) {
|
|
796
|
+
reasons.set(part, "limit");
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
const id = String(part.ref.attachmentId);
|
|
800
|
+
try {
|
|
801
|
+
if (!reads.has(id)) reads.set(id, readRequestImage(attachments, part.ref, limits, signal));
|
|
802
|
+
const image = await reads.get(id);
|
|
803
|
+
const size = base64Length(image.data.byteLength);
|
|
804
|
+
if (size > MAX_IMAGE_BASE64_BYTES) {
|
|
805
|
+
reasons.set(part, "too-large");
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (count + 1 > limits.maxImages || total + size > limits.maxTotalBase64Bytes) {
|
|
809
|
+
full = true;
|
|
810
|
+
reasons.set(part, "limit");
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
count += 1;
|
|
814
|
+
total += size;
|
|
815
|
+
carried.set(part, image);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
if (signal?.aborted === true) throw error;
|
|
818
|
+
reasons.set(part, "unreadable");
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
const blocks = [];
|
|
822
|
+
let text = shape.truncated ? `${TRUNCATED_MARK}\n\n` : "";
|
|
823
|
+
const flush = () => {
|
|
824
|
+
if (text !== "") blocks.push({
|
|
825
|
+
type: "text",
|
|
826
|
+
text
|
|
827
|
+
});
|
|
828
|
+
text = "";
|
|
829
|
+
};
|
|
830
|
+
for (const [turnIndex, turn] of shape.turns.entries()) {
|
|
831
|
+
text += `${turnIndex === 0 ? "" : "\n\n"}${turn.label}:`;
|
|
832
|
+
for (const [partIndex, part] of turn.parts.entries()) {
|
|
833
|
+
const separator = partIndex === 0 ? "" : "\n";
|
|
834
|
+
if (part.kind === "text") {
|
|
835
|
+
text += separator + part.text;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (part.kind === "file") {
|
|
839
|
+
const path = filePathOf(part.ref);
|
|
840
|
+
text += `${separator}[文件 ${attachmentName(part.ref)}${path === void 0 ? "" : `;只读副本:${path}`}]`;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const path = pathOf(part.ref);
|
|
844
|
+
const copy = path === void 0 ? "" : `;只读副本:${path}`;
|
|
845
|
+
const image = carried.get(part);
|
|
846
|
+
if (image === void 0) {
|
|
847
|
+
text += `${separator}[图片 ${attachmentName(part.ref)}:${imageOmission(reasons.get(part))}${copy}]`;
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
text += `${separator}[图片 ${attachmentName(part.ref)}(${image.width}x${image.height}px)${copy}]`;
|
|
851
|
+
flush();
|
|
852
|
+
blocks.push({
|
|
853
|
+
type: "image",
|
|
854
|
+
source: {
|
|
855
|
+
type: "base64",
|
|
856
|
+
media_type: image.mediaType,
|
|
857
|
+
data: Buffer.from(image.data).toString("base64")
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
flush();
|
|
863
|
+
return blocks;
|
|
864
|
+
}
|
|
865
|
+
function imageOmission(reason) {
|
|
866
|
+
switch (reason) {
|
|
867
|
+
case "limit": return "超出本回合的图片上限,未随消息附上,需要时请用 Read 工具读取";
|
|
868
|
+
case "too-large": return "图片过大,未随消息附上,需要时请用 Read 工具读取";
|
|
869
|
+
case "unavailable": return "当前运行环境无法读取附件";
|
|
870
|
+
default: return "读取失败,未随消息附上";
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* One request-sized version of a stored image: the store's own request
|
|
875
|
+
* encoder when it has one, otherwise the stored normalized bytes.
|
|
876
|
+
*/
|
|
877
|
+
async function readRequestImage(attachments, ref, limits, signal) {
|
|
878
|
+
if (typeof attachments.readImageRequest === "function") {
|
|
879
|
+
const version = await attachments.readImageRequest(ref, {
|
|
880
|
+
maxPixels: limits.maxPixels,
|
|
881
|
+
maxBytes: limits.maxBytesPerImage
|
|
882
|
+
}, signal);
|
|
883
|
+
return {
|
|
884
|
+
data: version.data,
|
|
885
|
+
mediaType: version.mediaType ?? ref.mediaType,
|
|
886
|
+
width: version.width ?? ref.width,
|
|
887
|
+
height: version.height ?? ref.height
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const stored = await attachments.readImage(ref, signal);
|
|
891
|
+
return {
|
|
892
|
+
data: stored.data,
|
|
893
|
+
mediaType: stored.mediaType ?? ref.mediaType,
|
|
894
|
+
width: ref.width,
|
|
895
|
+
height: ref.height
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* The prompt of a one-shot auxiliary request: its messages' text, verbatim.
|
|
900
|
+
* The caller frames its own input, so nothing is added around it.
|
|
901
|
+
* @param options - the assembled request.
|
|
902
|
+
* @returns the prompt text.
|
|
903
|
+
*/
|
|
904
|
+
function renderAuxiliaryPrompt(options) {
|
|
905
|
+
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();
|
|
345
906
|
}
|
|
346
907
|
/**
|
|
347
908
|
* Emit text and reasoning as well-formed harness blocks.
|
|
@@ -428,43 +989,9 @@ function turnUsage(lastRequest, finished) {
|
|
|
428
989
|
cacheWriteTokens: finished?.cacheWriteTokens ?? 0
|
|
429
990
|
};
|
|
430
991
|
}
|
|
431
|
-
/**
|
|
432
|
-
|
|
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();
|
|
992
|
+
/** One line describing a retry the CLI is making on its own. */
|
|
993
|
+
function retryNote(event) {
|
|
994
|
+
return `网关返回 ${event.status === void 0 ? "出错" : `HTTP ${event.status}`}${typeof event.error === "string" && event.error !== "" ? `(${event.error})` : ""},CLI 自动重试${event.attempt === void 0 ? "" : event.maxRetries === void 0 ? ` ${event.attempt}` : ` ${event.attempt}/${event.maxRetries}`}${event.delayMs === void 0 ? "" : `,${(event.delayMs / 1e3).toFixed(1)} 秒后`}`;
|
|
468
995
|
}
|
|
469
996
|
/**
|
|
470
997
|
* How one request runs on the CLI: a title request on the small model with
|
|
@@ -473,7 +1000,7 @@ function renderAuxiliaryPrompt(options) {
|
|
|
473
1000
|
* The CLI's own system prompt and tool set stay in place even for a title:
|
|
474
1001
|
* they are part of what the gateway sees from the official client.
|
|
475
1002
|
*/
|
|
476
|
-
function cliCall(options, { titleModel, cwdOf, modeOf }) {
|
|
1003
|
+
async function cliCall(options, { titleModel, cwdOf, modeOf, attachmentsOf, imageLimits }) {
|
|
477
1004
|
if (options?.purpose === "session-title") return {
|
|
478
1005
|
prompt: renderAuxiliaryPrompt(options),
|
|
479
1006
|
model: titleModel,
|
|
@@ -483,7 +1010,12 @@ function cliCall(options, { titleModel, cwdOf, modeOf }) {
|
|
|
483
1010
|
};
|
|
484
1011
|
const cwd = cwdOf();
|
|
485
1012
|
return {
|
|
486
|
-
|
|
1013
|
+
content: await buildDelegatedContent(options, {
|
|
1014
|
+
attachments: attachmentsOf(),
|
|
1015
|
+
limits: imageLimits,
|
|
1016
|
+
signal: options?.signal
|
|
1017
|
+
}),
|
|
1018
|
+
appendSystemPrompt: delegationInstructions(options, cwd),
|
|
487
1019
|
model: options.model,
|
|
488
1020
|
mode: modeOf(),
|
|
489
1021
|
cwd
|
|
@@ -493,26 +1025,39 @@ function cliCall(options, { titleModel, cwdOf, modeOf }) {
|
|
|
493
1025
|
* Wrap an adapter so the CLI-backed models run through the local CLI.
|
|
494
1026
|
*
|
|
495
1027
|
* @param options - the inner adapter, the CLI runner, the CLI-backed model ids,
|
|
496
|
-
* the model session titles run on,
|
|
497
|
-
* turn runs with.
|
|
1028
|
+
* the model session titles run on, the mode and working directory each
|
|
1029
|
+
* turn runs with, and the attachment store images are read from.
|
|
498
1030
|
* @returns an adapter with the same provider surface.
|
|
499
1031
|
*/
|
|
500
|
-
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, resolveMode, resolveCwd }) {
|
|
1032
|
+
function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS, titleModel = TITLE_MODEL_ID, resolveMode, resolveCwd, resolveAttachments, imageLimits = CLI_IMAGE_LIMITS }) {
|
|
501
1033
|
if (inner === void 0 || inner === null) throw new Error("the CLI adapter needs an inner adapter");
|
|
502
1034
|
if (runner === void 0 || runner === null) throw new Error("the CLI adapter needs a runner");
|
|
503
1035
|
const cliModels = new Set(modelIds);
|
|
504
1036
|
/** The mode is read per turn so a settings change applies without a reload. */
|
|
505
1037
|
const modeOf = () => typeof resolveMode === "function" ? resolveMode() : void 0;
|
|
506
1038
|
const cwdOf = () => typeof resolveCwd === "function" ? resolveCwd() : void 0;
|
|
1039
|
+
const attachmentsOf = () => {
|
|
1040
|
+
try {
|
|
1041
|
+
return typeof resolveAttachments === "function" ? resolveAttachments() ?? void 0 : void 0;
|
|
1042
|
+
} catch {
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
};
|
|
507
1046
|
async function* cliStream(options) {
|
|
1047
|
+
const quiet = options?.purpose === "session-title";
|
|
1048
|
+
const blocks = createBlockWriter();
|
|
1049
|
+
if (!quiet) yield* blocks.note("▸ 启动 Claude Code CLI…\n");
|
|
508
1050
|
let call;
|
|
509
1051
|
try {
|
|
510
|
-
call = cliCall(options, {
|
|
1052
|
+
call = await cliCall(options, {
|
|
511
1053
|
titleModel,
|
|
512
1054
|
cwdOf,
|
|
513
|
-
modeOf
|
|
1055
|
+
modeOf,
|
|
1056
|
+
attachmentsOf,
|
|
1057
|
+
imageLimits
|
|
514
1058
|
});
|
|
515
1059
|
} catch (error) {
|
|
1060
|
+
yield* blocks.close();
|
|
516
1061
|
yield {
|
|
517
1062
|
type: "finish",
|
|
518
1063
|
reason: {
|
|
@@ -525,7 +1070,6 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
525
1070
|
};
|
|
526
1071
|
return;
|
|
527
1072
|
}
|
|
528
|
-
const blocks = createBlockWriter();
|
|
529
1073
|
const seen = /* @__PURE__ */ new Set();
|
|
530
1074
|
const firstSighting = (key) => {
|
|
531
1075
|
if (key.endsWith(":")) return true;
|
|
@@ -533,6 +1077,7 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
533
1077
|
seen.add(key);
|
|
534
1078
|
return true;
|
|
535
1079
|
};
|
|
1080
|
+
let retries = 0;
|
|
536
1081
|
try {
|
|
537
1082
|
let finished;
|
|
538
1083
|
let lastRequest;
|
|
@@ -540,6 +1085,16 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
540
1085
|
...call,
|
|
541
1086
|
signal: options.signal
|
|
542
1087
|
})) switch (event.type) {
|
|
1088
|
+
case "started":
|
|
1089
|
+
if (!quiet) {
|
|
1090
|
+
const detail = [event.version, event.model].filter((part) => typeof part === "string" && part !== "").join(" · ");
|
|
1091
|
+
yield* blocks.note(`▸ Claude Code CLI 已启动${detail === "" ? "" : `(${detail})`}\n`);
|
|
1092
|
+
}
|
|
1093
|
+
break;
|
|
1094
|
+
case "retry":
|
|
1095
|
+
retries = Math.max(retries, event.attempt ?? retries + 1);
|
|
1096
|
+
if (!quiet) yield* blocks.note(` ↻ ${retryNote(event)}\n`);
|
|
1097
|
+
break;
|
|
543
1098
|
case "block":
|
|
544
1099
|
blocks.boundary(event.kind);
|
|
545
1100
|
break;
|
|
@@ -573,13 +1128,15 @@ function createClaudeCliAdapter({ inner, runner, modelIds = CLI_BACKED_MODEL_IDS
|
|
|
573
1128
|
reason: { kind: "stop" }
|
|
574
1129
|
};
|
|
575
1130
|
} catch (error) {
|
|
1131
|
+
yield* blocks.close();
|
|
1132
|
+
const raw = String(error?.message ?? error);
|
|
576
1133
|
yield {
|
|
577
1134
|
type: "finish",
|
|
578
1135
|
reason: {
|
|
579
1136
|
kind: "error",
|
|
580
1137
|
failure: {
|
|
581
1138
|
code: "PI_AI_ERROR",
|
|
582
|
-
message:
|
|
1139
|
+
message: classifyClientPolicy(raw) ? explainCliClientPolicyFailure(raw) : classifyCapacity(raw) ? explainCliCapacityFailure(raw, retries) : raw
|
|
583
1140
|
}
|
|
584
1141
|
}
|
|
585
1142
|
};
|
|
@@ -743,8 +1300,32 @@ const DEFAULT_CLAUDE_MODE = "edit";
|
|
|
743
1300
|
/** Isolated CLI home; the plugin owns the endpoint and key it runs with. */
|
|
744
1301
|
const DEFAULT_CLAUDE_CONFIG_DIR = join(homedir(), ".dsh", "packycode-claude-cli");
|
|
745
1302
|
const DEFAULT_CLAUDE_TIMEOUT_MS = 1800 * 1e3;
|
|
746
|
-
/**
|
|
1303
|
+
/**
|
|
1304
|
+
* How long a streaming run may stay silent after starting. The CLI prints its
|
|
1305
|
+
* `system/init` line within a second or two once it runs; one that prints
|
|
1306
|
+
* nothing for this long is stuck before its first request and is stopped, so
|
|
1307
|
+
* the turn reports it instead of spinning until the overall timeout.
|
|
1308
|
+
*/
|
|
1309
|
+
const DEFAULT_CLAUDE_STARTUP_TIMEOUT_MS = 120 * 1e3;
|
|
1310
|
+
/**
|
|
1311
|
+
* Upper bound on one run's prompt text. The prompt travels over stdin, so this
|
|
1312
|
+
* is a sanity cap rather than an OS limit.
|
|
1313
|
+
*/
|
|
747
1314
|
const MAX_PROMPT_CHARS = 1e5;
|
|
1315
|
+
/** Upper bound on the images one run carries. */
|
|
1316
|
+
const MAX_INPUT_IMAGES = 20;
|
|
1317
|
+
/**
|
|
1318
|
+
* Instructions a run carries when its caller supplies none.
|
|
1319
|
+
*
|
|
1320
|
+
* In print mode the official CLI chooses its own identity line from whether
|
|
1321
|
+
* it was given appended instructions: with them it introduces itself to the
|
|
1322
|
+
* model as "Claude Code, Anthropic's official CLI for Claude, running within
|
|
1323
|
+
* the Claude Agent SDK"; without them as "a Claude agent, built on Anthropic's
|
|
1324
|
+
* Claude Agent SDK". PackyCode routes Claude only to Claude Code, and a run
|
|
1325
|
+
* without instructions was refused upstream as not coming from it, so every
|
|
1326
|
+
* run states what it was delegated to do.
|
|
1327
|
+
*/
|
|
1328
|
+
const DEFAULT_APPEND_SYSTEM_PROMPT = "This task was delegated to Claude Code by DeepSeek Harness. Complete it with your own tools and reply with the final result.";
|
|
748
1329
|
const KILL_GRACE_MS = 3e3;
|
|
749
1330
|
/** The npm package the official CLI ships as. */
|
|
750
1331
|
const CLAUDE_NPM_PACKAGE = "@anthropic-ai/claude-code";
|
|
@@ -860,34 +1441,74 @@ function resolveSpawnCommand(binary, { platform = process.platform, exists = exi
|
|
|
860
1441
|
}
|
|
861
1442
|
/**
|
|
862
1443
|
* Build the CLI arguments for one delegated task.
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
866
|
-
*
|
|
1444
|
+
*
|
|
1445
|
+
* The prompt is not among them: it travels over stdin (see
|
|
1446
|
+
* {@link buildClaudeInput}). An argv is bounded — about 32K UTF-16 units on
|
|
1447
|
+
* Windows — and cannot carry an image; stdin carries both.
|
|
1448
|
+
* @param options - the permission mode, an optional model, whether the caller
|
|
1449
|
+
* consumes the streaming protocol, and instructions appended to the CLI's
|
|
1450
|
+
* own system prompt ({@link DEFAULT_APPEND_SYSTEM_PROMPT} when blank). The
|
|
1451
|
+
* CLI's system prompt and tool set are never replaced: they are part of
|
|
1452
|
+
* what the official client sends.
|
|
867
1453
|
* @returns argv after the binary name.
|
|
868
1454
|
*/
|
|
869
|
-
function buildClaudeArgs({
|
|
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)`);
|
|
1455
|
+
function buildClaudeArgs({ mode = DEFAULT_CLAUDE_MODE, model, stream = false, appendSystemPrompt } = {}) {
|
|
873
1456
|
const spec = CLAUDE_CLI_MODES[mode];
|
|
874
1457
|
if (spec === void 0) throw new Error(`claude_code mode must be one of ${Object.keys(CLAUDE_CLI_MODES).join(", ")}`);
|
|
875
|
-
const args = [
|
|
1458
|
+
const args = stream ? [
|
|
1459
|
+
"-p",
|
|
1460
|
+
"--input-format",
|
|
1461
|
+
"stream-json",
|
|
1462
|
+
"--output-format",
|
|
1463
|
+
"stream-json",
|
|
1464
|
+
"--include-partial-messages",
|
|
1465
|
+
"--verbose"
|
|
1466
|
+
] : [
|
|
876
1467
|
"-p",
|
|
877
|
-
text,
|
|
878
1468
|
"--output-format",
|
|
879
|
-
|
|
1469
|
+
"json"
|
|
880
1470
|
];
|
|
881
|
-
if (stream) args.push("--include-partial-messages", "--verbose");
|
|
882
1471
|
const named = typeof model === "string" ? model.trim() : "";
|
|
883
1472
|
if (named !== "") args.push("--model", named);
|
|
884
1473
|
const appended = typeof appendSystemPrompt === "string" ? appendSystemPrompt.trim() : "";
|
|
885
|
-
|
|
1474
|
+
args.push("--append-system-prompt", appended === "" ? DEFAULT_APPEND_SYSTEM_PROMPT : appended);
|
|
886
1475
|
if (spec.skipPermissions === true) args.push("--dangerously-skip-permissions");
|
|
887
1476
|
else args.push("--allowedTools", ...spec.allowedTools);
|
|
888
1477
|
return args;
|
|
889
1478
|
}
|
|
890
1479
|
/**
|
|
1480
|
+
* Build what the CLI reads from stdin for one run.
|
|
1481
|
+
*
|
|
1482
|
+
* A streaming run reads one `stream-json` user message, whose content may mix
|
|
1483
|
+
* text and Anthropic image blocks; the CLI forwards those images to the model
|
|
1484
|
+
* as images. A non-streaming run reads plain prompt text.
|
|
1485
|
+
* @param options - the prompt text, or `content` blocks (`text` and
|
|
1486
|
+
* `image` with a base64 `source`), and whether the run streams.
|
|
1487
|
+
* @returns the stdin payload.
|
|
1488
|
+
*/
|
|
1489
|
+
function buildClaudeInput({ prompt, content, stream = false } = {}) {
|
|
1490
|
+
const blocks = Array.isArray(content) ? content.filter((block) => block?.type === "text" && typeof block.text === "string" && block.text !== "" || block?.type === "image") : [{
|
|
1491
|
+
type: "text",
|
|
1492
|
+
text: typeof prompt === "string" ? prompt : ""
|
|
1493
|
+
}];
|
|
1494
|
+
const text = blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
1495
|
+
const images = blocks.filter((block) => block.type === "image");
|
|
1496
|
+
if (text.trim() === "" && images.length === 0) throw new Error("claude_code needs a non-empty prompt");
|
|
1497
|
+
if (text.length > MAX_PROMPT_CHARS) throw new Error(`claude_code prompt is too long (${text.length} > ${MAX_PROMPT_CHARS} characters)`);
|
|
1498
|
+
if (images.length > MAX_INPUT_IMAGES) throw new Error(`claude_code carries at most ${MAX_INPUT_IMAGES} images per run (got ${images.length})`);
|
|
1499
|
+
if (!stream) {
|
|
1500
|
+
if (images.length > 0) throw new Error("claude_code images need the streaming CLI input");
|
|
1501
|
+
return text;
|
|
1502
|
+
}
|
|
1503
|
+
return `${JSON.stringify({
|
|
1504
|
+
type: "user",
|
|
1505
|
+
message: {
|
|
1506
|
+
role: "user",
|
|
1507
|
+
content: blocks
|
|
1508
|
+
}
|
|
1509
|
+
})}\n`;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
891
1512
|
* The child's environment: this plugin's endpoint and key, an isolated config
|
|
892
1513
|
* home, and the CLI's own network-noise switches off.
|
|
893
1514
|
* @param options - credential, config home, and the parent environment.
|
|
@@ -1046,6 +1667,25 @@ function readClaudeStreamEvents(event) {
|
|
|
1046
1667
|
id: String(block.tool_use_id ?? ""),
|
|
1047
1668
|
text: oneLine(toolResultText(block.content))
|
|
1048
1669
|
}));
|
|
1670
|
+
if (event.type === "system") {
|
|
1671
|
+
if (event.subtype === "init") return [{
|
|
1672
|
+
type: "started",
|
|
1673
|
+
version: typeof event.claude_code_version === "string" ? event.claude_code_version : "",
|
|
1674
|
+
model: typeof event.model === "string" ? event.model : ""
|
|
1675
|
+
}];
|
|
1676
|
+
if (event.subtype === "api_retry") {
|
|
1677
|
+
const count = (value) => typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1678
|
+
return [{
|
|
1679
|
+
type: "retry",
|
|
1680
|
+
attempt: count(event.attempt),
|
|
1681
|
+
maxRetries: count(event.max_retries),
|
|
1682
|
+
delayMs: count(event.retry_delay_ms),
|
|
1683
|
+
status: count(event.error_status),
|
|
1684
|
+
error: typeof event.error === "string" ? event.error : ""
|
|
1685
|
+
}];
|
|
1686
|
+
}
|
|
1687
|
+
return [];
|
|
1688
|
+
}
|
|
1049
1689
|
if (event.type === "result") return [{
|
|
1050
1690
|
type: "result",
|
|
1051
1691
|
result: event
|
|
@@ -1056,24 +1696,29 @@ function readClaudeStreamEvents(event) {
|
|
|
1056
1696
|
* A Claude Code CLI runner.
|
|
1057
1697
|
* @param options - binary (a path, or a function resolving one per run so a
|
|
1058
1698
|
* CLI installed after startup is picked up), config home, credential
|
|
1059
|
-
* resolution, and process
|
|
1060
|
-
*
|
|
1699
|
+
* resolution, the overall and (streaming) startup time limits, and process
|
|
1700
|
+
* seams.
|
|
1701
|
+
* @returns `{ run(options), stream(options) }`.
|
|
1061
1702
|
*/
|
|
1062
|
-
function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_CLAUDE_CONFIG_DIR, resolveCredential, spawnImpl = spawn, timeoutMs = DEFAULT_CLAUDE_TIMEOUT_MS, platform = process.platform } = {}) {
|
|
1703
|
+
function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_CLAUDE_CONFIG_DIR, resolveCredential, spawnImpl = spawn, timeoutMs = DEFAULT_CLAUDE_TIMEOUT_MS, startupTimeoutMs = DEFAULT_CLAUDE_STARTUP_TIMEOUT_MS, platform = process.platform } = {}) {
|
|
1063
1704
|
if (typeof resolveCredential !== "function") throw new Error("the Claude Code CLI runner needs resolveCredential");
|
|
1064
1705
|
/**
|
|
1065
1706
|
* Spawn one CLI process and own its lifetime: capture both pipes, enforce the
|
|
1066
1707
|
* timeout, and kill the process — with a grace period — when the caller
|
|
1067
1708
|
* aborts. Both public entry points share it.
|
|
1068
1709
|
*/
|
|
1069
|
-
const start = async ({ prompt, mode, model, appendSystemPrompt, cwd, signal, stream, onStdout }) => {
|
|
1710
|
+
const start = async ({ prompt, content, mode, model, appendSystemPrompt, cwd, signal, stream, onStdout }) => {
|
|
1070
1711
|
const args = buildClaudeArgs({
|
|
1071
|
-
prompt,
|
|
1072
1712
|
mode,
|
|
1073
1713
|
model,
|
|
1074
1714
|
stream,
|
|
1075
1715
|
appendSystemPrompt
|
|
1076
1716
|
});
|
|
1717
|
+
const input = buildClaudeInput({
|
|
1718
|
+
prompt,
|
|
1719
|
+
content,
|
|
1720
|
+
stream
|
|
1721
|
+
});
|
|
1077
1722
|
const { baseUrl, token } = await resolveCredential();
|
|
1078
1723
|
const program = typeof binary === "function" ? await binary() : binary;
|
|
1079
1724
|
const command = resolveSpawnCommand(program, { platform });
|
|
@@ -1086,16 +1731,20 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1086
1731
|
cwd,
|
|
1087
1732
|
env,
|
|
1088
1733
|
stdio: [
|
|
1089
|
-
"
|
|
1734
|
+
"pipe",
|
|
1090
1735
|
"pipe",
|
|
1091
1736
|
"pipe"
|
|
1092
1737
|
],
|
|
1093
1738
|
windowsHide: true
|
|
1094
1739
|
});
|
|
1740
|
+
child.stdin?.on?.("error", () => void 0);
|
|
1741
|
+
child.stdin?.end?.(input);
|
|
1095
1742
|
const state = {
|
|
1096
1743
|
stdout: "",
|
|
1097
1744
|
stderr: "",
|
|
1098
1745
|
timedOut: false,
|
|
1746
|
+
silent: false,
|
|
1747
|
+
sawOutput: false,
|
|
1099
1748
|
settled: false
|
|
1100
1749
|
};
|
|
1101
1750
|
const kill = () => {
|
|
@@ -1112,17 +1761,30 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1112
1761
|
kill();
|
|
1113
1762
|
}, timeoutMs) : void 0;
|
|
1114
1763
|
timer?.unref?.();
|
|
1764
|
+
let startupTimer = stream && startupTimeoutMs > 0 ? setTimeout(() => {
|
|
1765
|
+
if (state.sawOutput) return;
|
|
1766
|
+
state.silent = true;
|
|
1767
|
+
kill();
|
|
1768
|
+
}, startupTimeoutMs) : void 0;
|
|
1769
|
+
startupTimer?.unref?.();
|
|
1770
|
+
const clearStartup = () => {
|
|
1771
|
+
if (startupTimer !== void 0) clearTimeout(startupTimer);
|
|
1772
|
+
startupTimer = void 0;
|
|
1773
|
+
};
|
|
1115
1774
|
const onAbort = () => kill();
|
|
1116
1775
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1117
1776
|
const release = () => {
|
|
1118
1777
|
if (state.settled) return;
|
|
1119
1778
|
state.settled = true;
|
|
1120
1779
|
if (timer !== void 0) clearTimeout(timer);
|
|
1780
|
+
clearStartup();
|
|
1121
1781
|
signal?.removeEventListener("abort", onAbort);
|
|
1122
1782
|
};
|
|
1123
1783
|
child.stdout?.setEncoding?.("utf8");
|
|
1124
1784
|
child.stderr?.setEncoding?.("utf8");
|
|
1125
1785
|
child.stdout?.on("data", (chunk) => {
|
|
1786
|
+
state.sawOutput = true;
|
|
1787
|
+
clearStartup();
|
|
1126
1788
|
const text = String(chunk);
|
|
1127
1789
|
state.stdout = (state.stdout + text).slice(-4194304);
|
|
1128
1790
|
onStdout?.(text);
|
|
@@ -1141,6 +1803,11 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1141
1803
|
reject(/* @__PURE__ */ new Error(`claude_code timed out after ${Math.round(timeoutMs / 1e3)}s`));
|
|
1142
1804
|
return;
|
|
1143
1805
|
}
|
|
1806
|
+
if (state.silent) {
|
|
1807
|
+
const detail = state.stderr.trim().split("\n").slice(-4).join(" ").slice(0, 400);
|
|
1808
|
+
reject(/* @__PURE__ */ new Error(`Claude Code CLI printed nothing within ${Math.round(startupTimeoutMs / 1e3)}s of starting and was stopped${detail === "" ? "" : `: ${detail}`}`));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1144
1811
|
if (signal?.aborted === true) {
|
|
1145
1812
|
reject(/* @__PURE__ */ new Error("claude_code was aborted"));
|
|
1146
1813
|
return;
|
|
@@ -1165,12 +1832,13 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1165
1832
|
* @param options - prompt, mode, model, working directory, and cancellation.
|
|
1166
1833
|
* @returns the CLI's answer plus its own accounting.
|
|
1167
1834
|
*/
|
|
1168
|
-
async run({ prompt, mode = DEFAULT_CLAUDE_MODE, model, cwd, signal } = {}) {
|
|
1835
|
+
async run({ prompt, mode = DEFAULT_CLAUDE_MODE, model, appendSystemPrompt, cwd, signal } = {}) {
|
|
1169
1836
|
const startedAt = Date.now();
|
|
1170
1837
|
const { state, closed } = await start({
|
|
1171
1838
|
prompt,
|
|
1172
1839
|
mode,
|
|
1173
1840
|
model,
|
|
1841
|
+
appendSystemPrompt,
|
|
1174
1842
|
cwd,
|
|
1175
1843
|
signal,
|
|
1176
1844
|
stream: false
|
|
@@ -1194,18 +1862,19 @@ function createClaudeRunner({ binary = findClaudeBinary(), configDir = DEFAULT_C
|
|
|
1194
1862
|
* Run one delegated task, yielding its answer as the CLI produces it.
|
|
1195
1863
|
* The final `result` event carries the CLI's own accounting, summed over
|
|
1196
1864
|
* every request the CLI made.
|
|
1197
|
-
* @param options - as {@link run}, plus optional `
|
|
1198
|
-
*
|
|
1865
|
+
* @param options - as {@link run}, plus optional `content` blocks (text
|
|
1866
|
+
* and base64 images) that replace `prompt`.
|
|
1199
1867
|
* @returns an async generator of {@link readClaudeStreamEvents} events and
|
|
1200
1868
|
* one closing `result` event.
|
|
1201
1869
|
*/
|
|
1202
|
-
async *stream({ prompt, mode = DEFAULT_CLAUDE_MODE, model, appendSystemPrompt, cwd, signal } = {}) {
|
|
1870
|
+
async *stream({ prompt, content, mode = DEFAULT_CLAUDE_MODE, model, appendSystemPrompt, cwd, signal } = {}) {
|
|
1203
1871
|
const startedAt = Date.now();
|
|
1204
1872
|
let buffer = "";
|
|
1205
1873
|
let sawResult;
|
|
1206
1874
|
const lines = [];
|
|
1207
1875
|
const { state, closed, kill } = await start({
|
|
1208
1876
|
prompt,
|
|
1877
|
+
content,
|
|
1209
1878
|
mode,
|
|
1210
1879
|
model,
|
|
1211
1880
|
appendSystemPrompt,
|
|
@@ -1960,6 +2629,383 @@ function capClaudeMode(requested, ceiling) {
|
|
|
1960
2629
|
return RANK[requested] > RANK[ceiling] ? ceiling : requested;
|
|
1961
2630
|
}
|
|
1962
2631
|
//#endregion
|
|
2632
|
+
//#region src/plugin-version.js
|
|
2633
|
+
/**
|
|
2634
|
+
* Version self-knowledge for the settings panel.
|
|
2635
|
+
*
|
|
2636
|
+
* The running version comes from this package. The latest version comes from
|
|
2637
|
+
* the npm registry. An in-place update is only offered for a registry install,
|
|
2638
|
+
* and it installs that exact version with the host's own pnpm — never through
|
|
2639
|
+
* `dsh plugin --profile desktop`, which the desktop app refuses. Registry
|
|
2640
|
+
* bodies and package-manager output stay host-side.
|
|
2641
|
+
*
|
|
2642
|
+
* @module dsh-packycode-api/plugin-version
|
|
2643
|
+
*/
|
|
2644
|
+
const PACKAGE_NAME = "dsh-packycode-api";
|
|
2645
|
+
const NPM_REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
2646
|
+
const DEFAULT_VERSION_TTL_MS = 5 * 6e4;
|
|
2647
|
+
const DEFAULT_VERSION_TIMEOUT_MS = 1e4;
|
|
2648
|
+
const DEFAULT_UPDATE_TIMEOUT_MS = 18e4;
|
|
2649
|
+
const clone = (value) => structuredClone(value);
|
|
2650
|
+
/**
|
|
2651
|
+
* Resolve fetch at call time. A sibling plugin may wrap `globalThis.fetch`
|
|
2652
|
+
* and later dismantle that wrapper; a binding captured during `apply()` would
|
|
2653
|
+
* keep the dead wrapper for the process lifetime.
|
|
2654
|
+
*/
|
|
2655
|
+
function ambientFetch(input, init) {
|
|
2656
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : void 0));
|
|
2657
|
+
if (!headers.has("accept-encoding")) headers.set("accept-encoding", "identity");
|
|
2658
|
+
return globalThis.fetch(input, {
|
|
2659
|
+
...init,
|
|
2660
|
+
headers
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
/** One caller's cancellation must not abort the shared registry read. */
|
|
2664
|
+
function waitForSharedRequest(promise, signal) {
|
|
2665
|
+
if (signal === void 0) return promise;
|
|
2666
|
+
signal.throwIfAborted();
|
|
2667
|
+
return new Promise((resolve, reject) => {
|
|
2668
|
+
const onAbort = () => reject(signal.reason);
|
|
2669
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2670
|
+
promise.then((value) => {
|
|
2671
|
+
signal.removeEventListener("abort", onAbort);
|
|
2672
|
+
resolve(value);
|
|
2673
|
+
}, (error) => {
|
|
2674
|
+
signal.removeEventListener("abort", onAbort);
|
|
2675
|
+
reject(error);
|
|
2676
|
+
});
|
|
2677
|
+
});
|
|
2678
|
+
}
|
|
2679
|
+
/** Parse a strict `x.y.z[-pre][+build]` version; anything else is undefined. */
|
|
2680
|
+
function parseSemver(value) {
|
|
2681
|
+
if (typeof value !== "string") return void 0;
|
|
2682
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value.trim());
|
|
2683
|
+
if (match === null) return void 0;
|
|
2684
|
+
return {
|
|
2685
|
+
major: Number(match[1]),
|
|
2686
|
+
minor: Number(match[2]),
|
|
2687
|
+
patch: Number(match[3]),
|
|
2688
|
+
pre: match[4]
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
/** Compare two versions: -1/0/1, or undefined when either side is not semver. */
|
|
2692
|
+
function compareSemver(a, b) {
|
|
2693
|
+
const left = parseSemver(a);
|
|
2694
|
+
const right = parseSemver(b);
|
|
2695
|
+
if (left === void 0 || right === void 0) return void 0;
|
|
2696
|
+
for (const key of [
|
|
2697
|
+
"major",
|
|
2698
|
+
"minor",
|
|
2699
|
+
"patch"
|
|
2700
|
+
]) if (left[key] !== right[key]) return left[key] < right[key] ? -1 : 1;
|
|
2701
|
+
if (left.pre === right.pre) return 0;
|
|
2702
|
+
if (left.pre === void 0) return 1;
|
|
2703
|
+
if (right.pre === void 0) return -1;
|
|
2704
|
+
return left.pre < right.pre ? -1 : 1;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Classify how the running package reached the profile. Only registry specs
|
|
2708
|
+
* may be updated in place; local checkouts belong to the owner's iteration.
|
|
2709
|
+
*/
|
|
2710
|
+
function classifySpec(spec) {
|
|
2711
|
+
if (typeof spec !== "string" || spec === "") return "unknown";
|
|
2712
|
+
if (/^(?:link|file):/u.test(spec)) return "link";
|
|
2713
|
+
if (/^(?:\d|[~^*])/u.test(spec)) return "npm";
|
|
2714
|
+
return "unknown";
|
|
2715
|
+
}
|
|
2716
|
+
const readManifest = async (path) => {
|
|
2717
|
+
try {
|
|
2718
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
2719
|
+
return parsed !== null && typeof parsed === "object" ? parsed : void 0;
|
|
2720
|
+
} catch {
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
};
|
|
2724
|
+
/**
|
|
2725
|
+
* A zero exit only proves the command ran. pnpm can skip a too-new version
|
|
2726
|
+
* and still exit zero, so the landed manifest is what makes the answer true.
|
|
2727
|
+
*/
|
|
2728
|
+
async function confirmInstalled({ profileDir, expected, settleDelayMs, attempts = 3 }) {
|
|
2729
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
2730
|
+
if ((await readManifest(join(profileDir, "node_modules", "dsh-packycode-api", "package.json")))?.version === expected) return;
|
|
2731
|
+
if (attempt < attempts) await new Promise((resolve) => {
|
|
2732
|
+
setTimeout(resolve, settleDelayMs);
|
|
2733
|
+
});
|
|
2734
|
+
}
|
|
2735
|
+
throw new Error("PackyCode plugin update did not install the requested version");
|
|
2736
|
+
}
|
|
2737
|
+
/**
|
|
2738
|
+
* Find the profile that owns this running installation. pnpm symlinks resolve
|
|
2739
|
+
* to the same realpath on both sides. When no realpath matches, fall back to
|
|
2740
|
+
* the only profile that declares the dependency at all.
|
|
2741
|
+
*/
|
|
2742
|
+
async function findInstall({ dshHome, ownPackageJsonUrl }) {
|
|
2743
|
+
let ownReal;
|
|
2744
|
+
try {
|
|
2745
|
+
ownReal = await realpath(fileURLToPath(ownPackageJsonUrl));
|
|
2746
|
+
} catch {
|
|
2747
|
+
ownReal = void 0;
|
|
2748
|
+
}
|
|
2749
|
+
let entries = [];
|
|
2750
|
+
try {
|
|
2751
|
+
entries = await readdir(join(dshHome, "profiles"), { withFileTypes: true });
|
|
2752
|
+
} catch {
|
|
2753
|
+
entries = [];
|
|
2754
|
+
}
|
|
2755
|
+
const declared = [];
|
|
2756
|
+
for (const entry of entries) {
|
|
2757
|
+
if (!entry.isDirectory()) continue;
|
|
2758
|
+
const profileDir = join(dshHome, "profiles", entry.name);
|
|
2759
|
+
const spec = (await readManifest(join(profileDir, "package.json")))?.dependencies?.[PACKAGE_NAME];
|
|
2760
|
+
if (typeof spec !== "string") continue;
|
|
2761
|
+
declared.push({
|
|
2762
|
+
profile: entry.name,
|
|
2763
|
+
spec
|
|
2764
|
+
});
|
|
2765
|
+
if (ownReal === void 0) continue;
|
|
2766
|
+
try {
|
|
2767
|
+
if (await realpath(join(profileDir, "node_modules", "dsh-packycode-api", "package.json")) === ownReal) return {
|
|
2768
|
+
kind: classifySpec(spec),
|
|
2769
|
+
profile: entry.name
|
|
2770
|
+
};
|
|
2771
|
+
} catch {}
|
|
2772
|
+
}
|
|
2773
|
+
if (declared.length === 1) return {
|
|
2774
|
+
kind: classifySpec(declared[0].spec),
|
|
2775
|
+
profile: declared[0].profile
|
|
2776
|
+
};
|
|
2777
|
+
return { kind: "unknown" };
|
|
2778
|
+
}
|
|
2779
|
+
const isExistingPath = (value) => typeof value === "string" && value !== "" && existsSync(value);
|
|
2780
|
+
/** Anchored to the file name so a directory that merely contains the word cannot match. */
|
|
2781
|
+
const PNPM_SCRIPT = /[\\/]pnpm\.(?:mjs|cjs|js)$/u;
|
|
2782
|
+
const dshRuntimeRoot = (value) => isExistingPath(value) && existsSync(join(value, "node_modules", "@deepseek-ai", "dsh", "package.json")) ? value : void 0;
|
|
2783
|
+
/**
|
|
2784
|
+
* Locate the package manager that owns this profile.
|
|
2785
|
+
*
|
|
2786
|
+
* `dsh plugin --profile desktop` is refused by the desktop app, so the update
|
|
2787
|
+
* installs with pnpm directly. The desktop host puts the bundled pnpm in its
|
|
2788
|
+
* own arguments; nothing is guessed from the entry script.
|
|
2789
|
+
*/
|
|
2790
|
+
function resolvePnpm({ argv } = {}) {
|
|
2791
|
+
const entries = Array.isArray(argv) ? argv : [];
|
|
2792
|
+
for (const entry of entries) if (isExistingPath(entry) && PNPM_SCRIPT.test(entry)) return entry;
|
|
2793
|
+
for (const entry of entries) {
|
|
2794
|
+
const root = dshRuntimeRoot(entry);
|
|
2795
|
+
if (root === void 0) continue;
|
|
2796
|
+
const script = join(root, "..", "pnpm", "bin", "pnpm.mjs");
|
|
2797
|
+
if (existsSync(script)) return script;
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
function resolveLauncher({ argv, execPath }) {
|
|
2801
|
+
const pnpm = resolvePnpm({ argv });
|
|
2802
|
+
if (pnpm === void 0) return {
|
|
2803
|
+
command: "pnpm",
|
|
2804
|
+
prefix: []
|
|
2805
|
+
};
|
|
2806
|
+
return {
|
|
2807
|
+
command: execPath,
|
|
2808
|
+
prefix: [pnpm]
|
|
2809
|
+
};
|
|
2810
|
+
}
|
|
2811
|
+
/** Run pnpm host-side. Its output is never returned to the browser. */
|
|
2812
|
+
function spawnRunCommand({ command, args, cwd }, { signal, timeoutMs }) {
|
|
2813
|
+
return new Promise((resolve, reject) => {
|
|
2814
|
+
if (signal?.aborted) {
|
|
2815
|
+
reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("PackyCode plugin update aborted"));
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
const controller = new AbortController();
|
|
2819
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
2820
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2821
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error("PackyCode plugin update timed out")), timeoutMs);
|
|
2822
|
+
let settled = false;
|
|
2823
|
+
const settle = (callback, value) => {
|
|
2824
|
+
if (settled) return;
|
|
2825
|
+
settled = true;
|
|
2826
|
+
clearTimeout(timer);
|
|
2827
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2828
|
+
callback(value);
|
|
2829
|
+
};
|
|
2830
|
+
let child;
|
|
2831
|
+
try {
|
|
2832
|
+
child = spawn(command, args, {
|
|
2833
|
+
stdio: [
|
|
2834
|
+
"ignore",
|
|
2835
|
+
"pipe",
|
|
2836
|
+
"pipe"
|
|
2837
|
+
],
|
|
2838
|
+
signal: controller.signal,
|
|
2839
|
+
...cwd === void 0 ? {} : { cwd }
|
|
2840
|
+
});
|
|
2841
|
+
} catch (error) {
|
|
2842
|
+
settle(reject, error);
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
let stdout = "";
|
|
2846
|
+
let stderr = "";
|
|
2847
|
+
child.stdout?.on("data", (chunk) => {
|
|
2848
|
+
stdout = (stdout + chunk).slice(-8192);
|
|
2849
|
+
});
|
|
2850
|
+
child.stderr?.on("data", (chunk) => {
|
|
2851
|
+
stderr = (stderr + chunk).slice(-8192);
|
|
2852
|
+
});
|
|
2853
|
+
child.on("error", (error) => settle(reject, error));
|
|
2854
|
+
child.on("close", (code) => {
|
|
2855
|
+
if (controller.signal.aborted) {
|
|
2856
|
+
const reason = controller.signal.reason;
|
|
2857
|
+
settle(reject, reason instanceof Error ? reason : /* @__PURE__ */ new Error("PackyCode plugin update aborted"));
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
settle(resolve, {
|
|
2861
|
+
code: code ?? 1,
|
|
2862
|
+
stdout,
|
|
2863
|
+
stderr
|
|
2864
|
+
});
|
|
2865
|
+
});
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* The running version, the latest registry version, which profile installed
|
|
2870
|
+
* this copy, and registry-spec updates against that profile.
|
|
2871
|
+
*/
|
|
2872
|
+
function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = spawnRunCommand, execPath = process.execPath, argv = process.argv, env = process.env, ownPackageJsonUrl = new URL("../package.json", import.meta.url), registryUrl = NPM_REGISTRY_LATEST_URL, now = Date.now, ttlMs = DEFAULT_VERSION_TTL_MS, timeoutMs = DEFAULT_VERSION_TIMEOUT_MS, updateTimeoutMs = DEFAULT_UPDATE_TIMEOUT_MS, settleDelayMs = 250 } = {}) {
|
|
2873
|
+
if (typeof fetchImpl !== "function") throw new Error("PackyCode plugin manager requires fetch");
|
|
2874
|
+
if (typeof runCommand !== "function") throw new Error("PackyCode plugin manager requires runCommand");
|
|
2875
|
+
const dshHome = typeof env.DSH_HOME === "string" && env.DSH_HOME !== "" ? env.DSH_HOME : join(homedir(), ".dsh");
|
|
2876
|
+
const launcher = resolveLauncher({
|
|
2877
|
+
argv,
|
|
2878
|
+
execPath
|
|
2879
|
+
});
|
|
2880
|
+
let cached;
|
|
2881
|
+
let inFlight;
|
|
2882
|
+
let generation = 0;
|
|
2883
|
+
let updating = false;
|
|
2884
|
+
const fetchLatest = async (signal) => {
|
|
2885
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
2886
|
+
const requestSignal = signal === void 0 ? timeoutSignal : AbortSignal.any([signal, timeoutSignal]);
|
|
2887
|
+
let response;
|
|
2888
|
+
try {
|
|
2889
|
+
response = await fetchImpl(registryUrl, {
|
|
2890
|
+
headers: { Accept: "application/json" },
|
|
2891
|
+
redirect: "error",
|
|
2892
|
+
signal: requestSignal
|
|
2893
|
+
});
|
|
2894
|
+
} catch (error) {
|
|
2895
|
+
if (signal?.aborted) throw error;
|
|
2896
|
+
throw new Error("PackyCode plugin version check failed");
|
|
2897
|
+
}
|
|
2898
|
+
if (!response.ok) throw new Error("PackyCode plugin version check failed");
|
|
2899
|
+
let version;
|
|
2900
|
+
try {
|
|
2901
|
+
version = (await response.json())?.version;
|
|
2902
|
+
} catch {
|
|
2903
|
+
version = void 0;
|
|
2904
|
+
}
|
|
2905
|
+
if (parseSemver(version) === void 0) throw new Error("PackyCode plugin version check failed");
|
|
2906
|
+
return version;
|
|
2907
|
+
};
|
|
2908
|
+
const readOwnVersion = async () => {
|
|
2909
|
+
const version = (await readManifest(fileURLToPath(ownPackageJsonUrl)))?.version;
|
|
2910
|
+
if (parseSemver(version) === void 0) throw new Error("PackyCode plugin version is unavailable");
|
|
2911
|
+
return version;
|
|
2912
|
+
};
|
|
2913
|
+
const load = async (signal) => {
|
|
2914
|
+
signal?.throwIfAborted();
|
|
2915
|
+
const [current, install, latest] = await Promise.all([
|
|
2916
|
+
readOwnVersion(),
|
|
2917
|
+
findInstall({
|
|
2918
|
+
dshHome,
|
|
2919
|
+
ownPackageJsonUrl
|
|
2920
|
+
}),
|
|
2921
|
+
fetchLatest(signal)
|
|
2922
|
+
]);
|
|
2923
|
+
return {
|
|
2924
|
+
current,
|
|
2925
|
+
latest,
|
|
2926
|
+
updateAvailable: compareSemver(latest, current) === 1,
|
|
2927
|
+
install,
|
|
2928
|
+
fetchedAt: now()
|
|
2929
|
+
};
|
|
2930
|
+
};
|
|
2931
|
+
return Object.freeze({
|
|
2932
|
+
async read({ force = false, signal } = {}) {
|
|
2933
|
+
signal?.throwIfAborted();
|
|
2934
|
+
if (!force && cached !== void 0 && now() - cached.fetchedAt < ttlMs) return clone(cached);
|
|
2935
|
+
const createPending = (requestSignal) => {
|
|
2936
|
+
const observedGeneration = generation;
|
|
2937
|
+
return load(requestSignal).then((value) => {
|
|
2938
|
+
if (generation === observedGeneration) cached = value;
|
|
2939
|
+
return value;
|
|
2940
|
+
});
|
|
2941
|
+
};
|
|
2942
|
+
if (force) return clone(await createPending(signal));
|
|
2943
|
+
const pending = inFlight ?? createPending();
|
|
2944
|
+
if (inFlight === void 0) {
|
|
2945
|
+
inFlight = pending;
|
|
2946
|
+
const clear = () => {
|
|
2947
|
+
if (inFlight === pending) inFlight = void 0;
|
|
2948
|
+
};
|
|
2949
|
+
pending.then(clear, clear);
|
|
2950
|
+
}
|
|
2951
|
+
return clone(await waitForSharedRequest(pending, signal));
|
|
2952
|
+
},
|
|
2953
|
+
async update({ signal } = {}) {
|
|
2954
|
+
signal?.throwIfAborted();
|
|
2955
|
+
if (updating) throw new Error("PackyCode plugin update is already running");
|
|
2956
|
+
updating = true;
|
|
2957
|
+
try {
|
|
2958
|
+
const install = await findInstall({
|
|
2959
|
+
dshHome,
|
|
2960
|
+
ownPackageJsonUrl
|
|
2961
|
+
});
|
|
2962
|
+
if (install.kind === "link") throw new Error("PackyCode plugin is installed from a local checkout");
|
|
2963
|
+
if (install.kind !== "npm" || install.profile === void 0) throw new Error("PackyCode plugin update could not find the owning profile");
|
|
2964
|
+
const latest = await fetchLatest(signal);
|
|
2965
|
+
const profileDir = join(dshHome, "profiles", install.profile);
|
|
2966
|
+
let result;
|
|
2967
|
+
try {
|
|
2968
|
+
result = await runCommand({
|
|
2969
|
+
command: launcher.command,
|
|
2970
|
+
args: [
|
|
2971
|
+
...launcher.prefix,
|
|
2972
|
+
"add",
|
|
2973
|
+
"--save-exact",
|
|
2974
|
+
`--config.minimumReleaseAge=0`,
|
|
2975
|
+
`${PACKAGE_NAME}@${latest}`
|
|
2976
|
+
],
|
|
2977
|
+
cwd: profileDir
|
|
2978
|
+
}, {
|
|
2979
|
+
signal,
|
|
2980
|
+
timeoutMs: updateTimeoutMs
|
|
2981
|
+
});
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
if (signal?.aborted) throw error;
|
|
2984
|
+
throw new Error("PackyCode plugin update failed");
|
|
2985
|
+
}
|
|
2986
|
+
if (result.code !== 0) throw new Error("PackyCode plugin update failed");
|
|
2987
|
+
await confirmInstalled({
|
|
2988
|
+
profileDir,
|
|
2989
|
+
expected: latest,
|
|
2990
|
+
settleDelayMs
|
|
2991
|
+
});
|
|
2992
|
+
generation += 1;
|
|
2993
|
+
cached = void 0;
|
|
2994
|
+
return {
|
|
2995
|
+
version: latest,
|
|
2996
|
+
profile: install.profile
|
|
2997
|
+
};
|
|
2998
|
+
} finally {
|
|
2999
|
+
updating = false;
|
|
3000
|
+
}
|
|
3001
|
+
},
|
|
3002
|
+
invalidate() {
|
|
3003
|
+
generation += 1;
|
|
3004
|
+
cached = void 0;
|
|
3005
|
+
}
|
|
3006
|
+
});
|
|
3007
|
+
}
|
|
3008
|
+
//#endregion
|
|
1963
3009
|
//#region src/catalog.js
|
|
1964
3010
|
/**
|
|
1965
3011
|
* The built-in PackyCode model catalog.
|
|
@@ -2107,110 +3153,6 @@ const PACKYCODE_MODELS = Object.freeze([
|
|
|
2107
3153
|
/** Every model id this catalog advertises, in picker order. */
|
|
2108
3154
|
const PACKYCODE_MODEL_IDS = Object.freeze(PACKYCODE_MODELS.map((model) => model.id));
|
|
2109
3155
|
//#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
3156
|
//#region src/provider.js
|
|
2215
3157
|
/**
|
|
2216
3158
|
* The PackyCode pi-ai provider, built in code so the user configures no route.
|
|
@@ -2368,11 +3310,15 @@ function normalizeHost(value, fallback) {
|
|
|
2368
3310
|
* Claude Code CLI manager (optional: without it the CLI endpoints refuse).
|
|
2369
3311
|
* @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
|
|
2370
3312
|
*/
|
|
2371
|
-
function createPackyCodeRpc({ ring, balance, hosts, preference, claudeCli }) {
|
|
3313
|
+
function createPackyCodeRpc({ ring, balance, hosts, preference, claudeCli, pluginManager }) {
|
|
2372
3314
|
const requireCli = () => {
|
|
2373
3315
|
if (claudeCli === void 0) throw new Error("PackyCode Claude Code CLI support is unavailable in this runtime");
|
|
2374
3316
|
return claudeCli;
|
|
2375
3317
|
};
|
|
3318
|
+
const requireManager = () => {
|
|
3319
|
+
if (pluginManager === void 0) throw new Error("PackyCode plugin version is unavailable");
|
|
3320
|
+
return pluginManager;
|
|
3321
|
+
};
|
|
2376
3322
|
const status = async () => ({
|
|
2377
3323
|
...await ring.list(),
|
|
2378
3324
|
hosts: hosts(),
|
|
@@ -2382,7 +3328,7 @@ function createPackyCodeRpc({ ring, balance, hosts, preference, claudeCli }) {
|
|
|
2382
3328
|
statusHost: DEFAULT_STATUS_HOST
|
|
2383
3329
|
}
|
|
2384
3330
|
});
|
|
2385
|
-
return async (endpoint, payload) => {
|
|
3331
|
+
return async (endpoint, payload, signal) => {
|
|
2386
3332
|
try {
|
|
2387
3333
|
switch (endpoint) {
|
|
2388
3334
|
case "status": return ok(await status());
|
|
@@ -2431,6 +3377,11 @@ function createPackyCodeRpc({ ring, balance, hosts, preference, claudeCli }) {
|
|
|
2431
3377
|
cli.install();
|
|
2432
3378
|
return ok(await cli.status());
|
|
2433
3379
|
}
|
|
3380
|
+
case "plugin/version": return ok(await requireManager().read({
|
|
3381
|
+
force: payload?.force === true,
|
|
3382
|
+
signal
|
|
3383
|
+
}));
|
|
3384
|
+
case "plugin/update": return ok(await requireManager().update({ signal }));
|
|
2434
3385
|
default: return failed("Unknown PackyCode endpoint");
|
|
2435
3386
|
}
|
|
2436
3387
|
} catch (error) {
|
|
@@ -2460,6 +3411,8 @@ const name = PLUGIN_ID;
|
|
|
2460
3411
|
* blocking on a service it will never see.
|
|
2461
3412
|
*/
|
|
2462
3413
|
const inject = ["llm", "credentials"];
|
|
3414
|
+
/** What a `claude_code` tool run is told about where its task came from. */
|
|
3415
|
+
const CLAUDE_TOOL_INSTRUCTIONS = "This task was delegated to Claude Code by another agent in DeepSeek Harness through its claude_code tool. The task text is self-contained; complete it with your own tools and reply with the final result only.";
|
|
2463
3416
|
/** Host overrides; a profile that never installs this section keeps the defaults. */
|
|
2464
3417
|
const Config = z.object({
|
|
2465
3418
|
apiHost: z.string().default(DEFAULT_API_HOST),
|
|
@@ -2684,7 +3637,8 @@ function apply(ctx, config) {
|
|
|
2684
3637
|
}),
|
|
2685
3638
|
runner: claudeRunner,
|
|
2686
3639
|
resolveMode: resolveCliMode,
|
|
2687
|
-
resolveCwd: resolveCliCwd
|
|
3640
|
+
resolveCwd: resolveCliCwd,
|
|
3641
|
+
resolveAttachments: () => ctx.get?.("attachments") ?? ctx.attachments
|
|
2688
3642
|
});
|
|
2689
3643
|
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
2690
3644
|
ctx.llm.registerConfigurableProviders([{
|
|
@@ -2721,7 +3675,8 @@ function apply(ctx, config) {
|
|
|
2721
3675
|
balance,
|
|
2722
3676
|
hosts,
|
|
2723
3677
|
preference,
|
|
2724
|
-
claudeCli
|
|
3678
|
+
claudeCli,
|
|
3679
|
+
pluginManager: createPackyCodePluginManager()
|
|
2725
3680
|
});
|
|
2726
3681
|
/**
|
|
2727
3682
|
* Connection registers a channel through `owner.webServer`, read on its own
|
|
@@ -2803,6 +3758,7 @@ function apply(ctx, config) {
|
|
|
2803
3758
|
prompt: args.prompt,
|
|
2804
3759
|
mode: capClaudeMode(args.mode, cliModeCeiling(exec?.agent?.session)) ?? "edit",
|
|
2805
3760
|
model: args.model,
|
|
3761
|
+
appendSystemPrompt: CLAUDE_TOOL_INSTRUCTIONS,
|
|
2806
3762
|
cwd,
|
|
2807
3763
|
signal: exec?.signal
|
|
2808
3764
|
});
|
|
@@ -2811,4 +3767,4 @@ function apply(ctx, config) {
|
|
|
2811
3767
|
});
|
|
2812
3768
|
}
|
|
2813
3769
|
//#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 };
|
|
3770
|
+
export { AUTO_CLAUDE_MODE, CHANNEL, CLAUDE_CLI_MODES, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLI_BACKED_MODEL_IDS, CLI_IMAGE_LIMITS, Config, DEFAULT_API_HOST, DEFAULT_APPEND_SYSTEM_PROMPT, DEFAULT_STATUS_HOST, DISPLAY_NAME, LEGACY_STATUS_HOSTS, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SETTINGS_NAMESPACE, TITLE_MODEL_ID, accountHostOrder, apply, assertApiKey, buildClaudeArgs, buildClaudeEnvironment, buildClaudeInput, buildDelegatedContent, capClaudeMode, classifyClientPolicy, classifyGatewayFailure, claudeBinaryCandidates, claudeModeFor, claudeModeForSession, createBalanceReader, createClaudeCliAdapter, createClaudeCliManager, createClaudeRunner, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, delegationInstructions, explainCliClientPolicyFailure, explainClientPolicyFailure, explainNpmFailure, findClaudeBinary, guardKeyRotation, inject, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, npmCandidates, parseBillingSubscription, parseBillingUsage, parseClaudeResult, parseClaudeVersion, parseTokenUsage, readClaudeStreamEvents, renderAuxiliaryPrompt, renderDelegatedPrompt, resolveSpawnCommand, statusHostOrder, summarizeToolUse };
|