pennyrouter 0.3.4 → 0.3.5
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/opencode.js +4 -0
- package/src/launch.js +25 -4
- package/src/launch.test.js +3 -1
- package/src/statusline.js +6 -0
package/package.json
CHANGED
|
@@ -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" }',
|
|
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" }',
|
|
303
|
+
];
|
|
299
304
|
return [
|
|
300
305
|
CODEX_PROFILE_MARKER,
|
|
301
306
|
'model_provider = "pennyrouter"',
|
|
@@ -606,19 +611,27 @@ function renderCacheWarmth(status) {
|
|
|
606
611
|
* This is deliberately local and best-effort: failure to poll the gateway must never
|
|
607
612
|
* affect Codex, and non-TTY invocations (CI, pipes) receive no escape sequences.
|
|
608
613
|
*/
|
|
609
|
-
export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey
|
|
614
|
+
export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey, titleSessionId,
|
|
615
|
+
titleSessionFile }) {
|
|
610
616
|
if (!process.stdout.isTTY) return () => {};
|
|
611
617
|
|
|
612
618
|
let status = null;
|
|
613
619
|
let stopped = false;
|
|
614
620
|
let statusInFlight = false;
|
|
621
|
+
let resolvedTitleSessionId = titleSessionId || "";
|
|
615
622
|
|
|
616
623
|
const refreshStatus = async () => {
|
|
617
624
|
if (stopped || statusInFlight || !gatewayBaseUrl || !apiKey) return;
|
|
618
625
|
statusInFlight = true;
|
|
619
626
|
try {
|
|
627
|
+
if (!resolvedTitleSessionId && titleSessionFile) {
|
|
628
|
+
try { resolvedTitleSessionId = (await readFile(titleSessionFile, "utf8")).trim(); }
|
|
629
|
+
catch { /* Claude statusline has not handed off its session yet */ }
|
|
630
|
+
}
|
|
631
|
+
const headers = { "x-api-key": apiKey };
|
|
632
|
+
if (resolvedTitleSessionId) headers["x-pennyrouter-title-session"] = resolvedTitleSessionId;
|
|
620
633
|
const response = await fetch(`${String(gatewayBaseUrl).replace(/\/$/, "")}/v1/penny/statusline`, {
|
|
621
|
-
headers
|
|
634
|
+
headers,
|
|
622
635
|
signal: AbortSignal.timeout(4000),
|
|
623
636
|
});
|
|
624
637
|
if (response.ok) status = await response.json();
|
|
@@ -845,6 +858,7 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
845
858
|
assertNoConflictingArgs(harness, args);
|
|
846
859
|
const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
|
|
847
860
|
if (harness === "claude") {
|
|
861
|
+
env.PENNYROUTER_TITLE_SESSION_FILE = join(pennyDataDir(), `title-session-${randomUUID()}`);
|
|
848
862
|
const models = item.models || {};
|
|
849
863
|
env.ANTHROPIC_BASE_URL = config.gateway_base_url;
|
|
850
864
|
env.ANTHROPIC_AUTH_TOKEN = config.api_key;
|
|
@@ -870,9 +884,14 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
870
884
|
};
|
|
871
885
|
}
|
|
872
886
|
if (harness === "codex") {
|
|
887
|
+
// The parent process owns the OSC tab title, while Codex owns the actual conversation
|
|
888
|
+
// identity. Give both sides a launch-local join token so title warmth cannot bleed between
|
|
889
|
+
// two Codex TUIs using the same Penny key.
|
|
890
|
+
env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
|
|
873
891
|
return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
|
|
874
892
|
}
|
|
875
893
|
if (harness === "opencode") {
|
|
894
|
+
env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
|
|
876
895
|
return { command: "opencode", args, env };
|
|
877
896
|
}
|
|
878
897
|
throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
|
|
@@ -956,6 +975,8 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
956
975
|
? startPennyTerminalTitle({
|
|
957
976
|
gatewayBaseUrl: config.gateway_base_url,
|
|
958
977
|
apiKey: config.api_key,
|
|
978
|
+
titleSessionId: invocation.env.PENNYROUTER_TITLE_SESSION_ID,
|
|
979
|
+
titleSessionFile: invocation.env.PENNYROUTER_TITLE_SESSION_FILE,
|
|
959
980
|
})
|
|
960
981
|
: () => {};
|
|
961
982
|
const result = await waitForChild(child);
|
package/src/launch.test.js
CHANGED
|
@@ -183,8 +183,10 @@ async function main() {
|
|
|
183
183
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
184
184
|
fs.rmSync(desktopHome, { recursive: true, force: true });
|
|
185
185
|
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
186
|
-
assert.ok(codexProfile.includes('
|
|
186
|
+
assert.ok(codexProfile.includes('"x-pennyrouter-key" = "PENNYROUTER_API_KEY"'));
|
|
187
187
|
assert.ok(codexProfile.includes("[tui]\nterminal_title = []"));
|
|
188
|
+
assert.ok(codexProfile.includes('"x-pennyrouter-title-session" = "PENNYROUTER_TITLE_SESSION_ID"'));
|
|
189
|
+
assert.ok(codexProfile.includes('"x-pennyrouter-harness" = "codex"'));
|
|
188
190
|
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
189
191
|
assert.ok(!rc.includes("pr-super-secret"));
|
|
190
192
|
|
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");
|