Compaction that continues without a break: writes a handoff card before /compact and auto-compact, summarises on Haiku, and tells the agent to carry on from…

<img alt="segue: a long conversation runs into a compaction; a handoff card is written before it and read after it, and the conversation continues from the card" src="https://raw.githubusercontent.com/sipyourdrink-ltd/segue/main/assets/segue-light.svg" width="820">
install · Codex · what it costs · what the agent reads · options · limits
Status: experimental. segue uses Claude Code's function hooks, an interface that is switched on by an environment variable and may change between releases. When the hook is not loaded, compaction is the built-in one: nothing breaks, you only lose what segue adds. Needs Claude Code 2.1.278 or later; tested on 2.1.285 and 2.1.286.
After a compaction the agent keeps a summary and loses the thread: which step it was on, what it had already tried, what you told it not to do. segue is a Claude Code plugin that hooks /compact and auto-compact and does three things in one pass:
hooks/register.ts for Claude Code, codex/segue.mjs for Codex.Two commands and one setting.
claude plugin marketplace add sipyourdrink-ltd/segue
claude plugin install segue@sipyourdrink
Then switch function hooks on: add one key to env in ~/.claude/settings.json. It applies to the CLI and the desktop app alike.
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
Start a new session. After the next /compact or auto-compact a toast says handoff card: <path>, and the card is in ~/.claude/handoffs/. If the model call failed, the toast says built-in summary used and why.
git clone https://github.com/sipyourdrink-ltd/segue
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./segue
To keep a clone loaded in every session, set CLAUDE_CODE_PLUGIN_DIRS in the same env block to the clone's absolute path. The variable is a colon-separated list: if it already has a value, append to it instead of replacing it.
The first load writes type files into the clone (.claude-plugin/types/, tsconfig.json). They are ignored by git.
Update: claude plugin marketplace update sipyourdrink, then claude plugin update segue@sipyourdrink; or git pull in a clone. Remove: claude plugin uninstall segue@sipyourdrink. Sessions already running keep the compaction they started with.
The same card, pointer, hold and guard run under Codex through its command hooks: codex/segue.mjs, one Node script with no dependencies, wired by codex/hooks.json. What differs from Claude Code:
| Claude Code | Codex | |
|---|---|---|
| handoff card before compaction | yes | yes, to ~/.codex/handoffs/ |
| pointer to the card afterwards | in the summary | as developer context (PostCompact) |
| hold an auto-compaction while subagents run | yes | yes (continue: false) |
| refuse a new subagent near the limit | yes | yes (PreToolUse on spawn_agent) |
| the summary itself | written on Haiku | Codex's own; a hook cannot replace it |
| who writes the card | $.model.complete on Haiku | codex exec --ephemeral on the model you pick (SEGUE_MODEL, default: your Codex default) |
| the context's fill | from the engine | estimated from the transcript's last token_count |
Install from a clone (hooks from plugins run only after you trust them in /hooks):
git clone https://github.com/sipyourdrink-ltd/segue ~/.codex/segue-plugin
Then register the marketplace at ~/.agents/plugins/marketplace.json (the repository's own .agents/plugins/marketplace.json is a template: point source.path at the clone), run /plugins in Codex, enable segue, and trust its hooks in /hooks. Without plugins, paste the five entries of codex/hooks.json into ~/.codex/hooks.json with $PLUGIN_ROOT replaced by the clone's path.
Options are environment variables, read by the hook process: SEGUE_MODEL, SEGUE_HANDOFF_DIR, SEGUE_CENSUS_COMMAND, SEGUE_HOLD_MINUTES, SEGUE_GUARD_PERCENT — the same meaning as the Claude Code options below — and SEGUE_TIMEOUT_SECONDS (default 180) for the model call. Pick a fast model for SEGUE_MODEL: the call runs with low reasoning effort from an empty directory in a read-only sandbox, and the card is only as good as the model that writes it. When the call fails or times out, the compaction proceeds and a card with the running subagents is still written. State (running subagents, the last card's path) lives in the plugin's data directory, or ~/.codex/segue/ when the hooks are wired by hand.
One real conversation of 105k tokens, compacted three ways:
| built-in, on the session's model (Opus) | built-in, session switched to Haiku | segue | |
|---|---|---|---|
| cost of the compaction | $0.31 warm cache · $1.12 cold | $0.10 | ≈ $0.011 |
| context left afterwards | ~5.9k tokens | 816 tokens | |
| time | ~20 s | 6.5 s |
How this was measured: 2026-10-02, Claude Code 2.1.285, two runs per column, cost read from modelUsage.costUSD of a forked session; segue's call was 7.5k tokens in and 0.6k out. These figures are for the summary alone. The handoff card was added afterwards and adds its own output (roughly one to two thousand tokens) to the same call; that version has not been re-measured yet.
The smaller context afterwards matters more than the call itself: every later turn re-reads what the compaction left behind.
The compacted conversation is the summary, followed by this (and then your last message, if you had just sent one):
Before this compaction a handoff card was written to
/Users/you/.claude/handoffs/2026-10-04-161207-3f9c2a1b.md. Continue the work
from it: read that file first, check it against the actual state (git status,
running processes), then carry on from its "Next steps". The card is a
hypothesis; the machine is the truth.
And the card it points to looks like this (an illustrative example, not a recording):
# Handoff before compaction — 2026-10-04 161207 +0300 (session 3f9c2a1b)
- trigger: auto · session: 3f9c2a1b-… · cwd: /Users/you/work/billing
- written by haiku from the transcript and a census of the machine: a hypothesis, not the truth
- resume: read this card → check it against the repository's actual state → carry on from "Next steps"
## Goal and repo
- move invoice rounding from floats to integer cents · ~/work/billing · branch fix/rounding
## Done
- ✅ Money type with integer cents — src/money.ts (a41c9e2)
- ⚠️ unconfirmed: migration script ran on staging
## In flight
- ⏳ test suite — 3 failing in tests/invoice.test.ts · resume: `npm test -- invoice`
## Next steps
1. fix the half-cent case in `roundLine()` — src/invoice.ts:88
2. `npm test -- invoice`
3. push fix/rounding and open the PR
## Only the user
- decide whether historical invoices are re-rounded
## Read first
- src/money.ts
- tests/invoice.test.ts
## Traps
- do not touch the legacy exporter: the user said it is frozen until Q1
- banker's rounding was tried and rejected: totals must match the printed invoices
A line under Done carries a ✅ only when the transcript holds the artefact for it. Everything else is marked unconfirmed, so the agent re-checks instead of trusting.
Three, all optional. Set them with /plugin configure segue@sipyourdrink, or in your user settings.json (project settings are not read for plugin options):
{
"pluginConfigs": {
"segue@sipyourdrink": {
"options": { "handoffDir": "~/notes/handoffs" }
}
}
}
For a clone loaded with --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS the key is segue.
| option | default | what it does |
|---|---|---|
model | haiku | the model that writes the summary and the card |
handoffDir | ~/.claude/handoffs | where the cards go; one file per compaction, <date>-<time>-<session>.md |
censusCommand | empty | a shell command whose output is given to the model as the machine's state; empty means the built-in git census |
holdForAgentsMinutes | 10 | how long an automatic compaction is held back while subagents of the conversation are still running; 0 never holds |
agentGuardPercent | 90 | from this fill of the context a new subagent is refused until the conversation compacts or hands off; 0 turns the guard off |
The built-in census is git status --branch, git worktree list, the last five commits and the stash list, read at the moment of compaction. It is what lets the card say "3 uncommitted files on fix/rounding" from the machine instead of from memory. Outside a git repository there is no census and the card rests on the transcript alone. With a censusCommand, the instruction after compaction tells the agent to run that same command when it checks the card.
Text you pass to /compact <instructions> reaches the model for both the summary and the card. It decides what they keep; the card's sections stay as they are.
/compact or auto-compact
│
├─ running subagents auto-compact with some still running → held, asked again later
├─ census git state, or your censusCommand
├─ one model call transcript (tool I/O clipped) + census → <summary> + <handoff>
├─ write the card <handoffDir>/<date>-<time>-<session>.md
├─ replace history summary + "continue from <card>" (+ your last message, verbatim)
└─ toast handoff card: <path>
| if | then |
|---|---|
| the model call fails or the summary is too short | the built-in summary runs; a card that was written is still named after it |
| the card is cut off by the output limit | it is written as far as it got |
| the card cannot be written | the summary stands without the pointer |
| a subagent compacts its own transcript | segue stays out of it |
| auto-compaction comes while subagents of the conversation are still running | it is held back ({ skip }): the engine asks again before each request, and the compaction runs once they have answered, with their answers in the transcript. The hold ends at 96% of the context or after holdForAgentsMinutes, whichever first |
subagents are still running when the compaction does run (/compact, the hold ran out) | their exact prompts go into the card, from the transcript, and the continued conversation is told to check their output on disk and relaunch the unfinished ones rather than wait |
the conversation starts a subagent at agentGuardPercent of the context or above | the call is refused with the reason: a subagent started now would be stopped by the next compaction; compact or hand off first |
Each row is a test in hooks/register.test.ts, run against the engine itself. From a clone:
cd segue && claude plugin validate .claude-plugin/plugin.json && claude plugin test .
Neither command needs a login. validate also lists every engine call the module makes: one environment read (HOME), the session's id, working directory and context fill, the list of its agents, a clock read, file writes, the model call, child processes (in the source: git, date, and sh for your census command), a log line and a toast. There is no network call among them.
CI installs the latest Claude Code on every run, so a red badge means the interface moved, not that your install broke: yours falls back to the built-in compaction.
handoffDir out of anything you share, and clear it when you like.session.compact, only one of them writes the summary.date and sh on the path, file names fall back to UTC and a custom census command does not run.Something off? Open an issue with the toast text or the segue: line from claude --debug.
Segue is a direction in a score: go on to the next section without a pause. It comes from the same house as Bernstein.
hooks/register.ts 719 lines1// segue: compaction that continues without a break.
2//
3// One completion on a small model over the rendered transcript writes two
4// things: the summary that replaces the conversation, and a handoff card saved
5// to disk before the compaction lands. The compacted conversation names that
6// file and tells the agent to continue from it. The session's own model is
7// never switched.
8//
9// Fail-open: a refused call, an API error, an empty or short reply, a thrown
10// error — each hands the compaction to the built-in summarizer via next(e).
11// A card that was already written is still named after the built-in summary.
12
13// The summary model's window is 200k tokens; ~2.5 chars per token for mixed text.
14const MAX_CHARS = 420_000;
15const HEAD_CHARS = 40_000;
16const TOOL_INPUT_CHARS = 500;
17const TOOL_TEXT_CHARS = 1500;
18const CENSUS_CHARS = 8000;
19const PROJECT_STATE_CHARS = 6000;
20const TODOS_CHARS = 4000;
21const AGENT_PROMPT_CHARS = 6000;
22// Past this fill an automatic compaction is no longer held for running subagents.
23const HOLD_CEILING_PERCENT = 96;
24const MIN_SUMMARY_CHARS = 200;
25const MIN_HANDOFF_CHARS = 200;
26// How many parent directories to walk when looking for a project-root marker.
27const ANCESTOR_LIMIT = 10;
28// The verbatim tail of the conversation in the card: the last messages of each side, the language, the background tasks.
29const LAST_MESSAGES = 2;
30const LAST_MESSAGE_CHARS = 1600;
31const LANGUAGE_MESSAGES = 5;
32const BACKGROUND_TASKS = 10;
33const BACKGROUND_TASK_CHARS = 160;
34
35const SYSTEM =
36 "You prepare the hand-over for a long coding-agent conversation that is about to be compacted. " +
37 "The agent continues the work from what you write alone, so keep exact file paths, " +
38 "commands, ids, numbers, decisions, constraints the user stated, and the next step. " +
39 "Reply in the language the user wrote in, with exactly two blocks and nothing else: " +
40 "<summary>…</summary> first, then <handoff>…</handoff>.";
41
42const SUMMARY_INSTRUCTION =
43 "Up to 15 sentences of plain prose, no headings and no lists: what was being done, as detailed " +
44 "and as compressed as possible — the goal, the decisions, files and paths, the current state, the next step.";
45
46const HANDOFF_INSTRUCTION =
47 "A handoff card in markdown, one fact per line, with exactly these sections in this order " +
48 "(keep the headings in English):\n" +
49 "## Goal and repo — the session's goal in one line, repositories and paths, branch\n" +
50 "## Done — `- ✅ <what> — <sha | PR | path>`; with no artefact in the transcript the line is `- ⚠️ unconfirmed: <what>`\n" +
51 "## In flight — `- ⏳ <what> — pid <n> · done when <condition> · resume: <command>`; or `none`\n" +
52 "## Open work in project trees — if the project state block names peel/breaker/improve open items, quote their ids and one-line what each one is, verbatim from that block; or `none`\n" +
53 "## Next steps — up to 3, in order, each with a command or a path\n" +
54 "## Only the user — what only the person can do or decide; or `none`\n" +
55 "## Read first — up to 5 paths\n" +
56 "## Traps — what was tried and failed, and why; constraints the user stated\n" +
57 "Take facts only from the transcript, the census, the project state and the todos. What is in none of them, leave out.";
58
59function clip(s, n) {
60 if (!s) return "";
61 return s.length > n ? s.slice(0, n) + "…" : s;
62}
63
64function render(messages) {
65 const out = [];
66 for (const m of messages) {
67 const parts = [];
68 if (m.text) parts.push(m.text);
69 for (const u of m.toolUses || []) {
70 let input = "";
71 try { input = JSON.stringify(u.input); } catch (_) { input = ""; }
72 parts.push(`[tool ${u.tool} ${clip(input, TOOL_INPUT_CHARS)}]` +
73 (u.text ? ` → ${clip(u.text, TOOL_TEXT_CHARS)}` : ""));
74 }
75 for (const r of m.toolResults || []) {
76 parts.push(`[result${r.isError ? " error" : ""}] ${clip(r.text, TOOL_TEXT_CHARS)}`);
77 }
78 if (parts.length) out.push(`${m.role.toUpperCase()}: ${parts.join("\n")}`);
79 }
80 let text = out.join("\n\n");
81 if (text.length > MAX_CHARS) {
82 text = text.slice(0, HEAD_CHARS) + "\n\n[… middle of the conversation omitted …]\n\n" +
83 text.slice(text.length - (MAX_CHARS - HEAD_CHARS));
84 }
85 return text;
86}
87
88function block(text, tag) {
89 // The closing tag may be lost to the output limit; the opening one may not.
90 const m = text.match(new RegExp(`<${tag}>([\\s\\S]*?)(?:</${tag}>|$)`));
91 return m ? m[1].trim() : "";
92}
93
94// The latest TodoWrite tool call in the transcript carries the task tracker's
95// full state as of the last write. The small model is not asked to reconstruct
96// it from fragments; the hook extracts the todos list verbatim and writes it
97// into the card unchanged, so the next session resumes against a reliable list.
98function latestTodos(messages) {
99 for (let i = messages.length - 1; i >= 0; i--) {
100 const m = messages[i];
101 const uses = m.toolUses || [];
102 for (let j = uses.length - 1; j >= 0; j--) {
103 const u = uses[j];
104 if (u.tool === "TodoWrite" && u.input && Array.isArray(u.input.todos)) {
105 return u.input.todos;
106 }
107 }
108 }
109 return [];
110}
111
112// Some hosts keep the task ledger on disk under $HOME/.claude/tasks/, grouped
113// by list id. When present, that record outlives transcript truncation and is
114// the more durable source for the todos list. The in-process scan above runs
115// first (fast, same session); this probe fills in only when the transcript scan
116// found nothing and the disk ledger is readable.
117async function diskTasks($, home) {
118 if (!home) return [];
119 try {
120 const base = `${home}/.claude/tasks`;
121 if (!(await fsExists($, base))) return [];
122 const lists = await $.fs.list(base);
123 if (!Array.isArray(lists) || !lists.length) return [];
124 // Pick the most recently modified list dir.
125 const stamped = await Promise.all(lists.map(async (name) => {
126 try {
127 const st = await $.fs.stat(`${base}/${name}`);
128 return { name, mtime: st && typeof st.mtimeMs === "number" ? st.mtimeMs : 0 };
129 } catch (_) { return { name, mtime: 0 }; }
130 }));
131 stamped.sort((a, b) => b.mtime - a.mtime);
132 const listDir = `${base}/${stamped[0].name}`;
133 const files = await $.fs.list(listDir);
134 const tasks = [];
135 for (const f of (Array.isArray(files) ? files : [])) {
136 if (!f.endsWith(".json")) continue;
137 try {
138 const raw = await $.fs.read(`${listDir}/${f}`);
139 const t = JSON.parse(raw);
140 if (t && typeof t.content === "string") tasks.push(t);
141 } catch (_) { /* skip malformed entries */ }
142 }
143 return tasks;
144 } catch (_) {
145 return [];
146 }
147}
148
149function todosSection(todos) {
150 if (!todos.length) return "";
151 const marker = { pending: " ", in_progress: "⏳", completed: "x" };
152 const openCount = todos.filter(t => t.status !== "completed").length;
153 const doneCount = todos.length - openCount;
154 const lines = todos.map(t => {
155 const m = marker[t.status] !== undefined ? marker[t.status] : " ";
156 const text = t.activeForm && t.status === "in_progress" ? t.activeForm : (t.content || "");
157 return `- [${m}] ${text}`;
158 });
159 return clip(
160 `## Task tracker at compaction\n` +
161 `Open ${openCount} · done ${doneCount}. Treat this list as the authoritative state at compaction; ` +
162 `re-check each item's actual progress before marking it in a new TodoWrite.\n\n` +
163 lines.join("\n"),
164 TODOS_CHARS,
165 );
166}
167
168// Non-throwing shell runner for optional probes: a probe that cannot run on
169// this host (missing python3, missing skill script, no fs access) is dropped,
170// not propagated as an error.
171async function probe($, argv) {
172 try {
173 const r = await $.process.run(argv, { timeoutMs: 10_000 });
174 return r.exitCode === 0 ? r.stdout.trim() : "";
175 } catch (_) {
176 return "";
177 }
178}
179
180async function fsExists($, path) {
181 try {
182 const v = await $.fs.exists(path);
183 return !!v;
184 } catch (_) {
185 return false;
186 }
187}
188
189// Walk cwd upwards for ANCESTOR_LIMIT levels looking for the first ancestor
190// that contains every marker file. Returns null when none match or when
191// $.fs.exists is unavailable in the host.
192async function findProjectRoot($, cwd, markers) {
193 if (!cwd) return null;
194 let dir = cwd;
195 for (let i = 0; i < ANCESTOR_LIMIT; i++) {
196 let allPresent = true;
197 for (const marker of markers) {
198 if (!(await fsExists($, `${dir}/${marker}`))) {
199 allPresent = false;
200 break;
201 }
202 }
203 if (allPresent) return dir;
204 const slash = dir.lastIndexOf("/");
205 if (slash <= 0) break;
206 const parent = dir.slice(0, slash) || "/";
207 if (parent === dir) break;
208 dir = parent;
209 }
210 return null;
211}
212
213// The on-disk state of the three skill-project harnesses (peel, blackbox-breaker,
214// improve) at compaction. Every probe is gated on an fs.exists check: on a host
215// where $.fs.exists is unavailable or the marker files are absent, the probe
216// never shells out at all, so the card simply omits that section. The improve
217// journal is global ($HOME/.local/share/improve/journal.jsonl); the other two
218// are rooted in an ancestor of cwd.
219async function projectState($, cwd) {
220 const blocks = [];
221 const home = await (async () => { try { return await $.env.get("HOME"); } catch (_) { return ""; } })();
222
223 // peel: ACCESS.md with a tier: line is scaffold's unique marker.
224 const peelRoot = await findProjectRoot($, cwd, ["ACCESS.md"]);
225 if (peelRoot && home) {
226 const out = await probe($, ["sh", "-c",
227 `grep -q '^tier: ' "${peelRoot}/ACCESS.md" 2>/dev/null && ` +
228 `python3 "${home}/.claude/skills/peel/scripts/peel.py" status --root "${peelRoot}"`]);
229 if (out) blocks.push(`=== PEEL project: ${peelRoot} ===\n${out}`);
230 }
231
232 // blackbox-breaker: AUTHORIZATION.md + vectors.csv at the same ancestor.
233 const breakerRoot = await findProjectRoot($, cwd, ["AUTHORIZATION.md", "vectors.csv"]);
234 if (breakerRoot && home) {
235 const out = await probe($, ["sh", "-c",
236 `python3 "${home}/.claude/skills/blackbox-breaker/scripts/breaker.py" status --root "${breakerRoot}" && ` +
237 `echo '--- top 3 open vectors ---' && ` +
238 `tail -n +2 "${breakerRoot}/vectors.csv" | awk -F, '$6=="open"' | head -3`]);
239 if (out) blocks.push(`=== BREAKER project: ${breakerRoot} ===\n${out}`);
240 }
241
242 // improve: global journal, independent of cwd. Gated on fs.exists so an
243 // unavailable fs.* namespace (as in the default test world) prevents the
244 // shell call entirely, and the probe never fires on hosts without the skill.
245 if (home) {
246 const journal = `${home}/.local/share/improve/journal.jsonl`;
247 if (await fsExists($, journal)) {
248 const out = await probe($, ["sh", "-c",
249 `python3 "${home}/.claude/skills/improve/scripts/improve.py" log due --days 7 2>/dev/null | head -40`]);
250 if (out) blocks.push(`=== IMPROVE journal: due or overdue (next 7d) ===\n${out}`);
251 }
252 }
253
254 return clip(blocks.join("\n\n"), PROJECT_STATE_CHARS);
255}
256
257function projectStateSection(block) {
258 if (!block) return "";
259 return `## Open work in project trees\n` +
260 `Captured verbatim at compaction from the project harnesses. The next session ` +
261 `should re-run each named status subcommand before touching open items.\n\n` +
262 "```\n" + block + "\n```";
263}
264
265// Subagents the main loop started that were still running at compaction. The
266// calls that would carry their results are summarised away, so the next turn
267// gets their prompts verbatim, from the transcript, not from the small model.
268async function inFlight($, messages) {
269 const calls = [];
270 for (const m of messages) {
271 for (const u of m.toolUses || []) {
272 if ((u.tool === "Agent" || u.tool === "Task") && u.input) calls.push(u);
273 }
274 }
275 if (!calls.length) return [];
276 let running = null;
277 try {
278 running = new Set((await $.agent.list()).filter(a => a.status === "running").map(a => a.id));
279 } catch (_) {
280 // No listing: a call still waiting for its answer is the best evidence left.
281 }
282 return calls.filter(u => running ? Boolean(u.agentId && running.has(u.agentId)) : !u.result && !u.text);
283}
284
285function agentsSection(calls) {
286 if (!calls.length) return "";
287 return "## Subagents running at compaction\n" +
288 "Their results may never reach this conversation. Check what they already wrote to disk, " +
289 "then relaunch the unfinished ones with these prompts; do not wait for them.\n\n" +
290 calls.map((u, i) => {
291 const x = u.input;
292 const meta = [x.subagent_type, x.model, u.agentId && `id ${u.agentId}`].filter(Boolean).join(" · ");
293 return `### ${i + 1}. ${x.description || "agent"}${meta ? " · " + meta : ""}\n` +
294 fence(clip(String(x.prompt || ""), AGENT_PROMPT_CHARS));
295 }).join("\n\n");
296}
297
298// Long texts keep their beginning and their end: the question at the end of a message is what a resume needs.
299function clipMiddle(s, n) {
300 if (s.length <= n) return s;
301 const head = Math.ceil(n / 2);
302 return `${s.slice(0, head)}\n… [${s.length - n} chars cut] …\n${s.slice(s.length - (n - head))}`;
303}
304
305// A fence longer than any backtick run in the text, so the text cannot close it early.
306function fence(text) {
307 let longest = 0;
308 for (const m of text.matchAll(/`+/g)) longest = Math.max(longest, m[0].length);
309 const f = "`".repeat(Math.max(3, longest + 1));
310 return `${f}text\n${text}\n${f}`;
311}
312
313// Secrets the person may have typed in the conversation. The card outlives the session, so verbatim text is
314// masked before it is written.
315const SECRET_TOKENS = [
316 /\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_]{20,}/g,
317 /\bsk-[A-Za-z0-9_-]{16,}/g,
318 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
319 /\bAKIA[0-9A-Z]{16}\b/g,
320 /\bAIza[0-9A-Za-z_-]{35}\b/g,
321 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
322 /\bBearer\s+[A-Za-z0-9._~+/-]{16,}/gi,
323];
324const SECRET_ASSIGNMENT = /\b(password|passwd|secret|token|api[_-]?key)(\s*[:=]\s*)\S+/gi;
325
326function redact(text) {
327 let out = text;
328 for (const re of SECRET_TOKENS) out = out.replace(re, "[redacted]");
329 return out.replace(SECRET_ASSIGNMENT, "$1$2[redacted]");
330}
331
332// The last few user or agent texts, oldest first; tool results and empty turns are skipped.
333function tailMessages(messages, perRole) {
334 const counts = { user: 0, assistant: 0 };
335 const picked = [];
336 for (let i = messages.length - 1; i >= 0; i--) {
337 const m = messages[i];
338 if (!(m.role === "user" || m.role === "assistant") || !(m.text || "").trim() || counts[m.role] >= perRole) continue;
339 counts[m.role] += 1;
340 picked.unshift(m);
341 }
342 return picked;
343}
344
345// The person's language from the words of their last few messages, code and URLs left out. Cyrillic words
346// count as ru; below one word in five Cyrillic the text is reported as en.
347const PROSE_SKIP = /```[\s\S]*?```|`[^`\n]*`|https?:\/\/\S+/g;
348function userLanguage(messages) {
349 const recent = messages.filter(m => m.role === "user" && (m.text || "").trim()).slice(-LANGUAGE_MESSAGES);
350 const words = recent.map(m => m.text.replace(PROSE_SKIP, " ")).join(" ").split(/[^\p{L}]+/u).filter(Boolean);
351 if (!words.length) return "unknown";
352 const cyrillic = words.filter(w => /\p{Script=Cyrillic}/u.test(w)).length;
353 const share = Math.round((100 * cyrillic) / words.length);
354 return `${share >= 20 ? "ru" : "en"} (Cyrillic words ${share}% of the last user messages)`;
355}
356
357// The conversation's tail as it was written: the resume point that the summary can blur.
358function conversationSection(messages) {
359 const tail = tailMessages(messages, LAST_MESSAGES);
360 if (!tail.length) return "";
361 return "## Last messages at compaction\n" +
362 `- user language: ${userLanguage(messages)} — reply in the same language\n` +
363 "Verbatim from the transcript, oldest first; the middle is cut when a message is long.\n\n" +
364 tail.map(m => `### ${m.role === "user" ? "User" : "Agent"}\n` +
365 fence(clipMiddle(redact(m.text.trim()), LAST_MESSAGE_CHARS))).join("\n\n");
366}
367
368// One line, for list items that must not break across lines.
369function oneLine(s) {
370 return s.replace(/\s+/g, " ").trim();
371}
372
373function tagText(text, tag) {
374 const m = text.match(new RegExp(`<${tag}>([^<]*)</${tag}>`));
375 return m ? m[1].trim() : "";
376}
377
378// Bash calls started with run_in_background: the task id and output file the tool returned. A task
379// notification later in the conversation closes a task; without one it may still run, or be lost.
380function backgroundTasks(messages) {
381 const tasks = [];
382 messages.forEach((m, i) => {
383 for (const u of m.toolUses || []) {
384 if (u.tool !== "Bash" || !u.input || u.input.run_in_background !== true) continue;
385 const text = String(u.text || "");
386 const id = (text.match(/background with ID: (\S+?)\./) || [])[1];
387 if (!id) continue;
388 const output = ((text.match(/written to: (\S+)/) || [])[1] || "").replace(/\.$/, "");
389 const what = clip(oneLine(redact(String(u.input.description || u.input.command || ""))), BACKGROUND_TASK_CHARS);
390 const note = messages.slice(i + 1).map(x => x.text || "").find(t => t.includes(`<task-id>${id}</task-id>`));
391 const state = note ? oneLine(redact(tagText(note, "summary") || tagText(note, "status") || "finished")) : "";
392 tasks.push({ id, output, what, state });
393 }
394 });
395 return tasks;
396}
397
398function backgroundSection(messages) {
399 const tasks = backgroundTasks(messages).slice(-BACKGROUND_TASKS);
400 if (!tasks.length) return "";
401 return "## Background tasks at compaction\n" +
402 "Bash calls started with run_in_background. ✅ = a completion notice came back; " +
403 "⏳ = none did, so it may still run or be lost: read its output file before relaunching it.\n" +
404 tasks.map(t => t.state
405 ? `- ✅ ${t.id} · ${t.what} · ${t.state} · output ${t.output}`
406 : `- ⏳ ${t.id} · ${t.what} · no completion notice · output ${t.output}`).join("\n");
407}
408
409async function run($, argv) {
410 try {
411 const r = await $.process.run(argv, { timeoutMs: 20_000 });
412 return r.exitCode === 0 ? r.stdout.trim() : "";
413 } catch (_) {
414 return "";
415 }
416}
417
418// The machine's state at the moment of compaction, so the card rests on more
419// than the transcript. Best effort: the card is written without it.
420async function census($, command) {
421 if (command) return clip(await run($, ["sh", "-c", command]), CENSUS_CHARS);
422 if (!(await run($, ["git", "rev-parse", "--show-toplevel"]))) return "";
423 const [status, worktrees, log, stashes] = await Promise.all([
424 run($, ["git", "status", "--porcelain=v1", "--branch"]),
425 run($, ["git", "worktree", "list"]),
426 run($, ["git", "log", "--oneline", "-5"]),
427 run($, ["git", "stash", "list"]),
428 ]);
429 return clip([
430 "$ git status --porcelain=v1 --branch\n" + status,
431 "$ git worktree list\n" + worktrees,
432 "$ git log --oneline -5\n" + log,
433 stashes ? "$ git stash list\n" + stashes : "",
434 ].filter(Boolean).join("\n\n"), CENSUS_CHARS);
435}
436
437// Local day and time for the file name; UTC when the host has no `date`.
438async function stamp($) {
439 const local = (await run($, ["date", "+%Y-%m-%d %H%M%S %z"])).split(" ");
440 if (local.length === 3) return { day: local[0], time: local[1], zone: local[2] };
441 const iso = new Date(await $.clock.now()).toISOString();
442 return { day: iso.slice(0, 10), time: iso.slice(11, 19).replace(/:/g, ""), zone: "UTC" };
443}
444
445async function saveHandoff($, dir, source, e, card) {
446 const { day, time, zone } = await stamp($);
447 const id = await $.session.id();
448 const path = `${dir}/${day}-${time}-${id.slice(0, 8)}.md`;
449 const head =
450 `# Handoff before compaction — ${day} ${time} ${zone} (session ${id.slice(0, 8)})\n` +
451 `- trigger: ${e.trigger} · session: ${id} · cwd: ${await $.session.cwd()}\n` +
452 `- written by ${source}: a hypothesis, not the truth\n` +
453 `- resume: read this card → check it against the repository's actual state → carry on from "Next steps"\n\n`;
454 await $.fs.write(path, head + card + "\n");
455 return path;
456}
457
458// The compacted summary itself is a durable artifact: readers of the card
459// benefit from the original prose, and a search over past summaries recovers
460// context the card compresses out. Written as a sibling of the card, named
461// after it so the pair stays discoverable. Gated on the plugin data dir
462// existing: the extra write only happens on hosts where the CLI has set up
463// the data directory, which keeps the test matrix stable.
464async function saveSummary($, cardPath, summary, dataDir) {
465 if (!cardPath || !summary || !dataDir) return "";
466 if (!(await fsExists($, dataDir))) return "";
467 try {
468 const sumPath = cardPath.replace(/\.md$/, ".summary.md");
469 await $.fs.write(sumPath, summary + "\n");
470 return sumPath;
471 } catch (_) {
472 return "";
473 }
474}
475
476// A per-plugin KV directory ($CLAUDE_PLUGIN_DATA) is the right place for an
477// index that outlives any single project tree. The index is append-only so
478// readers can tail it without locking. Gated on the data directory actually
479// existing so hosts that do not provide one are untouched.
480async function appendCardIndex($, cardPath, e, dataDir) {
481 if (!cardPath || !dataDir) return;
482 if (!(await fsExists($, dataDir))) return;
483 try {
484 const line = JSON.stringify({
485 ts: new Date(await $.clock.now()).toISOString(),
486 trigger: e.trigger,
487 session: await $.session.id(),
488 cwd: await $.session.cwd(),
489 card: cardPath,
490 });
491 const idxPath = `${dataDir}/cards.jsonl`;
492 let prev = "";
493 try { prev = await $.fs.read(idxPath); } catch (_) { prev = ""; }
494 await $.fs.write(idxPath, prev + line + "\n");
495 } catch (_) { /* index is best-effort */ }
496}
497
498// A pointer file in the project tree outlives the compacted summary. When the
499// cwd is a repo and writable, writing a short pointer under .claude/ lets a
500// later session discover the last handoff without reading plugin data.
501async function writeProjectPointer($, cwd, cardPath) {
502 if (!cwd || !cardPath) return "";
503 try {
504 const dir = `${cwd}/.claude`;
505 if (!(await fsExists($, dir))) return "";
506 const path = `${dir}/handoff-current.md`;
507 const body = `# Current handoff\n\nLast compaction wrote a card to:\n\n ${cardPath}\n\n` +
508 `Read that file first. The card is a hypothesis; the machine is the truth.\n`;
509 await $.fs.write(path, body);
510 return path;
511 } catch (_) {
512 return "";
513 }
514}
515
516// Which host the plugin is running under; used in logs and toasts so a reader
517// can tell CLI from desktop apart at a glance.
518async function hostLabel($) {
519 try {
520 const ep = await $.env.get("CLAUDE_CODE_ENTRYPOINT");
521 if (!ep) return "";
522 return ep === "claude-desktop" ? "desktop" : (ep === "cli" ? "cli" : ep);
523 } catch (_) {
524 return "";
525 }
526}
527
528function pointer(path, censusCommand, agents, hasProjectState, hasTodos) {
529 const stateHint = hasProjectState
530 ? " The card's \"Open work in project trees\" names peel/breaker/improve open items — " +
531 "re-run each harness's `status` subcommand before touching those items."
532 : "";
533 const todosHint = hasTodos
534 ? " The card's \"Task tracker at compaction\" is the authoritative todo list; " +
535 "re-check each item's actual progress before marking it in a new TodoWrite."
536 : "";
537 return "Before this compaction a handoff card was written to " + path + ". " +
538 "Continue the work from it: read that file first, check it against the actual state " +
539 (censusCommand ? "(run `" + censusCommand + "`)" : "(git status, running processes)") +
540 ", then carry on from its \"Next steps\". " +
541 "The card is a hypothesis; the machine is the truth." +
542 todosHint +
543 stateHint +
544 (agents ? ` ${agents} subagent(s) were running at compaction and may not report back: ` +
545 "the card's \"Subagents running at compaction\" has their exact prompts; " +
546 "check their output on disk and relaunch what is unfinished instead of waiting." : "");
547}
548
549// The context's fill as the status line has it; unknown when the engine does not say.
550async function contextPercent($) {
551 try {
552 const u = await $.session.usage();
553 return u && u.context && typeof u.context.percent === "number" ? u.context.percent : undefined;
554 } catch (_) {
555 return undefined;
556 }
557}
558
559function number(v, fallback) {
560 const n = Number(v);
561 return v === undefined || v === null || v === "" || Number.isNaN(n) ? fallback : Math.max(0, n);
562}
563
564export function register(on, options) {
565 const model = String((options && options.model) || "haiku");
566 const censusCommand = String((options && options.censusCommand) || "");
567 const handoffDir = String((options && options.handoffDir) || "~/.claude/handoffs");
568 const holdMs = number(options && options.holdForAgentsMinutes, 10) * 60_000;
569 const guardPercent = number(options && options.agentGuardPercent, 90);
570 // An automatic compaction being held back for running subagents: when the hold began.
571 let hold = null;
572
573 // A subagent started this close to the limit would be stopped by the next
574 // compaction before it answers. Refused with the way out, so the model
575 // compacts or hands off first and starts it after.
576 on("tool.call", async ($, e, next) => {
577 if (e.tool !== "Agent" || e.agentId || !guardPercent) return await next(e);
578 const percent = await contextPercent($);
579 if (percent === undefined || percent < guardPercent) return await next(e);
580 return {
581 deny: `segue: the context is at ${percent}% (guard at ${guardPercent}%); a subagent started now would be lost at the next compaction. ` +
582 "Compact or hand off first, then start it.",
583 };
584 });
585
586 on("session.compact", async ($, e, next) => {
587 try {
588 // A subagent's own compaction stays with the engine.
589 if (e.agentId) return await next(e);
590 const transcript = render(e.messages);
591 if (!transcript) return await next(e);
592 const agents = await inFlight($, e.messages);
593 // Subagents still running are stopped by a compaction. An automatic one
594 // is held back until they answer: the engine asks again before each
595 // request. Bounded by the context's fill and by time, so a hold cannot
596 // run the conversation into the limit itself.
597 if (e.trigger === "auto" && agents.length && holdMs) {
598 const now = await $.clock.now();
599 if (!hold) hold = { since: now, toasted: false };
600 const percent = await contextPercent($);
601 if (percent !== undefined && percent < HOLD_CEILING_PERCENT && now - hold.since < holdMs) {
602 const why = `${agents.length} subagent(s) still running, context ${percent}%`;
603 $.ui.log(`segue: compaction held (${why})`);
604 if (!hold.toasted) {
605 hold.toasted = true;
606 $.ui.toast(`compaction held: ${why}`);
607 }
608 return { skip: `segue: ${why}; compaction resumes when they answer` };
609 }
610 }
611 hold = null;
612 // Custom instructions steer what both blocks keep; the card's sections are not theirs to change.
613 const extra = e.instructions
614 ? `\n\nThe user also asked: ${e.instructions}\nFollow it in the summary. In the card follow it for what to keep; the card's sections and line format stay as specified.`
615 : "";
616 // Parallel probes: git census, three skill-project harnesses, the
617 // in-process TodoWrite scan, and the disk task ledger. The disk ledger
618 // is read only when the transcript scan found nothing, so a transcript
619 // that still holds the latest TodoWrite is trusted first.
620 const cwd = await (async () => { try { return await $.session.cwd(); } catch (_) { return ""; } })();
621 const home = await (async () => { try { return await $.env.get("HOME"); } catch (_) { return ""; } })();
622 const [state, projectBlock] = await Promise.all([
623 census($, censusCommand),
624 projectState($, cwd),
625 ]);
626 let todos = latestTodos(e.messages);
627 if (!todos.length) todos = await diskTasks($, home);
628 const todosBlock = todosSection(todos);
629 const projectBlockSection = projectStateSection(projectBlock);
630 const running = agentsSection(agents);
631 const r = await $.model.complete({
632 model,
633 system: SYSTEM,
634 prompt: `<transcript>\n${transcript}\n</transcript>\n\n` +
635 (state ? `<census>\n${state}\n</census>\n\n` : "") +
636 (projectBlock ? `<project-state>\n${projectBlock}\n</project-state>\n\n` : "") +
637 (todos.length ? `<todos>\n${JSON.stringify(todos, null, 2)}\n</todos>\n\n` : "") +
638 `In the <summary> block: ${SUMMARY_INSTRUCTION}\n\n` +
639 `In the <handoff> block: ${HANDOFF_INSTRUCTION}${extra}`,
640 maxTokens: 8000,
641 timeoutMs: 180_000,
642 });
643 const text = r.isAnswered ? r.text : "";
644 let handoffPath = "";
645 const written = block(text, "handoff");
646 // The running subagents, the todo tracker, the project-state block, the verbatim tail and the background
647 // tasks are saved even when the small model wrote no card: they carry the in-flight work and the resume point
648 // that the Haiku summary would have had to reconstruct.
649 const modelWrote = written.length >= MIN_HANDOFF_CHARS;
650 const card = [
651 modelWrote ? written : "",
652 todosBlock,
653 projectBlockSection,
654 running,
655 conversationSection(e.messages),
656 backgroundSection(e.messages),
657 ].filter(Boolean).join("\n\n");
658 if (card) {
659 try {
660 const dir = handoffDir.startsWith("~/") && home ? home + handoffDir.slice(1) : handoffDir;
661 const source = modelWrote ? `${model} from the transcript and a census of the machine`
662 : "segue (the model wrote no card) from the transcript";
663 handoffPath = await saveHandoff($, dir, source, e, card);
664 } catch (err) {
665 $.ui.log(`segue: handoff not written: ${err}`);
666 }
667 }
668 // Persist the summary prose alongside the card and keep an index of all
669 // handoffs under the plugin's data dir. A pointer under cwd's `.claude/`
670 // lets a later session discover the last handoff without reading plugin
671 // data. All three are best-effort and each is gated on the relevant
672 // directory existing; a failure never fails the compaction.
673 const dataDir = await (async () => { try { return await $.env.get("CLAUDE_PLUGIN_DATA"); } catch (_) { return ""; } })();
674 const bodyForPersist = block(text, "summary") || (text.includes("<handoff>") ? "" : text.trim());
675 await saveSummary($, handoffPath, bodyForPersist, dataDir);
676 await appendCardIndex($, handoffPath, e, dataDir);
677 await writeProjectPointer($, cwd, handoffPath);
678 const host = await hostLabel($);
679 // A reply without either tag is read as the summary alone.
680 const body = block(text, "summary") || (text.includes("<handoff>") ? "" : text.trim());
681 if (body.length < MIN_SUMMARY_CHARS) {
682 const why = r.isAnswered ? "short reply" : r.reason;
683 $.ui.log(`segue: fallback to built-in (${why})`);
684 if (e.trigger !== "precompute") {
685 $.ui.toast(`built-in summary used (${why})` + (handoffPath ? `; handoff card: ${handoffPath}` : ""));
686 }
687 const res = await next(e);
688 if (!handoffPath || !res.messages) return res;
689 return { ...res, messages: [...res.messages, { role: "user", text: pointer(handoffPath, censusCommand, agents.length, Boolean(projectBlock), todos.length > 0), toolUses: [] }] };
690 }
691 const u = r.usage;
692 const hostTag = host ? ` [${host}]` : "";
693 $.ui.log(`segue${hostTag}: ${e.trigger} by ${model}, in ${u.input_tokens} out ${u.output_tokens}` +
694 (handoffPath ? `, handoff ${handoffPath}` : ", no handoff"));
695 if (e.trigger !== "precompute") {
696 $.ui.toast((handoffPath ? `handoff card: ${handoffPath}` : "summary written, no handoff card") +
697 (agents.length ? ` · ${agents.length} subagent(s) were running: prompts in the card` : ""));
698 }
699 const summary = {
700 role: "user",
701 text: "This session is being continued from a previous conversation that ran out of context. " +
702 "The summary below covers the earlier portion of the conversation.\n\nSummary:\n" + body +
703 (handoffPath ? "\n\n" + pointer(handoffPath, censusCommand, agents.length, Boolean(projectBlock), todos.length > 0) : ""),
704 toolUses: [],
705 };
706 const kept = [summary];
707 // A trailing plain prompt from the person stays verbatim, by identity.
708 const last = e.messages[e.messages.length - 1];
709 if (last && last.role === "user" && last.text && !(last.toolResults && last.toolResults.length) && !(last.toolUses && last.toolUses.length)) {
710 kept.push(last);
711 }
712 return { messages: kept };
713 } catch (err) {
714 $.ui.log(`segue: error, fallback to built-in: ${err}`);
715 return await next(e);
716 }
717 });
718}
719