@machine-made/pi-rethread 0.0.0-stage → 0.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-28
10
+
11
+ ### Added
12
+ - `/rethread [goal]`: the agent lands its step, writes a letter to the next session and a ledger entry (`rethread_save` tool), then a new session opens with the letter and asks for a three-line readback.
13
+ - `/rethread --now` (side model call writes the handoff), `--review` (edit before moving), `--cancel`.
14
+ - `/ledger` shows the ledger file and its entry count.
15
+ - Automatic rethread at `--rethread-auto-percent` (default 70) and instead of Pi's automatic compaction; once per session, with a loop guard.
16
+ - The four prompts are Markdown templates; a project can override each one in `.pi/rethread/`.
17
+
18
+ ### Fixed (before the first release)
19
+ - A reply that went past the whole context window let Pi compact the old session in the middle of the handoff, so the agent wrote its letter from a lossy summary. Only Pi's recovery of a rejected request (which must retry) may compact now.
20
+ - The integration tests read `~/.agents/skills` and `PI_*` variables from the real environment, so their result depended on the machine. Each Pi now runs with an empty home.
package/LICENSE ADDED
@@ -0,0 +1,111 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ https://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction, and
10
+ distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by the copyright
13
+ owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all other entities
16
+ that control, are controlled by, or are under common control with that entity.
17
+ For the purposes of this definition, "control" means (i) the power, direct or
18
+ indirect, to cause the direction or management of such entity, whether by
19
+ contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
20
+ outstanding shares, or (iii) beneficial ownership of such entity.
21
+
22
+ "You" (or "Your") shall mean an individual or Legal Entity exercising
23
+ permissions granted by this License.
24
+
25
+ "Source" form shall mean the preferred form for making modifications, including
26
+ but not limited to software source code, documentation source, and configuration
27
+ files.
28
+
29
+ "Object" form shall mean any form resulting from mechanical transformation or
30
+ translation of a Source form, including but not limited to compiled object code,
31
+ generated documentation, and conversions to other media types.
32
+
33
+ "Work" shall mean the work of authorship, whether in Source or Object form, made
34
+ available under the License, as indicated by a copyright notice that is
35
+ included in or attached to the work.
36
+
37
+ "Derivative Works" shall mean any work, whether in Source or Object form, that
38
+ is based on (or derived from) the Work and for which the editorial revisions,
39
+ annotations, elaborations, or other modifications represent, as a whole, an
40
+ original work of authorship.
41
+
42
+ "Contribution" shall mean any work of authorship, including the original version
43
+ of the Work and any modifications or additions to that Work or Derivative Works,
44
+ that is intentionally submitted to Licensor for inclusion in the Work by the
45
+ copyright owner or by an individual or Legal Entity authorized to submit on
46
+ behalf of the copyright owner.
47
+
48
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf
49
+ of whom a Contribution has been received by Licensor and subsequently
50
+ incorporated within the Work.
51
+
52
+ 2. Grant of Copyright License. Subject to the terms and conditions of this
53
+ License, each Contributor hereby grants to You a perpetual, worldwide,
54
+ non-exclusive, no-charge, royalty-free, irrevocable copyright license to
55
+ reproduce, prepare Derivative Works of, publicly display, publicly perform,
56
+ sublicense, and distribute the Work and such Derivative Works in Source or
57
+ Object form.
58
+
59
+ 3. Grant of Patent License. Subject to the terms and conditions of this
60
+ License, each Contributor hereby grants to You a perpetual, worldwide,
61
+ non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
62
+ section) patent license to make, have made, use, offer to sell, sell, import,
63
+ and otherwise transfer the Work.
64
+
65
+ 4. Redistribution. You may reproduce and distribute copies of the Work or
66
+ Derivative Works thereof in any medium, with or without modifications, and in
67
+ Source or Object form, provided that You meet the following conditions:
68
+
69
+ (a) You must give any other recipients of the Work or Derivative Works a copy of
70
+ this License; and
71
+
72
+ (b) You must cause any modified files to carry prominent notices stating that
73
+ You changed the files; and
74
+
75
+ (c) You must retain, in the Source form of any Derivative Works that You
76
+ distribute, all copyright, patent, trademark, and attribution notices from the
77
+ Source form of the Work; and
78
+
79
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then
80
+ any Derivative Works that You distribute must include a readable copy of the
81
+ attribution notices contained within such NOTICE file, excluding those notices
82
+ that do not pertain to any part of the Derivative Works, in at least one of the
83
+ following places: within a NOTICE text file distributed as part of the
84
+ Derivative Works; within the Source form or documentation, if provided along
85
+ with the Derivative Works; or within a display generated by the Derivative
86
+ Works, if and wherever such third-party notices normally appear.
87
+
88
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any
89
+ Contribution intentionally submitted for inclusion in the Work by You to the
90
+ Licensor shall be under the terms and conditions of this License.
91
+
92
+ 6. Trademarks. This License does not grant permission to use the trade names,
93
+ trademarks, service marks, or product names of the Licensor, except as required
94
+ for reasonable and customary use in describing the origin of the Work.
95
+
96
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
97
+ writing, Licensor provides the Work on an "AS IS" BASIS, WITHOUT WARRANTIES OR
98
+ CONDITIONS OF ANY KIND, either express or implied.
99
+
100
+ 8. Limitation of Liability. In no event and under no legal theory shall any
101
+ Contributor be liable to You for damages, including any direct, indirect,
102
+ special, incidental, or consequential damages arising as a result of this
103
+ License or out of the use or inability to use the Work.
104
+
105
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or
106
+ Derivative Works thereof, You may choose to offer, and charge a fee for,
107
+ acceptance of support, warranty, indemnity, or other liability obligations.
108
+
109
+ END OF TERMS AND CONDITIONS
110
+
111
+ Copyright 2026 Ricardo Frantz
package/README.md CHANGED
@@ -1,3 +1,104 @@
1
- # Temporary Holding Version
1
+ # pi-rethread
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Extension for [pi](https://github.com/earendil-works/pi): when a session gets long, the agent finishes its step, writes a **letter** to the next session and a **ledger** entry, and the work continues in a **fresh session**. No lossy compaction.
4
+
5
+ ```text
6
+ pi install npm:@machine-made/pi-rethread
7
+ ```
8
+
9
+ Tested with Pi 1.4.0.
10
+
11
+ ## Commands
12
+
13
+ ```text
14
+ /rethread [goal] finish, hand off, continue in a new session
15
+ /rethread --now skip the agent (it is stuck): a side model call writes the handoff
16
+ /rethread --review edit the ledger entry before moving (interactive only)
17
+ /rethread --cancel stop a rethread in progress
18
+ /ledger show the ledger file and its number of entries
19
+ ```
20
+
21
+ ## What happens
22
+
23
+ `/rethread` runs four prepared messages, each doing one job:
24
+
25
+ 1. **Land.** The running agent finishes the step it is on and stops at a safe point: no half-edited
26
+ file, no new work, the quick check (tests, build) run once. It replies in three lines: what is
27
+ *verified*, what is only *assumed*, what is still broken.
28
+ 2. **Letter.** The agent writes to the agent of the next session, in a fixed shape:
29
+ *Where we are*, *Do first*, *Watch out*, *Don't redo* (dead ends, so they are not tried again)
30
+ and *Trust* (verified vs assumed). The letter comes first: writing to a colleague forces priorities.
31
+ 3. **Ledger.** In the same tool call, after the letter, the agent writes the durable facts:
32
+ Goal, Done, Decisions, Files, Commands, Open problems, Next steps. The letter is intent for the
33
+ next hop; the ledger is what stays true across many hops. It is appended to `.pi/ledger.md`.
34
+ 4. **Readback.** A new session opens (linked to the old one as its parent) and receives the letter
35
+ and the ledger entry. Its first reply is three lines: the goal, the first action, the main risk.
36
+ A misunderstanding costs one short reply instead of a whole run.
37
+
38
+ The `rethread_save` tool is shown to the model only during step 2.
39
+
40
+ If the agent does not call the tool, a side model call writes the letter and the ledger from the
41
+ transcript instead, and the move still happens.
42
+
43
+ ## Automatic
44
+
45
+ After a run, if context use is at or above `--rethread-auto-percent` (default 70), `/rethread`
46
+ starts by itself. When Pi would auto-compact first, the compaction is cancelled and a rethread
47
+ runs instead. This covers Pi's threshold and a reply that silently went past the window. No
48
+ automatic compaction touches the old session while the agent writes its handoff.
49
+
50
+ Left alone: manual `/compact`, and Pi's recovery when the provider rejects a request as too long.
51
+ That recovery compacts and retries the failed turn; without it the turn cannot run. The rethread
52
+ then continues, and the agent writes its letter from the compacted session.
53
+
54
+ It fires once per session. If a new session is already over the limit right after the handoff
55
+ (a low limit, a long ledger), the automatic rethread turns itself off for that session and says so,
56
+ so it never loops.
57
+
58
+ ## Flags
59
+
60
+ | Flag | Default | Meaning |
61
+ |---|---|---|
62
+ | `--rethread-auto-percent <n>` | `70` | Context % that starts `/rethread`; `0` turns the automatic rethread off |
63
+ | `--ledger-file <path>` | `.pi/ledger.md` | Ledger file, relative to the working directory |
64
+
65
+ ## Your own prompts
66
+
67
+ The four messages are Markdown templates in [`prompts/`](prompts). Put a file with the same name in
68
+ `.pi/rethread/` of a project to replace one there:
69
+
70
+ | File | Sent to | Placeholders |
71
+ |---|---|---|
72
+ | `land.md` | the old agent, step 1 | none |
73
+ | `write.md` | the old agent, step 2 | `{{tool}}` `{{headings}}` `{{goal_line}}` `{{previous}}` |
74
+ | `kickoff.md` | the new agent, step 4 | `{{ledger_path}}` `{{letter}}` `{{goal_line}}` `{{status}}` `{{ledger}}` |
75
+ | `summary.md` | the side model (`--now`, fallback) | `{{headings}}` |
76
+
77
+ A custom `kickoff.md` must keep a `<rethread-ledger>` tag: it is how the new session knows it was
78
+ started by a rethread (for the loop guard).
79
+
80
+ ## The ledger
81
+
82
+ `.pi/ledger.md` is plain Markdown, newest entry last. Each entry records the old session file, the
83
+ status (`landed`, `partial`, `blocked`), who wrote it (the agent or a summary), the goal, the ledger
84
+ sections and the letter. The file is written atomically. Commit it, or add it to `.gitignore`.
85
+
86
+ ## Limits
87
+
88
+ - A `/rethread --cancel` typed in the instant between two steps is queued behind the next step;
89
+ type it again.
90
+ - The new session uses the same model and tools as the old one.
91
+
92
+ ## Development
93
+
94
+ ```text
95
+ bun install
96
+ bun run check
97
+ bun run test
98
+ ```
99
+
100
+ The integration test runs real Pi in RPC mode with a scripted fake model, so it needs no API key.
101
+
102
+ ## Licence
103
+
104
+ Apache-2.0
package/SECURITY.md ADDED
@@ -0,0 +1,16 @@
1
+ # Security
2
+
3
+ pi-rethread has not had a security review yet.
4
+
5
+ What it does: it reads and writes one Markdown file (`.pi/ledger.md`, or the path given by
6
+ `--ledger-file`), reads prompt templates from the package and from `.pi/rethread/` in the working
7
+ directory, sends prompts to the session's own model, and opens a new Pi session. It makes no network
8
+ call of its own and spawns no process.
9
+
10
+ Things to know:
11
+ - The ledger and the letter are written by the model and sent to the next session as a user message.
12
+ Treat them like any other model output: review the ledger if the session handled untrusted input.
13
+ - Project templates in `.pi/rethread/` change what is sent to the model, and they are read from
14
+ the working directory whoever wrote them. Check that folder in a repository you did not write.
15
+
16
+ Report issues at https://github.com/machine-made/pi-rethread/issues
package/index.ts ADDED
@@ -0,0 +1,375 @@
1
+ /**
2
+ * pi-rethread — end this session cleanly and continue in a new one.
3
+ *
4
+ * /rethread [goal] 1. land: the agent finishes its step and says what is verified
5
+ * 2. write: the agent writes a letter to the next session, then a ledger entry
6
+ * 3. move: the ledger is appended, a new session opens with the letter
7
+ * 4. readback: the new agent restates goal, first action, main risk, then starts
8
+ * /rethread --now skip 1-2 (agent stuck): a side model call writes letter + ledger
9
+ * /rethread --review edit the ledger entry before moving (interactive only)
10
+ * /rethread --cancel abort a rethread in progress
11
+ * /ledger show the ledger path and entry count
12
+ *
13
+ * Automatic: after a run settles at or above --rethread-auto-percent of the context window, or when
14
+ * Pi would auto-compact, /rethread starts by itself (once per session; never loops).
15
+ *
16
+ * The session switch uses Pi's documented API (ctx.newSession + withSession), as in Pi's
17
+ * examples/extensions/handoff.ts.
18
+ */
19
+
20
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
21
+ import { type Message, StringEnum, uuidv7 } from "@earendil-works/pi-ai";
22
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
23
+ import { BorderedLoader, convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
24
+ import { Type } from "typebox";
25
+ import {
26
+ appendEntry,
27
+ entryLedger,
28
+ fill,
29
+ goalLine,
30
+ type Handoff,
31
+ headingsList,
32
+ isKickoff,
33
+ loadTemplate,
34
+ looksLikeLedger,
35
+ parseArgs,
36
+ parseThreshold,
37
+ readEntries,
38
+ resolveLedgerPath,
39
+ STATUSES,
40
+ type Status,
41
+ shownPath,
42
+ splitSummary,
43
+ TOOL_NAME,
44
+ USAGE,
45
+ } from "./rethread.ts";
46
+
47
+ type Phase = "idle" | "land" | "write" | "move";
48
+
49
+ function entryToMessage(entry: SessionEntry): AgentMessage | undefined {
50
+ if (entry.type === "message") return entry.message;
51
+ if (entry.type === "compaction") {
52
+ return {
53
+ role: "compactionSummary",
54
+ summary: entry.summary,
55
+ tokensBefore: entry.tokensBefore,
56
+ timestamp: new Date(entry.timestamp).getTime(),
57
+ };
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ /** Messages of the active branch, respecting the last compaction (same rule as Pi's handoff.ts). */
63
+ function branchMessages(branch: SessionEntry[]): AgentMessage[] {
64
+ let c = -1;
65
+ for (let i = branch.length - 1; i >= 0; i--) {
66
+ if (branch[i].type === "compaction") {
67
+ c = i;
68
+ break;
69
+ }
70
+ }
71
+ if (c < 0) return branch.map(entryToMessage).filter((m) => m !== undefined);
72
+ const comp = branch[c];
73
+ const firstKept = comp.type === "compaction" ? branch.findIndex((e) => e.id === comp.firstKeptEntryId) : -1;
74
+ const kept = [comp, ...(firstKept >= 0 ? branch.slice(firstKept, c) : []), ...branch.slice(c + 1)];
75
+ return kept.map(entryToMessage).filter((m) => m !== undefined);
76
+ }
77
+
78
+ export default function rethread(pi: ExtensionAPI) {
79
+ pi.registerFlag("ledger-file", { type: "string", description: "Ledger file (default .pi/ledger.md)" });
80
+ pi.registerFlag("rethread-auto-percent", {
81
+ type: "string",
82
+ description: "Start /rethread at this context % (default 70; 0 = off)",
83
+ });
84
+
85
+ // Per-session state. Pi runs this factory again for each new session, so it starts fresh there.
86
+ let phase: Phase = "idle";
87
+ let goal = "";
88
+ let review = false;
89
+ let saved: Omit<Handoff, "goal" | "source"> | undefined;
90
+ let autoFired = false;
91
+ let autoDisabled = false;
92
+ let kickoffRun = false;
93
+ let pendingAuto = false;
94
+
95
+ const threshold = () => parseThreshold(pi.getFlag("rethread-auto-percent"));
96
+ const ledgerFile = (cwd: string) => resolveLedgerPath(cwd, pi.getFlag("ledger-file"));
97
+ const previousLedger = (cwd: string) => {
98
+ const last = readEntries(ledgerFile(cwd)).at(-1);
99
+ return last ? entryLedger(last) : "";
100
+ };
101
+
102
+ const setTool = (on: boolean) => {
103
+ const active = pi.getActiveTools().filter((t) => t !== TOOL_NAME);
104
+ pi.setActiveTools(on ? [...active, TOOL_NAME] : active);
105
+ };
106
+
107
+ const reset = () => {
108
+ phase = "idle";
109
+ goal = "";
110
+ review = false;
111
+ saved = undefined;
112
+ setTool(false);
113
+ };
114
+
115
+ const send = (ctx: ExtensionContext, text: string) => {
116
+ if (ctx.isIdle()) pi.sendUserMessage(text);
117
+ else pi.sendUserMessage(text, { deliverAs: "followUp" });
118
+ };
119
+
120
+ pi.registerTool({
121
+ name: TOOL_NAME,
122
+ label: "Rethread: save handoff",
123
+ description:
124
+ "Save the handoff for the next session: a letter to the next agent, then the ledger entry. Only call this when a [rethread] message asks you to.",
125
+ parameters: Type.Object({
126
+ status: StringEnum(STATUSES, { description: "landed | partial | blocked" }),
127
+ next_message: Type.String({ description: "Letter to the next session's agent. Written first." }),
128
+ ledger: Type.String({ description: "Self-contained ledger entry with the required headings." }),
129
+ }),
130
+ defaultActive: false,
131
+ async execute(_id, params) {
132
+ if (phase !== "write") {
133
+ return {
134
+ content: [{ type: "text", text: "No rethread is in progress; nothing was saved." }],
135
+ details: { saved: false },
136
+ };
137
+ }
138
+ const problems: string[] = [];
139
+ if (!params.next_message.trim()) problems.push("next_message is empty");
140
+ if (!looksLikeLedger(params.ledger)) problems.push("ledger is missing the required ### headings");
141
+ if (problems.length) {
142
+ return {
143
+ content: [{ type: "text", text: `Not saved: ${problems.join("; ")}. Call ${TOOL_NAME} again.` }],
144
+ details: { saved: false },
145
+ };
146
+ }
147
+ saved = { status: params.status as Status, letter: params.next_message, ledger: params.ledger };
148
+ return {
149
+ content: [{ type: "text", text: "Saved. Stop now: the next session starts when you finish." }],
150
+ details: { saved: true },
151
+ };
152
+ },
153
+ });
154
+
155
+ /** Side model call: letter + ledger from the transcript (used by --now and as a fallback). */
156
+ async function summarize(ctx: ExtensionCommandContext): Promise<{ letter: string; ledger: string } | null> {
157
+ const messages = branchMessages(ctx.sessionManager.getBranch());
158
+ if (messages.length === 0 || !ctx.model) return null;
159
+ const prompt = [
160
+ "## Previous ledger entry",
161
+ previousLedger(ctx.cwd) || "(none)",
162
+ "",
163
+ "## Conversation",
164
+ serializeConversation(convertToLlm(messages)),
165
+ "",
166
+ "## User's goal for the next session",
167
+ goal || "(none given: continue the work)",
168
+ ].join("\n");
169
+ const system = fill(loadTemplate("summary", ctx.cwd), { headings: headingsList() });
170
+ const run = async (signal?: AbortSignal) => {
171
+ const user: Message = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() };
172
+ const res = await ctx.modelRegistry.complete(
173
+ ctx.model!,
174
+ { systemPrompt: system, messages: [user] },
175
+ { signal, cacheRetention: "none", sessionId: uuidv7() },
176
+ );
177
+ if (res.stopReason === "aborted" || res.stopReason === "error") return null;
178
+ const text = res.content
179
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
180
+ .map((c) => c.text)
181
+ .join("\n")
182
+ .trim();
183
+ return text ? splitSummary(text) : null;
184
+ };
185
+ if (ctx.mode !== "tui") return run(ctx.signal);
186
+ return ctx.ui.custom((tui, theme, _kb, done) => {
187
+ const loader = new BorderedLoader(tui, theme, "Writing the handoff...");
188
+ loader.onAbort = () => done(null);
189
+ run(loader.signal)
190
+ .then(done)
191
+ .catch((err) => {
192
+ console.error("rethread: summary failed:", err);
193
+ done(null);
194
+ });
195
+ return loader;
196
+ });
197
+ }
198
+
199
+ /** Step 3: append the ledger, open the new session, send the kickoff. Command context only. */
200
+ async function move(ctx: ExtensionCommandContext): Promise<void> {
201
+ phase = "move";
202
+ setTool(false);
203
+ let h: Handoff | undefined;
204
+ if (saved) h = { ...saved, goal, source: "agent" };
205
+ else {
206
+ ctx.ui.notify("rethread: the agent did not save a handoff; writing a summary instead", "warning");
207
+ const s = await summarize(ctx);
208
+ if (s) h = { ...s, goal, status: "partial", source: "summary" };
209
+ }
210
+ if (!h) {
211
+ ctx.ui.notify("rethread: no handoff could be written; staying in this session", "error");
212
+ reset();
213
+ return;
214
+ }
215
+ if (review && ctx.mode === "tui") {
216
+ const edited = await ctx.ui.editor("Edit the ledger entry", h.ledger);
217
+ if (edited === undefined) {
218
+ ctx.ui.notify("rethread: cancelled; nothing written", "info");
219
+ reset();
220
+ return;
221
+ }
222
+ h.ledger = edited;
223
+ }
224
+
225
+ const file = ledgerFile(ctx.cwd);
226
+ const oldSession = ctx.sessionManager.getSessionFile();
227
+ const n = appendEntry(file, oldSession, h);
228
+ const kickoff = fill(loadTemplate("kickoff", ctx.cwd), {
229
+ ledger_path: shownPath(file, ctx.cwd),
230
+ letter: h.letter.trim() || "(none)",
231
+ goal_line: goalLine(h.goal, false),
232
+ status: h.status,
233
+ ledger: h.ledger.trim(),
234
+ });
235
+ reset();
236
+
237
+ // Only plain data crosses the switch; ctx is stale afterwards.
238
+ const result = await ctx.newSession({
239
+ parentSession: oldSession,
240
+ withSession: async (next) => {
241
+ next.ui.notify(`Ledger entry ${n} written; new session started`, "info");
242
+ // Not awaited: the run would hold the switch open until it ends.
243
+ next.sendUserMessage(kickoff).catch((err) => console.error("rethread: kickoff failed:", err));
244
+ },
245
+ });
246
+ if (result.cancelled) ctx.ui.notify(`Ledger entry ${n} written, but the new session was cancelled`, "warning");
247
+ }
248
+
249
+ pi.registerCommand("rethread", {
250
+ description: "Finish, write a letter and a ledger entry, continue in a new session",
251
+ handler: async (raw, ctx) => {
252
+ const a = parseArgs(raw);
253
+ if (a.cancel) {
254
+ if (phase === "idle") ctx.ui.notify("rethread: nothing to cancel", "info");
255
+ else {
256
+ reset();
257
+ ctx.ui.notify("rethread: cancelled", "info");
258
+ }
259
+ return;
260
+ }
261
+ if (a.switchNow) {
262
+ if (phase === "write") await move(ctx);
263
+ return;
264
+ }
265
+ if (phase !== "idle") {
266
+ ctx.ui.notify(`rethread: already in progress (${phase}); /rethread --cancel to stop`, "warning");
267
+ return;
268
+ }
269
+ if (!ctx.model) {
270
+ ctx.ui.notify("rethread: no model selected", "error");
271
+ return;
272
+ }
273
+ if (branchMessages(ctx.sessionManager.getBranch()).length === 0) {
274
+ ctx.ui.notify(`rethread: nothing to carry over yet. ${USAGE}`, "warning");
275
+ return;
276
+ }
277
+ goal = a.goal;
278
+ review = a.review;
279
+ if (a.now) {
280
+ await ctx.waitForIdle();
281
+ phase = "write"; // so move() accepts it; saved stays empty -> summary path
282
+ await move(ctx);
283
+ return;
284
+ }
285
+ phase = "land";
286
+ ctx.ui.notify("rethread 1/3: asking the agent to finish its step", "info");
287
+ send(ctx, loadTemplate("land", ctx.cwd).trim());
288
+ },
289
+ });
290
+
291
+ pi.registerCommand("ledger", {
292
+ description: "Show the ledger file and its number of entries",
293
+ handler: async (_args, ctx) => {
294
+ const file = ledgerFile(ctx.cwd);
295
+ const n = readEntries(file).length;
296
+ ctx.ui.notify(n ? `Ledger: ${file} (${n} entries)` : `No ledger yet (${file})`, "info");
297
+ },
298
+ });
299
+
300
+ pi.on("session_start", async () => {
301
+ phase = "idle";
302
+ saved = undefined;
303
+ autoFired = false;
304
+ autoDisabled = false;
305
+ kickoffRun = false;
306
+ pendingAuto = false;
307
+ });
308
+
309
+ // Pi's automatic compaction becomes a rethread: at the threshold, and after a reply that
310
+ // silently went past the window (reason "overflow", nothing to retry). Left alone: manual
311
+ // /compact, and recovery of a request the provider rejected as too long (willRetry), which
312
+ // must compact to retry the failed turn.
313
+ pi.on("session_before_compact", async (event) => {
314
+ if (event.reason === "manual" || event.willRetry) return;
315
+ if (phase !== "idle") return { cancel: true }; // we are leaving this session anyway
316
+ if (threshold() <= 0 || autoDisabled || autoFired) return;
317
+ pendingAuto = true;
318
+ return { cancel: true };
319
+ });
320
+
321
+ pi.on("before_agent_start", async (event) => {
322
+ if (event.messageType === "user" && isKickoff(event.prompt)) kickoffRun = true;
323
+ });
324
+
325
+ pi.on("agent_settled", async (_event, ctx) => {
326
+ // Advance a rethread in progress. Prompts sent here are deferred by Pi until settling ends.
327
+ if (phase === "land") {
328
+ phase = "write";
329
+ setTool(true);
330
+ ctx.ui.notify("rethread 2/3: asking for the letter and the ledger", "info");
331
+ pi.sendUserMessage(
332
+ fill(loadTemplate("write", ctx.cwd), {
333
+ tool: TOOL_NAME,
334
+ headings: headingsList(),
335
+ goal_line: goalLine(goal, true),
336
+ previous: previousLedger(ctx.cwd) || "(none)",
337
+ }),
338
+ );
339
+ return;
340
+ }
341
+ if (phase === "write") {
342
+ ctx.ui.notify("rethread 3/3: moving to a new session", "info");
343
+ pi.sendUserMessage("/rethread --switch", { expandPromptTemplates: true });
344
+ return;
345
+ }
346
+ if (phase !== "idle") return;
347
+
348
+ const limit = threshold();
349
+ const pct = ctx.getContextUsage()?.percent;
350
+ if (kickoffRun) {
351
+ kickoffRun = false;
352
+ // Loop guard: a new session already over the limit would rethread forever.
353
+ if (pendingAuto || (limit > 0 && pct != null && pct >= limit)) {
354
+ pendingAuto = false;
355
+ autoDisabled = true;
356
+ ctx.ui.notify(
357
+ `rethread: context is already ${pct != null ? `${Math.round(pct)}%` : "at Pi's compaction point"} right after the handoff; automatic rethread is off for this session. Raise --rethread-auto-percent or shorten the ledger.`,
358
+ "warning",
359
+ );
360
+ }
361
+ return;
362
+ }
363
+ if (limit <= 0 || autoFired || autoDisabled) return;
364
+ if (!pendingAuto && (pct == null || pct < limit)) return;
365
+ autoFired = true;
366
+ ctx.ui.notify(
367
+ pendingAuto
368
+ ? "rethread: Pi would compact now; rethreading instead"
369
+ : `rethread: context at ${Math.round(pct!)}%; rethreading`,
370
+ "info",
371
+ );
372
+ pendingAuto = false;
373
+ pi.sendUserMessage("/rethread", { expandPromptTemplates: true });
374
+ });
375
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@machine-made/pi-rethread",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "author": "Ricardo Frantz",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/machine-made/pi-rethread.git"
8
+ },
9
+ "devDependencies": {
10
+ "@earendil-works/pi-agent-core": "1.4.0",
11
+ "@earendil-works/pi-ai": "1.4.0",
12
+ "@earendil-works/pi-coding-agent": "1.4.0",
13
+ "@types/node": "^24.0.0",
14
+ "tsx": "^4.20.0",
15
+ "typebox": "1.3.27",
16
+ "typescript": "^5.9.0"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/machine-made/pi-rethread/issues"
20
+ },
21
+ "description": "Pi extension: the agent finishes its step, writes a letter and a ledger entry, and the work continues in a fresh session",
22
+ "files": [
23
+ "CHANGELOG.md",
24
+ "SECURITY.md",
25
+ "index.ts",
26
+ "rethread.ts",
27
+ "prompts/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "homepage": "https://github.com/machine-made/pi-rethread#readme",
32
+ "keywords": [
33
+ "pi-package",
34
+ "pi-extension",
35
+ "pi",
36
+ "pi-coding-agent",
37
+ "llm",
38
+ "developer-tools",
39
+ "context",
40
+ "handoff",
41
+ "ledger",
42
+ "session"
43
+ ],
44
+ "license": "Apache-2.0",
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-agent-core": "*",
47
+ "@earendil-works/pi-ai": "*",
48
+ "@earendil-works/pi-coding-agent": "*",
49
+ "typebox": "*"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "pi": {
55
+ "extensions": [
56
+ "./index.ts"
57
+ ]
58
+ },
59
+ "scripts": {
60
+ "check": "tsc --noEmit",
61
+ "test": "tsx --test tests/*.test.ts"
62
+ },
63
+ "type": "module",
64
+ "packageManager": "bun@1.3.13"
65
+ }
@@ -0,0 +1,12 @@
1
+ This session continues earlier work. The ledger is `{{ledger_path}}` (newest entry last); its latest entry is below.
2
+
3
+ Letter from the previous session's agent:
4
+ {{letter}}
5
+
6
+ {{goal_line}}
7
+
8
+ First, reply with a 3-line readback: (1) the goal, (2) your first action, (3) the main risk. Then start. Check the files the ledger names before you change them.
9
+
10
+ <rethread-ledger status="{{status}}">
11
+ {{ledger}}
12
+ </rethread-ledger>
@@ -0,0 +1,9 @@
1
+ [rethread 1/2: land] This session is about to end and a fresh session will continue the work.
2
+
3
+ Land the work now:
4
+ - Finish the step you are on if it needs only a few more actions. Do not start anything new.
5
+ - Leave no half-edited file. If a change cannot be finished, revert it or leave a clear `TODO(rethread)` comment.
6
+ - If the project has a quick check (tests, build, lint), run it once.
7
+ - Then reply in at most 3 lines: what is **verified** (you ran or saw it), what is **assumed** (not checked), and anything still broken.
8
+
9
+ Do not write the handoff yet; that is the next step.
@@ -0,0 +1,11 @@
1
+ You write a handoff so a fresh coding-agent session can continue the work with no access to the old conversation. You get the previous ledger entry (may be empty), the conversation since then, and optionally the user's goal for the next session.
2
+
3
+ Output exactly two sections and nothing else.
4
+
5
+ ## Letter
6
+ A short message to the next agent ("You..."): **Where we are** (two sentences), **Do first** (one action), **Watch out**, **Don't redo** (failed approaches and why), **Trust** (verified vs assumed).
7
+
8
+ ## Ledger
9
+ Self-contained durable facts with exactly these headings, in this order ("none" under an empty heading):
10
+ {{headings}}
11
+ Carry over what is still true from the previous entry; drop what is obsolete. Be specific: exact paths, commands, numbers, error messages.
@@ -0,0 +1,21 @@
1
+ [rethread 2/2: write] Now write the handoff with one call to the `{{tool}}` tool. Fill the fields in this order.
2
+
3
+ **`next_message`**: a letter from you to the agent of the next session. Write it to them ("You..."), short, in this shape:
4
+ - **Where we are**: two sentences.
5
+ - **Do first**: one concrete action.
6
+ - **Watch out**: traps, flaky tests, things that look right but are not.
7
+ - **Don't redo**: approaches already tried that failed, and why.
8
+ - **Trust**: what is verified and what is only assumed.
9
+
10
+ **`ledger`**: durable facts that stay true across many sessions. Self-contained: the next agent has no access to this conversation. Use exactly these headings, in this order, and write "none" under a heading with nothing to say:
11
+ {{headings}}
12
+ Carry over what is still true from the previous entry; drop what is finished or obsolete. Be specific: exact paths, commands, numbers, error messages.
13
+
14
+ **`status`**: `landed` (clean stopping point), `partial` (work in progress, described in the ledger) or `blocked` (cannot continue without the user).
15
+
16
+ {{goal_line}}
17
+
18
+ Previous ledger entry:
19
+ {{previous}}
20
+
21
+ After the tool call, stop.
package/rethread.ts ADDED
@@ -0,0 +1,143 @@
1
+ // Pure helpers for pi-rethread: templates, ledger file, kickoff. No Pi runtime imports.
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ export const ENTRY_MARK = "<!-- ledger-entry -->";
7
+ export const DEFAULT_LEDGER = ".pi/ledger.md";
8
+ export const DEFAULT_AUTO_PERCENT = 70;
9
+ export const TOOL_NAME = "rethread_save";
10
+ export const KICKOFF_TAG = "rethread-ledger";
11
+ export const OVERRIDE_DIR = ".pi/rethread";
12
+ export const USAGE = "Usage: /rethread [goal for the next session] [--now] [--review] [--cancel]";
13
+
14
+ export const LEDGER_HEADINGS = ["Goal", "Done", "Decisions", "Files", "Commands", "Open problems", "Next steps"] as const;
15
+ export const STATUSES = ["landed", "partial", "blocked"] as const;
16
+ export type Status = (typeof STATUSES)[number];
17
+
18
+ export type TemplateName = "land" | "write" | "kickoff" | "summary";
19
+
20
+ const PACKAGE_PROMPTS = join(dirname(fileURLToPath(import.meta.url)), "prompts");
21
+
22
+ /** Project override in .pi/rethread/<name>.md wins over the packaged template. */
23
+ export function loadTemplate(name: TemplateName, cwd: string): string {
24
+ const own = join(cwd, OVERRIDE_DIR, `${name}.md`);
25
+ return readFileSync(existsSync(own) ? own : join(PACKAGE_PROMPTS, `${name}.md`), "utf8");
26
+ }
27
+
28
+ /** Replace {{key}} placeholders; unknown keys are left as they are. */
29
+ export function fill(template: string, vars: Record<string, string>): string {
30
+ return template.replace(/\{\{(\w+)\}\}/g, (m, k: string) => (k in vars ? vars[k] : m)).trim();
31
+ }
32
+
33
+ export const headingsList = (): string => LEDGER_HEADINGS.map((h) => `### ${h}`).join("\n");
34
+
35
+ export function goalLine(goal: string, forNextSession: boolean): string {
36
+ if (goal) return `The user's goal for ${forNextSession ? "the next" : "this"} session: ${goal}`;
37
+ return forNextSession
38
+ ? "The user gave no goal: the next session continues the work."
39
+ : 'Continue with the first item under "Next steps".';
40
+ }
41
+
42
+ export interface Args {
43
+ goal: string;
44
+ now: boolean;
45
+ review: boolean;
46
+ cancel: boolean;
47
+ switchNow: boolean;
48
+ }
49
+
50
+ export function parseArgs(raw: string): Args {
51
+ const words = raw.trim().split(/\s+/).filter(Boolean);
52
+ const flags = new Set(words.filter((w) => w.startsWith("--")));
53
+ return {
54
+ goal: words.filter((w) => !w.startsWith("--")).join(" "),
55
+ now: flags.has("--now"),
56
+ review: flags.has("--review"),
57
+ cancel: flags.has("--cancel"),
58
+ switchNow: flags.has("--switch"), // internal: sent by the extension itself
59
+ };
60
+ }
61
+
62
+ export function resolveLedgerPath(cwd: string, flag: unknown): string {
63
+ const p = typeof flag === "string" && flag.trim() ? flag.trim() : DEFAULT_LEDGER;
64
+ return isAbsolute(p) ? p : join(cwd, p);
65
+ }
66
+
67
+ /** Context percent that starts an automatic rethread; 0 means off. */
68
+ export function parseThreshold(flag: unknown): number {
69
+ if (typeof flag !== "string" || flag.trim() === "") return DEFAULT_AUTO_PERCENT;
70
+ const n = Number(flag);
71
+ return Number.isFinite(n) && n > 0 ? n : 0;
72
+ }
73
+
74
+ export function readEntries(file: string): string[] {
75
+ if (!existsSync(file)) return [];
76
+ return readFileSync(file, "utf8")
77
+ .split(ENTRY_MARK)
78
+ .slice(1)
79
+ .map((s) => s.trim())
80
+ .filter(Boolean);
81
+ }
82
+
83
+ export interface Handoff {
84
+ goal: string;
85
+ letter: string;
86
+ ledger: string;
87
+ status: Status;
88
+ source: "agent" | "summary";
89
+ }
90
+
91
+ /** Append one entry atomically (temp file, then rename). Returns the entry number. */
92
+ export function appendEntry(file: string, sessionFile: string | undefined, h: Handoff, now = new Date()): number {
93
+ mkdirSync(dirname(file), { recursive: true });
94
+ const header = existsSync(file)
95
+ ? readFileSync(file, "utf8")
96
+ : "# Ledger\n\nWritten by /rethread (pi-rethread). Newest entry last. Each entry is self-contained.\n";
97
+ const n = readEntries(file).length + 1;
98
+ const meta = [
99
+ `## Entry ${n} — ${now.toISOString()}`,
100
+ "",
101
+ `- From session: \`${sessionFile ? basename(sessionFile) : "in-memory"}\``,
102
+ `- Status: ${h.status} (written by ${h.source === "agent" ? "the agent" : "a summary"})`,
103
+ h.goal ? `- Goal for next session: ${h.goal.replace(/\s+/g, " ").trim()}` : undefined,
104
+ ]
105
+ .filter((l) => l !== undefined)
106
+ .join("\n");
107
+ const letter = h.letter.trim() ? `\n\n### Letter to the next session\n${h.letter.trim()}` : "";
108
+ const text = `${header.trimEnd()}\n\n${ENTRY_MARK}\n${meta}\n\n${h.ledger.trim()}${letter}\n`;
109
+ const tmp = `${file}.${process.pid}.tmp`;
110
+ writeFileSync(tmp, text, "utf8");
111
+ renameSync(tmp, file);
112
+ return n;
113
+ }
114
+
115
+ /** The ledger part of a stored entry (no metadata, no letter): what the next write builds on. */
116
+ export function entryLedger(entry: string): string {
117
+ return entry
118
+ .replace(/^## Entry \d+ — .*$/m, "")
119
+ .replace(/^- (From session|Status|Goal for next session):.*$/gm, "")
120
+ .split(/^### Letter to the next session$/m)[0]
121
+ .trim();
122
+ }
123
+
124
+ /** Split the fallback summary ("## Letter" / "## Ledger") into its parts. */
125
+ export function splitSummary(text: string): { letter: string; ledger: string } {
126
+ const m = text.match(/^##\s*Letter\s*$([\s\S]*?)^##\s*Ledger\s*$([\s\S]*)/im);
127
+ if (!m) return { letter: "", ledger: text.trim() };
128
+ return { letter: m[1].trim(), ledger: m[2].trim() };
129
+ }
130
+
131
+ export function isKickoff(prompt: string): boolean {
132
+ return prompt.includes(`<${KICKOFF_TAG}`);
133
+ }
134
+
135
+ /** A usable ledger has most of the expected headings. */
136
+ export function looksLikeLedger(text: string): boolean {
137
+ return LEDGER_HEADINGS.filter((h) => new RegExp(`^#{2,4}\\s*${h}\\b`, "mi").test(text)).length >= 4;
138
+ }
139
+
140
+ export function shownPath(file: string, cwd: string): string {
141
+ const r = relative(cwd, file);
142
+ return r && !r.startsWith("..") ? r : file;
143
+ }