pennyrouter 0.2.10 → 0.2.12
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 +2 -2
- package/src/cli.js +116 -8
- package/src/harnesses/claude-code.js +33 -7
- package/src/launch.js +2 -1
- package/src/launch.test.js +2 -0
- package/src/mcp-register.js +193 -0
- package/src/mcp-register.test.js +171 -0
- package/src/mcp.js +44 -16
- package/src/mcp.test.js +55 -0
- package/src/thread-session-codex.js +152 -0
- package/src/thread-session-codex.test.js +136 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
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
|
-
|
|
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 [--
|
|
1847
|
+
pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
|
|
1744
1848
|
|
|
1745
|
-
|
|
1746
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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,22 @@ const LEGACY_PENNY_LABELS = [
|
|
|
33
36
|
"Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
|
|
34
37
|
];
|
|
35
38
|
|
|
39
|
+
// Used both for globally-installed Claude Code and `penny claude`, whose isolated
|
|
40
|
+
// launch environment otherwise bypasses ~/.claude/settings.json entirely.
|
|
41
|
+
export function writeClaudeCodeModelPresentation(env) {
|
|
42
|
+
for (const [slot, { name, description }] of Object.entries(CLAUDE_CODE_MODEL_PRESENTATION)) {
|
|
43
|
+
const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
|
|
44
|
+
env[`${prefix}_NAME`] = name;
|
|
45
|
+
env[`${prefix}_DESCRIPTION`] = description;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CLAUDE_CODE_MODEL_PRESENTATION_KEYS = Object.keys(CLAUDE_CODE_MODEL_PRESENTATION)
|
|
50
|
+
.flatMap((slot) => {
|
|
51
|
+
const prefix = `ANTHROPIC_DEFAULT_${slot.toUpperCase()}_MODEL`;
|
|
52
|
+
return [`${prefix}_NAME`, `${prefix}_DESCRIPTION`];
|
|
53
|
+
});
|
|
54
|
+
|
|
36
55
|
export const claudeCodeHarness = {
|
|
37
56
|
id: "claude-code",
|
|
38
57
|
name: "Claude Code",
|
|
@@ -69,6 +88,7 @@ export const claudeCodeHarness = {
|
|
|
69
88
|
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
70
89
|
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
71
90
|
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
|
|
91
|
+
writeClaudeCodeModelPresentation(current.env);
|
|
72
92
|
// This is global, not per-model. Leave it unset so Claude Code uses each native id's
|
|
73
93
|
// real context limit instead of incorrectly giving Haiku a 1M window.
|
|
74
94
|
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
@@ -94,6 +114,7 @@ export const claudeCodeHarness = {
|
|
|
94
114
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
95
115
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
96
116
|
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
117
|
+
...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
|
|
97
118
|
],
|
|
98
119
|
},
|
|
99
120
|
};
|
|
@@ -117,6 +138,7 @@ export const claudeCodeHarness = {
|
|
|
117
138
|
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
118
139
|
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
119
140
|
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
|
|
141
|
+
writeClaudeCodeModelPresentation(current.env);
|
|
120
142
|
// This is global, not per-model. Leave it unset so Claude Code uses each native id's
|
|
121
143
|
// real context limit instead of incorrectly giving Haiku a 1M window.
|
|
122
144
|
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
@@ -142,6 +164,7 @@ export const claudeCodeHarness = {
|
|
|
142
164
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
143
165
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
144
166
|
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
167
|
+
...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
|
|
145
168
|
],
|
|
146
169
|
},
|
|
147
170
|
};
|
|
@@ -161,6 +184,7 @@ export const claudeCodeHarness = {
|
|
|
161
184
|
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
162
185
|
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
163
186
|
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
|
|
187
|
+
writeClaudeCodeModelPresentation(current.env);
|
|
164
188
|
// This is global, not per-model. Leave it unset so Claude Code uses each native id's
|
|
165
189
|
// real context limit instead of incorrectly giving Haiku a 1M window.
|
|
166
190
|
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
@@ -186,6 +210,7 @@ export const claudeCodeHarness = {
|
|
|
186
210
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
187
211
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
188
212
|
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
213
|
+
...CLAUDE_CODE_MODEL_PRESENTATION_KEYS,
|
|
189
214
|
],
|
|
190
215
|
},
|
|
191
216
|
};
|
|
@@ -220,6 +245,7 @@ export const claudeCodeHarness = {
|
|
|
220
245
|
]) {
|
|
221
246
|
if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
|
|
222
247
|
}
|
|
248
|
+
for (const k of CLAUDE_CODE_MODEL_PRESENTATION_KEYS) delete current.env[k];
|
|
223
249
|
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
224
250
|
}
|
|
225
251
|
restoreClaudeAiConnectors(current, record);
|
package/src/launch.js
CHANGED
|
@@ -13,7 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
14
|
|
|
15
15
|
import { configureClaudeCodeStatusline } from "./statusline.js";
|
|
16
|
-
import { upgradeClaudeCodeModelValue } from "./harnesses/claude-code.js";
|
|
16
|
+
import { upgradeClaudeCodeModelValue, writeClaudeCodeModelPresentation } from "./harnesses/claude-code.js";
|
|
17
17
|
import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
|
|
18
18
|
|
|
19
19
|
const CONFIG_VERSION = 1;
|
|
@@ -438,6 +438,7 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
438
438
|
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
|
|
439
439
|
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
|
|
440
440
|
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
|
|
441
|
+
writeClaudeCodeModelPresentation(env);
|
|
441
442
|
// Per-model limits must come from Claude Code's recognized native model ids. This
|
|
442
443
|
// global override would incorrectly give Haiku the 1M window.
|
|
443
444
|
delete env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
package/src/launch.test.js
CHANGED
|
@@ -105,6 +105,8 @@ async function main() {
|
|
|
105
105
|
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
106
106
|
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5");
|
|
107
107
|
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
108
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5 (PennyRouter)");
|
|
109
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
|
|
108
110
|
assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
|
|
109
111
|
|
|
110
112
|
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
@@ -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
|
|
9
|
-
a
|
|
10
|
-
transcript never enters the caller's context, so moving a 200-turn
|
|
11
|
-
costs the caller nothing. That asymmetry is the whole reason for two
|
|
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
|
|
48
|
-
"session
|
|
49
|
-
"
|
|
50
|
-
"
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
167
|
+
codex
|
|
168
|
+
? ` cd ${cwd} && codex resume ${sessionId}`
|
|
169
|
+
: ` cd ${cwd} && claude --resume ${sessionId}`,
|
|
145
170
|
"",
|
|
146
|
-
|
|
147
|
-
"
|
|
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");
|