pennyrouter 0.2.10 → 0.2.11

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.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "start": "node ./bin/pennyrouter.js",
27
- "check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/mcp.test.js"
27
+ "check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
28
28
  },
29
29
  "license": "MIT"
30
30
  }
package/src/cli.js CHANGED
@@ -4,6 +4,10 @@ import { createInterface } from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
5
  import { aiderHarness } from "./harnesses/aider.js";
6
6
  import { runMcpServer } from "./mcp.js";
7
+ import { mcpStatus, registerMcp } from "./mcp-register.js";
8
+
9
+ /** The harnesses that can run an MCP server. The other supported harnesses cannot. */
10
+ const MCP_HARNESS_IDS = ["claude-code", "codex"];
7
11
  import {
8
12
  CLAUDE_CODE_MODELS,
9
13
  claudeCodeHarness,
@@ -117,7 +121,13 @@ export async function main(argv = process.argv.slice(2)) {
117
121
  }
118
122
 
119
123
  if (command === "mcp") {
120
- await serveMcp(flags);
124
+ // Bare `mcp` is the server itself, started by a harness over stdio. The subcommands are for
125
+ // a person: registering after --no-mcp, retrying after a name conflict, or checking state.
126
+ const sub = (flags.args[0] || "").toLowerCase();
127
+ if (sub === "install" || sub === "add") await installMcp(flags);
128
+ else if (sub === "uninstall" || sub === "remove") await uninstallMcp(flags);
129
+ else if (sub === "status" || sub === "list") await statusMcp();
130
+ else await serveMcp(flags);
121
131
  return;
122
132
  }
123
133
 
@@ -141,6 +151,7 @@ function parseArgs(argv) {
141
151
  else if (arg === "--make-default") flags.makeDefault = true;
142
152
  else if (arg === "--penny-only") flags.pennyOnly = true;
143
153
  else if (arg === "--no-path-update") flags.noPathUpdate = true;
154
+ else if (arg === "--no-mcp") flags.noMcp = true;
144
155
  else if (arg === "--no-browser") flags.noBrowser = true;
145
156
  else if (arg === "--existing-account") flags.existingAccount = true;
146
157
  else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
@@ -578,6 +589,24 @@ export async function install(flags = {}) {
578
589
  } else if (runtimeResult?.changed) {
579
590
  console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
580
591
  }
592
+
593
+ // Registered after routing, which is the primary job: a naming conflict here reports itself
594
+ // and leaves a working install rather than failing the whole run.
595
+ if (!flags.noMcp && !flags.dryRun) {
596
+ const mcp = await registerMcp(records.map((r) => r.harness));
597
+ const registered = mcp.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
598
+ if (registered.length) {
599
+ console.log("");
600
+ console.log(color("Threads MCP", "bold"));
601
+ console.log(` Registered on ${registered.join(" and ")}.`);
602
+ console.log(" Ask your agent to read or resume an AI share link:");
603
+ console.log(' "code based on this chat: <paste a ChatGPT/Claude/Gemini/Perplexity link>"');
604
+ }
605
+ for (const result of mcp.filter((r) => r.error)) {
606
+ console.log(` MCP registration skipped for ${result.harness}: ${result.error}`);
607
+ }
608
+ }
609
+
581
610
  console.log("");
582
611
  reportFailures(failures);
583
612
  }
@@ -596,6 +625,59 @@ export async function getSetupPreflight() {
596
625
  };
597
626
  }
598
627
 
628
+ /* The `mcp install|uninstall|status` subcommands.
629
+
630
+ Install already registers the server, so these exist for the states install cannot reach:
631
+ someone who passed --no-mcp and changed their mind, someone who hit a name conflict and has
632
+ now renamed the other server, and anyone who just wants to know whether it is wired up. */
633
+
634
+ function mcpTargets(flags) {
635
+ const requested = parseHarnessList(flags.harness).filter((id) => MCP_HARNESS_IDS.includes(id));
636
+ return requested.length ? requested : MCP_HARNESS_IDS;
637
+ }
638
+
639
+ async function installMcp(flags) {
640
+ const results = await registerMcp(mcpTargets(flags));
641
+ console.log("PennyRouter Threads MCP");
642
+ console.log("");
643
+ for (const result of results) {
644
+ const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
645
+ if (result.error) console.log(` ${name}: ${result.error}`);
646
+ else if (result.changed) console.log(` ${name}: registered`);
647
+ else if (result.reason) console.log(` ${name}: skipped — ${result.reason}`);
648
+ else console.log(` ${name}: already registered`);
649
+ }
650
+ console.log("");
651
+ console.log("Restart any running session, then ask your agent to read or resume a share link.");
652
+ }
653
+
654
+ async function uninstallMcp(flags) {
655
+ const results = await registerMcp(mcpTargets(flags), { remove: true });
656
+ console.log("PennyRouter Threads MCP");
657
+ console.log("");
658
+ for (const result of results) {
659
+ const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
660
+ console.log(` ${name}: ${result.changed ? "removed" : "nothing to remove"}`);
661
+ }
662
+ }
663
+
664
+ async function statusMcp() {
665
+ const rows = await mcpStatus(MCP_HARNESS_IDS);
666
+ console.log("PennyRouter Threads MCP");
667
+ console.log("");
668
+ for (const row of rows) {
669
+ const name = HARNESS_BY_ID[row.harness]?.name || row.harness;
670
+ if (!row.configFound) console.log(` ${name}: not installed on this machine`);
671
+ else if (row.conflict) {
672
+ console.log(` ${name}: a different server is already named "pennyrouter"`);
673
+ } else console.log(` ${name}: ${row.registered ? "registered" : "not registered"}`);
674
+ }
675
+ if (rows.some((row) => row.configFound && !row.registered)) {
676
+ console.log("");
677
+ console.log("Register with: pennyrouter mcp install");
678
+ }
679
+ }
680
+
599
681
  /* Serve the Threads MCP tools over stdio. Started by the harness, not by a person: stdout is the
600
682
  JSON-RPC channel, so nothing may be printed to it. Diagnostics go to stderr. */
601
683
  async function serveMcp(flags) {
@@ -736,6 +818,24 @@ async function update(flags) {
736
818
  }
737
819
 
738
820
  await saveManifest(manifest);
821
+
822
+ // Pick up MCP registration for installs that predate it, which is every existing user: the
823
+ // server ships with the package but nothing registers it retroactively.
824
+ //
825
+ // Update always registers rather than honoring a stored opt-out. Registration is what makes
826
+ // the Threads tools exist at all, and a preference remembered across version bumps would
827
+ // mostly serve to leave people silently without them. `--no-mcp` still skips a single run,
828
+ // and `pennyrouter mcp uninstall` removes it for good.
829
+ if (!flags.dryRun && !flags.noMcp) {
830
+ const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
831
+ const results = await registerMcp(wanted.map((record) => record.harness));
832
+ const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
833
+ if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
834
+ for (const result of results.filter((r) => r.error)) {
835
+ console.log(`MCP registration skipped for ${result.harness}: ${result.error}`);
836
+ }
837
+ }
838
+
739
839
  console.log("");
740
840
  if (runtimeResult && !runtimeResult.available) {
741
841
  console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
@@ -1203,6 +1303,10 @@ async function uninstall(flags) {
1203
1303
  });
1204
1304
  }
1205
1305
 
1306
+ // Only entries PennyRouter wrote are removed, so a server the user re-pointed at their own
1307
+ // build survives an uninstall.
1308
+ await registerMcp(selectedRecords.map((record) => record.harness), { remove: true });
1309
+
1206
1310
  if (anthropicTokenNotice) {
1207
1311
  console.log("");
1208
1312
  console.log("Note: Your stored Anthropic token remains on the server for other machines.");
@@ -1733,22 +1837,26 @@ function printHelp() {
1733
1837
  console.log(`PennyRouter CLI
1734
1838
 
1735
1839
  Usage:
1736
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
1737
- pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL] [--allow-running-harnesses]
1840
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
1841
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL] [--no-mcp] [--allow-running-harnesses]
1738
1842
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1739
1843
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1740
1844
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1741
1845
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
1742
1846
  pennyrouter status
1743
- pennyrouter mcp [--penny-key pr-...] [--gateway-base-url URL]
1847
+ pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
1744
1848
 
1745
- The mcp command runs the Threads MCP server on stdio. It is started by your agent, not by hand —
1746
- register it once and then ask the agent to read or resume a share link:
1849
+ Install and update both register the Threads MCP server with Claude Code and Codex (--no-mcp to
1850
+ skip a run). Run "pennyrouter mcp install" if you skipped it or had to rename a conflicting
1851
+ server, "pennyrouter mcp status" to see whether it is wired up, and "pennyrouter mcp uninstall"
1852
+ to remove it. Bare "pennyrouter mcp" is the server itself, run by those harnesses over stdio —
1853
+ you never invoke it by hand.
1747
1854
 
1748
- claude mcp add pennyrouter -- npx -y pennyrouter mcp
1855
+ Once registered, ask your agent to read or resume an AI share link:
1749
1856
 
1750
1857
  thread_read <url> pull a ChatGPT/Claude/Gemini/Perplexity conversation into the chat
1751
- thread_resume <url> write it as a resumable Claude Code session and print the resume command
1858
+ thread_resume <url> write it as a resumable session and print the resume command
1859
+ (harness: claude-code by default, or codex)
1752
1860
 
1753
1861
  Update refreshes the managed local config and the penny runtime to this package version.
1754
1862
  It reuses your existing key and never re-asks for subscription or API credentials.
@@ -4,13 +4,10 @@ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../s
4
4
  const CONFIG_PATH = "~/.claude/settings.json";
5
5
  const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
6
6
  const CONNECTOR_SETTING = "disableClaudeAiConnectors";
7
- // Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
8
- // its /model menu AND sends it as the request `model`, so use genuine Anthropic ids here.
9
- // That lets Claude Code apply its own per-model context limits: Haiku gets its 200K window;
10
- // the 1M-capable slots get 1M. The gateway recognizes and pins these native ids, so the user's
11
- // pick still runs on that exact model (background/utility calls remain cheap). "Penny Custom"
12
- // remains the dynamic PennyRouter option. Friendly pinned labels from earlier installs remain
13
- // recognized for uninstall and backwards compatibility (see isPennyRouterModelValue).
7
+ // The slot values remain genuine Anthropic ids so Claude Code recognizes their actual context
8
+ // windows (Haiku 200K; the 1M-capable models 1M) and sends the native id on the wire. Claude
9
+ // Code separately supports *_MODEL_NAME and *_MODEL_DESCRIPTION for the picker, so Penny can
10
+ // present a friendly alias without sacrificing model recognition.
14
11
  export const CLAUDE_CODE_MODELS = {
15
12
  haiku: "claude-haiku-4-5-20251001",
16
13
  sonnet: "claude-sonnet-5",
@@ -18,6 +15,12 @@ export const CLAUDE_CODE_MODELS = {
18
15
  fable: "claude-fable-5",
19
16
  custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
20
17
  };
18
+ const CLAUDE_CODE_MODEL_PRESENTATION = {
19
+ haiku: { name: "Haiku 4.5 (PennyRouter)", description: "Fast, economical Claude model · 200K context" },
20
+ sonnet: { name: "Sonnet 5 (PennyRouter)", description: "Balanced Claude model · 1M context" },
21
+ opus: { name: "Opus 5 (PennyRouter)", description: "Most capable Claude model · 1M context" },
22
+ fable: { name: "Fable 5 (PennyRouter)", description: "Claude model · 1M context" },
23
+ };
21
24
  const LEGACY_PINNED_MODEL_IDS = {
22
25
  "Haiku 4.5 (PennyRouter)": CLAUDE_CODE_MODELS.haiku,
23
26
  "Sonnet 5 (PennyRouter)": CLAUDE_CODE_MODELS.sonnet,
@@ -33,6 +36,20 @@ const LEGACY_PENNY_LABELS = [
33
36
  "Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
34
37
  ];
35
38
 
39
+ function writeClaudeCodeModelPresentation(env) {
40
+ for (const [slot, { name, description }] of Object.entries(CLAUDE_CODE_MODEL_PRESENTATION)) {
41
+ const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
42
+ env[`${prefix}_NAME`] = name;
43
+ env[`${prefix}_DESCRIPTION`] = description;
44
+ }
45
+ }
46
+
47
+ const CLAUDE_CODE_MODEL_PRESENTATION_KEYS = Object.keys(CLAUDE_CODE_MODEL_PRESENTATION)
48
+ .flatMap((slot) => {
49
+ const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
50
+ return [`${prefix}_NAME`, `${prefix}_DESCRIPTION`];
51
+ });
52
+
36
53
  export const claudeCodeHarness = {
37
54
  id: "claude-code",
38
55
  name: "Claude Code",
@@ -69,6 +86,7 @@ export const claudeCodeHarness = {
69
86
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
70
87
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
71
88
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
89
+ writeClaudeCodeModelPresentation(current.env);
72
90
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
73
91
  // real context limit instead of incorrectly giving Haiku a 1M window.
74
92
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -94,6 +112,7 @@ export const claudeCodeHarness = {
94
112
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
95
113
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
96
114
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
115
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
97
116
  ],
98
117
  },
99
118
  };
@@ -117,6 +136,7 @@ export const claudeCodeHarness = {
117
136
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
118
137
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
119
138
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
139
+ writeClaudeCodeModelPresentation(current.env);
120
140
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
121
141
  // real context limit instead of incorrectly giving Haiku a 1M window.
122
142
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -142,6 +162,7 @@ export const claudeCodeHarness = {
142
162
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
143
163
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
144
164
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
165
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
145
166
  ],
146
167
  },
147
168
  };
@@ -161,6 +182,7 @@ export const claudeCodeHarness = {
161
182
  current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
162
183
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
163
184
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
185
+ writeClaudeCodeModelPresentation(current.env);
164
186
  // This is global, not per-model. Leave it unset so Claude Code uses each native id's
165
187
  // real context limit instead of incorrectly giving Haiku a 1M window.
166
188
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
@@ -186,6 +208,7 @@ export const claudeCodeHarness = {
186
208
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
187
209
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
188
210
  "ANTHROPIC_DEFAULT_FABLE_MODEL",
211
+ ...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
189
212
  ],
190
213
  },
191
214
  };
@@ -220,6 +243,7 @@ export const claudeCodeHarness = {
220
243
  ]) {
221
244
  if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
222
245
  }
246
+ for (const k of CLAUDE_CODE_MODEL_PRESENTATION_KEYS) delete current.env[k];
223
247
  delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
224
248
  }
225
249
  restoreClaudeAiConnectors(current, record);
@@ -0,0 +1,193 @@
1
+ /* Register the Threads MCP server with the harnesses that can run it.
2
+
3
+ Without this, `pennyrouter install` gives a user routing and nothing else: the MCP server
4
+ exists as a command they would have to discover and register by hand, which almost nobody
5
+ does. Registration is what makes thread_read/thread_resume simply be there.
6
+
7
+ Two harnesses, two config files, neither of them the one the installer already manages:
8
+
9
+ Claude Code ~/.claude.json -> mcpServers.pennyrouter (JSON)
10
+ Codex ~/.codex/config.toml -> [mcp_servers.pennyrouter] (TOML)
11
+
12
+ NOTE the file for Claude Code. Its MCP registry is `~/.claude.json`, NOT the
13
+ `~/.claude/settings.json` that harnesses/claude-code.js writes — different file, different
14
+ shape. Writing the entry into settings.json looks right and does nothing.
15
+
16
+ The command is `npx -y pennyrouter mcp`, deliberately unpinned. An absolute path to the
17
+ current bin breaks the moment the user switches node versions or reinstalls, and the server
18
+ then silently stops appearing rather than failing loudly. A pinned version would freeze the
19
+ session writers, which is wrong for a feature whose whole risk is that Claude Code and Codex
20
+ change their undocumented formats — the writers need to be updatable. */
21
+
22
+ import { atomicWrite, backupFile, exists, expandHome, readJsonFile } from "./state.js";
23
+ import { readFile } from "node:fs/promises";
24
+
25
+ export const SERVER_NAME = "pennyrouter";
26
+ const CLAUDE_REGISTRY = "~/.claude.json";
27
+ const CODEX_CONFIG = "~/.codex/config.toml";
28
+
29
+ const MANAGED_START = "# --- PennyRouter managed MCP server ---";
30
+ const MANAGED_END = "# --- end PennyRouter managed MCP server ---";
31
+
32
+ /** What we register. Shared by both harnesses so the two configs cannot drift apart. */
33
+ export function serverEntry() {
34
+ return { command: "npx", args: ["-y", "pennyrouter", "mcp"] };
35
+ }
36
+
37
+ /** True when an entry is one of ours, so re-installing updates rather than duplicating and
38
+ uninstalling never removes a server the user registered themselves. */
39
+ export function isManagedEntry(entry) {
40
+ if (!entry || typeof entry !== "object") return false;
41
+ const args = Array.isArray(entry.args) ? entry.args.join(" ") : "";
42
+ return /\bpennyrouter\b/.test(`${entry.command || ""} ${args}`);
43
+ }
44
+
45
+ // -- Claude Code ----------------------------------------------------------
46
+
47
+ export function applyClaudeRegistry(config, { remove = false } = {}) {
48
+ const next = { ...(config || {}) };
49
+ const servers = { ...(next.mcpServers || {}) };
50
+
51
+ if (remove) {
52
+ // Only remove what we wrote. A user who pointed the name at their own build keeps it.
53
+ if (isManagedEntry(servers[SERVER_NAME])) delete servers[SERVER_NAME];
54
+ } else {
55
+ const existing = servers[SERVER_NAME];
56
+ if (existing && !isManagedEntry(existing)) {
57
+ throw new Error(
58
+ `Claude Code already has an MCP server named "${SERVER_NAME}" that PennyRouter did not ` +
59
+ "write. Rename it before installing so your configuration is not overwritten.",
60
+ );
61
+ }
62
+ servers[SERVER_NAME] = serverEntry();
63
+ }
64
+
65
+ if (Object.keys(servers).length) next.mcpServers = servers;
66
+ else delete next.mcpServers;
67
+ return next;
68
+ }
69
+
70
+ async function writeClaude({ remove }) {
71
+ if (!(await exists(CLAUDE_REGISTRY))) {
72
+ // Claude Code writes this on first run. Creating it ourselves risks clobbering a shape we
73
+ // do not own, so skip rather than guess.
74
+ return { changed: false, reason: "Claude Code config not found" };
75
+ }
76
+ const current = (await readJsonFile(CLAUDE_REGISTRY, null)) || {};
77
+ const next = applyClaudeRegistry(current, { remove });
78
+ if (JSON.stringify(next) === JSON.stringify(current)) return { changed: false };
79
+
80
+ await backupFile(CLAUDE_REGISTRY, "mcp");
81
+ await atomicWrite(CLAUDE_REGISTRY, `${JSON.stringify(next, null, 2)}\n`);
82
+ return { changed: true, path: expandHome(CLAUDE_REGISTRY) };
83
+ }
84
+
85
+ // -- Codex ----------------------------------------------------------------
86
+
87
+ export function renderCodexMcp(text, { remove = false } = {}) {
88
+ const clean = removeManagedCodexMcp(text).trimEnd();
89
+ if (remove) return clean ? `${clean}\n` : "";
90
+
91
+ const pattern = new RegExp(`^\\s*\\[mcp_servers\\.${SERVER_NAME}\\]\\s*$`, "m");
92
+ if (pattern.test(clean)) {
93
+ throw new Error(
94
+ `Codex already defines [mcp_servers.${SERVER_NAME}] outside PennyRouter's managed block. ` +
95
+ "Rename that server before installing so PennyRouter does not overwrite it.",
96
+ );
97
+ }
98
+
99
+ const entry = serverEntry();
100
+ const block = [
101
+ MANAGED_START,
102
+ `[mcp_servers.${SERVER_NAME}]`,
103
+ `command = "${entry.command}"`,
104
+ `args = [${entry.args.map((a) => `"${a}"`).join(", ")}]`,
105
+ MANAGED_END,
106
+ ].join("\n");
107
+
108
+ // Appended rather than spliced at the first table: a TOML table header captures every key
109
+ // that follows it, so inserting this block above existing tables would silently reparent
110
+ // their keys into ours.
111
+ return `${clean ? `${clean}\n\n` : ""}${block}\n`;
112
+ }
113
+
114
+ export function removeManagedCodexMcp(text) {
115
+ return String(text || "").replace(
116
+ new RegExp(
117
+ `${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`,
118
+ "g",
119
+ ),
120
+ "",
121
+ );
122
+ }
123
+
124
+ function escapeRegex(value) {
125
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
+ }
127
+
128
+ async function writeCodex({ remove }) {
129
+ if (!(await exists(CODEX_CONFIG))) return { changed: false, reason: "Codex config not found" };
130
+ const current = await readFile(expandHome(CODEX_CONFIG), "utf8").catch(() => "");
131
+ const next = renderCodexMcp(current, { remove });
132
+ if (next === current) return { changed: false };
133
+
134
+ await backupFile(CODEX_CONFIG, "mcp");
135
+ await atomicWrite(CODEX_CONFIG, next);
136
+ return { changed: true, path: expandHome(CODEX_CONFIG) };
137
+ }
138
+
139
+ // -- entry points ---------------------------------------------------------
140
+
141
+ const WRITERS = { "claude-code": writeClaude, codex: writeCodex };
142
+
143
+ /** Whether each harness currently has our server registered, and whether it could. */
144
+ export async function mcpStatus(harnessIds = Object.keys(WRITERS)) {
145
+ const out = [];
146
+ for (const id of harnessIds) {
147
+ if (id === "claude-code") {
148
+ const present = await exists(CLAUDE_REGISTRY);
149
+ const config = present ? (await readJsonFile(CLAUDE_REGISTRY, null)) || {} : {};
150
+ out.push({
151
+ harness: id,
152
+ configFound: present,
153
+ registered: isManagedEntry((config.mcpServers || {})[SERVER_NAME]),
154
+ conflict: Boolean(
155
+ (config.mcpServers || {})[SERVER_NAME] &&
156
+ !isManagedEntry((config.mcpServers || {})[SERVER_NAME]),
157
+ ),
158
+ });
159
+ } else if (id === "codex") {
160
+ const present = await exists(CODEX_CONFIG);
161
+ const text = present ? await readFile(expandHome(CODEX_CONFIG), "utf8").catch(() => "") : "";
162
+ const managed = text.includes(MANAGED_START);
163
+ out.push({
164
+ harness: id,
165
+ configFound: present,
166
+ registered: managed,
167
+ conflict:
168
+ !managed && new RegExp(`^\\s*\\[mcp_servers\\.${SERVER_NAME}\\]`, "m").test(text),
169
+ });
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * Register (or with `remove`, unregister) the MCP server for `harnessIds`.
177
+ * Returns one result per harness; a harness whose config is absent is skipped, not an error.
178
+ */
179
+ export async function registerMcp(harnessIds, { remove = false } = {}) {
180
+ const results = [];
181
+ for (const id of harnessIds) {
182
+ const writer = WRITERS[id];
183
+ if (!writer) continue;
184
+ try {
185
+ results.push({ harness: id, ...(await writer({ remove })) });
186
+ } catch (error) {
187
+ // A conflict must not fail the whole install: routing is the primary job and is already
188
+ // done by this point. Report it and let the user resolve the name.
189
+ results.push({ harness: id, changed: false, error: error?.message || String(error) });
190
+ }
191
+ }
192
+ return results;
193
+ }
@@ -0,0 +1,171 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import {
4
+ SERVER_NAME,
5
+ applyClaudeRegistry,
6
+ isManagedEntry,
7
+ removeManagedCodexMcp,
8
+ renderCodexMcp,
9
+ serverEntry,
10
+ } from "./mcp-register.js";
11
+
12
+ // -- what gets written ----------------------------------------------------
13
+ // Unpinned on purpose: an absolute path breaks on a node/nvm switch and the server then stops
14
+ // appearing silently, and a pinned version would freeze the session writers — wrong for a
15
+ // feature whose risk is that the harnesses change their undocumented formats.
16
+ {
17
+ const entry = serverEntry();
18
+ assert.equal(entry.command, "npx");
19
+ assert.deepEqual(entry.args, ["-y", "pennyrouter", "mcp"]);
20
+ assert.ok(!JSON.stringify(entry).includes("/"), "no absolute paths");
21
+ assert.ok(!/pennyrouter@/.test(JSON.stringify(entry)), "not version-pinned");
22
+ }
23
+
24
+ // -- Claude Code registry -------------------------------------------------
25
+ {
26
+ // Registering into an empty config.
27
+ const fresh = applyClaudeRegistry({});
28
+ assert.deepEqual(fresh.mcpServers[SERVER_NAME], serverEntry());
29
+
30
+ // Existing unrelated servers are preserved.
31
+ const withOthers = applyClaudeRegistry({
32
+ mcpServers: { railway: { command: "railway" } },
33
+ projects: { "/x": {} },
34
+ });
35
+ assert.ok(withOthers.mcpServers.railway, "other servers survive");
36
+ assert.ok(withOthers.projects, "unrelated top-level keys survive");
37
+
38
+ // Re-installing updates in place rather than duplicating.
39
+ const twice = applyClaudeRegistry(applyClaudeRegistry({}));
40
+ assert.equal(Object.keys(twice.mcpServers).length, 1);
41
+
42
+ // A same-named server we did not write is a hard stop, not a silent overwrite.
43
+ assert.throws(
44
+ () => applyClaudeRegistry({ mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } }),
45
+ /did not write/,
46
+ );
47
+
48
+ // Removal takes ours and leaves everything else.
49
+ const removed = applyClaudeRegistry(
50
+ { mcpServers: { railway: { command: "railway" }, [SERVER_NAME]: serverEntry() } },
51
+ { remove: true },
52
+ );
53
+ assert.ok(!removed.mcpServers[SERVER_NAME]);
54
+ assert.ok(removed.mcpServers.railway);
55
+
56
+ // A user who re-pointed the name at their own build keeps it through an uninstall.
57
+ const custom = { mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } };
58
+ assert.deepEqual(applyClaudeRegistry(custom, { remove: true }), custom);
59
+
60
+ // Removing the last server drops the key entirely rather than leaving `{}`.
61
+ const emptied = applyClaudeRegistry(
62
+ { mcpServers: { [SERVER_NAME]: serverEntry() } },
63
+ { remove: true },
64
+ );
65
+ assert.ok(!("mcpServers" in emptied));
66
+ }
67
+
68
+ // -- managed-entry detection ----------------------------------------------
69
+ assert.equal(isManagedEntry(serverEntry()), true);
70
+ assert.equal(isManagedEntry({ command: "npx", args: ["-y", "pennyrouter@1.2.3", "mcp"] }), true);
71
+ assert.equal(isManagedEntry({ command: "/my/own/build" }), false);
72
+ assert.equal(isManagedEntry(null), false);
73
+ assert.equal(isManagedEntry({}), false);
74
+
75
+ // -- Codex TOML -----------------------------------------------------------
76
+ {
77
+ const existing = [
78
+ 'model_provider = "pennyrouter"',
79
+ "",
80
+ "[mcp_servers.node_repl]",
81
+ 'command = "/Applications/Codex.app/node_repl"',
82
+ ].join("\n");
83
+
84
+ const written = renderCodexMcp(existing);
85
+ assert.ok(written.includes(`[mcp_servers.${SERVER_NAME}]`));
86
+ assert.ok(written.includes('command = "npx"'));
87
+ assert.ok(written.includes('args = ["-y", "pennyrouter", "mcp"]'));
88
+ assert.ok(written.includes("[mcp_servers.node_repl]"), "existing servers survive");
89
+
90
+ // A TOML table header captures every key after it, so our block must be appended — splicing
91
+ // it above an existing table would reparent that table's keys into ours.
92
+ const ourIndex = written.indexOf(`[mcp_servers.${SERVER_NAME}]`);
93
+ assert.ok(ourIndex > written.indexOf("[mcp_servers.node_repl]"), "appended, not spliced");
94
+ assert.ok(
95
+ written.indexOf('model_provider = "pennyrouter"') < written.indexOf("[mcp_servers."),
96
+ "root keys stay above every table",
97
+ );
98
+
99
+ // Re-installing is idempotent: the managed block is replaced, not stacked.
100
+ const again = renderCodexMcp(written);
101
+ assert.equal(again.match(new RegExp(`\\[mcp_servers\\.${SERVER_NAME}\\]`, "g")).length, 1);
102
+
103
+ // Removal restores the original.
104
+ assert.equal(renderCodexMcp(written, { remove: true }).trim(), existing.trim());
105
+ assert.equal(removeManagedCodexMcp(written).trim(), existing.trim());
106
+
107
+ // An unmanaged server of the same name is a hard stop.
108
+ assert.throws(
109
+ () => renderCodexMcp(`[mcp_servers.${SERVER_NAME}]\ncommand = "mine"`),
110
+ /outside PennyRouter's managed block/,
111
+ );
112
+
113
+ // Empty config in, valid config out.
114
+ assert.ok(renderCodexMcp("").includes(`[mcp_servers.${SERVER_NAME}]`));
115
+ assert.equal(renderCodexMcp("", { remove: true }), "");
116
+ }
117
+
118
+ // -- status reporting -----------------------------------------------------
119
+ // `mcp status` is how someone finds out they are in the state install cannot reach: opted out
120
+ // with --no-mcp, or blocked by a name conflict. Both must be distinguishable from "not set up".
121
+ {
122
+ const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs");
123
+ const { tmpdir } = await import("node:os");
124
+ const { join } = await import("node:path");
125
+ const { mcpStatus } = await import("./mcp-register.js");
126
+
127
+ const home = mkdtempSync(join(tmpdir(), "pr-mcp-status-"));
128
+ const originalHome = process.env.HOME;
129
+ process.env.HOME = home;
130
+ try {
131
+ // No configs at all: reported as absent, not as "not registered".
132
+ let rows = await mcpStatus(["claude-code", "codex"]);
133
+ assert.deepEqual(rows.map((r) => r.configFound), [false, false]);
134
+
135
+ // Present but not registered — the --no-mcp state.
136
+ writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: {} }));
137
+ mkdirSync(join(home, ".codex"), { recursive: true });
138
+ writeFileSync(join(home, ".codex", "config.toml"), 'model = "gpt-5"\n');
139
+ rows = await mcpStatus(["claude-code", "codex"]);
140
+ assert.deepEqual(rows.map((r) => r.registered), [false, false]);
141
+ assert.deepEqual(rows.map((r) => r.conflict), [false, false]);
142
+ assert.deepEqual(rows.map((r) => r.configFound), [true, true]);
143
+
144
+ // Someone else's server under our name.
145
+ writeFileSync(
146
+ join(home, ".claude.json"),
147
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } }),
148
+ );
149
+ writeFileSync(
150
+ join(home, ".codex", "config.toml"),
151
+ `[mcp_servers.${SERVER_NAME}]\ncommand = "mine"\n`,
152
+ );
153
+ rows = await mcpStatus(["claude-code", "codex"]);
154
+ assert.deepEqual(rows.map((r) => r.conflict), [true, true]);
155
+ assert.deepEqual(rows.map((r) => r.registered), [false, false]);
156
+
157
+ // Ours.
158
+ writeFileSync(
159
+ join(home, ".claude.json"),
160
+ JSON.stringify(applyClaudeRegistry({})),
161
+ );
162
+ writeFileSync(join(home, ".codex", "config.toml"), renderCodexMcp(""));
163
+ rows = await mcpStatus(["claude-code", "codex"]);
164
+ assert.deepEqual(rows.map((r) => r.registered), [true, true]);
165
+ assert.deepEqual(rows.map((r) => r.conflict), [false, false]);
166
+ } finally {
167
+ process.env.HOME = originalHome;
168
+ }
169
+ }
170
+
171
+ console.log("mcp-register tests passed");
package/src/mcp.js CHANGED
@@ -5,10 +5,15 @@
5
5
 
6
6
  thread_read fetch + parse, return the transcript to the model. For "what did we decide
7
7
  in this chat?" — the body lands in the caller's context, which is the point.
8
- thread_resume fetch + parse, write a resumable Claude Code session, return only a path and
9
- a one-line summary. For "let's continue coding from this chat" — the
10
- transcript never enters the caller's context, so moving a 200-turn thread
11
- costs the caller nothing. That asymmetry is the whole reason for two tools.
8
+ thread_resume fetch + parse, write a resumable session for Claude Code OR Codex, return
9
+ only a path and a resume command. For "let's continue coding from this chat"
10
+ — the transcript never enters the caller's context, so moving a 200-turn
11
+ thread costs the caller nothing. That asymmetry is the whole reason for two
12
+ tools rather than one with a flag.
13
+
14
+ Because the destination is a parameter, "resume this Perplexity thread in Codex" and "…in
15
+ Claude Code" are the same call with a different `harness`. Each harness has its own writer:
16
+ the two on-disk formats share nothing structurally (see thread-session-codex.js).
12
17
 
13
18
  ALL SCRAPING STAYS SERVER-SIDE. This process holds no adapters, no fetcher credentials and no
14
19
  provider knowledge; it POSTs a URL to the gateway and gets a canonical thread back. What ships
@@ -22,6 +27,7 @@ import { createInterface } from "node:readline";
22
27
  import { execFileSync } from "node:child_process";
23
28
 
24
29
  import { writeSession, VERIFIED_MIN, VERIFIED_MAX } from "./thread-session.js";
30
+ import { writeSession as writeCodexSession } from "./thread-session-codex.js";
25
31
 
26
32
  const PROTOCOL_VERSION = "2024-11-05";
27
33
 
@@ -44,10 +50,10 @@ const TOOLS = [
44
50
  {
45
51
  name: "thread_resume",
46
52
  description:
47
- "Fetch an AI conversation from a share link and write it as a resumable Claude Code " +
48
- "session in a workspace, so the user can `claude --resume` into it and continue coding " +
49
- "with that conversation as history. Use when the user wants to CONTINUE or BUILD FROM a " +
50
- "past chat rather than talk about it. Returns a session id and path; the transcript is " +
53
+ "Fetch an AI conversation from a share link and write it as a resumable coding-agent " +
54
+ "session (Claude Code or Codex), so the user can resume into it and continue coding with " +
55
+ "that conversation as history. Use when the user wants to CONTINUE or BUILD FROM a past " +
56
+ "chat rather than talk about it. Returns a session id and path; the transcript is " +
51
57
  "deliberately NOT returned, so importing a long thread costs you no context.",
52
58
  inputSchema: {
53
59
  type: "object",
@@ -59,15 +65,24 @@ const TOOLS = [
59
65
  "Absolute path to the workspace the session should resume in. Defaults to the " +
60
66
  "current working directory.",
61
67
  },
68
+ harness: {
69
+ type: "string",
70
+ enum: ["claude-code", "codex"],
71
+ description:
72
+ "Which agent should be able to resume it. Defaults to claude-code. Use codex when " +
73
+ "the user asks to continue the conversation in Codex.",
74
+ },
62
75
  },
63
76
  required: ["url"],
64
77
  },
65
78
  },
66
79
  ];
67
80
 
68
- function claudeVersion() {
81
+ /** `<tool> --version`, or null when the tool is not installed. The writers version-gate on this
82
+ and refuse rather than write a session file that only fails at resume time. */
83
+ function toolVersion(binary) {
69
84
  try {
70
- return execFileSync("claude", ["--version"], { encoding: "utf8" }).trim();
85
+ return execFileSync(binary, ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
71
86
  } catch {
72
87
  return null;
73
88
  }
@@ -130,21 +145,34 @@ async function callTool(name, args, context) {
130
145
 
131
146
  if (name === "thread_resume") {
132
147
  const cwd = String(args?.directory || process.cwd());
133
- const version = claudeVersion();
148
+ const harness = String(args?.harness || "claude-code").toLowerCase();
149
+ if (harness !== "claude-code" && harness !== "codex") {
150
+ throw new Error(`unknown harness "${harness}"; expected claude-code or codex`);
151
+ }
152
+
153
+ const codex = harness === "codex";
154
+ const version = codex ? toolVersion("codex") : toolVersion("claude");
134
155
  const thread = await fetchCanonical(url, context);
135
- const { sessionId, path, messageCount } = writeSession(thread, { cwd, version });
156
+ const write = codex ? writeCodexSession : writeSession;
157
+ const { sessionId, path, messageCount } = write(thread, { cwd, version });
158
+
136
159
  return [
137
160
  `Imported "${thread.title}" (${thread.messages?.length || 0} messages, ` +
138
161
  `~${thread.token_estimate || 0} tokens) from ${thread.origin_app || thread.source}.`,
139
162
  "",
140
- `Wrote a resumable session: ${messageCount} records`,
163
+ `Wrote a resumable ${codex ? "Codex" : "Claude Code"} session: ${messageCount} records`,
141
164
  ` ${path}`,
142
165
  "",
143
166
  "Resume it with:",
144
- ` cd ${cwd} && claude --resume ${sessionId}`,
167
+ codex
168
+ ? ` cd ${cwd} && codex resume ${sessionId}`
169
+ : ` cd ${cwd} && claude --resume ${sessionId}`,
145
170
  "",
146
- "The transcript was written to disk and deliberately not returned here, so it did not " +
147
- "consume this session's context.",
171
+ codex
172
+ ? "Tool calls were rendered as prose: Codex and the source harness do not share a tool " +
173
+ "format, so this is a readable continuation rather than a replay."
174
+ : "The transcript was written to disk and deliberately not returned here, so it did not " +
175
+ "consume this session's context.",
148
176
  ].join("\n");
149
177
  }
150
178
 
package/src/mcp.test.js CHANGED
@@ -133,6 +133,61 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
133
133
  assert.equal(byId.get(4).result.isError, undefined);
134
134
  }
135
135
 
136
+ // -- harness routing ------------------------------------------------------
137
+ // "Resume this in Codex" is the same call with a different `harness`, so the tool must dispatch
138
+ // to the right writer and print the right resume command. Skipped when codex is not installed:
139
+ // the writer version-gates on `codex --version` and correctly refuses without it.
140
+ {
141
+ const { server, port } = await stubGateway((req, body, res) => {
142
+ res.setHeader("content-type", "application/json");
143
+ res.end(JSON.stringify(thread));
144
+ });
145
+
146
+ const { messages } = await rpc(
147
+ [
148
+ { jsonrpc: "2.0", id: 1, method: "tools/list" },
149
+ {
150
+ jsonrpc: "2.0",
151
+ id: 2,
152
+ method: "tools/call",
153
+ params: {
154
+ name: "thread_resume",
155
+ arguments: { url: "https://x/y", directory: workspace, harness: "codex" },
156
+ },
157
+ },
158
+ {
159
+ jsonrpc: "2.0",
160
+ id: 3,
161
+ method: "tools/call",
162
+ params: {
163
+ name: "thread_resume",
164
+ arguments: { url: "https://x/y", directory: workspace, harness: "nonsense" },
165
+ },
166
+ },
167
+ ],
168
+ { port, home },
169
+ );
170
+ server.close();
171
+
172
+ const byId = new Map(messages.map((m) => [m.id, m]));
173
+
174
+ const schema = byId.get(1).result.tools.find((t) => t.name === "thread_resume").inputSchema;
175
+ assert.deepEqual(schema.properties.harness.enum, ["claude-code", "codex"]);
176
+
177
+ const codex = byId.get(2).result;
178
+ if (codex.isError) {
179
+ assert.match(codex.content[0].text, /Codex .*outside the range|ENOENT|not.*install/i);
180
+ } else {
181
+ assert.ok(codex.content[0].text.includes("codex resume"), "prints the codex resume command");
182
+ assert.ok(!codex.content[0].text.includes("claude --resume"));
183
+ assert.ok(!codex.content[0].text.includes("UNIQUE-QUESTION"), "still returns no transcript");
184
+ }
185
+
186
+ // An unknown harness is refused rather than silently defaulting to the wrong format.
187
+ assert.equal(byId.get(3).result.isError, true);
188
+ assert.match(byId.get(3).result.content[0].text, /unknown harness/);
189
+ }
190
+
136
191
  // -- gateway failures reach the model as readable text --------------------
137
192
  {
138
193
  const { server, port } = await stubGateway((req, body, res) => {
@@ -0,0 +1,152 @@
1
+ /* Write a canonical thread into a Codex rollout it will resume from.
2
+
3
+ `codex resume <session-id>` reads ~/.codex/sessions/YYYY/MM/DD/rollout-<stamp>-<uuid>.jsonl,
4
+ so a synthesized rollout in that tree resumes like any other session. Verified end-to-end on
5
+ codex-cli 0.146.0: a file written by this shape resumed and the model answered from history it
6
+ never lived.
7
+
8
+ The format differs from Claude Code's in every structural respect, which is why this is a
9
+ separate writer rather than a flag on the other one:
10
+
11
+ - Records are a {timestamp, type, payload} envelope, not flat message objects.
12
+ - There is no parentUuid chain. Order in the file IS the order; nothing to link.
13
+ - The first record is a `session_meta` header carrying cwd, cli_version and the id.
14
+ - User content is `input_text`; assistant content is `output_text`. Using the wrong key
15
+ for a role is the one mistake that produces a file which loads but renders empty.
16
+ - Ids are UUIDv7 (time-ordered), and both the filename stamp and the id encode when the
17
+ session began. Codex sorts its picker by these, so a random v4 sorts to 1970.
18
+
19
+ THE FORMAT IS UNDOCUMENTED, exactly as with Claude Code, and the same three rules apply:
20
+ never overwrite, version-gate, and fail loudly rather than write something that only reveals
21
+ itself as broken at resume time. */
22
+
23
+ import { randomBytes } from "node:crypto";
24
+ import { mkdirSync, existsSync, writeFileSync } from "node:fs";
25
+ import { homedir } from "node:os";
26
+ import { join } from "node:path";
27
+
28
+ import { orientation, renderMessage } from "./thread-session.js";
29
+
30
+ /** Versions this writer has been verified against. */
31
+ export const VERIFIED_MIN = "0.100.0";
32
+ export const VERIFIED_MAX = "0.999.999";
33
+
34
+ /** UUIDv7: 48-bit big-endian millisecond timestamp, then version/variant bits, then random.
35
+ Codex orders sessions by this, so a v4 would sort as if it began in 1970. */
36
+ export function uuidv7(now = Date.now()) {
37
+ const bytes = randomBytes(16);
38
+ const ms = BigInt(now);
39
+ for (let i = 0; i < 6; i += 1) {
40
+ bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
41
+ }
42
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
43
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
44
+ const hex = bytes.toString("hex");
45
+ return [
46
+ hex.slice(0, 8),
47
+ hex.slice(8, 12),
48
+ hex.slice(12, 16),
49
+ hex.slice(16, 20),
50
+ hex.slice(20),
51
+ ].join("-");
52
+ }
53
+
54
+ function compare(a, b) {
55
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
56
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
57
+ for (let i = 0; i < 3; i += 1) {
58
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0) ? -1 : 1;
59
+ }
60
+ return 0;
61
+ }
62
+
63
+ export function versionSupported(version) {
64
+ if (!version) return false;
65
+ // `codex --version` prints "codex-cli 0.146.0"; take the numeric field wherever it sits.
66
+ const match = String(version).match(/(\d+\.\d+\.\d+)/);
67
+ if (!match) return false;
68
+ return compare(match[1], VERIFIED_MIN) >= 0 && compare(match[1], VERIFIED_MAX) <= 0;
69
+ }
70
+
71
+ /** ~/.codex/sessions/YYYY/MM/DD — Codex partitions rollouts by date. */
72
+ export function sessionDirFor(date = new Date()) {
73
+ const yyyy = String(date.getUTCFullYear());
74
+ const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
75
+ const dd = String(date.getUTCDate()).padStart(2, "0");
76
+ return join(homedir(), ".codex", "sessions", yyyy, mm, dd);
77
+ }
78
+
79
+ /** The stamp embedded in a rollout filename: 2026-08-09T22-15-30. */
80
+ export function fileStamp(date = new Date()) {
81
+ return date.toISOString().replace(/\.\d+Z$/, "").replace(/:/g, "-");
82
+ }
83
+
84
+ export function buildRecords(thread, { cwd, sessionId, version, timestamp = new Date() }) {
85
+ const ts = timestamp.toISOString();
86
+ const envelope = (type, payload) => ({ timestamp: ts, type, payload });
87
+
88
+ const message = (role, text) =>
89
+ envelope("response_item", {
90
+ type: "message",
91
+ id: `msg_${uuidv7(timestamp.getTime())}`,
92
+ role,
93
+ // The content key is role-dependent. Swapping them yields a file that loads and shows
94
+ // nothing, which is the worst failure mode available here.
95
+ content: [{ type: role === "assistant" ? "output_text" : "input_text", text }],
96
+ });
97
+
98
+ const records = [
99
+ envelope("session_meta", {
100
+ session_id: sessionId,
101
+ id: sessionId,
102
+ timestamp: ts,
103
+ cwd,
104
+ originator: "pennyrouter",
105
+ cli_version: String(version || "").match(/(\d+\.\d+\.\d+)/)?.[1] || "0.0.0",
106
+ source: "cli",
107
+ thread_source: "user",
108
+ model_provider: "openai",
109
+ history_mode: "legacy",
110
+ }),
111
+ message("user", orientation(thread)),
112
+ message("assistant", "Understood — I have the imported conversation and will continue from it."),
113
+ ];
114
+
115
+ for (const entry of thread.messages || []) {
116
+ const text = renderMessage(entry);
117
+ if (!text) continue;
118
+ if (entry.role === "assistant") records.push(message("assistant", text));
119
+ else if (entry.role === "user") records.push(message("user", text));
120
+ }
121
+
122
+ return records;
123
+ }
124
+
125
+ /**
126
+ * Write `thread` as a resumable Codex rollout.
127
+ * Returns { sessionId, path, messageCount }.
128
+ */
129
+ export function writeSession(thread, { cwd, version, timestamp = new Date() }) {
130
+ if (!versionSupported(version)) {
131
+ throw new Error(
132
+ `Codex ${version || "(unknown version)"} is outside the range this importer has been ` +
133
+ `verified against (${VERIFIED_MIN}–${VERIFIED_MAX}). Refusing to write a rollout that ` +
134
+ `may not resume.`,
135
+ );
136
+ }
137
+
138
+ const dir = sessionDirFor(timestamp);
139
+ mkdirSync(dir, { recursive: true });
140
+
141
+ const sessionId = uuidv7(timestamp.getTime());
142
+ const path = join(dir, `rollout-${fileStamp(timestamp)}-${sessionId}.jsonl`);
143
+ if (existsSync(path)) throw new Error("session id collision; try again");
144
+
145
+ const records = buildRecords(thread, { cwd, sessionId, version, timestamp });
146
+ writeFileSync(path, records.map((r) => JSON.stringify(r)).join("\n") + "\n", {
147
+ encoding: "utf8",
148
+ flag: "wx", // fail rather than clobber, even against a race
149
+ });
150
+
151
+ return { sessionId, path, messageCount: records.length };
152
+ }
@@ -0,0 +1,136 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ buildRecords,
8
+ fileStamp,
9
+ sessionDirFor,
10
+ uuidv7,
11
+ versionSupported,
12
+ writeSession,
13
+ } from "./thread-session-codex.js";
14
+
15
+ const thread = {
16
+ title: "Kickplate removal",
17
+ source: "perplexity_share",
18
+ origin_app: "Perplexity",
19
+ source_url: "https://www.perplexity.ai/search/x",
20
+ messages: [
21
+ { role: "user", blocks: [{ type: "text", text: "USER-TURN" }] },
22
+ {
23
+ role: "assistant",
24
+ blocks: [
25
+ { type: "thinking", text: "REASONING-TRACE" },
26
+ { type: "text", text: "ASSISTANT-TURN" },
27
+ ],
28
+ },
29
+ { role: "system", blocks: [{ type: "text", text: "SYSTEM-PREAMBLE" }] },
30
+ ],
31
+ };
32
+
33
+ // -- uuidv7 ---------------------------------------------------------------
34
+ // Codex sorts its session picker by these ids. A random v4 would sort as if the session began
35
+ // in 1970, so the timestamp prefix is not cosmetic.
36
+ {
37
+ const when = Date.UTC(2026, 7, 9, 22, 15, 30);
38
+ const id = uuidv7(when);
39
+ assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
40
+ assert.equal(parseInt(id.slice(0, 8) + id.slice(9, 13), 16), when, "ms timestamp is encoded");
41
+ assert.notEqual(uuidv7(when), uuidv7(when), "the random tail still varies");
42
+ // Time-ordering is the property Codex depends on.
43
+ assert.ok(uuidv7(when) < uuidv7(when + 1000));
44
+ }
45
+
46
+ // -- paths ----------------------------------------------------------------
47
+ {
48
+ const date = new Date(Date.UTC(2026, 7, 9, 22, 15, 30));
49
+ assert.ok(sessionDirFor(date).endsWith(join(".codex", "sessions", "2026", "08", "09")));
50
+ assert.equal(fileStamp(date), "2026-08-09T22-15-30");
51
+ }
52
+
53
+ // -- version gate ---------------------------------------------------------
54
+ // `codex --version` prints "codex-cli 0.146.0", so the number has to be found, not assumed.
55
+ assert.equal(versionSupported("codex-cli 0.146.0"), true);
56
+ assert.equal(versionSupported("0.100.0"), true);
57
+ assert.equal(versionSupported("0.99.0"), false);
58
+ assert.equal(versionSupported("1.0.0"), false);
59
+ assert.equal(versionSupported("not-a-version"), false);
60
+ assert.equal(versionSupported(null), false);
61
+
62
+ // -- record construction --------------------------------------------------
63
+ {
64
+ const records = buildRecords(thread, {
65
+ cwd: "/tmp/ws",
66
+ sessionId: "sid-1",
67
+ version: "codex-cli 0.146.0",
68
+ });
69
+
70
+ // Every record is the {timestamp, type, payload} envelope.
71
+ for (const record of records) {
72
+ assert.deepEqual(Object.keys(record).sort(), ["payload", "timestamp", "type"]);
73
+ }
74
+
75
+ const [meta, ...rest] = records;
76
+ assert.equal(meta.type, "session_meta");
77
+ assert.equal(meta.payload.session_id, "sid-1");
78
+ assert.equal(meta.payload.cwd, "/tmp/ws");
79
+ assert.equal(meta.payload.cli_version, "0.146.0", "the bare number, not the whole string");
80
+
81
+ // Content key is role-dependent. Getting this wrong yields a rollout that loads and renders
82
+ // empty, which is the failure mode least likely to be noticed before a user hits it.
83
+ for (const record of rest) {
84
+ assert.equal(record.type, "response_item");
85
+ assert.equal(record.payload.type, "message");
86
+ const key = record.payload.content[0].type;
87
+ assert.equal(key, record.payload.role === "assistant" ? "output_text" : "input_text");
88
+ assert.match(record.payload.id, /^msg_/);
89
+ }
90
+
91
+ assert.deepEqual(
92
+ rest.map((r) => r.payload.role),
93
+ ["user", "assistant", "user", "assistant"],
94
+ );
95
+
96
+ const serialized = JSON.stringify(records);
97
+ assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
98
+ assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
99
+ assert.ok(serialized.includes("USER-TURN") && serialized.includes("ASSISTANT-TURN"));
100
+ assert.ok(rest[0].payload.content[0].text.includes("Perplexity"), "orientation names origin");
101
+ }
102
+
103
+ // -- writing --------------------------------------------------------------
104
+ {
105
+ const home = mkdtempSync(join(tmpdir(), "pr-codex-home-"));
106
+ const originalHome = process.env.HOME;
107
+ process.env.HOME = home;
108
+ try {
109
+ const result = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.146.0" });
110
+
111
+ // The filename carries both the stamp and the id; Codex's picker reads both.
112
+ assert.match(result.path, /rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f-]{36}\.jsonl$/);
113
+ assert.ok(result.path.includes(result.sessionId));
114
+
115
+ const lines = readFileSync(result.path, "utf8").trim().split("\n");
116
+ assert.equal(lines.length, result.messageCount);
117
+ const parsed = lines.map((l) => JSON.parse(l));
118
+ assert.equal(parsed[0].type, "session_meta");
119
+ assert.equal(parsed[0].payload.id, result.sessionId);
120
+
121
+ // Never overwrite an existing session.
122
+ const again = writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 0.146.0" });
123
+ assert.notEqual(again.sessionId, result.sessionId);
124
+ assert.notEqual(again.path, result.path);
125
+
126
+ assert.throws(
127
+ () => writeSession(thread, { cwd: "/tmp/ws", version: "codex-cli 2.0.0" }),
128
+ /outside the range/,
129
+ );
130
+ assert.throws(() => writeSession(thread, { cwd: "/tmp/ws", version: null }), /outside the range/);
131
+ } finally {
132
+ process.env.HOME = originalHome;
133
+ }
134
+ }
135
+
136
+ console.log("thread-session-codex tests passed");