pennyrouter 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +1882 -1727
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +346 -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-register.js +193 -0
- package/src/mcp-register.test.js +171 -0
- package/src/mcp.js +256 -0
- package/src/mcp.test.js +237 -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-codex.js +152 -0
- package/src/thread-session-codex.test.js +136 -0
- package/src/thread-session.js +204 -0
- package/src/thread-session.test.js +140 -0
|
@@ -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");
|
|
@@ -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");
|