dsh-packycode-api 1.3.2 → 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",
@@ -470,6 +471,2047 @@ function createBalanceReader({ listKeys, getHosts, fetchImpl = ambientFetch, now
470
471
  });
471
472
  }
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
+ };
2427
+ return Object.freeze({
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
+ }
2444
+ });
2445
+ }
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;
@@ -2542,7 +4602,11 @@ function createModelSelectionController({ readPreference, writePreference, isWri
2542
4602
  * The running version comes from this package. The latest version comes from
2543
4603
  * the npm registry. An in-place update is only offered for a registry install,
2544
4604
  * and it installs that exact version with the host's own pnpm — never through
2545
- * `dsh plugin --profile desktop`, which the desktop app refuses. Registry
4605
+ * `dsh plugin --profile desktop`, which the desktop app refuses. The install
4606
+ * runs under the profile's own `minimumReleaseAge`: a version installed past
4607
+ * it would sit in the profile's lockfile, and pnpm then refuses every later
4608
+ * plugin install in that profile until the version is old enough. A version
4609
+ * pnpm still holds back is reported as pending, with when it clears. Registry
2546
4610
  * bodies and package-manager output stay host-side.
2547
4611
  *
2548
4612
  * @module dsh-packycode-api/plugin-version
@@ -2552,6 +4616,28 @@ const NPM_REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/late
2552
4616
  const DEFAULT_VERSION_TTL_MS = 5 * 6e4;
2553
4617
  const DEFAULT_VERSION_TIMEOUT_MS = 1e4;
2554
4618
  const DEFAULT_UPDATE_TIMEOUT_MS = 18e4;
4619
+ const RELEASE_AGE_ERROR = /ERR_PNPM_(?:NO_MATURE_MATCHING_VERSION|MINIMUM_RELEASE_AGE_VIOLATION)/u;
4620
+ const RELEASE_AGE_ENTRY = /(\S+@\S+) was published at (\S+?), within the minimumReleaseAge cutoff \((\S+?)\)/gu;
4621
+ /**
4622
+ * Recognize pnpm refusing a version that is younger than its
4623
+ * `minimumReleaseAge`, whether the version was requested or already sits in
4624
+ * the lockfile. Each refused entry reads `<name>@<version> was published at
4625
+ * <time>, within the minimumReleaseAge cutoff (<time>)`.
4626
+ * @param output - the install's stdout and stderr.
4627
+ * @param finishedAt - when the install ended; pnpm drew its cutoff then.
4628
+ * @returns undefined when this is not a release-age refusal; otherwise
4629
+ * `{ availableAt }`, when the youngest refused entry clears, if pnpm said.
4630
+ */
4631
+ function releaseAgeRefusal(output, finishedAt) {
4632
+ const text = String(output ?? "").replace(/\u001b\[[0-9;]*m/gu, "");
4633
+ if (!RELEASE_AGE_ERROR.test(text)) return void 0;
4634
+ let availableAt;
4635
+ for (const [, , published, cutoff] of text.matchAll(RELEASE_AGE_ENTRY)) {
4636
+ const clears = Date.parse(published) + (finishedAt - Date.parse(cutoff));
4637
+ if (Number.isFinite(clears) && (availableAt === void 0 || clears > availableAt)) availableAt = clears;
4638
+ }
4639
+ return availableAt === void 0 ? {} : { availableAt };
4640
+ }
2555
4641
  /** Parse a strict `x.y.z[-pre][+build]` version; anything else is undefined. */
2556
4642
  function parseSemver(value) {
2557
4643
  if (typeof value !== "string") return void 0;
@@ -2826,7 +4912,6 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2826
4912
  ...launcher.prefix,
2827
4913
  "add",
2828
4914
  "--save-exact",
2829
- `--config.minimumReleaseAge=0`,
2830
4915
  `${PACKAGE_NAME}@${latest}`
2831
4916
  ],
2832
4917
  cwd: profileDir
@@ -2838,7 +4923,16 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2838
4923
  if (signal?.aborted) throw error;
2839
4924
  throw new Error("PackyCode plugin update failed");
2840
4925
  }
2841
- if (result.code !== 0) throw new Error("PackyCode plugin update failed");
4926
+ if (result.code !== 0) {
4927
+ const refusal = releaseAgeRefusal(`${result.stdout ?? ""}\n${result.stderr ?? ""}`, now());
4928
+ if (refusal !== void 0) return {
4929
+ version: latest,
4930
+ profile: install.profile,
4931
+ pending: true,
4932
+ ...refusal
4933
+ };
4934
+ throw new Error("PackyCode plugin update failed");
4935
+ }
2842
4936
  await confirmInstalled({
2843
4937
  profileDir,
2844
4938
  expected: latest,
@@ -2857,154 +4951,6 @@ function createPackyCodePluginManager({ fetchImpl = ambientFetch, runCommand = s
2857
4951
  });
2858
4952
  }
2859
4953
  //#endregion
2860
- //#region src/failures.js
2861
- /**
2862
- * Failure classification for the two conditions that move the key ring along.
2863
- *
2864
- * The gateway reports an exhausted key and a rejected key in Chinese, and the
2865
- * harness classifies failures by fixed English/status wording it recognizes
2866
- * (`isQuotaExceededError`, `\b401\b`). Normalizing here is what makes the
2867
- * harness's retry policy re-run the step — and therefore what lets the retry
2868
- * pick up the next key. Rewriting is honest about what happened: this plugin
2869
- * knows the key's condition from the gateway's own words plus its balance
2870
- * reader, so the sentence it substitutes says no more than that.
2871
- *
2872
- * @module dsh-packycode-api/failures
2873
- */
2874
- /** Terminal quota, balance, or credit wording, in the gateway's own languages. */
2875
- const QUOTA_WORDING = /额度|余额|配额|欠费|insufficient[\s_-]+(?:quota|balance|credits?)|quota[\s_-]+(?:exceeded|exhausted|reached)|out[\s_-]+of[\s_-]+(?:credits?|budget)/iu;
2876
- /** A rejected key names itself; a bare 403 does not, and must not disable one. */
2877
- const INVALID_KEY_WORDING = /无效的令牌|令牌无效|无效密钥|密钥无效|invalid[\s_-]+api[\s_-]*key|invalid_authentication|api key is invalid/iu;
2878
- const INVALID_KEY_STATUS = /\b401\b/;
2879
- /**
2880
- * A gateway group that only serves its vendor's official client. These arrive
2881
- * as 403, which the harness reads as an authentication failure and renders as
2882
- * an invalid-API-key problem — the opposite of what happened. This wording
2883
- * also covers intermittent client-anomaly refusals; it does not establish
2884
- * whether a future request will be accepted.
2885
- */
2886
- 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;
2887
- /** A content-policy refusal observed on an already-authorized request. */
2888
- const TEST_MESSAGE_POLICY_CODE = "GATEWAY_TEST_MESSAGE";
2889
- const TEST_MESSAGE_PREFIX = `[${TEST_MESSAGE_POLICY_CODE}]`;
2890
- /** Match the captured refusal narrowly; a bare 403 is still not evidence. */
2891
- function classifyTestMessagePolicy(message) {
2892
- if (typeof message !== "string") return false;
2893
- 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);
2894
- }
2895
- /** Keep the host's status-word classifier from turning this into AUTH. */
2896
- function explainTestMessagePolicyFailure() {
2897
- return `${TEST_MESSAGE_PREFIX} PackyCode 将这次请求识别为连通性测试并拒绝。请使用实际业务内容;本次拒绝不表示 Key 失效,也不会禁用或切换 Key。`;
2898
- }
2899
- /**
2900
- * The gateway's answer when a key's groups do not include the model's
2901
- * supplier: `model_not_found` / 当前令牌未覆盖供应商. The key is fine for other
2902
- * models, so it is never moved aside for this.
2903
- */
2904
- const COVERAGE_WORDING = /model_not_found|未覆盖供应商|无可用渠道|no available channel/iu;
2905
- /**
2906
- * Whether the gateway refused the model for this key's groups.
2907
- * @param message - the failure text.
2908
- * @returns true for a group-coverage refusal.
2909
- */
2910
- function classifyCoverage(message) {
2911
- return COVERAGE_WORDING.test(typeof message === "string" ? message : "");
2912
- }
2913
- /** The key's quota is exhausted; move to the next key. */
2914
- const QUOTA_FAILURE = "quota";
2915
- /** The gateway rejected the key itself; move to the next key. */
2916
- const INVALID_KEY_FAILURE = "invalid-key";
2917
- /**
2918
- * Whether the gateway is refusing this client rather than this credential.
2919
- * @param message - provider failure text, as pi-ai carries it.
2920
- * @returns true when the route is gated to a vendor's official client.
2921
- */
2922
- function classifyClientPolicy(message) {
2923
- return CLIENT_POLICY_WORDING.test(typeof message === "string" ? message : "");
2924
- }
2925
- /**
2926
- * OpenAI's generic transient failure: the sentence its `server_error` carries,
2927
- * or the `server_error:` prefix pi-ai writes when the event names the code.
2928
- * The gateway can relay it inside a stream it has already answered 200 for —
2929
- * an SSE `error` event with a 503 `server_error`, then `response.failed` — so
2930
- * no HTTP status comes with it.
2931
- */
2932
- const UPSTREAM_SERVER_ERROR = /an error occurred while processing your request\b[\s\S]*\bhelp\.openai\.com\b|^(?:error code\s+)?server_error:/iu;
2933
- /**
2934
- * Whether a failure is the upstream's transient server error, which a resent
2935
- * request clears. The key is not at fault.
2936
- * @param message - provider failure text, as pi-ai carries it.
2937
- * @returns true for OpenAI's generic `server_error`.
2938
- */
2939
- function classifyUpstreamServerError(message) {
2940
- return UPSTREAM_SERVER_ERROR.test(typeof message === "string" ? message : "");
2941
- }
2942
- /**
2943
- * Classify a gateway failure message.
2944
- *
2945
- * Quota wording wins over a status token: an exhausted balance is often
2946
- * reported as 402/403, and only the key's own rejection may read as 401. A
2947
- * client-policy refusal is not a key fault and is classified separately.
2948
- * @param message - provider failure text, as pi-ai carries it.
2949
- * @returns the failure kind, or undefined when the key is not at fault.
2950
- */
2951
- function classifyGatewayFailure(message) {
2952
- const text = typeof message === "string" ? message : "";
2953
- if (text === "") return void 0;
2954
- if (classifyClientPolicy(text) || classifyTestMessagePolicy(text)) return void 0;
2955
- if (QUOTA_WORDING.test(text)) return QUOTA_FAILURE;
2956
- if (INVALID_KEY_WORDING.test(text)) return INVALID_KEY_FAILURE;
2957
- if (INVALID_KEY_STATUS.test(text)) return INVALID_KEY_FAILURE;
2958
- }
2959
- /**
2960
- * Rewrite a failure message so the harness classifies it as quota or auth.
2961
- *
2962
- * A bare status token is neutralized first: an exhausted balance arrives as
2963
- * 402 or 403 on some gateway routes, and the harness's auth rule matches
2964
- * 401/403 before it reads any wording, which would send the failure into a
2965
- * category the retry policy does not re-run.
2966
- * @param message - the original provider failure text.
2967
- * @param kind - the classification of {@link classifyGatewayFailure}.
2968
- * @returns text the harness routes to QUOTA or AUTH.
2969
- */
2970
- /**
2971
- * Explain a client-policy refusal so the user reads the real cause.
2972
- *
2973
- * The sentence must not read as any other failure the harness knows: it
2974
- * deliberately carries no HTTP status and none of the words the classifier
2975
- * matches for auth, quota, rate limits, timeouts, or transport faults. That
2976
- * keeps the failure out of the host's generic retry policy and lets the
2977
- * sentence reach the user verbatim, in
2978
- * place of the "API key is invalid" the bare 403 would have produced.
2979
- * @param keyName - the account name the balance reader saw, when known.
2980
- * @returns the replacement sentence.
2981
- */
2982
- function explainClientPolicyFailure() {
2983
- return "PackyCode 拒绝了这个客户端:所选分组只允许厂商官方客户端调用(Claude 分组要求官方 Claude CLI,Codex 分组要求标准 Codex 客户端)。这不是 API Key 失效。请检查当前分组的客户端要求;若调整分组,应先在模型广场确认该模型可用。 / This group only serves the vendor’s official client; the API key is fine.";
2984
- }
2985
- /**
2986
- * Rewrite a failure message so the harness classifies it as quota or auth.
2987
- *
2988
- * A bare status token is neutralized first: an exhausted balance arrives as
2989
- * 402 or 403 on some gateway routes, and the harness's auth rule matches
2990
- * 401/403 before it reads any wording, which would send the failure into a
2991
- * category the retry policy does not re-run.
2992
- * @param message - the original provider failure text.
2993
- * @param kind - the classification of {@link classifyGatewayFailure}.
2994
- * @param options - optional account name for the policy explanation.
2995
- * @returns text the harness routes to QUOTA or AUTH, or a policy explanation.
2996
- */
2997
- function normalizeFailureMessage(message, kind, options = {}) {
2998
- const text = typeof message === "string" && message.length > 0 ? message : "PackyCode request failed";
2999
- if (kind === "client-policy") return explainClientPolicyFailure(options.keyName);
3000
- if (kind === "quota") return `${neutralizeAuthStatus(text)} — PackyCode: insufficient quota for this API key; trying the next key`;
3001
- if (kind === "invalid-key") return `${neutralizeAuthStatus(text)} — PackyCode: 401 invalid_authentication for this API key; trying the next key`;
3002
- return text;
3003
- }
3004
- function neutralizeAuthStatus(text) {
3005
- return text.replace(/\b(?:401|403)\b/gu, "HTTP");
3006
- }
3007
- //#endregion
3008
4954
  //#region src/gemini-replay.js
3009
4955
  /** A bounded resend for the gateway's intermittent Flash signature refusal. */
3010
4956
  const waitForRetry = (ms, signal) => setTimeout$1(ms, void 0, { signal });
@@ -3107,8 +5053,13 @@ async function isReplayableRefusal(response, { allowTestMessage }) {
3107
5053
  reader?.releaseLock();
3108
5054
  }
3109
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
+ */
3110
5061
  function hasToolContext(context) {
3111
- 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"));
3112
5063
  }
3113
5064
  /**
3114
5065
  * Fixed Haiku and Sonnet tool payloads were observed returning both 200 and
@@ -3298,16 +5249,37 @@ const BUILTIN_NAMES = Object.freeze({
3298
5249
  glob: "Glob",
3299
5250
  grep: "Grep"
3300
5251
  });
3301
- /** CLI-compatible wire names, restored to their originals before host dispatch. */
3302
- 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 } = {}) {
3303
5272
  const owners = /* @__PURE__ */ new Map(), offered = /* @__PURE__ */ new Map(), clientNames = /* @__PURE__ */ new Set();
3304
5273
  let declaredNames = /* @__PURE__ */ new Set();
3305
5274
  const encode = (name, available = false) => {
3306
5275
  if (typeof name !== "string") return name;
3307
- const slug = name.replace(/[^a-zA-Z0-9_-]/gu, "_").slice(0, 40) || "tool";
3308
- const digest = createHash("sha256").update(name).digest("hex").slice(0, 12);
3309
- const builtin = Object.hasOwn(BUILTIN_NAMES, name) ? BUILTIN_NAMES[name] : void 0;
3310
- 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
+ }
3311
5283
  if (owners.has(alias) && owners.get(alias) !== name) throw new Error("Ambiguous Claude tool name");
3312
5284
  owners.set(alias, name);
3313
5285
  if (available) {
@@ -3392,8 +5364,54 @@ function createClaudeToolNames() {
3392
5364
  };
3393
5365
  }
3394
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
3395
5413
  //#region src/claude-api.js
3396
- /** 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. */
3397
5415
  const CLIENT_VERSION = "2.1.295";
3398
5416
  const USER_AGENT = `claude-cli/${CLIENT_VERSION} (external, sdk-cli)`;
3399
5417
  const CLIENT_BETA = "claude-code-20250219";
@@ -3440,8 +5458,14 @@ function sessionUuid(value) {
3440
5458
  * Only the host-side client envelope is changed. The bearer is always the
3441
5459
  * explicit request key selected by this plugin; there is no credential or
3442
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.
3443
5467
  */
3444
- function createClaudeApi() {
5468
+ function createClaudeApi({ cli } = {}) {
3445
5469
  const api = anthropicMessagesApi();
3446
5470
  const deviceId = randomBytes(32).toString("hex");
3447
5471
  const adapt = (model, context, options, toolNames) => {
@@ -3511,9 +5535,38 @@ function createClaudeApi() {
3511
5535
  }
3512
5536
  };
3513
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
+ };
3514
5563
  const invoke = (method, model, context, options) => {
3515
- const names = createClaudeToolNames();
3516
- 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)));
3517
5570
  };
3518
5571
  return {
3519
5572
  ...api,
@@ -3624,6 +5677,16 @@ function createGeminiApi() {
3624
5677
  */
3625
5678
  const TERMINAL_EVENTS = /* @__PURE__ */ new Set(["error", "done"]);
3626
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
+ /**
3627
5690
  * The api-key method pi-ai consults before it honours a request's `apiKey`
3628
5691
  * override. Resolution itself stays empty: this plugin authenticates from its
3629
5692
  * own key ring through the adapter, and ambient `PACKY_CODE_API_KEY` discovery
@@ -3639,8 +5702,12 @@ function keyRingAuth() {
3639
5702
  })
3640
5703
  } };
3641
5704
  }
3642
- /** Build the route's pi-ai provider (unguarded; the plugin wraps its streams). */
3643
- 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 } = {}) {
3644
5711
  const provider = createProvider({
3645
5712
  id: PROVIDER,
3646
5713
  name: DISPLAY_NAME,
@@ -3650,7 +5717,7 @@ function createPackyCodeProvider() {
3650
5717
  api: {
3651
5718
  "openai-completions": openAICompletionsApi(),
3652
5719
  "openai-responses": openAIResponsesApi(),
3653
- "anthropic-messages": createClaudeApi(),
5720
+ "anthropic-messages": createClaudeApi({ cli: claudeCli }),
3654
5721
  "google-generative-ai": createGeminiApi()
3655
5722
  }
3656
5723
  });
@@ -3698,18 +5765,12 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
3698
5765
  for (const carrierKey of ["error", "message"]) {
3699
5766
  const carrier = value[carrierKey];
3700
5767
  if (carrier === null || typeof carrier !== "object") continue;
3701
- if (classifyTestMessagePolicy(carrier.errorMessage)) return {
3702
- ...value,
3703
- [carrierKey]: {
3704
- ...carrier,
3705
- errorMessage: explainTestMessagePolicyFailure()
3706
- }
3707
- };
3708
- if (classifyClientPolicy(carrier.errorMessage)) return {
5768
+ const explained = explainFinalFailure(carrier.errorMessage);
5769
+ if (explained !== void 0) return {
3709
5770
  ...value,
3710
5771
  [carrierKey]: {
3711
5772
  ...carrier,
3712
- errorMessage: explainClientPolicyFailure()
5773
+ errorMessage: explained
3713
5774
  }
3714
5775
  };
3715
5776
  const kind = classifyGatewayFailure(carrier.errorMessage);
@@ -3743,8 +5804,8 @@ function guardKeyRotation(stream, { onKeyFailure, key } = {}) {
3743
5804
  const original = error instanceof Error ? error.message : String(error);
3744
5805
  const text = redactSecrets(original, key === void 0 ? [] : [key]);
3745
5806
  const cause = text === original ? error : new Error(text);
3746
- if (classifyTestMessagePolicy(text)) throw new Error(explainTestMessagePolicyFailure(), { cause });
3747
- if (classifyClientPolicy(text)) throw new Error(explainClientPolicyFailure(), { cause });
5807
+ const explained = explainFinalFailure(text);
5808
+ if (explained !== void 0) throw new Error(explained, { cause });
3748
5809
  const kind = observed ? void 0 : classifyGatewayFailure(text);
3749
5810
  if (kind !== void 0) {
3750
5811
  report(kind, text);
@@ -3955,18 +6016,31 @@ function createPackyCodeProfile(provider) {
3955
6016
  }, `${PLUGIN_ID}.retryPolicy`)
3956
6017
  });
3957
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
+ }
3958
6029
  /**
3959
- * The HTTP route's adapter.
6030
+ * The route's adapter.
3960
6031
  *
3961
6032
  * Each request resolves the active key, then runs on the key the chooser picks
3962
- * 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.
3963
6036
  * @param options - the key ring and chooser, the session id used when a caller
3964
- * supplies none, the attachment store image input is read from, and an
3965
- * 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.
3966
6040
  * @returns a `PiAiAdapter` serving the `packycode` route.
3967
6041
  */
3968
- function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments, resolveWorkingDirectory, isModelVisible = () => true }) {
3969
- const raw = createPackyCodeProvider();
6042
+ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments, resolveWorkingDirectory, isModelVisible = () => true, claudeCli }) {
6043
+ const raw = createPackyCodeProvider({ claudeCli });
3970
6044
  /**
3971
6045
  * The gateway's Codex-group check refuses a Responses request that carries
3972
6046
  * no `prompt_cache_key` with a 403, which pi-ai derives from the session id.
@@ -4002,17 +6076,31 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
4002
6076
  streamSimple: guarded("streamSimple")
4003
6077
  });
4004
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
+ };
4005
6093
  return new class extends PiAiAdapter {
4006
6094
  async prepareCall(provider, model, signal) {
4007
6095
  const prepared = await super.prepareCall(provider, model, signal);
4008
6096
  const title = TITLE_MODELS[model];
4009
6097
  return {
4010
6098
  ...prepared,
4011
- 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))
4012
6100
  };
4013
6101
  }
4014
6102
  stream(options) {
4015
- return classifyRouteFailures(super.stream(clarifyWorkingDirectory(titleRequest(options), resolveWorkingDirectory)));
6103
+ return inContext(titleRequest(options), (routedOptions) => super.stream(routedOptions));
4016
6104
  }
4017
6105
  async listModels(provider) {
4018
6106
  return (await super.listModels(provider)).filter((model) => isModelVisible(model.id));
@@ -4020,8 +6108,10 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
4020
6108
  }({
4021
6109
  profiles: () => profiles,
4022
6110
  resolveApiKey: async () => {
4023
- const active = await keys.active();
6111
+ const request = currentRequest();
6112
+ const active = request === void 0 ? await ring.activeKey() : await keys.active();
4024
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?.();
4025
6115
  return active.key;
4026
6116
  },
4027
6117
  auth: Object.freeze({
@@ -4040,10 +6130,11 @@ function createRouteAdapter({ ring, keys, fallbackSessionId, resolveAttachments,
4040
6130
  * The loopback RPC surface behind the settings panel and the composer chip.
4041
6131
  *
4042
6132
  * Every reply carries only what this plugin owns: key labels and state, the
4043
- * balance and group projections the host built, host overrides, the plugin
4044
- * version, web search preferences and model visibility, bounded session-authorized
4045
- * original PNG chunks, or a numbers-only session diagnosis. Key material never
4046
- * 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.
4047
6138
  *
4048
6139
  * @module dsh-packycode-api/rpc
4049
6140
  */
@@ -4065,15 +6156,16 @@ const keyId = (payload) => String(payload?.id ?? "");
4065
6156
  /**
4066
6157
  * Build the RPC handler.
4067
6158
  * @param options - the key ring, the balance and group readers, the host
4068
- * preference, and the optional plugin manager and web search controller
4069
- * (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).
4070
6161
  * @returns a handler for `connection.rpc.handle(CHANNEL, handler)`.
4071
6162
  */
4072
- 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 }) {
4073
6164
  const need = (service, message) => {
4074
6165
  if (service === void 0) throw new Error(message);
4075
6166
  return service;
4076
6167
  };
6168
+ const cli = () => need(claude, "PackyCode Claude Code CLI support is unavailable in this runtime");
4077
6169
  const manager = () => need(pluginManager, "PackyCode plugin version is unavailable");
4078
6170
  const webSearch = () => need(search, "PackyCode web search is unavailable in this runtime");
4079
6171
  const groupReader = () => need(groups, "PackyCode key groups are unavailable in this runtime");
@@ -4120,7 +6212,17 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, pluginMa
4120
6212
  activeId: state.activeId
4121
6213
  };
4122
6214
  },
4123
- 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
+ },
4124
6226
  "hosts/update": async (payload) => {
4125
6227
  if (preference.writable !== true) throw new Error("PackyCode host overrides are not writable in this profile");
4126
6228
  await preference.set({
@@ -4132,6 +6234,15 @@ function createPackyCodeRpc({ ring, balance, groups, hosts, preference, pluginMa
4132
6234
  images?.invalidateBalance?.();
4133
6235
  return status();
4134
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
+ },
4135
6246
  "plugin/version": (payload, signal) => manager().read({
4136
6247
  force: payload?.force === true,
4137
6248
  signal
@@ -5086,7 +7197,15 @@ const Config = z.object({
5086
7197
  hiddenModels: live(z.array(z.string()).default([])),
5087
7198
  imageEnabled: live(z.boolean().default(true)),
5088
7199
  imageModel: live(z.union([...IMAGE_MODEL_IDS]).default(DEFAULT_IMAGE_MODEL)),
5089
- 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(""))
5090
7209
  });
5091
7210
  function apply(ctx, config) {
5092
7211
  const ring = new PackyCodeKeyRing(ctx.credentials);
@@ -5099,7 +7218,9 @@ function apply(ctx, config) {
5099
7218
  hiddenModels: fieldValue(config?.hiddenModels),
5100
7219
  imageEnabled: fieldValue(config?.imageEnabled),
5101
7220
  imageModel: fieldValue(config?.imageModel),
5102
- imageQuality: fieldValue(config?.imageQuality)
7221
+ imageQuality: fieldValue(config?.imageQuality),
7222
+ claudeTransport: fieldValue(config?.claudeTransport),
7223
+ claudeCliPath: fieldValue(config?.claudeCliPath)
5103
7224
  });
5104
7225
  const hosts = () => {
5105
7226
  const current = source() ?? {};
@@ -5160,12 +7281,45 @@ function apply(ctx, config) {
5160
7281
  isWritable: () => modelPreference.writable === true && typeof modelPreference.set === "function",
5161
7282
  onChange: () => refreshModels()
5162
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
+ });
5163
7312
  const registration = ctx.llm.registerAdapter([PROVIDER], createRouteAdapter({
5164
7313
  ring,
5165
7314
  keys,
5166
7315
  fallbackSessionId,
5167
7316
  resolveAttachments: attachments,
5168
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
+ }),
5169
7323
  resolveWorkingDirectory: (sessionId) => {
5170
7324
  const session = currentSession();
5171
7325
  return session && (sessionId === void 0 || session.id === sessionId) ? session.header?.cwd : void 0;
@@ -5249,7 +7403,8 @@ function apply(ctx, config) {
5249
7403
  hostPreference,
5250
7404
  searchPreference,
5251
7405
  modelPreference,
5252
- imagePreference
7406
+ imagePreference,
7407
+ claudePreference
5253
7408
  }));
5254
7409
  ctx.on?.("loader/volatile-update", () => {
5255
7410
  modelSelection.reconcile();
@@ -5291,6 +7446,30 @@ function apply(ctx, config) {
5291
7446
  hosts,
5292
7447
  preference: hostPreference,
5293
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
+ }),
5294
7473
  search,
5295
7474
  modelSelection,
5296
7475
  images,
@@ -5333,4 +7512,4 @@ function apply(ctx, config) {
5333
7512
  });
5334
7513
  }
5335
7514
  //#endregion
5336
- 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 };