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/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").replaceAll('"', '\\"');
111
- await writeFile(pennyShimPath(), `#!/bin/sh\nexec node "${entry}" "$@"\n`, {
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
- return String(process.env.PATH || "").split(":").some((candidate) => resolve(candidate) === resolve(dir));
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 shellRcPath() {
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 null;
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 rcPath = shellRcPath();
133
- if (!rcPath) return { available: false, changed: false, rc_path: null };
134
- const current = await readText(rcPath, "");
135
- if (!current.includes(PATH_START)) {
136
- const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
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 { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
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.rc_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
- await removeManagedPathBlock(config.path_rc);
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
- throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
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 (!(["claude", "codex"].includes(harness))) {
373
- throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
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
  }
@@ -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.ANTHROPIC_DEFAULT_OPUS_MODEL, "Opus 5 (PennyRouter)");
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,