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…

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.
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>.
It runs by itself. /rollover gives you control:
| Command | What it does |
|---|---|
/rollover or /rollover status | The state in words: phase, context, thresholds, handoff, last outcome, error |
/rollover now | Roll 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 cancel | Stand a pending rollover down and lift the gate (not once the restart has begun) |
/rollover config | The effective 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
}
| Setting | Meaning |
|---|---|
softLimit | The model is told the session will roll over soon, and should keep its task list current. |
prepareLimit | A draft continuation is written to disk; new Agent spawns are refused (blockNewAgents); the rollover runs at the next turn end. |
hardLimit | Mid-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. |
automaticRestart | Off: the continuation is persisted, /clear is put in your prompt box, and your /clear restores it. |
restartMode | clear (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. |
continuationDir | Empty: ~/.claude/context-rollover/<project> (outside the repository, so nothing appears in git status). Relative: under the project. |
agentPolicy | drain: tell running agents to wrap up (notifyAgents), wait up to agentDrainTimeoutMs, record the rest. record: record them and go. |
autoCommit | Off 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. |
gitSafetyRef | Keeps 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. |
resumeOnStartup | A 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.
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.
At the rollover the mod gathers structured state first:
status --porcelain=v2, diff --shortstat, log -5, merge/rebase markers, a safety ref (and a commit only if you turned autoCommit on).TaskList tool's own record (owners, blockers), else the TaskCreate/TaskUpdate calls seen this session, else the last TodoWrite.$.agent.list() joined with the Agent calls in the transcript (their prompts = responsibilities) and each agent's last answer from its own transcript.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.
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:
git status / git log against the record.Its first completed turn marks the transition COMPLETED, and monitoring starts over for the next one.
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:
$.session.send) and waited for up to agentDrainTimeoutMs. Their file edits are already in the working tree, which is the source of truth.re-dispatch — running, pending, failed or killed, or an idle teammate.review-output — finished; its output may not be in the repository yet.none./rollover now, a measurement) joins the one in flight./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.maxRetries, retryDelayMs). An attempt is bounded by rolloverTimeoutMs.awaiting-restart, /clear is put in your prompt box, and your /clear restores it; otherwise failed, and /rollover now retries.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.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.
| Field | Meaning |
|---|---|
schemaVersion | 1. Readers draw nothing they cannot validate. |
sessionId, generation | The current session; how many rollovers this chain has done. |
phase | disabled · monitoring · preparing · ready · draining · persisting · restarting · resuming · completed · awaiting-restart · failed |
context | tokens (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) |
continuation | status (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 |
last | The previous rollover: outcome (success · failed · interrupted), at, rolloverId, fromSessionId, toSessionId, finalTokens, detail |
operation | What is happening now, or next |
error | { message, at, isRetriable } or null |
restart | { mode, isAutomatic } |
heartbeatAt, updatedAt | Heartbeat 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.
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
claude plugin test . # 73 tests
npx -p typescript@5.6.3 tsc -p .
claude plugin validate .
What is and is not tested:
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.tests/lifecycle.test.ts): a simulated process (tests/world.ts) whose /clear behaves as the engine documents. It covers:new-terminal mode;These are simulations.
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.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.
/clear happens are left to the engine (they may be stopped). Their file edits remain in the working tree.$.agent.list() by their roster word and are recorded, but the mod neither stops nor restarts them.git stash create covers tracked changes only. Untracked files are listed in the continuation and stay on disk; the mod never cleans.approxTokens bounds the artifact by characters (÷ 3.5), which is deliberately generous. It is a size cap on the file, not a context reading.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./rollover …) run from the interactive prompt, not from -p input.hooks/register.ts 302 lines1/**
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}
302hooks/lib/agents.ts 104 lines1/**
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.'
104hooks/lib/config.ts 191 lines1/**
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)
191hooks/lib/contract.ts 89 lines1/**
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
89hooks/lib/launch.ts 39 lines1/** 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}
39hooks/lib/rollover.ts 852 lines1/**
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}
852hooks/lib/store.ts 226 lines1/**
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}
226hooks/lib/thresholds.ts 70 lines1/**
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)
70hooks/lib/transcript.ts 149 lines1/**
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}
149hooks/lib/continuation.ts 273 lines1/**
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
273hooks/lib/git.ts 104 lines1/**
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, '-')}`
104hooks/lib/summary.ts 105 lines1/**
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