SLOPSHOPPER

segue

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…

newguardtoastmodelprocess
v0.8.0Apache-2.0updated 2026-10-08sipyourdrink-ltd/segue
A shopper browsing a rack in a slop shop
README

<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">

Compaction that leaves a handoff card and continues from it — for Claude Code and Codex

tests release License

install &middot; Codex &middot; what it costs &middot; what the agent reads &middot; options &middot; 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:

  1. Before the conversation is replaced, it writes a handoff card to disk: what is done (with the commit or path that proves it), what is in flight, the next three steps, the traps. It also keeps the last user and agent messages verbatim (the middle cut when long), the person's language, and the background Bash tasks with their output files; secrets typed in those messages are masked before the file is written.
  2. It writes the summary on Haiku instead of the session's model. The session's model is never switched.
  3. After the compaction, the conversation carries the card's path and one instruction: read it, check it against the repository, carry on from "Next steps".

at a glance

  • A card on disk, not only a summary in context. It survives the next compaction, a crash, and a move to another session.
  • About a cent per compaction. One call to a small model over a transcript with tool output clipped. Numbers below.
  • Fails open. A refused call, an API error, a short reply, a thrown error: the built-in summary runs as if segue were not there.
  • Subagents are not lost to a compaction. An automatic compaction waits for the ones still running, a new one is refused when the context is nearly full, and the prompts of any still running when the summary is written go into the card.
  • Small enough to read. One file per host, about 300 lines each, no dependencies. hooks/register.ts for Claude Code, codex/segue.mjs for Codex.

install

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.

Codex CLI and the ChatGPT desktop app

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 CodeCodex
handoff card before compactionyesyes, to ~/.codex/handoffs/
pointer to the card afterwardsin the summaryas developer context (PostCompact)
hold an auto-compaction while subagents runyesyes (continue: false)
refuse a new subagent near the limityesyes (PreToolUse on spawn_agent)
the summary itselfwritten on HaikuCodex's own; a hook cannot replace it
who writes the card$.model.complete on Haikucodex exec --ephemeral on the model you pick (SEGUE_MODEL, default: your Codex default)
the context's fillfrom the engineestimated 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.

what it costs

One real conversation of 105k tokens, compacted three ways:

built-in, on the session's model (Opus)built-in, session switched to Haikusegue
cost of the compaction$0.31 warm cache · $1.12 cold$0.10≈ $0.011
context left afterwards~5.9k tokens816 tokens
time~20 s6.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.

what the agent reads afterwards

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.

options

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.

optiondefaultwhat it does
modelhaikuthe model that writes the summary and the card
handoffDir~/.claude/handoffswhere the cards go; one file per compaction, <date>-<time>-<session>.md
censusCommandemptya shell command whose output is given to the model as the machine's state; empty means the built-in git census
holdForAgentsMinutes10how long an automatic compaction is held back while subagents of the conversation are still running; 0 never holds
agentGuardPercent90from 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.

how it works

/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>
ifthen
the model call fails or the summary is too shortthe built-in summary runs; a card that was written is still named after it
the card is cut off by the output limitit is written as far as it got
the card cannot be writtenthe summary stands without the pointer
a subagent compacts its own transcriptsegue stays out of it
auto-compaction comes while subagents of the conversation are still runningit 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 abovethe 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.

limits

  • The interface is experimental. A Claude Code update can change it. The failure mode is the built-in compaction, and the toast or the debug log says so.
  • The card is written by a small model. It is told to cite an artefact for every "done" and to mark the rest unconfirmed, and the agent is told to check the card against the repository. It is still a hypothesis.
  • The summary is short on purpose (up to 15 sentences). Detail belongs in the card and in the files it points to.
  • Very long transcripts are trimmed. Tool output is clipped, and above about 420k characters the middle of the conversation is left out of the model call; the beginning and the recent part stay.
  • Cards are plain files and are never deleted. A card can contain whatever the conversation contained. Keep handoffDir out of anything you share, and clear it when you like.
  • One compaction plugin at a time. If another plugin also answers session.compact, only one of them writes the summary.
  • The transcript goes to the summary model through Claude Code's own model access. segue makes no network call of its own.
  • Tested on macOS; CI runs the tests on Linux. Without 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.

why the name?

Segue is a direction in a score: go on to the next section without a pause. It comes from the same house as Bernstein.

license

Apache-2.0.

Source 1 files
hooks/register.ts 719 lines
1// 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