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/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.com";
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
- const response = await fetchImpl(url, {
146
- headers: {
147
- Authorization: `Bearer ${key}`,
148
- Accept: "application/json"
149
- },
150
- redirect: "error",
151
- signal: requestSignal
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([.../* @__PURE__ */ new Set([
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 = [.../* @__PURE__ */ new Set([
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: await loadKey(entry, hosts, signal)
317
+ usage
252
318
  });
253
319
  usableIds.push(entry.id);
254
320
  } catch (error) {
255
- entries.push({
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: error instanceof Error ? error.message : String(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
- account = { error: error instanceof Error ? error.message : String(error) };
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. While it works, its
316
- * thinking and tool activity stream into a reasoning block and its answer
317
- * streams as text.
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
- /** Render one content block as text a CLI prompt can carry. */
337
- function renderBlock(block) {
338
- if (block === null || typeof block !== "object") return "";
339
- if (block.type === "text" && typeof block.text === "string") return block.text;
340
- if (block.type === "image") return "[图片]";
341
- if (block.type === "tool-call" || block.type === "tool-call-delta") return `[调用工具 ${block.name ?? block.id ?? ""}]`;
342
- if (block.type === "tool-result") return `[工具结果] ${(typeof block.content === "string" ? block.content : "").slice(0, MAX_BLOCK_CHARS)}`;
343
- if (block.type === "reasoning") return "";
344
- return "";
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
- * Turn an assembled request into one CLI prompt.
433
- *
434
- * The leading system message carries the harness's system prompt, which
435
- * describes tools this model cannot call; the CLI brings its own instead.
436
- * @param options - the assembled request.
437
- * @param cwd - the working directory the CLI will run in, when known.
438
- * @returns the prompt text.
439
- */
440
- function renderDelegatedPrompt(options, cwd) {
441
- const messages = Array.isArray(options?.messages) ? options.messages : [];
442
- const turns = [];
443
- for (const message of messages) {
444
- if (message === null || typeof message !== "object") continue;
445
- if (message.role === "system") continue;
446
- const text = (Array.isArray(message.content) ? message.content : []).map(renderBlock).filter((part) => part !== "").join("\n").trim();
447
- if (text === "") continue;
448
- const label = message.role === "user" ? "用户" : message.role === "assistant" ? "助手" : String(message.role);
449
- turns.push(`${label}:${text}`);
450
- }
451
- let transcript = turns.join("\n\n");
452
- if (transcript.length > MAX_PROMPT_CHARS$1) transcript = `…(较早对话已截断)\n\n${transcript.slice(-48e3)}`;
453
- const system = typeof options?.system === "string" && options.system.trim() !== "" ? options.system.trim() : "";
454
- return `${[
455
- "[本回合由 DeepSeek Harness 委派给 Claude Code CLI 执行。请用你自己的工具完成任务,直接输出最终结果;不要反问、不要复述流程。]",
456
- cwd === void 0 ? void 0 : `[工作目录:${cwd}]`,
457
- system === "" ? void 0 : `[补充说明:${system.slice(0, 2e3)}]`
458
- ].filter((part) => part !== void 0).join("\n")}\n\n${transcript}`.trim();
459
- }
460
- /**
461
- * The prompt of a one-shot auxiliary request: its messages' text, verbatim.
462
- * The caller frames its own input, so nothing is added around it.
463
- * @param options - the assembled request.
464
- * @returns the prompt text.
465
- */
466
- function renderAuxiliaryPrompt(options) {
467
- return (Array.isArray(options?.messages) ? options.messages : []).filter((message) => message !== null && typeof message === "object" && message.role !== "system").flatMap((message) => Array.isArray(message.content) ? message.content : []).filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n\n").trim();
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
- prompt: renderDelegatedPrompt(options, cwd),
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, and the mode and working directory each
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: String(error?.message ?? error)
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
- /** Argument lists travel through argv, which the kernel bounds. */
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
- * @param options - the prompt, the permission mode, an optional model, whether
864
- * the caller consumes the streaming protocol, and optional instructions
865
- * appended to the CLI's own system prompt. The CLI's system prompt and tool
866
- * set are never replaced: they are part of what the official client sends.
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({ prompt, mode = DEFAULT_CLAUDE_MODE, model, stream = false, appendSystemPrompt } = {}) {
870
- const text = typeof prompt === "string" ? prompt : "";
871
- if (text.trim() === "") throw new Error("claude_code needs a non-empty prompt");
872
- if (text.length > MAX_PROMPT_CHARS) throw new Error(`claude_code prompt is too long (${text.length} > ${MAX_PROMPT_CHARS} characters)`);
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
- stream ? "stream-json" : "json"
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
- if (appended !== "") args.push("--append-system-prompt", appended);
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 seams.
1060
- * @returns `{ run({ prompt, mode, model, cwd, signal }) }`.
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
- "ignore",
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 `appendSystemPrompt`
1198
- * instructions.
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 };