pennyrouter 0.2.14 → 0.2.15

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.14",
3
+ "version": "0.2.15",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -690,7 +690,11 @@ async function serveMcp(flags) {
690
690
  || process.env.PENNYROUTER_GATEWAY_BASE_URL
691
691
  || "https://api.pennyrouter.com";
692
692
 
693
- await runMcpServer({ gatewayBaseUrl, apiKey });
693
+ await runMcpServer({
694
+ gatewayBaseUrl,
695
+ apiKey,
696
+ harness: process.env.PENNYROUTER_MCP_HARNESS,
697
+ });
694
698
  }
695
699
 
696
700
  // Refresh the managed local files to this package version WITHOUT re-running any of the
@@ -30,8 +30,12 @@ const MANAGED_START = "# --- PennyRouter managed MCP server ---";
30
30
  const MANAGED_END = "# --- end PennyRouter managed MCP server ---";
31
31
 
32
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"] };
33
+ export function serverEntry(harness = "claude-code") {
34
+ return {
35
+ command: "npx",
36
+ args: ["-y", "pennyrouter", "mcp"],
37
+ env: { PENNYROUTER_MCP_HARNESS: harness },
38
+ };
35
39
  }
36
40
 
37
41
  /** True when an entry is one of ours, so re-installing updates rather than duplicating and
@@ -59,7 +63,7 @@ export function applyClaudeRegistry(config, { remove = false } = {}) {
59
63
  "write. Rename it before installing so your configuration is not overwritten.",
60
64
  );
61
65
  }
62
- servers[SERVER_NAME] = serverEntry();
66
+ servers[SERVER_NAME] = serverEntry("claude-code");
63
67
  }
64
68
 
65
69
  if (Object.keys(servers).length) next.mcpServers = servers;
@@ -96,12 +100,13 @@ export function renderCodexMcp(text, { remove = false } = {}) {
96
100
  );
97
101
  }
98
102
 
99
- const entry = serverEntry();
103
+ const entry = serverEntry("codex");
100
104
  const block = [
101
105
  MANAGED_START,
102
106
  `[mcp_servers.${SERVER_NAME}]`,
103
107
  `command = "${entry.command}"`,
104
108
  `args = [${entry.args.map((a) => `"${a}"`).join(", ")}]`,
109
+ `env = { PENNYROUTER_MCP_HARNESS = "${entry.env.PENNYROUTER_MCP_HARNESS}" }`,
105
110
  MANAGED_END,
106
111
  ].join("\n");
107
112
 
@@ -17,6 +17,7 @@ import {
17
17
  const entry = serverEntry();
18
18
  assert.equal(entry.command, "npx");
19
19
  assert.deepEqual(entry.args, ["-y", "pennyrouter", "mcp"]);
20
+ assert.equal(entry.env.PENNYROUTER_MCP_HARNESS, "claude-code");
20
21
  assert.ok(!JSON.stringify(entry).includes("/"), "no absolute paths");
21
22
  assert.ok(!/pennyrouter@/.test(JSON.stringify(entry)), "not version-pinned");
22
23
  }
@@ -85,6 +86,7 @@ assert.equal(isManagedEntry({}), false);
85
86
  assert.ok(written.includes(`[mcp_servers.${SERVER_NAME}]`));
86
87
  assert.ok(written.includes('command = "npx"'));
87
88
  assert.ok(written.includes('args = ["-y", "pennyrouter", "mcp"]'));
89
+ assert.ok(written.includes('PENNYROUTER_MCP_HARNESS = "codex"'));
88
90
  assert.ok(written.includes("[mcp_servers.node_repl]"), "existing servers survive");
89
91
 
90
92
  // A TOML table header captures every key after it, so our block must be appended — splicing
package/src/mcp.js CHANGED
@@ -30,6 +30,13 @@ import { writeSession, VERIFIED_MIN, VERIFIED_MAX } from "./thread-session.js";
30
30
  import { writeSession as writeCodexSession } from "./thread-session-codex.js";
31
31
 
32
32
  const PROTOCOL_VERSION = "2024-11-05";
33
+ const SERVER_INSTRUCTIONS =
34
+ "When the user provides an AI share link and asks to resume, continue, import, or build from " +
35
+ "that conversation in a coding agent, call a PennyRouter thread tool immediately. For the exact " +
36
+ "command '@penny import session <link>', use thread_read when it is the first user turn so the " +
37
+ "conversation enters the current session; otherwise use thread_resume to create a fresh native " +
38
+ "session. Do not search the local workspace, fetch the link directly, or look for another resume " +
39
+ "utility first.";
33
40
 
34
41
  const TOOLS = [
35
42
  {
@@ -39,6 +46,8 @@ const TOOLS = [
39
46
  "return its transcript. Use when the user wants to discuss, summarize or ask about a " +
40
47
  "past conversation. The transcript enters your context — for long threads that are " +
41
48
  "meant to be continued as work, prefer thread_resume. " +
49
+ "For `@penny import session <link>` on the FIRST user turn, call this tool immediately: " +
50
+ "the desired result is to continue in the current session. " +
42
51
  "This tool is the only supported way to read a share link: those pages are " +
43
52
  "JavaScript-rendered shells that return markup, not conversation, to a plain fetch. Never " +
44
53
  "fall back to fetching the URL directly, and never guess at the contents. If this call " +
@@ -57,8 +66,13 @@ const TOOLS = [
57
66
  description:
58
67
  "Fetch an AI conversation from a share link and write it as a resumable coding-agent " +
59
68
  "session (Claude Code or Codex), so the user can resume into it and continue coding with " +
60
- "that conversation as history. Use when the user wants to CONTINUE or BUILD FROM a past " +
61
- "chat rather than talk about it. Returns a session id and path; the transcript is " +
69
+ "that conversation as history. When the user asks to RESUME, CONTINUE, IMPORT, or BUILD " +
70
+ "FROM a share link, call this tool immediately — do not search the workspace or look for " +
71
+ "another resume utility first. Use this instead of thread_read when they want to work from " +
72
+ "the conversation rather than discuss it. For `@penny import session <link>` AFTER the " +
73
+ "first user turn, call this tool: a new native session avoids injecting a large transcript " +
74
+ "into an active conversation. The configured harness is the default; do not guess the other " +
75
+ "one. Returns a session id and path; the transcript is " +
62
76
  "deliberately NOT returned, so importing a long thread costs you no context. That is by " +
63
77
  "design and is not a failure — do not read the thread another way to 'check' it, and do " +
64
78
  "not fetch the share URL directly. Report the resume command to the user. If this call " +
@@ -77,8 +91,8 @@ const TOOLS = [
77
91
  type: "string",
78
92
  enum: ["claude-code", "codex"],
79
93
  description:
80
- "Which agent should be able to resume it. Defaults to claude-code. Use codex when " +
81
- "the user asks to continue the conversation in Codex.",
94
+ "Which agent should be able to resume it. Defaults to the harness that launched this " +
95
+ "MCP server. Supply this only to override that active harness.",
82
96
  },
83
97
  },
84
98
  required: ["url"],
@@ -153,7 +167,7 @@ async function callTool(name, args, context) {
153
167
 
154
168
  if (name === "thread_resume") {
155
169
  const cwd = String(args?.directory || process.cwd());
156
- const harness = String(args?.harness || "claude-code").toLowerCase();
170
+ const harness = String(args?.harness || context.harness || "claude-code").toLowerCase();
157
171
  if (harness !== "claude-code" && harness !== "codex") {
158
172
  throw new Error(`unknown harness "${harness}"; expected claude-code or codex`);
159
173
  }
@@ -204,7 +218,7 @@ function toolError(id, error) {
204
218
  });
205
219
  }
206
220
 
207
- export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
221
+ export async function runMcpServer({ gatewayBaseUrl, apiKey, harness = process.env.PENNYROUTER_MCP_HARNESS }) {
208
222
  if (!apiKey) {
209
223
  process.stderr.write(
210
224
  "pennyrouter mcp: no API key. Run `npx pennyrouter install` first.\n",
@@ -213,7 +227,7 @@ export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
213
227
  return;
214
228
  }
215
229
 
216
- const context = { gatewayBaseUrl, apiKey };
230
+ const context = { gatewayBaseUrl, apiKey, harness };
217
231
  const rl = createInterface({ input: process.stdin });
218
232
 
219
233
  for await (const line of rl) {
@@ -233,6 +247,7 @@ export async function runMcpServer({ gatewayBaseUrl, apiKey }) {
233
247
  protocolVersion: PROTOCOL_VERSION,
234
248
  capabilities: { tools: {} },
235
249
  serverInfo: { name: "pennyrouter", version: "1.0.0" },
250
+ instructions: SERVER_INSTRUCTIONS,
236
251
  });
237
252
  } else if (method === "tools/list") {
238
253
  reply(id, { tools: TOOLS });
package/src/mcp.test.js CHANGED
@@ -41,12 +41,15 @@ function stubGateway(handler) {
41
41
  }
42
42
 
43
43
  /** Drive the server over stdio and collect the responses to `requests`. */
44
- function rpc(requests, { port, home }) {
44
+ function rpc(requests, { port, home, harness }) {
45
45
  return new Promise((resolve, reject) => {
46
46
  const child = spawn(
47
47
  process.execPath,
48
48
  [binary, "mcp", "--penny-key", "pr-test", "--gateway-base-url", `http://127.0.0.1:${port}`],
49
- { stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, HOME: home } },
49
+ {
50
+ stdio: ["pipe", "pipe", "pipe"],
51
+ env: { ...process.env, HOME: home, ...(harness ? { PENNYROUTER_MCP_HARNESS: harness } : {}) },
52
+ },
50
53
  );
51
54
  let out = "";
52
55
  let err = "";
@@ -108,6 +111,8 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
108
111
 
109
112
  assert.equal(byId.get(1).result.protocolVersion, "2024-11-05");
110
113
  assert.equal(byId.get(1).result.serverInfo.name, "pennyrouter");
114
+ assert.match(byId.get(1).result.instructions, /thread_read when it is the first user turn/i);
115
+ assert.match(byId.get(1).result.instructions, /otherwise use thread_resume/i);
111
116
 
112
117
  assert.deepEqual(
113
118
  byId.get(2).result.tools.map((t) => t.name),
@@ -122,6 +127,10 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
122
127
  assert.match(tool.description, /not fetch|Never fall back to fetching/i, tool.name);
123
128
  assert.match(tool.description, /blocked/i, tool.name);
124
129
  }
130
+ assert.match(
131
+ byId.get(2).result.tools.find((tool) => tool.name === "thread_resume").description,
132
+ /do not search the workspace/i,
133
+ );
125
134
 
126
135
  // The URL is forwarded to the gateway under the caller's key; no provider logic runs here.
127
136
  assert.equal(JSON.parse(requestBody).url, "https://www.perplexity.ai/search/x");
@@ -161,7 +170,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
161
170
  method: "tools/call",
162
171
  params: {
163
172
  name: "thread_resume",
164
- arguments: { url: "https://x/y", directory: workspace, harness: "codex" },
173
+ arguments: { url: "https://x/y", directory: workspace },
165
174
  },
166
175
  },
167
176
  {
@@ -174,7 +183,7 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
174
183
  },
175
184
  },
176
185
  ],
177
- { port, home },
186
+ { port, home, harness: "codex" },
178
187
  );
179
188
  server.close();
180
189