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