clauderipple 0.2.0

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +674 -0
  3. package/README.ko.md +328 -0
  4. package/README.md +372 -0
  5. package/bin/clauderipple.js +12 -0
  6. package/dist/app/assets/trayDownTemplate.png +0 -0
  7. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  8. package/dist/app/assets/trayTemplate.png +0 -0
  9. package/dist/app/assets/trayTemplate@2x.png +0 -0
  10. package/dist/app/assets/trayWarnTemplate.png +0 -0
  11. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  12. package/dist/app/assets/trayWin.png +0 -0
  13. package/dist/app/assets/trayWin@2x.png +0 -0
  14. package/dist/app/assets/trayWinDown.png +0 -0
  15. package/dist/app/assets/trayWinDown@2x.png +0 -0
  16. package/dist/app/assets/trayWinWarn.png +0 -0
  17. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  18. package/dist/app/dist/main.js +518 -0
  19. package/dist/cli/src/browser.js +21 -0
  20. package/dist/cli/src/bundle.js +51 -0
  21. package/dist/cli/src/certs.js +33 -0
  22. package/dist/cli/src/claude-auth.js +112 -0
  23. package/dist/cli/src/codex.js +172 -0
  24. package/dist/cli/src/gen-certs.js +7 -0
  25. package/dist/cli/src/hooks/agent-title.js +160 -0
  26. package/dist/cli/src/index.js +489 -0
  27. package/dist/cli/src/launchd.js +183 -0
  28. package/dist/cli/src/picker.js +166 -0
  29. package/dist/cli/src/probe.js +55 -0
  30. package/dist/cli/src/runtime.js +62 -0
  31. package/dist/cli/src/schtasks.js +134 -0
  32. package/dist/cli/src/settings.js +142 -0
  33. package/dist/cli/src/supervisor.js +100 -0
  34. package/dist/cli/src/tray.js +85 -0
  35. package/dist/router/src/admin.js +945 -0
  36. package/dist/router/src/bootstrap.js +80 -0
  37. package/dist/router/src/certs.js +65 -0
  38. package/dist/router/src/compat.js +172 -0
  39. package/dist/router/src/config.js +179 -0
  40. package/dist/router/src/health.js +45 -0
  41. package/dist/router/src/identity.js +51 -0
  42. package/dist/router/src/index.js +144 -0
  43. package/dist/router/src/ingress/models.js +29 -0
  44. package/dist/router/src/ingress/server.js +400 -0
  45. package/dist/router/src/ingress/translate.js +457 -0
  46. package/dist/router/src/log.js +81 -0
  47. package/dist/router/src/picker.js +74 -0
  48. package/dist/router/src/presets.js +267 -0
  49. package/dist/router/src/providers/anthropic-observed.js +88 -0
  50. package/dist/router/src/providers/anthropic-token-file.js +48 -0
  51. package/dist/router/src/providers/anthropic.js +203 -0
  52. package/dist/router/src/providers/chatgpt/auth.js +226 -0
  53. package/dist/router/src/providers/chatgpt/index.js +274 -0
  54. package/dist/router/src/providers/chatgpt/sse.js +28 -0
  55. package/dist/router/src/providers/chatgpt/translate.js +393 -0
  56. package/dist/router/src/providers/claude-oauth.js +252 -0
  57. package/dist/router/src/providers/openai/index.js +193 -0
  58. package/dist/router/src/providers/openai/translate.js +504 -0
  59. package/dist/router/src/proxy.js +724 -0
  60. package/dist/router/src/redact.js +43 -0
  61. package/dist/router/src/requestlog.js +346 -0
  62. package/dist/router/src/routing.js +113 -0
  63. package/dist/router/src/version.js +8 -0
  64. package/dist/router/src/x509.js +203 -0
  65. package/dist/ui/app.js +1228 -0
  66. package/dist/ui/i18n.js +95 -0
  67. package/dist/ui/index.html +104 -0
  68. package/dist/ui/presets-fallback.js +61 -0
  69. package/dist/ui/style.css +347 -0
  70. package/docs/ARCHITECTURE.md +441 -0
  71. package/package.json +66 -0
@@ -0,0 +1,112 @@
1
+ // `claude setup-token` invocation for a ClaudeRipple-owned, 0600 token file.
2
+ // The token is never echoed, logged, or returned from this module.
3
+ import { execFileSync } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { removeClaudeAuthFile, saveClaudeAuthFile } from "../../router/src/providers/anthropic-token-file.js";
8
+ function isSetupToken(value) {
9
+ return /^sk-ant-oat01-[A-Za-z0-9._~-]{16,}$/.test(value);
10
+ }
11
+ export function parseSetupToken(stdout) {
12
+ const matches = stdout.match(/sk-ant-oat01-[A-Za-z0-9._~-]{16,}/g) ?? [];
13
+ const unique = [...new Set(matches)];
14
+ return unique.length === 1 && isSetupToken(unique[0]) ? unique[0] : null;
15
+ }
16
+ const isWindows = process.platform === "win32";
17
+ /** Launcher names on PATH. Windows has no extensionless one: npm writes .cmd, the app ships .exe. */
18
+ const BINARY_NAMES = isWindows ? ["claude.exe", "claude.cmd", "claude.bat"] : ["claude"];
19
+ /** Where Claude Desktop caches the CLI it downloads, per platform. */
20
+ export function desktopClaudeCodeDirs() {
21
+ if (!isWindows)
22
+ return [path.join(os.homedir(), "Library", "Application Support", "Claude", "claude-code")];
23
+ const roots = [
24
+ process.env.LOCALAPPDATA ?? path.join(os.homedir(), "AppData", "Local"),
25
+ process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"),
26
+ ];
27
+ return roots.map((root) => path.join(root, "Claude", "claude-code"));
28
+ }
29
+ /** Launcher inside one cached version directory. The macOS build nests an .app bundle. */
30
+ function versionBinaries(versionDir) {
31
+ return isWindows
32
+ ? BINARY_NAMES.map((name) => path.join(versionDir, name))
33
+ : [path.join(versionDir, "claude.app", "Contents", "MacOS", "claude")];
34
+ }
35
+ export function latestDesktopClaude() {
36
+ for (const directory of desktopClaudeCodeDirs()) {
37
+ try {
38
+ const versions = fs.readdirSync(directory)
39
+ .filter((entry) => /^\d+\.\d+\.\d+$/.test(entry))
40
+ .sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
41
+ for (const version of versions.reverse()) {
42
+ const binary = versionBinaries(path.join(directory, version)).find((candidate) => fs.existsSync(candidate));
43
+ if (binary)
44
+ return binary;
45
+ }
46
+ }
47
+ catch {
48
+ // Try the next root; a missing directory just means the app has not cached a CLI there.
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ export function claudeBinary(pathValue = process.env.PATH) {
54
+ for (const directory of (pathValue ?? "").split(path.delimiter)) {
55
+ if (!directory)
56
+ continue;
57
+ for (const name of BINARY_NAMES) {
58
+ const binary = path.join(directory, name);
59
+ try {
60
+ // X_OK is not meaningful on Windows; existence of a named launcher is the real test.
61
+ fs.accessSync(binary, isWindows ? fs.constants.F_OK : fs.constants.X_OK);
62
+ return binary;
63
+ }
64
+ catch {
65
+ // Continue to the next name, then the bundled Desktop CLI.
66
+ }
67
+ }
68
+ }
69
+ return latestDesktopClaude();
70
+ }
71
+ function needsTerminal() {
72
+ return (`Claude sign-in needs a terminal: run \`clauderipple claude-login\` in ${isWindows ? "PowerShell" : "Terminal"}. ` +
73
+ "If Claude Code is already signed in on this computer, its login is reused automatically and this step is not needed.");
74
+ }
75
+ export function claudeLogin(home, options = {}) {
76
+ const binary = options.binary ?? claudeBinary(options.pathValue);
77
+ if (!binary)
78
+ throw new Error("Claude Code CLI not found; install Claude Code or open Claude Desktop once");
79
+ // `claude setup-token` is an interactive terminal flow (it opens the browser and waits for the
80
+ // code to be pasted back). From the tray app or the GUI there is no terminal: it cannot succeed,
81
+ // and left to run it waits for input that never comes (the tray has no timeout at all). Refuse
82
+ // before spawning and say where it works (reported as a bare "setup-token failed", 2026-09-15).
83
+ if (!options.run && !(options.interactive ?? process.stdin.isTTY))
84
+ throw new Error(needsTerminal());
85
+ let stdout;
86
+ try {
87
+ // A .cmd/.bat launcher cannot be spawned directly on Windows; it needs a shell, and then the
88
+ // path must carry its own quotes because the shell re-parses the whole command line.
89
+ const viaShell = /\.(cmd|bat)$/i.test(binary);
90
+ const spawn = () => viaShell
91
+ ? execFileSync(`"${binary}"`, ["setup-token"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"], shell: true })
92
+ : execFileSync(binary, ["setup-token"], { encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] });
93
+ stdout = options.run ? options.run(binary) : spawn();
94
+ }
95
+ catch (error) {
96
+ const detail = error.stderr;
97
+ const text = typeof detail === "string" ? detail : Buffer.isBuffer(detail) ? detail.toString("utf8") : "";
98
+ if (/unknown command|unknown option|setup-token/i.test(text))
99
+ throw new Error("This Claude Code version does not support `claude setup-token`; update Claude Code and try again");
100
+ const reason = text.replace(/\s+/g, " ").trim().slice(0, 200);
101
+ if (!(options.interactive ?? process.stdin.isTTY))
102
+ throw new Error(`${needsTerminal()}${reason ? ` (claude setup-token said: ${reason})` : ""}`);
103
+ throw new Error(`Claude Code setup-token failed${reason ? `: ${reason}` : ""}`);
104
+ }
105
+ const token = parseSetupToken(stdout);
106
+ if (!token)
107
+ throw new Error("Claude Code did not return a recognizable setup token; no credential was saved");
108
+ saveClaudeAuthFile(home, token);
109
+ }
110
+ export function claudeLogout(home) {
111
+ return removeClaudeAuthFile(home);
112
+ }
@@ -0,0 +1,172 @@
1
+ // `clauderipple codex on|off`: installs only ClaudeRipple-owned TOML fragments.
2
+ // Codex 0.146 profiles are separate $CODEX_HOME/<name>.config.toml layers, so the default
3
+ // selection in config.toml is never changed. The provider definition is marked and removed exactly.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ const START = "# >>> ClaudeRipple Codex provider >>>";
8
+ const END = "# <<< ClaudeRipple Codex provider <<<";
9
+ const PROFILE_START = "# >>> ClaudeRipple Codex profile >>>";
10
+ const PROFILE_END = "# <<< ClaudeRipple Codex profile <<<";
11
+ const CATALOG_START = "# >>> ClaudeRipple Codex model catalog >>>";
12
+ const CATALOG_END = "# <<< ClaudeRipple Codex model catalog <<<";
13
+ export const CODEX_PROVIDER_MARKER = START;
14
+ export function codexHome() {
15
+ return process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
16
+ }
17
+ function configFile(home = codexHome()) {
18
+ return path.join(home, "config.toml");
19
+ }
20
+ function profileFile(home = codexHome()) {
21
+ return path.join(home, "clauderipple.config.toml");
22
+ }
23
+ function backup(file) {
24
+ if (!fs.existsSync(file))
25
+ return undefined;
26
+ const destination = `${file}.clauderipple-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
27
+ fs.copyFileSync(file, destination);
28
+ return destination;
29
+ }
30
+ function withoutOwnedBlock(content, start, end) {
31
+ const startAt = content.indexOf(start);
32
+ if (startAt < 0)
33
+ return content;
34
+ const endAt = content.indexOf(end, startAt);
35
+ if (endAt < 0)
36
+ throw new Error(`refusing to edit ${start}: end marker is missing`);
37
+ const after = endAt + end.length;
38
+ const trailing = content.slice(after).replace(/^\r?\n/, "");
39
+ return (content.slice(0, startAt).replace(/\s*$/, "") + (content.slice(0, startAt).trim() && trailing.trim() ? "\n\n" : "") + trailing).replace(/^\s+/, "");
40
+ }
41
+ function appendBlock(content, block) {
42
+ const base = content.trimEnd();
43
+ return `${base}${base ? "\n\n" : ""}${block}\n`;
44
+ }
45
+ function providerBlock(port) {
46
+ return `${START}
47
+ [model_providers.clauderipple]
48
+ name = "ClaudeRipple local ingress"
49
+ base_url = "http://127.0.0.1:${port}/v1"
50
+ wire_api = "responses"
51
+ ${END}`;
52
+ }
53
+ function catalogFile(home = codexHome()) {
54
+ return path.join(home, "clauderipple-models.json");
55
+ }
56
+ /**
57
+ * Codex lists models from its catalog (`model_catalog_json` overrides the bundled one; measured with
58
+ * 0.146: the file is `{ models: [...] }` in the shape of Codex's own `models_cache.json`, and each
59
+ * entry additionally needs `base_instructions` and `supports_parallel_tool_calls`; when an entry
60
+ * carries `model_messages.instructions_template` Codex renders that as the system prompt and ignores
61
+ * `base_instructions`). We copy the cached OpenAI entries as they are — so the app keeps its GPT
62
+ * list — and append ClaudeRipple's models built from the cached `gpt-5.5` entry (same tools,
63
+ * same instructions template). Without a cache (Codex never signed in) there is nothing to copy
64
+ * from and the catalog is skipped: Codex then works but shows the model as "custom".
65
+ */
66
+ export function writeCodexCatalog(models, home = codexHome()) {
67
+ const cache = path.join(home, "models_cache.json");
68
+ if (!fs.existsSync(cache))
69
+ return undefined;
70
+ let cached;
71
+ try {
72
+ cached = JSON.parse(fs.readFileSync(cache, "utf8"));
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ const entries = (cached.models ?? []).filter((m) => typeof m.slug === "string");
78
+ const template = entries.find((m) => m.slug === "gpt-5.5" && m.model_messages) ?? entries.find((m) => m.model_messages);
79
+ if (!template)
80
+ return undefined;
81
+ const ours = models.map((m, i) => ({
82
+ ...template,
83
+ slug: m.id,
84
+ display_name: m.name ?? m.id,
85
+ description: `via ClaudeRipple (${m.provider})`,
86
+ default_reasoning_level: m.effortLevels.includes("medium") ? "medium" : (m.effortLevels[0] ?? "medium"),
87
+ supported_reasoning_levels: m.effortLevels.map((effort) => ({ effort, description: effort })),
88
+ visibility: "list",
89
+ supported_in_api: true,
90
+ priority: 100 + i,
91
+ additional_speed_tiers: [],
92
+ service_tiers: [],
93
+ availability_nux: null,
94
+ upgrade: null,
95
+ context_window: 200000,
96
+ max_context_window: 200000,
97
+ base_instructions: "",
98
+ supports_parallel_tool_calls: true,
99
+ }));
100
+ const theirs = entries.filter((m) => !models.some((o) => o.id === m.slug)).map((m) => ({ base_instructions: "", supports_parallel_tool_calls: true, ...m }));
101
+ const file = catalogFile(home);
102
+ fs.writeFileSync(file, JSON.stringify({ models: [...theirs, ...ours] }, null, 1), { mode: 0o600 });
103
+ return file;
104
+ }
105
+ function catalogBlock(file) {
106
+ return `${CATALOG_START}
107
+ model_catalog_json = ${JSON.stringify(file)}
108
+ ${CATALOG_END}`;
109
+ }
110
+ /** Top-level keys must precede any `[table]`, so the catalog block goes first. Never overrides a user's own setting. */
111
+ function withCatalog(content, file) {
112
+ const stripped = withoutOwnedBlock(content, CATALOG_START, CATALOG_END);
113
+ if (!file || /^\s*model_catalog_json\s*=/m.test(stripped))
114
+ return stripped;
115
+ return `${catalogBlock(file)}\n\n${stripped}`.replace(/\n+$/, "\n");
116
+ }
117
+ function profileBlock() {
118
+ return `${PROFILE_START}
119
+ model_provider = "clauderipple"
120
+ ${PROFILE_END}`;
121
+ }
122
+ /** Install provider in config.toml plus selection in the dedicated `clauderipple` profile. */
123
+ export function codexOn(port, home = codexHome(), models = []) {
124
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
125
+ const config = configFile(home);
126
+ const profile = profileFile(home);
127
+ const beforeConfig = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
128
+ const beforeProfile = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
129
+ const catalog = models.length ? writeCodexCatalog(models, home) : undefined;
130
+ const nextConfig = withCatalog(appendBlock(withoutOwnedBlock(beforeConfig, START, END), providerBlock(port)), catalog);
131
+ const nextProfile = appendBlock(withoutOwnedBlock(beforeProfile, PROFILE_START, PROFILE_END), profileBlock());
132
+ if (nextConfig === beforeConfig && nextProfile === beforeProfile)
133
+ return { changed: false, config, profile };
134
+ const configBackup = backup(config);
135
+ const profileBackup = backup(profile);
136
+ fs.writeFileSync(config, nextConfig, { mode: 0o600 });
137
+ fs.writeFileSync(profile, nextProfile, { mode: 0o600 });
138
+ return { changed: true, ...(configBackup ? { backup: configBackup } : {}), ...(profileBackup ? { profileBackup } : {}), config, profile };
139
+ }
140
+ /** Remove only blocks with our exact sentinels. Keeps all user config and profile values. */
141
+ export function codexOff(home = codexHome()) {
142
+ const config = configFile(home);
143
+ const profile = profileFile(home);
144
+ const beforeConfig = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
145
+ const beforeProfile = fs.existsSync(profile) ? fs.readFileSync(profile, "utf8") : "";
146
+ const nextConfig = withoutOwnedBlock(withoutOwnedBlock(beforeConfig, START, END), CATALOG_START, CATALOG_END);
147
+ const nextProfile = withoutOwnedBlock(beforeProfile, PROFILE_START, PROFILE_END);
148
+ fs.rmSync(catalogFile(home), { force: true });
149
+ if (nextConfig === beforeConfig && nextProfile === beforeProfile)
150
+ return { changed: false, config, profile };
151
+ const configBackup = backup(config);
152
+ const profileBackup = backup(profile);
153
+ if (nextConfig)
154
+ fs.writeFileSync(config, nextConfig.endsWith("\n") ? nextConfig : `${nextConfig}\n`, { mode: 0o600 });
155
+ else
156
+ fs.rmSync(config, { force: true });
157
+ if (nextProfile)
158
+ fs.writeFileSync(profile, nextProfile.endsWith("\n") ? nextProfile : `${nextProfile}\n`, { mode: 0o600 });
159
+ else
160
+ fs.rmSync(profile, { force: true });
161
+ return { changed: true, ...(configBackup ? { backup: configBackup } : {}), ...(profileBackup ? { profileBackup } : {}), config, profile };
162
+ }
163
+ /** True when `codex on` is in effect for this Codex home. */
164
+ export function codexEnabled(home = codexHome()) {
165
+ try {
166
+ const text = fs.readFileSync(configFile(home), "utf8");
167
+ return text.includes(START) && text.includes(END);
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // Dev helper until `clauderipple install` exists: generate CA + leaf into CLAUDERIPPLE_HOME.
3
+ import { generateCerts } from "./certs.js";
4
+ import { homeDir } from "../../router/src/config.js";
5
+ const home = homeDir();
6
+ const p = generateCerts(home);
7
+ console.log(`certs written to ${home}: ${Object.values(p).map((f) => f.split("/").pop()).join(", ")}`);
@@ -0,0 +1,160 @@
1
+ // Claude Code PreToolUse hook (matcher: Agent|Task): put the real model and thinking depth into the
2
+ // subagent's title, e.g. "Terra·high · Provider presets", so the Claude Desktop background-task
3
+ // panel shows which model a subagent runs on. The app's own second line is a fixed "Agent" label
4
+ // (verified in the app renderer), so the description is the only text we can influence.
5
+ //
6
+ // Model resolution mirrors the router (packages/router/src/routing.ts): a `[[gpt: sol@xhigh]]`
7
+ // marker in the prompt's first 5 lines, else the agent definition's frontmatter `model:`, else the
8
+ // explicit `model` argument, else the parent session's model from the transcript tail.
9
+ // Depth = the hook input's effort.level. Runs in <50ms, never fails the tool call (empty output = no-op).
10
+ //
11
+ // Installed by `clauderipple agent-title on` into ~/.claude/settings.json; the router ships no
12
+ // Python, so this is TypeScript run by the Node recorded in <home>/paths.json.
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ const MARKER = /\[\[\s*gpt\s*:\s*([a-z0-9.\-]+)\s*(?:@\s*([a-z]+))?\s*\]\]/i;
18
+ const OUR_PREFIX = /^[^\s·]+(?:·[a-z]+)? · /;
19
+ const CLAUDE_ID = /^claude-(opus|sonnet|haiku|fable)-(\d+(?:-\d+)*?)(?:-\d{8})?$/;
20
+ /** Display names: config's picker names (e.g. "GPT-5.6 Terra" → "Terra"), then a built-in table. */
21
+ function prettyNames() {
22
+ const out = { "gpt-5.6-terra": "Terra", "gpt-5.6-sol": "Sol", "gpt-5.6-luna": "Luna", "gpt-6-astra": "Astra" };
23
+ const home = process.env.CLAUDERIPPLE_HOME ?? path.join(os.homedir(), ".clauderipple");
24
+ try {
25
+ const cfg = JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8"));
26
+ const add = (id, name) => {
27
+ if (!id || !name)
28
+ return;
29
+ const words = name.trim().split(/\s+/);
30
+ out[id.toLowerCase()] = words.length > 1 ? words[words.length - 1] : name.trim();
31
+ };
32
+ for (const m of cfg.cli?.extraModels ?? [])
33
+ add(m.model, m.name);
34
+ for (const p of Object.values(cfg.providers ?? {}))
35
+ for (const m of p.models ?? [])
36
+ add(m.id, m.name);
37
+ }
38
+ catch {
39
+ /* no config: built-ins only */
40
+ }
41
+ return out;
42
+ }
43
+ export function fromMarker(prompt) {
44
+ const head = prompt.split("\n").slice(0, 5).join("\n");
45
+ const m = MARKER.exec(head);
46
+ return m ? { model: m[1].toLowerCase(), effort: (m[2] ?? "").toLowerCase() } : null;
47
+ }
48
+ export function fromAgentFile(subagentType, dirs) {
49
+ if (!subagentType || subagentType.includes("/") || subagentType.startsWith("."))
50
+ return null;
51
+ for (const d of dirs) {
52
+ let head;
53
+ try {
54
+ head = fs.readFileSync(path.join(d, `${subagentType}.md`), "utf8").slice(0, 8192);
55
+ }
56
+ catch {
57
+ continue;
58
+ }
59
+ if (head.startsWith("---")) {
60
+ const end = head.indexOf("\n---", 3);
61
+ if (end !== -1)
62
+ head = head.slice(0, end);
63
+ }
64
+ const m = /^model:\s*['"]?([^'"\s]+)/m.exec(head);
65
+ if (m) {
66
+ const [model, effort = ""] = m[1].toLowerCase().split("@");
67
+ return { model: model, effort };
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+ export function claudePretty(modelId) {
73
+ const v = (modelId ?? "").trim().toLowerCase();
74
+ if (["opus", "sonnet", "haiku", "fable"].includes(v))
75
+ return v[0].toUpperCase() + v.slice(1);
76
+ const m = CLAUDE_ID.exec(v);
77
+ return m ? m[1][0].toUpperCase() + m[1].slice(1) + m[2].replace(/-/g, ".") : null;
78
+ }
79
+ function parentModel(transcriptPath) {
80
+ if (!transcriptPath)
81
+ return null;
82
+ let chunk;
83
+ try {
84
+ const fd = fs.openSync(transcriptPath, "r");
85
+ const size = fs.fstatSync(fd).size;
86
+ const start = Math.max(0, size - 262144);
87
+ const buf = Buffer.alloc(size - start);
88
+ fs.readSync(fd, buf, 0, buf.length, start);
89
+ fs.closeSync(fd);
90
+ chunk = buf.toString("utf8");
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ for (const line of chunk.split("\n").reverse()) {
96
+ if (!line.includes('"assistant"') || line.includes('"isSidechain":true'))
97
+ continue;
98
+ try {
99
+ const d = JSON.parse(line);
100
+ if (d.message?.model)
101
+ return d.message.model;
102
+ }
103
+ catch {
104
+ /* the first line of a tail read can be cut */
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ export function retitle(input, agentDirs, names) {
110
+ if (input.tool_name !== "Agent" && input.tool_name !== "Task")
111
+ return null;
112
+ const inp = input.tool_input ?? {};
113
+ const desc = inp.description;
114
+ if (typeof desc !== "string" || desc.trim() === "")
115
+ return null;
116
+ const effort = (typeof input.effort === "object" && input.effort ? input.effort.level : typeof input.effort === "string" ? input.effort : "") ?? "";
117
+ const got = fromMarker(typeof inp.prompt === "string" ? inp.prompt : "") ?? fromAgentFile(typeof inp.subagent_type === "string" ? inp.subagent_type : "", agentDirs);
118
+ let tag = null;
119
+ if (got) {
120
+ // Marker short forms ("sol") resolve against full ids ("gpt-5.6-sol"), as the router's aliases do.
121
+ const key = names[got.model] !== undefined ? got.model : Object.keys(names).find((k) => k.endsWith(`-${got.model}`));
122
+ const name = (key ? names[key] : undefined) ?? (got.model.startsWith("gpt") || got.model.includes("/") ? got.model : null);
123
+ if (name)
124
+ tag = got.effort ? `${name}·${got.effort}` : name;
125
+ }
126
+ if (tag === null) {
127
+ const name = claudePretty(typeof inp.model === "string" ? inp.model : undefined) ?? (got ? claudePretty(got.model) : null) ?? claudePretty(parentModel(input.transcript_path) ?? undefined);
128
+ if (!name)
129
+ return null;
130
+ tag = effort ? `${name}·${effort}` : name;
131
+ }
132
+ const prefix = `${tag} · `;
133
+ if (desc.startsWith(prefix))
134
+ return null;
135
+ let bare = desc.replace(OUR_PREFIX, "");
136
+ if (bare.trim() === "")
137
+ bare = desc;
138
+ return { ...inp, description: prefix + bare };
139
+ }
140
+ function defaultAgentDirs() {
141
+ const dirs = [path.join(os.homedir(), ".claude", "agents")];
142
+ for (const base of [process.env.CLAUDE_PROJECT_DIR, process.cwd()])
143
+ if (base)
144
+ dirs.push(path.join(base, ".claude", "agents"));
145
+ return dirs;
146
+ }
147
+ // fileURLToPath, not URL.pathname: the latter keeps percent-encoding (a space in the path becomes
148
+ // %20, so the comparison fails and the hook silently does nothing) and on Windows yields "/C:/…".
149
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
150
+ try {
151
+ const raw = fs.readFileSync(0, "utf8");
152
+ const data = JSON.parse(raw || "{}");
153
+ const updated = retitle(data, defaultAgentDirs(), prettyNames());
154
+ if (updated)
155
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", updatedInput: updated } }) + "\n");
156
+ }
157
+ catch {
158
+ /* a hook must never break the tool call */
159
+ }
160
+ }