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 +5 -3
- package/package.json +2 -2
- package/src/cli.js +19 -12
- package/src/harnesses/claude-code.js +20 -5
- package/src/harnesses/opencode.js +52 -7
- package/src/harnesses/opencode.test.js +82 -0
- package/src/launch.js +111 -9
- package/src/launch.test.js +14 -1
- package/src/session.js +1 -1
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
|
|
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
|
-
|
|
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.
|
|
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"
|
|
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
|
-
|
|
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"
|
|
721
|
-
|
|
722
|
-
const
|
|
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"
|
|
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
|
|
24
|
-
sonnet: { name: "Sonnet 5
|
|
25
|
-
opus: { name: "Opus 5
|
|
26
|
-
fable: { name: "Fable 5
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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 (!
|
|
476
|
-
throw new Error(`Unknown harness "${harness}". Try penny claude or penny
|
|
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
|
}
|
package/src/launch.test.js
CHANGED
|
@@ -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
|
|
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
|
|
100
|
+
model: "Sonnet 5",
|
|
101
101
|
max_tokens: 1,
|
|
102
102
|
stream: false,
|
|
103
103
|
messages: [{ role: "user", content: "Reply OK." }],
|