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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -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
- : ['env_key = "PENNYROUTER_API_KEY"'];
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: { "x-api-key": apiKey },
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);
@@ -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('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
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");