pennyrouter 0.3.0 → 0.3.2
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/README.md +14 -9
- package/package.json +2 -2
- package/src/cli.js +626 -133
- package/src/cli.test.js +196 -0
- package/src/harnesses/claude-code.js +81 -32
- package/src/harnesses/opencode.js +82 -8
- package/src/harnesses/opencode.test.js +75 -0
- package/src/launch.js +260 -28
- package/src/launch.test.js +47 -1
- package/src/mcp-register.js +212 -0
- package/src/mcp-register.test.js +200 -0
- package/src/mcp.js +375 -0
- package/src/mcp.test.js +259 -0
- package/src/session.js +9 -5
- package/src/statusline.js +73 -5
- package/src/thread-session-codex.js +218 -0
- package/src/thread-session-codex.test.js +151 -0
- package/src/thread-session.js +215 -0
- package/src/thread-session.test.js +151 -0
package/src/launch.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import {
|
|
3
3
|
chmod,
|
|
4
4
|
cp,
|
|
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
14
|
|
|
15
15
|
import { configureClaudeCodeStatusline } from "./statusline.js";
|
|
16
|
+
import { upgradeClaudeCodeModelValue, writeClaudeCodeModelPresentation } from "./harnesses/claude-code.js";
|
|
16
17
|
import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
|
|
17
18
|
|
|
18
19
|
const CONFIG_VERSION = 1;
|
|
@@ -20,8 +21,13 @@ const PATH_START = "# >>> pennyrouter PATH >>>";
|
|
|
20
21
|
const PATH_END = "# <<< pennyrouter PATH <<<";
|
|
21
22
|
const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
|
|
22
23
|
const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
|
|
24
|
+
const TERMINAL_TITLE_REFRESH_MS = 1000;
|
|
25
|
+
const TERMINAL_STATUS_REFRESH_MS = 15000;
|
|
23
26
|
|
|
24
27
|
export function pennyConfigDir() {
|
|
28
|
+
if (process.platform === "win32") {
|
|
29
|
+
return join(process.env.APPDATA || process.env.LOCALAPPDATA || homeDir(), "PennyRouter");
|
|
30
|
+
}
|
|
25
31
|
return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
|
|
26
32
|
}
|
|
27
33
|
|
|
@@ -30,6 +36,9 @@ export function pennyConfigPath() {
|
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export function pennyDataDir() {
|
|
39
|
+
if (process.platform === "win32") {
|
|
40
|
+
return join(process.env.LOCALAPPDATA || process.env.APPDATA || homeDir(), "PennyRouter");
|
|
41
|
+
}
|
|
33
42
|
return join(process.env.XDG_DATA_HOME || join(homeDir(), ".local", "share"), "pennyrouter");
|
|
34
43
|
}
|
|
35
44
|
|
|
@@ -38,11 +47,12 @@ export function pennyRuntimeDir() {
|
|
|
38
47
|
}
|
|
39
48
|
|
|
40
49
|
export function pennyBinDir() {
|
|
50
|
+
if (process.platform === "win32") return join(pennyDataDir(), "bin");
|
|
41
51
|
return join(homeDir(), ".local", "bin");
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
export function pennyShimPath() {
|
|
45
|
-
return join(pennyBinDir(), "penny");
|
|
55
|
+
return join(pennyBinDir(), process.platform === "win32" ? "penny.cmd" : "penny");
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
export function claudeLaunchSettingsPath() {
|
|
@@ -107,49 +117,131 @@ async function copyRuntime() {
|
|
|
107
117
|
|
|
108
118
|
async function writeShim() {
|
|
109
119
|
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
110
|
-
const entry = join(pennyRuntimeDir(), "bin", "penny.js")
|
|
111
|
-
|
|
120
|
+
const entry = join(pennyRuntimeDir(), "bin", "penny.js");
|
|
121
|
+
const body = process.platform === "win32"
|
|
122
|
+
// `node` and the native harnesses are normally npm .cmd shims on Windows. cmd.exe
|
|
123
|
+
// resolves those through PATHEXT, while Node's direct spawn does not.
|
|
124
|
+
? `@echo off\r\nnode "${entry.replaceAll('"', '""')}" %*\r\n`
|
|
125
|
+
: `#!/bin/sh\nexec node "${entry.replaceAll('"', '\\"')}" "$@"\n`;
|
|
126
|
+
await writeFile(pennyShimPath(), body, {
|
|
112
127
|
encoding: "utf8",
|
|
113
128
|
mode: 0o755,
|
|
114
129
|
});
|
|
115
|
-
await chmod(pennyShimPath(), 0o755);
|
|
130
|
+
if (process.platform !== "win32") await chmod(pennyShimPath(), 0o755);
|
|
116
131
|
}
|
|
117
132
|
|
|
118
133
|
function pathContains(dir) {
|
|
119
|
-
|
|
134
|
+
const delimiter = process.platform === "win32" ? ";" : ":";
|
|
135
|
+
const normalized = process.platform === "win32" ? String(dir).toLowerCase() : resolve(dir);
|
|
136
|
+
return String(process.env.PATH || "").split(delimiter).some((candidate) => {
|
|
137
|
+
const value = candidate.trim().replace(/^"|"$/g, "");
|
|
138
|
+
return process.platform === "win32"
|
|
139
|
+
? value.toLowerCase() === normalized
|
|
140
|
+
: resolve(value) === normalized;
|
|
141
|
+
});
|
|
120
142
|
}
|
|
121
143
|
|
|
122
|
-
function
|
|
144
|
+
function readWindowsUserPath() {
|
|
145
|
+
const result = spawnSync("reg", ["query", "HKCU\\Environment", "/v", "Path"], {
|
|
146
|
+
encoding: "utf8", windowsHide: true,
|
|
147
|
+
});
|
|
148
|
+
if (result.status !== 0) return "";
|
|
149
|
+
const line = String(result.stdout || "").split(/\r?\n/).find((item) => /\bPath\s+REG_/i.test(item));
|
|
150
|
+
return line ? line.replace(/^\s*Path\s+REG_\S+\s*/i, "").trim() : "";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function writeWindowsUserPath(value) {
|
|
154
|
+
const result = spawnSync("reg", [
|
|
155
|
+
"add", "HKCU\\Environment", "/v", "Path", "/t", "REG_EXPAND_SZ", "/d", value, "/f",
|
|
156
|
+
], { stdio: "ignore", windowsHide: true });
|
|
157
|
+
if (result.status !== 0) throw new Error("Could not add PennyRouter to the Windows user PATH.");
|
|
158
|
+
broadcastEnvironmentChange();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// user32.dll,UpdatePerUserSystemParameters refreshes desktop/profile parameters (wallpaper,
|
|
162
|
+
// etc.) — it is NOT the env var broadcast and does nothing for PATH. The actual mechanism
|
|
163
|
+
// (what setx.exe and the System Properties dialog use) is a WM_SETTINGCHANGE broadcast with
|
|
164
|
+
// lParam "Environment", which only Win32 SendMessageTimeout can send. Shell out to a tiny
|
|
165
|
+
// inline PowerShell snippet to make that call. New processes started by an already-running
|
|
166
|
+
// shell host (Explorer, Windows Terminal) still won't see the change until that host itself
|
|
167
|
+
// receives and acts on the broadcast, but this is what makes a freshly-opened terminal work.
|
|
168
|
+
function broadcastEnvironmentChange() {
|
|
169
|
+
const script = [
|
|
170
|
+
"$sig = '[DllImport(\"user32.dll\", SetLastError = true, CharSet = CharSet.Auto)]",
|
|
171
|
+
"public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam,",
|
|
172
|
+
"string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'",
|
|
173
|
+
"$type = Add-Type -MemberDefinition $sig -Name NativeMethods -Namespace Win32 -PassThru",
|
|
174
|
+
"$result = [UIntPtr]::Zero",
|
|
175
|
+
"$HWND_BROADCAST = [IntPtr]0xffff",
|
|
176
|
+
"$WM_SETTINGCHANGE = 0x1a",
|
|
177
|
+
"$SMTO_ABORTIFHUNG = 0x2",
|
|
178
|
+
"$type::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', $SMTO_ABORTIFHUNG, 5000, [ref]$result) | Out-Null",
|
|
179
|
+
].join("\n");
|
|
180
|
+
spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
181
|
+
stdio: "ignore", windowsHide: true,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function windowsPathContains(pathValue, dir) {
|
|
186
|
+
const wanted = String(dir).toLowerCase();
|
|
187
|
+
return String(pathValue || "").split(";").some((item) => item.trim().replace(/^"|"$/g, "").toLowerCase() === wanted);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function ensurePennyOnWindowsPath({ updatePath }) {
|
|
191
|
+
if (pathContains(pennyBinDir())) return { available: true, changed: false, path_managed: false };
|
|
192
|
+
const current = readWindowsUserPath();
|
|
193
|
+
if (windowsPathContains(current, pennyBinDir())) return { available: true, changed: false, path_managed: false };
|
|
194
|
+
if (!updatePath) return { available: false, changed: false, path_managed: false };
|
|
195
|
+
const next = current ? `${current};${pennyBinDir()}` : pennyBinDir();
|
|
196
|
+
writeWindowsUserPath(next);
|
|
197
|
+
return { available: true, changed: true, path_managed: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function shellRcPaths() {
|
|
123
201
|
const shell = basename(process.env.SHELL || "");
|
|
124
|
-
if (shell === "zsh") return join(homeDir(), ".zshrc");
|
|
125
|
-
if (shell === "bash") return join(homeDir(), ".bashrc");
|
|
126
|
-
return
|
|
202
|
+
if (shell === "zsh") return [join(homeDir(), ".zshrc"), join(homeDir(), ".zprofile")];
|
|
203
|
+
if (shell === "bash") return [join(homeDir(), ".bashrc"), join(homeDir(), ".bash_profile")];
|
|
204
|
+
return [];
|
|
127
205
|
}
|
|
128
206
|
|
|
129
207
|
export async function ensurePennyOnPath({ updatePath = true } = {}) {
|
|
208
|
+
if (process.platform === "win32") return ensurePennyOnWindowsPath({ updatePath });
|
|
130
209
|
if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
|
|
131
210
|
if (!updatePath) return { available: false, changed: false, rc_path: null };
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
211
|
+
const rcPaths = shellRcPaths();
|
|
212
|
+
if (rcPaths.length === 0) return { available: false, changed: false, rc_path: null, rc_paths: [] };
|
|
213
|
+
const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
|
|
214
|
+
const changedPaths = [];
|
|
215
|
+
for (const rcPath of rcPaths) {
|
|
216
|
+
const current = await readText(rcPath, "");
|
|
217
|
+
if (current.includes(PATH_START)) continue;
|
|
137
218
|
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
138
219
|
await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
|
|
220
|
+
changedPaths.push(rcPath);
|
|
139
221
|
}
|
|
140
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
available: true,
|
|
224
|
+
changed: changedPaths.length > 0,
|
|
225
|
+
rc_path: rcPaths[0],
|
|
226
|
+
rc_paths: rcPaths,
|
|
227
|
+
};
|
|
141
228
|
}
|
|
142
229
|
|
|
143
230
|
export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
144
|
-
if (!(["darwin", "linux"].includes(process.platform))) {
|
|
231
|
+
if (!(["darwin", "linux", "win32"].includes(process.platform))) {
|
|
145
232
|
return { supported: false, available: false, changed: false, rc_path: null };
|
|
146
233
|
}
|
|
147
234
|
await copyRuntime();
|
|
148
235
|
await writeShim();
|
|
149
236
|
const path = await ensurePennyOnPath({ updatePath });
|
|
150
237
|
const config = await loadPennyConfig();
|
|
151
|
-
if (path.
|
|
238
|
+
if (path.rc_paths?.length > 0) {
|
|
152
239
|
config.path_rc = path.rc_path;
|
|
240
|
+
config.path_rcs = path.rc_paths;
|
|
241
|
+
await savePennyConfig(config);
|
|
242
|
+
}
|
|
243
|
+
if (process.platform === "win32" && path.path_managed) {
|
|
244
|
+
config.windows_path_managed = true;
|
|
153
245
|
await savePennyConfig(config);
|
|
154
246
|
}
|
|
155
247
|
return { supported: true, ...path, shim_path: pennyShimPath() };
|
|
@@ -173,9 +265,93 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
|
173
265
|
"supports_websockets = false",
|
|
174
266
|
...authLines,
|
|
175
267
|
"",
|
|
268
|
+
"[tui]",
|
|
269
|
+
// Penny owns the live terminal title while this profile is running. An empty
|
|
270
|
+
// list disables Codex's default activity/project title so the two writers do
|
|
271
|
+
// not race each other.
|
|
272
|
+
"terminal_title = []",
|
|
273
|
+
"",
|
|
176
274
|
].join("\n");
|
|
177
275
|
}
|
|
178
276
|
|
|
277
|
+
function terminalTitle(text) {
|
|
278
|
+
// OSC titles are interpreted by the terminal, so remove control characters and
|
|
279
|
+
// the OSC terminators rather than allowing gateway-provided text into the escape.
|
|
280
|
+
const safe = String(text).replace(/[\u0000-\u001f\u007f\u001b]/g, " ").slice(0, 240);
|
|
281
|
+
return `\u001b]0;${safe}\u0007`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeTerminalTitle(title) {
|
|
285
|
+
if (process.stdout.isTTY) process.stdout.write(terminalTitle(title));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function renderPennyTerminalTitle(status) {
|
|
289
|
+
const parts = ["PennyRouter"];
|
|
290
|
+
if (status?.model_display_name) parts.push(status.model_display_name);
|
|
291
|
+
else if (status?.model) parts.push(status.model);
|
|
292
|
+
if (status?.auth_mode) parts.push(`(${status.auth_mode})`);
|
|
293
|
+
parts.push(renderCacheWarmth(status));
|
|
294
|
+
return parts.join(" · ");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function renderCacheWarmth(status) {
|
|
298
|
+
const expiresAt = status?.cache_expires_at;
|
|
299
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
|
300
|
+
return "❄️ cold";
|
|
301
|
+
}
|
|
302
|
+
const remaining = Math.floor(expiresAt - Date.now() / 1000);
|
|
303
|
+
if (remaining <= 0) return "❄️ cold";
|
|
304
|
+
const minutes = Math.floor(remaining / 60);
|
|
305
|
+
const seconds = remaining % 60;
|
|
306
|
+
return `🔥 ${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Keep the terminal/tab title useful while `penny codex` owns the foreground TTY.
|
|
311
|
+
* This is deliberately local and best-effort: failure to poll the gateway must never
|
|
312
|
+
* affect Codex, and non-TTY invocations (CI, pipes) receive no escape sequences.
|
|
313
|
+
*/
|
|
314
|
+
export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey }) {
|
|
315
|
+
if (!process.stdout.isTTY) return () => {};
|
|
316
|
+
|
|
317
|
+
let status = null;
|
|
318
|
+
let stopped = false;
|
|
319
|
+
let statusInFlight = false;
|
|
320
|
+
|
|
321
|
+
const refreshStatus = async () => {
|
|
322
|
+
if (stopped || statusInFlight || !gatewayBaseUrl || !apiKey) return;
|
|
323
|
+
statusInFlight = true;
|
|
324
|
+
try {
|
|
325
|
+
const response = await fetch(`${String(gatewayBaseUrl).replace(/\/$/, "")}/v1/penny/statusline`, {
|
|
326
|
+
headers: { "x-api-key": apiKey },
|
|
327
|
+
signal: AbortSignal.timeout(4000),
|
|
328
|
+
});
|
|
329
|
+
if (response.ok) status = await response.json();
|
|
330
|
+
} catch {
|
|
331
|
+
// The title remains useful with its local timer when the gateway is unavailable.
|
|
332
|
+
} finally {
|
|
333
|
+
statusInFlight = false;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const tick = () => writeTerminalTitle(renderPennyTerminalTitle(status));
|
|
338
|
+
const timer = setInterval(tick, TERMINAL_TITLE_REFRESH_MS);
|
|
339
|
+
const statusTimer = setInterval(refreshStatus, TERMINAL_STATUS_REFRESH_MS);
|
|
340
|
+
timer.unref?.();
|
|
341
|
+
statusTimer.unref?.();
|
|
342
|
+
void refreshStatus();
|
|
343
|
+
tick();
|
|
344
|
+
|
|
345
|
+
return () => {
|
|
346
|
+
if (stopped) return;
|
|
347
|
+
stopped = true;
|
|
348
|
+
clearInterval(timer);
|
|
349
|
+
clearInterval(statusTimer);
|
|
350
|
+
// Let the shell or terminal choose its normal title again after Codex exits.
|
|
351
|
+
writeTerminalTitle("");
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
179
355
|
async function writeClaudeLaunchSettings() {
|
|
180
356
|
const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
|
|
181
357
|
const overlay = { disableClaudeAiConnectors: true };
|
|
@@ -249,6 +425,11 @@ export async function configurePennyHarness(harness, {
|
|
|
249
425
|
enabled: true,
|
|
250
426
|
subscription_auth: Boolean(subscriptionAuth),
|
|
251
427
|
};
|
|
428
|
+
} else if (harness === "opencode") {
|
|
429
|
+
// OpenCode owns its provider and statusline configuration. The launcher only
|
|
430
|
+
// records that the Penny command is enabled; its existing OpenCode config is
|
|
431
|
+
// left untouched.
|
|
432
|
+
config.harnesses.opencode = { enabled: true };
|
|
252
433
|
} else {
|
|
253
434
|
throw new Error(`The penny command does not support ${harness}.`);
|
|
254
435
|
}
|
|
@@ -307,9 +488,23 @@ async function removeManagedPathBlock(rcPath) {
|
|
|
307
488
|
await atomicWrite(rcPath, next);
|
|
308
489
|
}
|
|
309
490
|
|
|
491
|
+
function removeWindowsPathEntry() {
|
|
492
|
+
const current = readWindowsUserPath();
|
|
493
|
+
if (!current) return;
|
|
494
|
+
const next = current.split(";")
|
|
495
|
+
.filter((item) => item.trim().replace(/^"|"$/g, "").toLowerCase() !== pennyBinDir().toLowerCase())
|
|
496
|
+
.join(";");
|
|
497
|
+
if (next !== current) writeWindowsUserPath(next);
|
|
498
|
+
}
|
|
499
|
+
|
|
310
500
|
export async function removePennyRuntime() {
|
|
311
501
|
const config = await loadPennyConfig();
|
|
312
|
-
|
|
502
|
+
if (process.platform === "win32") {
|
|
503
|
+
if (config.windows_path_managed) removeWindowsPathEntry();
|
|
504
|
+
} else {
|
|
505
|
+
const paths = config.path_rcs || [config.path_rc];
|
|
506
|
+
for (const rcPath of paths) await removeManagedPathBlock(rcPath);
|
|
507
|
+
}
|
|
313
508
|
await unlink(pennyShimPath()).catch((error) => {
|
|
314
509
|
if (error.code !== "ENOENT") throw error;
|
|
315
510
|
});
|
|
@@ -328,6 +523,22 @@ function assertNoConflictingArgs(harness, args) {
|
|
|
328
523
|
}
|
|
329
524
|
}
|
|
330
525
|
|
|
526
|
+
const CLAUDE_AUTH_STARTUP_MODES = new Set(["auto", "proxy", "sub", "api", "openrouter"]);
|
|
527
|
+
|
|
528
|
+
function rewriteClaudeAuthStartup(args) {
|
|
529
|
+
// `claude <prompt>` submits the positional argument as the first user turn. Convert the
|
|
530
|
+
// convenient `penny claude proxy` spelling into the managed in-band command before Claude
|
|
531
|
+
// starts, so Claude receives a real @penny command rather than the literal word "proxy".
|
|
532
|
+
if (args.length === 1 && CLAUDE_AUTH_STARTUP_MODES.has(args[0].toLowerCase())) {
|
|
533
|
+
return [`@penny auth ${args[0].toLowerCase()}`];
|
|
534
|
+
}
|
|
535
|
+
if (args.length === 2 && args[0].toLowerCase() === "auth"
|
|
536
|
+
&& CLAUDE_AUTH_STARTUP_MODES.has(args[1].toLowerCase())) {
|
|
537
|
+
return [`@penny auth ${args[1].toLowerCase()}`];
|
|
538
|
+
}
|
|
539
|
+
return args;
|
|
540
|
+
}
|
|
541
|
+
|
|
331
542
|
export function buildPennyInvocation(harness, args, config) {
|
|
332
543
|
const item = config.harnesses?.[harness];
|
|
333
544
|
if (!item || !item.enabled) {
|
|
@@ -339,21 +550,34 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
339
550
|
const models = item.models || {};
|
|
340
551
|
env.ANTHROPIC_BASE_URL = config.gateway_base_url;
|
|
341
552
|
env.ANTHROPIC_AUTH_TOKEN = config.api_key;
|
|
553
|
+
// Ask Claude Code to discover the gateway's model catalog at startup. The gateway
|
|
554
|
+
// advertises Penny-facing ids in /v1/models; this is separate from inference routing.
|
|
555
|
+
env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
|
|
342
556
|
delete env.ANTHROPIC_API_KEY;
|
|
343
|
-
if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
|
|
344
|
-
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
|
|
345
|
-
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
|
|
346
|
-
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
|
|
557
|
+
if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = upgradeClaudeCodeModelValue(models.haiku);
|
|
558
|
+
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
|
|
559
|
+
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
|
|
560
|
+
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
|
|
561
|
+
delete env.ANTHROPIC_CUSTOM_MODEL_OPTION;
|
|
562
|
+
delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
|
|
563
|
+
delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
|
|
564
|
+
writeClaudeCodeModelPresentation(env);
|
|
565
|
+
// Per-model limits must come from Claude Code's recognized native model ids. This
|
|
566
|
+
// global override would incorrectly give Haiku the 1M window.
|
|
567
|
+
delete env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
347
568
|
return {
|
|
348
569
|
command: "claude",
|
|
349
|
-
args: ["--settings", claudeLaunchSettingsPath(), ...args],
|
|
570
|
+
args: ["--settings", claudeLaunchSettingsPath(), ...rewriteClaudeAuthStartup(args)],
|
|
350
571
|
env,
|
|
351
572
|
};
|
|
352
573
|
}
|
|
353
574
|
if (harness === "codex") {
|
|
354
575
|
return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
|
|
355
576
|
}
|
|
356
|
-
|
|
577
|
+
if (harness === "opencode") {
|
|
578
|
+
return { command: "opencode", args, env };
|
|
579
|
+
}
|
|
580
|
+
throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
|
|
357
581
|
}
|
|
358
582
|
|
|
359
583
|
async function waitForChild(child) {
|
|
@@ -366,11 +590,11 @@ async function waitForChild(child) {
|
|
|
366
590
|
export async function runPenny(argv = process.argv.slice(2)) {
|
|
367
591
|
const harness = argv[0];
|
|
368
592
|
if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
|
|
369
|
-
console.log("Usage: penny <claude|codex> [native arguments...]");
|
|
593
|
+
console.log("Usage: penny <claude|codex|opencode> [native arguments...]");
|
|
370
594
|
return 0;
|
|
371
595
|
}
|
|
372
|
-
if (!
|
|
373
|
-
throw new Error(`Unknown harness "${harness}". Try penny claude or penny
|
|
596
|
+
if (!["claude", "codex", "opencode"].includes(harness)) {
|
|
597
|
+
throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
|
|
374
598
|
}
|
|
375
599
|
const config = await loadPennyConfig();
|
|
376
600
|
const invocation = buildPennyInvocation(harness, argv.slice(1), config);
|
|
@@ -378,8 +602,16 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
378
602
|
cwd: process.cwd(),
|
|
379
603
|
env: invocation.env,
|
|
380
604
|
stdio: "inherit",
|
|
605
|
+
shell: process.platform === "win32",
|
|
381
606
|
});
|
|
607
|
+
const stopTerminalTitle = ["codex", "opencode"].includes(harness)
|
|
608
|
+
? startPennyTerminalTitle({
|
|
609
|
+
gatewayBaseUrl: config.gateway_base_url,
|
|
610
|
+
apiKey: config.api_key,
|
|
611
|
+
})
|
|
612
|
+
: () => {};
|
|
382
613
|
const result = await waitForChild(child);
|
|
614
|
+
stopTerminalTitle();
|
|
383
615
|
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
384
616
|
return result.code ?? 1;
|
|
385
617
|
}
|
package/src/launch.test.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
removePennyRuntime,
|
|
20
20
|
renderCodexLaunchProfile,
|
|
21
21
|
} from "./launch.js";
|
|
22
|
+
import { claudeCodeHarness } from "./harnesses/claude-code.js";
|
|
22
23
|
|
|
23
24
|
const ENV_KEYS = [
|
|
24
25
|
"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "CODEX_HOME", "PATH", "SHELL",
|
|
@@ -73,6 +74,8 @@ async function main() {
|
|
|
73
74
|
apiKey: "pr-super-secret",
|
|
74
75
|
gatewayBaseUrl: "https://gateway.example/",
|
|
75
76
|
subscriptionAuth: true,
|
|
77
|
+
// Simulate an existing install. `penny claude` must fix this at launch,
|
|
78
|
+
// without requiring a separate reinstall before its context limits are right.
|
|
76
79
|
models: { haiku: "Haiku 4.5 (PennyRouter)", sonnet: "Sonnet 5 (PennyRouter)", opus: "Opus 5 (PennyRouter)" },
|
|
77
80
|
});
|
|
78
81
|
await configurePennyHarness("codex", {
|
|
@@ -80,6 +83,10 @@ async function main() {
|
|
|
80
83
|
gatewayBaseUrl: "https://gateway.example/",
|
|
81
84
|
subscriptionAuth: true,
|
|
82
85
|
});
|
|
86
|
+
await configurePennyHarness("opencode", {
|
|
87
|
+
apiKey: "pr-super-secret",
|
|
88
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
89
|
+
});
|
|
83
90
|
|
|
84
91
|
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
85
92
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
@@ -89,8 +96,10 @@ async function main() {
|
|
|
89
96
|
|
|
90
97
|
const config = await loadPennyConfig();
|
|
91
98
|
assert.equal(config.api_key, "pr-super-secret");
|
|
99
|
+
assert.equal(config.harnesses.opencode.enabled, true);
|
|
92
100
|
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
93
101
|
assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
|
|
102
|
+
assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
|
|
94
103
|
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
95
104
|
assert.ok(!rc.includes("pr-super-secret"));
|
|
96
105
|
|
|
@@ -100,11 +109,33 @@ async function main() {
|
|
|
100
109
|
assert.ok(!claude.args.includes("--plugin-dir"));
|
|
101
110
|
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
102
111
|
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
103
|
-
assert.equal(claude.env.
|
|
112
|
+
assert.equal(claude.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1");
|
|
113
|
+
// 1M-capable slots carry the `[1m]` suffix; Haiku has no 1M variant and stays bare.
|
|
114
|
+
// Per-slot suffixes, not the global CLAUDE_CODE_MAX_CONTEXT_TOKENS override, which
|
|
115
|
+
// would wrongly hand Haiku a 1M window too.
|
|
116
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5[1m]");
|
|
117
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
118
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5");
|
|
119
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
|
|
120
|
+
assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
|
|
121
|
+
|
|
122
|
+
const proxyStartup = buildPennyInvocation("claude", ["proxy"], config);
|
|
123
|
+
assert.deepEqual(proxyStartup.args.slice(-1), ["@penny auth proxy"]);
|
|
124
|
+
const apiStartup = buildPennyInvocation("claude", ["auth", "api"], config);
|
|
125
|
+
assert.deepEqual(apiStartup.args.slice(-1), ["@penny auth api"]);
|
|
126
|
+
const normalPrompt = buildPennyInvocation("claude", ["proxy", "please"], config);
|
|
127
|
+
assert.deepEqual(normalPrompt.args.slice(-2), ["proxy", "please"]);
|
|
104
128
|
|
|
105
129
|
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
106
130
|
assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
|
|
107
131
|
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
132
|
+
const opencode = buildPennyInvocation("opencode", ["--continue"], config);
|
|
133
|
+
assert.equal(opencode.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
134
|
+
assert.deepEqual(opencode, {
|
|
135
|
+
command: "opencode",
|
|
136
|
+
args: ["--continue"],
|
|
137
|
+
env: opencode.env,
|
|
138
|
+
});
|
|
108
139
|
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
109
140
|
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
110
141
|
|
|
@@ -112,6 +143,18 @@ async function main() {
|
|
|
112
143
|
assert.ok(command.includes("@penny $ARGUMENTS"));
|
|
113
144
|
assert.ok(command.includes("name: penny"));
|
|
114
145
|
|
|
146
|
+
// Persistent direct-Claude installs use the same native ids and must not
|
|
147
|
+
// leave the former global 1M override behind.
|
|
148
|
+
await claudeCodeHarness.install({
|
|
149
|
+
apiKey: "pr-super-secret",
|
|
150
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
151
|
+
plan: { configPath: path.join(home, ".claude", "settings.json") },
|
|
152
|
+
});
|
|
153
|
+
const persistedClaude = JSON.parse(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"));
|
|
154
|
+
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
155
|
+
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5[1m]");
|
|
156
|
+
assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
|
|
157
|
+
|
|
115
158
|
await removePennyHarness("claude-code");
|
|
116
159
|
assert.ok(!fs.existsSync(claudeUserSkillPath()));
|
|
117
160
|
fs.mkdirSync(path.dirname(claudeUserSkillPath()), { recursive: true });
|
|
@@ -124,12 +167,15 @@ async function main() {
|
|
|
124
167
|
assert.equal(fs.readFileSync(claudeUserSkillPath(), "utf8"), "user-owned command\n");
|
|
125
168
|
assert.ok(fs.existsSync(codexLaunchProfilePath()));
|
|
126
169
|
assert.ok(fs.existsSync(pennyShimPath()));
|
|
170
|
+
assert.ok(fs.readFileSync(path.join(home, ".zprofile"), "utf8").includes(">>> pennyrouter PATH >>>"));
|
|
127
171
|
await removePennyHarness("codex");
|
|
172
|
+
await removePennyHarness("opencode");
|
|
128
173
|
await removePennyRuntime();
|
|
129
174
|
assert.ok(!fs.existsSync(pennyShimPath()));
|
|
130
175
|
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
131
176
|
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
132
177
|
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
178
|
+
assert.equal(fs.readFileSync(path.join(home, ".zprofile"), "utf8"), "");
|
|
133
179
|
|
|
134
180
|
const paid = renderCodexLaunchProfile({
|
|
135
181
|
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|