pennyrouter 0.2.27 → 0.2.29

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.27",
3
+ "version": "0.2.29",
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)) {
@@ -676,9 +677,10 @@ export async function install(flags = {}) {
676
677
  }
677
678
  if (supportsPennyCommand) {
678
679
  record.launch_available = true;
679
- record.launch_config_path = compactHome(
680
- harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
681
- );
680
+ const launchConfigPath = harness.id === "claude-code"
681
+ ? claudeLaunchSettingsPath()
682
+ : harness.id === "codex" ? codexLaunchProfilePath() : null;
683
+ record.launch_config_path = launchConfigPath ? compactHome(launchConfigPath) : null;
682
684
  record.default_routing = Boolean(makeDefault);
683
685
  }
684
686
  if (harness.id === "claude-code" && claudeSubscriptionAuth) {
@@ -717,9 +719,12 @@ export async function install(flags = {}) {
717
719
  for (const record of records) {
718
720
  const harness = HARNESS_BY_ID[record.harness];
719
721
  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";
722
+ const command = record.harness === "claude-code"
723
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
724
+ const native = record.harness === "claude-code" ? "claude"
725
+ : record.harness === "codex" ? "codex" : "opencode";
726
+ const harnessName = record.harness === "claude-code" ? "Claude Code"
727
+ : record.harness === "codex" ? "Codex" : "OpenCode";
723
728
  const commandWidth = Math.max(command.length, native.length) + 2;
724
729
  console.log("");
725
730
  console.log(color(`How to Run ${harnessName}`, "bold"));
@@ -928,7 +933,7 @@ async function update(flags) {
928
933
 
929
934
  const failures = [];
930
935
  let runtimeResult = null;
931
- if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))) {
936
+ if (targets.some((record) => ["claude-code", "codex", "opencode"].includes(record.harness))) {
932
937
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
933
938
  }
934
939
 
@@ -937,7 +942,7 @@ async function update(flags) {
937
942
  if (!harness) continue;
938
943
 
939
944
  await runHarnessJob(failures, record.harness, "update", async () => {
940
- const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness);
945
+ const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(record.harness);
941
946
  // An update rewrites the harness's own config only when that config is ours to
942
947
  // rewrite — i.e. the bare command still routes through the gateway. In penny-only
943
948
  // mode the harness config belongs to the user and we touch just the launch profile.
@@ -1604,7 +1609,8 @@ async function status() {
1604
1609
  console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
1605
1610
  }
1606
1611
  if (record.launch_available) {
1607
- const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1612
+ const command = record.harness === "claude-code"
1613
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
1608
1614
  console.log(` command: ${command}`);
1609
1615
  console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
1610
1616
  }
@@ -8,11 +8,24 @@ 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
+ export const OPENCODE_MODEL_CHOICES = [
15
+ { id: "pennyrouter/penny-core:premium", name: "Penny Core" },
16
+ { id: "pennyrouter/penny-speed:standard", name: "Penny Speed" },
17
+ { id: "pennyrouter/penny-power:premium", name: "Penny Power" },
18
+ { id: "pennyrouter/auto", name: "PennyRouter Auto" },
19
+ { id: "claude-penny-deepseek-v4-flash", name: "DeepSeek V4 Flash" },
20
+ { id: "claude-penny-glm-5-3", name: "GLM 5.3" },
21
+ { id: "claude-penny-gpt-5-6-luna", name: "GPT-5.6 Luna" },
22
+ { id: "claude-penny-grok-4-6", name: "Grok 4.6" },
23
+ { id: "claude-penny-kimi-k3", name: "Kimi K3" },
24
+ { id: "claude-penny-ling-3-flash", name: "Ling-3.0 Flash" },
25
+ { id: "claude-penny-minimax-m3", name: "MiniMax M3" },
26
+ { id: "claude-penny-nemotron-ultra-free", name: "Nemotron 3 Ultra (free)" },
27
+ { id: "claude-penny-solar-pro4", name: "Solar Pro 4" },
28
+ ];
16
29
 
17
30
  export const opencodeHarness = {
18
31
  id: "opencode",
@@ -29,13 +42,13 @@ export const opencodeHarness = {
29
42
  summary: [
30
43
  `write ${configPath}`,
31
44
  "merge PennyRouter provider without replacing existing providers",
32
- "publish the four Penny model choices",
45
+ "publish the curated Penny bundles and individual model pins",
33
46
  `reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
34
47
  ],
35
48
  };
36
49
  },
37
50
 
38
- async install({ apiKey, gatewayBaseUrl, plan }) {
51
+ async install({ apiKey, gatewayBaseUrl, plan, models }) {
39
52
  const configPath = compactHome(plan.configPath);
40
53
  const backupPath = await backupFile(configPath, "opencode");
41
54
  const current = await readConfig(configPath);
@@ -44,18 +57,29 @@ export const opencodeHarness = {
44
57
  npm: "@ai-sdk/openai-compatible",
45
58
  name: "PennyRouter",
46
59
  options: {
47
- baseURL: `${gatewayBaseUrl}/v1`,
60
+ baseURL: `${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`,
48
61
  apiKey,
49
62
  },
50
63
  models: Object.fromEntries(
51
- PENNYROUTER_MODEL_CHOICES.map((model) => [model.id, { name: model.name }]),
64
+ (models || OPENCODE_MODEL_CHOICES).map((model) => [
65
+ opencodeModelId(model.id),
66
+ { name: model.name, ...(model.description ? { description: model.description } : {}) },
67
+ ]),
52
68
  ),
53
69
  };
54
70
 
55
71
  await atomicWrite(configPath, `${JSON.stringify(current, null, 2)}\n`);
56
72
 
57
73
  const modelBackupPath = await backupFile(MODEL_STATE_PATH, "opencode-model");
58
- await writeJsonFile(MODEL_STATE_PATH, { recent: [], favorite: [], variant: {} });
74
+ const model = (await readJsonFile(MODEL_STATE_PATH, null)) || {};
75
+ model.recent = removePennyRouterModels(model.recent);
76
+ model.favorite = removePennyRouterModels(model.favorite);
77
+ if (model.variant && typeof model.variant === "object") {
78
+ for (const [key, value] of Object.entries(model.variant)) {
79
+ if (isPennyRouterModel(key) || isPennyRouterModel(value)) delete model.variant[key];
80
+ }
81
+ }
82
+ await writeJsonFile(MODEL_STATE_PATH, model);
59
83
 
60
84
  return {
61
85
  harness: "opencode",
@@ -136,7 +160,45 @@ function stripJsonComments(text) {
136
160
  result += char;
137
161
  }
138
162
 
139
- return result;
163
+ return result.replace(/,\s*([}\]])/g, "$1");
164
+ }
165
+
166
+ export function opencodeModelId(modelId) {
167
+ return modelId;
168
+ }
169
+
170
+ export async function fetchOpenCodeModels(gatewayBaseUrl) {
171
+ try {
172
+ const response = await fetch(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1/models`);
173
+ if (!response.ok) throw new Error(`model catalog returned ${response.status}`);
174
+ const payload = await response.json();
175
+ const catalog = new Map(
176
+ Array.isArray(payload?.data)
177
+ ? payload.data
178
+ .filter((model) => typeof model?.id === "string")
179
+ .map((model) => [model.id, model])
180
+ : [],
181
+ );
182
+ const models = OPENCODE_MODEL_CHOICES.map((choice) => {
183
+ const model = catalog.get(choice.id);
184
+ return {
185
+ ...choice,
186
+ ...(model?.description ? { description: model.description } : {}),
187
+ };
188
+ });
189
+ if (models.length > 0) return models;
190
+ } catch {}
191
+ return OPENCODE_MODEL_CHOICES;
192
+ }
193
+
194
+ function openCodeModelName(modelId) {
195
+ const suffix = opencodeModelId(modelId);
196
+ if (suffix === "auto") return "PennyRouter Auto";
197
+ return `PennyRouter ${suffix
198
+ .split(/[-:]/)
199
+ .filter(Boolean)
200
+ .map((part) => part[0].toUpperCase() + part.slice(1))
201
+ .join(" ")}`;
140
202
  }
141
203
 
142
204
  function removePennyRouterModels(value) {
@@ -0,0 +1,72 @@
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, OPENCODE_MODEL_CHOICES } 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: OPENCODE_MODEL_CHOICES,
43
+ });
44
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
45
+ assert.equal(config.provider.custom.models["model-a"] !== undefined, true);
46
+ assert.deepEqual(Object.keys(config.provider.pennyrouter.models), OPENCODE_MODEL_CHOICES.map((model) => model.id));
47
+ assert.equal(config.provider.pennyrouter.options.baseURL, "https://api.pennyrouter.com/v1");
48
+ assert.deepEqual(JSON.parse(fs.readFileSync(modelStatePath, "utf8")).recent, ["custom/model-a"]);
49
+
50
+ await opencodeHarness.uninstall(record);
51
+ const restored = JSON.parse(fs.readFileSync(configPath, "utf8"));
52
+ assert.equal(restored.provider.pennyrouter, undefined);
53
+ assert.deepEqual(restored.provider.custom.models, { "model-a": {} });
54
+ const model = JSON.parse(fs.readFileSync(modelStatePath, "utf8"));
55
+ assert.deepEqual(model.recent, ["custom/model-a"]);
56
+ assert.deepEqual(model.favorite, []);
57
+ assert.deepEqual(model.variant, { "custom/model-a": {} });
58
+ } finally {
59
+ for (const key of ENV_KEYS) {
60
+ if (previous[key] === undefined) delete process.env[key];
61
+ else process.env[key] = previous[key];
62
+ }
63
+ fs.rmSync(root, { recursive: true, force: true });
64
+ }
65
+
66
+ console.log("opencode.test.js: all checks passed");
67
+ }
68
+
69
+ main().catch((error) => {
70
+ console.error(error);
71
+ process.exit(1);
72
+ });
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
  }
@@ -457,7 +548,10 @@ export function buildPennyInvocation(harness, args, config) {
457
548
  if (harness === "codex") {
458
549
  return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
459
550
  }
460
- 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.`);
461
555
  }
462
556
 
463
557
  async function waitForChild(child) {
@@ -470,11 +564,11 @@ async function waitForChild(child) {
470
564
  export async function runPenny(argv = process.argv.slice(2)) {
471
565
  const harness = argv[0];
472
566
  if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
473
- console.log("Usage: penny <claude|codex> [native arguments...]");
567
+ console.log("Usage: penny <claude|codex|opencode> [native arguments...]");
474
568
  return 0;
475
569
  }
476
- if (!(["claude", "codex"].includes(harness))) {
477
- 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.`);
478
572
  }
479
573
  const config = await loadPennyConfig();
480
574
  const invocation = buildPennyInvocation(harness, argv.slice(1), config);
@@ -484,7 +578,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
484
578
  stdio: "inherit",
485
579
  shell: process.platform === "win32",
486
580
  });
581
+ const stopTerminalTitle = ["codex", "opencode"].includes(harness)
582
+ ? startPennyTerminalTitle({
583
+ gatewayBaseUrl: config.gateway_base_url,
584
+ apiKey: config.api_key,
585
+ })
586
+ : () => {};
487
587
  const result = await waitForChild(child);
588
+ stopTerminalTitle();
488
589
  if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
489
590
  return result.code ?? 1;
490
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
 
@@ -116,6 +121,12 @@ async function main() {
116
121
  const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
117
122
  assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
118
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
+ });
119
130
  assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
120
131
  assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
121
132
 
@@ -148,6 +159,7 @@ async function main() {
148
159
  assert.ok(fs.existsSync(codexLaunchProfilePath()));
149
160
  assert.ok(fs.existsSync(pennyShimPath()));
150
161
  await removePennyHarness("codex");
162
+ await removePennyHarness("opencode");
151
163
  await removePennyRuntime();
152
164
  assert.ok(!fs.existsSync(pennyShimPath()));
153
165
  assert.ok(!fs.existsSync(pennyRuntimeDir()));