SLOPSHOPPER

context-rollover

Rolls long-running (multi-agent) Claude Code work over into fresh sessions before the context gets too large: persists a compact, verified continuation of the…

newguardcommandtoastmodelprocess
v0.1.0no licenseupdated 2026-10-09aymengraoui/claude-code-context-rollover
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-rollover
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rollover ⎿ context-rollover: phase monitoring · session preview- · generation 0 ⎿ context-rollover: context 97k / 180k (soft 160k, prepare 170k, clamped from 200k to fit the model's window) ⎿ context-rollover: handoff none ⎿ context-rollover: restart automatic via clear ⎿ context-rollover: now: Monitoring context usage ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

context-rollover

A Claude Code mod that rolls long-running work — including multi-agent work — over into genuinely fresh sessions before the context grows too large, and carries the project's state (not the conversation) across.

Session A ──soft──▶ preparing ──prepare──▶ ready ──turn ends──▶ persist ─▶ /clear ─▶ Session B ─▶ … ─▶ Session C
                                              └──hard, mid-turn──▶ drain ──┘

It publishes its whole lifecycle as shared state, which the Cockpit sidebar draws as a CONTEXT ROLLOVER block.

Install

The mod is a plugin folder loaded the same way as the Cockpit: list both folders in CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. The separator is ; on Windows and : elsewhere.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "C:/Users/UltraPc/Desktop/Code/claude-code-cockpit;C:/Users/UltraPc/Desktop/Code/claude-code-context-rollover"
  }
}

Start a new Claude Code session; the mod loads at startup. For a one-off session use claude --plugin-dir <this folder>.

Use

It runs by itself. /rollover gives you control:

CommandWhat it does
/rollover or /rollover statusThe state in words: phase, context, thresholds, handoff, last outcome, error
/rollover nowRoll over now, whatever the context
/rollover resume [id]Restore a saved continuation into this (fresh) session — the newest unrestored one when no id is given
/rollover cancelStand a pending rollover down and lift the gate (not once the restart has begun)
/rollover configThe effective configuration

Configuration

Defaults below. Every value is a userConfig field: set it in /config, or in settings.json under pluginConfigs["context-rollover"].options. A project can override any of them in .claude/context-rollover.json, which takes precedence over both. Bad values are skipped with a warning shown in /rollover status and the Cockpit; thresholds that are not strictly rising fall back to the defaults together.

{
  "enabled": true,
  "softLimit": 180000,
  "prepareLimit": 190000,
  "hardLimit": 200000,
  "automaticRestart": true,
  "restartMode": "clear",
  "continuationDir": "",
  "maxContinuationTokens": 10000,
  "rolloverTimeoutMs": 600000,
  "maxRetries": 3,
  "retryDelayMs": 5000,
  "logLevel": "info",
  "agentPolicy": "drain",
  "agentDrainTimeoutMs": 300000,
  "notifyAgents": true,
  "blockNewAgents": true,
  "autoCommit": false,
  "protectedBranches": "main,master",
  "gitSafetyRef": true,
  "resumeOnStartup": "ask",
  "useModelSummary": true,
  "notifyModel": true,
  "staleAfterHours": 24
}
SettingMeaning
softLimitThe model is told the session will roll over soon, and should keep its task list current.
prepareLimitA draft continuation is written to disk; new Agent spawns are refused (blockNewAgents); the rollover runs at the next turn end.
hardLimitMid-turn: the main loop's tool calls are refused (task-list tools excepted) so the model ends its turn; if it does not end within min(2 min, rolloverTimeoutMs/4) the turn is aborted. Then the rollover runs.
automaticRestartOff: the continuation is persisted, /clear is put in your prompt box, and your /clear restores it.
restartModeclear (default): /clear in the same terminal — the engine ends the conversation and continues under a new session id with an empty transcript. new-terminal: opens a terminal running claude "/rollover resume <id>", then exits this one; falls back to clear when no terminal opens.
continuationDirEmpty: ~/.claude/context-rollover/<project> (outside the repository, so nothing appears in git status). Relative: under the project.
agentPolicydrain: tell running agents to wrap up (notifyAgents), wait up to agentDrainTimeoutMs, record the rest. record: record them and go.
autoCommitOff by default. On, it commits (git add -A) only on a named branch that is not protected, with no conflicts and no merge/rebase/cherry-pick in progress.
gitSafetyRefKeeps refs/context-rollover/<id> → a git stash create commit of the tracked changes. That touches neither the working tree, the index nor the stash list.
resumeOnStartupA new process finding an unrestored continuation (the terminal closed mid-rollover): ask puts /rollover resume <id> in the prompt box, auto restores it, off only shows it.

Safety margin. The defaults leave a 10k gap between each threshold. If the model's context window is smaller than the configured hard limit allows, all three thresholds move down together so the hard limit sits at 90% of the window (/rollover status says so). If a fresh session already starts past the soft limit — for example because the system prompt, tools and CLAUDE.md are larger than the limits — the mod stops after one rollover with an error rather than looping.

How it works

Context usage

The figure is the engine's own, never an estimate:

  • turn.step — every main-loop model response reports its usage; context = input_tokens + cache_read_input_tokens + cache_creation_input_tokens. This is exactly the status line's total_input_tokens, and it arrives mid-turn, which matters: a single autonomous turn of a five-agent build can add hundreds of thousands of tokens.
  • session.measure — pushed by the engine after each main turn (and on window changes).
  • $.session.usage() — read once at startup.

What it measures: the input tokens the last main-loop request was answered over — the current context window's fill as of that response, not session totals and not subagents' contexts. The next request will be larger by the response and any tool results, which is why the limits sit below where you would actually want to stop. No polling: readings are pushed by those events. A reading that repeats the zone it is in does nothing; each threshold acts once per session.

Continuation state

At the rollover the mod gathers structured state first:

  1. Git: status --porcelain=v2, diff --shortstat, log -5, merge/rebase markers, a safety ref (and a commit only if you turned autoCommit on).
  2. Tasks: the TaskList tool's own record (owners, blockers), else the TaskCreate/TaskUpdate calls seen this session, else the last TodoWrite.
  3. Agents: $.agent.list() joined with the Agent calls in the transcript (their prompts = responsibilities) and each agent's last answer from its own transcript.
  4. The transcript's own payloads: the person's first and recent requests (verbatim, cut), files written, recent tool errors.
  5. The previous generation's objective and decisions, carried forward.

Then it asks one targeted question through $.model.fork: intent, decisions with reasons, blockers, unfinished work, next actions, unverified beliefs, suggested skills — only what the repository cannot say, as JSON. The fork re-sends the transcript as the main thread last sent it, so the prompt cache serves almost all of it. If that fails, the structured state is persisted anyway, with a note.

The result is rendered as markdown under maxContinuationTokens. If it is over budget, the lowest-priority sections lose lines first; the objective, next actions and resume protocol are never cut. Known credential shapes are redacted. The conventions follow the handoff skill: reference settled artifacts by path, never copy them; list unverified claims separately; suggest skills.

Persistence: each snapshot is a new file (snapshots/<time>-<id>-<draft|final>.json, plus a readable .md) whose checksum covers its content; it is read back and verified after writing. Nothing is ever overwritten or deleted, so the only copy of state is never destroyed. A torn or corrupted file fails its checksum, and the next newest valid one is used.

Fresh session

Between turns, the mod runs /clear through $.command.run. The engine ends the conversation (SessionEnd reason clear) and continues in the same process under a new session id with an empty transcript. It does not inherit the conversation. The mod waits for the new id, claims the rollover (a claims/<id>.json, so it is restored once), appends the continuation as a row the model reads and you do not see as typed ($.session.append, user role, isMeta), and submits a short prompt that starts the turn. If the row is refused, the continuation rides in the prompt itself.

The fresh session follows the continuation's resume protocol:

  1. Verify git status / git log against the record.
  2. Recreate the tasks.
  3. Re-dispatch agents.
  4. Continue with the first next action.

Its first completed turn marks the transition COMPLETED, and monitoring starts over for the next one.

Agents and teammates

What survives a rollover is the record, not the agent. A subagent's or an in-process teammate's loop belongs to the conversation that started it; Claude Code has no supported way to re-attach a running loop to a new conversation, and this mod does not pretend otherwise. So:

  • From the prepare limit, new agents are refused (the model is told to record the task instead).
  • At the rollover, running agents are told to wrap up ($.session.send) and waited for up to agentDrainTimeoutMs. Their file edits are already in the working tree, which is the source of truth.
  • Every agent is recorded with its identity (name, id, type, team address), responsibility (its dispatch prompt), status, last output, and an action:
  • re-dispatch — running, pending, failed or killed, or an idle teammate.
  • review-output — finished; its output may not be in the repository yet.
  • none.
  • The fresh session re-dispatches from that record. Task ownership travels with the task list.

Recovery and idempotency

  • One rollover at a time. A second trigger (another reading, /rollover now, a measurement) joins the one in flight.
  • Every step checks whether it already happened before acting: a final snapshot on disk is reused; a session id that already changed skips /clear; a held claim is never restored twice. So a retry, a module reload or a duplicate trigger resumes the same rollover rather than starting a competing one.
  • Retries with exponential backoff (maxRetries, retryDelayMs). An attempt is bounded by rolloverTimeoutMs.
  • On failure the gate is lifted, so the session is never left stuck. If the continuation was saved, the phase is awaiting-restart, /clear is put in your prompt box, and your /clear restores it; otherwise failed, and /rollover now retries.
  • A per-session journal (sessions/<id>.json) records the phase. If the process dies (Ctrl+C, terminal closed, crash), the next session in that project reports the rollover as interrupted, finds the newest valid unclaimed continuation (younger than staleAfterHours), and offers or restores it per resumeOnStartup.
  • Missing or corrupted files are skipped; a newer corrupted snapshot falls back to an older valid one.
  • A failure inside the mod's gate lets the tool call through. The mod must never block your work by breaking.

Shared state contract

The mod is the single writer of $.state context-rollover.status; the type is in types/index.d.ts (schema version 1). $.state is the engine's supported cross-mod channel: host-held, versioned, written with compare-and-set (update retries on a version miss, so concurrent writes cannot corrupt it), and reactive. A render hook that reads it is redrawn on every write, with no polling and no flicker.

FieldMeaning
schemaVersion1. Readers draw nothing they cannot validate.
sessionId, generationThe current session; how many rollovers this chain has done.
phasedisabled · monitoring · preparing · ready · draining · persisting · restarting · resuming · completed · awaiting-restart · failed
contexttokens (null before the first response), window, measuredAt, source (turn.step, session.measure or session.usage)
thresholds{ soft, prepare, hard } in force (after any window clamp)
continuationstatus (none · in-progress · draft · persisted · pending · restored · failed), path, rolloverId, bytes, approxTokens, updatedAt
agents{ active, idle, pending, completed, failed, total }, or null when unavailable — never invented
tasks{ pending, inProgress, completed, total, source } or null
lastThe previous rollover: outcome (success · failed · interrupted), at, rolloverId, fromSessionId, toSessionId, finalTokens, detail
operationWhat is happening now, or next
error{ message, at, isRetriable } or null
restart{ mode, isAutomatic }
heartbeatAt, updatedAtHeartbeat every 15 s. A reader calls the state STALE past 45 s.

Observers must treat a missing value as UNAVAILABLE, an unknown schema or malformed value as UNAVAILABLE, and an old heartbeat as STALE. They must never trigger a rollover themselves. The Cockpit keeps its own copy of the type and validates every read, so either mod builds, loads and runs without the other.

Files

hooks/register.ts          the engine's events → the lifecycle; $ → its port
hooks/lib/rollover.ts      the lifecycle (one deep module behind a narrow Port)
hooks/lib/thresholds.ts    zones, progress, next threshold, window clamp
hooks/lib/config.ts        defaults, merge, validation
hooks/lib/contract.ts      the shared state's runtime side: initial value, validation, staleness
hooks/lib/continuation.ts  the artifact: sections, budget trimming, redaction, resume prompt
hooks/lib/summary.ts       the one targeted model question, and its parse
hooks/lib/store.ts         snapshots, checksums, claims, journals
hooks/lib/git.ts           porcelain parsing, the autoCommit policy
hooks/lib/agents.ts        counts, records, actions
hooks/lib/transcript.ts    structured facts from the session's own tool calls
tests/                     units, lifecycle simulation, engine-dispatch tests
types/index.d.ts           the shared state contract

Tests

claude plugin test .     # 73 tests
npx -p typescript@5.6.3 tsc -p .
claude plugin validate .

What is and is not tested:

  • Unit tests (tests/units.test.ts): configuration, thresholds, progress, the contract, git parsing and commit policy, agent records, transcript extraction, summary parsing, continuation size limits and redaction, checksums, corrupted-file fallback, claims and claim races.
  • Lifecycle simulation (tests/lifecycle.test.ts): a simulated process (tests/world.ts) whose /clear behaves as the engine documents. It covers:
  • every threshold transition, drain and abort;
  • duplicate and concurrent triggers;
  • uncommitted changes and safety refs;
  • five agents draining, with records;
  • failed, hanging and no-op restarts;
  • submit failures, manual restart, new-terminal mode;
  • a killed process and recovery;
  • corrupted files, module reload, limits below the fresh-session size;
  • and the full A → threshold → persisted → A ended → B started → restored → resumed → rollover → C chain.

These are simulations.

  • Engine-dispatch tests (tests/engine.test.ts): register.ts through the engine's own hook dispatch (the claude plugin test kit). They cover the published $.state, the project config file, the tool-call gate from a real turn.step usage chunk, /rollover status and /rollover resume, and the startup offer.
  • Live run (manual, during development): a real claude -p --input-format stream-json process with this mod and thresholds of 32k/33k/34k ran A → B → C for real. Each session hit the hard limit mid-turn (measured from turn.step), drained, persisted, ran /clear (SessionStart:clear fired), and got a new session id with an empty transcript. Each fresh session read the appended continuation, verified git, and resumed. A decision made in A was still known in C.

Not covered live: an interactive terminal session, and real running subagents during a drain. The agent logic is covered by the simulation only.

Known limitations

  • Live agents cannot be carried over. They are recorded and re-dispatched; their in-flight reasoning is lost. Agents still running when /clear happens are left to the engine (they may be stopped). Their file edits remain in the working tree.
  • Teammates in their own terminal panes run outside this process. They show in $.agent.list() by their roster word and are recorded, but the mod neither stops nor restarts them.
  • Untracked files are not in the safety ref. git stash create covers tracked changes only. Untracked files are listed in the continuation and stay on disk; the mod never cleans.
  • The claim is a best-effort lease. The engine's file API has no exclusive create or rename, so two processes racing to restore the same continuation are resolved by write-then-verify. The simulation shows the loser backs off; a sub-millisecond race could in theory let both proceed.
  • approxTokens bounds the artifact by characters (÷ 3.5), which is deliberately generous. It is a size cap on the file, not a context reading.
  • The targeted summary costs one forked request over the cached transcript (cache reads plus about 1–2k output tokens) per rollover. Turn it off with useModelSummary: false.
  • claude -p exits when its turn ends, so a one-shot -p run cannot complete a rollover (it is reported as interrupted next time). Interactive sessions and long-lived headless (stream-json, SDK) sessions can.
  • A timed-out attempt cannot be cancelled mid-call. The retry is safe because every step checks whether it already happened.
  • Plugin slash commands (/rollover …) run from the interactive prompt, not from -p input.
Source 13 files
hooks/register.ts 302 lines
1/**
2 * The context-rollover mod: the engine's events wired to the lifecycle in
3 * `lib/rollover.ts`, and `$` adapted to its port. Nothing here decides anything.
4 *
5 * Reading:  turn.step (each main-loop response's own usage), session.measure, $.session.usage
6 * Acting:   tool.call (the gate), /clear, $.session.append (the continuation), $.prompt.submit
7 * Sharing:  $.state `context-rollover.status`, the contract in types/index.d.ts
8 */
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import type { RolloverStatus } from '../types'
14import type { AgentSeen } from './lib/agents'
15import { mergeConfig, parseProjectConfig, shouldLog } from './lib/config'
16import type { Config } from './lib/config'
17import { HEARTBEAT_MS, initialStatus, validateStatus } from './lib/contract'
18import { isWindowsPath, launchCommands } from './lib/launch'
19import { ALLOWED_WHILE_DRAINING, Rollover } from './lib/rollover'
20import type { Port } from './lib/rollover'
21import { rootFor } from './lib/store'
22import { contextTokensOf } from './lib/thresholds'
23import { tasksFromTaskList } from './lib/transcript'
24import type { MessageSeen } from './lib/transcript'
25
26const PROJECT_CONFIG = '.claude/context-rollover.json'
27
28const status = atom({ plugin: 'context-rollover', key: 'status' } as const, initialStatus({ soft: 180000, prepare: 190000, hard: 200000 }, 0))
29
30/** The lifecycle of this load; a reload makes a new one over the state the host kept. */
31let rollover: Rollover | null = null
32
33const toPosix = (path: string): string => path.split(String.fromCharCode(92)).join('/')
34
35const asMessages = (rows: unknown): MessageSeen[] => (Array.isArray(rows) ? (rows as MessageSeen[]) : [])
36
37/** Run `work` from a timer, outside any hook a turn waits on, and settle with it. */
38const detached = <T>($: EngineInterface, work: () => Promise<T>): Promise<T> =>
39  new Promise<T>((resolve, reject) => {
40    $.clock.after(1, () => {
41      work().then(resolve, reject)
42    })
43  })
44
45const readConfig = async ($: EngineInterface, options: Readonly<Record<string, unknown>>): Promise<{ config: Config; warnings: string[] }> => {
46  const cwd = toPosix(await $.session.cwd().catch(() => ''))
47  const file = cwd === '' ? null : await $.fs.read(`${cwd}/${PROJECT_CONFIG}`).catch(() => null)
48  const project = file === null ? { values: null, warning: null } : parseProjectConfig(file)
49  const merged = mergeConfig(options, project.values)
50
51  return { config: merged.config, warnings: project.warning === null ? merged.warnings : [project.warning, ...merged.warnings] }
52}
53
54/** `$` as the lifecycle's port. */
55const portOf = ($: EngineInterface, getConfig: () => Config): Port => {
56  let root: string | null = null
57
58  return {
59    now: () => $.clock.now(),
60    sleep: ms => $.clock.sleep(ms),
61    timer: (ms, fn) => {
62      const timer = $.clock.after(ms, fn)
63
64      return () => timer.cancel()
65    },
66    fs: {
67      read: path => $.fs.read(path),
68      write: (path, text) => $.fs.write(path, text),
69      list: path => $.fs.list(path),
70      exists: path => $.fs.exists(path),
71    },
72    root: async () => {
73      if (root !== null) return root
74      const [cwd, profile, home] = await Promise.all([$.session.cwd(), $.env.get('USERPROFILE'), $.env.get('HOME')])
75      root = rootFor(getConfig().continuationDir, toPosix(cwd), profile ?? home ?? null)
76
77      return root
78    },
79    readStatus: async () => {
80      const held = await $.state.get({ plugin: 'context-rollover', key: 'status' })
81      const checked = validateStatus(held.value)
82
83      return checked.isValid && held.version > 0 ? checked.status : null
84    },
85    writeStatus: async change => {
86      await update($, status, prev => change(validateStatus(prev).isValid ? prev : initialStatus(prev.thresholds, Date.now())))
87    },
88    sessionId: () => $.session.id(),
89    cwd: async () => toPosix(await $.session.cwd()),
90    usage: async () => {
91      const { context } = await $.session.usage()
92
93      return { tokens: context.tokens ?? null, window: context.window ?? null }
94    },
95    agents: async () =>
96      (await $.agent.list()).map(
97        (one): AgentSeen => ({
98          id: one.id,
99          status: one.status,
100          description: one.description,
101          type: one.type,
102          ...(one.name === undefined ? {} : { name: one.name }),
103          ...(one.teammateId === undefined ? {} : { teammateId: one.teammateId }),
104          ...(one.parentId === undefined ? {} : { parentId: one.parentId }),
105        }),
106      ),
107    agentMessages: async agentId => {
108      const rows = await $.session.messages({ agentId })
109
110      return Array.isArray(rows) ? asMessages(rows) : null
111    },
112    messages: async () => asMessages(await $.session.messages()),
113    taskList: async () => {
114      const listed = await $.tool.list().catch(() => [])
115      if (!listed.some(one => one.name === 'TaskList')) return null
116      const ran = await $.tool.call({ tool: 'TaskList' } as never)
117      if ('deny' in ran && typeof ran.deny === 'string') return null
118
119      return tasksFromTaskList(ran.result ?? ran.text ?? null)
120    },
121    git: async args => {
122      try {
123        const cwd = await $.session.cwd()
124        const ran = await $.process.run(['git', '-C', cwd, ...args], { timeoutMs: 30000 })
125
126        return { exitCode: ran.exitCode, stdout: ran.stdout }
127      } catch {
128        return { exitCode: -1, stdout: '' }
129      }
130    },
131    fork: async prompt => {
132      const reply = await $.model.fork({ prompt })
133
134      return reply.isAnswered ? { isAnswered: true, text: reply.text } : { isAnswered: false, reason: reply.reason }
135    },
136    sendToAgent: async (agentId, text) => {
137      await $.session.send({ to: { agentId }, text })
138    },
139    noteToModel: async text => {
140      await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
141    },
142    clear: () =>
143      detached($, async () => {
144        await $.command.run({ command: 'clear' })
145      }),
146    submit: text =>
147      detached($, async () => {
148        await $.prompt.submit({ text })
149      }),
150    launchTerminal: async (command, cwd) => {
151      for (const argv of launchCommands(command, cwd, isWindowsPath(cwd))) {
152        const ran = await $.process.run(argv).catch(() => null)
153        if (ran?.exitCode === 0) return true
154      }
155
156      return false
157    },
158    exit: () =>
159      detached($, async () => {
160        await $.command.run({ command: 'exit' })
161      }),
162    abortTurn: turnId => $.turn.abort({ turnId }),
163    fillPrompt: async text => {
164      await $.prompt.fill({ text })
165    },
166    toast: text => $.ui.toast(text),
167    log: (level, text, toTranscript) => {
168      if (!shouldLog(getConfig().logLevel, level)) return
169      $.ui.log(`context-rollover: ${text}`, toTranscript ? undefined : { to: 'debug' })
170    },
171  }
172}
173
174/** Lines `/rollover status` prints: the state as the sidebar sees it, in words. */
175const describe = (s: RolloverStatus, config: Config): string => {
176  const k = (n: number | null): string => (n === null ? '—' : `${Math.round(n / 1000)}k`)
177  const lines = [
178    `phase ${s.phase} · session ${s.sessionId?.slice(0, 8) ?? '?'} · generation ${s.generation}`,
179    `context ${k(s.context.tokens)} / ${k(s.thresholds.hard)} (soft ${k(s.thresholds.soft)}, prepare ${k(s.thresholds.prepare)}${s.thresholds.hard !== config.hardLimit ? `, clamped from ${k(config.hardLimit)} to fit the model's window` : ''})`,
180    `handoff ${s.continuation.status}${s.continuation.path === null ? '' : ` · ${s.continuation.path}`}`,
181    `restart ${s.restart.isAutomatic ? 'automatic' : 'manual'} via ${s.restart.mode}`,
182    `now: ${s.operation}`,
183  ]
184  if (s.last !== null) lines.push(`last: ${s.last.outcome} · ${s.last.rolloverId} · ${new Date(s.last.at).toISOString()}${s.last.detail === null ? '' : ` · ${s.last.detail}`}`)
185  if (s.error !== null) lines.push(`error: ${s.error.message}`)
186
187  return lines.join('\n')
188}
189
190export const register: Register = (on, options) => {
191  /** The configuration of this load: options from /config, then the project's file. */
192  let config: Config = mergeConfig(options).config
193
194  on('session.start', async ($, e, next) => {
195    await $.command.register({
196      name: 'rollover',
197      description: 'Context rollover: status, now, resume [id], cancel, config',
198      argumentHint: '[status|now|resume [id]|cancel|config]',
199    })
200    const loaded = await readConfig($, options)
201    config = loaded.config
202    rollover = new Rollover(portOf($, () => config), config, loaded.warnings)
203    await rollover.start().catch(err => $.ui.log(`context-rollover: start failed: ${String(err)}`))
204
205    // The heartbeat says the mod is alive; the agent counts ride along.
206    $.clock.every(HEARTBEAT_MS, () => {
207      void rollover?.heartbeat().catch(() => undefined)
208      void rollover?.refreshAgents().catch(() => undefined)
209    })
210
211    return next(e)
212  })
213
214  on('turn.start', async ($, e, next) => {
215    await rollover?.onTurnStart(e.turnId).catch(() => undefined)
216
217    return next(e)
218  })
219
220  // Each main-loop response's own usage: the exact context, mid-turn, with no extra call.
221  on('turn.step', async function* ($, e, next) {
222    const result = yield* next(e)
223    if (e.agentId === undefined && result.usage !== null) {
224      await rollover?.observe(contextTokensOf(result.usage), null, 'turn.step').catch(() => undefined)
225    }
226
227    return result
228  })
229
230  on('session.measure', async ($, e, next) => {
231    if (e.changed.includes('context')) {
232      await rollover?.observe(e.context.tokens ?? null, e.context.window ?? null, 'session.measure').catch(() => undefined)
233    }
234
235    return next(e)
236  })
237
238  on('turn.complete', async ($, e, next) => {
239    const result = await next(e)
240    if (e.agentId === undefined) await rollover?.onTurnEnd().catch(() => undefined)
241
242    return result
243  })
244
245  // The gate: from the prepare limit, no new agents; while draining, no new main-loop work.
246  // A failure here lets the call through: this mod must never block work by breaking.
247  on('tool.call', async ($, e, next) => {
248    const isMain = (e as { agentId?: string }).agentId === undefined
249    const refusal = rollover === null ? null : await rollover.gate(e.tool, isMain)
250    if (refusal !== null) return { deny: refusal }
251
252    const result = await next(e)
253    if (ALLOWED_WHILE_DRAINING.has(e.tool) && !('deny' in result && typeof result.deny === 'string')) {
254      await rollover?.onToolResult(e.tool, e as unknown as Record<string, unknown>, result.result).catch(() => undefined)
255    }
256
257    return result
258  }).catch(($, e, next) => next(e))
259
260  on('classic.SessionStart', async ($, e, next) => {
261    if (e.source === 'clear' && typeof e.session_id === 'string') await rollover?.onCleared(e.session_id).catch(() => undefined)
262
263    return next(e)
264  })
265
266  on('classic.SubagentStart', async ($, e, next) => {
267    await rollover?.refreshAgents().catch(() => undefined)
268
269    return next(e)
270  })
271
272  on('classic.SubagentStop', async ($, e, next) => {
273    await rollover?.refreshAgents().catch(() => undefined)
274
275    return next(e)
276  })
277
278  on('command.run', { command: 'rollover' }, async ($, e) => {
279    const current = rollover
280    if (current === null) return { text: 'context-rollover is not running yet.' }
281    const [verb = 'status', arg] = e.args.trim().split(/\s+/).filter(one => one !== '')
282    switch (verb) {
283      case 'now':
284        if (current.isRolling) return { text: 'A rollover is already under way.' }
285        // Detached: the rollover runs /clear, which cannot run inside this command.
286        $.clock.after(1, () => void current.rollover('requested with /rollover now'))
287        return { text: 'Rolling over now: persisting the continuation, then starting a fresh session.' }
288      case 'resume':
289        $.clock.after(1, () => void current.resume(arg).then(text => $.ui.toast(text)))
290        return { text: arg === undefined ? 'Restoring the newest continuation nobody restored yet.' : `Restoring ${arg}.` }
291      case 'cancel':
292        return { text: await current.cancel() }
293      case 'config':
294        return { text: JSON.stringify(current.settings, null, 2) }
295      default: {
296        const s = await read($, status)
297        return { text: describe(s, current.settings) }
298      }
299    }
300  })
301}
302
hooks/lib/agents.ts 104 lines
1/**
2 * Agents and teammates: counting them for the sidebar, and recording each one so a
3 * fresh session can reconstruct the team. Pure.
4 *
5 * What survives a rollover is the record, not the agent: a subagent's or an
6 * in-process teammate's loop belongs to the session that started it, and nothing in
7 * Claude Code re-attaches a running loop to a new conversation. So each agent is
8 * written down with its identity, its responsibility (the prompt it was given) and
9 * its last output, and the fresh session re-dispatches what was unfinished.
10 */
11
12import type { AgentCounts } from '../../types'
13
14export type AgentStatus = 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
15
16/** What `$.agent.list()` gives, as far as this module reads it. */
17export type AgentSeen = {
18  id: string
19  status: AgentStatus
20  description: string
21  type: string
22  name?: string
23  teammateId?: string
24  parentId?: string
25}
26
27/** What the main transcript says the Agent call that started it asked. */
28export type AgentCall = { agentId: string | null; description: string; prompt: string; subagentType: string | null; name: string | null }
29
30export type AgentRecord = {
31  id: string
32  name: string | null
33  type: string
34  description: string
35  status: AgentStatus
36  teammateId: string | null
37  /** The prompt it was dispatched with, cut. */
38  responsibility: string | null
39  /** Its last answer, cut. */
40  lastOutput: string | null
41  /** What the fresh session should do about it. */
42  action: 're-dispatch' | 'review-output' | 'none'
43}
44
45export const ACTIVE: ReadonlySet<AgentStatus> = new Set(['running', 'waiting'])
46
47export const countAgents = (agents: readonly Pick<AgentSeen, 'status'>[]): AgentCounts => {
48  const counts: AgentCounts = { active: 0, idle: 0, pending: 0, completed: 0, failed: 0, total: agents.length }
49  for (const one of agents) {
50    if (ACTIVE.has(one.status)) counts.active += 1
51    else if (one.status === 'idle') counts.idle += 1
52    else if (one.status === 'pending') counts.pending += 1
53    else if (one.status === 'completed') counts.completed += 1
54    else counts.failed += 1
55  }
56
57  return counts
58}
59
60/** Agents still doing work the rollover would cut off. */
61export const stillWorking = (agents: readonly Pick<AgentSeen, 'status'>[]): number =>
62  agents.filter(one => ACTIVE.has(one.status) || one.status === 'pending').length
63
64/**
65 * The action for an agent as the rollover finds it. Interrupted or never-started work
66 * is re-dispatched; finished work is reviewed (its output may not be in the repository
67 * yet); an idle teammate is re-dispatched too, since its standing role ends with the loop.
68 */
69export const actionFor = (status: AgentStatus, hasOutput: boolean, isTeammate: boolean): AgentRecord['action'] => {
70  if (status === 'running' || status === 'waiting' || status === 'pending' || status === 'failed' || status === 'killed') return 're-dispatch'
71  if (status === 'idle') return isTeammate ? 're-dispatch' : hasOutput ? 'review-output' : 'none'
72
73  return hasOutput ? 'review-output' : 'none'
74}
75
76/** Join the engine's list with the calls that started each agent and their outputs. */
77export const recordAgents = (
78  seen: readonly AgentSeen[],
79  calls: readonly AgentCall[],
80  outputs: Readonly<Record<string, string | null>>,
81  limit = 12,
82): AgentRecord[] =>
83  seen.slice(-limit).map(agent => {
84    const call = calls.find(one => one.agentId === agent.id) ?? calls.find(one => one.agentId === null && agent.name !== undefined && one.name === agent.name) ?? null
85    const lastOutput = outputs[agent.id] ?? null
86    const isTeammate = agent.teammateId !== undefined || agent.type === 'teammate'
87
88    return {
89      id: agent.id,
90      name: agent.name ?? call?.name ?? null,
91      type: call?.subagentType ?? agent.type,
92      description: agent.description || call?.description || agent.type,
93      status: agent.status,
94      teammateId: agent.teammateId ?? null,
95      responsibility: call?.prompt ?? null,
96      lastOutput,
97      action: actionFor(agent.status, lastOutput !== null && lastOutput !== '', isTeammate),
98    }
99  })
100
101/** What a running agent is told when the drain starts. */
102export const WRAP_UP_MESSAGE =
103  'The lead session is rolling over to a fresh context shortly. Finish the step you are on, leave the files consistent, report what you completed and what remains in your final answer, and do not start new subtasks.'
104
hooks/lib/config.ts 191 lines
1/**
2 * Configuration: the defaults, the two sources merged over them, and the checks that
3 * keep a bad value from ever reaching the lifecycle. Pure.
4 *
5 * Sources, lowest precedence first: the defaults below; the plugin's `userConfig`
6 * (the `/config` menu, stored in settings.json `pluginConfigs`); a project's own
7 * `.claude/context-rollover.json`. Every threshold the lifecycle uses is read from the
8 * result — nothing downstream names a number of its own.
9 */
10
11export type RestartMode = 'clear' | 'new-terminal'
12export type AgentPolicy = 'drain' | 'record'
13export type ResumeOnStartup = 'ask' | 'auto' | 'off'
14export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
15
16export type Config = {
17  enabled: boolean
18  softLimit: number
19  prepareLimit: number
20  hardLimit: number
21  /** Start the fresh session without asking. Off: persist, then wait for the person's /clear. */
22  automaticRestart: boolean
23  /** `clear`: /clear in this terminal. `new-terminal`: open a new one, then exit this one. */
24  restartMode: RestartMode
25  /** Where continuations live. Empty: `~/.claude/context-rollover/<project>`. Relative: under the project. */
26  continuationDir: string
27  /** The continuation is trimmed, lowest-priority sections first, to fit this. */
28  maxContinuationTokens: number
29  /** Past this, an attempt is abandoned (and retried, or marked failed). */
30  rolloverTimeoutMs: number
31  maxRetries: number
32  retryDelayMs: number
33  logLevel: LogLevel
34  /** `drain`: wait for running agents (up to agentDrainTimeoutMs). `record`: record them and go. */
35  agentPolicy: AgentPolicy
36  agentDrainTimeoutMs: number
37  /** Ask running agents to wrap up when the drain starts. */
38  notifyAgents: boolean
39  /** Refuse new Agent spawns from the prepare limit on. */
40  blockNewAgents: boolean
41  /** Commit the working tree before the restart. Off by default: never commit to make rollover easy. */
42  autoCommit: boolean
43  /** Branches autoCommit never commits to. */
44  protectedBranches: readonly string[]
45  /** Keep a ref to the working tree (`git stash create`), which touches neither tree nor index. */
46  gitSafetyRef: boolean
47  /** A new session finding a continuation nobody restored: ask (fill the prompt), auto, or off. */
48  resumeOnStartup: ResumeOnStartup
49  /** Ask the session's own model (a fork, cached prefix) for what the repository cannot say. */
50  useModelSummary: boolean
51  /** Tell the model when the soft and prepare limits pass. */
52  notifyModel: boolean
53  /** A continuation older than this is offered no more. */
54  staleAfterHours: number
55}
56
57export const DEFAULTS: Config = {
58  enabled: true,
59  softLimit: 180000,
60  prepareLimit: 190000,
61  hardLimit: 200000,
62  automaticRestart: true,
63  restartMode: 'clear',
64  continuationDir: '',
65  maxContinuationTokens: 10000,
66  rolloverTimeoutMs: 600000,
67  maxRetries: 3,
68  retryDelayMs: 5000,
69  logLevel: 'info',
70  agentPolicy: 'drain',
71  agentDrainTimeoutMs: 300000,
72  notifyAgents: true,
73  blockNewAgents: true,
74  autoCommit: false,
75  protectedBranches: ['main', 'master'],
76  gitSafetyRef: true,
77  resumeOnStartup: 'ask',
78  useModelSummary: true,
79  notifyModel: true,
80  staleAfterHours: 24,
81}
82
83/** What a merge said about the values it could not take. */
84export type ConfigReading = { config: Config; warnings: string[] }
85
86type Rule = (value: unknown) => unknown | undefined
87
88const bool: Rule = v => (typeof v === 'boolean' ? v : undefined)
89const positiveInt: Rule = v =>
90  typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : undefined
91const nonNegativeInt: Rule = v =>
92  typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : undefined
93const oneOf =
94  (...values: readonly string[]): Rule =>
95  v =>
96    typeof v === 'string' && values.includes(v) ? v : undefined
97const text: Rule = v => (typeof v === 'string' ? v.trim() : undefined)
98const list: Rule = v =>
99  Array.isArray(v) && v.every(one => typeof one === 'string')
100    ? v
101    : typeof v === 'string'
102      ? v
103          .split(',')
104          .map(one => one.trim())
105          .filter(one => one !== '')
106      : undefined
107
108const RULES: { [K in keyof Config]: Rule } = {
109  enabled: bool,
110  softLimit: positiveInt,
111  prepareLimit: positiveInt,
112  hardLimit: positiveInt,
113  automaticRestart: bool,
114  restartMode: oneOf('clear', 'new-terminal'),
115  continuationDir: text,
116  maxContinuationTokens: positiveInt,
117  rolloverTimeoutMs: positiveInt,
118  maxRetries: nonNegativeInt,
119  retryDelayMs: nonNegativeInt,
120  logLevel: oneOf('error', 'warn', 'info', 'debug'),
121  agentPolicy: oneOf('drain', 'record'),
122  agentDrainTimeoutMs: nonNegativeInt,
123  notifyAgents: bool,
124  blockNewAgents: bool,
125  autoCommit: bool,
126  protectedBranches: list,
127  gitSafetyRef: bool,
128  resumeOnStartup: oneOf('ask', 'auto', 'off'),
129  useModelSummary: bool,
130  notifyModel: bool,
131  staleAfterHours: positiveInt,
132}
133
134/**
135 * Merge sources over the defaults, later sources winning. A value of the wrong kind
136 * is skipped with a warning naming it; a key nobody knows is reported too, so a typo
137 * in a project file is not silently ignored.
138 */
139export const mergeConfig = (...sources: readonly (Readonly<Record<string, unknown>> | null | undefined)[]): ConfigReading => {
140  const config: Record<string, unknown> = { ...DEFAULTS }
141  const warnings: string[] = []
142
143  for (const source of sources) {
144    if (source === null || source === undefined) continue
145    for (const [key, raw] of Object.entries(source)) {
146      const rule = (RULES as Record<string, Rule | undefined>)[key]
147      if (rule === undefined) {
148        warnings.push(`unknown setting "${key}"`)
149        continue
150      }
151      // An empty string from the /config menu means "unset": the default stands.
152      if (raw === '' && key !== 'continuationDir') continue
153      const value = rule(raw)
154      if (value === undefined) warnings.push(`"${key}" has an invalid value; kept ${JSON.stringify(config[key])}`)
155      else config[key] = value
156    }
157  }
158
159  const merged = config as Config
160  if (!(merged.softLimit < merged.prepareLimit && merged.prepareLimit < merged.hardLimit)) {
161    warnings.push(
162      `thresholds must rise soft < prepare < hard (got ${merged.softLimit} / ${merged.prepareLimit} / ${merged.hardLimit}); using the defaults`,
163    )
164    merged.softLimit = DEFAULTS.softLimit
165    merged.prepareLimit = DEFAULTS.prepareLimit
166    merged.hardLimit = DEFAULTS.hardLimit
167  }
168
169  return { config: merged, warnings }
170}
171
172/** Parse a project's `.claude/context-rollover.json`; anything but an object is a warning. */
173export const parseProjectConfig = (text: string): { values: Record<string, unknown> | null; warning: string | null } => {
174  try {
175    const parsed: unknown = JSON.parse(text)
176    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
177      return { values: null, warning: 'project config is not a JSON object; ignored' }
178    }
179
180    return { values: parsed as Record<string, unknown>, warning: null }
181  } catch {
182    return { values: null, warning: 'project config is not valid JSON; ignored' }
183  }
184}
185
186const LEVELS: readonly LogLevel[] = ['error', 'warn', 'info', 'debug']
187
188/** Whether a line at `level` is written under the configured level. */
189export const shouldLog = (configured: LogLevel, level: LogLevel): boolean =>
190  LEVELS.indexOf(level) <= LEVELS.indexOf(configured)
191
hooks/lib/contract.ts 89 lines
1/**
2 * The shared state contract's runtime side: the empty value, and the check every
3 * read goes through. Pure. The types are in `types/index.d.ts`.
4 */
5
6import type { RolloverPhase, RolloverStatus } from '../../types'
7import type { Thresholds } from './thresholds'
8
9export const SCHEMA_VERSION = 1 as const
10
11/** How often the heartbeat is bumped; an observer calls the state stale past 3×. */
12export const HEARTBEAT_MS = 15000
13
14export const PHASES: readonly RolloverPhase[] = [
15  'disabled',
16  'monitoring',
17  'preparing',
18  'ready',
19  'draining',
20  'persisting',
21  'restarting',
22  'resuming',
23  'completed',
24  'awaiting-restart',
25  'failed',
26]
27
28/** Phases in which a rollover is under way: a second trigger is a duplicate. */
29export const BUSY: ReadonlySet<RolloverPhase> = new Set(['draining', 'persisting', 'restarting', 'resuming'])
30
31export const initialStatus = (thresholds: Thresholds, now: number): RolloverStatus => ({
32  schemaVersion: SCHEMA_VERSION,
33  sessionId: null,
34  generation: 0,
35  phase: 'monitoring',
36  enabled: true,
37  context: { tokens: null, window: null, measuredAt: null, source: null },
38  thresholds,
39  continuation: { status: 'none', path: null, rolloverId: null, bytes: null, approxTokens: null, updatedAt: null },
40  agents: null,
41  tasks: null,
42  last: null,
43  operation: 'Monitoring context usage',
44  error: null,
45  restart: { mode: 'clear', isAutomatic: true },
46  heartbeatAt: now,
47  updatedAt: now,
48})
49
50const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
51const isNumOrNull = (v: unknown): boolean => v === null || isNum(v)
52const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
53
54export type Validated = { isValid: true; status: RolloverStatus } | { isValid: false; reason: string }
55
56/**
57 * Whether a value read from `$.state` (or anywhere) is a status this version can draw.
58 * Checks the fields a reader relies on; extra fields are allowed.
59 */
60export const validateStatus = (value: unknown): Validated => {
61  if (value === undefined || value === null) return { isValid: false, reason: 'missing' }
62  if (!isObj(value)) return { isValid: false, reason: 'not an object' }
63  if (value.schemaVersion !== SCHEMA_VERSION) {
64    return { isValid: false, reason: `schema ${String(value.schemaVersion)} unsupported` }
65  }
66  if (typeof value.phase !== 'string' || !PHASES.includes(value.phase as RolloverPhase)) {
67    return { isValid: false, reason: 'unknown phase' }
68  }
69  const context = value.context
70  if (!isObj(context) || !isNumOrNull(context.tokens) || !isNumOrNull(context.window)) {
71    return { isValid: false, reason: 'bad context' }
72  }
73  const t = value.thresholds
74  if (!isObj(t) || !isNum(t.soft) || !isNum(t.prepare) || !isNum(t.hard) || t.hard <= 0) {
75    return { isValid: false, reason: 'bad thresholds' }
76  }
77  if (!isObj(value.continuation) || typeof value.continuation.status !== 'string') {
78    return { isValid: false, reason: 'bad continuation' }
79  }
80  if (!isNum(value.heartbeatAt) || !isNum(value.updatedAt)) return { isValid: false, reason: 'bad timestamps' }
81  if (typeof value.operation !== 'string') return { isValid: false, reason: 'bad operation' }
82
83  return { isValid: true, status: value as RolloverStatus }
84}
85
86/** Whether the writer has stopped: no heartbeat for three periods. */
87export const isStale = (status: RolloverStatus, now: number, heartbeatMs = HEARTBEAT_MS): boolean =>
88  now - status.heartbeatAt > heartbeatMs * 3
89
hooks/lib/launch.ts 39 lines
1/** Opening a command in a terminal of its own (copied from the Cockpit mod, so each mod stands alone). Pure: it only builds the commands. */
2
3/** Windows paths start with a drive letter; that is enough to tell the platforms apart. */
4export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path)
5
6/**
7 * The commands to try, in order, to run `command` — `claude`, `claude --resume <id>` — in a
8 * new terminal.
9 *
10 * Every entry is a plain argv, so nothing is shell-quoted and nothing is guessed about
11 * the shell. The caller runs them until one exits cleanly, and copies the command to the
12 * clipboard when none does — a terminal that is not there must not lose the click.
13 */
14export const launchCommands = (
15  command: string,
16  cwd: string,
17  isWindows: boolean,
18): readonly string[][] => {
19
20  if (isWindows) {
21    return [
22      // Windows Terminal: a new tab in the window that is already open.
23      ['wt.exe', '-w', '0', 'nt', '-d', cwd, 'powershell', '-NoExit', '-Command', command],
24      // No Windows Terminal: a console window of its own.
25      // `start /D` sets where it opens: a resumed session is found from its own directory.
26      ['cmd.exe', '/c', 'start', '', '/D', cwd, 'cmd.exe', '/k', command],
27    ]
28  }
29
30  return [
31    // macOS: Terminal.app runs the command in a new window.
32    ['osascript', '-e', `tell application "Terminal" to do script "cd ${cwd} && ${command}"`],
33    // Linux: whatever the desktop nominated, then the usual suspects.
34    ['x-terminal-emulator', '-e', 'sh', '-c', `cd ${cwd} && ${command}`],
35    ['gnome-terminal', '--working-directory', cwd, '--', 'sh', '-c', command],
36    ['konsole', '--workdir', cwd, '-e', 'sh', '-c', command],
37  ]
38}
39
hooks/lib/rollover.ts 852 lines
1/**
2 * The rollover lifecycle: one deep module behind a narrow port. It decides when to
3 * prepare, when to roll over, what to persist, how to start the fresh session and how
4 * to recover; the port is everything it needs from the outside world, so the whole
5 * lifecycle runs in a test with fakes, and `register.ts` only adapts `$` to the port.
6 *
7 *   monitoring ─soft→ preparing ─prepare→ ready ─turn ends─→ persisting → restarting → resuming → completed → monitoring
8 *                                              └─hard mid-turn→ draining ─turn ends─┘
9 *
10 * Every step checks whether it already happened (a snapshot on disk, a session id that
11 * already changed, a claim already held), so a retry, a module reload or a second
12 * trigger resumes the same rollover instead of starting a competing one.
13 */
14
15import type { AgentCounts, RolloverError, RolloverOutcome, RolloverPhase, RolloverStatus, TaskCounts } from '../../types'
16import { countAgents, recordAgents, stillWorking, WRAP_UP_MESSAGE } from './agents'
17import type { AgentSeen } from './agents'
18import type { Config, LogLevel } from './config'
19import { buildContinuation, cut, resumePrompt, rolloverIdIn } from './continuation'
20import type { ContinuationData, TaskItem, TaskList } from './continuation'
21import { BUSY, initialStatus } from './contract'
22import { commitDecision, IN_PROGRESS_MARKERS, parsePorcelain, safetyRefName } from './git'
23import type { GitState } from './git'
24import { claim, findPending, findSnapshot, readJournals, seal, snapshotName, writeJournal, writeSnapshot } from './store'
25import type { FilePort, Snapshot } from './store'
26import { parseSummary, SUMMARY_PROMPT } from './summary'
27import type { Summary } from './summary'
28import { effectiveThresholds, zoneOf } from './thresholds'
29import type { Thresholds } from './thresholds'
30import { agentCalls, filesWritten, lastAnswer, lastTodos, recentErrors, userRequests } from './transcript'
31import type { MessageSeen } from './transcript'
32
33export type ContextSource = 'turn.step' | 'session.measure' | 'session.usage'
34
35/** Everything the lifecycle needs from outside. Each call may reject; the lifecycle copes. */
36export type Port = {
37  now: () => Promise<number>
38  sleep: (ms: number) => Promise<void>
39  /** Calls `fn` once after `ms` unless the returned cancel runs first. */
40  timer: (ms: number, fn: () => void) => () => void
41  fs: FilePort
42  /** Where this project's record lives (resolved once by the adapter). */
43  root: () => Promise<string>
44  readStatus: () => Promise<RolloverStatus | null>
45  writeStatus: (change: (prev: RolloverStatus) => RolloverStatus) => Promise<void>
46  sessionId: () => Promise<string>
47  cwd: () => Promise<string>
48  usage: () => Promise<{ tokens: number | null; window: number | null }>
49  agents: () => Promise<AgentSeen[] | null>
50  agentMessages: (agentId: string) => Promise<MessageSeen[] | null>
51  messages: () => Promise<MessageSeen[]>
52  taskList: () => Promise<TaskList | null>
53  /** git with the given arguments, in the project; never throws, exitCode -1 when it could not run. */
54  git: (args: readonly string[]) => Promise<{ exitCode: number; stdout: string }>
55  fork: (prompt: string) => Promise<{ isAnswered: boolean; text?: string; reason?: string }>
56  sendToAgent: (agentId: string, text: string) => Promise<void>
57  /** A user-role row the model reads and the person does not see as typed. */
58  noteToModel: (text: string) => Promise<void>
59  /** /clear: the conversation ends, the process goes on under a new session id. */
60  clear: () => Promise<void>
61  submit: (text: string) => Promise<void>
62  launchTerminal: (command: string, cwd: string) => Promise<boolean>
63  exit: () => Promise<void>
64  abortTurn: (turnId: string) => Promise<void>
65  fillPrompt: (text: string) => Promise<void>
66  toast: (text: string) => void
67  log: (level: LogLevel, text: string, toTranscript: boolean) => void
68}
69
70/** Tools the model may still use while a rollover drains: the ones that record state. */
71export const ALLOWED_WHILE_DRAINING: ReadonlySet<string> = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
72
73export const DRAIN_MESSAGE =
74  'Context rollover in progress: the context limit has been reached. Do not call more tools. End your turn now with a short status of what you were doing; the work continues automatically in a fresh session with the project state restored.'
75
76export const HOLD_AGENTS_MESSAGE =
77  'Context rollover is imminent: do not start new agents now. Record the task (TaskCreate or TodoWrite) so the fresh session dispatches it.'
78
79const SETTLE_MS = 400
80const NEW_SESSION_WAIT_MS = 15000
81const AGENT_POLL_MS = 3000
82
83type Counter = { pending: number; inProgress: number; completed: number }
84
85const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err))
86
87/** Run `work`, or reject with `timed out` once the port's timer for `ms` fires. */
88const within = async <T>(port: Port, ms: number, work: Promise<T>): Promise<T> => {
89  let cancel = (): void => undefined
90  const timer = new Promise<never>((_, reject) => {
91    cancel = port.timer(ms, () => reject(new Error(`timed out after ${Math.round(ms / 1000)}s`)))
92  })
93  try {
94    return await Promise.race([work, timer])
95  } finally {
96    cancel()
97  }
98}
99
100export class Rollover {
101  private config: Config
102  private configWarnings: string[]
103  private inFlight: Promise<void> | null = null
104  private turnId: string | null = null
105  private isTurnRunning = false
106  private notified = new Set<string>()
107  private draft: Promise<void> | null = null
108  /** Continuations handed to a fresh session, by rollover id, so `contextFor` need not read the disk. */
109  private handed = new Map<string, string>()
110  /** Tasks the TaskCreate/TaskUpdate calls of this session have built, by id. */
111  private tasks = new Map<string, TaskItem>()
112  private todos: TaskItem[] | null = null
113  /** The resumed session's first reading: its size before it did any work. */
114  private baseline: number | null = null
115
116  constructor(
117    private readonly port: Port,
118    config: Config,
119    warnings: readonly string[] = [],
120  ) {
121    this.config = config
122    this.configWarnings = [...warnings]
123  }
124
125  setConfig(config: Config, warnings: readonly string[]): void {
126    this.config = config
127    this.configWarnings = [...warnings]
128  }
129
130  get settings(): Config {
131    return this.config
132  }
133
134  private log(level: LogLevel, text: string, toTranscript = false): void {
135    this.port.log(level, text, toTranscript)
136  }
137
138  private async patch(change: Partial<RolloverStatus> | ((prev: RolloverStatus) => Partial<RolloverStatus>)): Promise<void> {
139    const now = await this.port.now()
140    await this.port.writeStatus(prev => ({
141      ...prev,
142      ...(typeof change === 'function' ? change(prev) : change),
143      updatedAt: now,
144      heartbeatAt: now,
145    }))
146  }
147
148  private async status(): Promise<RolloverStatus> {
149    const now = await this.port.now()
150
151    return (await this.port.readStatus()) ?? initialStatus(this.configured(), now)
152  }
153
154  private configured(): Thresholds {
155    return { soft: this.config.softLimit, prepare: this.config.prepareLimit, hard: this.config.hardLimit }
156  }
157
158  private thresholdsFor(window: number | null): Thresholds {
159    return effectiveThresholds(this.configured(), window)
160  }
161
162  private async journal(sessionId: string, phase: string, rolloverId: string | null, generation: number, toSessionId: string | null, error: string | null = null): Promise<void> {
163    try {
164      await writeJournal(this.port.fs, await this.port.root(), { sessionId, phase, rolloverId, generation, toSessionId, error, updatedAt: await this.port.now() })
165    } catch (err) {
166      this.log('warn', `journal not written: ${errorText(err)}`)
167    }
168  }
169
170  // ─── start and recovery ────────────────────────────────────────────────────
171
172  /**
173   * At every load: a fresh process, or a reload of this module mid-session. A reload
174   * keeps the state the host holds; a rollover it interrupted is picked up again.
175   */
176  async start(): Promise<void> {
177    const [sessionId, held, usage, now] = await Promise.all([
178      this.port.sessionId().catch(() => ''),
179      this.port.readStatus().catch(() => null),
180      this.port.usage().catch(() => ({ tokens: null, window: null })),
181      this.port.now(),
182    ])
183    const thresholds = this.thresholdsFor(usage.window)
184    const isReload = held !== null && held.sessionId === sessionId
185    const warning = this.configWarnings.length > 0 ? this.configWarnings.join('; ') : null
186
187    await this.port.writeStatus(prev => {
188      const base = isReload ? prev : { ...initialStatus(thresholds, now), last: held?.last ?? null }
189      const phase: RolloverPhase = !this.config.enabled ? 'disabled' : base.phase === 'disabled' ? 'monitoring' : base.phase
190
191      return {
192        ...base,
193        sessionId: sessionId === '' ? null : sessionId,
194        enabled: this.config.enabled,
195        phase,
196        thresholds,
197        context: usage.tokens === null && isReload ? base.context : { tokens: usage.tokens, window: usage.window, measuredAt: usage.tokens === null ? null : now, source: usage.tokens === null ? null : 'session.usage' },
198        restart: { mode: this.config.restartMode, isAutomatic: this.config.automaticRestart },
199        operation: phase === 'disabled' ? 'Disabled by configuration' : base.operation,
200        error: warning === null ? (isReload ? base.error : null) : { message: `config: ${warning}`, at: now, isRetriable: false },
201        heartbeatAt: now,
202        updatedAt: now,
203      }
204    })
205    await this.refreshAgents()
206    if (!this.config.enabled) return
207
208    if (isReload) {
209      // A reload dropped the work in flight; the same rollover carries on.
210      if (held !== null && (held.phase === 'draining' || held.phase === 'persisting' || held.phase === 'restarting') && held.continuation.rolloverId !== null) {
211        this.log('info', `resuming rollover ${held.continuation.rolloverId} after a reload`)
212        void this.rollover('reload')
213      }
214      return
215    }
216
217    await this.recover(sessionId)
218  }
219
220  /** A fresh process: is there a continuation nobody restored, or a rollover that died? */
221  private async recover(sessionId: string): Promise<void> {
222    let root: string
223    try {
224      root = await this.port.root()
225    } catch {
226      return
227    }
228    const now = await this.port.now()
229    const journals = await readJournals(this.port.fs, root).catch(() => [])
230    const died = journals.find(j => j.sessionId !== sessionId && ['draining', 'persisting', 'restarting', 'resuming'].includes(j.phase) && now - j.updatedAt > 60000)
231    if (died !== undefined && died.rolloverId !== null) {
232      const last: RolloverOutcome = {
233        outcome: 'interrupted',
234        rolloverId: died.rolloverId,
235        at: died.updatedAt,
236        fromSessionId: died.sessionId,
237        toSessionId: died.toSessionId,
238        finalTokens: null,
239        detail: `stopped while ${died.phase}`,
240      }
241      await this.patch({ last })
242      await this.journal(died.sessionId, 'interrupted', died.rolloverId, 0, died.toSessionId, `stopped while ${died.phase}`)
243    }
244
245    const pending = await findPending(this.port.fs, root, now, this.config.staleAfterHours * 3600000).catch(() => null)
246    if (pending === null || pending.snapshot.fromSessionId === sessionId) return
247    const { snapshot, path } = pending
248    await this.patch({
249      continuation: { status: 'pending', path, rolloverId: snapshot.rolloverId, bytes: snapshot.markdown.length, approxTokens: null, updatedAt: snapshot.createdAt },
250      operation: this.config.resumeOnStartup === 'off' ? `Continuation ${snapshot.rolloverId} waiting — /rollover resume` : `Restoring continuation ${snapshot.rolloverId}`,
251    })
252    if (this.config.resumeOnStartup === 'auto') {
253      await this.resume(snapshot.rolloverId)
254    } else if (this.config.resumeOnStartup === 'ask') {
255      await this.port.fillPrompt(`/rollover resume ${snapshot.rolloverId}`).catch(() => undefined)
256      this.port.toast(`A rollover continuation (${snapshot.rolloverId}) was never restored — press Enter to restore it`)
257    }
258  }
259
260  // ─── observing ─────────────────────────────────────────────────────────────
261
262  async refreshAgents(): Promise<AgentCounts | null> {
263    const agents = await this.port.agents().catch(() => null)
264    const counts = agents === null ? null : countAgents(agents)
265    const held = await this.port.readStatus().catch(() => null)
266    if (held === null || JSON.stringify(held.agents) !== JSON.stringify(counts)) await this.patch({ agents: counts })
267
268    return counts
269  }
270
271  async heartbeat(): Promise<void> {
272    const now = await this.port.now()
273    await this.port.writeStatus(prev => ({ ...prev, heartbeatAt: now }))
274  }
275
276  /** A tool call finished: keep the task counts from the task tools' own payloads. */
277  async onToolResult(tool: string, input: Readonly<Record<string, unknown>>, result: unknown): Promise<void> {
278    if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
279      this.todos = lastTodos([{ role: 'assistant', text: '', toolUses: [{ tool, input }] }])?.items.slice() ?? null
280    } else if (tool === 'TaskCreate') {
281      const task = (result as { task?: { id?: unknown; subject?: unknown } } | null)?.task
282      if (typeof task?.id === 'string') this.tasks.set(task.id, { id: task.id, subject: String(task.subject ?? input.subject ?? ''), status: 'pending' })
283    } else if (tool === 'TaskUpdate' && typeof input.taskId === 'string') {
284      const was = this.tasks.get(input.taskId) ?? { id: input.taskId, subject: String(input.subject ?? input.taskId), status: 'pending' as const }
285      if (input.status === 'deleted') this.tasks.delete(input.taskId)
286      else {
287        this.tasks.set(input.taskId, {
288          ...was,
289          ...(typeof input.subject === 'string' ? { subject: input.subject } : {}),
290          ...(input.status === 'pending' || input.status === 'in_progress' || input.status === 'completed' ? { status: input.status } : {}),
291          ...(typeof input.owner === 'string' ? { owner: input.owner } : {}),
292        })
293      }
294    } else return
295    await this.patch({ tasks: this.taskCounts() })
296  }
297
298  private taskCounts(): TaskCounts | null {
299    const fromTasks = [...this.tasks.values()]
300    const items = fromTasks.length > 0 ? fromTasks : this.todos
301    if (items === null) return null
302    const c: Counter = { pending: 0, inProgress: 0, completed: 0 }
303    for (const one of items) {
304      if (one.status === 'completed') c.completed += 1
305      else if (one.status === 'in_progress') c.inProgress += 1
306      else c.pending += 1
307    }
308
309    return { ...c, total: items.length, source: fromTasks.length > 0 ? 'TaskList' : 'TodoWrite' }
310  }
311
312  onTurnStart(turnId: string): Promise<void> {
313    this.turnId = turnId
314    this.isTurnRunning = true
315
316    return this.patch(prev => (prev.phase === 'completed' ? { phase: 'monitoring', operation: 'Monitoring context usage' } : {}))
317  }
318
319  /**
320   * A context reading from the engine. Only phase changes write anything beyond the
321   * reading, and each change acts once: a reading that repeats a zone does nothing.
322   */
323  async observe(tokens: number | null, window: number | null, source: ContextSource): Promise<void> {
324    const now = await this.port.now()
325    const before = await this.status()
326    const thresholds = this.thresholdsFor(window ?? before.context.window)
327    await this.patch({ context: { tokens, window: window ?? before.context.window, measuredAt: now, source }, thresholds })
328    if (before.phase === 'resuming' && this.baseline === null && tokens !== null) this.baseline = tokens
329    if (!this.config.enabled || tokens === null) return
330
331    const phase = before.phase
332    if (BUSY.has(phase) || phase === 'awaiting-restart' || phase === 'disabled') return
333    const zone = zoneOf(tokens, thresholds)
334    const sessionId = before.sessionId ?? ''
335
336    if (zone === 'below') {
337      if (phase === 'preparing' || phase === 'ready') await this.patch({ phase: 'monitoring', operation: 'Monitoring context usage', continuation: { ...before.continuation, status: before.continuation.status === 'draft' ? 'none' : before.continuation.status } })
338      return
339    }
340    if (phase === 'failed') return
341
342    if (zone === 'soft' && phase !== 'preparing') {
343      if (phase === 'ready') return
344      await this.patch({ phase: 'preparing', operation: `Preparing: rollover at ${Math.round(thresholds.prepare / 1000)}k–${Math.round(thresholds.hard / 1000)}k` })
345      await this.tellModel(`soft:${sessionId}`, `[context-rollover] Context is at ${Math.round(tokens / 1000)}k tokens; this session will roll over to a fresh one between turns once it passes ${Math.round(thresholds.prepare / 1000)}k (at the latest ${Math.round(thresholds.hard / 1000)}k). Keep the task list current (TaskUpdate/TodoWrite), finish steps cleanly, and prefer not to start large new subtasks. No need to reply to this note.`)
346      return
347    }
348    if (zone === 'prepare' && phase !== 'ready') {
349      await this.patch({ phase: 'ready', operation: this.isTurnRunning ? 'Ready: rollover when this turn ends' : 'Ready: rollover at the next turn end' })
350      await this.tellModel(`prepare:${sessionId}`, `[context-rollover] Context is at ${Math.round(tokens / 1000)}k tokens. The session rolls over to a fresh one as soon as this turn ends. Wrap up the current step, record remaining work in the task list, and do not start new agents. No need to reply to this note.`)
351      this.saveDraft()
352      if (!this.isTurnRunning) void this.rollover('prepare limit between turns')
353      return
354    }
355    if (zone === 'hard') {
356      if (this.isTurnRunning) await this.startDrain(tokens)
357      else void this.rollover('hard limit')
358    }
359  }
360
361  private async tellModel(key: string, text: string): Promise<void> {
362    if (!this.config.notifyModel || this.notified.has(key)) return
363    this.notified.add(key)
364    await this.port.noteToModel(text).catch(err => this.log('debug', `note to model failed: ${errorText(err)}`))
365  }
366
367  /** Hard limit mid-turn: refuse new main-loop work, and end the turn if it will not end. */
368  private async startDrain(tokens: number): Promise<void> {
369    await this.patch({ phase: 'draining', operation: 'Hard limit: stopping new work, waiting for the turn to end' })
370    this.log('info', `context ${Math.round(tokens / 1000)}k reached the hard limit; draining the turn`, true)
371    const turnId = this.turnId
372    const grace = Math.min(120000, Math.floor(this.config.rolloverTimeoutMs / 4))
373    void (async () => {
374      await this.port.sleep(grace).catch(() => undefined)
375      if (this.isTurnRunning && this.turnId === turnId && turnId !== null) {
376        this.log('warn', 'the turn did not end on its own; ending it', true)
377        await this.port.abortTurn(turnId).catch(err => this.log('warn', `could not end the turn: ${errorText(err)}`))
378      }
379    })()
380  }
381
382  /** The main loop's turn ended: the safe point for a rollover. */
383  async onTurnEnd(): Promise<void> {
384    this.isTurnRunning = false
385    this.turnId = null
386    const s = await this.status()
387    if (s.phase === 'resuming') {
388      const now = await this.port.now()
389      // A fresh session that already starts past the soft limit (a huge system prompt,
390      // tool list or CLAUDE.md, or limits set too low) would roll over on every turn.
391      const baseline = this.baseline
392      this.baseline = null
393      if (baseline !== null && baseline >= s.thresholds.soft) {
394        const message = `the fresh session already holds ${Math.round(baseline / 1000)}k tokens, past the ${Math.round(s.thresholds.soft / 1000)}k soft limit; rolling over again would not help — raise the limits`
395        await this.patch(prev => ({
396          phase: 'failed',
397          operation: 'Stopped: limits below a fresh session’s size',
398          error: { message, at: now, isRetriable: false },
399          last: prev.last === null ? null : { ...prev.last, outcome: 'success', toSessionId: prev.sessionId, at: now, detail: 'resumed; limits too low to continue' },
400        }))
401        this.log('error', message, true)
402        return
403      }
404      await this.patch(prev => ({
405        phase: 'completed',
406        operation: 'Monitoring context usage',
407        last: prev.last === null ? null : { ...prev.last, outcome: 'success', toSessionId: prev.sessionId, at: now, detail: 'resumed' },
408      }))
409      if (s.sessionId !== null && s.continuation.rolloverId !== null) await this.journal(s.sessionId, 'resumed', s.continuation.rolloverId, s.generation, null)
410      this.log('info', `rollover ${s.continuation.rolloverId ?? ''} complete: the fresh session finished its first turn`, true)
411      return
412    }
413    // Not awaited: the rollover runs /clear, which cannot run inside the hook a turn waits on.
414    if (s.phase === 'ready' || s.phase === 'draining') void this.rollover(s.phase === 'draining' ? 'hard limit' : 'prepare limit')
415  }
416
417  /** Resolves once no rollover or draft is in flight (for tests and for an orderly exit). */
418  async settled(): Promise<void> {
419    while (this.inFlight !== null || this.draft !== null) await (this.inFlight ?? this.draft)
420  }
421
422  /** Whether a tool call may go ahead; a string is the refusal the model reads. */
423  async gate(tool: string, isMain: boolean): Promise<string | null> {
424    if (!this.config.enabled) return null
425    const { phase } = await this.status()
426    if ((phase === 'draining' || phase === 'persisting' || phase === 'restarting') && isMain && !ALLOWED_WHILE_DRAINING.has(tool)) return DRAIN_MESSAGE
427    if (tool === 'Agent' && this.config.blockNewAgents && (phase === 'ready' || phase === 'draining' || phase === 'persisting' || phase === 'restarting')) return HOLD_AGENTS_MESSAGE
428
429    return null
430  }
431
432  // ─── the rollover itself ───────────────────────────────────────────────────
433
434  /** Start (or join) the one rollover of this session. Idempotent: a second trigger joins the first. */
435  rollover(trigger: string): Promise<void> {
436    if (this.inFlight !== null) return this.inFlight
437    this.inFlight = this.runWithRetries(trigger).finally(() => {
438      this.inFlight = null
439    })
440
441    return this.inFlight
442  }
443
444  get isRolling(): boolean {
445    return this.inFlight !== null
446  }
447
448  private async runWithRetries(trigger: string): Promise<void> {
449    const before = await this.status()
450    const fromSessionId = before.sessionId ?? (await this.port.sessionId())
451    const now = await this.port.now()
452    // The id outlives retries and reloads: it is in the state the host holds.
453    const isOwnId = before.continuation.rolloverId !== null && before.continuation.status !== 'restored' && before.continuation.status !== 'pending' && before.continuation.status !== 'none'
454    const rolloverId = isOwnId && before.continuation.rolloverId !== null ? before.continuation.rolloverId : `g${before.generation + 1}-${fromSessionId.slice(0, 8)}-${now.toString(36)}`
455    const previousRolloverId = before.continuation.status === 'restored' ? before.continuation.rolloverId : null
456    this.log('info', `rollover ${rolloverId} started (${trigger}) at ${before.context.tokens === null ? '?' : `${Math.round(before.context.tokens / 1000)}k`} tokens`, true)
457    await this.patch({ continuation: { ...before.continuation, status: 'in-progress', rolloverId }, error: null })
458
459    for (let attempt = 0; ; attempt += 1) {
460      try {
461        await within(this.port, this.config.rolloverTimeoutMs, this.attempt(rolloverId, fromSessionId, previousRolloverId, before.generation, before.context.tokens))
462        return
463      } catch (err) {
464        const message = errorText(err)
465        const at = await this.port.now()
466        this.log('error', `rollover ${rolloverId} attempt ${attempt + 1} failed: ${message}`, true)
467        if (attempt < this.config.maxRetries) {
468          const wait = this.config.retryDelayMs * 2 ** attempt
469          await this.patch({ error: { message, at, isRetriable: true }, operation: `Retrying in ${Math.round(wait / 1000)}s (attempt ${attempt + 2}/${this.config.maxRetries + 1})` })
470          await this.port.sleep(wait)
471          continue
472        }
473        await this.fail(rolloverId, fromSessionId, message, before.context.tokens)
474        return
475      }
476    }
477  }
478
479  private async fail(rolloverId: string, fromSessionId: string, message: string, finalTokens: number | null): Promise<void> {
480    const s = await this.status()
481    const now = await this.port.now()
482    const isPersisted = s.continuation.status === 'persisted'
483    const error: RolloverError = { message, at: now, isRetriable: true }
484    const last: RolloverOutcome = { outcome: 'failed', rolloverId, at: now, fromSessionId, toSessionId: null, finalTokens, detail: message }
485    // The gate is lifted either way: a failed rollover must never leave the session stuck.
486    await this.patch({
487      phase: isPersisted ? 'awaiting-restart' : 'failed',
488      error,
489      last,
490      operation: isPersisted ? 'Continuation saved — run /clear to continue in a fresh session' : 'Rollover failed — /rollover now to retry',
491    })
492    await this.journal(fromSessionId, 'failed', rolloverId, s.generation, null, message)
493    this.port.toast(isPersisted ? 'Rollover could not restart the session: run /clear and it resumes from the saved continuation' : `Rollover failed: ${cut(message, 120)}`)
494    if (isPersisted) await this.port.fillPrompt('/clear').catch(() => undefined)
495  }
496
497  private async attempt(rolloverId: string, fromSessionId: string, previousRolloverId: string | null, generation: number, finalTokens: number | null): Promise<void> {
498    const root = await this.port.root()
499    const cwd = await this.port.cwd()
500
501    // 1. The old session's work: agents finish (or are recorded), then the state is persisted.
502    let persisted = await findSnapshot(this.port.fs, root, rolloverId).then(found => (found?.snapshot.kind === 'final' ? found : null)).catch(() => null)
503    const currentId = await this.port.sessionId().catch(() => fromSessionId)
504    if (persisted === null) {
505      if (currentId !== fromSessionId) throw new Error('the session changed before the continuation was saved; nothing to restore from')
506      await this.patch({ phase: 'draining', operation: 'Waiting for agents to finish' })
507      await this.journal(fromSessionId, 'draining', rolloverId, generation, null)
508      await this.drainAgents()
509
510      await this.patch({ phase: 'persisting', operation: 'Persisting continuation state' })
511      await this.journal(fromSessionId, 'persisting', rolloverId, generation, null)
512      const snapshot = await this.collect(rolloverId, fromSessionId, previousRolloverId, generation, finalTokens, cwd, root, 'final')
513      const path = await writeSnapshot(this.port.fs, root, snapshot.snapshot)
514      persisted = { snapshot: snapshot.snapshot, path }
515      await this.patch({ continuation: { status: 'persisted', path, rolloverId, bytes: snapshot.snapshot.markdown.length, approxTokens: snapshot.approxTokens, updatedAt: await this.port.now() } })
516      await this.journal(fromSessionId, 'persisted', rolloverId, generation, null)
517      this.log('info', `continuation saved: ${path} (~${snapshot.approxTokens} tokens)`, true)
518    }
519
520    if (!this.config.automaticRestart) {
521      await this.patch({ phase: 'awaiting-restart', operation: 'Continuation saved — run /clear to continue in a fresh session' })
522      await this.journal(fromSessionId, 'awaiting-restart', rolloverId, generation, null)
523      await this.port.fillPrompt('/clear').catch(() => undefined)
524      this.port.toast('Context rollover is ready: run /clear and the fresh session resumes from the saved continuation')
525      return
526    }
527
528    // 2. The fresh session. Skipped when the session already changed (a retry after /clear).
529    let toSessionId = await this.port.sessionId().catch(() => fromSessionId)
530    if (toSessionId === fromSessionId) {
531      await this.patch({ phase: 'restarting', operation: 'Starting a fresh session' })
532      await this.journal(fromSessionId, 'restarting', rolloverId, generation, null)
533      if (this.config.restartMode === 'new-terminal' && (await this.handOffToTerminal(rolloverId, cwd))) return
534      await this.port.clear()
535      toSessionId = await this.waitForNewSession(fromSessionId)
536    }
537
538    // 3. Hand the continuation over.
539    await this.handOver(persisted.snapshot, persisted.path, toSessionId, fromSessionId, finalTokens)
540  }
541
542  private async handOffToTerminal(rolloverId: string, cwd: string): Promise<boolean> {
543    const isLaunched = await this.port.launchTerminal(`claude "/rollover resume ${rolloverId}"`, cwd).catch(() => false)
544    if (!isLaunched) {
545      this.log('warn', 'no terminal could be opened; restarting in this one with /clear instead', true)
546      return false
547    }
548    await this.patch({ phase: 'restarting', operation: 'Handed off to a new terminal; closing this session' })
549    this.port.toast('The fresh session is starting in a new terminal; this one closes')
550    await this.port.sleep(2000)
551    await this.port.exit()
552
553    return true
554  }
555
556  private async waitForNewSession(fromSessionId: string): Promise<string> {
557    const deadline = (await this.port.now()) + NEW_SESSION_WAIT_MS
558    for (;;) {
559      const id = await this.port.sessionId().catch(() => fromSessionId)
560      if (id !== fromSessionId && id !== '') return id
561      if ((await this.port.now()) > deadline) throw new Error('/clear did not start a new session')
562      await this.port.sleep(250)
563    }
564  }
565
566  private async handOver(snapshot: Snapshot, path: string, toSessionId: string, fromSessionId: string, finalTokens: number | null): Promise<void> {
567    const isMine = await claim(this.port.fs, await this.port.root(), snapshot.rolloverId, toSessionId, await this.port.now(), () => this.port.sleep(SETTLE_MS))
568    if (!isMine) throw new Error(`rollover ${snapshot.rolloverId} was already restored by another session`)
569    const now = await this.port.now()
570    this.handed.set(snapshot.rolloverId, snapshot.markdown)
571    await this.patch(prev => ({
572      sessionId: toSessionId,
573      generation: snapshot.generation,
574      phase: 'resuming',
575      context: { tokens: null, window: prev.context.window, measuredAt: null, source: null },
576      continuation: { status: 'restored', path, rolloverId: snapshot.rolloverId, bytes: snapshot.markdown.length, approxTokens: Math.ceil(snapshot.markdown.length / 3.5), updatedAt: now },
577      last: { outcome: 'success', rolloverId: snapshot.rolloverId, at: now, fromSessionId, toSessionId, finalTokens, detail: 'handed over; resuming' },
578      operation: 'Resuming unfinished tasks',
579      error: null,
580    }))
581    this.notified.clear()
582    this.tasks.clear()
583    this.todos = null
584    this.baseline = null
585    await this.journal(fromSessionId, 'handed-over', snapshot.rolloverId, snapshot.generation, toSessionId)
586    await this.journal(toSessionId, 'resuming', snapshot.rolloverId, snapshot.generation, null)
587    // The continuation goes in as a row the model reads and the person does not see as
588    // typed; the short prompt then starts the turn. Where the row is refused, the
589    // continuation rides in the prompt itself, so it always arrives.
590    const readable = path.replace(/\.json$/, '.md')
591    const isAppended = await this.port
592      .noteToModel(snapshot.markdown)
593      .then(() => true)
594      .catch(err => {
595        this.log('warn', `continuation not appended (${errorText(err)}); sending it in the prompt`)
596        return false
597      })
598    const prompt = resumePrompt(snapshot.rolloverId, snapshot.generation, readable)
599    await this.port.submit(isAppended ? prompt : `${prompt}\n\n${snapshot.markdown}`)
600    this.log('info', `fresh session ${toSessionId.slice(0, 8)} resuming from ${snapshot.rolloverId}`, true)
601  }
602
603  /**
604   * Restore a persisted continuation into this session: `/rollover resume`, a new
605   * process that found one, or the person's own /clear after a manual rollover.
606   */
607  async resume(rolloverId?: string): Promise<string> {
608    const root = await this.port.root()
609    const now = await this.port.now()
610    const found = rolloverId === undefined ? await findPending(this.port.fs, root, now, this.config.staleAfterHours * 3600000) : await findSnapshot(this.port.fs, root, rolloverId)
611    if (found === null) return rolloverId === undefined ? 'No continuation is waiting.' : `No valid snapshot of ${rolloverId} was found.`
612    const sessionId = await this.port.sessionId()
613    if (found.snapshot.fromSessionId === sessionId) return `${found.snapshot.rolloverId} was written by this very session; run /clear first so it restores into a fresh one.`
614    try {
615      await this.handOver(found.snapshot, found.path, sessionId, found.snapshot.fromSessionId, found.snapshot.data.finalTokens)
616    } catch (err) {
617      await this.patch({ error: { message: errorText(err), at: await this.port.now(), isRetriable: false } })
618      return `Could not restore: ${errorText(err)}`
619    }
620
621    return `Restoring ${found.snapshot.rolloverId}.`
622  }
623
624  /** The person ran /clear (or the engine did for us): a new conversation in this process. */
625  async onCleared(newSessionId: string): Promise<void> {
626    if (this.inFlight !== null) return
627    const s = await this.status()
628    if (s.sessionId === newSessionId) return
629    if (s.phase === 'awaiting-restart' && s.continuation.status === 'persisted' && s.continuation.rolloverId !== null && this.config.enabled) {
630      // A manual rollover: the person cleared to continue; restore without asking again.
631      await this.patch({ sessionId: newSessionId })
632      const found = await findSnapshot(this.port.fs, await this.port.root(), s.continuation.rolloverId).catch(() => null)
633      if (found !== null) {
634        this.isTurnRunning = false
635        await this.handOver(found.snapshot, found.path, newSessionId, found.snapshot.fromSessionId, found.snapshot.data.finalTokens).catch(async err => {
636          await this.patch({ phase: 'failed', error: { message: errorText(err), at: await this.port.now(), isRetriable: false } })
637        })
638        return
639      }
640    }
641    // A plain /clear: a new chain, nothing carried.
642    const now = await this.port.now()
643    this.notified.clear()
644    this.tasks.clear()
645    this.todos = null
646    await this.patch(prev => ({
647      ...initialStatus(prev.thresholds, now),
648      sessionId: newSessionId,
649      enabled: prev.enabled,
650      phase: prev.enabled ? 'monitoring' : 'disabled',
651      last: prev.last,
652      agents: prev.agents,
653      restart: prev.restart,
654      context: { tokens: null, window: prev.context.window, measuredAt: null, source: null },
655    }))
656  }
657
658  /** `/rollover cancel`: stop a rollover that has not restarted yet, and lift the gate. */
659  async cancel(): Promise<string> {
660    const s = await this.status()
661    if (s.phase === 'restarting' || s.phase === 'resuming') return 'Too late to cancel: the fresh session is already starting.'
662    await this.patch({ phase: 'monitoring', operation: 'Monitoring context usage (rollover cancelled)', error: null })
663
664    return this.inFlight === null ? 'Nothing to cancel; monitoring.' : 'Cancelled; a persisted continuation stays on disk. /rollover now starts again.'
665  }
666
667  /** The continuation a resume prompt names, by its rollover id: from memory, else from disk. */
668  async contextFor(text: string): Promise<string | null> {
669    const id = rolloverIdIn(text)
670    if (id === null) return null
671    const held = this.handed.get(id)
672    if (held !== undefined) return held
673    const found = await findSnapshot(this.port.fs, await this.port.root(), id).catch(() => null)
674
675    return found?.snapshot.markdown ?? null
676  }
677
678  // ─── gathering ─────────────────────────────────────────────────────────────
679
680  /** Wait for running agents to finish, by policy; tell them to wrap up first. */
681  private async drainAgents(): Promise<void> {
682    let agents = await this.port.agents().catch(() => null)
683    if (agents === null || this.config.agentPolicy === 'record') return
684    let working = stillWorking(agents)
685    if (working === 0) return
686    if (this.config.notifyAgents) {
687      await Promise.all(agents.filter(one => one.status === 'running' || one.status === 'waiting').map(one => this.port.sendToAgent(one.id, WRAP_UP_MESSAGE).catch(() => undefined)))
688    }
689    const deadline = (await this.port.now()) + this.config.agentDrainTimeoutMs
690    while (working > 0 && (await this.port.now()) < deadline) {
691      await this.patch({ operation: `Waiting for ${working} agent${working === 1 ? '' : 's'} to finish`, agents: countAgents(agents) })
692      await this.port.sleep(AGENT_POLL_MS)
693      agents = (await this.port.agents().catch(() => null)) ?? agents
694      working = stillWorking(agents)
695    }
696    if (working > 0) this.log('warn', `${working} agent(s) still running at the drain deadline; recorded for re-dispatch`, true)
697  }
698
699  private async tasksNow(messages: readonly MessageSeen[]): Promise<TaskList | null> {
700    const listed = await this.port.taskList().catch(() => null)
701    if (listed !== null && listed.items.length > 0) return listed
702    if (this.tasks.size > 0) return { source: 'TaskList', items: [...this.tasks.values()] }
703
704    return lastTodos(messages)
705  }
706
707  private async gitState(rolloverId: string): Promise<GitState | null> {
708    const top = await this.port.git(['rev-parse', '--show-toplevel'])
709    if (top.exitCode !== 0) return null
710    const [status, stat, log] = await Promise.all([
711      this.port.git(['status', '--porcelain=v2', '--branch']),
712      this.port.git(['diff', '--shortstat', 'HEAD']),
713      this.port.git(['log', '-5', '--oneline']),
714    ])
715    let inProgress: string | null = null
716    for (const [marker, name] of IN_PROGRESS_MARKERS) {
717      const path = (await this.port.git(['rev-parse', '--git-path', marker])).stdout.trim()
718      if (path !== '' && (await this.port.fs.exists(path.startsWith('/') || /^[A-Za-z]:/.test(path) ? path : `${top.stdout.trim()}/${path}`).catch(() => false))) {
719        inProgress = name
720        break
721      }
722    }
723    const parsed = parsePorcelain(status.stdout)
724    const git: GitState = {
725      root: top.stdout.trim(),
726      ...parsed,
727      diffStat: stat.stdout.trim(),
728      recentCommits: log.stdout.split(/\r?\n/).filter(line => line.trim() !== ''),
729      inProgress,
730      safetyRef: null,
731      commit: null,
732      note: null,
733    }
734
735    // A ref to the tree as it stands: `git stash create` writes a commit object and
736    // touches neither the working tree, the index nor the stash list.
737    if (this.config.gitSafetyRef && git.changes.length > 0) {
738      const created = await this.port.git(['stash', 'create', `context-rollover ${rolloverId}`])
739      const sha = created.stdout.trim()
740      if (created.exitCode === 0 && /^[0-9a-f]{7,64}$/.test(sha)) {
741        const ref = safetyRefName(rolloverId)
742        const updated = await this.port.git(['update-ref', ref, sha])
743        if (updated.exitCode === 0) git.safetyRef = ref
744      }
745    }
746
747    const decision = commitDecision(git, this.config.autoCommit, this.config.protectedBranches)
748    if (decision.shouldCommit) {
749      const added = await this.port.git(['add', '-A'])
750      const committed = added.exitCode === 0 ? await this.port.git(['commit', '-m', `chore(context-rollover): checkpoint before rollover ${rolloverId}`]) : added
751      if (committed.exitCode === 0) git.commit = (await this.port.git(['rev-parse', '--short', 'HEAD'])).stdout.trim() || null
752      else git.note = 'autoCommit was on but the commit failed; the changes are left uncommitted'
753    } else if (this.config.autoCommit) {
754      git.note = `autoCommit skipped: ${decision.reason}`
755    }
756
757    return git
758  }
759
760  private async summarize(): Promise<{ summary: Summary | null; note: string | null }> {
761    if (!this.config.useModelSummary) return { summary: null, note: 'model summary is off' }
762    const reply = await this.port.fork(SUMMARY_PROMPT).catch(err => ({ isAnswered: false, reason: errorText(err) }) as const)
763    if (!reply.isAnswered || reply.text === undefined) return { summary: null, note: `the summary request failed (${reply.reason ?? 'no reply'})` }
764    const summary = parseSummary(reply.text)
765
766    return summary === null ? { summary: null, note: 'the summary reply was not usable JSON' } : { summary, note: null }
767  }
768
769  private async collect(
770    rolloverId: string,
771    fromSessionId: string,
772    previousRolloverId: string | null,
773    generation: number,
774    finalTokens: number | null,
775    cwd: string,
776    root: string,
777    kind: 'draft' | 'final',
778  ): Promise<{ snapshot: Snapshot; approxTokens: number }> {
779    await this.patch({ operation: kind === 'final' ? 'Reading git, tasks and agents' : 'Drafting continuation' })
780    const messages = await this.port.messages().catch(() => [] as MessageSeen[])
781    const [git, tasks, seen, previous] = await Promise.all([
782      this.gitState(rolloverId).catch(() => null),
783      this.tasksNow(messages),
784      this.port.agents().catch(() => null),
785      previousRolloverId === null ? Promise.resolve(null) : findSnapshot(this.port.fs, root, previousRolloverId).catch(() => null),
786    ])
787    const agents = seen ?? []
788    const outputs: Record<string, string | null> = {}
789    await Promise.all(
790      agents.slice(-12).map(async agent => {
791        const rows = await this.port.agentMessages(agent.id).catch(() => null)
792        outputs[agent.id] = rows === null ? null : lastAnswer(rows)
793      }),
794    )
795    let summary: Summary | null = null
796    let summaryNote: string | null = kind === 'draft' ? 'draft snapshot (no summary yet)' : null
797    if (kind === 'final') {
798      await this.patch({ operation: 'Asking for a targeted summary' })
799      ;({ summary, note: summaryNote } = await this.summarize())
800    }
801    const requests = userRequests(messages)
802    const carried = previous?.snapshot.data
803    const data: ContinuationData = {
804      rolloverId,
805      generation: generation + 1,
806      previousRolloverId,
807      fromSessionId,
808      createdAt: await this.port.now(),
809      project: cwd,
810      finalTokens,
811      thresholds: (await this.status()).thresholds,
812      objective: carried?.summary?.objective ?? carried?.objective ?? requests.first,
813      carriedDecisions: [...(carried?.carriedDecisions ?? []), ...(carried?.summary?.decisions ?? [])].slice(-20),
814      userRequests: requests.recent,
815      summary,
816      summaryNote,
817      tasks,
818      agents: recordAgents(agents, agentCalls(messages), outputs),
819      git,
820      files: filesWritten(messages),
821      errors: recentErrors(messages),
822    }
823    const path = `${root}/snapshots/${snapshotName({ createdAt: data.createdAt, rolloverId, kind })}`
824    const built = buildContinuation(data, path, this.config.maxContinuationTokens)
825    const snapshot = seal({ rolloverId, kind, generation: generation + 1, createdAt: data.createdAt, fromSessionId, project: cwd, markdown: built.markdown, data })
826
827    return { snapshot, approxTokens: built.approxTokens }
828  }
829
830  /** At the prepare limit: a cheap draft on disk, so a crash from here on loses little. */
831  private saveDraft(): void {
832    if (this.draft !== null) return
833    this.draft = (async () => {
834      try {
835        const s = await this.status()
836        const sessionId = s.sessionId ?? (await this.port.sessionId())
837        const now = await this.port.now()
838        const rolloverId = `g${s.generation + 1}-${sessionId.slice(0, 8)}-${now.toString(36)}`
839        const root = await this.port.root()
840        const previous = s.continuation.status === 'restored' ? s.continuation.rolloverId : null
841        const { snapshot, approxTokens } = await this.collect(rolloverId, sessionId, previous, s.generation, s.context.tokens, await this.port.cwd(), root, 'draft')
842        const path = await writeSnapshot(this.port.fs, root, snapshot)
843        await this.patch(prev => (BUSY.has(prev.phase) ? {} : { continuation: { status: 'draft', path, rolloverId, bytes: snapshot.markdown.length, approxTokens, updatedAt: now }, operation: prev.phase === 'ready' ? 'Draft saved; rollover at the turn end' : prev.operation }))
844      } catch (err) {
845        this.log('warn', `draft continuation not saved: ${errorText(err)}`)
846      } finally {
847        this.draft = null
848      }
849    })()
850  }
851}
852
hooks/lib/store.ts 226 lines
1/**
2 * The durable record: versioned, checksummed snapshots, one claim per restored
3 * rollover, and a journal per session. Everything goes through a small file port so
4 * the logic is testable without a disk.
5 *
6 * The engine's file API writes whole files and has no rename or exclusive create, so
7 * atomicity comes from never overwriting anything that matters: each snapshot is a new
8 * file whose checksum covers its content, a torn or corrupted one fails the check and
9 * the next newest valid one is used instead, and nothing here ever deletes. The
10 * journal is overwritten, but it only says where a rollover stood; losing it costs a
11 * status line, never state.
12 */
13
14import type { ContinuationData } from './continuation'
15
16export type FilePort = {
17  read: (path: string) => Promise<string>
18  write: (path: string, text: string) => Promise<void>
19  list: (path: string) => Promise<readonly { name: string; kind: string }[]>
20  exists: (path: string) => Promise<boolean>
21}
22
23export const SNAPSHOT_FORMAT = 'context-rollover/snapshot@1'
24
25export type SnapshotKind = 'draft' | 'final'
26
27export type Snapshot = {
28  format: typeof SNAPSHOT_FORMAT
29  rolloverId: string
30  kind: SnapshotKind
31  generation: number
32  createdAt: number
33  fromSessionId: string
34  project: string
35  markdown: string
36  data: ContinuationData
37  checksum: string
38}
39
40export type Claim = { rolloverId: string; claimedBy: string; at: number }
41
42/** Where a session's rollover stands, for recovery after a crash. */
43export type Journal = {
44  sessionId: string
45  phase: string
46  rolloverId: string | null
47  generation: number
48  /** Set once the restart was issued: the session the continuation was meant for. */
49  toSessionId: string | null
50  error: string | null
51  updatedAt: number
52}
53
54/** FNV-1a over UTF-16 code units, as 8 hex digits: enough to catch a torn write. */
55export const checksum = (text: string): string => {
56  let hash = 0x811c9dc5
57  for (let i = 0; i < text.length; i += 1) {
58    hash ^= text.charCodeAt(i)
59    hash = Math.imul(hash, 0x01000193) >>> 0
60  }
61
62  return hash.toString(16).padStart(8, '0')
63}
64
65const sealOf = (s: Omit<Snapshot, 'checksum'>): string =>
66  checksum(`${s.format}|${s.rolloverId}|${s.kind}|${s.generation}|${s.createdAt}|${s.fromSessionId}|${s.markdown}|${JSON.stringify(s.data)}`)
67
68export const seal = (s: Omit<Snapshot, 'checksum' | 'format'>): Snapshot => {
69  const unsealed = { format: SNAPSHOT_FORMAT, ...s } as const
70
71  return { ...unsealed, checksum: sealOf(unsealed) }
72}
73
74/** A snapshot read back, or null when it is not one, or its checksum does not hold. */
75export const parseSnapshot = (text: string): Snapshot | null => {
76  let value: unknown
77  try {
78    value = JSON.parse(text)
79  } catch {
80    return null
81  }
82  if (typeof value !== 'object' || value === null) return null
83  const s = value as Snapshot
84  if (s.format !== SNAPSHOT_FORMAT || typeof s.markdown !== 'string' || typeof s.rolloverId !== 'string') return null
85  if (typeof s.data !== 'object' || s.data === null || typeof s.createdAt !== 'number') return null
86  const { checksum: stated, ...rest } = s
87
88  return sealOf(rest) === stated ? s : null
89}
90
91export const snapshotName = (s: Pick<Snapshot, 'createdAt' | 'rolloverId' | 'kind'>): string =>
92  `${String(s.createdAt).padStart(14, '0')}-${s.rolloverId}-${s.kind}.json`
93
94export const dirs = (root: string) => ({
95  snapshots: `${root}/snapshots`,
96  claims: `${root}/claims`,
97  sessions: `${root}/sessions`,
98})
99
100/**
101 * Write a snapshot as a new file and read it back: resolves the path once the bytes
102 * on disk verify, rejects otherwise (the caller retries; the previous file stands).
103 */
104export const writeSnapshot = async (fs: FilePort, root: string, snapshot: Snapshot): Promise<string> => {
105  const path = `${dirs(root).snapshots}/${snapshotName(snapshot)}`
106  const text = JSON.stringify(snapshot, null, 1)
107  await fs.write(path, text)
108  const back = parseSnapshot(await fs.read(path))
109  if (back === null || back.checksum !== snapshot.checksum) throw new Error(`snapshot ${path} did not verify after writing`)
110  // A readable copy beside it, for a person or a fresh session's Read; the JSON is the record.
111  await fs.write(path.replace(/\.json$/, '.md'), snapshot.markdown).catch(() => undefined)
112
113  return path
114}
115
116const listNames = async (fs: FilePort, dir: string): Promise<string[]> => {
117  try {
118    return (await fs.list(dir)).filter(one => one.kind === 'file').map(one => one.name)
119  } catch {
120    return []
121  }
122}
123
124export const readClaim = async (fs: FilePort, root: string, rolloverId: string): Promise<Claim | null> => {
125  try {
126    const value = JSON.parse(await fs.read(`${dirs(root).claims}/${rolloverId}.json`)) as Claim
127    return typeof value.claimedBy === 'string' ? value : null
128  } catch {
129    return null
130  }
131}
132
133/**
134 * The newest final snapshot nobody has restored, younger than `maxAgeMs`, that
135 * verifies. A corrupted newest file falls back to the one before it.
136 */
137export const findPending = async (fs: FilePort, root: string, now: number, maxAgeMs: number): Promise<{ snapshot: Snapshot; path: string } | null> => {
138  const names = (await listNames(fs, dirs(root).snapshots)).filter(name => name.endsWith('-final.json')).sort().reverse()
139  for (const name of names) {
140    const createdAt = Number(name.slice(0, 14))
141    if (Number.isFinite(createdAt) && now - createdAt > maxAgeMs) break
142    const path = `${dirs(root).snapshots}/${name}`
143    const snapshot = parseSnapshot(await fs.read(path).catch(() => ''))
144    if (snapshot === null) continue
145    if ((await readClaim(fs, root, snapshot.rolloverId)) !== null) continue
146
147    return { snapshot, path }
148  }
149
150  return null
151}
152
153/** The newest valid snapshot of a rollover, final before draft. */
154export const findSnapshot = async (fs: FilePort, root: string, rolloverId: string): Promise<{ snapshot: Snapshot; path: string } | null> => {
155  const names = (await listNames(fs, dirs(root).snapshots))
156    .filter(name => name.includes(`-${rolloverId}-`) && name.endsWith('.json'))
157    .sort((a, b) => Number(b.endsWith('-final.json')) - Number(a.endsWith('-final.json')) || b.localeCompare(a))
158  for (const name of names) {
159    const path = `${dirs(root).snapshots}/${name}`
160    const snapshot = parseSnapshot(await fs.read(path).catch(() => ''))
161    if (snapshot !== null) return { snapshot, path }
162  }
163
164  return null
165}
166
167/**
168 * Claim a rollover for restoring, once. A claim already held by another session
169 * loses; otherwise the claim is written, the port waits a moment, and it is read
170 * back: of two sessions racing, the one whose write landed last wins and the other
171 * sees it and backs off. Best effort — the file API has no exclusive create.
172 */
173export const claim = async (
174  fs: FilePort,
175  root: string,
176  rolloverId: string,
177  sessionId: string,
178  now: number,
179  settle: () => Promise<void>,
180): Promise<boolean> => {
181  const held = await readClaim(fs, root, rolloverId)
182  if (held !== null) return held.claimedBy === sessionId
183  const mine: Claim = { rolloverId, claimedBy: sessionId, at: now }
184  await fs.write(`${dirs(root).claims}/${rolloverId}.json`, JSON.stringify(mine))
185  await settle()
186
187  return (await readClaim(fs, root, rolloverId))?.claimedBy === sessionId
188}
189
190export const writeJournal = (fs: FilePort, root: string, journal: Journal): Promise<void> =>
191  fs.write(`${dirs(root).sessions}/${journal.sessionId}.json`, JSON.stringify(journal))
192
193export const readJournals = async (fs: FilePort, root: string): Promise<Journal[]> => {
194  const names = await listNames(fs, dirs(root).sessions)
195  const read = await Promise.all(
196    names.map(async name => {
197      try {
198        const value = JSON.parse(await fs.read(`${dirs(root).sessions}/${name}`)) as Journal
199        return typeof value.sessionId === 'string' && typeof value.phase === 'string' ? value : null
200      } catch {
201        return null
202      }
203    }),
204  )
205
206  return read.filter((one): one is Journal => one !== null).sort((a, b) => b.updatedAt - a.updatedAt)
207}
208
209/** A folder name for a project directory: the same shape Claude Code's own projects folder uses. */
210export const projectKey = (cwd: string): string => cwd.replace(/[^A-Za-z0-9]/g, '-').replace(/^-+/, '') || 'project'
211
212/** Where this project's record lives. */
213export const rootFor = (configured: string, cwd: string, home: string | null): string => {
214  const posixCwd = cwd.replace(/\\/g, '/')
215  if (configured !== '') {
216    const c = configured.replace(/\\/g, '/').replace(/\/+$/, '')
217    const isAbsolute = /^([A-Za-z]:)?\//.test(c)
218    const base = isAbsolute ? c : `${posixCwd}/${c}`
219
220    return `${base}/${projectKey(posixCwd)}`
221  }
222  const h = (home ?? posixCwd).replace(/\\/g, '/').replace(/\/+$/, '')
223
224  return `${h}/.claude/context-rollover/${projectKey(posixCwd)}`
225}
226
hooks/lib/thresholds.ts 70 lines
1/**
2 * Thresholds and where a context reading stands against them. Pure.
3 *
4 * The reading is the engine's own: the input tokens the last main-loop response was
5 * answered over (uncached + cache-written + cache-read), the figure the status line
6 * shows. It is the size of the context as of that response — the next request is
7 * that plus the response and any tool results, which is why the limits sit below the
8 * point where the session would actually stop.
9 */
10
11export type Thresholds = { soft: number; prepare: number; hard: number }
12
13export type Zone = 'below' | 'soft' | 'prepare' | 'hard'
14
15/** The share of the model's window a hard limit may claim: room above it for the last turn. */
16export const WINDOW_SHARE = 0.9
17
18/**
19 * The thresholds in force. Configured values stand unless the model's window is too
20 * small for them; then all three move down together, keeping their gaps, so the hard
21 * limit leaves a tenth of the window for the turn that is running when it passes.
22 */
23export const effectiveThresholds = (configured: Thresholds, window: number | null): Thresholds => {
24  if (window === null || !Number.isFinite(window) || window <= 0) return configured
25  const ceiling = Math.floor(window * WINDOW_SHARE)
26  if (configured.hard <= ceiling) return configured
27
28  const shift = configured.hard - ceiling
29  const soft = Math.max(1, configured.soft - shift)
30  const prepare = Math.max(soft + 1, configured.prepare - shift)
31
32  return { soft, prepare, hard: Math.max(prepare + 1, ceiling) }
33}
34
35export const zoneOf = (tokens: number | null, t: Thresholds): Zone => {
36  if (tokens === null) return 'below'
37  if (tokens >= t.hard) return 'hard'
38  if (tokens >= t.prepare) return 'prepare'
39  if (tokens >= t.soft) return 'soft'
40
41  return 'below'
42}
43
44/** Progress toward the hard limit, 0–100, clamped; null without a reading. */
45export const progressOf = (tokens: number | null, hard: number): number | null => {
46  if (tokens === null || !Number.isFinite(tokens) || hard <= 0) return null
47
48  return Math.max(0, Math.min(100, (tokens / hard) * 100))
49}
50
51/** The next threshold above the reading and how far off it is; null past the hard limit. */
52export const nextThreshold = (
53  tokens: number | null,
54  t: Thresholds,
55): { name: 'soft' | 'prepare' | 'hard'; at: number; remaining: number } | null => {
56  if (tokens === null) return { name: 'soft', at: t.soft, remaining: t.soft }
57  for (const name of ['soft', 'prepare', 'hard'] as const) {
58    if (tokens < t[name]) return { name, at: t[name], remaining: t[name] - tokens }
59  }
60
61  return null
62}
63
64/** Input tokens a response was answered over, from its usage: the status line's figure. */
65export const contextTokensOf = (usage: {
66  input_tokens: number
67  cache_read_input_tokens: number
68  cache_creation_input_tokens?: number
69}): number => usage.input_tokens + usage.cache_read_input_tokens + (usage.cache_creation_input_tokens ?? 0)
70
hooks/lib/transcript.ts 149 lines
1/**
2 * Structured facts read off the session's own transcript (`$.session.messages()`):
3 * the person's requests, the task list as last written, the files written, the
4 * errors met and the Agent calls made. These are the tool calls' own payloads, not a
5 * summary of the conversation. Pure.
6 */
7
8import type { AgentCall } from './agents'
9import type { TaskItem, TaskList } from './continuation'
10
11/** The fields of `SessionMessage` this module reads. */
12export type MessageSeen = {
13  role: 'user' | 'assistant'
14  text: string
15  toolUses: readonly {
16    tool: string
17    input: Readonly<Record<string, unknown>>
18    text?: string
19    isError?: true
20    agentId?: string
21    result?: unknown
22  }[]
23}
24
25const MARK = '[context-rollover]'
26
27const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
28
29/** A user row the person typed, not a tool result, a command record or a reminder. */
30const isTyped = (m: MessageSeen): boolean => {
31  if (m.role !== 'user') return false
32  const t = m.text.trim()
33
34  return t !== '' && !t.startsWith('<') && !t.includes(MARK)
35}
36
37/** The person's requests: the first one (the objective's origin) and the last few. */
38export const userRequests = (messages: readonly MessageSeen[], recent = 5): { first: string | null; recent: string[] } => {
39  const typed = messages.filter(isTyped).map(m => m.text.trim())
40
41  return { first: typed[0] ?? null, recent: typed.slice(-recent) }
42}
43
44const STATUSES = new Set(['pending', 'in_progress', 'completed'])
45
46const asTask = (raw: unknown): TaskItem | null => {
47  if (typeof raw !== 'object' || raw === null) return null
48  const r = raw as Record<string, unknown>
49  const subject = typeof r.subject === 'string' ? r.subject : typeof r.content === 'string' ? r.content : null
50  if (subject === null || typeof r.status !== 'string' || !STATUSES.has(r.status)) return null
51
52  return {
53    ...(typeof r.id === 'string' ? { id: r.id } : {}),
54    subject,
55    status: r.status as TaskItem['status'],
56    ...(typeof r.owner === 'string' && r.owner !== '' ? { owner: r.owner } : {}),
57    ...(Array.isArray(r.blockedBy) ? { blockedBy: r.blockedBy.filter((one): one is string => typeof one === 'string') } : {}),
58  }
59}
60
61/** The TodoWrite list as last written in this conversation; null when it never was. */
62export const lastTodos = (messages: readonly MessageSeen[]): TaskList | null => {
63  for (let i = messages.length - 1; i >= 0; i -= 1) {
64    const uses = messages[i]?.toolUses ?? []
65    for (let j = uses.length - 1; j >= 0; j -= 1) {
66      const use = uses[j]
67      if (use?.tool !== 'TodoWrite' || !Array.isArray(use.input.todos)) continue
68      const items = use.input.todos.map(asTask).filter((one): one is TaskItem => one !== null)
69
70      return { source: 'TodoWrite', items }
71    }
72  }
73
74  return null
75}
76
77/** The TaskList tool's answer (`{ tasks }`, as its record or its text), read defensively. */
78export const tasksFromTaskList = (result: unknown): TaskList | null => {
79  let value = result
80  if (typeof value === 'string') {
81    try {
82      value = JSON.parse(value)
83    } catch {
84      return null
85    }
86  }
87  if (typeof value !== 'object' || value === null) return null
88  const tasks = (value as { tasks?: unknown }).tasks
89  if (!Array.isArray(tasks)) return null
90
91  return { source: 'TaskList', items: tasks.map(asTask).filter((one): one is TaskItem => one !== null) }
92}
93
94/** Files the session wrote, newest last, each once. */
95export const filesWritten = (messages: readonly MessageSeen[], max = 40): string[] => {
96  const seen: string[] = []
97  for (const m of messages) {
98    for (const use of m.toolUses) {
99      if (!WRITERS.has(use.tool)) continue
100      const path = use.input.file_path ?? use.input.notebook_path
101      if (typeof path !== 'string' || path === '') continue
102      const at = seen.indexOf(path)
103      if (at >= 0) seen.splice(at, 1)
104      seen.push(path)
105    }
106  }
107
108  return seen.slice(-max)
109}
110
111/** The last few failed tool calls, by what failed and the first line of why. */
112export const recentErrors = (messages: readonly MessageSeen[], max = 6): string[] => {
113  const errors: string[] = []
114  for (const m of messages) {
115    for (const use of m.toolUses) {
116      if (use.isError !== true) continue
117      const what = typeof use.input.command === 'string' ? use.input.command : typeof use.input.file_path === 'string' ? use.input.file_path : ''
118      const why = (use.text ?? '').split(/\r?\n/).find(line => line.trim() !== '') ?? ''
119      errors.push(`${use.tool}${what === '' ? '' : ` ${what}`}: ${why}`.slice(0, 300))
120    }
121  }
122
123  return errors.slice(-max)
124}
125
126/** Every Agent call the main loop made, with the id the engine gave its agent. */
127export const agentCalls = (messages: readonly MessageSeen[]): AgentCall[] =>
128  messages.flatMap(m =>
129    m.toolUses
130      .filter(use => use.tool === 'Agent')
131      .map(use => ({
132        agentId: typeof use.agentId === 'string' ? use.agentId : null,
133        description: typeof use.input.description === 'string' ? use.input.description : '',
134        prompt: typeof use.input.prompt === 'string' ? use.input.prompt : '',
135        subagentType: typeof use.input.subagent_type === 'string' ? use.input.subagent_type : null,
136        name: typeof use.input.name === 'string' ? use.input.name : null,
137      })),
138  )
139
140/** An agent's last answer, from its own transcript. */
141export const lastAnswer = (messages: readonly MessageSeen[]): string | null => {
142  for (let i = messages.length - 1; i >= 0; i -= 1) {
143    const m = messages[i]
144    if (m?.role === 'assistant' && m.text.trim() !== '') return m.text.trim()
145  }
146
147  return null
148}
149
hooks/lib/continuation.ts 273 lines
1/**
2 * The continuation: what a fresh session needs to carry on, and nothing it can read
3 * from the repository itself. Built from structured state (git, the task list, the
4 * agents, the transcript's own tool calls), with the session's model asked only for
5 * the part no structure holds. Pure: the data in, the markdown out.
6 *
7 * Shaped after the handoff skill's rules: settled artifacts (specs, plans, commits,
8 * diffs) are referenced by path, never copied; secrets are redacted; a claim nobody
9 * verified is listed as unverified, because the next session treats this as a contract.
10 */
11
12import type { AgentRecord } from './agents'
13import type { GitState } from './git'
14import type { Summary } from './summary'
15import type { Thresholds } from './thresholds'
16
17export type TaskItem = {
18  id?: string
19  subject: string
20  status: 'pending' | 'in_progress' | 'completed'
21  owner?: string
22  blockedBy?: readonly string[]
23}
24
25export type TaskList = { source: 'TaskList' | 'TodoWrite'; items: readonly TaskItem[] }
26
27export type ContinuationData = {
28  rolloverId: string
29  generation: number
30  previousRolloverId: string | null
31  fromSessionId: string
32  createdAt: number
33  project: string
34  finalTokens: number | null
35  thresholds: Thresholds
36  /** The objective as the chain first stated it, carried from generation to generation. */
37  objective: string | null
38  /** Decisions carried from earlier generations, oldest first. */
39  carriedDecisions: readonly string[]
40  /** The person's own recent requests, verbatim (cut): nothing else records them. */
41  userRequests: readonly string[]
42  summary: Summary | null
43  /** Why there is no summary, when there is none. */
44  summaryNote: string | null
45  tasks: TaskList | null
46  agents: readonly AgentRecord[]
47  git: GitState | null
48  files: readonly string[]
49  errors: readonly string[]
50}
51
52/** Characters per token used to bound the artifact's size; generous, so the bound holds. */
53export const CHARS_PER_TOKEN = 3.5
54
55export const approxTokens = (text: string): number => Math.ceil(text.length / CHARS_PER_TOKEN)
56
57const SECRET_PATTERNS: readonly RegExp[] = [
58  /sk-[A-Za-z0-9_-]{16,}/g,
59  /gh[pousr]_[A-Za-z0-9]{20,}/g,
60  /AKIA[0-9A-Z]{16}/g,
61  /xox[abpr]-[A-Za-z0-9-]{10,}/g,
62  /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
63  /((?:api[_-]?key|token|secret|password|passwd)\s*[:=]\s*)["']?[^\s"']{6,}/gi,
64]
65
66/** Known credential shapes replaced, so a continuation never carries a key forward. */
67export const redact = (text: string): string =>
68  SECRET_PATTERNS.reduce(
69    (out, pattern) => out.replace(pattern, (match, prefix?: string) => (typeof prefix === 'string' && prefix !== match ? `${prefix}[REDACTED]` : '[REDACTED]')),
70    text,
71  )
72
73export const cut = (text: string, max: number): string => {
74  const clean = text.replace(/\s+/g, ' ').trim()
75
76  return clean.length <= max ? clean : `${clean.slice(0, Math.max(1, max - 1))}…`
77}
78
79type Section = {
80  title: string
81  /** Lower is kept longer. */
82  priority: number
83  lines: string[]
84  /** Never trimmed below this many lines. */
85  keep: number
86}
87
88const STATUS_MARK: Record<TaskItem['status'], string> = { completed: '[x]', in_progress: '[~]', pending: '[ ]' }
89
90const taskLine = (task: TaskItem): string => {
91  const owner = task.owner === undefined || task.owner === '' ? '' : ` — owner: ${task.owner}`
92  const blocked = task.blockedBy !== undefined && task.blockedBy.length > 0 ? ` — blocked by ${task.blockedBy.join(', ')}` : ''
93  const id = task.id === undefined ? '' : `#${task.id} `
94
95  return `- ${STATUS_MARK[task.status]} ${id}${cut(task.subject, 160)}${owner}${blocked}`
96}
97
98const agentLines = (agent: AgentRecord): string[] => {
99  const who = agent.name ?? agent.description
100  const lines = [`- **${cut(who, 60)}** (${agent.type}, ${agent.status}, id \`${agent.id}\`) → ${agent.action}`]
101  if (agent.responsibility !== null) lines.push(`  - responsibility: ${cut(agent.responsibility, 400)}`)
102  if (agent.lastOutput !== null) lines.push(`  - last output: ${cut(agent.lastOutput, 500)}`)
103
104  return lines
105}
106
107const ACTION_ORDER: Record<AgentRecord['action'], number> = { 're-dispatch': 0, 'review-output': 1, none: 2 }
108
109/** Every section, in the order it is read, with its trimming priority. */
110const sectionsOf = (d: ContinuationData, snapshotPath: string): Section[] => {
111  const s = d.summary
112  const unfinishedTasks = d.tasks?.items.filter(one => one.status !== 'completed') ?? []
113  const doneTasks = d.tasks?.items.filter(one => one.status === 'completed') ?? []
114  const decisions = [...d.carriedDecisions, ...(s?.decisions ?? [])]
115  const uniqueDecisions = decisions.filter((one, index) => decisions.indexOf(one) === index)
116  const git = d.git
117  const sections: Section[] = []
118
119  sections.push({
120    title: 'Objective',
121    priority: 0,
122    keep: 1,
123    lines: [cut(s?.objective ?? d.objective ?? 'Not recorded — infer it from the tasks and the recent requests below.', 1200)],
124  })
125
126  if (s?.status !== undefined && s.status !== '') {
127    sections.push({ title: 'Where it stands', priority: 1, keep: 1, lines: [cut(s.status, 1500)] })
128  } else if (d.summaryNote !== null) {
129    sections.push({ title: 'Where it stands', priority: 1, keep: 1, lines: [`No model summary: ${d.summaryNote}. Rely on the structured state below.`] })
130  }
131
132  if (s !== null && s.nextActions.length > 0) {
133    sections.push({ title: 'Next actions (in order)', priority: 0, keep: 3, lines: s.nextActions.map((one, i) => `${i + 1}. ${cut(one, 300)}`) })
134  }
135
136  if (s !== null && s.unfinished.length > 0) {
137    sections.push({ title: 'Unfinished work (priority order)', priority: 1, keep: 3, lines: s.unfinished.map(one => `- ${cut(one, 300)}`) })
138  }
139
140  if (d.tasks !== null) {
141    sections.push({
142      title: `Tasks (${d.tasks.source}: ${unfinishedTasks.length} open, ${doneTasks.length} done)`,
143      priority: 1,
144      keep: Math.min(8, unfinishedTasks.length),
145      lines: [...unfinishedTasks.map(taskLine), ...doneTasks.map(taskLine)],
146    })
147  }
148
149  if (d.agents.length > 0) {
150    const ordered = [...d.agents].sort((a, b) => ACTION_ORDER[a.action] - ACTION_ORDER[b.action])
151    sections.push({
152      title: `Agents (${d.agents.length})`,
153      priority: 1,
154      keep: 2,
155      lines: [
156        'The agents below do not survive the rollover. Re-dispatch the ones marked re-dispatch with their responsibility; read the output of the ones marked review-output and incorporate it if it is not in the repository yet.',
157        ...ordered.flatMap(agentLines),
158      ],
159    })
160  }
161
162  if (uniqueDecisions.length > 0) {
163    sections.push({ title: 'Decisions', priority: 2, keep: 4, lines: uniqueDecisions.map(one => `- ${cut(one, 300)}`) })
164  }
165
166  const blockers = s?.blockers ?? []
167  if (blockers.length > 0) sections.push({ title: 'Blockers and known bugs', priority: 1, keep: 2, lines: blockers.map(one => `- ${cut(one, 300)}`) })
168
169  if (d.errors.length > 0) sections.push({ title: 'Recent tool errors', priority: 4, keep: 0, lines: d.errors.map(one => `- ${cut(one, 240)}`) })
170
171  if (git !== null) {
172    const lines = [
173      `- root: \`${git.root ?? '?'}\` · branch: \`${git.branch ?? 'detached'}\` · HEAD: \`${git.head ?? '?'}\`${git.ahead > 0 ? ` · ahead ${git.ahead}` : ''}${git.behind > 0 ? ` · behind ${git.behind}` : ''}`,
174    ]
175    if (git.inProgress !== null) lines.push(`- **${git.inProgress} in progress** — finish or abort it deliberately`)
176    if (git.conflicted.length > 0) lines.push(`- conflicts: ${git.conflicted.join(', ')}`)
177    lines.push(`- uncommitted: ${git.changes.length} changed, ${git.untracked.length} untracked${git.diffStat === '' ? '' : ` (${git.diffStat})`}`)
178    if (git.safetyRef !== null) lines.push(`- safety ref of the working tree: \`${git.safetyRef}\` (\`git stash apply ${git.safetyRef}\` restores tracked changes if they are ever lost)`)
179    if (git.commit !== null) lines.push(`- checkpoint commit made for this rollover: \`${git.commit}\``)
180    if (git.note !== null) lines.push(`- note: ${git.note}`)
181    lines.push(...git.changes.map(one => `  - ${one.status} ${one.path}`))
182    lines.push(...git.untracked.map(one => `  - ? ${one}`))
183    if (git.recentCommits.length > 0) lines.push('- recent commits:', ...git.recentCommits.map(one => `  - ${cut(one, 120)}`))
184    sections.push({ title: 'Repository state at rollover', priority: 2, keep: 4, lines })
185  }
186
187  if (d.files.length > 0) sections.push({ title: 'Files this session wrote', priority: 3, keep: 5, lines: d.files.map(one => `- ${one}`) })
188
189  if (d.userRequests.length > 0) {
190    sections.push({ title: 'Recent requests from the person (verbatim, cut)', priority: 2, keep: 1, lines: d.userRequests.map(one => `- “${cut(one, 600)}”`) })
191  }
192
193  const unverified = s?.unverified ?? []
194  if (unverified.length > 0) sections.push({ title: 'Unverified — check before relying on these', priority: 2, keep: 2, lines: unverified.map(one => `- ${cut(one, 240)}`) })
195
196  const skills = s?.skills ?? []
197  if (skills.length > 0) sections.push({ title: 'Suggested skills', priority: 4, keep: 0, lines: skills.map(one => `- ${cut(one, 120)}`) })
198
199  if (s?.notes !== undefined && s.notes !== '') sections.push({ title: 'Other notes', priority: 3, keep: 0, lines: [cut(s.notes, 1500)] })
200
201  sections.push({
202    title: 'Resume protocol',
203    priority: 0,
204    keep: 99,
205    lines: [
206      '1. This session is fresh: the previous conversation is gone. Treat this file as a lead, not as truth — the repository is the source of truth.',
207      '2. Verify first: run `git status` and `git log -3 --oneline`, compare them with “Repository state” above, and look at any file you are about to change before changing it.',
208      '3. Recreate the open tasks above (TaskCreate, or TodoWrite) so the plan is visible again; keep owners.',
209      '4. Re-dispatch agents marked re-dispatch, each with its recorded responsibility; incorporate outputs marked review-output if the repository does not already hold them.',
210      '5. Continue with the first of “Next actions” (else the highest-priority unfinished task). Do not redo work the repository shows as done.',
211      `6. This continuation is already in your context. Its full record is \`${snapshotPath}\` (large JSON) — do not read it unless something here is missing; a readable copy of this text is beside it as \`.md\`.`,
212    ],
213  })
214
215  return sections
216}
217
218const render = (d: ContinuationData, sections: readonly Section[], omitted: number): string => {
219  const head = [
220    `# Context rollover continuation — ${d.rolloverId}`,
221    '',
222    `Generation ${d.generation}${d.previousRolloverId === null ? '' : ` (after ${d.previousRolloverId})`} · from session \`${d.fromSessionId}\` at ${new Date(d.createdAt).toISOString()} · project \`${d.project}\`${d.finalTokens === null ? '' : ` · rolled over at ${Math.round(d.finalTokens / 1000)}k / ${Math.round(d.thresholds.hard / 1000)}k tokens`}`,
223    '',
224    '> This restores the project’s state, not the conversation. It was written automatically by the context-rollover mod.',
225  ]
226  const body = sections.flatMap(one => (one.lines.length === 0 ? [] : ['', `## ${one.title}`, '', ...one.lines]))
227  const tail = omitted > 0 ? ['', `_${omitted} lower-priority lines were left out to stay within the size limit; the full record is in the snapshot file._`] : []
228
229  return redact([...head, ...body, ...tail, ''].join('\n'))
230}
231
232/**
233 * The continuation as markdown, within `maxTokens` (by the character bound above).
234 * Over budget, the lowest-priority section loses its last line first, down to the
235 * lines it always keeps; the resume protocol and the objective are never cut.
236 */
237export const buildContinuation = (
238  d: ContinuationData,
239  snapshotPath: string,
240  maxTokens: number,
241): { markdown: string; approxTokens: number; omittedLines: number } => {
242  const sections = sectionsOf(d, snapshotPath).map(one => ({ ...one, lines: [...one.lines] }))
243  const maxChars = Math.floor(maxTokens * CHARS_PER_TOKEN)
244  let omitted = 0
245  let markdown = render(d, sections, omitted)
246
247  while (markdown.length > maxChars) {
248    const trimmable = sections
249      .filter(one => one.lines.length > one.keep)
250      .sort((a, b) => b.priority - a.priority || b.lines.length - a.lines.length)[0]
251    if (trimmable === undefined) break
252    // Cut in bigger steps while far over, single lines near the limit.
253    const over = markdown.length - maxChars
254    const step = Math.max(1, Math.min(trimmable.lines.length - trimmable.keep, Math.floor(over / 400)))
255    trimmable.lines.splice(trimmable.lines.length - step, step)
256    omitted += step
257    markdown = render(d, sections, omitted)
258  }
259
260  return { markdown, approxTokens: approxTokens(markdown), omittedLines: omitted }
261}
262
263/** The short prompt the fresh session is started with; the continuation rides as context. */
264export const resumePrompt = (rolloverId: string, generation: number, snapshotPath: string): string =>
265  [
266    `[context-rollover] Fresh session after rollover ${rolloverId} (generation ${generation}).`,
267    'The continuation state is in the message just before this one. Follow its resume protocol: verify the project state, restore the task list and agents, then continue the highest-priority unfinished work.',
268    `Readable copy, if ever needed: ${snapshotPath}`,
269  ].join('\n')
270
271/** The marker a resume prompt carries, so the prompt.submit hook can find its rollover. */
272export const rolloverIdIn = (text: string): string | null => /\[context-rollover\] Fresh session after rollover (\S+) /.exec(text)?.[1] ?? null
273
hooks/lib/git.ts 104 lines
1/**
2 * The repository's state as git reports it, and the one decision this mod ever makes
3 * about committing. Pure: parsing and policy; the commands run in the orchestrator,
4 * and none of them resets, cleans, checks out or otherwise rewrites the working tree.
5 */
6
7export type GitChange = { path: string; status: string }
8
9export type GitState = {
10  root: string | null
11  branch: string | null
12  head: string | null
13  ahead: number
14  behind: number
15  changes: GitChange[]
16  untracked: string[]
17  conflicted: string[]
18  /** `git diff --shortstat HEAD`, trimmed. */
19  diffStat: string
20  recentCommits: string[]
21  /** A merge, rebase, cherry-pick or revert under way, by name. */
22  inProgress: string | null
23  /** `refs/context-rollover/<id>`, pointing at a `git stash create` commit of the tree. */
24  safetyRef: string | null
25  /** The checkpoint commit autoCommit made, when it made one. */
26  commit: string | null
27  note: string | null
28}
29
30/** Status letters as the porcelain gives them: `.M` → `M`, `A.` → `A`. */
31const letterOf = (xy: string): string => {
32  const [x = '.', y = '.'] = xy
33  if (x !== '.' && y !== '.' && x !== y) return `${x}${y}`
34
35  return x !== '.' ? x : y
36}
37
38/** `git status --porcelain=v2 --branch`, read; unknown lines are skipped. */
39export const parsePorcelain = (out: string): Pick<GitState, 'branch' | 'head' | 'ahead' | 'behind' | 'changes' | 'untracked' | 'conflicted'> => {
40  const state = { branch: null as string | null, head: null as string | null, ahead: 0, behind: 0, changes: [] as GitChange[], untracked: [] as string[], conflicted: [] as string[] }
41
42  for (const line of out.split(/\r?\n/)) {
43    if (line.startsWith('# branch.oid ')) {
44      const oid = line.slice('# branch.oid '.length).trim()
45      state.head = oid === '(initial)' ? null : oid.slice(0, 12)
46    } else if (line.startsWith('# branch.head ')) {
47      const head = line.slice('# branch.head '.length).trim()
48      state.branch = head === '(detached)' ? null : head
49    } else if (line.startsWith('# branch.ab ')) {
50      const match = /\+(\d+) -(\d+)/.exec(line)
51      state.ahead = Number(match?.[1] ?? 0)
52      state.behind = Number(match?.[2] ?? 0)
53    } else if (line.startsWith('1 ')) {
54      const parts = line.split(' ')
55      const path = parts.slice(8).join(' ')
56      if (path !== '') state.changes.push({ path, status: letterOf(parts[1] ?? '') })
57    } else if (line.startsWith('2 ')) {
58      const parts = line.split(' ')
59      const paths = parts.slice(9).join(' ').split('\t')
60      if (paths[0] !== undefined && paths[0] !== '') state.changes.push({ path: `${paths[1] ?? '?'} → ${paths[0]}`, status: 'R' })
61    } else if (line.startsWith('u ')) {
62      const path = line.split(' ').slice(10).join(' ')
63      if (path !== '') state.conflicted.push(path)
64    } else if (line.startsWith('? ')) {
65      state.untracked.push(line.slice(2))
66    }
67  }
68
69  return state
70}
71
72export type CommitDecision = { shouldCommit: true } | { shouldCommit: false; reason: string }
73
74/**
75 * Whether autoCommit may commit now. It is off by default, and even on it never
76 * commits onto a protected or detached branch, over conflicts, or in the middle of a
77 * merge or rebase — those are the person's to finish.
78 */
79export const commitDecision = (
80  git: Pick<GitState, 'branch' | 'changes' | 'untracked' | 'conflicted' | 'inProgress'>,
81  isEnabled: boolean,
82  protectedBranches: readonly string[],
83): CommitDecision => {
84  if (!isEnabled) return { shouldCommit: false, reason: 'autoCommit is off' }
85  if (git.branch === null) return { shouldCommit: false, reason: 'HEAD is detached' }
86  if (protectedBranches.includes(git.branch)) return { shouldCommit: false, reason: `${git.branch} is protected` }
87  if (git.inProgress !== null) return { shouldCommit: false, reason: `a ${git.inProgress} is in progress` }
88  if (git.conflicted.length > 0) return { shouldCommit: false, reason: 'there are conflicts' }
89  if (git.changes.length === 0 && git.untracked.length === 0) return { shouldCommit: false, reason: 'nothing to commit' }
90
91  return { shouldCommit: true }
92}
93
94/** The markers `git rev-parse --git-path <name>` resolves, and what each means. */
95export const IN_PROGRESS_MARKERS: readonly (readonly [string, string])[] = [
96  ['MERGE_HEAD', 'merge'],
97  ['rebase-merge', 'rebase'],
98  ['rebase-apply', 'rebase'],
99  ['CHERRY_PICK_HEAD', 'cherry-pick'],
100  ['REVERT_HEAD', 'revert'],
101]
102
103export const safetyRefName = (rolloverId: string): string => `refs/context-rollover/${rolloverId.replace(/[^A-Za-z0-9._-]/g, '-')}`
104
hooks/lib/summary.ts 105 lines
1/**
2 * The one targeted question put to the session's own model before a rollover: only
3 * what no structured source holds (intent, decisions and their reasons, what is
4 * next). It is asked through `$.model.fork`, which re-sends the transcript as the
5 * main thread last sent it, so the prompt cache serves almost all of it. Pure: the
6 * prompt and the parse.
7 */
8
9export type Summary = {
10  objective?: string
11  status?: string
12  decisions: string[]
13  blockers: string[]
14  unfinished: string[]
15  nextActions: string[]
16  unverified: string[]
17  skills: string[]
18  notes?: string
19}
20
21export const SUMMARY_PROMPT = [
22  'The conversation is about to roll over into a fresh session with no access to this transcript.',
23  'The repository, its git state, the task list and the agents list are captured separately and automatically — do NOT restate them, do not copy code, diffs, specs or plans; reference files by path.',
24  'Answer with ONE JSON object and nothing else, at most about 1200 words in all, with these keys:',
25  '- "objective": the overall goal the person is pursuing, one or two sentences;',
26  '- "status": where the work stands right now, a short paragraph;',
27  '- "decisions": array of important architectural or product decisions made in this conversation, each with its reason (only ones the repository does not already document);',
28  '- "blockers": array of known bugs, blockers, open questions;',
29  '- "unfinished": array of unfinished work items, highest priority first;',
30  '- "nextActions": array of the exact next steps a fresh session should take, in order;',
31  '- "unverified": array of things believed but not verified in this conversation (so the next session checks them);',
32  '- "skills": array of skills (by name) the next session should reach for, possibly empty;',
33  '- "notes": anything else the next session cannot reconstruct from the repository, or "".',
34  'Never include secrets, tokens or passwords.',
35].join('\n')
36
37const strings = (value: unknown, max: number): string[] =>
38  Array.isArray(value)
39    ? value
40        .filter((one): one is string => typeof one === 'string' && one.trim() !== '')
41        .map(one => one.trim())
42        .slice(0, max)
43    : []
44
45const text = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined)
46
47/** The first balanced `{…}` in a reply, so prose or a code fence around it is ignored. */
48const firstObject = (reply: string): string | null => {
49  const start = reply.indexOf('{')
50  if (start < 0) return null
51  let depth = 0
52  let inString = false
53  let escaped = false
54  for (let i = start; i < reply.length; i += 1) {
55    const ch = reply[i]
56    if (inString) {
57      if (escaped) escaped = false
58      else if (ch === '\\') escaped = true
59      else if (ch === '"') inString = false
60      continue
61    }
62    if (ch === '"') inString = true
63    else if (ch === '{') depth += 1
64    else if (ch === '}') {
65      depth -= 1
66      if (depth === 0) return reply.slice(start, i + 1)
67    }
68  }
69
70  return null
71}
72
73/** The reply as a Summary, or null when it holds no usable object. */
74export const parseSummary = (reply: string): Summary | null => {
75  const raw = firstObject(reply)
76  if (raw === null) return null
77  let value: unknown
78  try {
79    value = JSON.parse(raw)
80  } catch {
81    return null
82  }
83  if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
84  const v = value as Record<string, unknown>
85  const summary: Summary = {
86    objective: text(v.objective),
87    status: text(v.status),
88    decisions: strings(v.decisions, 20),
89    blockers: strings(v.blockers, 15),
90    unfinished: strings(v.unfinished, 20),
91    nextActions: strings(v.nextActions, 10),
92    unverified: strings(v.unverified, 15),
93    skills: strings(v.skills, 8),
94    notes: text(v.notes),
95  }
96  const isEmpty =
97    summary.objective === undefined &&
98    summary.status === undefined &&
99    summary.nextActions.length === 0 &&
100    summary.unfinished.length === 0 &&
101    summary.decisions.length === 0
102
103  return isEmpty ? null : summary
104}
105