pennyrouter 0.2.26 → 0.2.28

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 CHANGED
@@ -23,7 +23,7 @@ npx pennyrouter status
23
23
  Installation opens a browser and requires login to the PennyRouter account that should own
24
24
  the new machine key. CLI installs no longer create anonymous ghost accounts.
25
25
 
26
- With no `--harness` flag, the interactive installer shows Claude Code and Codex. Detected
26
+ With no `--harness` flag, the interactive installer shows Claude Code, Codex, and OpenCode. Detected
27
27
  tools start checked. The additional legacy integrations remain available through an explicit
28
28
  `--harness <id>` selection or `--all`.
29
29
 
@@ -41,10 +41,12 @@ directory to the user PATH:
41
41
  ```bash
42
42
  penny claude
43
43
  penny codex
44
+ penny opencode
44
45
  ```
45
46
 
46
- Arguments are passed through to the native harness, so commands such as `penny claude --resume`
47
- and `penny codex resume --last` work normally. By default, bare `claude` and `codex` remain
47
+ Arguments are passed through to the native harness, so commands such as `penny claude --resume`,
48
+ `penny codex resume --last`, and `penny opencode --continue` work normally. By default, bare
49
+ `claude`, `codex`, and `opencode` remain
48
50
  completely native; their normal configuration files are not redirected. Choose the installer's
49
51
  "make PennyRouter the default" option, or pass `--make-default`, to retain the original global
50
52
  routing behavior.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.26",
3
+ "version": "0.2.28",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "start": "node ./bin/pennyrouter.js",
27
- "check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
27
+ "check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
28
28
  },
29
29
  "license": "MIT"
30
30
  }
package/src/cli.js CHANGED
@@ -68,7 +68,7 @@ const HARNESS_BY_ID = {
68
68
  };
69
69
 
70
70
  const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
71
- const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
71
+ const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex", "opencode"];
72
72
  const CLAUDE_PROVIDER_OPTIONS = [
73
73
  { id: "subscription", name: "Claude subscription" },
74
74
  { id: "anthropic-api", name: "Anthropic API key" },
@@ -521,8 +521,9 @@ export async function install(flags = {}) {
521
521
  if (flags.dryRun) {
522
522
  const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
523
523
  for (const harness of selected) {
524
- const explicit = ["claude-code", "codex"].includes(harness.id);
525
- const command = harness.id === "claude-code" ? "penny claude" : "penny codex";
524
+ const explicit = ["claude-code", "codex", "opencode"].includes(harness.id);
525
+ const command = harness.id === "claude-code"
526
+ ? "penny claude" : harness.id === "codex" ? "penny codex" : "penny opencode";
526
527
  console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
527
528
  if (defaultRouting.get(harness.id)) {
528
529
  console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
@@ -615,7 +616,7 @@ export async function install(flags = {}) {
615
616
  // choose the visible command behavior with all relevant context in front of them.
616
617
  const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
617
618
 
618
- const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
619
+ const launchable = selected.filter((harness) => ["claude-code", "codex", "opencode"].includes(harness.id));
619
620
  let runtimeResult = null;
620
621
  if (launchable.length > 0) {
621
622
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
@@ -629,7 +630,7 @@ export async function install(flags = {}) {
629
630
  const existingRecord = (manifest.installations || []).find(
630
631
  (candidate) => candidate.harness === harness.id,
631
632
  );
632
- const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id);
633
+ const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(harness.id);
633
634
  const makeDefault = defaultRouting.get(harness.id) ?? true;
634
635
  const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
635
636
  if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
@@ -677,7 +678,9 @@ export async function install(flags = {}) {
677
678
  if (supportsPennyCommand) {
678
679
  record.launch_available = true;
679
680
  record.launch_config_path = compactHome(
680
- harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
681
+ harness.id === "claude-code"
682
+ ? claudeLaunchSettingsPath()
683
+ : harness.id === "codex" ? codexLaunchProfilePath() : null,
681
684
  );
682
685
  record.default_routing = Boolean(makeDefault);
683
686
  }
@@ -717,9 +720,12 @@ export async function install(flags = {}) {
717
720
  for (const record of records) {
718
721
  const harness = HARNESS_BY_ID[record.harness];
719
722
  if (record.launch_available) {
720
- const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
721
- const native = record.harness === "claude-code" ? "claude" : "codex";
722
- const harnessName = record.harness === "claude-code" ? "Claude Code" : "Codex";
723
+ const command = record.harness === "claude-code"
724
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
725
+ const native = record.harness === "claude-code" ? "claude"
726
+ : record.harness === "codex" ? "codex" : "opencode";
727
+ const harnessName = record.harness === "claude-code" ? "Claude Code"
728
+ : record.harness === "codex" ? "Codex" : "OpenCode";
723
729
  const commandWidth = Math.max(command.length, native.length) + 2;
724
730
  console.log("");
725
731
  console.log(color(`How to Run ${harnessName}`, "bold"));
@@ -928,7 +934,7 @@ async function update(flags) {
928
934
 
929
935
  const failures = [];
930
936
  let runtimeResult = null;
931
- if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))) {
937
+ if (targets.some((record) => ["claude-code", "codex", "opencode"].includes(record.harness))) {
932
938
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
933
939
  }
934
940
 
@@ -937,7 +943,7 @@ async function update(flags) {
937
943
  if (!harness) continue;
938
944
 
939
945
  await runHarnessJob(failures, record.harness, "update", async () => {
940
- const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness);
946
+ const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(record.harness);
941
947
  // An update rewrites the harness's own config only when that config is ours to
942
948
  // rewrite — i.e. the bare command still routes through the gateway. In penny-only
943
949
  // mode the harness config belongs to the user and we touch just the launch profile.
@@ -1604,7 +1610,8 @@ async function status() {
1604
1610
  console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
1605
1611
  }
1606
1612
  if (record.launch_available) {
1607
- const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1613
+ const command = record.harness === "claude-code"
1614
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
1608
1615
  console.log(` command: ${command}`);
1609
1616
  console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
1610
1617
  }
@@ -17,15 +17,18 @@ export const CLAUDE_CODE_MODELS = {
17
17
  sonnet: "claude-sonnet-5[1m]",
18
18
  opus: "claude-opus-5[1m]",
19
19
  fable: "claude-fable-5[1m]",
20
- custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
21
20
  };
22
21
  const CLAUDE_CODE_MODEL_PRESENTATION = {
23
- haiku: { name: "Haiku 4.5 (PennyRouter)", description: "Fast, economical Claude model · 200K context" },
24
- sonnet: { name: "Sonnet 5 (PennyRouter)", description: "Balanced Claude model · 1M context" },
25
- opus: { name: "Opus 5 (PennyRouter)", description: "Most capable Claude model · 1M context" },
26
- fable: { name: "Fable 5 (PennyRouter)", description: "Claude model · 1M context" },
22
+ haiku: { name: "Haiku 4.5", description: "Fast, economical Claude model · 200K context" },
23
+ sonnet: { name: "Sonnet 5", description: "Balanced Claude model · 1M context" },
24
+ opus: { name: "Opus 5", description: "Most capable Claude model · 1M context" },
25
+ fable: { name: "Fable 5", description: "Claude model · 1M context" },
27
26
  };
28
27
  const LEGACY_PINNED_MODEL_IDS = {
28
+ "Haiku 4.5": CLAUDE_CODE_MODELS.haiku,
29
+ "Sonnet 5": CLAUDE_CODE_MODELS.sonnet,
30
+ "Opus 5": CLAUDE_CODE_MODELS.opus,
31
+ "Fable 5": CLAUDE_CODE_MODELS.fable,
29
32
  "Haiku 4.5 (PennyRouter)": CLAUDE_CODE_MODELS.haiku,
30
33
  "Sonnet 5 (PennyRouter)": CLAUDE_CODE_MODELS.sonnet,
31
34
  "Opus 5 (PennyRouter)": CLAUDE_CODE_MODELS.opus,
@@ -86,6 +89,7 @@ export const claudeCodeHarness = {
86
89
  // Anthropic API key. Clear any stale key so it doesn't take precedence.
87
90
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
88
91
  delete current.env.ANTHROPIC_API_KEY;
92
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
89
93
  // Claude Code displays these env values directly in its model picker; the
90
94
  // gateway decodes the friendly labels into bundle/profile overrides.
91
95
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
@@ -93,6 +97,9 @@ export const claudeCodeHarness = {
93
97
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
94
98
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
95
99
  writeClaudeCodeModelPresentation(current.env);
100
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
101
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
102
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
96
103
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
97
104
  // real context limit instead of incorrectly giving Haiku a 1M window.
98
105
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -138,11 +145,15 @@ export const claudeCodeHarness = {
138
145
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
139
146
  // Single credential only: no stale api key, no separate Anthropic bearer.
140
147
  delete current.env.ANTHROPIC_API_KEY;
148
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
141
149
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
142
150
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
143
151
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
144
152
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
145
153
  writeClaudeCodeModelPresentation(current.env);
154
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
155
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
156
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
146
157
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
147
158
  // real context limit instead of incorrectly giving Haiku a 1M window.
148
159
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -184,11 +195,15 @@ export const claudeCodeHarness = {
184
195
  // Authorization bearer token is forwarded by the gateway to Anthropic.
185
196
  current.env.ANTHROPIC_API_KEY = pennyKey;
186
197
  current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
198
+ current.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
187
199
  current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
188
200
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
189
201
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
190
202
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
191
203
  writeClaudeCodeModelPresentation(current.env);
204
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION;
205
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
206
+ delete current.env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
192
207
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
193
208
  // real context limit instead of incorrectly giving Haiku a 1M window.
194
209
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -8,11 +8,12 @@ import {
8
8
  readText,
9
9
  writeJsonFile,
10
10
  } from "../state.js";
11
- import { PENNYROUTER_MODEL_CHOICES } from "./shared.js";
12
-
13
11
  const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
14
12
  const JSON_PATH = "~/.config/opencode/opencode.json";
15
13
  const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
14
+ const FALLBACK_MODEL_CHOICES = [
15
+ { id: "pennyrouter/auto", name: "PennyRouter Auto" },
16
+ ];
16
17
 
17
18
  export const opencodeHarness = {
18
19
  id: "opencode",
@@ -35,7 +36,7 @@ export const opencodeHarness = {
35
36
  };
36
37
  },
37
38
 
38
- async install({ apiKey, gatewayBaseUrl, plan }) {
39
+ async install({ apiKey, gatewayBaseUrl, plan, models }) {
39
40
  const configPath = compactHome(plan.configPath);
40
41
  const backupPath = await backupFile(configPath, "opencode");
41
42
  const current = await readConfig(configPath);
@@ -44,18 +45,29 @@ export const opencodeHarness = {
44
45
  npm: "@ai-sdk/openai-compatible",
45
46
  name: "PennyRouter",
46
47
  options: {
47
- baseURL: `${gatewayBaseUrl}/v1`,
48
+ baseURL: `${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`,
48
49
  apiKey,
49
50
  },
50
51
  models: Object.fromEntries(
51
- PENNYROUTER_MODEL_CHOICES.map((model) => [model.id, { name: model.name }]),
52
+ (models || await fetchOpenCodeModels(gatewayBaseUrl)).map((model) => [
53
+ opencodeModelId(model.id),
54
+ { name: model.name, ...(model.description ? { description: model.description } : {}) },
55
+ ]),
52
56
  ),
53
57
  };
54
58
 
55
59
  await atomicWrite(configPath, `${JSON.stringify(current, null, 2)}\n`);
56
60
 
57
61
  const modelBackupPath = await backupFile(MODEL_STATE_PATH, "opencode-model");
58
- await writeJsonFile(MODEL_STATE_PATH, { recent: [], favorite: [], variant: {} });
62
+ const model = (await readJsonFile(MODEL_STATE_PATH, null)) || {};
63
+ model.recent = removePennyRouterModels(model.recent);
64
+ model.favorite = removePennyRouterModels(model.favorite);
65
+ if (model.variant && typeof model.variant === "object") {
66
+ for (const [key, value] of Object.entries(model.variant)) {
67
+ if (isPennyRouterModel(key) || isPennyRouterModel(value)) delete model.variant[key];
68
+ }
69
+ }
70
+ await writeJsonFile(MODEL_STATE_PATH, model);
59
71
 
60
72
  return {
61
73
  harness: "opencode",
@@ -136,7 +148,40 @@ function stripJsonComments(text) {
136
148
  result += char;
137
149
  }
138
150
 
139
- return result;
151
+ return result.replace(/,\s*([}\]])/g, "$1");
152
+ }
153
+
154
+ export function opencodeModelId(modelId) {
155
+ return modelId;
156
+ }
157
+
158
+ export async function fetchOpenCodeModels(gatewayBaseUrl) {
159
+ try {
160
+ const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`);
161
+ if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
162
+ const payload = await response.json();
163
+ const models = Array.isArray(payload?.data)
164
+ ? payload.data
165
+ .filter((model) => typeof model?.id === "string" && model.id.startsWith("pennyrouter/"))
166
+ .map((model) => ({
167
+ id: model.id,
168
+ name: model.display_name || openCodeModelName(model.id),
169
+ description: model.description,
170
+ }))
171
+ : [];
172
+ if (models.length > 0) return models;
173
+ } catch {}
174
+ return FALLBACK_MODEL_CHOICES;
175
+ }
176
+
177
+ function openCodeModelName(modelId) {
178
+ const suffix = opencodeModelId(modelId);
179
+ if (suffix === "auto") return "PennyRouter Auto";
180
+ return `PennyRouter ${suffix
181
+ .split(/[-:]/)
182
+ .filter(Boolean)
183
+ .map((part) => part[0].toUpperCase() + part.slice(1))
184
+ .join(" ")}`;
140
185
  }
141
186
 
142
187
  function removePennyRouterModels(value) {
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import { opencodeHarness, opencodeModelId } from "./opencode.js";
7
+
8
+ const ENV_KEYS = ["HOME", "XDG_STATE_HOME"];
9
+
10
+ async function main() {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-opencode-test-"));
12
+ const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
13
+ const home = path.join(root, "home");
14
+ const configPath = path.join(home, ".config", "opencode", "opencode.jsonc");
15
+ const modelStatePath = path.join(home, ".local", "state", "opencode", "model.json");
16
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
17
+ fs.mkdirSync(path.dirname(modelStatePath), { recursive: true });
18
+ fs.writeFileSync(configPath, [
19
+ "{",
20
+ ' "$schema": "https://opencode.ai/config.json",',
21
+ ' "provider": {',
22
+ ' "custom": { "models": { "model-a": {} } },',
23
+ " },",
24
+ "}",
25
+ "",
26
+ ].join("\n"));
27
+ fs.writeFileSync(modelStatePath, JSON.stringify({
28
+ recent: ["pennyrouter/old", "custom/model-a"],
29
+ favorite: ["pennyrouter/old"],
30
+ variant: { "pennyrouter/old": { value: "pennyrouter/old" }, "custom/model-a": {} },
31
+ }));
32
+ Object.assign(process.env, { HOME: home, XDG_STATE_HOME: path.join(home, ".local", "state") });
33
+
34
+ try {
35
+ assert.equal(opencodeModelId("pennyrouter/auto"), "pennyrouter/auto");
36
+ assert.equal(opencodeModelId("custom/model-a"), "custom/model-a");
37
+ const plan = await opencodeHarness.planInstall();
38
+ const record = await opencodeHarness.install({
39
+ apiKey: "pr-test",
40
+ gatewayBaseUrl: "https://api.pennyrouter.com/",
41
+ plan,
42
+ models: [
43
+ { id: "pennyrouter/penny-core:premium", name: "Penny Core" },
44
+ { id: "pennyrouter/penny-speed:standard", name: "Penny Speed" },
45
+ { id: "pennyrouter/penny-power:premium", name: "Penny Power" },
46
+ { id: "pennyrouter/auto", name: "Penny Custom" },
47
+ ],
48
+ });
49
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
50
+ assert.equal(config.provider.custom.models["model-a"] !== undefined, true);
51
+ assert.deepEqual(Object.keys(config.provider.pennyrouter.models), [
52
+ "pennyrouter/penny-core:premium",
53
+ "pennyrouter/penny-speed:standard",
54
+ "pennyrouter/penny-power:premium",
55
+ "pennyrouter/auto",
56
+ ]);
57
+ assert.equal(config.provider.pennyrouter.options.baseURL, "https://api.pennyrouter.com/v1");
58
+ assert.deepEqual(JSON.parse(fs.readFileSync(modelStatePath, "utf8")).recent, ["custom/model-a"]);
59
+
60
+ await opencodeHarness.uninstall(record);
61
+ const restored = JSON.parse(fs.readFileSync(configPath, "utf8"));
62
+ assert.equal(restored.provider.pennyrouter, undefined);
63
+ assert.deepEqual(restored.provider.custom.models, { "model-a": {} });
64
+ const model = JSON.parse(fs.readFileSync(modelStatePath, "utf8"));
65
+ assert.deepEqual(model.recent, ["custom/model-a"]);
66
+ assert.deepEqual(model.favorite, []);
67
+ assert.deepEqual(model.variant, { "custom/model-a": {} });
68
+ } finally {
69
+ for (const key of ENV_KEYS) {
70
+ if (previous[key] === undefined) delete process.env[key];
71
+ else process.env[key] = previous[key];
72
+ }
73
+ fs.rmSync(root, { recursive: true, force: true });
74
+ }
75
+
76
+ console.log("opencode.test.js: all checks passed");
77
+ }
78
+
79
+ main().catch((error) => {
80
+ console.error(error);
81
+ process.exit(1);
82
+ });
package/src/launch.js CHANGED
@@ -21,6 +21,8 @@ const PATH_START = "# >>> pennyrouter PATH >>>";
21
21
  const PATH_END = "# <<< pennyrouter PATH <<<";
22
22
  const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
23
23
  const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
24
+ const TERMINAL_TITLE_REFRESH_MS = 1000;
25
+ const TERMINAL_STATUS_REFRESH_MS = 15000;
24
26
 
25
27
  export function pennyConfigDir() {
26
28
  if (process.platform === "win32") {
@@ -254,9 +256,93 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
254
256
  "supports_websockets = false",
255
257
  ...authLines,
256
258
  "",
259
+ "[tui]",
260
+ // Penny owns the live terminal title while this profile is running. An empty
261
+ // list disables Codex's default activity/project title so the two writers do
262
+ // not race each other.
263
+ "terminal_title = []",
264
+ "",
257
265
  ].join("\n");
258
266
  }
259
267
 
268
+ function terminalTitle(text) {
269
+ // OSC titles are interpreted by the terminal, so remove control characters and
270
+ // the OSC terminators rather than allowing gateway-provided text into the escape.
271
+ const safe = String(text).replace(/[\u0000-\u001f\u007f\u001b]/g, " ").slice(0, 240);
272
+ return `\u001b]0;${safe}\u0007`;
273
+ }
274
+
275
+ function writeTerminalTitle(title) {
276
+ if (process.stdout.isTTY) process.stdout.write(terminalTitle(title));
277
+ }
278
+
279
+ function renderPennyTerminalTitle(status) {
280
+ const parts = ["PennyRouter"];
281
+ if (status?.model_display_name) parts.push(status.model_display_name);
282
+ else if (status?.model) parts.push(status.model);
283
+ if (status?.auth_mode) parts.push(`(${status.auth_mode})`);
284
+ parts.push(renderCacheWarmth(status));
285
+ return parts.join(" · ");
286
+ }
287
+
288
+ function renderCacheWarmth(status) {
289
+ const expiresAt = status?.cache_expires_at;
290
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
291
+ return "❄️ cold";
292
+ }
293
+ const remaining = Math.floor(expiresAt - Date.now() / 1000);
294
+ if (remaining <= 0) return "❄️ cold";
295
+ const minutes = Math.floor(remaining / 60);
296
+ const seconds = remaining % 60;
297
+ return `🔥 ${minutes}:${String(seconds).padStart(2, "0")}`;
298
+ }
299
+
300
+ /**
301
+ * Keep the terminal/tab title useful while `penny codex` owns the foreground TTY.
302
+ * This is deliberately local and best-effort: failure to poll the gateway must never
303
+ * affect Codex, and non-TTY invocations (CI, pipes) receive no escape sequences.
304
+ */
305
+ export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey }) {
306
+ if (!process.stdout.isTTY) return () => {};
307
+
308
+ let status = null;
309
+ let stopped = false;
310
+ let statusInFlight = false;
311
+
312
+ const refreshStatus = async () => {
313
+ if (stopped || statusInFlight || !gatewayBaseUrl || !apiKey) return;
314
+ statusInFlight = true;
315
+ try {
316
+ const response = await fetch(`${String(gatewayBaseUrl).replace(/\/$/, "")}/v1/penny/statusline`, {
317
+ headers: { "x-api-key": apiKey },
318
+ signal: AbortSignal.timeout(4000),
319
+ });
320
+ if (response.ok) status = await response.json();
321
+ } catch {
322
+ // The title remains useful with its local timer when the gateway is unavailable.
323
+ } finally {
324
+ statusInFlight = false;
325
+ }
326
+ };
327
+
328
+ const tick = () => writeTerminalTitle(renderPennyTerminalTitle(status));
329
+ const timer = setInterval(tick, TERMINAL_TITLE_REFRESH_MS);
330
+ const statusTimer = setInterval(refreshStatus, TERMINAL_STATUS_REFRESH_MS);
331
+ timer.unref?.();
332
+ statusTimer.unref?.();
333
+ void refreshStatus();
334
+ tick();
335
+
336
+ return () => {
337
+ if (stopped) return;
338
+ stopped = true;
339
+ clearInterval(timer);
340
+ clearInterval(statusTimer);
341
+ // Let the shell or terminal choose its normal title again after Codex exits.
342
+ writeTerminalTitle("");
343
+ };
344
+ }
345
+
260
346
  async function writeClaudeLaunchSettings() {
261
347
  const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
262
348
  const overlay = { disableClaudeAiConnectors: true };
@@ -330,6 +416,11 @@ export async function configurePennyHarness(harness, {
330
416
  enabled: true,
331
417
  subscription_auth: Boolean(subscriptionAuth),
332
418
  };
419
+ } else if (harness === "opencode") {
420
+ // OpenCode owns its provider and statusline configuration. The launcher only
421
+ // records that the Penny command is enabled; its existing OpenCode config is
422
+ // left untouched.
423
+ config.harnesses.opencode = { enabled: true };
333
424
  } else {
334
425
  throw new Error(`The penny command does not support ${harness}.`);
335
426
  }
@@ -433,16 +524,17 @@ export function buildPennyInvocation(harness, args, config) {
433
524
  const models = item.models || {};
434
525
  env.ANTHROPIC_BASE_URL = config.gateway_base_url;
435
526
  env.ANTHROPIC_AUTH_TOKEN = config.api_key;
527
+ // Ask Claude Code to discover the gateway's model catalog at startup. The gateway
528
+ // advertises Penny-facing ids in /v1/models; this is separate from inference routing.
529
+ env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
436
530
  delete env.ANTHROPIC_API_KEY;
437
531
  if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = upgradeClaudeCodeModelValue(models.haiku);
438
532
  if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
439
533
  if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
440
534
  if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
441
- if (models.custom) {
442
- env.ANTHROPIC_CUSTOM_MODEL_OPTION = upgradeClaudeCodeModelValue(models.custom);
443
- if (models.custom_name) env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = models.custom_name;
444
- if (models.custom_description) env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = models.custom_description;
445
- }
535
+ delete env.ANTHROPIC_CUSTOM_MODEL_OPTION;
536
+ delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME;
537
+ delete env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION;
446
538
  writeClaudeCodeModelPresentation(env);
447
539
  // Per-model limits must come from Claude Code's recognized native model ids. This
448
540
  // global override would incorrectly give Haiku the 1M window.
@@ -456,7 +548,10 @@ export function buildPennyInvocation(harness, args, config) {
456
548
  if (harness === "codex") {
457
549
  return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
458
550
  }
459
- throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
551
+ if (harness === "opencode") {
552
+ return { command: "opencode", args, env };
553
+ }
554
+ throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
460
555
  }
461
556
 
462
557
  async function waitForChild(child) {
@@ -469,11 +564,11 @@ async function waitForChild(child) {
469
564
  export async function runPenny(argv = process.argv.slice(2)) {
470
565
  const harness = argv[0];
471
566
  if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
472
- console.log("Usage: penny <claude|codex> [native arguments...]");
567
+ console.log("Usage: penny <claude|codex|opencode> [native arguments...]");
473
568
  return 0;
474
569
  }
475
- if (!(["claude", "codex"].includes(harness))) {
476
- throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
570
+ if (!["claude", "codex", "opencode"].includes(harness)) {
571
+ throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
477
572
  }
478
573
  const config = await loadPennyConfig();
479
574
  const invocation = buildPennyInvocation(harness, argv.slice(1), config);
@@ -483,7 +578,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
483
578
  stdio: "inherit",
484
579
  shell: process.platform === "win32",
485
580
  });
581
+ const stopTerminalTitle = ["codex", "opencode"].includes(harness)
582
+ ? startPennyTerminalTitle({
583
+ gatewayBaseUrl: config.gateway_base_url,
584
+ apiKey: config.api_key,
585
+ })
586
+ : () => {};
486
587
  const result = await waitForChild(child);
588
+ stopTerminalTitle();
487
589
  if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
488
590
  return result.code ?? 1;
489
591
  }
@@ -83,6 +83,10 @@ async function main() {
83
83
  gatewayBaseUrl: "https://gateway.example/",
84
84
  subscriptionAuth: true,
85
85
  });
86
+ await configurePennyHarness("opencode", {
87
+ apiKey: "pr-super-secret",
88
+ gatewayBaseUrl: "https://gateway.example/",
89
+ });
86
90
 
87
91
  assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
88
92
  assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
@@ -94,6 +98,7 @@ async function main() {
94
98
  assert.equal(config.api_key, "pr-super-secret");
95
99
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
96
100
  assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
101
+ assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
97
102
  assert.ok(!codexProfile.includes("pr-super-secret"));
98
103
  assert.ok(!rc.includes("pr-super-secret"));
99
104
 
@@ -103,18 +108,25 @@ async function main() {
103
108
  assert.ok(!claude.args.includes("--plugin-dir"));
104
109
  assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
105
110
  assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
111
+ assert.equal(claude.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY, "1");
106
112
  // 1M-capable slots carry the `[1m]` suffix; Haiku has no 1M variant and stays bare.
107
113
  // Per-slot suffixes, not the global CLAUDE_CODE_MAX_CONTEXT_TOKENS override, which
108
114
  // would wrongly hand Haiku a 1M window too.
109
115
  assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5[1m]");
110
116
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
111
- assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5 (PennyRouter)");
117
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5");
112
118
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
113
119
  assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
114
120
 
115
121
  const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
116
122
  assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
117
123
  assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
124
+ const opencode = buildPennyInvocation("opencode", ["--continue"], config);
125
+ assert.deepEqual(opencode, {
126
+ command: "opencode",
127
+ args: ["--continue"],
128
+ env: opencode.env,
129
+ });
118
130
  assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
119
131
  assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
120
132
 
@@ -147,6 +159,7 @@ async function main() {
147
159
  assert.ok(fs.existsSync(codexLaunchProfilePath()));
148
160
  assert.ok(fs.existsSync(pennyShimPath()));
149
161
  await removePennyHarness("codex");
162
+ await removePennyHarness("opencode");
150
163
  await removePennyRuntime();
151
164
  assert.ok(!fs.existsSync(pennyShimPath()));
152
165
  assert.ok(!fs.existsSync(pennyRuntimeDir()));
package/src/session.js CHANGED
@@ -97,7 +97,7 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
97
97
  "anthropic-beta": "compact-2026-01-12",
98
98
  },
99
99
  body: JSON.stringify({
100
- model: "Sonnet 5 (PennyRouter)",
100
+ model: "Sonnet 5",
101
101
  max_tokens: 1,
102
102
  stream: false,
103
103
  messages: [{ role: "user", content: "Reply OK." }],