pennyrouter 0.3.3 → 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/README.md CHANGED
@@ -40,7 +40,14 @@ directory to the user PATH:
40
40
 
41
41
  ```bash
42
42
  penny claude
43
+ penny claude desktop
44
+ penny claude desktop gateway
45
+ penny claude desktop native
46
+ penny claude desktop status
43
47
  penny codex
48
+ penny codex desktop
49
+ penny codex desktop gateway
50
+ penny codex desktop native
44
51
  penny opencode
45
52
  ```
46
53
 
@@ -51,6 +58,33 @@ completely native; their normal configuration files are not redirected. Choose t
51
58
  "make PennyRouter the default" option, or pass `--make-default`, to retain the original global
52
59
  routing behavior.
53
60
 
61
+ `penny codex desktop` launches Codex Desktop with a temporary `CODEX_HOME` overlay that selects
62
+ PennyRouter without changing the user's real Codex configuration. Auth, history, and other
63
+ Codex files remain linked to the normal home, and the overlay is removed when the app exits.
64
+ The command detaches by default and returns the shell prompt immediately; use
65
+ `penny codex desktop gateway --logs` to keep the desktop process and its logs attached to the
66
+ terminal. `penny codex desktop native` provides the symmetric explicit native launch used by the
67
+ PennyRouter Mac app; clicking the Codex icon directly remains native as well.
68
+ Launching Codex normally afterward therefore returns to the user's native provider.
69
+
70
+ `penny claude desktop` (or the explicit `gateway` subcommand) configures Claude Desktop's
71
+ official third-party Gateway provider and launches the app. `penny claude desktop native`
72
+ deactivates third-party inference for the next launch while preserving the Penny profile, and
73
+ `penny claude desktop status` reports the selected mode and whether the app is running. Claude
74
+ reads its provider once at startup, so Gateway and Native commands refuse to change modes until
75
+ Claude Desktop has been fully quit.
76
+
77
+ The integration does not intercept HTTPS traffic and does not modify terminal Claude Code's
78
+ `~/.claude/settings.json`. The generated Desktop profile contains no API key: its credential
79
+ helper reads the existing mode-0600 PennyRouter config when Claude requests a token. The profile
80
+ enables gateway model discovery, so the Desktop picker comes from PennyRouter's `/v1/models`
81
+ catalog rather than a launcher-maintained fixed list. Native Claude and third-party Claude keep
82
+ their own application data, so moving between them does not replace either mode's conversations.
83
+ PennyRouter also remembers the Desktop profile that was selected beforehand; use
84
+ `penny claude desktop --restore` for uninstall/recovery to remove the Penny profile and select
85
+ that previous third-party profile again. Launches detach by default; append `--logs` to a Gateway
86
+ or Native command to keep the app attached.
87
+
54
88
  Existing PennyRouter installs preserve their current bare-command behavior during upgrade unless
55
89
  the user explicitly changes it. Close running Claude Code or Codex sessions only when adding or
56
90
  removing global bare-command routing. A Penny-only install does not require closing native sessions.
package/bin/penny.js CHANGED
@@ -1,7 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { runPenny } from "../src/launch.js";
2
+ import {
3
+ runClaudeDesktopCredentialHelper,
4
+ runCodexDesktopSupervisor,
5
+ runPenny,
6
+ } from "../src/launch.js";
3
7
 
4
- runPenny().then((code) => {
8
+ const args = process.argv.slice(2);
9
+ const run = args[0] === "__codex-desktop-supervisor"
10
+ ? runCodexDesktopSupervisor(args[1])
11
+ : args[0] === "__claude-desktop-credential-helper"
12
+ ? runClaudeDesktopCredentialHelper()
13
+ : runPenny();
14
+
15
+ run.then((code) => {
5
16
  process.exitCode = code;
6
17
  }).catch((error) => {
7
18
  console.error(error?.message || String(error));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.3",
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": {
package/src/cli.js CHANGED
@@ -1397,7 +1397,7 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1397
1397
  + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
1398
1398
  );
1399
1399
  }
1400
- console.log("Claude Code needs a user-funded provider. PennyRouter will never fall back to its own API keys.");
1400
+ console.log("Claude Code needs a user-funded provider.");
1401
1401
  anthropicApiKey = (await promptSecretish(
1402
1402
  "Anthropic API key (leave blank to use OpenRouter): ",
1403
1403
  )).trim();
@@ -1418,7 +1418,6 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
1418
1418
  );
1419
1419
  }
1420
1420
  console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
1421
- console.log("PennyRouter will never fall back to its own API keys.");
1422
1421
  openaiApiKey = (await promptSecretish(
1423
1422
  "OpenAI API key (leave blank to use OpenRouter): ",
1424
1423
  )).trim();
@@ -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) => [
@@ -53,7 +53,7 @@
53
53
  },
54
54
  {
55
55
  "id": "anthropic-proxy",
56
- "name": "Other proxy (custom base URL)",
56
+ "name": "Proxy (e.g., Bedrock, Azure, private server)",
57
57
  "fields": [
58
58
  {
59
59
  "config_key": "proxyBaseUrl",
@@ -82,9 +82,16 @@
82
82
  { "config_key": "openaiApiKey", "label": "OpenAI API key", "secret": true }
83
83
  ]
84
84
  },
85
+ {
86
+ "id": "openrouter",
87
+ "name": "OpenRouter API key",
88
+ "fields": [
89
+ { "config_key": "openrouterApiKey", "label": "OpenRouter API key", "secret": true }
90
+ ]
91
+ },
85
92
  {
86
93
  "id": "openai-proxy",
87
- "name": "Other OpenAI-compatible proxy",
94
+ "name": "Proxy (e.g., Bedrock, Azure, private server)",
88
95
  "fields": [
89
96
  {
90
97
  "config_key": "openaiProxyBaseUrl",
@@ -94,20 +101,13 @@
94
101
  },
95
102
  { "config_key": "openaiProxyApiKey", "label": "OpenAI proxy API key", "secret": true }
96
103
  ]
97
- },
98
- {
99
- "id": "openrouter",
100
- "name": "OpenRouter API key",
101
- "fields": [
102
- { "config_key": "openrouterApiKey", "label": "OpenRouter API key", "secret": true }
103
- ]
104
104
  }
105
105
  ]
106
106
  }
107
107
  },
108
108
  "advanced": {
109
- "title": "Private gateway",
110
- "description": "Route through your own PennyRouter gateway instead of the hosted one.",
109
+ "title": "Private PennyRouter gateway",
110
+ "description": "Route through a private PennyRouter gateway if supplied by your IT team.",
111
111
  "fields": [
112
112
  {
113
113
  "config_key": "gatewayBaseUrl",
package/src/launch.js CHANGED
@@ -2,18 +2,28 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import {
3
3
  chmod,
4
4
  cp,
5
+ mkdtemp,
5
6
  mkdir,
7
+ readFile,
6
8
  rm,
9
+ readdir,
7
10
  stat,
11
+ symlink,
8
12
  unlink,
9
13
  writeFile,
10
14
  } from "node:fs/promises";
11
15
  import { constants as osConstants } from "node:os";
16
+ import { tmpdir } from "node:os";
12
17
  import { fileURLToPath } from "node:url";
13
18
  import { basename, dirname, join, resolve } from "node:path";
19
+ import { randomUUID } from "node:crypto";
14
20
 
15
21
  import { configureClaudeCodeStatusline } from "./statusline.js";
16
- import { upgradeClaudeCodeModelValue, writeClaudeCodeModelPresentation } from "./harnesses/claude-code.js";
22
+ import {
23
+ upgradeClaudeCodeModelValue,
24
+ writeClaudeCodeModelPresentation,
25
+ } from "./harnesses/claude-code.js";
26
+ import { renderCodexConfig } from "./harnesses/codex.js";
17
27
  import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
18
28
 
19
29
  const CONFIG_VERSION = 1;
@@ -21,6 +31,10 @@ const PATH_START = "# >>> pennyrouter PATH >>>";
21
31
  const PATH_END = "# <<< pennyrouter PATH <<<";
22
32
  const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
23
33
  const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
34
+ // A stable UUID keeps upgrades idempotent and avoids creating a new visible profile on
35
+ // every launch. It is deliberately not the user's hand-authored profile id.
36
+ const CLAUDE_DESKTOP_PROFILE_ID = "70656e6e-7972-4f75-8465-720000000001";
37
+ const CLAUDE_DESKTOP_PROFILE_NAME = "PennyRouter";
24
38
  const TERMINAL_TITLE_REFRESH_MS = 1000;
25
39
  const TERMINAL_STATUS_REFRESH_MS = 15000;
26
40
 
@@ -59,6 +73,31 @@ export function claudeLaunchSettingsPath() {
59
73
  return join(pennyConfigDir(), "claude-settings.json");
60
74
  }
61
75
 
76
+ export function claudeDesktopConfigLibraryPath() {
77
+ if (process.platform === "win32") {
78
+ return join(
79
+ process.env.LOCALAPPDATA || process.env.APPDATA || homeDir(),
80
+ "Claude-3p",
81
+ "configLibrary",
82
+ );
83
+ }
84
+ if (process.platform === "darwin") {
85
+ return join(homeDir(), "Library", "Application Support", "Claude-3p", "configLibrary");
86
+ }
87
+ return join(
88
+ process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"),
89
+ "Claude-3p",
90
+ "configLibrary",
91
+ );
92
+ }
93
+
94
+ export function claudeDesktopCredentialHelperPath() {
95
+ return join(
96
+ pennyConfigDir(),
97
+ process.platform === "win32" ? "claude-desktop-credential.cmd" : "claude-desktop-credential",
98
+ );
99
+ }
100
+
62
101
  export function codexLaunchProfilePath() {
63
102
  const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
64
103
  return join(codexHome, "pennyrouter.config.toml");
@@ -78,6 +117,11 @@ async function privateWrite(path, text) {
78
117
  await chmod(path, 0o600);
79
118
  }
80
119
 
120
+ async function executableWrite(path, text) {
121
+ await privateWrite(path, text);
122
+ if (process.platform !== "win32") await chmod(path, 0o700);
123
+ }
124
+
81
125
  export async function loadPennyConfig() {
82
126
  return (await readJsonFile(pennyConfigPath(), null)) || {
83
127
  version: CONFIG_VERSION,
@@ -251,9 +295,12 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
251
295
  const authLines = subscriptionAuth
252
296
  ? [
253
297
  "requires_openai_auth = true",
254
- '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" }',
255
299
  ]
256
- : ['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
+ ];
257
304
  return [
258
305
  CODEX_PROFILE_MARKER,
259
306
  'model_provider = "pennyrouter"',
@@ -274,6 +321,258 @@ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
274
321
  ].join("\n");
275
322
  }
276
323
 
324
+ export function codexDesktopExecutable() {
325
+ if (process.platform === "darwin") return "/Applications/Codex.app/Contents/MacOS/Codex";
326
+ if (process.platform === "win32") return "Codex.exe";
327
+ return "codex";
328
+ }
329
+
330
+ export function claudeDesktopExecutable() {
331
+ if (process.platform === "darwin") return "/Applications/Claude.app/Contents/MacOS/Claude";
332
+ if (process.platform === "win32") return "Claude.exe";
333
+ return "claude-desktop";
334
+ }
335
+
336
+ function shellQuote(value) {
337
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
338
+ }
339
+
340
+ function renderClaudeDesktopCredentialHelper() {
341
+ const entry = join(pennyRuntimeDir(), "bin", "penny.js");
342
+ if (process.platform === "win32") {
343
+ return `@echo off\r\n"${process.execPath.replaceAll('"', '""')}" "${entry.replaceAll('"', '""')}" __claude-desktop-credential-helper\r\n`;
344
+ }
345
+ return `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(entry)} __claude-desktop-credential-helper\n`;
346
+ }
347
+
348
+ /**
349
+ * Apply a dedicated local Claude Desktop 3P profile without touching Claude Code's
350
+ * ~/.claude settings. The profile contains no credential: Claude invokes the helper,
351
+ * which reads the existing mode-0600 Penny config at request time.
352
+ */
353
+ export async function configureClaudeDesktopProfile(config) {
354
+ if (!config.api_key) {
355
+ throw new Error("No PennyRouter key found. Run `npx pennyrouter install` first.");
356
+ }
357
+ if (!config.gateway_base_url) {
358
+ throw new Error("No PennyRouter gateway configured. Run `npx pennyrouter install` first.");
359
+ }
360
+
361
+ const library = claudeDesktopConfigLibraryPath();
362
+ const metaPath = join(library, "_meta.json");
363
+ const profilePath = join(library, `${CLAUDE_DESKTOP_PROFILE_ID}.json`);
364
+ const helperPath = claudeDesktopCredentialHelperPath();
365
+ const meta = (await readJsonFile(metaPath, null)) || { entries: [] };
366
+ const entries = Array.isArray(meta.entries) ? meta.entries : [];
367
+ const priorState = config.claude_desktop || {};
368
+ const previousAppliedId = meta.appliedId !== CLAUDE_DESKTOP_PROFILE_ID
369
+ ? (meta.appliedId || priorState.previous_applied_id || null)
370
+ : (priorState.previous_applied_id || null);
371
+
372
+ await executableWrite(helperPath, renderClaudeDesktopCredentialHelper());
373
+ const gatewayBaseUrl = String(config.gateway_base_url)
374
+ .replace(/\/+$/, "")
375
+ .replace(/\/v1$/, "");
376
+ const profile = {
377
+ inferenceProvider: "gateway",
378
+ inferenceGatewayBaseUrl: gatewayBaseUrl,
379
+ inferenceGatewayAuthScheme: "x-api-key",
380
+ inferenceCredentialKind: "helper-script",
381
+ inferenceCredentialHelper: helperPath,
382
+ inferenceCredentialHelperTtlSec: 60,
383
+ // Let Desktop populate its picker from PennyRouter's Anthropic-compatible
384
+ // /v1/models response. Do not also set inferenceModels: that key is an explicit
385
+ // override and would silently suppress discovery.
386
+ modelDiscoveryEnabled: true,
387
+ };
388
+ await privateWrite(profilePath, `${JSON.stringify(profile, null, 2)}\n`);
389
+ const nextEntries = entries.filter((entry) => entry?.id !== CLAUDE_DESKTOP_PROFILE_ID);
390
+ nextEntries.push({ id: CLAUDE_DESKTOP_PROFILE_ID, name: CLAUDE_DESKTOP_PROFILE_NAME });
391
+ await privateWrite(metaPath, `${JSON.stringify({
392
+ ...meta,
393
+ appliedId: CLAUDE_DESKTOP_PROFILE_ID,
394
+ entries: nextEntries,
395
+ }, null, 2)}\n`);
396
+
397
+ config.claude_desktop = {
398
+ managed_profile_id: CLAUDE_DESKTOP_PROFILE_ID,
399
+ previous_applied_id: previousAppliedId,
400
+ };
401
+ await savePennyConfig(config);
402
+ return { helperPath, library, profilePath, previousAppliedId };
403
+ }
404
+
405
+ /**
406
+ * Select standard, first-party Claude Desktop without deleting PennyRouter's saved
407
+ * third-party profile. Claude chooses its data directory and provider once at startup;
408
+ * an absent local appliedId is the standard/native selection, while the Penny profile
409
+ * remains available for the next explicit gateway launch.
410
+ */
411
+ export async function selectClaudeDesktopNativeMode() {
412
+ const library = claudeDesktopConfigLibraryPath();
413
+ const metaPath = join(library, "_meta.json");
414
+ const meta = await readJsonFile(metaPath, null);
415
+ if (!meta || !Object.hasOwn(meta, "appliedId")) return;
416
+ const next = { ...meta };
417
+ delete next.appliedId;
418
+ await privateWrite(metaPath, `${JSON.stringify(next, null, 2)}\n`);
419
+ }
420
+
421
+ /** Read the local Claude Desktop mode selected for the next app launch. */
422
+ export async function readClaudeDesktopMode() {
423
+ const library = claudeDesktopConfigLibraryPath();
424
+ const meta = await readJsonFile(join(library, "_meta.json"), null);
425
+ const appliedId = meta?.appliedId || null;
426
+ const entries = Array.isArray(meta?.entries) ? meta.entries : [];
427
+ const entry = entries.find((candidate) => candidate?.id === appliedId);
428
+ if (!appliedId) {
429
+ return { mode: "native", label: "Native Claude", profileId: null, profileName: null };
430
+ }
431
+ if (appliedId === CLAUDE_DESKTOP_PROFILE_ID) {
432
+ return {
433
+ mode: "gateway",
434
+ label: "PennyRouter Gateway",
435
+ profileId: appliedId,
436
+ profileName: CLAUDE_DESKTOP_PROFILE_NAME,
437
+ };
438
+ }
439
+ return {
440
+ mode: "third-party",
441
+ label: entry?.name ? `Third-party: ${entry.name}` : "Third-party inference",
442
+ profileId: appliedId,
443
+ profileName: entry?.name || null,
444
+ };
445
+ }
446
+
447
+ /**
448
+ * Claude is a single-instance app and reads provider configuration only at launch.
449
+ * Refuse mode changes while its main process is alive so an on-disk selection never
450
+ * disagrees with the mode the visible app is actually using.
451
+ */
452
+ export function isClaudeDesktopRunning(run = spawnSync) {
453
+ return isDesktopProcessRunning(
454
+ process.platform === "darwin" ? ["Claude"] : ["claude-desktop", "Claude"],
455
+ "Claude.exe",
456
+ run,
457
+ );
458
+ }
459
+
460
+ export function isCodexDesktopRunning(run = spawnSync) {
461
+ return isDesktopProcessRunning(
462
+ process.platform === "darwin" ? ["Codex"] : ["codex-desktop", "Codex"],
463
+ "Codex.exe",
464
+ run,
465
+ );
466
+ }
467
+
468
+ function isDesktopProcessRunning(names, windowsImageName, run) {
469
+ if (process.platform === "win32") {
470
+ const result = run("tasklist", ["/FI", `IMAGENAME eq ${windowsImageName}`, "/NH"], {
471
+ encoding: "utf8",
472
+ windowsHide: true,
473
+ });
474
+ return result.status === 0 && (result.stdout || "").toLowerCase()
475
+ .includes(windowsImageName.toLowerCase());
476
+ }
477
+ return names.some((name) => run("pgrep", ["-x", name], {
478
+ encoding: "utf8",
479
+ stdio: "ignore",
480
+ }).status === 0);
481
+ }
482
+
483
+ export async function restoreClaudeDesktopProfile() {
484
+ const config = await loadPennyConfig();
485
+ const state = config.claude_desktop;
486
+ const library = claudeDesktopConfigLibraryPath();
487
+ const metaPath = join(library, "_meta.json");
488
+ const profilePath = join(library, `${CLAUDE_DESKTOP_PROFILE_ID}.json`);
489
+ const meta = await readJsonFile(metaPath, null);
490
+
491
+ if (meta) {
492
+ const entries = (Array.isArray(meta.entries) ? meta.entries : [])
493
+ .filter((entry) => entry?.id !== CLAUDE_DESKTOP_PROFILE_ID);
494
+ const next = { ...meta, entries };
495
+ if (meta.appliedId === CLAUDE_DESKTOP_PROFILE_ID) {
496
+ const previous = state?.previous_applied_id;
497
+ const previousExists = previous
498
+ && entries.some((entry) => entry?.id === previous)
499
+ && await readText(join(library, `${previous}.json`), "");
500
+ if (previousExists) next.appliedId = previous;
501
+ else delete next.appliedId;
502
+ }
503
+ await privateWrite(metaPath, `${JSON.stringify(next, null, 2)}\n`);
504
+ }
505
+ await unlink(profilePath).catch((error) => {
506
+ if (error.code !== "ENOENT") throw error;
507
+ });
508
+ await unlink(claudeDesktopCredentialHelperPath()).catch((error) => {
509
+ if (error.code !== "ENOENT") throw error;
510
+ });
511
+ if (state) {
512
+ delete config.claude_desktop;
513
+ await savePennyConfig(config);
514
+ }
515
+ }
516
+
517
+ export async function readClaudeDesktopCredential() {
518
+ const config = await loadPennyConfig();
519
+ if (!config.api_key) throw new Error("PennyRouter credential is not configured.");
520
+ return config.api_key;
521
+ }
522
+
523
+ export async function runClaudeDesktopCredentialHelper() {
524
+ process.stdout.write(await readClaudeDesktopCredential());
525
+ return 0;
526
+ }
527
+
528
+ /**
529
+ * Build a disposable Codex home for the desktop app. Codex Desktop does not accept the
530
+ * CLI's --profile flag, so its default provider has to be selected in config.toml. Putting
531
+ * that config in an overlay keeps the user's real Codex mode native after the app exits,
532
+ * including after a crash or force-quit.
533
+ */
534
+ export async function createCodexDesktopHome({ config, realHome }) {
535
+ const overlay = await mkdtemp(join(tmpdir(), "penny-codex-desktop-"));
536
+ try {
537
+ for (const entry of await readdir(realHome)) {
538
+ if (entry === "config.toml") continue;
539
+ await symlink(join(realHome, entry), join(overlay, entry));
540
+ }
541
+ if (!config.api_key) throw new Error("No PennyRouter key found. Run `npx pennyrouter install` first.");
542
+ if (!config.gateway_base_url) throw new Error("No PennyRouter gateway configured. Run `npx pennyrouter install` first.");
543
+ const nativeConfig = await readText(join(realHome, "config.toml"), "");
544
+ const desktopConfig = setCodexDesktopDefault(renderCodexConfig(removeCodexPennyProvider(nativeConfig), {
545
+ apiKey: config.api_key,
546
+ gatewayBaseUrl: config.gateway_base_url,
547
+ subscriptionAuth: Boolean(config.harnesses?.codex?.subscription_auth),
548
+ }));
549
+ await privateWrite(join(overlay, "config.toml"), desktopConfig);
550
+ return overlay;
551
+ } catch (error) {
552
+ await rm(overlay, { recursive: true, force: true });
553
+ throw error;
554
+ }
555
+ }
556
+
557
+ function setCodexDesktopDefault(text) {
558
+ const lines = String(text).split("\n");
559
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
560
+ const rootEnd = firstTable < 0 ? lines.length : firstTable;
561
+ const root = lines.slice(0, rootEnd).filter((line) => !/^\s*model_provider\s*=/.test(line));
562
+ root.unshift('model_provider = "pennyrouter"');
563
+ return [...root, ...lines.slice(rootEnd)].join("\n");
564
+ }
565
+
566
+ function removeCodexPennyProvider(text) {
567
+ const lines = String(text).split("\n");
568
+ const start = lines.findIndex((line) => /^\s*\[model_providers\.pennyrouter\]\s*$/.test(line));
569
+ if (start < 0) return String(text);
570
+ let end = start + 1;
571
+ while (end < lines.length && !/^\s*\[/.test(lines[end])) end += 1;
572
+ lines.splice(start, end - start);
573
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n");
574
+ }
575
+
277
576
  function terminalTitle(text) {
278
577
  // OSC titles are interpreted by the terminal, so remove control characters and
279
578
  // the OSC terminators rather than allowing gateway-provided text into the escape.
@@ -290,14 +589,15 @@ function renderPennyTerminalTitle(status) {
290
589
  if (status?.model_display_name) parts.push(status.model_display_name);
291
590
  else if (status?.model) parts.push(status.model);
292
591
  if (status?.auth_mode) parts.push(`(${status.auth_mode})`);
293
- parts.push(renderCacheWarmth(status));
592
+ const warmth = renderCacheWarmth(status);
593
+ if (warmth) parts.push(warmth);
294
594
  return parts.join(" · ");
295
595
  }
296
596
 
297
597
  function renderCacheWarmth(status) {
298
598
  const expiresAt = status?.cache_expires_at;
299
599
  if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
300
- return "❄️ cold";
600
+ return "";
301
601
  }
302
602
  const remaining = Math.floor(expiresAt - Date.now() / 1000);
303
603
  if (remaining <= 0) return "❄️ cold";
@@ -311,19 +611,27 @@ function renderCacheWarmth(status) {
311
611
  * This is deliberately local and best-effort: failure to poll the gateway must never
312
612
  * affect Codex, and non-TTY invocations (CI, pipes) receive no escape sequences.
313
613
  */
314
- export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey }) {
614
+ export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey, titleSessionId,
615
+ titleSessionFile }) {
315
616
  if (!process.stdout.isTTY) return () => {};
316
617
 
317
618
  let status = null;
318
619
  let stopped = false;
319
620
  let statusInFlight = false;
621
+ let resolvedTitleSessionId = titleSessionId || "";
320
622
 
321
623
  const refreshStatus = async () => {
322
624
  if (stopped || statusInFlight || !gatewayBaseUrl || !apiKey) return;
323
625
  statusInFlight = true;
324
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;
325
633
  const response = await fetch(`${String(gatewayBaseUrl).replace(/\/$/, "")}/v1/penny/statusline`, {
326
- headers: { "x-api-key": apiKey },
634
+ headers,
327
635
  signal: AbortSignal.timeout(4000),
328
636
  });
329
637
  if (response.ok) status = await response.json();
@@ -498,6 +806,9 @@ function removeWindowsPathEntry() {
498
806
  }
499
807
 
500
808
  export async function removePennyRuntime() {
809
+ // The Desktop profile's credential helper lives in the Penny runtime. Restore the
810
+ // previously selected Claude Desktop profile before removing that executable.
811
+ await restoreClaudeDesktopProfile();
501
812
  const config = await loadPennyConfig();
502
813
  if (process.platform === "win32") {
503
814
  if (config.windows_path_managed) removeWindowsPathEntry();
@@ -547,6 +858,7 @@ export function buildPennyInvocation(harness, args, config) {
547
858
  assertNoConflictingArgs(harness, args);
548
859
  const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
549
860
  if (harness === "claude") {
861
+ env.PENNYROUTER_TITLE_SESSION_FILE = join(pennyDataDir(), `title-session-${randomUUID()}`);
550
862
  const models = item.models || {};
551
863
  env.ANTHROPIC_BASE_URL = config.gateway_base_url;
552
864
  env.ANTHROPIC_AUTH_TOKEN = config.api_key;
@@ -572,9 +884,14 @@ export function buildPennyInvocation(harness, args, config) {
572
884
  };
573
885
  }
574
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();
575
891
  return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
576
892
  }
577
893
  if (harness === "opencode") {
894
+ env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
578
895
  return { command: "opencode", args, env };
579
896
  }
580
897
  throw new Error(`Unknown Penny harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
@@ -597,6 +914,56 @@ export async function runPenny(argv = process.argv.slice(2)) {
597
914
  throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
598
915
  }
599
916
  const config = await loadPennyConfig();
917
+ if (harness === "claude" && argv[1]?.toLowerCase() === "desktop") {
918
+ const option = argv[2]?.toLowerCase();
919
+ if (option === "--restore" || option === "restore") {
920
+ if (argv.length > 3) {
921
+ throw new Error("Usage: penny claude desktop [gateway|native|status|--restore]");
922
+ }
923
+ await restoreClaudeDesktopProfile();
924
+ console.log("PennyRouter's Claude Desktop profile was removed and the previous profile restored.");
925
+ console.log("Fully quit and reopen Claude Desktop for the restored profile to take effect.");
926
+ return 0;
927
+ }
928
+ if (option === "status" || option === "--status") {
929
+ const json = argv[3]?.toLowerCase() === "--json";
930
+ if (argv.length > (json ? 4 : 3)) {
931
+ throw new Error("Usage: penny claude desktop status [--json]");
932
+ }
933
+ const status = { ...(await readClaudeDesktopMode()), running: isClaudeDesktopRunning() };
934
+ if (json) console.log(JSON.stringify(status));
935
+ else {
936
+ console.log(`Claude Desktop mode: ${status.label}`);
937
+ console.log(`Claude Desktop is ${status.running ? "running" : "not running"}.`);
938
+ }
939
+ return 0;
940
+ }
941
+ const mode = option === "native" || option === "--native" ? "native" : "gateway";
942
+ const legacyLogs = option === "--logs" || option === "logs";
943
+ const modeLogs = argv[3]?.toLowerCase() === "--logs" || argv[3]?.toLowerCase() === "logs";
944
+ const logs = legacyLogs || modeLogs;
945
+ const validOption = option === undefined || legacyLogs
946
+ || ["gateway", "--gateway", "native", "--native"].includes(option);
947
+ if (!validOption || argv.length > (modeLogs ? 4 : (legacyLogs ? 3 : (option ? 3 : 2)))) {
948
+ throw new Error(
949
+ "Usage: penny claude desktop [gateway|native|status] [--logs|--json]",
950
+ );
951
+ }
952
+ return runClaudeDesktop(config, { logs, mode });
953
+ }
954
+ if (harness === "codex" && argv[1]?.toLowerCase() === "desktop") {
955
+ const option = argv[2]?.toLowerCase();
956
+ const mode = option === "native" || option === "--native" ? "native" : "gateway";
957
+ const legacyLogs = option === "--logs" || option === "logs";
958
+ const modeLogs = argv[3]?.toLowerCase() === "--logs" || argv[3]?.toLowerCase() === "logs";
959
+ const logs = legacyLogs || modeLogs;
960
+ const validOption = option === undefined || legacyLogs
961
+ || ["gateway", "--gateway", "native", "--native"].includes(option);
962
+ if (!validOption || argv.length > (modeLogs ? 4 : (legacyLogs ? 3 : (option ? 3 : 2)))) {
963
+ throw new Error("Usage: penny codex desktop [gateway|native] [--logs]");
964
+ }
965
+ return runCodexDesktop(config, { logs, mode });
966
+ }
600
967
  const invocation = buildPennyInvocation(harness, argv.slice(1), config);
601
968
  const child = spawn(invocation.command, invocation.args, {
602
969
  cwd: process.cwd(),
@@ -604,10 +971,12 @@ export async function runPenny(argv = process.argv.slice(2)) {
604
971
  stdio: "inherit",
605
972
  shell: process.platform === "win32",
606
973
  });
607
- const stopTerminalTitle = ["codex", "opencode"].includes(harness)
974
+ const stopTerminalTitle = ["claude", "codex", "opencode"].includes(harness)
608
975
  ? startPennyTerminalTitle({
609
976
  gatewayBaseUrl: config.gateway_base_url,
610
977
  apiKey: config.api_key,
978
+ titleSessionId: invocation.env.PENNYROUTER_TITLE_SESSION_ID,
979
+ titleSessionFile: invocation.env.PENNYROUTER_TITLE_SESSION_FILE,
611
980
  })
612
981
  : () => {};
613
982
  const result = await waitForChild(child);
@@ -616,6 +985,92 @@ export async function runPenny(argv = process.argv.slice(2)) {
616
985
  return result.code ?? 1;
617
986
  }
618
987
 
988
+ async function runClaudeDesktop(config, { logs = false, mode = "gateway" } = {}) {
989
+ if (isClaudeDesktopRunning()) {
990
+ throw new Error(
991
+ `Claude Desktop is already running. Fully quit it before switching to ${mode} mode.`,
992
+ );
993
+ }
994
+ if (mode === "native") await selectClaudeDesktopNativeMode();
995
+ else await configureClaudeDesktopProfile(config);
996
+ const child = spawn(claudeDesktopExecutable(), [], {
997
+ cwd: process.cwd(),
998
+ env: process.env,
999
+ stdio: logs ? "inherit" : "ignore",
1000
+ detached: !logs,
1001
+ shell: process.platform === "win32",
1002
+ });
1003
+ if (!logs) {
1004
+ child.unref();
1005
+ console.log(mode === "native"
1006
+ ? "Claude Desktop launched in native mode (detached)."
1007
+ : "Claude Desktop launched with PennyRouter Gateway (detached).");
1008
+ console.log("Use `penny claude desktop status` to see the selected mode.");
1009
+ return 0;
1010
+ }
1011
+ const result = await waitForChild(child);
1012
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
1013
+ return result.code ?? 1;
1014
+ }
1015
+
1016
+ async function runCodexDesktop(config, { logs = false, mode = "gateway" } = {}) {
1017
+ if (isCodexDesktopRunning()) {
1018
+ throw new Error(`Codex Desktop is already running. Fully quit it before launching ${mode} mode.`);
1019
+ }
1020
+ if (mode === "native") {
1021
+ const child = spawn(codexDesktopExecutable(), [], {
1022
+ cwd: process.cwd(),
1023
+ env: process.env,
1024
+ stdio: logs ? "inherit" : "ignore",
1025
+ detached: !logs,
1026
+ shell: process.platform === "win32",
1027
+ });
1028
+ if (!logs) {
1029
+ child.unref();
1030
+ console.log("Codex Desktop launched in native mode (detached).");
1031
+ return 0;
1032
+ }
1033
+ const result = await waitForChild(child);
1034
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
1035
+ return result.code ?? 1;
1036
+ }
1037
+ const realHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
1038
+ const overlay = await createCodexDesktopHome({ config, realHome });
1039
+ if (!logs) {
1040
+ const supervisor = spawn(process.execPath, [
1041
+ fileURLToPath(new URL("../bin/penny.js", import.meta.url)),
1042
+ "__codex-desktop-supervisor",
1043
+ overlay,
1044
+ ], {
1045
+ cwd: process.cwd(),
1046
+ env: process.env,
1047
+ stdio: "ignore",
1048
+ detached: true,
1049
+ });
1050
+ supervisor.unref();
1051
+ console.log("Codex Desktop launched with PennyRouter (detached). Use `penny codex desktop gateway --logs` to keep logs in this terminal.");
1052
+ return 0;
1053
+ }
1054
+
1055
+ return runCodexDesktopSupervisor(overlay);
1056
+ }
1057
+
1058
+ export async function runCodexDesktopSupervisor(overlay) {
1059
+ const child = spawn(codexDesktopExecutable(), [], {
1060
+ cwd: process.cwd(),
1061
+ env: { ...process.env, CODEX_HOME: overlay },
1062
+ stdio: "inherit",
1063
+ shell: process.platform === "win32",
1064
+ });
1065
+ try {
1066
+ const result = await waitForChild(child);
1067
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
1068
+ return result.code ?? 1;
1069
+ } finally {
1070
+ await rm(overlay, { recursive: true, force: true });
1071
+ }
1072
+ }
1073
+
619
1074
  export async function fileMode(path) {
620
1075
  return (await stat(path)).mode & 0o777;
621
1076
  }
@@ -1,22 +1,33 @@
1
1
  import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
 
6
7
  import {
7
8
  buildPennyInvocation,
9
+ claudeDesktopConfigLibraryPath,
10
+ claudeDesktopCredentialHelperPath,
8
11
  claudeLaunchSettingsPath,
9
12
  claudeUserSkillPath,
10
13
  codexLaunchProfilePath,
14
+ configureClaudeDesktopProfile,
15
+ createCodexDesktopHome,
11
16
  configurePennyHarness,
12
17
  fileMode,
13
18
  installPennyRuntime,
19
+ isClaudeDesktopRunning,
20
+ isCodexDesktopRunning,
14
21
  loadPennyConfig,
15
22
  pennyConfigPath,
16
23
  pennyRuntimeDir,
17
24
  pennyShimPath,
25
+ readClaudeDesktopCredential,
26
+ readClaudeDesktopMode,
18
27
  removePennyHarness,
19
28
  removePennyRuntime,
29
+ restoreClaudeDesktopProfile,
30
+ selectClaudeDesktopNativeMode,
20
31
  renderCodexLaunchProfile,
21
32
  } from "./launch.js";
22
33
  import { claudeCodeHarness } from "./harnesses/claude-code.js";
@@ -40,7 +51,7 @@ async function main() {
40
51
  permissions: { allow: ["Read"] },
41
52
  statusLine: { type: "command", command: "native-status" },
42
53
  }, null, 2)}\n`;
43
- const nativeCodex = 'model = "native-model"\n';
54
+ const nativeCodex = 'model = "native-model"\n\nmodel_provider = "pennyrouter"\n\n[model_providers.pennyrouter]\nname = "PennyRouter"\nbase_url = "http://localhost:8400/v1"\n';
44
55
  fs.writeFileSync(path.join(home, ".claude", "settings.json"), nativeClaude);
45
56
  fs.writeFileSync(path.join(home, ".codex", "config.toml"), nativeCodex);
46
57
  fs.writeFileSync(path.join(home, ".zshrc"), "# user setting\n");
@@ -56,6 +67,21 @@ async function main() {
56
67
  });
57
68
 
58
69
  try {
70
+ const desktopLibrary = claudeDesktopConfigLibraryPath();
71
+ const nativeDesktopId = "native-profile";
72
+ fs.mkdirSync(desktopLibrary, { recursive: true });
73
+ fs.writeFileSync(
74
+ path.join(desktopLibrary, `${nativeDesktopId}.json`),
75
+ `${JSON.stringify({ inferenceProvider: "anthropic" }, null, 2)}\n`,
76
+ );
77
+ fs.writeFileSync(
78
+ path.join(desktopLibrary, "_meta.json"),
79
+ `${JSON.stringify({
80
+ appliedId: nativeDesktopId,
81
+ entries: [{ id: nativeDesktopId, name: "Native" }],
82
+ }, null, 2)}\n`,
83
+ );
84
+
59
85
  const first = await installPennyRuntime();
60
86
  const staleSkill = path.join(pennyRuntimeDir(), "claude-plugin", "skills", "penny", "SKILL.md");
61
87
  fs.mkdirSync(path.dirname(staleSkill), { recursive: true });
@@ -97,9 +123,70 @@ async function main() {
97
123
  const config = await loadPennyConfig();
98
124
  assert.equal(config.api_key, "pr-super-secret");
99
125
  assert.equal(config.harnesses.opencode.enabled, true);
126
+ const desktop = await configureClaudeDesktopProfile(config);
127
+ const desktopProfile = JSON.parse(fs.readFileSync(desktop.profilePath, "utf8"));
128
+ const desktopMeta = JSON.parse(
129
+ fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
130
+ );
131
+ assert.equal(desktopProfile.inferenceProvider, "gateway");
132
+ assert.equal(desktopProfile.inferenceGatewayBaseUrl, "https://gateway.example");
133
+ assert.equal(desktopProfile.inferenceCredentialKind, "helper-script");
134
+ assert.equal(desktopProfile.inferenceGatewayApiKey, undefined);
135
+ assert.equal(desktopProfile.modelDiscoveryEnabled, true);
136
+ assert.equal(desktopProfile.inferenceModels, undefined);
137
+ assert.equal(desktopMeta.appliedId, "70656e6e-7972-4f75-8465-720000000001");
138
+ assert.equal((await loadPennyConfig()).claude_desktop.previous_applied_id, nativeDesktopId);
139
+ assert.equal(await readClaudeDesktopCredential(), "pr-super-secret");
140
+ assert.equal(await fileMode(claudeDesktopCredentialHelperPath()), 0o700);
141
+ const helperResult = spawnSync(claudeDesktopCredentialHelperPath(), [], {
142
+ encoding: "utf8",
143
+ env: process.env,
144
+ });
145
+ assert.equal(helperResult.status, 0, helperResult.stderr);
146
+ assert.equal(helperResult.stdout, "pr-super-secret");
147
+ assert.equal((await readClaudeDesktopMode()).mode, "gateway");
148
+
149
+ await selectClaudeDesktopNativeMode();
150
+ const nativeModeMeta = JSON.parse(
151
+ fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
152
+ );
153
+ assert.equal(nativeModeMeta.appliedId, undefined);
154
+ assert.ok(fs.existsSync(desktop.profilePath), "native mode preserves the Penny profile");
155
+ assert.ok(fs.existsSync(claudeDesktopCredentialHelperPath()), "native mode preserves its helper");
156
+ assert.equal((await readClaudeDesktopMode()).mode, "native");
157
+
158
+ await configureClaudeDesktopProfile(await loadPennyConfig());
159
+ assert.equal((await readClaudeDesktopMode()).mode, "gateway");
160
+ assert.equal((await loadPennyConfig()).claude_desktop.previous_applied_id, nativeDesktopId);
161
+
162
+ assert.equal(isClaudeDesktopRunning(() => ({ status: 1, stdout: "" })), false);
163
+ assert.equal(isClaudeDesktopRunning(() => ({ status: 0, stdout: "Claude" })), true);
164
+ assert.equal(isCodexDesktopRunning(() => ({ status: 1, stdout: "" })), false);
165
+ assert.equal(isCodexDesktopRunning(() => ({ status: 0, stdout: "Codex" })), true);
166
+
167
+ await restoreClaudeDesktopProfile();
168
+ const restoredDesktopMeta = JSON.parse(
169
+ fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
170
+ );
171
+ assert.equal(restoredDesktopMeta.appliedId, nativeDesktopId);
172
+ assert.ok(!fs.existsSync(desktop.profilePath));
173
+ assert.ok(!fs.existsSync(claudeDesktopCredentialHelperPath()));
174
+ await configureClaudeDesktopProfile(await loadPennyConfig());
175
+ const desktopHome = await createCodexDesktopHome({
176
+ config,
177
+ realHome: path.join(home, ".codex"),
178
+ });
179
+ const desktopConfig = fs.readFileSync(path.join(desktopHome, "config.toml"), "utf8");
180
+ assert.match(desktopConfig, /^model_provider = "pennyrouter"/);
181
+ assert.match(desktopConfig, /\[model_providers\.pennyrouter\]/);
182
+ assert.match(desktopConfig, /http_headers = \{ "x-pennyrouter-key" = "pr-super-secret" \}/);
183
+ assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
184
+ fs.rmSync(desktopHome, { recursive: true, force: true });
100
185
  const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
101
- assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
186
+ assert.ok(codexProfile.includes('"x-pennyrouter-key" = "PENNYROUTER_API_KEY"'));
102
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"'));
103
190
  assert.ok(!codexProfile.includes("pr-super-secret"));
104
191
  assert.ok(!rc.includes("pr-super-secret"));
105
192
 
@@ -174,6 +261,13 @@ async function main() {
174
261
  assert.ok(!fs.existsSync(pennyShimPath()));
175
262
  assert.ok(!fs.existsSync(pennyRuntimeDir()));
176
263
  assert.ok(!fs.existsSync(pennyConfigPath()));
264
+ const uninstallDesktopMeta = JSON.parse(
265
+ fs.readFileSync(path.join(desktopLibrary, "_meta.json"), "utf8"),
266
+ );
267
+ assert.equal(uninstallDesktopMeta.appliedId, nativeDesktopId);
268
+ assert.ok(!uninstallDesktopMeta.entries.some(
269
+ (entry) => entry.id === "70656e6e-7972-4f75-8465-720000000001",
270
+ ));
177
271
  assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
178
272
  assert.equal(fs.readFileSync(path.join(home, ".zprofile"), "utf8"), "");
179
273
 
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
@@ -135,9 +136,6 @@ if (process.argv[2] === "--refresh") {
135
136
  });
136
137
  if (resp.ok) {
137
138
  const data = await resp.json();
138
- if (data.cache_expires_at && typeof data.cache_expires_at === "number") {
139
- data.cache_expires_at = Math.round(data.cache_expires_at / 10) * 10;
140
- }
141
139
  mkdirSync(cacheDir, { recursive: true });
142
140
  writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
143
141
  }
@@ -167,6 +165,11 @@ const raw = readFileSync(0, "utf8");
167
165
  let input = {};
168
166
  try { input = JSON.parse(raw); } catch { /* keep {} */ }
169
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
+ }
170
173
  const refreshMs = (config.refresh_seconds ?? 15) * 1000;
171
174
 
172
175
  const cachePath = join(cacheDir, sessionId + ".json");