pennyrouter 0.2.8 → 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.
@@ -1,72 +1,72 @@
1
- // Guards against the class of bug found 2026-07-18: an unescaped backtick inside a
2
- // comment on line 99 of statusline.js terminated the STATUSLINE_SCRIPT template
3
- // literal early, so every `pennyrouter install` since shipped a syntactically broken
4
- // generated script — installs succeeded silently, the crash only surfaced later, at
5
- // Claude Code's next statusline render, in a completely different process.
6
- //
7
- // `npm run check` (node --check on every source file) happened to catch this ONE
8
- // instance because the break also broke the OUTER file's parse — but that's
9
- // incidental: a subtler bug (backticks that still balance, or valid-looking JS that's
10
- // semantically wrong) would pass --check on the outer file yet still crash at
11
- // runtime, because --check never looks at the STRING CONTENT of a template literal.
12
- // This test extracts the ACTUAL constant (not a source-text regex) and validates the
13
- // real generated script: syntax-checks it, then executes it end-to-end with a fake
14
- // stdin payload the way Claude Code actually invokes it, so a broken script fails
15
- // `npm run check` locally instead of a user's install.
16
- //
17
- // No test framework in this package yet — plain assert + a process exit code, run via
18
- // `node src/statusline.test.js` (wired into `npm run check`).
19
- import assert from "node:assert/strict";
20
- import { execFileSync } from "node:child_process";
21
- import fs from "node:fs";
22
- import os from "node:os";
23
- import path from "node:path";
24
-
25
- import { configureClaudeCodeStatusline, statuslineScriptPath } from "./statusline.js";
26
-
27
- async function main() {
28
- const tmpState = fs.mkdtempSync(path.join(os.tmpdir(), "pr-statusline-test-"));
29
- const prevXdg = process.env.XDG_STATE_HOME;
30
- process.env.XDG_STATE_HOME = tmpState;
31
- try {
32
- const current = {};
33
- await configureClaudeCodeStatusline(current);
34
- const scriptPath = statuslineScriptPath();
35
- assert.ok(fs.existsSync(scriptPath), "script file was written");
36
-
37
- // 1. Syntax: node --check on the ACTUAL written file (not a regex-extracted copy).
38
- execFileSync(process.execPath, ["--check", scriptPath], { stdio: "pipe" });
39
-
40
- // 2. Behavior: run it the way Claude Code does — JSON on stdin, plain stdout.
41
- // The generated script may discover a real Claude config from the invoking user's
42
- // home, but must render a valid Penny segment either way. XDG_STATE_HOME is isolated
43
- // above, so no cached session state or wrapped command can influence this check.
44
- const payload = JSON.stringify({
45
- session_id: "test-session",
46
- model: { display_name: "Test Model" },
47
- workspace: { current_dir: "/tmp" },
48
- });
49
- const out = execFileSync(process.execPath, [scriptPath], {
50
- input: payload, encoding: "utf8", timeout: 5000,
51
- });
52
- assert.ok(out.includes("🪙 penny"), `expected Penny status segment in output, got: ${out}`);
53
- assert.ok(out.includes("Test Model"), `expected model name passed through, got: ${out}`);
54
-
55
- // 3. Malformed stdin must not crash the script (best-effort JSON.parse fallback).
56
- const outBadInput = execFileSync(process.execPath, [scriptPath], {
57
- input: "not json", encoding: "utf8", timeout: 5000,
58
- });
59
- assert.ok(outBadInput.length > 0, "script still prints something on bad stdin");
60
- } finally {
61
- if (prevXdg === undefined) delete process.env.XDG_STATE_HOME;
62
- else process.env.XDG_STATE_HOME = prevXdg;
63
- fs.rmSync(tmpState, { recursive: true, force: true });
64
- }
65
-
66
- console.log("statusline.test.js: all checks passed");
67
- }
68
-
69
- main().catch((err) => {
70
- console.error(err);
71
- process.exit(1);
72
- });
1
+ // Guards against the class of bug found 2026-07-18: an unescaped backtick inside a
2
+ // comment on line 99 of statusline.js terminated the STATUSLINE_SCRIPT template
3
+ // literal early, so every `pennyrouter install` since shipped a syntactically broken
4
+ // generated script — installs succeeded silently, the crash only surfaced later, at
5
+ // Claude Code's next statusline render, in a completely different process.
6
+ //
7
+ // `npm run check` (node --check on every source file) happened to catch this ONE
8
+ // instance because the break also broke the OUTER file's parse — but that's
9
+ // incidental: a subtler bug (backticks that still balance, or valid-looking JS that's
10
+ // semantically wrong) would pass --check on the outer file yet still crash at
11
+ // runtime, because --check never looks at the STRING CONTENT of a template literal.
12
+ // This test extracts the ACTUAL constant (not a source-text regex) and validates the
13
+ // real generated script: syntax-checks it, then executes it end-to-end with a fake
14
+ // stdin payload the way Claude Code actually invokes it, so a broken script fails
15
+ // `npm run check` locally instead of a user's install.
16
+ //
17
+ // No test framework in this package yet — plain assert + a process exit code, run via
18
+ // `node src/statusline.test.js` (wired into `npm run check`).
19
+ import assert from "node:assert/strict";
20
+ import { execFileSync } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ import { configureClaudeCodeStatusline, statuslineScriptPath } from "./statusline.js";
26
+
27
+ async function main() {
28
+ const tmpState = fs.mkdtempSync(path.join(os.tmpdir(), "pr-statusline-test-"));
29
+ const prevXdg = process.env.XDG_STATE_HOME;
30
+ process.env.XDG_STATE_HOME = tmpState;
31
+ try {
32
+ const current = {};
33
+ await configureClaudeCodeStatusline(current);
34
+ const scriptPath = statuslineScriptPath();
35
+ assert.ok(fs.existsSync(scriptPath), "script file was written");
36
+
37
+ // 1. Syntax: node --check on the ACTUAL written file (not a regex-extracted copy).
38
+ execFileSync(process.execPath, ["--check", scriptPath], { stdio: "pipe" });
39
+
40
+ // 2. Behavior: run it the way Claude Code does — JSON on stdin, plain stdout.
41
+ // The generated script may discover a real Claude config from the invoking user's
42
+ // home, but must render a valid Penny segment either way. XDG_STATE_HOME is isolated
43
+ // above, so no cached session state or wrapped command can influence this check.
44
+ const payload = JSON.stringify({
45
+ session_id: "test-session",
46
+ model: { display_name: "Test Model" },
47
+ workspace: { current_dir: "/tmp" },
48
+ });
49
+ const out = execFileSync(process.execPath, [scriptPath], {
50
+ input: payload, encoding: "utf8", timeout: 5000,
51
+ });
52
+ assert.ok(out.includes("🪙 penny"), `expected Penny status segment in output, got: ${out}`);
53
+ assert.ok(out.includes("Test Model"), `expected model name passed through, got: ${out}`);
54
+
55
+ // 3. Malformed stdin must not crash the script (best-effort JSON.parse fallback).
56
+ const outBadInput = execFileSync(process.execPath, [scriptPath], {
57
+ input: "not json", encoding: "utf8", timeout: 5000,
58
+ });
59
+ assert.ok(outBadInput.length > 0, "script still prints something on bad stdin");
60
+ } finally {
61
+ if (prevXdg === undefined) delete process.env.XDG_STATE_HOME;
62
+ else process.env.XDG_STATE_HOME = prevXdg;
63
+ fs.rmSync(tmpState, { recursive: true, force: true });
64
+ }
65
+
66
+ console.log("statusline.test.js: all checks passed");
67
+ }
68
+
69
+ main().catch((err) => {
70
+ console.error(err);
71
+ process.exit(1);
72
+ });
@@ -0,0 +1,204 @@
1
+ /* Write a canonical thread into a Claude Code session file it will resume from.
2
+
3
+ `claude --resume` enumerates ~/.claude/projects/<flattened-cwd>/*.jsonl and offers what it
4
+ finds, so a synthesized file appears in the picker and resumes with the history in context.
5
+ Verified end-to-end on 2.1.226: a file written by this shape resumed and the model quoted back
6
+ a turn it never actually saw.
7
+
8
+ THE FORMAT IS UNDOCUMENTED. It is read from ~140 real local sessions, not from a spec, and
9
+ Claude Code updates itself. Three rules follow, and none of them are optional:
10
+
11
+ 1. Never write over an existing session. Every synthesis gets a fresh uuid, so the worst
12
+ case is a stray file in the picker rather than a destroyed conversation.
13
+ 2. Version-gate. Outside the range this was verified against, write the brief instead of a
14
+ session file and say so — a malformed session file is worse than no session file,
15
+ because the user finds out at resume time.
16
+ 3. Records must chain. `parentUuid` is a linked list; a break in it truncates the history
17
+ silently, which looks like a working import that lost half the conversation.
18
+
19
+ Only `user` and `assistant` records are needed to reconstruct a conversation. Tool blocks are
20
+ deliberately NOT replayed as `tool_use`/`tool_result` pairs: those reference call ids the new
21
+ session's tools never issued, and a dangling pair is the one thing observed to make a resumed
22
+ session misbehave rather than merely look odd. They are rendered as prose instead. */
23
+
24
+ import { randomUUID } from "node:crypto";
25
+ import { mkdirSync, existsSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join } from "node:path";
28
+
29
+ /** Versions this writer has been verified against. */
30
+ export const VERIFIED_MIN = "2.0.0";
31
+ export const VERIFIED_MAX = "2.999.999";
32
+
33
+ export function projectDirFor(cwd) {
34
+ // Claude Code flattens the workspace path into a single directory name: every separator and
35
+ // dot becomes "-". Confirmed against the real ~/.claude/projects listing.
36
+ return join(homedir(), ".claude", "projects", cwd.replace(/[/\\.]/g, "-"));
37
+ }
38
+
39
+ function compare(a, b) {
40
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
41
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
42
+ for (let i = 0; i < 3; i += 1) {
43
+ if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) < (pb[i] || 0) ? -1 : 1;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ export function versionSupported(version) {
49
+ if (!version) return false;
50
+ const clean = String(version).trim().split(/\s+/)[0];
51
+ return compare(clean, VERIFIED_MIN) >= 0 && compare(clean, VERIFIED_MAX) <= 0;
52
+ }
53
+
54
+ /** One block rendered as the text a resumed model should read. */
55
+ function renderBlock(block) {
56
+ switch (block?.type) {
57
+ case "text":
58
+ return block.text || "";
59
+ case "thinking":
60
+ // One model's reasoning trace is noise to a different model picking the work up.
61
+ return "";
62
+ case "tool_use": {
63
+ const input = block.input ? ` ${JSON.stringify(block.input)}` : "";
64
+ return `[used ${block.name}${input}]`.slice(0, 2000);
65
+ }
66
+ case "tool_result": {
67
+ const status = block.ok === false ? "failed" : "result";
68
+ return `[${block.name || "tool"} ${status}] ${block.output || ""}`.slice(0, 2000);
69
+ }
70
+ case "file":
71
+ return ["```" + (block.language || ""), block.text || "", "```"].join("\n");
72
+ case "diff":
73
+ return ["```diff", block.patch || "", "```"].join("\n");
74
+ case "image":
75
+ return `[image${block.alt ? `: ${block.alt}` : ""}]`;
76
+ default:
77
+ return "";
78
+ }
79
+ }
80
+
81
+ export function renderMessage(message) {
82
+ return (message.blocks || [])
83
+ .map(renderBlock)
84
+ .filter((t) => t && t.trim())
85
+ .join("\n\n")
86
+ .trim();
87
+ }
88
+
89
+ /** The record that tells the resumed agent what it is looking at. */
90
+ export function orientation(thread) {
91
+ const origin = thread.origin_app || thread.source || "another assistant";
92
+ return [
93
+ `This session was imported by PennyRouter from a ${origin} conversation.`,
94
+ thread.source_url ? `Source: ${thread.source_url}` : null,
95
+ "",
96
+ "The turns that follow are that conversation, replayed as history. They are a record of",
97
+ "what was discussed elsewhere — not work this session performed. Tool calls in it were",
98
+ "rendered as text, so no file in this workspace has been touched on their account.",
99
+ "",
100
+ "Pick up from where it left off. Do not re-litigate decisions that were already settled.",
101
+ ]
102
+ .filter((line) => line !== null)
103
+ .join("\n");
104
+ }
105
+
106
+ /**
107
+ * Build the JSONL records for a thread. Exported separately from the write so the chain can be
108
+ * asserted in tests without touching the real ~/.claude directory.
109
+ */
110
+ export function buildRecords(thread, { cwd, sessionId, version, gitBranch = "" }) {
111
+ const now = new Date().toISOString();
112
+ const base = {
113
+ isSidechain: false,
114
+ userType: "external",
115
+ cwd,
116
+ sessionId,
117
+ version,
118
+ gitBranch,
119
+ };
120
+
121
+ const records = [];
122
+ let parentUuid = null;
123
+ const push = (type, message) => {
124
+ const uuid = randomUUID();
125
+ records.push({ ...base, parentUuid, uuid, type, timestamp: now, message });
126
+ parentUuid = uuid;
127
+ };
128
+
129
+ push("user", { role: "user", content: orientation(thread) });
130
+ push("assistant", {
131
+ role: "assistant",
132
+ model: thread.origin_model || "claude-opus-4",
133
+ content: [
134
+ {
135
+ type: "text",
136
+ text: "Understood — I have the imported conversation and will continue from it.",
137
+ },
138
+ ],
139
+ });
140
+
141
+ for (const message of thread.messages || []) {
142
+ const text = renderMessage(message);
143
+ if (!text) continue;
144
+ if (message.role === "assistant") {
145
+ push("assistant", {
146
+ role: "assistant",
147
+ model: message.model || thread.origin_model || "claude-opus-4",
148
+ content: [{ type: "text", text }],
149
+ });
150
+ } else if (message.role === "user") {
151
+ push("user", { role: "user", content: text });
152
+ }
153
+ // `system` and `tool` roles carry no turn a resumed agent should replay as its own.
154
+ }
155
+
156
+ return records;
157
+ }
158
+
159
+ /** Assert the linked list is intact: one root, and every parent already seen before its child. */
160
+ export function assertChained(records) {
161
+ const seen = new Set();
162
+ let roots = 0;
163
+ for (const record of records) {
164
+ if (record.parentUuid === null) roots += 1;
165
+ else if (!seen.has(record.parentUuid)) {
166
+ throw new Error(`session records are not chained (orphan ${record.uuid})`);
167
+ }
168
+ seen.add(record.uuid);
169
+ }
170
+ if (roots !== 1) throw new Error(`session must have exactly one root, found ${roots}`);
171
+ }
172
+
173
+ /**
174
+ * Write `thread` as a resumable Claude Code session in `cwd`.
175
+ * Returns { sessionId, path, messageCount }.
176
+ */
177
+ export function writeSession(thread, { cwd, version, gitBranch = "" }) {
178
+ if (!versionSupported(version)) {
179
+ throw new Error(
180
+ `Claude Code ${version || "(unknown version)"} is outside the range this importer has ` +
181
+ `been verified against (${VERIFIED_MIN}–${VERIFIED_MAX}). Refusing to write a session ` +
182
+ `file that may not resume.`,
183
+ );
184
+ }
185
+
186
+ const dir = projectDirFor(cwd);
187
+ mkdirSync(dir, { recursive: true });
188
+
189
+ const sessionId = randomUUID();
190
+ const path = join(dir, `${sessionId}.jsonl`);
191
+ // A fresh uuid cannot collide in practice; checking anyway is what makes "never overwrite an
192
+ // existing session" a property of the code rather than a probability.
193
+ if (existsSync(path)) throw new Error("session id collision; try again");
194
+
195
+ const records = buildRecords(thread, { cwd, sessionId, version, gitBranch });
196
+ assertChained(records);
197
+
198
+ writeFileSync(path, records.map((r) => JSON.stringify(r)).join("\n") + "\n", {
199
+ encoding: "utf8",
200
+ flag: "wx", // fail rather than clobber, even against a race
201
+ });
202
+
203
+ return { sessionId, path, messageCount: records.length };
204
+ }
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ assertChained,
8
+ buildRecords,
9
+ orientation,
10
+ projectDirFor,
11
+ renderMessage,
12
+ versionSupported,
13
+ writeSession,
14
+ } from "./thread-session.js";
15
+
16
+ const thread = {
17
+ title: "Kickplate removal",
18
+ source: "perplexity_share",
19
+ origin_app: "Perplexity",
20
+ source_url: "https://www.perplexity.ai/search/x",
21
+ messages: [
22
+ { role: "user", blocks: [{ type: "text", text: "Can the kickplate be removed?" }] },
23
+ {
24
+ role: "assistant",
25
+ blocks: [
26
+ { type: "thinking", text: "REASONING-TRACE" },
27
+ { type: "text", text: "Yes, it is a snap-in base grille." },
28
+ { type: "file", language: "python", text: "print('hi')" },
29
+ ],
30
+ },
31
+ { role: "system", blocks: [{ type: "text", text: "SYSTEM-PREAMBLE" }] },
32
+ { role: "user", blocks: [] },
33
+ ],
34
+ };
35
+
36
+ // -- path flattening ------------------------------------------------------
37
+ // Asserted against the real shape of ~/.claude/projects: separators AND dots collapse to "-",
38
+ // which is why a nested path with a dotted segment doubles up its dashes.
39
+ assert.ok(
40
+ projectDirFor("/Users/x/Documents/GitHub/penny-router").endsWith(
41
+ "-Users-x-Documents-GitHub-penny-router",
42
+ ),
43
+ );
44
+ assert.ok(projectDirFor("/tmp/a.b/c").endsWith("-tmp-a-b-c"));
45
+
46
+ // -- version gate ---------------------------------------------------------
47
+ // The format is undocumented, so writing outside the verified range is refused rather than
48
+ // attempted: a malformed session file is discovered at resume time, when it is too late.
49
+ assert.equal(versionSupported("2.1.226 (Claude Code)"), true);
50
+ assert.equal(versionSupported("2.0.0"), true);
51
+ assert.equal(versionSupported("1.9.9"), false);
52
+ assert.equal(versionSupported("3.0.0"), false);
53
+ assert.equal(versionSupported(null), false);
54
+
55
+ // -- record construction --------------------------------------------------
56
+ const records = buildRecords(thread, {
57
+ cwd: "/tmp/ws",
58
+ sessionId: "sid",
59
+ version: "2.1.226",
60
+ });
61
+
62
+ // Orientation pair, then one record per replayable turn. The empty user turn and the system
63
+ // turn are dropped: neither is something a resumed agent should replay as its own.
64
+ assert.deepEqual(
65
+ records.map((r) => r.type),
66
+ ["user", "assistant", "user", "assistant"],
67
+ );
68
+
69
+ const serialized = JSON.stringify(records);
70
+ assert.ok(!serialized.includes("REASONING-TRACE"), "thinking must never reach a handoff");
71
+ assert.ok(!serialized.includes("SYSTEM-PREAMBLE"), "system turns are not replayed");
72
+ assert.ok(serialized.includes("print('hi')"), "code blocks survive as fenced text");
73
+ assert.ok(records[0].message.content.includes("Perplexity"), "orientation names the origin");
74
+ assert.ok(orientation(thread).includes("Do not re-litigate"));
75
+
76
+ for (const record of records) {
77
+ assert.equal(record.sessionId, "sid");
78
+ assert.equal(record.cwd, "/tmp/ws");
79
+ assert.equal(record.isSidechain, false);
80
+ assert.equal(record.userType, "external");
81
+ assert.ok(record.uuid && record.timestamp);
82
+ }
83
+
84
+ // -- chain integrity ------------------------------------------------------
85
+ // A break in the parentUuid list truncates history silently, which reads as a working import
86
+ // that lost half the conversation. That has to fail loudly instead.
87
+ assertChained(records);
88
+ assert.equal(records[0].parentUuid, null);
89
+ assert.equal(records[1].parentUuid, records[0].uuid);
90
+
91
+ assert.throws(
92
+ () => assertChained([{ uuid: "a", parentUuid: "nope" }]),
93
+ /not chained/,
94
+ );
95
+ assert.throws(
96
+ () => assertChained([{ uuid: "a", parentUuid: null }, { uuid: "b", parentUuid: null }]),
97
+ /exactly one root/,
98
+ );
99
+
100
+ // -- writing --------------------------------------------------------------
101
+ const home = mkdtempSync(join(tmpdir(), "pr-thread-"));
102
+ const originalHome = process.env.HOME;
103
+ process.env.HOME = home;
104
+ try {
105
+ const workspace = mkdtempSync(join(tmpdir(), "pr-ws-"));
106
+ const result = writeSession(thread, { cwd: workspace, version: "2.1.226" });
107
+
108
+ const lines = readFileSync(result.path, "utf8").trim().split("\n");
109
+ assert.equal(lines.length, result.messageCount);
110
+ const parsed = lines.map((l) => JSON.parse(l));
111
+ assertChained(parsed);
112
+ assert.equal(parsed[0].sessionId, result.sessionId);
113
+ assert.ok(result.path.endsWith(`${result.sessionId}.jsonl`));
114
+
115
+ // Never overwrite an existing session: a second write of the same thread is a new file.
116
+ const again = writeSession(thread, { cwd: workspace, version: "2.1.226" });
117
+ assert.notEqual(again.sessionId, result.sessionId);
118
+ assert.notEqual(again.path, result.path);
119
+ // The first file is still intact and unmodified.
120
+ assert.equal(readFileSync(result.path, "utf8").trim().split("\n").length, lines.length);
121
+
122
+ assert.throws(
123
+ () => writeSession(thread, { cwd: workspace, version: "9.9.9" }),
124
+ /outside the range/,
125
+ );
126
+ } finally {
127
+ process.env.HOME = originalHome;
128
+ }
129
+
130
+ // -- rendering ------------------------------------------------------------
131
+ assert.equal(renderMessage({ blocks: [{ type: "text", text: "a" }] }), "a");
132
+ assert.equal(renderMessage({ blocks: [{ type: "thinking", text: "x" }] }), "");
133
+ assert.equal(renderMessage({ blocks: [] }), "");
134
+ assert.ok(
135
+ renderMessage({ blocks: [{ type: "tool_use", name: "Bash", input: { cmd: "ls" } }] }).startsWith(
136
+ "[used Bash",
137
+ ),
138
+ );
139
+
140
+ console.log("thread-session tests passed");