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 +5 -3
- package/package.json +2 -2
- package/src/cli.js +20 -14
- package/src/harnesses/opencode.js +70 -8
- package/src/harnesses/opencode.test.js +72 -0
- package/src/launch.js +105 -4
- package/src/launch.test.js +12 -0
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.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"
|
|
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
|
-
|
|
680
|
-
|
|
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"
|
|
721
|
-
|
|
722
|
-
const
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
477
|
-
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.`);
|
|
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
|
}
|
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
|
|
|
@@ -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()));
|