pennyrouter 0.3.4 → 0.3.6
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/package.json +1 -1
- package/src/harnesses/codex.js +10 -3
- package/src/harnesses/codex.test.js +4 -1
- package/src/harnesses/opencode.js +4 -0
- package/src/launch.js +36 -8
- package/src/launch.test.js +6 -2
- package/src/statusline.js +6 -0
package/package.json
CHANGED
package/src/harnesses/codex.js
CHANGED
|
@@ -37,6 +37,7 @@ export const codexHarness = {
|
|
|
37
37
|
apiKey,
|
|
38
38
|
gatewayBaseUrl,
|
|
39
39
|
subscriptionAuth,
|
|
40
|
+
surface: "codex-terminal",
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
await atomicWrite(CONFIG_PATH, next);
|
|
@@ -98,7 +99,10 @@ export async function detectCodexChatgptAuth() {
|
|
|
98
99
|
* x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
|
|
99
100
|
* the pr- key is the provider bearer, matching the isolated probe's existing behavior.
|
|
100
101
|
*/
|
|
101
|
-
export function renderCodexConfig(
|
|
102
|
+
export function renderCodexConfig(
|
|
103
|
+
text,
|
|
104
|
+
{ apiKey, gatewayBaseUrl, subscriptionAuth, surface = "codex-terminal" },
|
|
105
|
+
) {
|
|
102
106
|
const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
|
|
103
107
|
if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
|
|
104
108
|
throw new Error(
|
|
@@ -111,9 +115,12 @@ export function renderCodexConfig(text, { apiKey, gatewayBaseUrl, subscriptionAu
|
|
|
111
115
|
const authLines = subscriptionAuth
|
|
112
116
|
? [
|
|
113
117
|
"requires_openai_auth = true",
|
|
114
|
-
`http_headers = { "x-pennyrouter-key" = ${escapedKey} }`,
|
|
118
|
+
`http_headers = { "x-pennyrouter-key" = ${escapedKey}, "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
|
|
115
119
|
]
|
|
116
|
-
: [
|
|
120
|
+
: [
|
|
121
|
+
`experimental_bearer_token = ${escapedKey}`,
|
|
122
|
+
`http_headers = { "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = ${tomlString(surface)} }`,
|
|
123
|
+
];
|
|
117
124
|
const block = [
|
|
118
125
|
MANAGED_START,
|
|
119
126
|
`model_provider = "${PROVIDER_ID}"`,
|
|
@@ -29,7 +29,9 @@ function testSubscriptionProviderIsRootSafe() {
|
|
|
29
29
|
assert.ok(next.includes('[model_providers.pennyrouter]'));
|
|
30
30
|
assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
|
|
31
31
|
assert.ok(next.includes("requires_openai_auth = true"));
|
|
32
|
-
assert.ok(next.includes('
|
|
32
|
+
assert.ok(next.includes('"x-pennyrouter-key" = "pr-test-secret"'));
|
|
33
|
+
assert.ok(next.includes('"x-pennyrouter-harness" = "codex"'));
|
|
34
|
+
assert.ok(next.includes('"x-pennyrouter-surface" = "codex-terminal"'));
|
|
33
35
|
assert.ok(!next.includes("experimental_bearer_token"));
|
|
34
36
|
assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
|
|
35
37
|
}
|
|
@@ -51,6 +53,7 @@ function testLegacyMigrationAndIdempotence() {
|
|
|
51
53
|
assert.ok(!once.includes("openai_base_url"));
|
|
52
54
|
assert.ok(!once.includes("openai_api_key"));
|
|
53
55
|
assert.ok(once.includes('experimental_bearer_token = "pr-new"'));
|
|
56
|
+
assert.ok(once.includes('"x-pennyrouter-surface" = "codex-terminal"'));
|
|
54
57
|
assert.ok(!once.includes("requires_openai_auth"));
|
|
55
58
|
}
|
|
56
59
|
|
|
@@ -65,6 +65,10 @@ export const opencodeHarness = {
|
|
|
65
65
|
options: {
|
|
66
66
|
baseURL: `${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`,
|
|
67
67
|
apiKey,
|
|
68
|
+
headers: {
|
|
69
|
+
"x-pennyrouter-title-session": "{env:PENNYROUTER_TITLE_SESSION_ID}",
|
|
70
|
+
"x-pennyrouter-harness": "opencode",
|
|
71
|
+
},
|
|
68
72
|
},
|
|
69
73
|
models: Object.fromEntries(
|
|
70
74
|
(models || OPENCODE_MODEL_CHOICES).map((model) => [
|
package/src/launch.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
cp,
|
|
5
5
|
mkdtemp,
|
|
6
6
|
mkdir,
|
|
7
|
+
readFile,
|
|
7
8
|
rm,
|
|
8
9
|
readdir,
|
|
9
10
|
stat,
|
|
@@ -15,6 +16,7 @@ import { constants as osConstants } from "node:os";
|
|
|
15
16
|
import { tmpdir } from "node:os";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
17
18
|
import { basename, dirname, join, resolve } from "node:path";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
18
20
|
|
|
19
21
|
import { configureClaudeCodeStatusline } from "./statusline.js";
|
|
20
22
|
import {
|
|
@@ -293,9 +295,12 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
|
293
295
|
const authLines = subscriptionAuth
|
|
294
296
|
? [
|
|
295
297
|
"requires_openai_auth = true",
|
|
296
|
-
'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
|
|
298
|
+
'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY", "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
|
|
297
299
|
]
|
|
298
|
-
: [
|
|
300
|
+
: [
|
|
301
|
+
'env_key = "PENNYROUTER_API_KEY"',
|
|
302
|
+
'http_headers = { "x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID", "x-pennyrouter-harness" = "codex", "x-pennyrouter-surface" = "codex-terminal" }',
|
|
303
|
+
];
|
|
299
304
|
return [
|
|
300
305
|
CODEX_PROFILE_MARKER,
|
|
301
306
|
'model_provider = "pennyrouter"',
|
|
@@ -540,6 +545,7 @@ export async function createCodexDesktopHome({ config, realHome }) {
|
|
|
540
545
|
apiKey: config.api_key,
|
|
541
546
|
gatewayBaseUrl: config.gateway_base_url,
|
|
542
547
|
subscriptionAuth: Boolean(config.harnesses?.codex?.subscription_auth),
|
|
548
|
+
surface: "codex-desktop",
|
|
543
549
|
}));
|
|
544
550
|
await privateWrite(join(overlay, "config.toml"), desktopConfig);
|
|
545
551
|
return overlay;
|
|
@@ -606,19 +612,27 @@ function renderCacheWarmth(status) {
|
|
|
606
612
|
* This is deliberately local and best-effort: failure to poll the gateway must never
|
|
607
613
|
* affect Codex, and non-TTY invocations (CI, pipes) receive no escape sequences.
|
|
608
614
|
*/
|
|
609
|
-
export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey
|
|
615
|
+
export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey, titleSessionId,
|
|
616
|
+
titleSessionFile }) {
|
|
610
617
|
if (!process.stdout.isTTY) return () => {};
|
|
611
618
|
|
|
612
619
|
let status = null;
|
|
613
620
|
let stopped = false;
|
|
614
621
|
let statusInFlight = false;
|
|
622
|
+
let resolvedTitleSessionId = titleSessionId || "";
|
|
615
623
|
|
|
616
624
|
const refreshStatus = async () => {
|
|
617
625
|
if (stopped || statusInFlight || !gatewayBaseUrl || !apiKey) return;
|
|
618
626
|
statusInFlight = true;
|
|
619
627
|
try {
|
|
628
|
+
if (!resolvedTitleSessionId && titleSessionFile) {
|
|
629
|
+
try { resolvedTitleSessionId = (await readFile(titleSessionFile, "utf8")).trim(); }
|
|
630
|
+
catch { /* Claude statusline has not handed off its session yet */ }
|
|
631
|
+
}
|
|
632
|
+
const headers = { "x-api-key": apiKey };
|
|
633
|
+
if (resolvedTitleSessionId) headers["x-pennyrouter-title-session"] = resolvedTitleSessionId;
|
|
620
634
|
const response = await fetch(`${String(gatewayBaseUrl).replace(/\/$/, "")}/v1/penny/statusline`, {
|
|
621
|
-
headers
|
|
635
|
+
headers,
|
|
622
636
|
signal: AbortSignal.timeout(4000),
|
|
623
637
|
});
|
|
624
638
|
if (response.ok) status = await response.json();
|
|
@@ -845,6 +859,7 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
845
859
|
assertNoConflictingArgs(harness, args);
|
|
846
860
|
const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
|
|
847
861
|
if (harness === "claude") {
|
|
862
|
+
env.PENNYROUTER_TITLE_SESSION_FILE = join(pennyDataDir(), `title-session-${randomUUID()}`);
|
|
848
863
|
const models = item.models || {};
|
|
849
864
|
env.ANTHROPIC_BASE_URL = config.gateway_base_url;
|
|
850
865
|
env.ANTHROPIC_AUTH_TOKEN = config.api_key;
|
|
@@ -870,9 +885,14 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
870
885
|
};
|
|
871
886
|
}
|
|
872
887
|
if (harness === "codex") {
|
|
888
|
+
// The parent process owns the OSC tab title, while Codex owns the actual conversation
|
|
889
|
+
// identity. Give both sides a launch-local join token so title warmth cannot bleed between
|
|
890
|
+
// two Codex TUIs using the same Penny key.
|
|
891
|
+
env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
|
|
873
892
|
return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
|
|
874
893
|
}
|
|
875
894
|
if (harness === "opencode") {
|
|
895
|
+
env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
|
|
876
896
|
return { command: "opencode", args, env };
|
|
877
897
|
}
|
|
878
898
|
throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
|
|
@@ -952,10 +972,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
952
972
|
stdio: "inherit",
|
|
953
973
|
shell: process.platform === "win32",
|
|
954
974
|
});
|
|
955
|
-
|
|
975
|
+
// Claude Code owns its own terminal title updates while a turn is running. Do not
|
|
976
|
+
// compete with it from the parent process; Codex and OpenCode remain Penny-owned.
|
|
977
|
+
const stopTerminalTitle = ["codex", "opencode"].includes(harness)
|
|
956
978
|
? startPennyTerminalTitle({
|
|
957
979
|
gatewayBaseUrl: config.gateway_base_url,
|
|
958
980
|
apiKey: config.api_key,
|
|
981
|
+
titleSessionId: invocation.env.PENNYROUTER_TITLE_SESSION_ID,
|
|
982
|
+
titleSessionFile: invocation.env.PENNYROUTER_TITLE_SESSION_FILE,
|
|
959
983
|
})
|
|
960
984
|
: () => {};
|
|
961
985
|
const result = await waitForChild(child);
|
|
@@ -998,7 +1022,10 @@ async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {})
|
|
|
998
1022
|
}
|
|
999
1023
|
if (mode === "native") {
|
|
1000
1024
|
const child = spawn(codexDesktopExecutable(), [], {
|
|
1001
|
-
|
|
1025
|
+
// Desktop launches must not inherit a project directory. When PennyRouter itself is
|
|
1026
|
+
// started from ~/Documents, that makes macOS attribute Codex's access to Documents to
|
|
1027
|
+
// PennyRouter and trigger a protected-folder prompt.
|
|
1028
|
+
cwd: homeDir(),
|
|
1002
1029
|
env: process.env,
|
|
1003
1030
|
stdio: logs ? "inherit" : "ignore",
|
|
1004
1031
|
detached: !logs,
|
|
@@ -1021,7 +1048,8 @@ async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {})
|
|
|
1021
1048
|
"__codex-desktop-supervisor",
|
|
1022
1049
|
overlay,
|
|
1023
1050
|
], {
|
|
1024
|
-
|
|
1051
|
+
// Keep the detached supervisor out of the caller's protected project directory too.
|
|
1052
|
+
cwd: homeDir(),
|
|
1025
1053
|
env: process.env,
|
|
1026
1054
|
stdio: "ignore",
|
|
1027
1055
|
detached: true,
|
|
@@ -1036,7 +1064,7 @@ async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {})
|
|
|
1036
1064
|
|
|
1037
1065
|
export async function runCodexDesktopSupervisor(overlay) {
|
|
1038
1066
|
const child = spawn(codexDesktopExecutable(), [], {
|
|
1039
|
-
cwd:
|
|
1067
|
+
cwd: homeDir(),
|
|
1040
1068
|
env: { ...process.env, CODEX_HOME: overlay },
|
|
1041
1069
|
stdio: "inherit",
|
|
1042
1070
|
shell: process.platform === "win32",
|
package/src/launch.test.js
CHANGED
|
@@ -179,12 +179,16 @@ async function main() {
|
|
|
179
179
|
const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
|
|
180
180
|
assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
|
|
181
181
|
assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
|
|
182
|
-
assert.match(desktopConfig, /
|
|
182
|
+
assert.match(desktopConfig, /"x-pennyrouter-key" = "pr-super-secret"/);
|
|
183
|
+
assert.match(desktopConfig, /"x-pennyrouter-surface" = "codex-desktop"/);
|
|
183
184
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
184
185
|
fs.rmSync(desktopHome, { recursive: true, force: true });
|
|
185
186
|
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
186
|
-
assert.ok(codexProfile.includes('
|
|
187
|
+
assert.ok(codexProfile.includes('"x-pennyrouter-key" = "PENNYROUTER_API_KEY"'));
|
|
187
188
|
assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
|
|
189
|
+
assert.ok(codexProfile.includes('"x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID"'));
|
|
190
|
+
assert.ok(codexProfile.includes('"x-pennyrouter-harness" = "codex"'));
|
|
191
|
+
assert.ok(codexProfile.includes('"x-pennyrouter-surface" = "codex-terminal"'));
|
|
188
192
|
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
189
193
|
assert.ok(!rc.includes("pr-super-secret"));
|
|
190
194
|
|
package/src/statusline.js
CHANGED
|
@@ -91,6 +91,7 @@ const stateDir = join(
|
|
|
91
91
|
"pennyrouter",
|
|
92
92
|
);
|
|
93
93
|
const cacheDir = join(stateDir, "statusline-cache");
|
|
94
|
+
const titleSessionFile = process.env.PENNYROUTER_TITLE_SESSION_FILE || "";
|
|
94
95
|
// A wrapped user statusline command can be slow to spawn (npx especially: on Windows
|
|
95
96
|
// it goes through an extra cmd.exe/npx.cmd hop and can take 2-4s per invocation, vs
|
|
96
97
|
// well under 1s on macOS/Linux). Claude Code re-invokes this script every ~1s
|
|
@@ -164,6 +165,11 @@ const raw = readFileSync(0, "utf8");
|
|
|
164
165
|
let input = {};
|
|
165
166
|
try { input = JSON.parse(raw); } catch { /* keep {} */ }
|
|
166
167
|
const sessionId = input.session_id || "";
|
|
168
|
+
// The parent Penny launcher owns the tab title, while this command knows Claude Code's
|
|
169
|
+
// actual session id. Hand it across a launch-local file for an exact title lookup.
|
|
170
|
+
if (titleSessionFile && sessionId) {
|
|
171
|
+
try { writeFileSync(titleSessionFile, sessionId, { mode: 0o600 }); } catch { /* best-effort */ }
|
|
172
|
+
}
|
|
167
173
|
const refreshMs = (config.refresh_seconds ?? 15) * 1000;
|
|
168
174
|
|
|
169
175
|
const cachePath = join(cacheDir, sessionId + ".json");
|