dsh-packycode-api 1.3.3 → 1.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,14 +1,15 @@
1
- import { createHash, randomBytes, randomUUID } from "node:crypto";
1
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, dirname, join, posix, resolve, win32 } from "node:path";
4
4
  import z from "@deepseek-ai/schemastery";
5
- import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
5
+ import { spawn } from "node:child_process";
6
+ import { constants, existsSync, readFileSync, readdirSync } from "node:fs";
7
+ import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
8
+ import { createServer } from "node:http";
6
9
  import { fileURLToPath } from "node:url";
7
- import { constants, existsSync } from "node:fs";
8
10
  import { HarnessError, LlmError, QUOTA_EXCEEDED_CODE, createUserMessage, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
11
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
12
  import { credentialKey } from "@deepseek-ai/dsh-credentials";
11
- import { spawn } from "node:child_process";
12
13
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
13
14
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
14
15
  import { lazyStream } from "@earendil-works/pi-ai/api/lazy";
@@ -16,8 +17,8 @@ import { clampThinkingLevel, createProvider } from "@earendil-works/pi-ai";
16
17
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
17
18
  import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
18
19
  import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
19
- import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
20
20
  import { AsyncLocalStorage } from "node:async_hooks";
21
+ import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
21
22
  //#region src/request.js
22
23
  /**
23
24
  * The host-side HTTP plumbing every reader shares: fetch resolved at call
@@ -294,7 +295,7 @@ function redactSecrets(text, values) {
294
295
  *
295
296
  * @module dsh-packycode-api/balance
296
297
  */
297
- const record$2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
298
+ const record$4 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
298
299
  function number(value) {
299
300
  if (typeof value === "number" && Number.isFinite(value)) return value;
300
301
  if (typeof value === "string" && value.trim() !== "") {
@@ -322,8 +323,8 @@ function quotaFields(prefix, quota) {
322
323
  * @returns a small projection, or undefined when the shape is unrecognized.
323
324
  */
324
325
  function parseTokenUsage(payload) {
325
- const data = record$2(payload?.data) ? payload.data : payload;
326
- if (!record$2(data)) return void 0;
326
+ const data = record$4(payload?.data) ? payload.data : payload;
327
+ if (!record$4(data)) return void 0;
327
328
  if (![
328
329
  "name",
329
330
  "quota_limit",
@@ -460,16 +461,2057 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
460
461
  fetchedAt: now()
461
462
  };
462
463
  };
463
- const cache = createCachedRead(load, {
464
- ttlMs,
465
- now
466
- });
464
+ const cache = createCachedRead(load, {
465
+ ttlMs,
466
+ now
467
+ });
468
+ return Object.freeze({
469
+ read: (options) => cache.read(options),
470
+ invalidate: () => cache.invalidate()
471
+ });
472
+ }
473
+ //#endregion
474
+ //#region src/locate.js
475
+ /**
476
+ * Finding programs on macOS, Linux, and Windows.
477
+ *
478
+ * A desktop app launched from Finder or the Start menu inherits a much shorter
479
+ * PATH than a terminal, so a lookup that trusts PATH alone misses an npm or
480
+ * Claude Code install the user can run from their shell. These helpers walk
481
+ * PATH the way the platform's own shell would, and know the usual install
482
+ * directories it may lack.
483
+ *
484
+ * @module dsh-packycode-api/locate
485
+ */
486
+ /** The path flavour of `platform`, so a Windows layout is testable on macOS. */
487
+ const pathFor = (platform) => platform === "win32" ? win32 : posix;
488
+ const listDirectory = (directory) => {
489
+ try {
490
+ return readdirSync(directory);
491
+ } catch {
492
+ return [];
493
+ }
494
+ };
495
+ /** The environment's PATH key: Windows spells it `Path` and compares case-insensitively. */
496
+ function pathKey(env, platform = process.platform) {
497
+ if (platform !== "win32") return "PATH";
498
+ return Object.keys(env ?? {}).find((name) => name.toUpperCase() === "PATH") ?? "Path";
499
+ }
500
+ /** Numeric `vX.Y.Z` ordering, newest first, for nvm's per-version directories. */
501
+ function newestFirst(a, b) {
502
+ const parts = (value) => value.replace(/^v/u, "").split(".").map((part) => Number.parseInt(part, 10) || 0);
503
+ const left = parts(a);
504
+ const right = parts(b);
505
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
506
+ const delta = (right[index] ?? 0) - (left[index] ?? 0);
507
+ if (delta !== 0) return delta;
508
+ }
509
+ return 0;
510
+ }
511
+ /**
512
+ * The `bin` directories of every Node version nvm installed, newest first.
513
+ * @param options - home directory, environment, and a directory reader.
514
+ * @returns absolute directories; empty when nvm is absent.
515
+ */
516
+ function nvmBinDirs({ home, env = process.env, platform = process.platform, readdir = listDirectory } = {}) {
517
+ if (platform === "win32") return [];
518
+ const path = pathFor(platform);
519
+ const root = path.join(env.NVM_DIR ?? path.join(home, ".nvm"), "versions", "node");
520
+ return readdir(root).filter((name) => /^v\d/u.test(name)).sort(newestFirst).map((name) => path.join(root, name, "bin"));
521
+ }
522
+ /**
523
+ * Look a program up on PATH.
524
+ * @param names - file names to try in each directory, in order (on Windows
525
+ * pass the real file names, e.g. `claude.exe` and `claude.cmd`).
526
+ * @param options - platform, environment, and an existence check.
527
+ * @returns the first existing absolute path, or undefined.
528
+ */
529
+ function searchPath(names, { platform = process.platform, env = process.env, exists = existsSync } = {}) {
530
+ const path = pathFor(platform);
531
+ const value = env?.[pathKey(env, platform)];
532
+ if (typeof value !== "string" || value === "") return void 0;
533
+ for (const raw of value.split(platform === "win32" ? ";" : ":")) {
534
+ const directory = raw.trim().replace(/^"(.*)"$/u, "$1");
535
+ if (directory === "") continue;
536
+ for (const name of names) {
537
+ const candidate = path.join(directory, name);
538
+ if (exists(candidate)) return candidate;
539
+ }
540
+ }
541
+ }
542
+ /**
543
+ * An environment whose PATH starts with the directory a program lives in.
544
+ *
545
+ * npm's and the npm-installed CLI's launchers start with `#!/usr/bin/env node`,
546
+ * and nvm, Homebrew, and the official installers put `node` beside them; a
547
+ * GUI-launched host whose PATH lacks that directory would otherwise fail with
548
+ * `env: node: No such file or directory`.
549
+ * @param env - the environment to extend; not modified.
550
+ * @param program - the program's path; a bare name leaves PATH alone.
551
+ * @param platform - which path rules apply.
552
+ * @returns a new environment.
553
+ */
554
+ function withProgramDirOnPath(env, program, platform = process.platform) {
555
+ const path = pathFor(platform);
556
+ if (typeof program !== "string" || !path.isAbsolute(program)) return { ...env };
557
+ const key = pathKey(env, platform);
558
+ const separator = platform === "win32" ? ";" : ":";
559
+ const directory = path.dirname(program);
560
+ const entries = (typeof env?.[key] === "string" ? env[key] : "").split(separator).filter((entry) => entry !== "");
561
+ if (entries.includes(directory)) return { ...env };
562
+ return {
563
+ ...env,
564
+ [key]: [directory, ...entries].join(separator)
565
+ };
566
+ }
567
+ //#endregion
568
+ //#region src/claude-binary.js
569
+ /**
570
+ * Finding and starting the official Claude Code CLI.
571
+ *
572
+ * Keys in PackyCode's `cc` group are served only to the official client, so
573
+ * their Claude requests are sent by the real CLI binary on this device. These
574
+ * helpers locate it the way the user's shell would and turn an npm launcher
575
+ * into something `spawn` can start without a shell.
576
+ *
577
+ * @module dsh-packycode-api/claude-binary
578
+ */
579
+ /** The npm package the official CLI ships as. */
580
+ const CLAUDE_NPM_PACKAGE = "@anthropic-ai/claude-code";
581
+ /** Isolated CLI home; the plugin owns the endpoint and key it runs with. */
582
+ const DEFAULT_CLAUDE_CONFIG_DIR = join(homedir(), ".dsh", "packycode-claude-cli");
583
+ /** What a request reports when no CLI could be started at all. */
584
+ const CLAUDE_MISSING_MESSAGE = "本机没有可用的 Claude Code CLI,而当前 Key 的 Claude 分组只接受官方 CLI 发出的请求。请在 设置 → PackyCode → Claude Code CLI 安装,或在终端运行 npm install -g @anthropic-ai/claude-code,然后重发。 / Claude Code CLI is not installed on this device, and this key's Claude group only serves the official CLI. Install it under Settings → PackyCode, or run: npm install -g @anthropic-ai/claude-code";
585
+ /** The CLI's file names on PATH: the native installer ships an `.exe`, npm a `.cmd` launcher. */
586
+ const claudeFileNames = (platform) => platform === "win32" ? ["claude.exe", "claude.cmd"] : ["claude"];
587
+ /**
588
+ * Where the official installer, npm, and the common Node managers put the CLI,
589
+ * in the order they are tried. PATH is searched after these.
590
+ * @param options - platform, home directory, environment, and a directory reader.
591
+ * @returns absolute candidate paths.
592
+ */
593
+ function claudeBinaryCandidates({ platform = process.platform, home = homedir(), env = process.env, readdir } = {}) {
594
+ const path = pathFor(platform);
595
+ if (platform === "win32") {
596
+ const appData = env.APPDATA ?? path.join(home, "AppData", "Roaming");
597
+ return [
598
+ path.join(home, ".local", "bin", "claude.exe"),
599
+ path.join(appData, "npm", "claude.cmd"),
600
+ ...env.NVM_SYMLINK ? [path.join(env.NVM_SYMLINK, "claude.cmd")] : [],
601
+ path.join(env.ProgramFiles ?? "C:\\Program Files", "nodejs", "claude.cmd")
602
+ ];
603
+ }
604
+ return [
605
+ path.join(home, ".local", "bin", "claude"),
606
+ path.join(home, ".claude", "local", "claude"),
607
+ "/opt/homebrew/bin/claude",
608
+ "/usr/local/bin/claude",
609
+ path.join(home, ".npm-global", "bin", "claude"),
610
+ path.join(home, ".volta", "bin", "claude"),
611
+ ...nvmBinDirs({
612
+ home,
613
+ env,
614
+ platform,
615
+ readdir
616
+ }).map((directory) => path.join(directory, "claude"))
617
+ ];
618
+ }
619
+ /**
620
+ * Locate an installed Claude Code CLI.
621
+ * @param options - a configured path (wins when it exists), plus platform,
622
+ * home, environment, and file-system seams.
623
+ * @returns an absolute path, or undefined when none is installed.
624
+ */
625
+ function locateClaudeBinary({ explicit, platform = process.platform, home = homedir(), env = process.env, exists = existsSync, readdir } = {}) {
626
+ const configured = typeof explicit === "string" ? explicit.trim() : "";
627
+ if (configured !== "") {
628
+ if (pathFor(platform).isAbsolute(configured)) return exists(configured) ? configured : void 0;
629
+ return searchPath(platform === "win32" && !/\.[a-z]+$/iu.test(configured) ? [`${configured}.exe`, `${configured}.cmd`] : [configured], {
630
+ platform,
631
+ env,
632
+ exists
633
+ });
634
+ }
635
+ for (const candidate of claudeBinaryCandidates({
636
+ platform,
637
+ home,
638
+ env,
639
+ readdir
640
+ })) if (exists(candidate)) return candidate;
641
+ return searchPath(claudeFileNames(platform), {
642
+ platform,
643
+ env,
644
+ exists
645
+ });
646
+ }
647
+ /** The program an npm `.cmd` launcher runs, relative to the launcher's own directory. */
648
+ const SHIM_TARGET = /"%(?:~dp0|dp0%)\\([^"%]+?\.(?:c?js|mjs|exe))"/giu;
649
+ /**
650
+ * Turn a located CLI into something `spawn` can start without a shell.
651
+ *
652
+ * On Windows npm installs `claude.cmd`, and Node refuses to start a `.cmd`
653
+ * without `shell: true` — which would hand arguments to `cmd.exe` to parse.
654
+ * The launcher only forwards to a program beside it, so that program is
655
+ * started directly instead: `node <cli.js>` or the packaged `.exe`.
656
+ * @param binary - the located CLI.
657
+ * @param options - platform and file-system seams.
658
+ * @returns the command and the arguments that precede the CLI's own.
659
+ */
660
+ function resolveSpawnCommand(binary, { platform = process.platform, exists = existsSync, readFile = (file) => readFileSync(file, "utf8") } = {}) {
661
+ if (platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(binary)) return {
662
+ command: binary,
663
+ args: []
664
+ };
665
+ const path = pathFor(platform);
666
+ const directory = path.dirname(binary);
667
+ let relative;
668
+ try {
669
+ relative = [...readFile(binary).matchAll(SHIM_TARGET)].map((match) => match[1]).find((target) => !/^node\.exe$/iu.test(target));
670
+ } catch {}
671
+ const target = relative === void 0 ? path.join(directory, "node_modules", ...CLAUDE_NPM_PACKAGE.split("/"), "cli.js") : path.join(directory, relative);
672
+ if (!exists(target)) throw new Error(`Claude Code CLI launcher ${binary} points at a missing program; reinstall it with npm install -g ${CLAUDE_NPM_PACKAGE}`);
673
+ if (/\.exe$/iu.test(target)) return {
674
+ command: target,
675
+ args: []
676
+ };
677
+ const bundledNode = path.join(directory, "node.exe");
678
+ return {
679
+ command: exists(bundledNode) ? bundledNode : "node",
680
+ args: [target]
681
+ };
682
+ }
683
+ //#endregion
684
+ //#region src/claude-install.js
685
+ /**
686
+ * Detecting, and installing, the local Claude Code CLI.
687
+ *
688
+ * Claude requests on a key whose group serves only the official client are
689
+ * sent by this device's Claude Code CLI, so the settings panel shows whether
690
+ * one is installed (and which version), and offers to install it with
691
+ * `npm install -g @anthropic-ai/claude-code` — the same command on macOS and
692
+ * Windows. Installation runs on the host, never in a shell built from user
693
+ * text: npm's arguments are constants.
694
+ *
695
+ * @module dsh-packycode-api/claude-install
696
+ */
697
+ const CLAUDE_INSTALL_COMMAND = `npm install -g ${CLAUDE_NPM_PACKAGE}`;
698
+ const INSTALL_ARGS = Object.freeze([
699
+ "install",
700
+ "-g",
701
+ CLAUDE_NPM_PACKAGE
702
+ ]);
703
+ const PREFIX_ARGS = Object.freeze(["prefix", "-g"]);
704
+ const VERSION_TIMEOUT_MS = 2e4;
705
+ const PREFIX_TIMEOUT_MS = 2e4;
706
+ const INSTALL_TIMEOUT_MS = 900 * 1e3;
707
+ /** A detection is reused this long; the panel's refresh and an install bypass it. */
708
+ const DETECTION_TTL_MS = 300 * 1e3;
709
+ const DETAIL_CHARS = 800;
710
+ /**
711
+ * The version `claude --version` prints, e.g. `2.1.3 (Claude Code)`.
712
+ * @param output - the command's stdout.
713
+ * @returns the semantic version, or undefined.
714
+ */
715
+ function parseClaudeVersion(output) {
716
+ return /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/u.exec(typeof output === "string" ? output : "")?.[1];
717
+ }
718
+ /** The last lines of a command's output, bounded for display. */
719
+ function detailOf(...texts) {
720
+ const tail = texts.filter((text) => typeof text === "string").join("\n").split(/\r?\n/u).map((line) => line.trimEnd()).filter((line) => line.trim() !== "").slice(-8).join("\n");
721
+ return tail.length > DETAIL_CHARS ? tail.slice(-800) : tail;
722
+ }
723
+ /**
724
+ * Where npm usually lives, in the order tried after PATH.
725
+ * @param options - platform, home, environment, the host's own Node, and a directory reader.
726
+ * @returns absolute candidate paths.
727
+ */
728
+ function npmCandidates({ platform = process.platform, home = homedir(), env = process.env, execPath = process.execPath, readdir } = {}) {
729
+ const path = pathFor(platform);
730
+ const name = platform === "win32" ? "npm.cmd" : "npm";
731
+ const beside = typeof execPath === "string" && execPath !== "" ? [path.join(path.dirname(execPath), name)] : [];
732
+ if (platform === "win32") return [
733
+ ...beside,
734
+ ...env.NVM_SYMLINK ? [path.join(env.NVM_SYMLINK, name)] : [],
735
+ path.join(env.ProgramFiles ?? "C:\\Program Files", "nodejs", name)
736
+ ];
737
+ return [
738
+ ...beside,
739
+ "/opt/homebrew/bin/npm",
740
+ "/usr/local/bin/npm",
741
+ path.join(home, ".volta", "bin", "npm"),
742
+ ...nvmBinDirs({
743
+ home,
744
+ env,
745
+ platform,
746
+ readdir
747
+ }).map((directory) => path.join(directory, "npm"))
748
+ ];
749
+ }
750
+ /**
751
+ * Locate npm: PATH first (the one the user's terminal runs), then the usual places.
752
+ * @param options - as {@link npmCandidates}, plus an existence check.
753
+ * @returns an absolute path, or undefined.
754
+ */
755
+ function locateNpm({ platform = process.platform, home = homedir(), env = process.env, execPath = process.execPath, exists = existsSync, readdir } = {}) {
756
+ const onPath = searchPath(platform === "win32" ? ["npm.cmd"] : ["npm"], {
757
+ platform,
758
+ env,
759
+ exists
760
+ });
761
+ if (onPath !== void 0) return onPath;
762
+ return npmCandidates({
763
+ platform,
764
+ home,
765
+ env,
766
+ execPath,
767
+ readdir
768
+ }).find((candidate) => exists(candidate));
769
+ }
770
+ /**
771
+ * Classify an npm failure so the panel can say what to do about it.
772
+ * @param result - the captured npm run.
773
+ * @returns `{ code, detail }`.
774
+ */
775
+ function explainNpmFailure(result) {
776
+ const detail = detailOf(result?.error?.message, result?.stderr, result?.stdout);
777
+ if (/\bEACCES\b|\bEPERM\b|permission denied|operation not permitted/iu.test(detail)) return {
778
+ code: "permission",
779
+ detail
780
+ };
781
+ if (/\bENOTFOUND\b|\bETIMEDOUT\b|\bECONNRESET\b|\bECONNREFUSED\b|network/iu.test(detail)) return {
782
+ code: "network",
783
+ detail
784
+ };
785
+ if (/timed out after/iu.test(detail)) return {
786
+ code: "timeout",
787
+ detail
788
+ };
789
+ return {
790
+ code: "failed",
791
+ detail: detail === "" ? `npm exited with code ${result?.code ?? "unknown"}` : detail
792
+ };
793
+ }
794
+ /**
795
+ * Run a command to completion and capture its output. Never rejects: a
796
+ * missing program or a timeout comes back as `error`.
797
+ */
798
+ function runCaptured(spawnImpl, command, args, options, timeoutMs) {
799
+ return new Promise((resolve) => {
800
+ let child;
801
+ try {
802
+ child = spawnImpl(command, args, {
803
+ ...options,
804
+ stdio: [
805
+ "ignore",
806
+ "pipe",
807
+ "pipe"
808
+ ],
809
+ windowsHide: true
810
+ });
811
+ } catch (error) {
812
+ resolve({
813
+ code: null,
814
+ stdout: "",
815
+ stderr: "",
816
+ error
817
+ });
818
+ return;
819
+ }
820
+ let stdout = "";
821
+ let stderr = "";
822
+ let done = false;
823
+ const finish = (outcome) => {
824
+ if (done) return;
825
+ done = true;
826
+ clearTimeout(timer);
827
+ resolve({
828
+ stdout,
829
+ stderr,
830
+ ...outcome
831
+ });
832
+ };
833
+ const timer = setTimeout(() => {
834
+ try {
835
+ child.kill();
836
+ } catch {}
837
+ finish({
838
+ code: null,
839
+ error: /* @__PURE__ */ new Error(`timed out after ${Math.round(timeoutMs / 1e3)}s`)
840
+ });
841
+ }, timeoutMs);
842
+ timer.unref?.();
843
+ child.stdout?.setEncoding?.("utf8");
844
+ child.stderr?.setEncoding?.("utf8");
845
+ child.stdout?.on("data", (chunk) => {
846
+ stdout = (stdout + String(chunk)).slice(-65536);
847
+ });
848
+ child.stderr?.on("data", (chunk) => {
849
+ stderr = (stderr + String(chunk)).slice(-65536);
850
+ });
851
+ child.on("error", (error) => finish({
852
+ code: null,
853
+ error
854
+ }));
855
+ child.on("close", (code) => finish({
856
+ code,
857
+ error: void 0
858
+ }));
859
+ });
860
+ }
861
+ /**
862
+ * The CLI manager behind the settings panel and the runner.
863
+ * @param options - the configured CLI path (a function, so a settings change
864
+ * applies without a restart), plus platform, environment, and process seams.
865
+ * @returns `{ binary, detect, install, status }`.
866
+ */
867
+ function createClaudeCliManager({ explicitPath = () => "", platform = process.platform, home = homedir(), env = process.env, execPath = process.execPath, configDir = DEFAULT_CLAUDE_CONFIG_DIR, spawnImpl = spawn, exists = existsSync, readFile = (file) => readFileSync(file, "utf8"), readdir, now = () => Date.now() } = {}) {
868
+ const path = pathFor(platform);
869
+ const configured = () => {
870
+ const value = explicitPath();
871
+ return typeof value === "string" ? value.trim() : "";
872
+ };
873
+ const locateOptions = () => ({
874
+ platform,
875
+ home,
876
+ env,
877
+ exists,
878
+ readdir
879
+ });
880
+ const npm = () => locateNpm({
881
+ ...locateOptions(),
882
+ execPath
883
+ });
884
+ /**
885
+ * Run npm. On Windows npm is a `.cmd` launcher, which Node starts only
886
+ * through `cmd.exe`; that is safe here because every argument is a constant.
887
+ */
888
+ const runNpm = (program, args, timeoutMs) => runCaptured(spawnImpl, platform === "win32" ? `"${program}"` : program, args, {
889
+ env: withProgramDirOnPath(env, program, platform),
890
+ shell: platform === "win32"
891
+ }, timeoutMs);
892
+ /** npm's global bin directory, read once; a GUI PATH often lacks it. */
893
+ let globalBin;
894
+ const npmGlobalBin = async () => {
895
+ if (globalBin !== void 0) return globalBin === "" ? void 0 : globalBin;
896
+ const program = npm();
897
+ if (program === void 0) return void 0;
898
+ const result = await runNpm(program, PREFIX_ARGS, PREFIX_TIMEOUT_MS);
899
+ const prefix = result.code === 0 ? result.stdout.trim().split(/\r?\n/u).at(-1)?.trim() ?? "" : "";
900
+ globalBin = prefix === "" ? "" : platform === "win32" ? prefix : path.join(prefix, "bin");
901
+ return globalBin === "" ? void 0 : globalBin;
902
+ };
903
+ const locate = async () => {
904
+ const found = locateClaudeBinary({
905
+ explicit: configured(),
906
+ ...locateOptions()
907
+ });
908
+ if (found !== void 0 || configured() !== "") return found;
909
+ const bin = await npmGlobalBin();
910
+ if (bin === void 0) return void 0;
911
+ return claudeFileNames(platform).map((name) => path.join(bin, name)).find((candidate) => exists(candidate));
912
+ };
913
+ const probe = async () => {
914
+ const found = await locate();
915
+ if (found === void 0) return configured() === "" ? { installed: false } : {
916
+ installed: false,
917
+ configuredPath: configured()
918
+ };
919
+ let command;
920
+ try {
921
+ command = resolveSpawnCommand(found, {
922
+ platform,
923
+ exists,
924
+ readFile
925
+ });
926
+ } catch (error) {
927
+ return {
928
+ installed: true,
929
+ path: found,
930
+ error: error.message
931
+ };
932
+ }
933
+ const result = await runCaptured(spawnImpl, command.command, [...command.args, "--version"], { env: {
934
+ ...withProgramDirOnPath(env, found, platform),
935
+ CLAUDE_CONFIG_DIR: configDir,
936
+ DISABLE_AUTOUPDATER: "1",
937
+ DISABLE_TELEMETRY: "1",
938
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
939
+ } }, VERSION_TIMEOUT_MS);
940
+ const version = result.code === 0 ? parseClaudeVersion(result.stdout) : void 0;
941
+ if (version !== void 0) return {
942
+ installed: true,
943
+ path: found,
944
+ version
945
+ };
946
+ return {
947
+ installed: true,
948
+ path: found,
949
+ error: detailOf(result.error?.message, result.stderr, result.stdout) || `exited with code ${result.code ?? "unknown"}`
950
+ };
951
+ };
952
+ let detection;
953
+ let detecting;
954
+ const detect = async ({ force = false } = {}) => {
955
+ if (!force && detection !== void 0 && now() - detection.checkedAt < DETECTION_TTL_MS) return detection;
956
+ if (detecting !== void 0) return detecting;
957
+ detecting = probe().catch((error) => ({
958
+ installed: false,
959
+ error: error instanceof Error ? error.message : String(error)
960
+ })).then((value) => {
961
+ detection = {
962
+ ...value,
963
+ checkedAt: now()
964
+ };
965
+ return detection;
966
+ }).finally(() => {
967
+ detecting = void 0;
968
+ });
969
+ return detecting;
970
+ };
971
+ let installation = { state: "idle" };
972
+ let installing;
973
+ const install = () => {
974
+ if (installing !== void 0) return installation;
975
+ const program = npm();
976
+ if (program === void 0) {
977
+ installation = {
978
+ state: "failed",
979
+ code: "npm-missing",
980
+ finishedAt: now()
981
+ };
982
+ return installation;
983
+ }
984
+ installation = {
985
+ state: "running",
986
+ startedAt: now(),
987
+ npm: program
988
+ };
989
+ installing = runNpm(program, INSTALL_ARGS, INSTALL_TIMEOUT_MS).then(async (result) => {
990
+ globalBin = void 0;
991
+ if (result.code !== 0) {
992
+ installation = {
993
+ state: "failed",
994
+ ...explainNpmFailure(result),
995
+ finishedAt: now()
996
+ };
997
+ await detect({ force: true });
998
+ return;
999
+ }
1000
+ const found = await detect({ force: true });
1001
+ installation = found.installed && found.version !== void 0 ? {
1002
+ state: "succeeded",
1003
+ finishedAt: now()
1004
+ } : {
1005
+ state: "failed",
1006
+ code: "not-found-after-install",
1007
+ detail: detailOf(result.stdout, result.stderr),
1008
+ finishedAt: now()
1009
+ };
1010
+ }).catch((error) => {
1011
+ installation = {
1012
+ state: "failed",
1013
+ code: "failed",
1014
+ detail: error instanceof Error ? error.message : String(error),
1015
+ finishedAt: now()
1016
+ };
1017
+ }).finally(() => {
1018
+ installing = void 0;
1019
+ });
1020
+ return installation;
1021
+ };
1022
+ return Object.freeze({
1023
+ /**
1024
+ * The CLI the runner should start: the last detected one while it still
1025
+ * exists, otherwise a fresh lookup — so a CLI installed after startup,
1026
+ * from the panel or a terminal, is used without restarting.
1027
+ */
1028
+ async binary() {
1029
+ if (detection?.path !== void 0 && exists(detection.path)) return detection.path;
1030
+ return await locate() ?? (configured() === "" ? "claude" : configured());
1031
+ },
1032
+ detect,
1033
+ /** The last detection, without probing again; undefined before the first. */
1034
+ lastDetection: () => detection === void 0 ? void 0 : { ...detection },
1035
+ install,
1036
+ /** Wait for an install in flight; for tests and scripts. */
1037
+ settled: () => installing ?? Promise.resolve(),
1038
+ /**
1039
+ * What the settings panel shows. While an install runs, the last detection
1040
+ * stands instead of probing a half-written package.
1041
+ */
1042
+ async status({ force = false } = {}) {
1043
+ const value = installing === void 0 ? await detect({ force }) : detection;
1044
+ return {
1045
+ installed: value?.installed === true,
1046
+ ...value?.version === void 0 ? {} : { version: value.version },
1047
+ ...value?.path === void 0 ? {} : { path: value.path },
1048
+ ...value?.configuredPath === void 0 ? {} : { configuredPath: value.configuredPath },
1049
+ ...value?.error === void 0 ? {} : { error: value.error },
1050
+ ...value?.checkedAt === void 0 ? {} : { checkedAt: value.checkedAt },
1051
+ platform,
1052
+ command: CLAUDE_INSTALL_COMMAND,
1053
+ install: { ...installation }
1054
+ };
1055
+ }
1056
+ });
1057
+ }
1058
+ //#endregion
1059
+ //#region src/claude-mcp-bridge.js
1060
+ /**
1061
+ * The MCP server through which the Claude Code CLI sees DSH's tools.
1062
+ *
1063
+ * The CLI runs with its own tools disabled. Each tool the DSH request offers
1064
+ * is listed here instead, so the CLI's request carries DSH's exact names,
1065
+ * descriptions and schemas (under the `mcp__dsh__` prefix). A call the model
1066
+ * makes is never executed here: DSH executes it — with its own permissions,
1067
+ * sandbox, plan-mode gate and UI — and the result it sends on the next step
1068
+ * answers the call the CLI is holding open.
1069
+ *
1070
+ * One server per CLI process: a stateless, JSON-response Streamable HTTP
1071
+ * endpoint bound to 127.0.0.1, guarded by a random capability that only the
1072
+ * owner-only config file carries. No API key or user data crosses it.
1073
+ *
1074
+ * @module dsh-packycode-api/claude-mcp-bridge
1075
+ */
1076
+ /** The MCP server name; the CLI exposes its tools as `mcp__dsh__<name>`. */
1077
+ const MCP_SERVER_NAME = "dsh";
1078
+ const MCP_TOOL_PREFIX = `mcp__dsh__`;
1079
+ /** Tool arguments can carry whole files; bound them, but generously. */
1080
+ const MAX_BODY_BYTES = 64 * 1024 * 1024;
1081
+ const PROTOCOL_VERSIONS = Object.freeze([
1082
+ "2025-11-25",
1083
+ "2025-06-18",
1084
+ "2025-03-26",
1085
+ "2024-11-05"
1086
+ ]);
1087
+ const record$3 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1088
+ const validId = (value) => typeof value === "string" && value.length <= 256 || typeof value === "number" && Number.isSafeInteger(value);
1089
+ var RpcError = class extends Error {
1090
+ constructor(code, message) {
1091
+ super(message);
1092
+ this.code = code;
1093
+ }
1094
+ };
1095
+ /** An Anthropic tool_result block as an MCP CallToolResult. */
1096
+ function toolResultToMcp(block) {
1097
+ const parts = typeof block?.content === "string" ? [{
1098
+ type: "text",
1099
+ text: block.content
1100
+ }] : Array.isArray(block?.content) ? block.content : [];
1101
+ const content = [];
1102
+ for (const part of parts) if (part?.type === "text" && typeof part.text === "string") content.push({
1103
+ type: "text",
1104
+ text: part.text
1105
+ });
1106
+ else if (part?.type === "image" && part.source?.type === "base64") content.push({
1107
+ type: "image",
1108
+ data: part.source.data,
1109
+ mimeType: part.source.media_type
1110
+ });
1111
+ else if (part !== void 0) content.push({
1112
+ type: "text",
1113
+ text: JSON.stringify(part)
1114
+ });
1115
+ return {
1116
+ content,
1117
+ ...block?.is_error === true ? { isError: true } : {}
1118
+ };
1119
+ }
1120
+ /**
1121
+ * Start a bridge.
1122
+ * @param options - the tools to list (Anthropic tool definitions whose names
1123
+ * carry the `mcp__dsh__` prefix), and the call sink: `onCall({ toolUseId,
1124
+ * name, arguments })` returns a promise of the MCP result; `toolUseId` is
1125
+ * undefined when the CLI does not send it.
1126
+ * @returns `{ url, authorization, setTools, close }`.
1127
+ */
1128
+ async function startClaudeMcpBridge({ tools = [], onCall, version = "1.0.0", serverFactory = createServer } = {}) {
1129
+ if (typeof onCall !== "function") throw new Error("the Claude MCP bridge needs a call sink");
1130
+ const capability = Buffer.from(`Bearer ${randomBytes(32).toString("hex")}`);
1131
+ let listed = [];
1132
+ const setTools = (next) => {
1133
+ listed = (Array.isArray(next) ? next : []).filter((tool) => typeof tool?.name === "string" && tool.name.startsWith(MCP_TOOL_PREFIX) && record$3(tool.input_schema)).map((tool) => ({
1134
+ name: tool.name.slice(MCP_TOOL_PREFIX.length),
1135
+ description: typeof tool.description === "string" ? tool.description : "",
1136
+ inputSchema: tool.input_schema
1137
+ }));
1138
+ };
1139
+ setTools(tools);
1140
+ let closed = false;
1141
+ let origin;
1142
+ const reply = (res, status, value) => {
1143
+ if (res.destroyed || res.writableEnded) return;
1144
+ res.writeHead(status, {
1145
+ "Content-Type": "application/json",
1146
+ "Cache-Control": "no-store",
1147
+ "X-Content-Type-Options": "nosniff"
1148
+ });
1149
+ res.end(value === void 0 ? "" : JSON.stringify(value));
1150
+ };
1151
+ const authorized = (req) => {
1152
+ const actual = Buffer.from(typeof req.headers.authorization === "string" ? req.headers.authorization : "");
1153
+ return actual.length === capability.length && timingSafeEqual(actual, capability);
1154
+ };
1155
+ const dispatch = async (message) => {
1156
+ const params = message.params;
1157
+ switch (message.method) {
1158
+ case "initialize": return {
1159
+ protocolVersion: PROTOCOL_VERSIONS.includes(params?.protocolVersion) ? params.protocolVersion : PROTOCOL_VERSIONS[0],
1160
+ capabilities: { tools: { listChanged: false } },
1161
+ serverInfo: {
1162
+ name: "dsh",
1163
+ title: "DeepSeek Harness",
1164
+ version
1165
+ }
1166
+ };
1167
+ case "ping": return {};
1168
+ case "tools/list": return { tools: listed };
1169
+ case "tools/call": {
1170
+ if (!record$3(params) || typeof params.name !== "string") throw new RpcError(-32602, "Invalid tool call");
1171
+ const id = params._meta?.["claudecode/toolUseId"];
1172
+ return onCall({
1173
+ toolUseId: typeof id === "string" && id !== "" ? id : void 0,
1174
+ name: params.name,
1175
+ arguments: params.arguments
1176
+ });
1177
+ }
1178
+ default: throw new RpcError(-32601, "Method not found");
1179
+ }
1180
+ };
1181
+ const handle = async (req, res) => {
1182
+ if (closed || req.headers.host !== new URL(origin).host || req.headers.origin !== void 0 && req.headers.origin !== origin) return reply(res, 403);
1183
+ if (!authorized(req)) return reply(res, 401);
1184
+ if (req.url !== "/mcp") return reply(res, 404);
1185
+ if (req.method !== "POST") {
1186
+ res.setHeader("Allow", "POST");
1187
+ return reply(res, 405);
1188
+ }
1189
+ const declared = req.headers["content-length"];
1190
+ if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > MAX_BODY_BYTES)) return reply(res, 413);
1191
+ const chunks = [];
1192
+ let size = 0;
1193
+ try {
1194
+ for await (const chunk of req.iterator({ destroyOnReturn: false })) {
1195
+ size += chunk.length;
1196
+ if (size > MAX_BODY_BYTES) {
1197
+ req.resume();
1198
+ return reply(res, 413);
1199
+ }
1200
+ chunks.push(chunk);
1201
+ }
1202
+ } catch {
1203
+ return reply(res, 400);
1204
+ }
1205
+ let message;
1206
+ try {
1207
+ message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1208
+ } catch {
1209
+ return reply(res, 400, {
1210
+ jsonrpc: "2.0",
1211
+ id: null,
1212
+ error: {
1213
+ code: -32700,
1214
+ message: "Parse error"
1215
+ }
1216
+ });
1217
+ }
1218
+ if (!record$3(message) || message.jsonrpc !== "2.0" || typeof message.method !== "string" || Object.hasOwn(message, "id") && !validId(message.id)) {
1219
+ if (record$3(message) && message.jsonrpc === "2.0" && message.method === void 0) return reply(res, 202);
1220
+ return reply(res, 400, {
1221
+ jsonrpc: "2.0",
1222
+ id: null,
1223
+ error: {
1224
+ code: -32600,
1225
+ message: "Invalid request"
1226
+ }
1227
+ });
1228
+ }
1229
+ if (!Object.hasOwn(message, "id")) return reply(res, 202);
1230
+ try {
1231
+ const result = await dispatch(message);
1232
+ reply(res, 200, {
1233
+ jsonrpc: "2.0",
1234
+ id: message.id,
1235
+ result
1236
+ });
1237
+ } catch (error) {
1238
+ reply(res, 200, {
1239
+ jsonrpc: "2.0",
1240
+ id: message.id,
1241
+ error: {
1242
+ code: error instanceof RpcError ? error.code : -32603,
1243
+ message: error instanceof RpcError ? error.message : "Internal error"
1244
+ }
1245
+ });
1246
+ }
1247
+ };
1248
+ const server = serverFactory((req, res) => {
1249
+ handle(req, res).catch(() => reply(res, 500));
1250
+ });
1251
+ server.requestTimeout = 0;
1252
+ server.headersTimeout = 6e4;
1253
+ server.maxConnections = 32;
1254
+ server.on("clientError", (_error, socket) => socket.destroy());
1255
+ await new Promise((resolve, reject) => {
1256
+ server.once("error", reject);
1257
+ server.listen(0, "127.0.0.1", () => {
1258
+ server.off("error", reject);
1259
+ resolve();
1260
+ });
1261
+ });
1262
+ server.unref();
1263
+ origin = `http://127.0.0.1:${server.address().port}`;
1264
+ let closing;
1265
+ return {
1266
+ url: `${origin}/mcp`,
1267
+ authorization: capability.toString(),
1268
+ setTools,
1269
+ close() {
1270
+ if (closing !== void 0) return closing;
1271
+ closed = true;
1272
+ closing = new Promise((resolve) => server.close(() => resolve()));
1273
+ server.closeAllConnections();
1274
+ return closing;
1275
+ }
1276
+ };
1277
+ }
1278
+ //#endregion
1279
+ //#region src/failures.js
1280
+ /**
1281
+ * Failure classification for the two conditions that move the key ring along.
1282
+ *
1283
+ * The gateway reports an exhausted key and a rejected key in Chinese, and the
1284
+ * harness classifies failures by fixed English/status wording it recognizes
1285
+ * (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
1286
+ * harness's retry policy re-run the step — and therefore what lets the retry
1287
+ * pick up the next key. Rewriting is honest about what happened: this plugin
1288
+ * knows the key's condition from the gateway's own words plus its balance
1289
+ * reader, so the sentence it substitutes says no more than that.
1290
+ *
1291
+ * @module dsh-packycode-api/failures
1292
+ */
1293
+ /** Terminal quota, balance, or credit wording, in the gateway's own languages. */
1294
+ const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
1295
+ /** A rejected key names itself; a bare 403 does not, and must not disable one. */
1296
+ const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
1297
+ const INVALID_KEY_STATUS = /\b401\b/;
1298
+ /**
1299
+ * A gateway group that only serves its vendor's official client. These arrive
1300
+ * as 403, which the harness reads as an authentication failure and renders as
1301
+ * an invalid-API-key problem — the opposite of what happened. This wording
1302
+ * also covers intermittent client-anomaly refusals; it does not establish
1303
+ * whether a future request will be accepted.
1304
+ */
1305
+ 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;
1306
+ /** A content-policy refusal observed on an already-authorized request. */
1307
+ const TEST_MESSAGE_POLICY_CODE = "GATEWAY_TEST_MESSAGE";
1308
+ const TEST_MESSAGE_PREFIX = `[${TEST_MESSAGE_POLICY_CODE}]`;
1309
+ /** Match the captured refusal narrowly; a bare 403 is still not evidence. */
1310
+ function classifyTestMessagePolicy(message) {
1311
+ if (typeof message !== "string") return false;
1312
+ return message.startsWith(TEST_MESSAGE_PREFIX) || /already connected and authorized/iu.test(message) && /no need to send test messages/iu.test(message) && /verify the connection/iu.test(message);
1313
+ }
1314
+ /** Keep the host's status-word classifier from turning this into AUTH. */
1315
+ function explainTestMessagePolicyFailure() {
1316
+ return `${TEST_MESSAGE_PREFIX} PackyCode 将这次请求识别为连通性测试并拒绝。请使用实际业务内容;本次拒绝不表示 Key 失效,也不会禁用或切换 Key。`;
1317
+ }
1318
+ /**
1319
+ * The gateway's answer when a key's groups do not include the model's
1320
+ * supplier: `model_not_found` / 当前令牌未覆盖供应商. The key is fine for other
1321
+ * models, so it is never moved aside for this.
1322
+ */
1323
+ const COVERAGE_WORDING = /model_not_found|未覆盖供应商|无可用渠道|no available channel/iu;
1324
+ /**
1325
+ * Whether the gateway refused the model for this key's groups.
1326
+ * @param message - the failure text.
1327
+ * @returns true for a group-coverage refusal.
1328
+ */
1329
+ function classifyCoverage(message) {
1330
+ return COVERAGE_WORDING.test(typeof message === "string" ? message : "");
1331
+ }
1332
+ /** The key's quota is exhausted; move to the next key. */
1333
+ const QUOTA_FAILURE = "quota";
1334
+ /** The gateway rejected the key itself; move to the next key. */
1335
+ const INVALID_KEY_FAILURE = "invalid-key";
1336
+ /**
1337
+ * Whether the gateway is refusing this client rather than this credential.
1338
+ * @param message - provider failure text, as pi-ai carries it.
1339
+ * @returns true when the route is gated to a vendor's official client.
1340
+ */
1341
+ function classifyClientPolicy(message) {
1342
+ return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
1343
+ }
1344
+ /**
1345
+ * OpenAI's generic transient failure: the sentence its `server_error` carries,
1346
+ * or the `server_error:` prefix pi-ai writes when the event names the code.
1347
+ * The gateway can relay it inside a stream it has already answered 200 for —
1348
+ * an SSE `error` event with a 503 `server_error`, then `response.failed` — so
1349
+ * no HTTP status comes with it.
1350
+ */
1351
+ const UPSTREAM_SERVER_ERROR = /an error occurred while processing your request\b[\s\S]*\bhelp\.openai\.com\b|^(?:error code\s+)?server_error:/iu;
1352
+ /**
1353
+ * Whether a failure is the upstream's transient server error, which a resent
1354
+ * request clears. The key is not at fault.
1355
+ * @param message - provider failure text, as pi-ai carries it.
1356
+ * @returns true for OpenAI's generic `server_error`.
1357
+ */
1358
+ function classifyUpstreamServerError(message) {
1359
+ return UPSTREAM_SERVER_ERROR.test(typeof message === "string" ? message : "");
1360
+ }
1361
+ /**
1362
+ * Classify a gateway failure message.
1363
+ *
1364
+ * Quota wording wins over a status token: an exhausted balance is often
1365
+ * reported as 402/403, and only the key's own rejection may read as 401. A
1366
+ * client-policy refusal is not a key fault and is classified separately.
1367
+ * @param message - provider failure text, as pi-ai carries it.
1368
+ * @returns the failure kind, or undefined when the key is not at fault.
1369
+ */
1370
+ function classifyGatewayFailure(message) {
1371
+ const text = typeof message === "string" ? message : "";
1372
+ if (text === "") return void 0;
1373
+ if (classifyClientPolicy(text) || classifyTestMessagePolicy(text)) return void 0;
1374
+ if (QUOTA_WORDING.test(text)) return QUOTA_FAILURE;
1375
+ if (INVALID_KEY_WORDING.test(text)) return INVALID_KEY_FAILURE;
1376
+ if (INVALID_KEY_STATUS.test(text)) return INVALID_KEY_FAILURE;
1377
+ }
1378
+ /**
1379
+ * Rewrite a failure message so the harness classifies it as quota or auth.
1380
+ *
1381
+ * A bare status token is neutralized first: an exhausted balance arrives as
1382
+ * 402 or 403 on some gateway routes, and the harness's auth rule matches
1383
+ * 401/403 before it reads any wording, which would send the failure into a
1384
+ * category the retry policy does not re-run.
1385
+ * @param message - the original provider failure text.
1386
+ * @param kind - the classification of {@link classifyGatewayFailure}.
1387
+ * @returns text the harness routes to QUOTA or AUTH.
1388
+ */
1389
+ /**
1390
+ * Explain a client-policy refusal so the user reads the real cause.
1391
+ *
1392
+ * The sentence must not read as any other failure the harness knows: it
1393
+ * deliberately carries no HTTP status and none of the words the classifier
1394
+ * matches for auth, quota, rate limits, timeouts, or transport faults. That
1395
+ * keeps the failure out of the host's generic retry policy and lets the
1396
+ * sentence reach the user verbatim, in
1397
+ * place of the "API key is invalid" the bare 403 would have produced.
1398
+ * @param keyName - the account name the balance reader saw, when known.
1399
+ * @returns the replacement sentence.
1400
+ */
1401
+ function explainClientPolicyFailure() {
1402
+ return "PackyCode 拒绝了这个客户端:所选分组只允许厂商官方客户端调用(Claude 分组要求官方 Claude CLI,Codex 分组要求标准 Codex 客户端)。这不是 API Key 失效。请检查当前分组的客户端要求;若调整分组,应先在模型广场确认该模型可用。 / This group only serves the vendor’s official client; the API key is fine.";
1403
+ }
1404
+ /** Prefix of every failure the Claude Code CLI transport relays from the gateway. */
1405
+ const CLI_MARK = "[Claude Code CLI]";
1406
+ /** The CLI could not be found or started; the transport's error type says so. */
1407
+ const CLI_UNAVAILABLE = "dsh_cli_unavailable";
1408
+ /** The installed CLI refused an option the transport needs. */
1409
+ const CLI_OUTDATED = "dsh_cli_outdated";
1410
+ /**
1411
+ * The message a relayed failure carried. pi-ai reports an HTTP error as its
1412
+ * status followed by the JSON body; the gateway's or CLI's own sentence is
1413
+ * that body's `error.message`.
1414
+ */
1415
+ function relayedMessage(message) {
1416
+ const text = typeof message === "string" ? message : "";
1417
+ const start = text.indexOf("{");
1418
+ if (start >= 0) try {
1419
+ const body = JSON.parse(text.slice(start));
1420
+ const inner = body?.error?.message ?? body?.message;
1421
+ if (typeof inner === "string") return inner;
1422
+ } catch {}
1423
+ return text;
1424
+ }
1425
+ /** Whether a failure says the Claude Code CLI is missing. */
1426
+ const classifyCliUnavailable = (message) => typeof message === "string" && message.includes("dsh_cli_unavailable");
1427
+ /** Whether a failure says the Claude Code CLI is too old for this plugin. */
1428
+ const classifyCliOutdated = (message) => typeof message === "string" && message.includes("dsh_cli_outdated");
1429
+ /**
1430
+ * Explain a missing CLI. Like every explanation here it carries no status
1431
+ * token and no transport wording, so DSH shows it once instead of retrying a
1432
+ * request that cannot start.
1433
+ * @returns the replacement sentence.
1434
+ */
1435
+ function explainCliUnavailable(message) {
1436
+ const detail = relayedMessage(message).replace(/^[\s\S]*?dsh_cli_unavailable:?\s*/u, "").replace(/\b\d{3}\b/gu, "").replace(/\b(?:network|connection|socket|fetch|timed?\s*out|timeout|terminated)\b/giu, "…").trim().slice(0, 300);
1437
+ return "本机没有可用的 Claude Code CLI,而当前 Key 所在的 Claude 分组(cc)只接受官方 Claude Code 客户端。请在 设置 → PackyCode → Claude Code CLI 一键安装,或在终端执行 npm install -g @anthropic-ai/claude-code,然后重发这条消息。也可以改用 cc-sale / cc-expensive 分组的 Key,它们走插件内置的 HTTP 通道。" + (detail === "" || detail === "dsh_cli_unavailable" ? "" : `(启动失败:${detail})`) + " / Claude Code CLI is not available, and this key's Claude group only serves the official client. Install it under Settings → PackyCode (npm install -g @anthropic-ai/claude-code), then resend.";
1438
+ }
1439
+ /**
1440
+ * Explain a CLI that is too old for the options this plugin passes.
1441
+ * @param message - the CLI's own complaint.
1442
+ * @returns the replacement sentence.
1443
+ */
1444
+ function explainCliOutdated(message) {
1445
+ return `本机的 Claude Code CLI 版本过旧,不支持插件所需的参数。请在终端执行 claude update,或 npm install -g @anthropic-ai/claude-code@latest 更新后重发。 / The installed Claude Code CLI is too old for this plugin; update it (claude update). CLI said: ${relayedMessage(message).replace(/^[\s\S]*?dsh_cli_outdated:?\s*/u, "").replace(/\b\d{3}\b/gu, "").slice(0, 300)}`;
1446
+ }
1447
+ /**
1448
+ * Explain a client-policy refusal of a request the official CLI itself sent.
1449
+ * The HTTP route's advice (use the official client) does not apply; the
1450
+ * gateway's words are kept, without their status, for a support request.
1451
+ * @param message - the relayed failure text.
1452
+ * @returns the replacement sentence.
1453
+ */
1454
+ function explainCliClientPolicyFailure(message) {
1455
+ return `PackyCode 拒绝了这次由官方 Claude Code CLI 发出的请求,认为客户端异常。这不是 API Key 失效。请先把 Claude Code CLI 更新到最新版本(claude update)再试;仍然失败时,请把下面的原始错误(含 request id)发给 PackyCode 客服。 / The gateway refused a request the official Claude Code CLI sent; the API key is fine. Gateway said: ${relayedMessage(message).replace(/^[\s\S]*?\[Claude Code CLI\]\s*/u, "").replace(/\b(?:401|403)\b/gu, "HTTP").slice(0, 600)}`;
1456
+ }
1457
+ /**
1458
+ * Rewrite a failure message so the harness classifies it as quota or auth.
1459
+ *
1460
+ * A bare status token is neutralized first: an exhausted balance arrives as
1461
+ * 402 or 403 on some gateway routes, and the harness's auth rule matches
1462
+ * 401/403 before it reads any wording, which would send the failure into a
1463
+ * category the retry policy does not re-run.
1464
+ * @param message - the original provider failure text.
1465
+ * @param kind - the classification of {@link classifyGatewayFailure}.
1466
+ * @param options - optional account name for the policy explanation.
1467
+ * @returns text the harness routes to QUOTA or AUTH, or a policy explanation.
1468
+ */
1469
+ function normalizeFailureMessage(message, kind, options = {}) {
1470
+ const text = typeof message === "string" && message.length > 0 ? message : "PackyCode request failed";
1471
+ if (kind === "client-policy") return explainClientPolicyFailure(options.keyName);
1472
+ if (kind === "quota") return `${neutralizeAuthStatus(text)} — PackyCode: insufficient quota for this API key; trying the next key`;
1473
+ if (kind === "invalid-key") return `${neutralizeAuthStatus(text)} — PackyCode: 401 invalid_authentication for this API key; trying the next key`;
1474
+ return text;
1475
+ }
1476
+ function neutralizeAuthStatus(text) {
1477
+ return text.replace(/\b(?:401|403)\b/gu, "HTTP");
1478
+ }
1479
+ //#endregion
1480
+ //#region src/claude-transcript.js
1481
+ /**
1482
+ * Hand DSH's conversation to the Claude Code CLI.
1483
+ *
1484
+ * The CLI resumes a conversation from a JSONL transcript (`--resume <file>`)
1485
+ * and sends exactly those messages — text, images, signed thinking, tool calls
1486
+ * and results — before the new input. DSH's history, already converted to the
1487
+ * Anthropic Messages shape by the native route, is written in the CLI's
1488
+ * classic transcript format, so the CLI's request carries the same history the
1489
+ * HTTP route would have sent. Request-level cache markers are dropped: the CLI
1490
+ * places its own.
1491
+ *
1492
+ * @module dsh-packycode-api/claude-transcript
1493
+ */
1494
+ const record$2 = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
1495
+ /** A copy without `cache_control` anywhere in the block tree. */
1496
+ function withoutCacheControl(value) {
1497
+ if (Array.isArray(value)) return value.map(withoutCacheControl);
1498
+ if (!record$2(value)) return value;
1499
+ const copy = {};
1500
+ for (const [key, item] of Object.entries(value)) if (key !== "cache_control") copy[key] = withoutCacheControl(item);
1501
+ return copy;
1502
+ }
1503
+ /** Content as a block array. */
1504
+ const contentBlocks = (content) => typeof content === "string" ? content === "" ? [] : [{
1505
+ type: "text",
1506
+ text: content
1507
+ }] : Array.isArray(content) ? content : [];
1508
+ /**
1509
+ * One message in a canonical shape, for comparing requests: pi-ai sends the
1510
+ * newest message as blocks with a cache marker and the same message as a
1511
+ * plain string once it is older, and tool results either way.
1512
+ */
1513
+ function canonicalMessage(message) {
1514
+ const canonical = (block) => block?.type === "tool_result" ? {
1515
+ ...block,
1516
+ content: contentBlocks(block.content)
1517
+ } : block;
1518
+ return {
1519
+ role: message?.role,
1520
+ content: contentBlocks(withoutCacheControl(message?.content)).map(canonical)
1521
+ };
1522
+ }
1523
+ /** Whether a message carries tool results, i.e. continues an open turn. */
1524
+ const hasToolResult = (message) => message?.role === "user" && contentBlocks(message.content).some((block) => block?.type === "tool_result");
1525
+ /** The tool_use ids an assistant message asked for, in order. */
1526
+ const toolUseIds = (message) => message?.role === "assistant" ? contentBlocks(message.content).filter((block) => block?.type === "tool_use").map((block) => block.id) : [];
1527
+ /** Merge consecutive messages of the same role; the CLI expects alternation. */
1528
+ function alternate(messages) {
1529
+ const merged = [];
1530
+ for (const message of messages) {
1531
+ const previous = merged.at(-1);
1532
+ if (previous !== void 0 && previous.role === message.role) merged[merged.length - 1] = {
1533
+ role: message.role,
1534
+ content: [...contentBlocks(previous.content), ...contentBlocks(message.content)]
1535
+ };
1536
+ else merged.push({
1537
+ role: message.role,
1538
+ content: contentBlocks(message.content)
1539
+ });
1540
+ }
1541
+ return merged;
1542
+ }
1543
+ /**
1544
+ * Split a request into what the CLI resumes and what it receives as input.
1545
+ *
1546
+ * - At a turn boundary, trailing user messages are the new input: they go to
1547
+ * the CLI's stdin as one message, after the resumed history.
1548
+ * - Inside a turn (the newest message carries tool results, possibly with a
1549
+ * steering note), everything is resumed and the CLI continues the open turn.
1550
+ * Feeding the note over stdin instead would make the CLI close the turn with
1551
+ * a synthetic reply first.
1552
+ * @param messages - the request's Anthropic messages.
1553
+ * @returns `{ history, input, continues }`; `input` is a content array or
1554
+ * undefined, `continues` is true for an open turn.
1555
+ */
1556
+ function splitConversation(messages) {
1557
+ const clean = alternate(withoutCacheControl(Array.isArray(messages) ? messages : []));
1558
+ const last = clean.at(-1);
1559
+ if (last === void 0) return {
1560
+ history: [],
1561
+ input: void 0,
1562
+ continues: false
1563
+ };
1564
+ if (last.role !== "user") throw new Error("Claude Code CLI cannot continue a conversation that ends with an assistant message");
1565
+ if (hasToolResult(last)) return {
1566
+ history: clean,
1567
+ input: void 0,
1568
+ continues: true
1569
+ };
1570
+ return {
1571
+ history: clean.slice(0, -1),
1572
+ input: last.content,
1573
+ continues: false
1574
+ };
1575
+ }
1576
+ /**
1577
+ * Serialize history as a CLI transcript.
1578
+ * @param history - alternating Anthropic messages, oldest first.
1579
+ * @param options - the CLI session id, working directory, model, and the CLI
1580
+ * version string recorded in each entry.
1581
+ * @returns JSONL text, one entry per message.
1582
+ */
1583
+ function buildTranscript(history, { sessionId, cwd, model, version = "0.0.0", now = Date.now } = {}) {
1584
+ const start = now() - history.length * 1e3;
1585
+ let parentUuid = null;
1586
+ const lines = history.map((message, index) => {
1587
+ const uuid = randomUUID();
1588
+ const content = contentBlocks(message.content);
1589
+ const entry = {
1590
+ parentUuid,
1591
+ isSidechain: false,
1592
+ userType: "external",
1593
+ cwd,
1594
+ sessionId,
1595
+ version,
1596
+ type: message.role,
1597
+ message: message.role === "assistant" ? {
1598
+ id: `msg_dsh_${uuid.replaceAll("-", "")}`,
1599
+ type: "message",
1600
+ role: "assistant",
1601
+ model,
1602
+ content,
1603
+ stop_reason: content.some((block) => block?.type === "tool_use") ? "tool_use" : "end_turn",
1604
+ stop_sequence: null,
1605
+ usage: {
1606
+ input_tokens: 0,
1607
+ output_tokens: 0
1608
+ }
1609
+ } : {
1610
+ role: "user",
1611
+ content
1612
+ },
1613
+ uuid,
1614
+ timestamp: new Date(start + index * 1e3).toISOString()
1615
+ };
1616
+ parentUuid = uuid;
1617
+ return JSON.stringify(entry);
1618
+ });
1619
+ return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
1620
+ }
1621
+ //#endregion
1622
+ //#region src/claude-cli-session.js
1623
+ /**
1624
+ * One Claude Code CLI process serving one DSH conversation.
1625
+ *
1626
+ * The CLI is the transport, not the agent. It runs with its own tools
1627
+ * disabled, DSH's system prompt appended to its own, and DSH's tools listed
1628
+ * through the MCP bridge. Each DSH model step is one API request the CLI
1629
+ * makes; its raw stream events are replayed to the native Messages client as
1630
+ * the HTTP response of that step, frame for frame what the gateway streamed.
1631
+ * When the model calls tools, the CLI waits on the bridge while DSH executes
1632
+ * them; the next step's tool results answer those calls and the same process
1633
+ * continues, so its requests keep one growing, cacheable prefix. Between
1634
+ * turns the process waits for the next user message on stdin.
1635
+ *
1636
+ * A process that cannot continue a request (another history, prompt, tool set
1637
+ * or key) is replaced by one resumed from DSH's history; see the transport.
1638
+ *
1639
+ * @module dsh-packycode-api/claude-cli-session
1640
+ */
1641
+ /** The CLI wrote nothing for this long after starting: it is stuck before its first request. */
1642
+ const CLI_STARTUP_TIMEOUT_MS = 9e4;
1643
+ const KILL_GRACE_MS = 3e3;
1644
+ const MAX_LINE_CHARS = 64 * 1024 * 1024;
1645
+ /** The project-directory name the CLI's own files go under in its isolated home. */
1646
+ const CLI_PROJECT_DIR_NAME = "dsh-packycode";
1647
+ /** A held tool call's answer when the process is torn down first. */
1648
+ const DISPOSED_RESULT = Object.freeze({
1649
+ isError: true,
1650
+ content: [{
1651
+ type: "text",
1652
+ text: "DSH replaced this Claude Code CLI process before the tool result arrived."
1653
+ }]
1654
+ });
1655
+ const ERROR_TYPES = Object.freeze({
1656
+ 400: "invalid_request_error",
1657
+ 401: "authentication_error",
1658
+ 403: "permission_error",
1659
+ 404: "not_found_error",
1660
+ 413: "request_too_large",
1661
+ 429: "rate_limit_error",
1662
+ 500: "api_error",
1663
+ 529: "overloaded_error"
1664
+ });
1665
+ /**
1666
+ * CLI arguments for one process. DSH's tools reach the model through the
1667
+ * bridge; the CLI's own tools, settings files, slash commands and session
1668
+ * persistence are off, and its system prompt gets DSH's appended.
1669
+ * @param options - model, effort, private file paths, and either the
1670
+ * transcript to resume or the fresh session id.
1671
+ * @returns argv after the binary.
1672
+ */
1673
+ function buildCliArgs({ model, effort, systemFile, mcpConfigFile, transcriptFile, sessionId }) {
1674
+ return [
1675
+ "-p",
1676
+ "--input-format",
1677
+ "stream-json",
1678
+ "--output-format",
1679
+ "stream-json",
1680
+ "--verbose",
1681
+ "--include-partial-messages",
1682
+ "--model",
1683
+ model,
1684
+ ...typeof effort === "string" && effort !== "" ? ["--effort", effort] : [],
1685
+ "--tools",
1686
+ "",
1687
+ "--strict-mcp-config",
1688
+ "--mcp-config",
1689
+ mcpConfigFile,
1690
+ "--allowedTools",
1691
+ `mcp__dsh`,
1692
+ "--permission-prompts",
1693
+ "none",
1694
+ "--setting-sources",
1695
+ "",
1696
+ "--disable-slash-commands",
1697
+ "--no-session-persistence",
1698
+ "--append-system-prompt-file",
1699
+ systemFile,
1700
+ ...typeof transcriptFile === "string" ? ["--resume", transcriptFile] : ["--session-id", sessionId]
1701
+ ];
1702
+ }
1703
+ /**
1704
+ * The child's environment: the request's endpoint and key, an isolated CLI
1705
+ * home, retries and fallbacks handed back to DSH, and nothing inherited that
1706
+ * would authenticate another account or redirect the request.
1707
+ * @param options - endpoint, key, CLI home, output cap, whether the open turn
1708
+ * is continued, and the parent environment.
1709
+ * @returns the environment for the child process.
1710
+ */
1711
+ function buildCliEnvironment({ baseUrl, apiKey, configDir, maxTokens, continues = false, parent = process.env }) {
1712
+ if (typeof apiKey !== "string" || apiKey === "") throw new Error("Claude Code CLI has no PackyCode API key to run with");
1713
+ const env = {};
1714
+ for (const [name, value] of Object.entries(parent ?? {})) {
1715
+ if (/^(?:ANTHROPIC_|CLAUDE_|CLAUDECODE|AWS_BEARER_TOKEN_BEDROCK|MCP_|MAX_MCP_|DISABLE_|ENABLE_)/iu.test(name)) continue;
1716
+ if (value !== void 0) env[name] = value;
1717
+ }
1718
+ return {
1719
+ ...env,
1720
+ ANTHROPIC_BASE_URL: baseUrl,
1721
+ ANTHROPIC_AUTH_TOKEN: apiKey,
1722
+ CLAUDE_CONFIG_DIR: configDir,
1723
+ CLAUDE_CODE_PROJECT_DIR_NAME: CLI_PROJECT_DIR_NAME,
1724
+ DISABLE_AUTOUPDATER: "1",
1725
+ DISABLE_TELEMETRY: "1",
1726
+ DISABLE_ERROR_REPORTING: "1",
1727
+ DISABLE_BUG_COMMAND: "1",
1728
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
1729
+ CLAUDE_CODE_MAX_RETRIES: "0",
1730
+ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK: "1",
1731
+ CLAUDE_CODE_DISABLE_REFUSAL_FALLBACK: "1",
1732
+ CLAUDE_CODE_DISABLE_MODEL_ACCESS_FALLBACK: "1",
1733
+ CLAUDE_CODE_NO_MODEL_FALLBACK: "1",
1734
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
1735
+ CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
1736
+ DISABLE_AUTO_COMPACT: "1",
1737
+ DISABLE_COMPACT: "1",
1738
+ CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: "1",
1739
+ CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1",
1740
+ CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
1741
+ CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT: "0",
1742
+ CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS: "0",
1743
+ MCP_TOOL_TIMEOUT: "86400000",
1744
+ MAX_MCP_OUTPUT_TOKENS: "1000000",
1745
+ ...Number.isSafeInteger(maxTokens) && maxTokens > 0 ? { CLAUDE_CODE_MAX_OUTPUT_TOKENS: String(maxTokens) } : {},
1746
+ ...continues ? { CLAUDE_CODE_RESUME_INTERRUPTED_TURN: "1" } : {}
1747
+ };
1748
+ }
1749
+ /** The gateway message inside the CLI's "API Error: …" result text. */
1750
+ function cliErrorMessage(text) {
1751
+ const value = typeof text === "string" ? text.trim() : "";
1752
+ return (/API Error:\s*(?:Request rejected\s*\(\d{3}\)\s*·?\s*|\d{3}\s+)?([\s\S]*)$/u.exec(value)?.[1] ?? value).trim() || value;
1753
+ }
1754
+ /** An HTTP error response carrying the gateway's own words. */
1755
+ function errorResponse(status, message, type) {
1756
+ const code = Number.isInteger(status) && status >= 400 && status <= 599 ? status : 502;
1757
+ return new Response(JSON.stringify({
1758
+ type: "error",
1759
+ error: {
1760
+ type: type ?? ERROR_TYPES[code] ?? "api_error",
1761
+ message
1762
+ }
1763
+ }), {
1764
+ status: code,
1765
+ headers: { "content-type": "application/json" }
1766
+ });
1767
+ }
1768
+ const sseFrame = (event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`;
1769
+ const encoder = new TextEncoder();
1770
+ /**
1771
+ * Start a process.
1772
+ * @param options - the binary resolver, CLI home, private run directory,
1773
+ * endpoint and key, the request (model, effort, system text, tools, max
1774
+ * tokens), the conversation split (history to resume, input, open-turn
1775
+ * flag), cwd, the CLI session id, the version recorded in the transcript,
1776
+ * and process seams.
1777
+ * @returns the session; call `respond()` for each step it serves.
1778
+ */
1779
+ async function startClaudeCliSession({ binary, configDir, runDirectory, baseUrl, apiKey, request, conversation, cwd, sessionId, cliVersion, oneShot = false, platform = process.platform, parentEnv = process.env, spawnImpl = spawn, startupTimeoutMs = CLI_STARTUP_TIMEOUT_MS, logger }) {
1780
+ await mkdir(runDirectory, {
1781
+ recursive: true,
1782
+ mode: 448
1783
+ });
1784
+ /** Calls the CLI is holding open, by tool_use id. */
1785
+ const held = /* @__PURE__ */ new Map();
1786
+ /** DSH's results for the calls of the last response. */
1787
+ const answers = /* @__PURE__ */ new Map();
1788
+ /** The tool_use ids and names of the last response, in order. */
1789
+ let pending = [];
1790
+ let bridge;
1791
+ let child;
1792
+ let state = "starting";
1793
+ let step;
1794
+ let disposing;
1795
+ let stderr = "";
1796
+ let startupTimer;
1797
+ let apiError;
1798
+ let sawOutput = false;
1799
+ let version;
1800
+ const disposed = () => disposing !== void 0;
1801
+ const onCall = ({ toolUseId, name }) => {
1802
+ if (disposed()) return Promise.resolve(DISPOSED_RESULT);
1803
+ const id = toolUseId ?? pending.find((entry) => entry.name === name && !held.has(entry.id) && !entry.served)?.id;
1804
+ const entry = pending.find((candidate) => candidate.id === id);
1805
+ if (entry !== void 0) entry.served = true;
1806
+ if (answers.has(id)) return Promise.resolve(answers.get(id));
1807
+ return new Promise((resolve) => {
1808
+ held.set(id, resolve);
1809
+ });
1810
+ };
1811
+ const release = (entry) => {
1812
+ entry.done = true;
1813
+ entry.signal?.removeEventListener("abort", entry.onAbort);
1814
+ if (step === entry) step = void 0;
1815
+ };
1816
+ /** Fail the step that is waiting or streaming. */
1817
+ const failStep = (error, { status, message } = {}) => {
1818
+ const entry = step;
1819
+ if (entry === void 0 || entry.done) return;
1820
+ release(entry);
1821
+ if (!entry.started) {
1822
+ if (status !== void 0) entry.resolve(errorResponse(status, message ?? error.message));
1823
+ else entry.reject(error);
1824
+ return;
1825
+ }
1826
+ try {
1827
+ entry.controller.enqueue(encoder.encode(sseFrame({
1828
+ type: "error",
1829
+ error: {
1830
+ type: ERROR_TYPES[status] ?? "api_error",
1831
+ message: message ?? error.message
1832
+ }
1833
+ })));
1834
+ entry.controller.close();
1835
+ } catch {}
1836
+ };
1837
+ const dispose = (reason = "closed") => {
1838
+ if (disposing !== void 0) return disposing;
1839
+ state = "closed";
1840
+ clearTimeout(startupTimer);
1841
+ for (const resolve of held.values()) resolve(DISPOSED_RESULT);
1842
+ held.clear();
1843
+ failStep(/* @__PURE__ */ new Error(`Claude Code CLI process ended (${reason})`));
1844
+ if (child !== void 0 && child.exitCode === null && child.signalCode === null) {
1845
+ try {
1846
+ child.stdin?.end?.();
1847
+ } catch {}
1848
+ try {
1849
+ child.kill("SIGTERM");
1850
+ } catch {}
1851
+ const timer = setTimeout(() => {
1852
+ try {
1853
+ child.kill("SIGKILL");
1854
+ } catch {}
1855
+ }, KILL_GRACE_MS);
1856
+ timer.unref?.();
1857
+ child.once?.("close", () => clearTimeout(timer));
1858
+ }
1859
+ disposing = Promise.allSettled([bridge?.close(), rm(runDirectory, {
1860
+ recursive: true,
1861
+ force: true
1862
+ })]).then(() => void 0);
1863
+ return disposing;
1864
+ };
1865
+ const finishStep = () => {
1866
+ const entry = step;
1867
+ if (entry === void 0 || entry.done) return;
1868
+ release(entry);
1869
+ try {
1870
+ entry.controller.close();
1871
+ } catch {}
1872
+ if (oneShot) {
1873
+ dispose("one-shot request answered");
1874
+ return;
1875
+ }
1876
+ if (entry.stopReason === "tool_use" && entry.tools.length > 0) {
1877
+ state = "awaiting-tools";
1878
+ pending = entry.tools;
1879
+ answers.clear();
1880
+ } else if (entry.stopReason === "end_turn") {
1881
+ state = "idle";
1882
+ pending = [];
1883
+ } else dispose(`response ended with ${entry.stopReason ?? "no stop reason"}`);
1884
+ };
1885
+ const onEvent = (event) => {
1886
+ const entry = step;
1887
+ if (entry === void 0 || entry.done) return;
1888
+ if (event.type === "message_start") {
1889
+ if (entry.started) {
1890
+ failStep(/* @__PURE__ */ new Error("second response"), {
1891
+ status: 502,
1892
+ message: `${CLI_MARK} began a second response within one step`
1893
+ });
1894
+ dispose("second response in one step");
1895
+ return;
1896
+ }
1897
+ entry.started = true;
1898
+ clearTimeout(startupTimer);
1899
+ const body = new ReadableStream({
1900
+ start(controller) {
1901
+ entry.controller = controller;
1902
+ },
1903
+ cancel() {
1904
+ if (!entry.done) {
1905
+ release(entry);
1906
+ dispose("response cancelled");
1907
+ }
1908
+ }
1909
+ });
1910
+ entry.resolve(new Response(body, {
1911
+ status: 200,
1912
+ headers: {
1913
+ "content-type": "text/event-stream; charset=utf-8",
1914
+ "cache-control": "no-cache"
1915
+ }
1916
+ }));
1917
+ }
1918
+ if (!entry.started) return;
1919
+ if (event.type === "content_block_start" && event.content_block?.type === "tool_use") entry.tools.push({
1920
+ id: event.content_block.id,
1921
+ name: String(event.content_block.name ?? "").slice(MCP_TOOL_PREFIX.length)
1922
+ });
1923
+ if (event.type === "message_delta" && typeof event.delta?.stop_reason === "string") entry.stopReason = event.delta.stop_reason;
1924
+ try {
1925
+ entry.controller.enqueue(encoder.encode(sseFrame(event)));
1926
+ } catch {}
1927
+ if (event.type === "message_stop") finishStep();
1928
+ };
1929
+ const write = (frame) => {
1930
+ try {
1931
+ child?.stdin?.write?.(`${JSON.stringify(frame)}\n`);
1932
+ } catch {}
1933
+ };
1934
+ const onLine = (line) => {
1935
+ let message;
1936
+ try {
1937
+ message = JSON.parse(line);
1938
+ } catch {
1939
+ return;
1940
+ }
1941
+ if (message === null || typeof message !== "object") return;
1942
+ if (message.type === "stream_event" && message.event !== null && typeof message.event === "object") {
1943
+ onEvent(message.event);
1944
+ return;
1945
+ }
1946
+ if (message.type === "system" && message.subtype === "init" && typeof message.claude_code_version === "string") {
1947
+ version = message.claude_code_version;
1948
+ return;
1949
+ }
1950
+ if (message.type === "control_request" && typeof message.request_id === "string") {
1951
+ write({
1952
+ type: "control_response",
1953
+ response: {
1954
+ subtype: "error",
1955
+ request_id: message.request_id,
1956
+ error: "DeepSeek Harness answers no Claude Code CLI prompts"
1957
+ }
1958
+ });
1959
+ return;
1960
+ }
1961
+ if (message.type === "assistant" && typeof message.error === "string") {
1962
+ apiError = (message.message?.content ?? []).filter((block) => block?.type === "text").map((block) => block.text).join("\n");
1963
+ return;
1964
+ }
1965
+ if (message.type === "result") {
1966
+ if (message.is_error === true || typeof message.subtype === "string" && message.subtype !== "success") {
1967
+ const text = typeof message.result === "string" && message.result !== "" ? message.result : apiError ?? "Claude Code CLI request failed";
1968
+ failStep(new Error(text), {
1969
+ status: Number.isInteger(message.api_error_status) ? message.api_error_status : 502,
1970
+ message: `${CLI_MARK} ${cliErrorMessage(text)}`
1971
+ });
1972
+ dispose("request failed");
1973
+ }
1974
+ apiError = void 0;
1975
+ }
1976
+ };
1977
+ /**
1978
+ * Serve one step: give the CLI its input, then answer with the response it
1979
+ * streams for its next API request.
1980
+ * @param input - `{ kind: 'start' }`, `{ kind: 'tools', results }` (tool_use
1981
+ * id → Anthropic tool_result block), or `{ kind: 'turn', content }`.
1982
+ * @param signal - the step's abort signal.
1983
+ * @returns the HTTP response of this step.
1984
+ */
1985
+ const respond = (input, signal) => new Promise((resolve, reject) => {
1986
+ if (disposed()) {
1987
+ reject(/* @__PURE__ */ new Error("Claude Code CLI process is closed"));
1988
+ return;
1989
+ }
1990
+ if (step !== void 0) {
1991
+ reject(/* @__PURE__ */ new Error("Claude Code CLI process is already serving a step"));
1992
+ return;
1993
+ }
1994
+ const entry = {
1995
+ started: false,
1996
+ done: false,
1997
+ tools: [],
1998
+ stopReason: void 0,
1999
+ controller: void 0,
2000
+ resolve,
2001
+ reject,
2002
+ signal
2003
+ };
2004
+ entry.onAbort = () => {
2005
+ const error = signal.reason ?? new DOMException("This operation was aborted", "AbortError");
2006
+ if (entry.started && !entry.done) {
2007
+ release(entry);
2008
+ try {
2009
+ entry.controller.error(error);
2010
+ } catch {}
2011
+ } else failStep(error);
2012
+ dispose("aborted");
2013
+ };
2014
+ step = entry;
2015
+ if (signal?.aborted) {
2016
+ entry.onAbort();
2017
+ return;
2018
+ }
2019
+ signal?.addEventListener("abort", entry.onAbort, { once: true });
2020
+ state = "streaming";
2021
+ if (input.kind === "tools") {
2022
+ for (const [id, block] of input.results) answers.set(id, toolResultToMcp(block));
2023
+ for (const [id, resolveCall] of held) if (answers.has(id)) {
2024
+ held.delete(id);
2025
+ resolveCall(answers.get(id));
2026
+ }
2027
+ } else if (input.kind === "turn") write({
2028
+ type: "user",
2029
+ message: {
2030
+ role: "user",
2031
+ content: input.content
2032
+ },
2033
+ parent_tool_use_id: null
2034
+ });
2035
+ else if (conversation.input !== void 0) write({
2036
+ type: "user",
2037
+ message: {
2038
+ role: "user",
2039
+ content: conversation.input
2040
+ },
2041
+ parent_tool_use_id: null
2042
+ });
2043
+ });
2044
+ try {
2045
+ bridge = await startClaudeMcpBridge({
2046
+ tools: request.tools,
2047
+ onCall
2048
+ });
2049
+ const systemFile = join(runDirectory, "system-prompt.md");
2050
+ const mcpConfigFile = join(runDirectory, "mcp.json");
2051
+ await writeFile(systemFile, request.system, { mode: 384 });
2052
+ await writeFile(mcpConfigFile, JSON.stringify({ mcpServers: { ["dsh"]: {
2053
+ type: "http",
2054
+ url: bridge.url,
2055
+ headers: { Authorization: bridge.authorization },
2056
+ timeout: 864e5
2057
+ } } }), { mode: 384 });
2058
+ let transcriptFile;
2059
+ if (conversation.history.length > 0) {
2060
+ transcriptFile = join(runDirectory, `${sessionId}.jsonl`);
2061
+ await writeFile(transcriptFile, buildTranscript(conversation.history, {
2062
+ sessionId,
2063
+ cwd,
2064
+ model: request.model,
2065
+ version: cliVersion
2066
+ }), { mode: 384 });
2067
+ }
2068
+ const program = await binary();
2069
+ const command = resolveSpawnCommand(program, { platform });
2070
+ const args = buildCliArgs({
2071
+ model: request.model,
2072
+ effort: request.effort,
2073
+ systemFile,
2074
+ mcpConfigFile,
2075
+ transcriptFile,
2076
+ sessionId
2077
+ });
2078
+ const env = withProgramDirOnPath(buildCliEnvironment({
2079
+ baseUrl,
2080
+ apiKey,
2081
+ configDir,
2082
+ maxTokens: request.maxTokens,
2083
+ continues: conversation.continues,
2084
+ parent: parentEnv
2085
+ }), program, platform);
2086
+ child = spawnImpl(command.command, [...command.args, ...args], {
2087
+ cwd,
2088
+ env,
2089
+ stdio: [
2090
+ "pipe",
2091
+ "pipe",
2092
+ "pipe"
2093
+ ],
2094
+ windowsHide: true
2095
+ });
2096
+ } catch (error) {
2097
+ await dispose("could not start");
2098
+ throw error;
2099
+ }
2100
+ let buffer = "";
2101
+ child.stdout?.setEncoding?.("utf8");
2102
+ child.stderr?.setEncoding?.("utf8");
2103
+ child.stdout?.on("data", (chunk) => {
2104
+ sawOutput = true;
2105
+ buffer += chunk;
2106
+ if (buffer.length > MAX_LINE_CHARS) buffer = "";
2107
+ let index = buffer.indexOf("\n");
2108
+ while (index !== -1) {
2109
+ const line = buffer.slice(0, index).trim();
2110
+ buffer = buffer.slice(index + 1);
2111
+ if (line.startsWith("{")) onLine(line);
2112
+ index = buffer.indexOf("\n");
2113
+ }
2114
+ });
2115
+ child.stderr?.on("data", (chunk) => {
2116
+ stderr = (stderr + String(chunk)).slice(-16384);
2117
+ });
2118
+ child.stdin?.on?.("error", () => void 0);
2119
+ child.on("error", (error) => {
2120
+ if (error?.code === "ENOENT") failStep(error, {
2121
+ status: 400,
2122
+ message: CLI_UNAVAILABLE
2123
+ });
2124
+ else failStep(error, {
2125
+ status: 502,
2126
+ message: `${CLI_MARK} could not run: ${error.message}`
2127
+ });
2128
+ dispose("could not run");
2129
+ });
2130
+ child.on("close", (code, killSignal) => {
2131
+ if (!disposed()) {
2132
+ const detail = stderr.trim().split("\n").slice(-6).join(" ").slice(0, 800);
2133
+ const outdated = /unknown option|unrecognized option|error: option|invalid choice/iu.test(detail);
2134
+ failStep(new Error(detail), {
2135
+ status: outdated ? 400 : 502,
2136
+ message: outdated ? `${CLI_OUTDATED}: ${detail}` : `${CLI_MARK} exited with ${code ?? killSignal}${detail === "" ? "" : `: ${detail}`}`
2137
+ });
2138
+ logger?.warn?.("packycode-api: Claude Code CLI exited (%s)", code ?? killSignal);
2139
+ }
2140
+ dispose("exited");
2141
+ });
2142
+ startupTimer = setTimeout(() => {
2143
+ if (sawOutput || disposed()) return;
2144
+ failStep(/* @__PURE__ */ new Error("silent"), {
2145
+ status: 504,
2146
+ message: `${CLI_MARK} printed nothing within ${Math.round(startupTimeoutMs / 1e3)}s of starting`
2147
+ });
2148
+ dispose("startup timeout");
2149
+ }, startupTimeoutMs);
2150
+ startupTimer.unref?.();
2151
+ return {
2152
+ /** `starting`, `streaming`, `awaiting-tools`, `idle` or `closed`. */
2153
+ get state() {
2154
+ return state;
2155
+ },
2156
+ /** The tool_use ids the CLI is waiting on, in order. */
2157
+ get pendingToolIds() {
2158
+ return pending.map((entry) => entry.id);
2159
+ },
2160
+ get closed() {
2161
+ return disposed();
2162
+ },
2163
+ /** The CLI version the process announced, once it has. */
2164
+ get version() {
2165
+ return version;
2166
+ },
2167
+ respond,
2168
+ dispose
2169
+ };
2170
+ }
2171
+ /** A fresh private directory name for one process. */
2172
+ const runDirectoryName = () => `run-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}`;
2173
+ //#endregion
2174
+ //#region src/claude-cli-transport.js
2175
+ /**
2176
+ * Send Claude Messages requests through the official Claude Code CLI.
2177
+ *
2178
+ * The native route builds every Claude request exactly as it does for HTTP —
2179
+ * DSH's history, system prompt, tools, images and signed thinking, converted
2180
+ * by pi-ai — and hands the finished request to this transport instead of the
2181
+ * network when the key's group only serves the official client. The answer
2182
+ * comes back as the same SSE stream, so DSH's agent loop, tool execution,
2183
+ * permissions, plan mode, replay and error handling are unchanged.
2184
+ *
2185
+ * Each DSH conversation (session and model) keeps one live CLI process. A
2186
+ * request that continues it — the tool results it is waiting for, or the
2187
+ * user's next turn — reuses it, so its requests share one cacheable prefix.
2188
+ * Any other request (a different history, system prompt, tool set, key or
2189
+ * effort) replaces it with a process resumed from the request's own history.
2190
+ * Session titles and compaction run in a separate one-shot process.
2191
+ *
2192
+ * @module dsh-packycode-api/claude-cli-transport
2193
+ */
2194
+ /** Live processes kept at once; the least recently used one goes first. */
2195
+ const MAX_LIVE_CLI_SESSIONS = 8;
2196
+ /** A process nothing has used for this long is stopped; DSH resumes it from history. */
2197
+ const CLI_SESSION_IDLE_MS = 1200 * 1e3;
2198
+ const SWEEP_MS = 60 * 1e3;
2199
+ /** Run directories a crashed host left behind are removed after this long. */
2200
+ const STALE_RUN_MS = 1440 * 60 * 1e3;
2201
+ /** Text blocks the native route prepends for its own HTTP envelope; the CLI writes its own. */
2202
+ const ENVELOPE = /^(?:x-anthropic-billing-header:|You are Claude Code, Anthropic's official CLI for Claude)/u;
2203
+ /**
2204
+ * Appended after DSH's own instructions: the tools are DSH's, under the MCP
2205
+ * prefix the CLI gives them, and the CLI's own tools are absent.
2206
+ */
2207
+ const CLI_TOOL_NOTE = [
2208
+ "Tool access in this session: every tool you can call is provided by DeepSeek Harness (DSH) and appears with the mcp__dsh__ prefix.",
2209
+ "When the instructions above name a tool by its plain name (for example bash, read, edit or exit_plan_mode), call the mcp__dsh__ tool of that name.",
2210
+ "Claude Code's built-in tools (Bash, Read, Edit, Write, Glob, Grep, Task, TodoWrite, WebSearch, WebFetch, NotebookEdit) are not available here; never call them.",
2211
+ "DSH runs each call under its own permissions and sandbox and returns the result."
2212
+ ].join("\n");
2213
+ /** The DSH system prompt inside a Messages payload, without the HTTP envelope. */
2214
+ function dshSystemText(system) {
2215
+ return (typeof system === "string" ? [{
2216
+ type: "text",
2217
+ text: system
2218
+ }] : Array.isArray(system) ? system : []).filter((block) => block?.type === "text" && typeof block.text === "string" && !ENVELOPE.test(block.text)).map((block) => block.text).join("\n\n").trim();
2219
+ }
2220
+ /**
2221
+ * What a payload asks of the CLI.
2222
+ * @param body - the Messages request body the native route built.
2223
+ * @param meta - the request's key, endpoint and working directory.
2224
+ * @returns model, effort, cap, system text, tools, messages, and the
2225
+ * signature a live process must match to continue.
2226
+ */
2227
+ function describeRequest(body, { apiKey, baseUrl, cwd } = {}) {
2228
+ const tools = (Array.isArray(body?.tools) ? body.tools : []).filter((tool) => typeof tool?.name === "string" && tool.name.startsWith(MCP_TOOL_PREFIX) && tool.input_schema !== void 0).map((tool) => ({
2229
+ name: tool.name,
2230
+ description: tool.description ?? "",
2231
+ input_schema: tool.input_schema
2232
+ }));
2233
+ const own = dshSystemText(body?.system);
2234
+ const system = own === "" ? CLI_TOOL_NOTE : `${own}\n\n${CLI_TOOL_NOTE}`;
2235
+ const messages = Array.isArray(body?.messages) ? body.messages : [];
2236
+ const request = {
2237
+ model: String(body?.model ?? ""),
2238
+ effort: typeof body?.output_config?.effort === "string" ? body.output_config.effort : void 0,
2239
+ maxTokens: Number.isSafeInteger(body?.max_tokens) ? body.max_tokens : void 0,
2240
+ system,
2241
+ tools,
2242
+ messages,
2243
+ normalized: messages.map((message) => JSON.stringify(canonicalMessage(message)))
2244
+ };
2245
+ request.signature = createHash("sha256").update(JSON.stringify([
2246
+ request.model,
2247
+ request.effort,
2248
+ request.maxTokens,
2249
+ system,
2250
+ tools,
2251
+ createHash("sha256").update(String(apiKey ?? "")).digest("hex"),
2252
+ baseUrl,
2253
+ cwd
2254
+ ])).digest("hex");
2255
+ return request;
2256
+ }
2257
+ /**
2258
+ * How a live process continues this request, or undefined when it cannot.
2259
+ * @param entry - the live process and the request it last served.
2260
+ * @param request - the new request.
2261
+ * @returns `{ kind: 'tools', results }` or `{ kind: 'turn', content }`.
2262
+ */
2263
+ function planContinuation(entry, request) {
2264
+ const session = entry?.session;
2265
+ if (session === void 0 || session.closed || entry.signature !== request.signature) return void 0;
2266
+ const base = entry.baseline;
2267
+ const next = request.normalized;
2268
+ if (next.length < base.length + 2) return void 0;
2269
+ for (let index = 0; index < base.length; index++) if (next[index] !== base[index]) return void 0;
2270
+ const assistant = request.messages[base.length];
2271
+ const rest = request.messages.slice(base.length + 1);
2272
+ if (assistant?.role !== "assistant" || rest.some((message) => message?.role !== "user")) return void 0;
2273
+ const ids = toolUseIds(assistant);
2274
+ const blocks = rest.flatMap((message) => contentBlocks(withoutCacheControl(message.content)));
2275
+ if (session.state === "awaiting-tools") {
2276
+ const pending = session.pendingToolIds;
2277
+ if (ids.join("\n") !== pending.join("\n")) return void 0;
2278
+ if (blocks.some((block) => block?.type !== "tool_result")) return void 0;
2279
+ const results = new Map(blocks.map((block) => [block.tool_use_id, block]));
2280
+ if (results.size !== pending.length || pending.some((id) => !results.has(id))) return void 0;
2281
+ return {
2282
+ kind: "tools",
2283
+ results
2284
+ };
2285
+ }
2286
+ if (session.state === "idle") {
2287
+ if (ids.length > 0 || blocks.length === 0 || blocks.some((block) => block?.type === "tool_result")) return void 0;
2288
+ return {
2289
+ kind: "turn",
2290
+ content: blocks
2291
+ };
2292
+ }
2293
+ }
2294
+ /** The endpoint a Messages URL belongs to: everything before `/v1/messages`. */
2295
+ function baseUrlOf(input) {
2296
+ const url = new URL(input instanceof Request ? input.url : String(input));
2297
+ const path = url.pathname.replace(/\/v1\/messages\/?$/u, "").replace(/\/+$/u, "");
2298
+ return `${url.origin}${path}`;
2299
+ }
2300
+ /**
2301
+ * Create the transport.
2302
+ * @param options - how to find the CLI binary (`binary()` resolves a path or
2303
+ * throws), the isolated CLI home, the version recorded in transcripts,
2304
+ * limits, a logger, and process seams for tests.
2305
+ * @returns `{ fetch(input, init, meta), close(), stats() }`.
2306
+ */
2307
+ function createClaudeCliTransport({ binary, configDir = DEFAULT_CLAUDE_CONFIG_DIR, version = () => void 0, maxLive = 8, idleMs = CLI_SESSION_IDLE_MS, logger, spawnImpl, platform, parentEnv, startupTimeoutMs, now = Date.now } = {}) {
2308
+ if (typeof binary !== "function") throw new Error("the Claude Code CLI transport needs a binary resolver");
2309
+ const live = /* @__PURE__ */ new Map();
2310
+ /** One-shot processes in flight; they end themselves, unless the plugin unloads first. */
2311
+ const oneShots = /* @__PURE__ */ new Set();
2312
+ let closed = false;
2313
+ let opened = 0;
2314
+ let continued = 0;
2315
+ (async () => {
2316
+ const runs = join(configDir, "runs");
2317
+ for (const name of await readdir(runs).catch(() => [])) {
2318
+ if (!name.startsWith("run-")) continue;
2319
+ const path = join(runs, name);
2320
+ try {
2321
+ if (now() - (await stat(path)).mtimeMs > STALE_RUN_MS) await rm(path, {
2322
+ recursive: true,
2323
+ force: true
2324
+ });
2325
+ } catch {}
2326
+ }
2327
+ })();
2328
+ const evict = (key, reason) => {
2329
+ const entry = live.get(key);
2330
+ if (entry === void 0) return;
2331
+ live.delete(key);
2332
+ entry.session.dispose(reason);
2333
+ };
2334
+ const sweep = setInterval(() => {
2335
+ for (const session of oneShots) if (session.closed) oneShots.delete(session);
2336
+ for (const [key, entry] of live) if (entry.session.closed) live.delete(key);
2337
+ else if (now() - entry.touched > idleMs && entry.session.state !== "streaming") evict(key, "idle");
2338
+ }, SWEEP_MS);
2339
+ sweep.unref?.();
2340
+ /** The session's own directory when it exists; otherwise a neutral one. */
2341
+ const workingDirectory = async (cwd) => {
2342
+ if (typeof cwd === "string" && cwd !== "") try {
2343
+ if ((await stat(cwd)).isDirectory()) return cwd;
2344
+ } catch {}
2345
+ const fallback = join(configDir, "workspace");
2346
+ await mkdir(fallback, { recursive: true });
2347
+ return fallback;
2348
+ };
2349
+ const open = async (request, meta, { oneShot }) => startClaudeCliSession({
2350
+ binary,
2351
+ configDir,
2352
+ runDirectory: join(configDir, "runs", runDirectoryName()),
2353
+ baseUrl: meta.baseUrl,
2354
+ apiKey: meta.apiKey,
2355
+ request,
2356
+ conversation: splitConversation(request.messages),
2357
+ cwd: await workingDirectory(meta.cwd),
2358
+ sessionId: oneShot ? randomUUID() : meta.sessionId,
2359
+ cliVersion: version(),
2360
+ oneShot,
2361
+ logger,
2362
+ ...spawnImpl === void 0 ? {} : { spawnImpl },
2363
+ ...platform === void 0 ? {} : { platform },
2364
+ ...parentEnv === void 0 ? {} : { parentEnv },
2365
+ ...startupTimeoutMs === void 0 ? {} : { startupTimeoutMs }
2366
+ });
2367
+ /**
2368
+ * Send one Messages request through the CLI.
2369
+ * @param input - the request URL the native client targeted.
2370
+ * @param init - its init; `body` is the JSON payload, `signal` the abort.
2371
+ * @param meta - `{ apiKey, sessionId, purpose, cwd }`.
2372
+ * @returns the HTTP response of the request.
2373
+ */
2374
+ const send = async (input, init, meta) => {
2375
+ if (closed) throw new Error("the Claude Code CLI transport is closed");
2376
+ const body = JSON.parse(typeof init?.body === "string" ? init.body : "{}");
2377
+ const baseUrl = baseUrlOf(input);
2378
+ const request = describeRequest(body, {
2379
+ apiKey: meta.apiKey,
2380
+ baseUrl,
2381
+ cwd: meta.cwd
2382
+ });
2383
+ const context = {
2384
+ ...meta,
2385
+ baseUrl
2386
+ };
2387
+ const oneShot = async () => {
2388
+ const session = await open(request, context, { oneShot: true });
2389
+ opened += 1;
2390
+ oneShots.add(session);
2391
+ return session.respond({ kind: "start" }, init?.signal);
2392
+ };
2393
+ try {
2394
+ if (meta.purpose !== void 0) return await oneShot();
2395
+ const key = `${meta.sessionId}\u0000${request.model}`;
2396
+ const entry = live.get(key);
2397
+ if (entry !== void 0 && !entry.session.closed && ["starting", "streaming"].includes(entry.session.state)) return await oneShot();
2398
+ const plan = planContinuation(entry, request);
2399
+ if (plan !== void 0) {
2400
+ entry.baseline = request.normalized;
2401
+ entry.touched = now();
2402
+ continued += 1;
2403
+ return await entry.session.respond(plan, init?.signal);
2404
+ }
2405
+ if (entry !== void 0) evict(key, "superseded");
2406
+ const session = await open(request, context, { oneShot: false });
2407
+ opened += 1;
2408
+ live.set(key, {
2409
+ session,
2410
+ baseline: request.normalized,
2411
+ signature: request.signature,
2412
+ touched: now()
2413
+ });
2414
+ while (live.size > maxLive) {
2415
+ const [oldest] = [...live.entries()].filter(([candidate]) => candidate !== key).sort((left, right) => left[1].touched - right[1].touched);
2416
+ if (oldest === void 0) break;
2417
+ evict(oldest[0], "too many live sessions");
2418
+ }
2419
+ return await session.respond({ kind: "start" }, init?.signal);
2420
+ } catch (error) {
2421
+ if (init?.signal?.aborted) throw error;
2422
+ const reason = error?.code === "CLAUDE_CLI_MISSING" ? "" : `: ${String(error?.message ?? error).slice(0, 300)}`;
2423
+ logger?.warn?.("packycode-api: Claude Code CLI could not start%s", reason);
2424
+ return errorResponse(400, `${CLI_UNAVAILABLE}${reason}`, CLI_UNAVAILABLE);
2425
+ }
2426
+ };
467
2427
  return Object.freeze({
468
- read: (options) => cache.read(options),
469
- invalidate: () => cache.invalidate()
2428
+ fetch: send,
2429
+ /** Counters for diagnostics; never message content. */
2430
+ stats: () => ({
2431
+ live: [...live.values()].filter((entry) => !entry.session.closed).length,
2432
+ opened,
2433
+ continued
2434
+ }),
2435
+ /** Stop every process; the plugin is unloading. */
2436
+ async close() {
2437
+ closed = true;
2438
+ clearInterval(sweep);
2439
+ const sessions = [...[...live.values()].map((entry) => entry.session), ...oneShots];
2440
+ live.clear();
2441
+ oneShots.clear();
2442
+ await Promise.allSettled(sessions.map((session) => session.dispose("plugin unloaded")));
2443
+ }
470
2444
  });
471
2445
  }
472
2446
  //#endregion
2447
+ //#region src/claude-transport.js
2448
+ /**
2449
+ * Which transport a Claude request uses: the plugin's own Messages HTTP client,
2450
+ * or the official Claude Code CLI on this device.
2451
+ *
2452
+ * PackyCode's `cc` group ("claude code专用") serves only the official client,
2453
+ * so a key in it sends its Claude requests through the real CLI.
2454
+ * `cc-expensive`, `cc-sale` and `claude-officially` keep the native HTTP
2455
+ * route. The user can pin either transport in the settings panel.
2456
+ * (`claude-sale` is delisted for now; it joins the CLI list if it returns.)
2457
+ *
2458
+ * Only the transport changes: DSH's agent loop, tools, permissions, plan mode,
2459
+ * attachments and history are identical on both, and no other provider or
2460
+ * credential is ever substituted.
2461
+ *
2462
+ * @module dsh-packycode-api/claude-transport
2463
+ */
2464
+ /** Groups whose Claude requests are sent by the official Claude Code client. */
2465
+ const CLI_ONLY_GROUPS = Object.freeze(["cc"]);
2466
+ /** Claude groups whose requests use the plugin's HTTP route. */
2467
+ const HTTP_CLAUDE_GROUPS = Object.freeze([
2468
+ "cc-expensive",
2469
+ "cc-sale",
2470
+ "claude-officially"
2471
+ ]);
2472
+ /** `auto` follows the key's groups; `cli` and `http` pin one transport. */
2473
+ const CLAUDE_TRANSPORTS = Object.freeze([
2474
+ "auto",
2475
+ "cli",
2476
+ "http"
2477
+ ]);
2478
+ const DEFAULT_CLAUDE_TRANSPORT = "auto";
2479
+ /** Whether a catalog model is served over the Anthropic Messages protocol. */
2480
+ const isClaudeModel = (modelId) => typeof modelId === "string" && modelId.startsWith("claude-");
2481
+ /** Read the stored preference; anything unknown is the default. */
2482
+ function readClaudeTransport(value) {
2483
+ return CLAUDE_TRANSPORTS.includes(value) ? value : DEFAULT_CLAUDE_TRANSPORT;
2484
+ }
2485
+ /**
2486
+ * Decide the transport for a Claude request.
2487
+ * @param options - the stored preference and the request key's groups, as the
2488
+ * last group reading saw them (undefined when there is no reading yet).
2489
+ * @returns `{ transport: 'cli' | 'http', reason, groups? }`. `reason` is
2490
+ * `preference`, `group` (the key's groups decided), or `unknown` (no reading
2491
+ * yet; the native HTTP route stands, as it did before this choice existed).
2492
+ */
2493
+ function claudeTransportFor({ preference, groups } = {}) {
2494
+ const pinned = readClaudeTransport(preference);
2495
+ if (pinned !== "auto") return {
2496
+ transport: pinned,
2497
+ reason: "preference"
2498
+ };
2499
+ if (!Array.isArray(groups)) return {
2500
+ transport: "http",
2501
+ reason: "unknown"
2502
+ };
2503
+ const gated = groups.filter((name) => CLI_ONLY_GROUPS.includes(name));
2504
+ return gated.length > 0 ? {
2505
+ transport: "cli",
2506
+ reason: "group",
2507
+ groups: gated
2508
+ } : {
2509
+ transport: "http",
2510
+ reason: "group",
2511
+ groups: groups.filter((name) => HTTP_CLAUDE_GROUPS.includes(name))
2512
+ };
2513
+ }
2514
+ //#endregion
473
2515
  //#region src/diagnostics.js
474
2516
  /**
475
2517
  * A support summary of one DSH session, for reports such as "已达到输出 token
@@ -1323,7 +3365,8 @@ function validateImageOptions({ model = DEFAULT_IMAGE_MODEL, quality = "auto", s
1323
3365
  * plugin's own row, read through a live reference, written through
1324
3366
  * `settings.update(rowId, patch)`, and announced by `loader/volatile-update`.
1325
3367
  * Only volatile fields are writable there, so the host overrides stay
1326
- * read-only while web search and model visibility are editable.
3368
+ * read-only while web search, model visibility, images and the Claude
3369
+ * transport are editable.
1327
3370
  *
1328
3371
  * @module dsh-packycode-api/preferences
1329
3372
  */
@@ -1360,7 +3403,7 @@ function unwritable(message) {
1360
3403
  * current source and how to replace it, what to do when the section changes,
1361
3404
  * and the preference targets this fills in (host overrides, search, models).
1362
3405
  */
1363
- function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSource, onChange, hostPreference, searchPreference, modelPreference, imagePreference }) {
3406
+ function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSource, onChange, hostPreference, searchPreference, modelPreference, imagePreference, claudePreference }) {
1364
3407
  const settings = settingsCtx.settings;
1365
3408
  if (typeof settings?.installSection === "function") {
1366
3409
  try {
@@ -1378,6 +3421,7 @@ function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSourc
1378
3421
  Object.assign(searchPreference, target);
1379
3422
  Object.assign(modelPreference, target);
1380
3423
  if (imagePreference !== void 0) Object.assign(imagePreference, target);
3424
+ if (claudePreference !== void 0) Object.assign(claudePreference, target);
1381
3425
  }
1382
3426
  } catch (error) {
1383
3427
  ctx.logger?.warn?.("packycode-api: host overrides unavailable, defaults stand: %s", error.message);
@@ -1393,6 +3437,7 @@ function connectSettings(settingsCtx, { ctx, Config, namespace, source, setSourc
1393
3437
  Object.assign(searchPreference, target);
1394
3438
  Object.assign(modelPreference, target);
1395
3439
  if (imagePreference !== void 0) Object.assign(imagePreference, target);
3440
+ if (claudePreference !== void 0) Object.assign(claudePreference, target);
1396
3441
  if (typeof settings.configure === "function") {
1397
3442
  const scope = typeof settingsCtx.effect === "function" ? settingsCtx : ctx;
1398
3443
  try {
@@ -2189,10 +4234,25 @@ function createKeyChooser({ ring, groups, onRotated, logger, waitMs = DEFAULT_GR
2189
4234
  if (key !== void 0) await warm();
2190
4235
  return key;
2191
4236
  };
4237
+ /**
4238
+ * Wait at most `waitMs` for a group reading to exist, whatever the number
4239
+ * of keys: a Claude request's transport depends on its key's groups.
4240
+ */
4241
+ const awaitGroups = async () => {
4242
+ if (groups.hasReading() || snapshot.length === 0) return;
4243
+ let timer;
4244
+ await Promise.race([groups.read().catch(() => void 0), new Promise((resolve) => {
4245
+ timer = setTimeout(resolve, waitMs);
4246
+ })]);
4247
+ clearTimeout(timer);
4248
+ };
2192
4249
  return Object.freeze({
2193
4250
  remember,
2194
4251
  covers,
2195
4252
  active,
4253
+ awaitGroups,
4254
+ /** The ring id of the key material a request carries, as of the last resolution. */
4255
+ idOf: (key) => typeof key === "string" ? idByKey.get(key) : void 0,
2196
4256
  /**
2197
4257
  * The key one HTTP request for `modelId` runs with, given the active key
2198
4258
  * pi-ai resolved. Synchronous because pi-ai opens its stream synchronously;
@@ -2891,154 +4951,6 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2891
4951
  });
2892
4952
  }
2893
4953
  //#endregion
2894
- //#region src/failures.js
2895
- /**
2896
- * Failure classification for the two conditions that move the key ring along.
2897
- *
2898
- * The gateway reports an exhausted key and a rejected key in Chinese, and the
2899
- * harness classifies failures by fixed English/status wording it recognizes
2900
- * (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
2901
- * harness's retry policy re-run the step — and therefore what lets the retry
2902
- * pick up the next key. Rewriting is honest about what happened: this plugin
2903
- * knows the key's condition from the gateway's own words plus its balance
2904
- * reader, so the sentence it substitutes says no more than that.
2905
- *
2906
- * @module dsh-packycode-api/failures
2907
- */
2908
- /** Terminal quota, balance, or credit wording, in the gateway's own languages. */
2909
- const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
2910
- /** A rejected key names itself; a bare 403 does not, and must not disable one. */
2911
- const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
2912
- const INVALID_KEY_STATUS = /\b401\b/;
2913
- /**
2914
- * A gateway group that only serves its vendor's official client. These arrive
2915
- * as 403, which the harness reads as an authentication failure and renders as
2916
- * an invalid-API-key problem — the opposite of what happened. This wording
2917
- * also covers intermittent client-anomaly refusals; it does not establish
2918
- * whether a future request will be accepted.
2919
- */
2920
- 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;
2921
- /** A content-policy refusal observed on an already-authorized request. */
2922
- const TEST_MESSAGE_POLICY_CODE = "GATEWAY_TEST_MESSAGE";
2923
- const TEST_MESSAGE_PREFIX = `[${TEST_MESSAGE_POLICY_CODE}]`;
2924
- /** Match the captured refusal narrowly; a bare 403 is still not evidence. */
2925
- function classifyTestMessagePolicy(message) {
2926
- if (typeof message !== "string") return false;
2927
- return message.startsWith(TEST_MESSAGE_PREFIX) || /already connected and authorized/iu.test(message) && /no need to send test messages/iu.test(message) && /verify the connection/iu.test(message);
2928
- }
2929
- /** Keep the host's status-word classifier from turning this into AUTH. */
2930
- function explainTestMessagePolicyFailure() {
2931
- return `${TEST_MESSAGE_PREFIX} PackyCode 将这次请求识别为连通性测试并拒绝。请使用实际业务内容;本次拒绝不表示 Key 失效,也不会禁用或切换 Key。`;
2932
- }
2933
- /**
2934
- * The gateway's answer when a key's groups do not include the model's
2935
- * supplier: `model_not_found` / 当前令牌未覆盖供应商. The key is fine for other
2936
- * models, so it is never moved aside for this.
2937
- */
2938
- const COVERAGE_WORDING = /model_not_found|未覆盖供应商|无可用渠道|no available channel/iu;
2939
- /**
2940
- * Whether the gateway refused the model for this key's groups.
2941
- * @param message - the failure text.
2942
- * @returns true for a group-coverage refusal.
2943
- */
2944
- function classifyCoverage(message) {
2945
- return COVERAGE_WORDING.test(typeof message === "string" ? message : "");
2946
- }
2947
- /** The key's quota is exhausted; move to the next key. */
2948
- const QUOTA_FAILURE = "quota";
2949
- /** The gateway rejected the key itself; move to the next key. */
2950
- const INVALID_KEY_FAILURE = "invalid-key";
2951
- /**
2952
- * Whether the gateway is refusing this client rather than this credential.
2953
- * @param message - provider failure text, as pi-ai carries it.
2954
- * @returns true when the route is gated to a vendor's official client.
2955
- */
2956
- function classifyClientPolicy(message) {
2957
- return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
2958
- }
2959
- /**
2960
- * OpenAI's generic transient failure: the sentence its `server_error` carries,
2961
- * or the `server_error:` prefix pi-ai writes when the event names the code.
2962
- * The gateway can relay it inside a stream it has already answered 200 for —
2963
- * an SSE `error` event with a 503 `server_error`, then `response.failed` — so
2964
- * no HTTP status comes with it.
2965
- */
2966
- const UPSTREAM_SERVER_ERROR = /an error occurred while processing your request\b[\s\S]*\bhelp\.openai\.com\b|^(?:error code\s+)?server_error:/iu;
2967
- /**
2968
- * Whether a failure is the upstream's transient server error, which a resent
2969
- * request clears. The key is not at fault.
2970
- * @param message - provider failure text, as pi-ai carries it.
2971
- * @returns true for OpenAI's generic `server_error`.
2972
- */
2973
- function classifyUpstreamServerError(message) {
2974
- return UPSTREAM_SERVER_ERROR.test(typeof message === "string" ? message : "");
2975
- }
2976
- /**
2977
- * Classify a gateway failure message.
2978
- *
2979
- * Quota wording wins over a status token: an exhausted balance is often
2980
- * reported as 402/403, and only the key's own rejection may read as 401. A
2981
- * client-policy refusal is not a key fault and is classified separately.
2982
- * @param message - provider failure text, as pi-ai carries it.
2983
- * @returns the failure kind, or undefined when the key is not at fault.
2984
- */
2985
- function classifyGatewayFailure(message) {
2986
- const text = typeof message === "string" ? message : "";
2987
- if (text === "") return void 0;
2988
- if (classifyClientPolicy(text) || classifyTestMessagePolicy(text)) return void 0;
2989
- if (QUOTA_WORDING.test(text)) return QUOTA_FAILURE;
2990
- if (INVALID_KEY_WORDING.test(text)) return INVALID_KEY_FAILURE;
2991
- if (INVALID_KEY_STATUS.test(text)) return INVALID_KEY_FAILURE;
2992
- }
2993
- /**
2994
- * Rewrite a failure message so the harness classifies it as quota or auth.
2995
- *
2996
- * A bare status token is neutralized first: an exhausted balance arrives as
2997
- * 402 or 403 on some gateway routes, and the harness's auth rule matches
2998
- * 401/403 before it reads any wording, which would send the failure into a
2999
- * category the retry policy does not re-run.
3000
- * @param message - the original provider failure text.
3001
- * @param kind - the classification of {@link classifyGatewayFailure}.
3002
- * @returns text the harness routes to QUOTA or AUTH.
3003
- */
3004
- /**
3005
- * Explain a client-policy refusal so the user reads the real cause.
3006
- *
3007
- * The sentence must not read as any other failure the harness knows: it
3008
- * deliberately carries no HTTP status and none of the words the classifier
3009
- * matches for auth, quota, rate limits, timeouts, or transport faults. That
3010
- * keeps the failure out of the host's generic retry policy and lets the
3011
- * sentence reach the user verbatim, in
3012
- * place of the "API key is invalid" the bare 403 would have produced.
3013
- * @param keyName - the account name the balance reader saw, when known.
3014
- * @returns the replacement sentence.
3015
- */
3016
- function explainClientPolicyFailure() {
3017
- return "PackyCode 拒绝了这个客户端:所选分组只允许厂商官方客户端调用(Claude 分组要求官方 Claude CLI,Codex 分组要求标准 Codex 客户端)。这不是 API Key 失效。请检查当前分组的客户端要求;若调整分组,应先在模型广场确认该模型可用。 / This group only serves the vendor’s official client; the API key is fine.";
3018
- }
3019
- /**
3020
- * Rewrite a failure message so the harness classifies it as quota or auth.
3021
- *
3022
- * A bare status token is neutralized first: an exhausted balance arrives as
3023
- * 402 or 403 on some gateway routes, and the harness's auth rule matches
3024
- * 401/403 before it reads any wording, which would send the failure into a
3025
- * category the retry policy does not re-run.
3026
- * @param message - the original provider failure text.
3027
- * @param kind - the classification of {@link classifyGatewayFailure}.
3028
- * @param options - optional account name for the policy explanation.
3029
- * @returns text the harness routes to QUOTA or AUTH, or a policy explanation.
3030
- */
3031
- function normalizeFailureMessage(message, kind, options = {}) {
3032
- const text = typeof message === "string" && message.length > 0 ? message : "PackyCode request failed";
3033
- if (kind === "client-policy") return explainClientPolicyFailure(options.keyName);
3034
- if (kind === "quota") return `${neutralizeAuthStatus(text)} — PackyCode: insufficient quota for this API key; trying the next key`;
3035
- if (kind === "invalid-key") return `${neutralizeAuthStatus(text)} — PackyCode: 401 invalid_authentication for this API key; trying the next key`;
3036
- return text;
3037
- }
3038
- function neutralizeAuthStatus(text) {
3039
- return text.replace(/\b(?:401|403)\b/gu, "HTTP");
3040
- }
3041
- //#endregion
3042
4954
  //#region src/gemini-replay.js
3043
4955
  /** A bounded resend for the gateway's intermittent Flash signature refusal. */
3044
4956
  const waitForRetry = (ms, signal) => setTimeout$1(ms, void 0, { signal });
@@ -3141,8 +5053,13 @@ async function isReplayableRefusal(response, { allowTestMessage }) {
3141
5053
  reader?.releaseLock();
3142
5054
  }
3143
5055
  }
5056
+ /**
5057
+ * pi-ai 0.x carries the offered tools in `context.tools`; pi-ai 1.x (DSH
5058
+ * 0.2.1) hands the provider a transcript whose system messages declare them
5059
+ * as `toolsAdded`.
5060
+ */
3144
5061
  function hasToolContext(context) {
3145
- return (context.tools?.length ?? 0) > 0 || context.messages?.some((message) => message.role === "toolResult" || Array.isArray(message.content) && message.content.some((block) => block.type === "toolCall"));
5062
+ return (context.tools?.length ?? 0) > 0 || context.messages?.some((message) => message.role === "toolResult" || message.role === "system" && (message.toolsAdded?.length ?? 0) > 0 || Array.isArray(message.content) && message.content.some((block) => block.type === "toolCall"));
3146
5063
  }
3147
5064
  /**
3148
5065
  * Fixed Haiku and Sonnet tool payloads were observed returning both 200 and
@@ -3332,16 +5249,37 @@ const BUILTIN_NAMES = Object.freeze({
3332
5249
  glob: "Glob",
3333
5250
  grep: "Grep"
3334
5251
  });
3335
- /** CLI-compatible wire names, restored to their originals before host dispatch. */
3336
- function createClaudeToolNames() {
5252
+ /** Through the CLI every DSH tool is an MCP tool of the `dsh` server. */
5253
+ const CLI_PREFIX = "mcp__dsh__";
5254
+ /** A name the CLI keeps verbatim: no double underscore, and the whole name within 64 characters. */
5255
+ const CLI_SAFE_NAME = /^[a-zA-Z0-9-]+(?:_[a-zA-Z0-9-]+)*$/u;
5256
+ const CLI_MAX_NAME = 54;
5257
+ const digestOf = (name) => createHash("sha256").update(name).digest("hex").slice(0, 12);
5258
+ /** The wire name of a DSH tool when the official CLI carries the request. */
5259
+ function cliToolName(name) {
5260
+ if (CLI_SAFE_NAME.test(name) && name.length <= CLI_MAX_NAME) return `${CLI_PREFIX}${name}`;
5261
+ const slug = name.replace(/[^a-zA-Z0-9-]+/gu, "_").replace(/^_+/u, "").slice(0, CLI_MAX_NAME - 13).replace(/_+$/u, "") || "tool";
5262
+ return `${CLI_PREFIX}${slug}_${digestOf(name)}`;
5263
+ }
5264
+ /**
5265
+ * Request-scoped wire names, restored to their originals before host
5266
+ * dispatch. Over HTTP the CLI-compatible built-in spellings are used; through
5267
+ * the CLI every tool is listed by the MCP bridge and the model sees
5268
+ * `mcp__dsh__<name>`.
5269
+ * @param options - `cli` selects the CLI transport's names.
5270
+ */
5271
+ function createClaudeToolNames({ cli = false } = {}) {
3337
5272
  const owners = /* @__PURE__ */ new Map(), offered = /* @__PURE__ */ new Map(), clientNames = /* @__PURE__ */ new Set();
3338
5273
  let declaredNames = /* @__PURE__ */ new Set();
3339
5274
  const encode = (name, available = false) => {
3340
5275
  if (typeof name !== "string") return name;
3341
- const slug = name.replace(/[^a-zA-Z0-9_-]/gu, "_").slice(0, 40) || "tool";
3342
- const digest = createHash("sha256").update(name).digest("hex").slice(0, 12);
3343
- const builtin = Object.hasOwn(BUILTIN_NAMES, name) ? BUILTIN_NAMES[name] : void 0;
3344
- const alias = builtin && !declaredNames.has(builtin) ? builtin : `mcp__dsh__${slug}_${digest}`;
5276
+ let alias;
5277
+ if (cli) alias = cliToolName(name);
5278
+ else {
5279
+ const slug = name.replace(/[^a-zA-Z0-9_-]/gu, "_").slice(0, 40) || "tool";
5280
+ const builtin = Object.hasOwn(BUILTIN_NAMES, name) ? BUILTIN_NAMES[name] : void 0;
5281
+ alias = builtin && !declaredNames.has(builtin) ? builtin : `mcp__dsh__${slug}_${digestOf(name)}`;
5282
+ }
3345
5283
  if (owners.has(alias) && owners.get(alias) !== name) throw new Error("Ambiguous Claude tool name");
3346
5284
  owners.set(alias, name);
3347
5285
  if (available) {
@@ -3426,8 +5364,54 @@ function createClaudeToolNames() {
3426
5364
  };
3427
5365
  }
3428
5366
  //#endregion
5367
+ //#region src/request-context.js
5368
+ /**
5369
+ * Per-request facts the route knows but pi-ai does not carry to the wire.
5370
+ *
5371
+ * DSH hands the route a request's purpose (an agent step, a session title, a
5372
+ * compaction) and the initiating session's working directory; pi-ai forwards
5373
+ * only the model, messages and transport options to the provider. The route
5374
+ * runs each stream's iteration inside an async context holding those facts,
5375
+ * so the Claude transport can read them where the request is actually sent.
5376
+ * Nothing here is model-visible or leaves the host.
5377
+ *
5378
+ * @module dsh-packycode-api/request-context
5379
+ */
5380
+ const storage = new AsyncLocalStorage();
5381
+ /** The facts of the request being sent, or undefined outside the route. */
5382
+ const currentRequest = () => storage.getStore();
5383
+ /**
5384
+ * Iterate `iterable` with `context` as the current request. Every `next()` —
5385
+ * and so every promise the producer creates while it runs — sees the context,
5386
+ * however the consumer interleaves several streams.
5387
+ * @param context - plain facts; frozen before use.
5388
+ * @param iterable - the stream to iterate.
5389
+ * @returns an async iterable over the same items.
5390
+ */
5391
+ function withRequestContext(context, iterable) {
5392
+ const store = Object.freeze({ ...context });
5393
+ return { [Symbol.asyncIterator]() {
5394
+ let iterator;
5395
+ const run = (action) => storage.run(store, () => {
5396
+ iterator ??= iterable[Symbol.asyncIterator]();
5397
+ return action(iterator);
5398
+ });
5399
+ return {
5400
+ next: (...args) => run((it) => it.next(...args)),
5401
+ return: (...args) => run((it) => typeof it.return === "function" ? it.return(...args) : Promise.resolve({
5402
+ done: true,
5403
+ value: args[0]
5404
+ })),
5405
+ throw: (...args) => run((it) => typeof it.throw === "function" ? it.throw(...args) : Promise.reject(args[0])),
5406
+ [Symbol.asyncIterator]() {
5407
+ return this;
5408
+ }
5409
+ };
5410
+ } };
5411
+ }
5412
+ //#endregion
3429
5413
  //#region src/claude-api.js
3430
- /** Claude Messages over HTTP, with the gateway's tested client envelope. */
5414
+ /** Claude Messages over HTTP, or through the official CLI for keys whose group requires it. */
3431
5415
  const CLIENT_VERSION = "2.1.295";
3432
5416
  const USER_AGENT = `claude-cli/${CLIENT_VERSION} (external, sdk-cli)`;
3433
5417
  const CLIENT_BETA = "claude-code-20250219";
@@ -3474,8 +5458,14 @@ function sessionUuid(value) {
3474
5458
  * Only the host-side client envelope is changed. The bearer is always the
3475
5459
  * explicit request key selected by this plugin; there is no credential or
3476
5460
  * subprocess fallback. A client-policy refusal still reaches the route guard.
5461
+ *
5462
+ * With `cli`, a request whose key must use the official client is handed to
5463
+ * the Claude Code CLI transport instead of the network: the same payload,
5464
+ * the same SSE answer, DSH's tools listed through the CLI's MCP bridge.
5465
+ * @param options - `cli.choose({ apiKey, model })` decides per request;
5466
+ * `cli.fetch(input, init, meta)` sends it.
3477
5467
  */
3478
- function createClaudeApi() {
5468
+ function createClaudeApi({ cli } = {}) {
3479
5469
  const api = anthropicMessagesApi();
3480
5470
  const deviceId = randomBytes(32).toString("hex");
3481
5471
  const adapt = (model, context, options, toolNames) => {
@@ -3545,9 +5535,38 @@ function createClaudeApi() {
3545
5535
  }
3546
5536
  };
3547
5537
  };
5538
+ /**
5539
+ * The official CLI writes its own envelope, identity, effort and thinking
5540
+ * display; only DSH's payload (with CLI tool names) is handed over. The
5541
+ * route's request context says which DSH session, purpose and working
5542
+ * directory the request belongs to.
5543
+ */
5544
+ const adaptCli = (model, context, options, toolNames) => {
5545
+ const route = currentRequest();
5546
+ const meta = Object.freeze({
5547
+ apiKey: options?.apiKey,
5548
+ sessionId: sessionUuid(options?.sessionId),
5549
+ ...route?.purpose === void 0 ? {} : { purpose: route.purpose },
5550
+ ...typeof route?.cwd === "string" ? { cwd: route.cwd } : {}
5551
+ });
5552
+ return {
5553
+ ...options,
5554
+ fetch: async (input, init) => {
5555
+ return finishClaudeStream(await createClaudeReplayFetch((target, request) => cli.fetch(target, request, meta), {
5556
+ model,
5557
+ context
5558
+ })(input, init), init?.signal ?? (input instanceof Request ? input.signal : void 0));
5559
+ },
5560
+ onPayload: async (payload, model) => toolNames.encodePayload(await options?.onPayload?.(payload, model) ?? payload)
5561
+ };
5562
+ };
3548
5563
  const invoke = (method, model, context, options) => {
3549
- const names = createClaudeToolNames();
3550
- return names.decodeStream(api[method](model, context, adapt(model, context, options, names)));
5564
+ const viaCli = cli !== void 0 && cli.choose({
5565
+ apiKey: options?.apiKey,
5566
+ model: model.id
5567
+ }) === true;
5568
+ const names = createClaudeToolNames({ cli: viaCli });
5569
+ return names.decodeStream(api[method](model, context, (viaCli ? adaptCli : adapt)(model, context, options, names)));
3551
5570
  };
3552
5571
  return {
3553
5572
  ...api,
@@ -3658,6 +5677,16 @@ function createGeminiApi() {
3658
5677
  */
3659
5678
  const TERMINAL_EVENTS = /* @__PURE__ */ new Set(["error", "done"]);
3660
5679
  /**
5680
+ * A plugin-authored sentence for failures no retry can clear, or undefined.
5681
+ * Each carries no status token, so DSH shows it once instead of retrying.
5682
+ */
5683
+ function explainFinalFailure(text) {
5684
+ if (classifyCliUnavailable(text)) return explainCliUnavailable(text);
5685
+ if (classifyCliOutdated(text)) return explainCliOutdated(text);
5686
+ if (classifyTestMessagePolicy(text)) return explainTestMessagePolicyFailure();
5687
+ if (classifyClientPolicy(text)) return text.includes("[Claude Code CLI]") ? explainCliClientPolicyFailure(text) : explainClientPolicyFailure();
5688
+ }
5689
+ /**
3661
5690
  * The api-key method pi-ai consults before it honours a request's `apiKey`
3662
5691
  * override. Resolution itself stays empty: this plugin authenticates from its
3663
5692
  * own key ring through the adapter, and ambient `PACKY_CODE_API_KEY` discovery
@@ -3673,8 +5702,12 @@ function keyRingAuth() {
3673
5702
  })
3674
5703
  } };
3675
5704
  }
3676
- /** Build the route's pi-ai provider (unguarded; the plugin wraps its streams). */
3677
- function createPackyCodeProvider() {
5705
+ /**
5706
+ * Build the route's pi-ai provider (unguarded; the plugin wraps its streams).
5707
+ * @param options - `claudeCli`, the Claude Code CLI transport selector for
5708
+ * keys whose Claude group only serves the official client.
5709
+ */
5710
+ function createPackyCodeProvider({ claudeCli } = {}) {
3678
5711
  const provider = createProvider({
3679
5712
  id: PROVIDER,
3680
5713
  name: DISPLAY_NAME,
@@ -3684,7 +5717,7 @@ function createPackyCodeProvider() {
3684
5717
  api: {
3685
5718
  "openai-completions": openAICompletionsApi(),
3686
5719
  "openai-responses": openAIResponsesApi(),
3687
- "anthropic-messages": createClaudeApi(),
5720
+ "anthropic-messages": createClaudeApi({ cli: claudeCli }),
3688
5721
  "google-generative-ai": createGeminiApi()
3689
5722
  }
3690
5723
  });
@@ -3732,18 +5765,12 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
3732
5765
  for (const carrierKey of ["error", "message"]) {
3733
5766
  const carrier = value[carrierKey];
3734
5767
  if (carrier === null || typeof carrier !== "object") continue;
3735
- if (classifyTestMessagePolicy(carrier.errorMessage)) return {
3736
- ...value,
3737
- [carrierKey]: {
3738
- ...carrier,
3739
- errorMessage: explainTestMessagePolicyFailure()
3740
- }
3741
- };
3742
- if (classifyClientPolicy(carrier.errorMessage)) return {
5768
+ const explained = explainFinalFailure(carrier.errorMessage);
5769
+ if (explained !== void 0) return {
3743
5770
  ...value,
3744
5771
  [carrierKey]: {
3745
5772
  ...carrier,
3746
- errorMessage: explainClientPolicyFailure()
5773
+ errorMessage: explained
3747
5774
  }
3748
5775
  };
3749
5776
  const kind = classifyGatewayFailure(carrier.errorMessage);
@@ -3777,8 +5804,8 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
3777
5804
  const original = error instanceof Error ? error.message : String(error);
3778
5805
  const text = redactSecrets(original, key === void 0 ? [] : [key]);
3779
5806
  const cause = text === original ? error : new Error(text);
3780
- if (classifyTestMessagePolicy(text)) throw new Error(explainTestMessagePolicyFailure(), { cause });
3781
- if (classifyClientPolicy(text)) throw new Error(explainClientPolicyFailure(), { cause });
5807
+ const explained = explainFinalFailure(text);
5808
+ if (explained !== void 0) throw new Error(explained, { cause });
3782
5809
  const kind = observed ? void 0 : classifyGatewayFailure(text);
3783
5810
  if (kind !== void 0) {
3784
5811
  report(kind, text);
@@ -3989,18 +6016,31 @@ function createPackyCodeProfile(provider) {
3989
6016
  }, `${PLUGIN_ID}.retryPolicy`)
3990
6017
  });
3991
6018
  }
6019
+ /** The initiating session's working directory, when the resolver knows it. */
6020
+ function workingDirectoryOf(resolveWorkingDirectory, sessionId) {
6021
+ if (typeof resolveWorkingDirectory !== "function") return void 0;
6022
+ try {
6023
+ const cwd = resolveWorkingDirectory(sessionId);
6024
+ return typeof cwd === "string" && cwd !== "" && !cwd.includes("\0") ? cwd : void 0;
6025
+ } catch {
6026
+ return;
6027
+ }
6028
+ }
3992
6029
  /**
3993
- * The HTTP route's adapter.
6030
+ * The route's adapter.
3994
6031
  *
3995
6032
  * Each request resolves the active key, then runs on the key the chooser picks
3996
- * for its model; a key-level failure in the stream moves that key aside.
6033
+ * for its model; a key-level failure in the stream moves that key aside. A
6034
+ * Claude request on a key whose group only serves the official client is sent
6035
+ * by the Claude Code CLI (`claudeCli`); everything else is plain HTTP.
3997
6036
  * @param options - the key ring and chooser, the session id used when a caller
3998
- * supplies none, the attachment store image input is read from, and an
3999
- * optional synchronous resolver for the initiating session's exact cwd.
6037
+ * supplies none, the attachment store image input is read from, an optional
6038
+ * synchronous resolver for the initiating session's exact cwd, and the
6039
+ * optional Claude Code CLI transport selector.
4000
6040
  * @returns a `PiAiAdapter` serving the `packycode` route.
4001
6041
  */
4002
- function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments, resolveWorkingDirectory, isModelVisible = () => true }) {
4003
- const raw = createPackyCodeProvider();
6042
+ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments, resolveWorkingDirectory, isModelVisible = () => true, claudeCli }) {
6043
+ const raw = createPackyCodeProvider({ claudeCli });
4004
6044
  /**
4005
6045
  * The gateway's Codex-group check refuses a Responses request that carries
4006
6046
  * no `prompt_cache_key` with a 403, which pi-ai derives from the session id.
@@ -4036,17 +6076,31 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
4036
6076
  streamSimple: guarded("streamSimple")
4037
6077
  });
4038
6078
  const profiles = /* @__PURE__ */ new Map([[PROVIDER, createPackyCodeProfile(provider)]]);
6079
+ /**
6080
+ * Iterate one request inside its context: the transport reads the purpose
6081
+ * and the initiating session's cwd from it where the request is sent.
6082
+ */
6083
+ const inContext = (options, send) => {
6084
+ const clarified = clarifyWorkingDirectory(options, resolveWorkingDirectory);
6085
+ const cwd = workingDirectoryOf(resolveWorkingDirectory, options.sessionId);
6086
+ return classifyRouteFailures(withRequestContext({
6087
+ model: options.model,
6088
+ ...options.purpose === void 0 ? {} : { purpose: options.purpose },
6089
+ ...options.sessionId === void 0 ? {} : { sessionId: String(options.sessionId) },
6090
+ ...cwd === void 0 ? {} : { cwd }
6091
+ }, send(clarified)));
6092
+ };
4039
6093
  return new class extends PiAiAdapter {
4040
6094
  async prepareCall(provider, model, signal) {
4041
6095
  const prepared = await super.prepareCall(provider, model, signal);
4042
6096
  const title = TITLE_MODELS[model];
4043
6097
  return {
4044
6098
  ...prepared,
4045
- stream: (options) => options.purpose === "session-title" && (title !== void 0 || model === "claude-haiku-5-5") ? this.stream(options) : classifyRouteFailures(prepared.stream(clarifyWorkingDirectory(options, resolveWorkingDirectory)))
6099
+ stream: (options) => options.purpose === "session-title" && (title !== void 0 || model === "claude-haiku-5-5") ? this.stream(options) : inContext(options, (routedOptions) => prepared.stream(routedOptions))
4046
6100
  };
4047
6101
  }
4048
6102
  stream(options) {
4049
- return classifyRouteFailures(super.stream(clarifyWorkingDirectory(titleRequest(options), resolveWorkingDirectory)));
6103
+ return inContext(titleRequest(options), (routedOptions) => super.stream(routedOptions));
4050
6104
  }
4051
6105
  async listModels(provider) {
4052
6106
  return (await super.listModels(provider)).filter((model) => isModelVisible(model.id));
@@ -4054,8 +6108,10 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
4054
6108
  }({
4055
6109
  profiles: () => profiles,
4056
6110
  resolveApiKey: async () => {
4057
- const active = await keys.active();
6111
+ const request = currentRequest();
6112
+ const active = request === void 0 ? await ring.activeKey() : await keys.active();
4058
6113
  if (active === void 0) throw new LlmError("No usable PackyCode API key: add one under Settings → PackyCode, or re-enable the keys this plugin moved past.", "MISSING_CREDENTIAL");
6114
+ if (claudeCli?.followsGroups?.() === true && isClaudeModel(request?.model)) await keys.awaitGroups?.();
4059
6115
  return active.key;
4060
6116
  },
4061
6117
  auth: Object.freeze({
@@ -4074,10 +6130,11 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
4074
6130
  * The loopback RPC surface behind the settings panel and the composer chip.
4075
6131
  *
4076
6132
  * Every reply carries only what this plugin owns: key labels and state, the
4077
- * balance and group projections the host built, host overrides, the plugin
4078
- * version, web search preferences and model visibility, bounded session-authorized
4079
- * original PNG chunks, or a numbers-only session diagnosis. Key material never
4080
- * crosses this boundary.
6133
+ * balance and group projections the host built, host overrides, whether this
6134
+ * device has the Claude Code CLI and which transport Claude requests take, the
6135
+ * plugin version, web search preferences and model visibility, bounded
6136
+ * session-authorized original PNG chunks, or a numbers-only session
6137
+ * diagnosis. Key material never crosses this boundary.
4081
6138
  *
4082
6139
  * @module dsh-packycode-api/rpc
4083
6140
  */
@@ -4099,15 +6156,16 @@ const keyId = (payload) => String(payload?.id ?? "");
4099
6156
  /**
4100
6157
  * Build the RPC handler.
4101
6158
  * @param options - the key ring, the balance and group readers, the host
4102
- * preference, and the optional plugin manager and web search controller
4103
- * (without one, its endpoints refuse).
6159
+ * preference, and the optional Claude Code CLI support, plugin manager and
6160
+ * web search controller (without one, its endpoints refuse).
4104
6161
  * @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
4105
6162
  */
4106
- function createPackyCodeRpc({ ring, balance, groups, hosts, preference, pluginManager, search, modelSelection, images, imageOriginals, diagnostics }) {
6163
+ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, claude, pluginManager, search, modelSelection, images, imageOriginals, diagnostics }) {
4107
6164
  const need = (service, message) => {
4108
6165
  if (service === void 0) throw new Error(message);
4109
6166
  return service;
4110
6167
  };
6168
+ const cli = () => need(claude, "PackyCode Claude Code CLI support is unavailable in this runtime");
4111
6169
  const manager = () => need(pluginManager, "PackyCode plugin version is unavailable");
4112
6170
  const webSearch = () => need(search, "PackyCode web search is unavailable in this runtime");
4113
6171
  const groupReader = () => need(groups, "PackyCode key groups are unavailable in this runtime");
@@ -4154,7 +6212,17 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, pluginMa
4154
6212
  activeId: state.activeId
4155
6213
  };
4156
6214
  },
4157
- groups: (payload) => groupReader().read({ force: payload?.force === true }),
6215
+ groups: async (payload) => {
6216
+ const reading = await groupReader().read({ force: payload?.force === true });
6217
+ if (claude === void 0) return reading;
6218
+ return {
6219
+ ...reading,
6220
+ keys: reading.keys.map((entry) => ({
6221
+ ...entry,
6222
+ claudeTransport: claude.transportFor(entry.groups)
6223
+ }))
6224
+ };
6225
+ },
4158
6226
  "hosts/update": async (payload) => {
4159
6227
  if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
4160
6228
  await preference.set({
@@ -4166,6 +6234,15 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, pluginMa
4166
6234
  images?.invalidateBalance?.();
4167
6235
  return status();
4168
6236
  },
6237
+ "claude-cli/status": (payload) => cli().status({ force: payload?.force === true }),
6238
+ "claude-cli/install": () => {
6239
+ cli().install();
6240
+ return cli().status();
6241
+ },
6242
+ "claude-cli/update": async (payload) => {
6243
+ await cli().update({ transport: payload?.transport });
6244
+ return cli().status();
6245
+ },
4169
6246
  "plugin/version": (payload, signal) => manager().read({
4170
6247
  force: payload?.force === true,
4171
6248
  signal
@@ -5120,7 +7197,15 @@ const Config = z.object({
5120
7197
  hiddenModels: live(z.array(z.string()).default([])),
5121
7198
  imageEnabled: live(z.boolean().default(true)),
5122
7199
  imageModel: live(z.union([...IMAGE_MODEL_IDS]).default(DEFAULT_IMAGE_MODEL)),
5123
- imageQuality: live(z.union([...IMAGE_QUALITIES]).default("auto"))
7200
+ imageQuality: live(z.union([...IMAGE_QUALITIES]).default("auto")),
7201
+ /**
7202
+ * How Claude requests travel: `auto` sends a key in the `cc` group through
7203
+ * the official Claude Code CLI and every other key over HTTP; `cli` and
7204
+ * `http` pin one transport.
7205
+ */
7206
+ claudeTransport: live(z.union([...CLAUDE_TRANSPORTS]).default(DEFAULT_CLAUDE_TRANSPORT)),
7207
+ /** Path to the Claude Code CLI; empty means "the usual install places, then PATH". */
7208
+ claudeCliPath: live(z.string().default(""))
5124
7209
  });
5125
7210
  function apply(ctx, config) {
5126
7211
  const ring = new PackyCodeKeyRing(ctx.credentials);
@@ -5133,7 +7218,9 @@ function apply(ctx, config) {
5133
7218
  hiddenModels: fieldValue(config?.hiddenModels),
5134
7219
  imageEnabled: fieldValue(config?.imageEnabled),
5135
7220
  imageModel: fieldValue(config?.imageModel),
5136
- imageQuality: fieldValue(config?.imageQuality)
7221
+ imageQuality: fieldValue(config?.imageQuality),
7222
+ claudeTransport: fieldValue(config?.claudeTransport),
7223
+ claudeCliPath: fieldValue(config?.claudeCliPath)
5137
7224
  });
5138
7225
  const hosts = () => {
5139
7226
  const current = source() ?? {};
@@ -5194,12 +7281,45 @@ function apply(ctx, config) {
5194
7281
  isWritable: () => modelPreference.writable === true && typeof modelPreference.set === "function",
5195
7282
  onChange: () => refreshModels()
5196
7283
  });
7284
+ /**
7285
+ * Keys in a group that only serves the official client send their Claude
7286
+ * requests through this device's Claude Code CLI. DSH's agent loop, tools,
7287
+ * permissions and plan mode are unchanged; only the transport differs.
7288
+ */
7289
+ const claudeCli = createClaudeCliManager({ explicitPath: () => fieldValue(source()?.claudeCliPath) });
7290
+ const cliTransport = createClaudeCliTransport({
7291
+ binary: async () => {
7292
+ const found = await claudeCli.detect();
7293
+ if (typeof found.path !== "string" || found.path === "") throw Object.assign(/* @__PURE__ */ new Error("Claude Code CLI is not installed"), { code: "CLAUDE_CLI_MISSING" });
7294
+ return found.path;
7295
+ },
7296
+ version: () => claudeCli.lastDetection()?.version,
7297
+ logger: ctx.logger
7298
+ });
7299
+ ctx.effect(() => () => {
7300
+ cliTransport.close();
7301
+ }, "packycode-api: Claude Code CLI processes");
7302
+ const claudePreference = {
7303
+ writable: false,
7304
+ set: void 0
7305
+ };
7306
+ const claudeTransportPreference = () => readClaudeTransport(source()?.claudeTransport);
7307
+ /** The transport a Claude request on this key takes, by its last group reading. */
7308
+ const claudeTransportOf = (keyId) => claudeTransportFor({
7309
+ preference: claudeTransportPreference(),
7310
+ groups: keyId === void 0 ? void 0 : groups.groupsOf(keyId)
7311
+ });
5197
7312
  const registration = ctx.llm.registerAdapter([PROVIDER], createRouteAdapter({
5198
7313
  ring,
5199
7314
  keys,
5200
7315
  fallbackSessionId,
5201
7316
  resolveAttachments: attachments,
5202
7317
  isModelVisible: modelSelection.isVisible,
7318
+ claudeCli: Object.freeze({
7319
+ followsGroups: () => claudeTransportPreference() === "auto",
7320
+ choose: ({ apiKey, model }) => isClaudeModel(model) && claudeTransportOf(keys.idOf(apiKey)).transport === "cli",
7321
+ fetch: (input, init, meta) => cliTransport.fetch(input, init, meta)
7322
+ }),
5203
7323
  resolveWorkingDirectory: (sessionId) => {
5204
7324
  const session = currentSession();
5205
7325
  return session && (sessionId === void 0 || session.id === sessionId) ? session.header?.cwd : void 0;
@@ -5283,7 +7403,8 @@ function apply(ctx, config) {
5283
7403
  hostPreference,
5284
7404
  searchPreference,
5285
7405
  modelPreference,
5286
- imagePreference
7406
+ imagePreference,
7407
+ claudePreference
5287
7408
  }));
5288
7409
  ctx.on?.("loader/volatile-update", () => {
5289
7410
  modelSelection.reconcile();
@@ -5325,6 +7446,30 @@ function apply(ctx, config) {
5325
7446
  hosts,
5326
7447
  preference: hostPreference,
5327
7448
  pluginManager: createPackyCodePluginManager(),
7449
+ claude: Object.freeze({
7450
+ async status({ force = false } = {}) {
7451
+ return {
7452
+ ...await claudeCli.status({ force }),
7453
+ transport: claudeTransportPreference(),
7454
+ writable: claudePreference.writable === true && typeof claudePreference.set === "function",
7455
+ processes: cliTransport.stats()
7456
+ };
7457
+ },
7458
+ install: () => claudeCli.install(),
7459
+ async update({ transport } = {}) {
7460
+ if (!CLAUDE_TRANSPORTS.includes(transport)) throw new Error("That PackyCode Claude transport is not supported");
7461
+ if (claudePreference.writable !== true || typeof claudePreference.set !== "function") throw new Error("PackyCode settings are not writable in this profile");
7462
+ try {
7463
+ await claudePreference.set({ claudeTransport: transport });
7464
+ } catch {
7465
+ throw new Error("PackyCode could not save the Claude transport");
7466
+ }
7467
+ },
7468
+ transportFor: (names) => claudeTransportFor({
7469
+ preference: claudeTransportPreference(),
7470
+ groups: names
7471
+ })
7472
+ }),
5328
7473
  search,
5329
7474
  modelSelection,
5330
7475
  images,
@@ -5367,4 +7512,4 @@ function apply(ctx, config) {
5367
7512
  });
5368
7513
  }
5369
7514
  //#endregion
5370
- export { CHANNEL, Config, DEFAULT_API_HOST, DEFAULT_IMAGE_MODEL, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DIAGNOSTICS_FORMAT, DIAGNOSTICS_LIMITS, DISPLAY_NAME, IMAGE_MODELS, IMAGE_MODEL_IDS, IMAGE_QUALITIES, IMAGE_TIMEOUT_MS, IMAGE_TOOL_NAME, LEGACY_STATUS_HOSTS, MAX_IMAGE_BYTES, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TEST_MESSAGE_POLICY_CODE, apply, assertApiKey, buildSearchRequest, catalogCoverage, classifyClientPolicy, classifyGatewayFailure, classifyTestMessagePolicy, classifyUpstreamServerError, createBalanceReader, createDiagnostics, createGroupReader, createImageController, createKeyChooser, createModelSelectionController, createPackyCodeAutoSearchProvider, createPackyCodeImageClient, createPackyCodeImageTool, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, decodeImagePng, explainClientPolicyFailure, explainTestMessagePolicyFailure, guardKeyRotation, inject, name, normalizeFailureMessage, normalizeHost, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, pngDimensions, readHiddenModels, readImagePreference, readSearchChoice, statusHostOrder, summarizeSessionEvents, validateImageOptions };
7515
+ export { CHANNEL, CLAUDE_INSTALL_COMMAND, CLAUDE_MISSING_MESSAGE, CLAUDE_NPM_PACKAGE, CLAUDE_TRANSPORTS, CLI_ONLY_GROUPS, CLI_SESSION_IDLE_MS, CLI_TOOL_NOTE, Config, DEFAULT_API_HOST, DEFAULT_CLAUDE_CONFIG_DIR, DEFAULT_CLAUDE_TRANSPORT, DEFAULT_IMAGE_MODEL, DEFAULT_SEARCH_BACKEND, DEFAULT_STATUS_HOST, DIAGNOSTICS_FORMAT, DIAGNOSTICS_LIMITS, DISPLAY_NAME, HTTP_CLAUDE_GROUPS, IMAGE_MODELS, IMAGE_MODEL_IDS, IMAGE_QUALITIES, IMAGE_TIMEOUT_MS, IMAGE_TOOL_NAME, LEGACY_STATUS_HOSTS, MAX_IMAGE_BYTES, MAX_LIVE_CLI_SESSIONS, MCP_SERVER_NAME, MCP_TOOL_PREFIX, PACKYCODE_AUTO_SEARCH_PROVIDER_ID, PACKYCODE_MODELS, PACKYCODE_MODEL_IDS, PACKYCODE_SEARCH_PROVIDER_ID, PLUGIN_ID, PROVIDER, PackyCodeKeyRing, SEARCH_BACKENDS, SEARCH_BACKEND_MODELS, SEARCH_MODES, SETTINGS_NAMESPACE, TEST_MESSAGE_POLICY_CODE, apply, assertApiKey, baseUrlOf, buildCliArgs, buildCliEnvironment, buildSearchRequest, buildTranscript, canonicalMessage, catalogCoverage, classifyCliOutdated, classifyCliUnavailable, classifyClientPolicy, classifyGatewayFailure, classifyTestMessagePolicy, classifyUpstreamServerError, claudeBinaryCandidates, claudeTransportFor, cliErrorMessage, cliToolName, createBalanceReader, createClaudeCliManager, createClaudeCliTransport, createClaudeToolNames, createDiagnostics, createGroupReader, createImageController, createKeyChooser, createModelSelectionController, createPackyCodeAutoSearchProvider, createPackyCodeImageClient, createPackyCodeImageTool, createPackyCodeProfile, createPackyCodeProvider, createPackyCodeRpc, createPackyCodeSearchProvider, createProfileSearchComposition, createRouteAdapter, createSearchComposition, createSearchController, decodeImagePng, describeRequest, dshSystemText, explainCliClientPolicyFailure, explainCliOutdated, explainCliUnavailable, explainClientPolicyFailure, explainNpmFailure, explainTestMessagePolicyFailure, guardKeyRotation, inject, locateClaudeBinary, locateNpm, name, normalizeFailureMessage, normalizeHost, parseClaudeVersion, parseMessagesSearchResponse, parsePricing, parseResponsesSearchResponse, parseSelectedGroups, parseTokenUsage, pickKeyForModel, planContinuation, pngDimensions, readClaudeTransport, readHiddenModels, readImagePreference, readSearchChoice, resolveSpawnCommand, splitConversation, startClaudeCliSession, startClaudeMcpBridge, statusHostOrder, summarizeSessionEvents, toolResultToMcp, validateImageOptions };