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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -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(text, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
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
- : [`experimental_bearer_token = ${escapedKey}`];
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('http_headers = { "x-pennyrouter-key" = "pr-test-secret" }'));
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
- : ['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", "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: { "x-api-key": apiKey },
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
- const stopTerminalTitle = ["claude", "codex", "opencode"].includes(harness)
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
- cwd: process.cwd(),
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
- cwd: process.cwd(),
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: process.cwd(),
1067
+ cwd: homeDir(),
1040
1068
  env: { ...process.env, CODEX_HOME: overlay },
1041
1069
  stdio: "inherit",
1042
1070
  shell: process.platform === "win32",
@@ -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, /http_headers = \{ "x-pennyrouter-key" = "pr-super-secret" \}/);
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('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
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");