pennyrouter 0.2.9 → 0.2.10
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/LICENSE +21 -21
- package/README.md +225 -225
- package/bin/penny.js +9 -9
- package/bin/pennyrouter.js +7 -7
- package/package.json +30 -30
- package/src/cli.js +1774 -1727
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +322 -318
- package/src/harnesses/claude-code.test.js +57 -57
- package/src/harnesses/cline.js +77 -77
- package/src/harnesses/codegpt.js +76 -76
- package/src/harnesses/codex.js +180 -180
- package/src/harnesses/codex.test.js +119 -119
- package/src/harnesses/opencode.js +151 -151
- package/src/harnesses/shared.js +51 -51
- package/src/harnesses/windsurf.js +57 -57
- package/src/harnesses/zed.js +61 -61
- package/src/launch.js +487 -487
- package/src/launch.test.js +170 -153
- package/src/mcp.js +228 -0
- package/src/mcp.test.js +182 -0
- package/src/session.js +157 -157
- package/src/session.test.js +44 -44
- package/src/state.js +103 -103
- package/src/statusline.js +202 -202
- package/src/statusline.test.js +72 -72
- package/src/thread-session.js +204 -0
- package/src/thread-session.test.js +140 -0
package/src/launch.test.js
CHANGED
|
@@ -1,153 +1,170 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import os from "node:os";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
buildPennyInvocation,
|
|
8
|
-
claudeLaunchSettingsPath,
|
|
9
|
-
claudeUserSkillPath,
|
|
10
|
-
codexLaunchProfilePath,
|
|
11
|
-
configurePennyHarness,
|
|
12
|
-
fileMode,
|
|
13
|
-
installPennyRuntime,
|
|
14
|
-
loadPennyConfig,
|
|
15
|
-
pennyConfigPath,
|
|
16
|
-
pennyRuntimeDir,
|
|
17
|
-
pennyShimPath,
|
|
18
|
-
removePennyHarness,
|
|
19
|
-
removePennyRuntime,
|
|
20
|
-
renderCodexLaunchProfile,
|
|
21
|
-
} from "./launch.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
fs.mkdirSync(path.join(home, ".
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
fs.writeFileSync(path.join(home, ".
|
|
45
|
-
fs.writeFileSync(path.join(home, ".
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
fs.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert.equal(first.
|
|
65
|
-
assert.equal(
|
|
66
|
-
assert.equal(
|
|
67
|
-
assert.
|
|
68
|
-
assert.ok(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
assert.equal(
|
|
88
|
-
assert.equal(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
assert.equal(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
assert.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
assert.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.equal(claude.
|
|
102
|
-
assert.
|
|
103
|
-
assert.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
assert.
|
|
107
|
-
assert.equal(
|
|
108
|
-
assert.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
assert.
|
|
113
|
-
assert.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
assert.
|
|
130
|
-
assert.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
});
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildPennyInvocation,
|
|
8
|
+
claudeLaunchSettingsPath,
|
|
9
|
+
claudeUserSkillPath,
|
|
10
|
+
codexLaunchProfilePath,
|
|
11
|
+
configurePennyHarness,
|
|
12
|
+
fileMode,
|
|
13
|
+
installPennyRuntime,
|
|
14
|
+
loadPennyConfig,
|
|
15
|
+
pennyConfigPath,
|
|
16
|
+
pennyRuntimeDir,
|
|
17
|
+
pennyShimPath,
|
|
18
|
+
removePennyHarness,
|
|
19
|
+
removePennyRuntime,
|
|
20
|
+
renderCodexLaunchProfile,
|
|
21
|
+
} from "./launch.js";
|
|
22
|
+
import { claudeCodeHarness } from "./harnesses/claude-code.js";
|
|
23
|
+
|
|
24
|
+
const ENV_KEYS = [
|
|
25
|
+
"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "CODEX_HOME", "PATH", "SHELL",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
async function main() {
|
|
29
|
+
if (!(["darwin", "linux"].includes(process.platform))) {
|
|
30
|
+
console.log("launch.test.js: skipped on unsupported platform");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-launch-test-"));
|
|
35
|
+
const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
|
36
|
+
const home = path.join(root, "home");
|
|
37
|
+
fs.mkdirSync(path.join(home, ".claude"), { recursive: true });
|
|
38
|
+
fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
|
|
39
|
+
const nativeClaude = `${JSON.stringify({
|
|
40
|
+
permissions: { allow: ["Read"] },
|
|
41
|
+
statusLine: { type: "command", command: "native-status" },
|
|
42
|
+
}, null, 2)}\n`;
|
|
43
|
+
const nativeCodex = 'model = "native-model"\n';
|
|
44
|
+
fs.writeFileSync(path.join(home, ".claude", "settings.json"), nativeClaude);
|
|
45
|
+
fs.writeFileSync(path.join(home, ".codex", "config.toml"), nativeCodex);
|
|
46
|
+
fs.writeFileSync(path.join(home, ".zshrc"), "# user setting\n");
|
|
47
|
+
|
|
48
|
+
Object.assign(process.env, {
|
|
49
|
+
HOME: home,
|
|
50
|
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
|
51
|
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
|
52
|
+
XDG_STATE_HOME: path.join(home, ".local", "state"),
|
|
53
|
+
CODEX_HOME: path.join(home, ".codex"),
|
|
54
|
+
PATH: "/usr/bin:/bin",
|
|
55
|
+
SHELL: "/bin/zsh",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const first = await installPennyRuntime();
|
|
60
|
+
const staleSkill = path.join(pennyRuntimeDir(), "claude-plugin", "skills", "penny", "SKILL.md");
|
|
61
|
+
fs.mkdirSync(path.dirname(staleSkill), { recursive: true });
|
|
62
|
+
fs.writeFileSync(staleSkill, "stale plugin file");
|
|
63
|
+
const second = await installPennyRuntime();
|
|
64
|
+
assert.equal(first.supported, true);
|
|
65
|
+
assert.equal(first.changed, true);
|
|
66
|
+
assert.equal(second.changed, false);
|
|
67
|
+
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
68
|
+
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
69
|
+
assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
|
|
70
|
+
const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
|
|
71
|
+
assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
|
|
72
|
+
|
|
73
|
+
await configurePennyHarness("claude-code", {
|
|
74
|
+
apiKey: "pr-super-secret",
|
|
75
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
76
|
+
subscriptionAuth: true,
|
|
77
|
+
// Simulate an existing install. `penny claude` must fix this at launch,
|
|
78
|
+
// without requiring a separate reinstall before its context limits are right.
|
|
79
|
+
models: { haiku: "Haiku 4.5 (PennyRouter)", sonnet: "Sonnet 5 (PennyRouter)", opus: "Opus 5 (PennyRouter)" },
|
|
80
|
+
});
|
|
81
|
+
await configurePennyHarness("codex", {
|
|
82
|
+
apiKey: "pr-super-secret",
|
|
83
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
84
|
+
subscriptionAuth: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
88
|
+
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
89
|
+
assert.equal(await fileMode(pennyConfigPath()), 0o600);
|
|
90
|
+
assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
|
|
91
|
+
assert.equal(await fileMode(codexLaunchProfilePath()), 0o600);
|
|
92
|
+
|
|
93
|
+
const config = await loadPennyConfig();
|
|
94
|
+
assert.equal(config.api_key, "pr-super-secret");
|
|
95
|
+
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
96
|
+
assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
|
|
97
|
+
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
98
|
+
assert.ok(!rc.includes("pr-super-secret"));
|
|
99
|
+
|
|
100
|
+
const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
|
|
101
|
+
assert.equal(claude.command, "claude");
|
|
102
|
+
assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
|
|
103
|
+
assert.ok(!claude.args.includes("--plugin-dir"));
|
|
104
|
+
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
105
|
+
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
106
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5");
|
|
107
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
108
|
+
assert.equal(claude.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, undefined);
|
|
109
|
+
|
|
110
|
+
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
111
|
+
assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
|
|
112
|
+
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
113
|
+
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
114
|
+
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
115
|
+
|
|
116
|
+
const command = fs.readFileSync(claudeUserSkillPath(), "utf8");
|
|
117
|
+
assert.ok(command.includes("@penny $ARGUMENTS"));
|
|
118
|
+
assert.ok(command.includes("name: penny"));
|
|
119
|
+
|
|
120
|
+
// Persistent direct-Claude installs use the same native ids and must not
|
|
121
|
+
// leave the former global 1M override behind.
|
|
122
|
+
await claudeCodeHarness.install({
|
|
123
|
+
apiKey: "pr-super-secret",
|
|
124
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
125
|
+
plan: { configPath: path.join(home, ".claude", "settings.json") },
|
|
126
|
+
});
|
|
127
|
+
const persistedClaude = JSON.parse(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"));
|
|
128
|
+
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
|
|
129
|
+
assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5");
|
|
130
|
+
assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
|
|
131
|
+
|
|
132
|
+
await removePennyHarness("claude-code");
|
|
133
|
+
assert.ok(!fs.existsSync(claudeUserSkillPath()));
|
|
134
|
+
fs.mkdirSync(path.dirname(claudeUserSkillPath()), { recursive: true });
|
|
135
|
+
fs.writeFileSync(claudeUserSkillPath(), "user-owned command\n");
|
|
136
|
+
await assert.rejects(configurePennyHarness("claude-code", {
|
|
137
|
+
apiKey: "pr-super-secret",
|
|
138
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
139
|
+
models: {},
|
|
140
|
+
}), /not managed by PennyRouter/);
|
|
141
|
+
assert.equal(fs.readFileSync(claudeUserSkillPath(), "utf8"), "user-owned command\n");
|
|
142
|
+
assert.ok(fs.existsSync(codexLaunchProfilePath()));
|
|
143
|
+
assert.ok(fs.existsSync(pennyShimPath()));
|
|
144
|
+
await removePennyHarness("codex");
|
|
145
|
+
await removePennyRuntime();
|
|
146
|
+
assert.ok(!fs.existsSync(pennyShimPath()));
|
|
147
|
+
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
148
|
+
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
149
|
+
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
150
|
+
|
|
151
|
+
const paid = renderCodexLaunchProfile({
|
|
152
|
+
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|
|
153
|
+
});
|
|
154
|
+
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
155
|
+
assert.ok(!paid.includes("requires_openai_auth"));
|
|
156
|
+
} finally {
|
|
157
|
+
for (const key of ENV_KEYS) {
|
|
158
|
+
if (previous[key] === undefined) delete process.env[key];
|
|
159
|
+
else process.env[key] = previous[key];
|
|
160
|
+
}
|
|
161
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log("launch.test.js: all checks passed");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main().catch((error) => {
|
|
168
|
+
console.error(error);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
});
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/* PennyRouter MCP server (stdio).
|
|
2
|
+
|
|
3
|
+
Solves the last mile: a thread you can read in the browser is not a thread you can code from.
|
|
4
|
+
These two tools let an agent take a share link and either read it or *become* it.
|
|
5
|
+
|
|
6
|
+
thread_read fetch + parse, return the transcript to the model. For "what did we decide
|
|
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.
|
|
12
|
+
|
|
13
|
+
ALL SCRAPING STAYS SERVER-SIDE. This process holds no adapters, no fetcher credentials and no
|
|
14
|
+
provider knowledge; it POSTs a URL to the gateway and gets a canonical thread back. What ships
|
|
15
|
+
to a user's machine is a client, not the machinery.
|
|
16
|
+
|
|
17
|
+
Hand-rolled JSON-RPC rather than @modelcontextprotocol/sdk: this package has zero dependencies
|
|
18
|
+
and is installed by `npx pennyrouter`, where every transitive dep is a supply-chain surface and
|
|
19
|
+
a slower cold start. The protocol surface actually needed here is four methods. */
|
|
20
|
+
|
|
21
|
+
import { createInterface } from "node:readline";
|
|
22
|
+
import { execFileSync } from "node:child_process";
|
|
23
|
+
|
|
24
|
+
import { writeSession, VERIFIED_MIN, VERIFIED_MAX } from "./thread-session.js";
|
|
25
|
+
|
|
26
|
+
const PROTOCOL_VERSION = "2024-11-05";
|
|
27
|
+
|
|
28
|
+
const TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: "thread_read",
|
|
31
|
+
description:
|
|
32
|
+
"Fetch an AI conversation from a share link (ChatGPT, Claude, Gemini, Perplexity) and " +
|
|
33
|
+
"return its transcript. Use when the user wants to discuss, summarize or ask about a " +
|
|
34
|
+
"past conversation. The transcript enters your context — for long threads that are " +
|
|
35
|
+
"meant to be continued as work, prefer thread_resume.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
url: { type: "string", description: "The share link to read." },
|
|
40
|
+
},
|
|
41
|
+
required: ["url"],
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "thread_resume",
|
|
46
|
+
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 " +
|
|
51
|
+
"deliberately NOT returned, so importing a long thread costs you no context.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
url: { type: "string", description: "The share link to import." },
|
|
56
|
+
directory: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description:
|
|
59
|
+
"Absolute path to the workspace the session should resume in. Defaults to the " +
|
|
60
|
+
"current working directory.",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ["url"],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function claudeVersion() {
|
|
69
|
+
try {
|
|
70
|
+
return execFileSync("claude", ["--version"], { encoding: "utf8" }).trim();
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fetchCanonical(url, { gatewayBaseUrl, apiKey }) {
|
|
77
|
+
const response = await fetch(`${gatewayBaseUrl}/v1/penny/threads/canonical`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: {
|
|
80
|
+
"content-type": "application/json",
|
|
81
|
+
authorization: `Bearer ${apiKey}`,
|
|
82
|
+
},
|
|
83
|
+
body: JSON.stringify({ url }),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
let detail = `HTTP ${response.status}`;
|
|
88
|
+
try {
|
|
89
|
+
const body = await response.json();
|
|
90
|
+
// The gateway's 400s carry copy written for a person; surface it rather than a status.
|
|
91
|
+
if (body?.detail) detail = body.detail;
|
|
92
|
+
} catch {
|
|
93
|
+
/* non-JSON error body; the status is all we have */
|
|
94
|
+
}
|
|
95
|
+
throw new Error(detail);
|
|
96
|
+
}
|
|
97
|
+
return response.json();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function transcript(thread) {
|
|
101
|
+
const lines = [`# ${thread.title || "Imported thread"}`];
|
|
102
|
+
if (thread.source_url) lines.push(`Source: ${thread.source_url}`);
|
|
103
|
+
lines.push("");
|
|
104
|
+
for (const message of thread.messages || []) {
|
|
105
|
+
const body = (message.blocks || [])
|
|
106
|
+
.map((b) => {
|
|
107
|
+
if (b.type === "text") return b.text;
|
|
108
|
+
if (b.type === "file") return ["```" + (b.language || ""), b.text, "```"].join("\n");
|
|
109
|
+
if (b.type === "thinking") return null;
|
|
110
|
+
if (b.type === "tool_use") return `[used ${b.name}]`;
|
|
111
|
+
if (b.type === "tool_result") return `[tool result] ${b.output || ""}`;
|
|
112
|
+
return null;
|
|
113
|
+
})
|
|
114
|
+
.filter((t) => t && String(t).trim())
|
|
115
|
+
.join("\n\n");
|
|
116
|
+
if (!body.trim()) continue;
|
|
117
|
+
lines.push(`## ${message.role}`, body, "");
|
|
118
|
+
}
|
|
119
|
+
return lines.join("\n").trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function callTool(name, args, context) {
|
|
123
|
+
const url = String(args?.url || "").trim();
|
|
124
|
+
if (!url) throw new Error("a share link is required");
|
|
125
|
+
|
|
126
|
+
if (name === "thread_read") {
|
|
127
|
+
const thread = await fetchCanonical(url, context);
|
|
128
|
+
return transcript(thread);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (name === "thread_resume") {
|
|
132
|
+
const cwd = String(args?.directory || process.cwd());
|
|
133
|
+
const version = claudeVersion();
|
|
134
|
+
const thread = await fetchCanonical(url, context);
|
|
135
|
+
const { sessionId, path, messageCount } = writeSession(thread, { cwd, version });
|
|
136
|
+
return [
|
|
137
|
+
`Imported "${thread.title}" (${thread.messages?.length || 0} messages, ` +
|
|
138
|
+
`~${thread.token_estimate || 0} tokens) from ${thread.origin_app || thread.source}.`,
|
|
139
|
+
"",
|
|
140
|
+
`Wrote a resumable session: ${messageCount} records`,
|
|
141
|
+
` ${path}`,
|
|
142
|
+
"",
|
|
143
|
+
"Resume it with:",
|
|
144
|
+
` cd ${cwd} && claude --resume ${sessionId}`,
|
|
145
|
+
"",
|
|
146
|
+
"The transcript was written to disk and deliberately not returned here, so it did not " +
|
|
147
|
+
"consume this session's context.",
|
|
148
|
+
].join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
throw new Error(`unknown tool: ${name}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function send(message) {
|
|
155
|
+
process.stdout.write(JSON.stringify(message) + "\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function reply(id, result) {
|
|
159
|
+
send({ jsonrpc: "2.0", id, result });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Tool failures are reported as results with isError, not as JSON-RPC errors: the model should
|
|
163
|
+
see what went wrong and be able to tell the user, not have the call vanish into the harness. */
|
|
164
|
+
function toolError(id, error) {
|
|
165
|
+
reply(id, {
|
|
166
|
+
content: [{ type: "text", text: `Failed: ${error?.message || String(error)}` }],
|
|
167
|
+
isError: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
|
|
172
|
+
if (!apiKey) {
|
|
173
|
+
process.stderr.write(
|
|
174
|
+
"pennyrouter mcp: no API key. Run `npx pennyrouter install` first.\n",
|
|
175
|
+
);
|
|
176
|
+
process.exitCode = 1;
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const context = { gatewayBaseUrl, apiKey };
|
|
181
|
+
const rl = createInterface({ input: process.stdin });
|
|
182
|
+
|
|
183
|
+
for await (const line of rl) {
|
|
184
|
+
if (!line.trim()) continue;
|
|
185
|
+
|
|
186
|
+
let request;
|
|
187
|
+
try {
|
|
188
|
+
request = JSON.parse(line);
|
|
189
|
+
} catch {
|
|
190
|
+
continue; // a malformed frame has no id to answer to
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const { id, method, params } = request;
|
|
194
|
+
try {
|
|
195
|
+
if (method === "initialize") {
|
|
196
|
+
reply(id, {
|
|
197
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
198
|
+
capabilities: { tools: {} },
|
|
199
|
+
serverInfo: { name: "pennyrouter", version: "1.0.0" },
|
|
200
|
+
});
|
|
201
|
+
} else if (method === "tools/list") {
|
|
202
|
+
reply(id, { tools: TOOLS });
|
|
203
|
+
} else if (method === "tools/call") {
|
|
204
|
+
try {
|
|
205
|
+
const text = await callTool(params?.name, params?.arguments || {}, context);
|
|
206
|
+
reply(id, { content: [{ type: "text", text }] });
|
|
207
|
+
} catch (error) {
|
|
208
|
+
toolError(id, error);
|
|
209
|
+
}
|
|
210
|
+
} else if (method === "ping") {
|
|
211
|
+
reply(id, {});
|
|
212
|
+
} else if (id !== undefined) {
|
|
213
|
+
// Notifications (no id) are fire-and-forget and must never be answered.
|
|
214
|
+
send({ jsonrpc: "2.0", id, error: { code: -32601, message: `unknown method: ${method}` } });
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (id !== undefined) {
|
|
218
|
+
send({
|
|
219
|
+
jsonrpc: "2.0",
|
|
220
|
+
id,
|
|
221
|
+
error: { code: -32603, message: error?.message || String(error) },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export { VERIFIED_MIN, VERIFIED_MAX };
|