Automatic, durable task tracking (a journal of prompts, tool calls, plan steps, file hashes, test runs and sessions), recovery that reconciles before it…

A Claude Code plugin that automatically keeps a record of each task, lets you pause Claude at once, and resumes safely after a pause, a crash, a compaction or a restart. Resuming never just picks up where the notes say: checkpoint first works out what changed and what may have happened, and puts anything risky at the top.
you > /check-pause
checkpoint: paused this session (at once, in 0.21 s). Stopped the running turn.
Still running: Bash `pytest -q`, left running (...)
Tool calls stay held until you resume: type a message (any non-/check command resumes) or /check:resume.
you > go on
Claude gets this first (abridged):
Check first
- Bash `git push origin staging`: its outcome is unknown and it may have changed state outside this
repository. Check whether the push landed (`git fetch` then `git status -sb`) ... do not run it again until you know.
- 1 file(s) changed outside this task since checkpoint c_01k8...: app/models.py (...) never revert them.
Plugin check 0.9.0, marketplace checkpoint. It works alone; with the gateway plugin, long jobs run under the gateway's supervision and the hosts a task used are checked before a resume.
~/.local/state/checkpoint, scrubbed first. You don't ask for it, and no model is involved.check: setting up its runtime (first run); pausing works, then check: recording.<project>/.work/<task>/TRACKER.md, to update them before it stops, and in a git repository to add .work/ to the clone's local exclude file (never committed) unless you commit trackers. checkpoint itself writes nothing into your projects. CHECKPOINT_TRACKERS=off turns trackers off./check:*, and /check-* forms that act at once, even mid-reply.check: paused · type a message (any non-/check command resumes) or /check:resume.checkpoint: task t_01k8... was interrupted ("migrate the schema..."): claude --resume <id> continues its session, or /check:resume t_01k8... picks it up here.~/.local/state/checkpoint/checkpoint.Nothing is added to your settings or permission rules.
/check-* commands, the footer label, the status line, the Checkpoints pane and telling your messages from automated ones. Without mods (VS Code, claude -p, mods turned off) the hooks still record and the /check:* commands work, but there are no immediate commands, label, status line or pane, a pause waits for the next tool call, and any prompt resumes a paused session.pixi self-update, or your package manager (Homebrew, MacPorts) if that installed it. checkpoint brings its own Python through it (the newest from conda-forge).pixi global install git, with ~/.pixi/bin on your PATH).In Claude Code:
/plugin marketplace add p-w-rs/checkpoint
/plugin install check@checkpoint
Or from a shell:
claude plugin marketplace add p-w-rs/checkpoint
claude plugin install check@checkpoint
In the desktop app, add the marketplace from a terminal first, then in the Code tab click + → Plugins → Add plugin and choose check.
/reload-plugins). The footer goes from check: setting up its runtime (first run); pausing works to check: recording./check:help and /check:status.sleep 20, then say done" (keep it under 25 s: Claude Code refuses a longer foreground sleep), and type /check-pause while it runs. Then type go on./check-save before the refactor, then /check-list.pgrep -fl claude lists the processes with claude in their command line, and the session is the one whose command is just claude; kill -9 <its number>. Start claude again in the same folder: the interrupted task is offered. A normal quit (/exit, Ctrl-D) isn't a crash and shows no notice.| Command | What it does | ||
|---|---|---|---|
/check-pause [now] | Pause now, even mid-reply. now also stops the running command (its whole process tree). | ||
| `/check:pause [all\ | now\ | <session>] [--stop-jobs]` | Pause this, every, or another session. Typed while Claude is busy it waits for the turn to end. |
/check:resume, /check-resume | Resume after reconciling. | ||
| `/check:resume <task>\ | last\ | <checkpoint>` | Pick up a task in this session, or continue from a checkpoint (nothing is rolled back). |
/check:status | Sessions, pause state, jobs, this project's unfinished tasks, this session's summary. | ||
/check:save [why], /check-save | Record a checkpoint now (synced to disk); stops nothing. Every pause saves one too. | ||
/check:list, /check-list | The task's checkpoints (the dash form opens a pane to diff or resume). | ||
| `/check:diff [<cp> [<cp>\ | now]]` | What changed between checkpoints, or since one. | |
/check:stop [<job>] | Stop a detached job, or list them. | ||
| `/check:cancel [interrupt\ | detach], /check:complete` | End the task, history kept. | |
/check:export [<task>] [<path>] | A scrubbed bundle of the task, to hand over. Review it before sharing. | ||
/check:forget <task> | Delete one task's recorded history. | ||
/check:doctor, /check-doctor | Versions, pixi, the runtime, the gate, the gateway, stuck jobs. |
Only you can run these; Claude can't. All but the pause and resume commands work while paused and never resume.
Pausing. /check-pause (or typing pause while Claude is busy) ends the running turn at once and holds every tool call except reads, tracker edits, to-do lists, checkpoint_note, task_policy and the job tools other than job_start. A running Bash command keeps running unless you add now. Detached jobs keep running too (a gateway job follows its pause policy; /check:pause --stop-jobs stops them). pause all pauses every session on the machine at its next tool call.
Resuming. Any message you type resumes, and so does any slash command except checkpoint's own (another plugin's skill, for example). With mods, scheduled prompts, /loop, notifications and other agents never do. Claude first gets the reconciliation: files changed outside the task (kept, never reverted), test results that are now stale, newer instructions, and every interrupted operation as never dispatched, confirmed completed, still running, confirmed failed, or outcome unknown. An unknown one that may have changed something outside the repository (a push, a deploy, a remote command) comes first, and is never simply run again.
After a crash. Use the claude --resume <id> from the notice to get the conversation back too, or type /check:resume last in the new session. In a session, last is the newest task in this project other than this session's own, an interrupted one first.
What Claude can use. job_start runs long work as a detached job that survives pauses and the end of the session; it asks like a Bash command. job_status, job_tail, job_stop, checkpoint_note (constraints, decisions, open questions) and task_policy run without a prompt.
From a terminal, when no session is free: ~/.local/state/checkpoint/checkpoint pause all (also status, resume all, doctor, tasks, summary, reconcile <task>, export <task>, stop <job>, forget <task>, purge, and help for the rest).
Jobs and the gateway. With the gateway serving the session, job_start hands the job to the gateway, which runs and supervises it with your session's environment, minus credential variables (Claude Code's own, and names such as GH_TOKEN or PGPASSWORD; SSH_AUTH_SOCK stays, so the job can use your ssh agent) and the git config entries in it that hold one (GIT_CONFIG_*, such as an http.extraHeader). Each job has a pause policy: continue (the default), interrupt, suspend or detach; a pause says what each one did. A job the gateway refuses runs nowhere. The gateway also checks a job's command at job_start as it checks Bash, but nobody can be asked while a job runs: it refuses a network command such as curl unless its [network] setting allows it, its private folder and session transcripts, anything set to false and any "ask" right, and it never approves a job by itself. A new gateway install names no machine or account and asks only about network commands. pixi-guard refuses Python outside pixi. The gateway masks what job_tail, job_status and job_stop show Claude, except a failed job_stop's error text, which no hook can rewrite; it warns instead. More: the gateway's With checkpoint section and pixi-guard's README. Without the gateway, checkpoint runs jobs itself: no pause policy, no remote hosts, nothing survives a reboot, and a job's log keeps its last 32-64 MB.
No settings file; a few optional environment variables, set where Claude Code starts. The ones people use:
| Variable | Default | |
|---|---|---|
CHECKPOINT_TRACKERS | on | off: no tracker instructions, reminders or stop prompts |
CHECKPOINT_KEEP_DAYS | 30 | days a finished task's journal is kept (0: forever) |
CHECKPOINT_KEEP_OPEN_DAYS | 180 | the same for unfinished tasks |
CHECKPOINT_HOME | ~/.local/state/checkpoint | the state folder |
All of them: docs/CONFIGURATION.md.
checkpoint is a record and a guardrail for pausing, not a sandbox. Claude's commands run as you, with your permissions; checkpoint holds tool calls only while a session is paused.
What it guarantees:
/check:status says so.job_start goes through Claude Code's permission check.What it doesn't:
the password is hunter2 stays). Review an export before sharing it. The full list: docs/dev/SCRUBBING.md.Recorded (scrubbed): your prompts (the first 4,000 characters of each), plan steps, each tool call and its outcome (commands up to 2,000 characters, file paths, sizes, sha256 hashes, exit codes), the last 400 characters of a test, build or check's output, subagents and Claude's last message of a turn (300 characters), session starts, the repository's branch and HEAD, permission decisions (names only), and Claude's checkpoint_note notes.
Never recorded: file contents, edit strings, what Read, Grep, Glob, WebFetch or WebSearch returned, the output of other commands (only its size), a failed command's output (only its exit code, or the last 500 characters for a failed test), reasons for permission decisions.
Where and how long:
| What | Where | Kept |
|---|---|---|
Journals, with each session's pause-time checkpoint (journal/<task>/checkpoint-<session>.json) | ~/.local/state/checkpoint/journal/ | finished tasks 30 days after their last activity, unfinished ones 180; never while a session of the task runs or is paused, or a job of it may still run |
| Session records, and the files a pause leaves | the state folder | 7 days after the session's last activity, once its Claude has quit and it isn't paused. The checkpoints /check:list shows are journal records, kept with the journal |
| The spool (events waiting to be journaled) | spool/<session>/ | seconds to a turn; raw while the runtime is ready or being set up, and without one only which tool ran, ids and sizes. Every session's spool holds at most 10,000 events or 50 MB; a spool goes 7 quiet days after its session is gone |
| Jobs and their logs | jobs/ | 14 days after the job ends. A gateway job whose outcome is unknown is kept until the gateway reports its end, or you release it (checkpoint forget-job <job>, after 7 days unknown; /check:doctor names them) |
| Exports | exports/, or the path you gave | until you delete them |
| Trackers (Claude's notes) | <project>/.work/<task>/ | until you delete them |
| The runtime (Python) | Claude Code's plugin data folder | rebuilt when missing; removed after 7 days unused, or by uninstall |
Claude Code keeps its own transcripts of every session; nothing here touches them.
Deleting it (checkpoint is the terminal helper, ~/.local/state/checkpoint/checkpoint):
/check:forget <task>, or checkpoint forget <task> [--dry-run] in a terminal: one task's journal, checkpoints, finished jobs and exports in the state folder. Refused while a session of that task runs or is paused (the current one too) or one of its jobs runs.checkpoint purge [--dry-run]: everything in the state folder except what a live or paused session or a running job still needs. It lists what it removes.checkpoint prune --dry-run shows what retention would remove now.Everything, for good: see Uninstall.
claude -p, mods off), a pause waits for the next tool call, and any prompt resumes a paused session./check:pause typed while Claude is busy waits for the turn to end. Use /check-pause or type pause.now.resume all, each session continues with your next message to it./check:cancel detach detaches a job in checkpoint's records only; a task's jobs that another open session started are paused only once that session has ended; with the gateway gone, their state stays unknown.scripts = true, because the gateway can't tell it came from Claude any more; keep the session open until such a job is done.More: the scrubber's limits in docs/dev/SCRUBBING.md, the design's in docs/dev/ARCHITECTURE.md.
| What | Command |
|---|---|
| Update | claude plugin marketplace update checkpoint, then claude plugin update check@checkpoint; then /reload-plugins or a new session |
| Turn off for now | claude plugin disable check@checkpoint (back on: enable) |
| Uninstall | claude plugin uninstall check@checkpoint (--keep-data keeps the built Python) |
| Forget the marketplace | claude plugin marketplace remove checkpoint (also uninstalls the plugin) |
In Claude Code, /plugin opens the same actions as a menu. An update that changes the runtime builds it in the background while the old one keeps recording.
After an uninstall, quit and restart every open Claude Code session: they still have checkpoint's hooks and commands loaded, pointing at files that are gone.
What stays behind: the state folder, the trackers Claude wrote in your projects' .work/ folders (and a .work/ line it added to a clone's exclude file), and exports you saved elsewhere. To remove everything:
~/.local/state/checkpoint/checkpoint purge --dry-run names the projects it has seen, where .work/ folders may be.rm -rf ~/.local/state/checkpoint (or your $CHECKPOINT_HOME).rm -rf .work, and delete a .work/ line from the file git rev-parse --git-path info/exclude prints.Upgrading. Nothing to do; your history carries over. Records written before 0.8.0 were scrubbed with fewer rules and kept failed commands' output, and .work/*/checkpoint.json files written into projects before 0.8.0 weren't scrubbed at all. New exports scrub old records again; exports made earlier stay as they were. To drop old records, use checkpoint forget or purge (it also removes the exports in the state folder), and delete old .work/*/checkpoint.json files and exports saved elsewhere. What changed in each release: CHANGELOG.md.
Run /check:doctor (or /check-doctor, or checkpoint doctor in a terminal). It shows the versions, pixi and the runtime, the gate, the gateway, the state folder and any stuck gateway job.
check: no runtime: pixi not found. Install pixi (it is also found in ~/.pixi/bin, ~/.local/bin, Homebrew and MacPorts, off PATH). Until then checkpoint records only which tools ran.check: runtime setup FAILED. The doctor shows pixi's error. The setup is retried at every session start.check: recording FAILED. Usually a full disk. Events that couldn't be saved at all are lost; a failed journal write is redone from the spool. Free space and recording resumes by itself, with any gap noted.0.9.0 was tested on macOS (Apple silicon) and Debian 13 (ARM64) with Claude Code 2.1.310 and pixi 0.83: the test suite, and live Claude Code sessions driven against a stand-in model, alone and with the gateway and pixi-guard; the suite also on Intel macOS packages through Rosetta. Not tested: the desktop app, real x86-64 hardware. Details: docs/dev/TESTING.md.
Start with CLAUDE.md, then docs/dev/ARCHITECTURE.md. The gateway contract is docs/CONTRACT-v1.md.
See SECURITY.md.
hooks/mod/register.tsx 857 lines1// checkpoint's mod: the native half of the plugin (the latest Claude Code,
2// in the terminal or the desktop app). Everything that must hold when
3// mods are switched off is in the classic hooks (hooks/fast.sh); this adds
4// what only a mod can do:
5//
6// - /check-pause [now]: an immediate command (it runs mid-turn). Writes the
7// pause flag (hooks/env.sh flag pause: the file fast.sh and Python read),
8// ends the running turn ($.turn.abort) and says at once what was paused and
9// what still runs. A running Bash call is left running (Claude Code keeps it
10// as a background shell), and the ack says so; with `now` it is stopped
11// first (Python's kill of its process tree), then the turn ends.
12// - /check:pause [now] (the skill, so the name works without mods too) is
13// answered the same way when it runs; `all`, `<session>` and --stop-jobs go
14// on to the skill and its Python hook. Mod commands can't carry the plugin's
15// `check:` prefix (letters, digits, `_`, `-` only), and a bare `/pause` is
16// refused because it is the skill's own short name, hence `/check-pause`.
17// The plain keyword `pause [now]` typed while busy reaches prompt.submit at
18// once (with the running turn's id): it pauses there too.
19// - A tool.call gate that holds every call while paused (but the read-only
20// tools and tracker writes the Python gate also allows), with a .catch that
21// denies: it fails closed.
22// - No self-resume: a prompt that isn't the person's (isMeta, scheduled,
23// automation, notifications, other agents; a slash command a schedule or
24// /loop fires too) gets noresume/<session> before the classic hook sees
25// it, so it never resumes a paused session. If that mark can't be written
26// the prompt is held back (fails closed).
27// - /check-doctor (immediate) and /check:doctor; /check:status answered from
28// Python without a model turn when the runtime is ready.
29// - Its state: recording / setting up / updating as a dim label in the
30// prompt footer (SessionMode), paused / recording FAILED / setup FAILED /
31// no pixi / unknown on the status line (Claude Code draws every plugin
32// status line with a warning sign: $.ui.status has no level). Truthful from
33// the first draw: until SessionStart recorded the env (rt/<session>, written
34// after the mod's session.start) env.sh says what the runtime is doing.
35// - The journal (PLAN-v4 phase 2): turn.start / turn.complete, compactions
36// and calls its gate denied go to the session's spool (env.sh spool: the
37// same records fast.sh writes), and a timer has Python ingest the spool
38// every INGEST_EVERY_MS while it grows. /check-save (immediate) and
39// /check:save checkpoint now without stopping anything; /check-list lists
40// the checkpoints of this session's task.
41// - Recovery (PLAN-v4 phase 3), immediate and usable while paused:
42// /check-resume [checkpoint] reconciles (Python), lifts the pause and sends
43// a prompt so the task goes on with the reconciliation in its context;
44// /check-cancel [interrupt|detach] ends the task (its jobs follow the
45// policy, stopped all at once; interrupt stops the running command before
46// the turn ends; the gate holds every tool call while it runs);
47// /check-stop <job> stops one job (checkpoint's runner's, or a gateway job
48// through the gateway); /check-diff
49// compares checkpoints; /check-list also opens a pane (Checkpoints) whose
50// rows diff against now or resume from that checkpoint.
51// - Authorization references (0.7.0): a tool.check observer spools Claude
52// Code's permission verdict for each real call a settings rule, a hook or
53// an ask decided (decision, rule, deciding hook; the reason text goes only
54// to the spool, where Python keeps the gateway's right names from it), and
55// after an ask or a deny the gate spools what came of the call. It returns
56// the verdict beneath unchanged; a failure is skipped (the verdict stands).
57//
58// State lives on disk (CHECKPOINT_HOME, as fast.sh and state.py read it);
59// module variables only hold what one process saw (the running turn, the
60// calls in flight).
61import type { EngineInterface, Register } from 'claude-code'
62import {
63 ackText, allowedTool, cancelDenyReason, clockOf, denyReason, GATE_FAILED, insideWorkDir, jobLive, MAX_DENIES,
64 NORESUME_FAILED, parseCancelArgs, parseSelfArgs, parseSelfPause, pluginDataDir, RESUME_PROMPT, rowText,
65 RUNTIME_WORDS, safeId, statusOf, TRACKER_TOOLS, TYPED_ORIGINS, typedResumes,
66} from './logic.ts'
67import type { Call, CheckpointRow, Job } from './logic.ts'
68
69type E = EngineInterface
70
71const STATUS_EVERY_MS = 2000
72const STATUS_SOON_MS = [300, 1000] // after the session start: SessionStart has recorded the env by then
73const INGEST_EVERY_MS = 20000
74const RESUME_EVERY_MS = 250
75const GW_ACK_WAIT_MS = 1500 // how long the pause ack waits for the gateway's per-job outcomes
76const PANE = 'check-checkpoints'
77
78let turnId: string | undefined // the main loop's running turn (turn.start without agentId)
79let denies = 0 // calls denied while paused in this turn
80let pending = false // a pause is being written: the gate holds meanwhile
81let memoryOnly = false // the pause flag couldn't be written: the gate holds anyway
82let lastStatus: string | undefined // the status line drawn ($.ui.status): attention states only
83let modeLabel: string | undefined // the footer's mode label drawn (SessionMode): the normal states
84let contractAt: string | undefined // the gateway's discovery file, as env.sh found it (the home doesn't change)
85let home: string | undefined
86let ingestedAt = 0 // the spool index's mtime when the timer last had it ingested
87let ingesting = false
88const inflight = new Map<string, Call>()
89let resumeDue = false // /check-resume lifted the pause: the resume prompt goes in from the timer
90let canceling = false // /check-cancel runs: the gate holds the session's tool calls until it is done
91let paneTask = '' // the Checkpoints pane: whose checkpoints, the rows, and what a press showed
92let paneRows: CheckpointRow[] = []
93let paneNote: string[] = []
94let gwNote = '' // the gateway's pause outcomes that came after the ack: the timer logs them
95const verdicts = new Map<string, string>() // tool_use_id -> an ask or deny tool.check saw (until its call returns)
96const VERDICTS_MAX = 200
97// Claude Code's own text for a call it refused (2.1.301): a deny rule or
98// hook, and the person's No at the permission prompt.
99const REFUSAL_RE = /^(Permission to use .{1,400} has been denied|The user doesn't want to proceed with this tool use)/
100
101async function stateHome($: E): Promise<string> {
102 if (home !== undefined) return home
103 const h = await $.env.get('CHECKPOINT_HOME')
104 const x = await $.env.get('XDG_STATE_HOME')
105 const u = await $.env.get('HOME')
106 home = h ? h : x ? `${x}/checkpoint` : `${u ?? ''}/.local/state/checkpoint`
107 return home
108}
109
110async function flagPath($: E): Promise<string> {
111 return `${await stateHome($)}/flags/${safeId(await $.session.id())}`
112}
113
114async function isPaused($: E): Promise<boolean> {
115 if (pending || memoryOnly) return true
116 return $.fs.exists(await flagPath($))
117}
118
119/** The runtime env SessionStart recorded for this session (rt/<session>), or ''. */
120async function recordedEnv($: E): Promise<string> {
121 try {
122 return (await $.fs.read(`${await stateHome($)}/rt/${safeId(await $.session.id())}`)).trim()
123 } catch {
124 return ''
125 }
126}
127
128/** Where env.sh finds the runtime: the env SessionStart recorded, else (the
129 * mod's session.start runs before that hook) this copy's data folder. */
130async function runtimeEnv($: E): Promise<Record<string, string>> {
131 const envd = await recordedEnv($)
132 if (envd) return { CHECKPOINT_ENVD: envd }
133 const cfg = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) ?? ''}/.claude`
134 const data = pluginDataDir($.plugin.root, $.plugin.name, cfg)
135 return data ? { CHECKPOINT_PLUGIN_DATA: data } : {}
136}
137
138/** hooks/env.sh ARGS: the shell half that needs no Python. */
139async function sh($: E, args: readonly string[], timeoutMs = 5000): Promise<{ ok: boolean; out: string; code: number }> {
140 try {
141 const r = await $.process.run(['/bin/sh', `${$.plugin.root}/hooks/env.sh`, ...args], {
142 env: args[0] === 'flag' || args[0] === 'contract-path' ? {} : await runtimeEnv($),
143 timeoutMs,
144 })
145 return { ok: r.exitCode === 0, out: r.exitCode === 0 ? r.stdout : `${r.stderr}${r.stdout}`.trim(), code: r.exitCode }
146 } catch (err) {
147 return { ok: false, out: String(err).slice(0, 200), code: -1 }
148 }
149}
150
151/** noresume/<session>: the prompt being submitted isn't the person's, so the
152 * classic hook must not resume (env.sh flag noresume, else written here).
153 * False when neither could write it. */
154async function markNoresume($: E, sid: string): Promise<boolean> {
155 if ((await sh($, ['flag', 'noresume', sid])).ok) return true
156 try {
157 await $.fs.write(`${await stateHome($)}/noresume/${safeId(sid)}`, `${new Date().toISOString()} mod\n`)
158 return true
159 } catch {
160 return false
161 }
162}
163
164/** One of the mod's events into the session's spool (env.sh spool). The
165 * process is started here, synchronously, while the calling hook still runs
166 * (a `$` call begun after a hook returned is refused); nobody waits for it,
167 * and it never throws: the classic hooks record the same session, so a lost
168 * mod event is a gap, not a failure to act on. */
169function spool($: E, sid: string, event: string, data: Record<string, unknown>): void {
170 try {
171 $.process.run(['/bin/sh', `${$.plugin.root}/hooks/env.sh`, 'spool', sid, event], {
172 stdin: JSON.stringify({ hook_event_name: event, session_id: sid, ...data }),
173 timeoutMs: 5000,
174 }).catch(() => undefined)
175 } catch {
176 // see above
177 }
178}
179
180/** Every INGEST_EVERY_MS: when the spool index changed since the last time,
181 * Python moves it into the journal (one at a time). */
182async function ingestTick($: E): Promise<void> {
183 if (ingesting) return
184 try {
185 const sid = await $.session.id()
186 const st = await $.fs.stat(`${await stateHome($)}/spool/${safeId(sid)}/index`).catch(() => null)
187 if (!st || st.mtimeMs <= ingestedAt) return
188 if (!(await runtimeReady($))) return
189 ingesting = true
190 const r = await sh($, ['run', 'ingest', sid], 30000)
191 if (r.ok) ingestedAt = st.mtimeMs
192 } catch {
193 // the next tick, or the next classic Python moment
194 } finally {
195 ingesting = false
196 }
197}
198
199/** A recording failure this session hit (journal-errors/ or spool-errors/). */
200async function recordingFailed($: E): Promise<boolean> {
201 const h = await stateHome($)
202 const sid = safeId(await $.session.id())
203 return (await $.fs.exists(`${h}/journal-errors/${sid}`)) || (await $.fs.exists(`${h}/spool-errors/${sid}`))
204}
205
206/** The runtime for this session's plugin version: 'ready' (its env is built),
207 * 'older' (an update's env is still building, and Python runs on the newest
208 * previous env that finished: hooks/lib.sh ck_older, which `env.sh run`
209 * uses), 'missing' (nothing built yet: the first run), or undefined (no
210 * session start recorded which env). */
211async function runtimeState($: E): Promise<'ready' | 'older' | 'missing' | undefined> {
212 const envd = await recordedEnv($)
213 if (!envd) return undefined
214 if (await $.fs.exists(`${envd}/.ready`)) return 'ready'
215 const cut = envd.lastIndexOf('/')
216 const envs = cut > 0 ? envd.slice(0, cut) : ''
217 if (!envs) return 'missing'
218 try {
219 for (const d of await $.fs.list(envs)) {
220 // the build folders (`<hash>.building`, `.break`) have a dot; envs don't
221 if (d.kind !== 'dir' || d.name.includes('.') || `${envs}/${d.name}` === envd) continue
222 if ((await $.fs.exists(`${envs}/${d.name}/.ready`)) &&
223 (await $.fs.exists(`${envs}/${d.name}/.pixi/envs/default/bin/python3`))) return 'older'
224 }
225 } catch {
226 // no envs folder yet
227 }
228 return 'missing'
229}
230
231/** Can Python answer now (this version's env, or an older one while an
232 * update's env builds)? undefined: no session start recorded which env. */
233async function runtimeReady($: E): Promise<boolean | undefined> {
234 const st = await runtimeState($)
235 return st === undefined ? undefined : st !== 'missing'
236}
237
238/** What the runtime is doing as env.sh says it (a RUNTIME_WORDS word), or ''. */
239async function shellState($: E): Promise<string> {
240 const r = await sh($, ['state'])
241 const w = r.ok ? r.out.trim() : ''
242 return RUNTIME_WORDS.includes(w) ? w : ''
243}
244
245async function refreshStatus($: E): Promise<void> {
246 let s: { text: string; warn: boolean }
247 try {
248 const paused = await isPaused($)
249 let runtime = ''
250 if (!paused) {
251 runtime = (await runtimeState($)) ?? ''
252 // Not ready by its files, or not recorded yet (SessionStart writes rt/<session>
253 // after the mod's session.start): the shell says building, failed, no pixi or
254 // ready, so the first draw never says "recording" while the runtime builds.
255 if (runtime !== 'ready' && runtime !== 'older') runtime = (await shellState($)) || runtime
256 }
257 s = statusOf({ paused, runtime, recordingFailed: !paused && (await recordingFailed($)) })
258 } catch {
259 s = { text: 'state unknown (/check-doctor)', warn: true }
260 }
261 const line = s.warn ? s.text : undefined
262 const label = s.warn ? undefined : `check: ${s.text}`
263 if (line !== lastStatus) {
264 lastStatus = line
265 $.ui.status(line)
266 }
267 if (label !== modeLabel) {
268 modeLabel = label
269 $.ui.invalidate('ui.render') // the SessionMode hook draws the new label
270 }
271}
272
273async function endTurn($: E): Promise<string> {
274 const t = turnId
275 if (t === undefined) return 'No turn was running.'
276 try {
277 await $.turn.abort({ turnId: t })
278 return 'Stopped the running turn.'
279 } catch {
280 return 'The turn had already ended.'
281 }
282}
283
284async function runningJobs($: E, sid: string): Promise<Job[]> {
285 try {
286 const reg = JSON.parse(await $.fs.read(`${await stateHome($)}/jobs.json`)) as {
287 jobs?: Record<string, { id?: string; name?: string; state?: string; session_id?: string; owner?: string; gone?: boolean }>
288 }
289 return Object.values(reg.jobs ?? {})
290 .filter(j => j.session_id === sid && jobLive(j))
291 .map(j => ({ id: String(j.id ?? ''), name: String(j.name ?? ''), supervisor: j.owner === 'gateway' ? 'gateway' : 'checkpoint' }))
292 } catch {
293 return []
294 }
295}
296
297/** Where the gateway's daemon writes its discovery file (CONTRACT-v1), as
298 * Python's gateway.contract_path finds it: CHECKPOINT_GATEWAY_CONTRACT ('' turns
299 * the integration off), else in the password database's home, which env.sh
300 * reads (a mod can't; $HOME may point elsewhere, and the gateway's daemon
301 * writes it in that home). null when the shell couldn't say. Python's
302 * gateway.discover makes the real checks; this only decides whether to ask it. */
303async function contractPath($: E): Promise<string | null> {
304 const env = await $.env.get('CHECKPOINT_GATEWAY_CONTRACT')
305 if (env !== undefined) return env
306 if (contractAt === undefined) {
307 const r = await sh($, ['contract-path'])
308 const p = r.out.trim()
309 if (!r.ok || !p.startsWith('/')) return null
310 contractAt = p
311 }
312 return contractAt
313}
314
315/** The gateway's half of a pause (Python's gw-pause: POST /v1/pause for this
316 * session's task, each job's on_pause policy): its line for the ack, '' when
317 * there is no gateway. Where the contract file can't be found out, Python
318 * decides (it says nothing when there is no gateway). */
319async function gatewayPause($: E, sid: string): Promise<string> {
320 const path = await contractPath($)
321 if (path === '' || (path !== null && !(await $.fs.exists(path).catch(() => false)))) return ''
322 if (!(await runtimeReady($))) return "Gateway: not told yet (checkpoint's runtime isn't ready); checkpoint tells it once it is."
323 const r = await sh($, ['run', 'gw-pause', sid], 15000)
324 try {
325 return String((JSON.parse(r.out.trim().split('\n').at(-1) ?? '{}') as { text?: unknown }).text ?? '')
326 } catch {
327 return `Gateway: the pause couldn't be sent (exit ${r.code}); checkpoint tries again at its next moment (/check-doctor).`
328 }
329}
330
331function mainBash(): Call[] {
332 return [...inflight.values()].filter(c => c.tool === 'Bash' && c.agentId === undefined)
333}
334
335/** Pause this session now; returns the acknowledgement. */
336async function pause($: E, now: boolean, via: string): Promise<string> {
337 const t0 = Date.now()
338 const sid = await $.session.id()
339 const path = await flagPath($)
340 const already = await $.fs.exists(path)
341 let since = ''
342 let wrote = true
343 let writeError = ''
344 pending = true
345 try {
346 if (already) {
347 since = clockOf(await $.fs.read(path).catch(() => ''))
348 } else {
349 const r = await sh($, ['flag', 'pause', sid, via])
350 if (!r.ok) {
351 try {
352 await $.fs.write(path, `${new Date().toISOString()} ${via}\n`)
353 } catch (err) {
354 wrote = false
355 writeError = `${r.out}; ${String(err)}`.slice(0, 200)
356 }
357 }
358 }
359 } finally {
360 pending = false
361 }
362 memoryOnly = !wrote
363 // The gateway's half starts now, beside the rest (its jobs' pause policies).
364 const gw = !already && wrote ? gatewayPause($, sid).catch(() => '') : null
365 // What runs now, taken before anything ends: ending the turn resolves a
366 // running Bash call (it moves to the background) and takes it out of inflight.
367 const before = [...inflight.values()]
368 const fg = mainBash()
369 let stopped: Call[] = []
370 let nowNote = ''
371 if (now && fg.length) {
372 // Stop the running command first (Python's `pause now`: the process tree
373 // of each guarded foreground call), then end the turn; ending the turn
374 // first would leave the command running as a background shell.
375 if (!(await runtimeReady($))) {
376 nowNote = "`now` couldn't stop it: checkpoint's runtime isn't ready."
377 } else if (!(await sh($, ['flag', 'now', sid])).ok) {
378 nowNote = "`now` couldn't ask for it to be stopped."
379 } else {
380 const r = await sh($, ['run', 'watch-fire', sid, fg[0]!.id], 15000)
381 let mine = 0
382 let others = 0
383 let detail = ''
384 try {
385 const j = JSON.parse(r.out.trim().split('\n').at(-1) ?? '{}') as {
386 killed?: { processes?: number; sigkill?: boolean; survivors?: number }[]; others?: number
387 }
388 mine = (j.killed ?? []).length
389 others = Number(j.others ?? 0)
390 const k = (j.killed ?? [])[0]
391 if (k) {
392 // Exactly what happened to it (Python's kill of its process tree).
393 detail = `${k.processes ?? '?'} process(es) ended: ${k.sigkill ? 'SIGTERM, then SIGKILL for what was left' : 'SIGTERM'}${k.survivors ? `; ${k.survivors} survived SIGKILL` : ''}`
394 }
395 } catch {
396 // no answer: nothing was stopped
397 }
398 stopped = [...(mine ? [{ ...fg[0]!, detail }] : []), ...fg.slice(1, 1 + others)]
399 if (!stopped.length) nowNote = "`now` couldn't stop it (/check:status says why)."
400 }
401 }
402 const turn = await endTurn($)
403 const jobs = await runningJobs($, sid)
404 const stoppedIds = new Set(stopped.map(c => c.id))
405 const calls = [...stopped, ...before.filter(c => !stoppedIds.has(c.id))]
406 void refreshStatus($)
407 if (!already && wrote) void sh($, ['run', 'sync-pause', sid], 30000) // session record + checkpoint
408 // The gateway's outcomes go into the ack when they come in time; later
409 // ones are logged by the timer (the turn has already ended either way).
410 let gateway = ''
411 if (gw) {
412 const late = Symbol('late')
413 const got = await Promise.race([gw, $.clock.sleep(GW_ACK_WAIT_MS).then(() => late)])
414 if (got === late) {
415 gateway = 'Gateway: its jobs of this task are being paused (their pause policies); what happened follows in a moment.'
416 void gw.then(t => {
417 if (t) gwNote = t
418 })
419 } else {
420 gateway = String(got)
421 }
422 }
423 return ackText({ already, pausedSince: since, wrote, writeError, turn, calls, stopped, nowNote, jobs, gateway, tookMs: Date.now() - t0 })
424}
425
426/** Checkpoint now (Python's save: fsynced before it says saved). Never pauses. */
427async function save($: E, why: string): Promise<string> {
428 if (!(await runtimeReady($))) return "checkpoint: NOT saved: checkpoint's runtime isn't ready yet (first-time setup); what happens meanwhile waits in the spool."
429 const sid = await $.session.id()
430 const r = await sh($, ['run', 'save', sid, ...(why.trim() ? [why.trim()] : [])], 60000)
431 const out = r.out.trim()
432 return out ? `checkpoint: ${out}` : `checkpoint: NOT saved (exit ${r.code}); /check-doctor says why.`
433}
434
435async function listCheckpoints($: E): Promise<string> {
436 if (!(await runtimeReady($))) return "checkpoint: checkpoint's runtime isn't ready yet (first-time setup)."
437 const r = await sh($, ['run', 'list', await $.session.id()], 30000)
438 return r.out.trim() || `checkpoint: listing failed (exit ${r.code}); /check-doctor says why.`
439}
440
441/** The immediate /check-resume [checkpoint]: Python reconciles and lifts the
442 * pause (the resume stays pending), then a prompt goes in so the task goes on
443 * at once, the classic hook adding the reconciliation to it. */
444async function resumeNow($: E, ref: string): Promise<string> {
445 const held = memoryOnly
446 if (!(await runtimeReady($))) {
447 if (held) {
448 memoryOnly = false
449 void refreshStatus($)
450 return 'checkpoint: resumed (the pause was held in memory only); without the runtime there is no reconciliation: check the files and anything that was running before going on.'
451 }
452 return "checkpoint: /check-resume reconciles first, and checkpoint's runtime isn't ready yet (first-time setup). Type a message to resume without it (no resume notes), or try again once the setup is done (the footer says recording)."
453 }
454 const sid = await $.session.id()
455 const r = await sh($, ['run', 'resume-now', sid, ...(ref.trim() ? [ref.trim()] : [])], 60000)
456 const out = r.out.trim()
457 if (!r.ok) return `checkpoint: ${out || `resume failed (exit ${r.code})`}`
458 memoryOnly = false
459 void refreshStatus($)
460 const goes = held || !out.startsWith("This session wasn't paused")
461 // A prompt submitted inside a command's hook never entered (measured, 2.1.301:
462 // the session is busy with the command until the hook returns, and a $ call
463 // left for later is refused): the session.start timer sends it.
464 if (goes) resumeDue = true
465 return `checkpoint: ${out}${goes ? ' The task goes on now (checkpoint sends the resume prompt).' : ''}`
466}
467
468/** From the session.start timer: the resume prompt /check-resume asked for,
469 * and the gateway's pause outcomes that came after the ack. */
470function sendResume($: E): void {
471 if (gwNote) {
472 const note = gwNote
473 gwNote = ''
474 try {
475 $.ui.log(`checkpoint: ${note}`.replace(/\n/g, ' '))
476 } catch {
477 // the ack said they would follow; /check:status shows the jobs
478 }
479 }
480 if (!resumeDue) return
481 resumeDue = false
482 try {
483 $.prompt.submit({ text: RESUME_PROMPT }).catch(() => {
484 $.ui.log('checkpoint: sending the resume prompt failed; send any message to continue (the resume notes come with it).')
485 })
486 } catch {
487 $.ui.log('checkpoint: sending the resume prompt failed; send any message to continue (the resume notes come with it).')
488 }
489}
490
491/** The immediate /check-cancel: the task ends (Python: the running command,
492 * then its jobs by policy, all at once, then the record), and so does the
493 * running turn. The gate holds every tool call from the start until it is
494 * done: while Python stops things the model can't act (a job_start then
495 * would escape the cancel). With interrupt the running command is stopped
496 * before the turn ends (ending it first would leave it running in the
497 * background); with detach the turn ends first and the command stays. */
498async function cancelTask($: E, args: string): Promise<string> {
499 if (!(await runtimeReady($))) return "checkpoint: NOT canceled: checkpoint's runtime isn't ready yet (first-time setup); /check:cancel after it is."
500 const { policy } = parseCancelArgs(args)
501 const sid = await $.session.id()
502 let turn = ''
503 canceling = true
504 try {
505 if (policy === 'detach') turn = await endTurn($)
506 const r = await sh($, ['run', 'cancel', sid, ...args.trim().split(/\s+/).filter(Boolean)], 60000)
507 if (policy !== 'detach') turn = await endTurn($)
508 memoryOnly = false
509 void refreshStatus($)
510 const out = r.out.trim()
511 return out ? `checkpoint: ${out} ${turn}` : `checkpoint: cancel failed (exit ${r.code}); /check-doctor says why.`
512 } finally {
513 canceling = false
514 }
515}
516
517async function runPy($: E, what: string, args: readonly string[], timeoutMs = 30000): Promise<string> {
518 if (!(await runtimeReady($))) return "checkpoint: checkpoint's runtime isn't ready yet (first-time setup)."
519 const r = await sh($, ['run', what, await $.session.id(), ...args], timeoutMs)
520 return r.out.trim() || `checkpoint: ${what} failed (exit ${r.code}); /check-doctor says why.`
521}
522
523/** The Checkpoints pane's rows (Python's list-json). */
524async function loadPane($: E): Promise<void> {
525 const r = await sh($, ['run', 'list-json', await $.session.id()], 30000)
526 try {
527 const j = JSON.parse(r.out.trim()) as { task?: string; checkpoints?: CheckpointRow[] }
528 paneTask = String(j.task ?? '')
529 paneRows = (j.checkpoints ?? []).slice(-12)
530 } catch {
531 paneTask = ''
532 paneRows = []
533 }
534}
535
536async function paneDiff($: E, id: string): Promise<void> {
537 const out = await runPy($, 'diff-session', [id, 'now'])
538 paneNote = out.split('\n').slice(0, 40)
539 $.ui.invalidate('ui.render')
540}
541
542async function paneResume($: E, id: string): Promise<void> {
543 paneNote = [await resumeNow($, id)]
544 $.ui.invalidate('ui.render')
545}
546
547async function doctor($: E): Promise<string> {
548 const v = await $.session.version().catch(() => undefined)
549 const surfaces = await $.session.surfaces().catch(() => [] as readonly string[])
550 const sid = await $.session.id().catch(() => '')
551 const r = await sh($, ['doctor', sid], 20000)
552 // What only Python knows (the journal's integrity, the gateway's answers, a
553 // gateway job stuck unknown that the person can release with `forget-job`),
554 // when a runtime answers (`run` exits 75 at once without one).
555 const py = await sh($, ['run', 'doctor-py', sid], 15000)
556 const lines = [
557 'checkpoint doctor',
558 `- Claude Code: ${v?.version ?? '?'}; mods: on (this report is the mod's)`,
559 `- surfaces: ${surfaces.length ? surfaces.join(', ') : 'none'}`,
560 '- immediate commands: /check-pause [now|interrupt], /check-resume [checkpoint], /check-cancel [interrupt|detach], /check-stop <job>, /check-save, /check-list (pane), /check-diff, /check-doctor; /check:pause, /check:save, /check:status and /check:doctor are answered at once when they run',
561 ...(r.out.trim() ? r.out.trim().split('\n').map(l => `- ${l}`) : ['- runtime report: hooks/env.sh doctor printed nothing']),
562 ]
563 if (!r.ok) lines.push(`- runtime report failed (exit ${r.code})`)
564 if (py.ok && py.out.trim()) lines.push(...py.out.trim().split('\n').map(l => `- ${l}`))
565 return lines.join('\n')
566}
567
568export const register: Register = on => {
569 on('session.start', async ($, e, next) => {
570 const started = await next(e)
571 const failed = (name: string) => (err: unknown) =>
572 $.ui.log(`checkpoint: /${name} is unavailable: ${String(err).slice(0, 200)}`, { to: 'debug' })
573 await $.command.register({
574 name: 'check-pause',
575 description: 'Pause this task now, even mid-turn: stops the running turn and holds tool calls (checkpoint)',
576 argumentHint: '[now]',
577 immediate: true,
578 }).catch(failed('check-pause'))
579 await $.command.register({
580 name: 'check-doctor',
581 description: "checkpoint's diagnostics, at once: versions, runtime, gate, state, journal, gateway jobs",
582 immediate: true,
583 }).catch(failed('check-doctor'))
584 await $.command.register({
585 name: 'check-save',
586 description: 'Checkpoint now, even mid-turn: records the task state (synced to disk) without stopping anything (checkpoint)',
587 argumentHint: '[why]',
588 immediate: true,
589 }).catch(failed('check-save'))
590 await $.command.register({
591 name: 'check-list',
592 description: "List the checkpoints of this session's task, in a pane to diff or resume from one (checkpoint)",
593 immediate: true,
594 }).catch(failed('check-list'))
595 await $.command.register({
596 name: 'check-resume',
597 description: 'Resume the paused task now, after reconciling it (or from a checkpoint); works while paused (checkpoint)',
598 argumentHint: '[checkpoint]',
599 immediate: true,
600 }).catch(failed('check-resume'))
601 await $.command.register({
602 name: 'check-cancel',
603 description: 'Cancel this task now: its jobs are stopped (interrupt, default) or left running (detach); history kept (checkpoint)',
604 argumentHint: '[interrupt|detach]',
605 immediate: true,
606 }).catch(failed('check-cancel'))
607 await $.command.register({
608 name: 'check-stop',
609 description: "Stop one detached job now (checkpoint's runner, or a gateway job through the gateway); without a name, list them (checkpoint)",
610 argumentHint: '[job]',
611 immediate: true,
612 }).catch(failed('check-stop'))
613 await $.command.register({
614 name: 'check-diff',
615 description: 'What changed between two checkpoints, or a checkpoint and now (checkpoint)',
616 argumentHint: '[checkpoint [checkpoint|now]]',
617 immediate: true,
618 }).catch(failed('check-diff'))
619 void refreshStatus($)
620 for (const ms of STATUS_SOON_MS) $.clock.after(ms, () => void refreshStatus($))
621 $.clock.every(STATUS_EVERY_MS, () => void refreshStatus($))
622 $.clock.every(INGEST_EVERY_MS, () => void ingestTick($))
623 $.clock.every(RESUME_EVERY_MS, () => sendResume($))
624 return started
625 })
626
627 on('turn.start', async ($, e, next) => {
628 if (e.agentId === undefined) {
629 turnId = e.turnId
630 denies = 0
631 }
632 const sid = await $.session.id().catch(() => '')
633 if (sid) spool($, sid, 'mod.turn.start', { turnId: e.turnId, agent_id: e.agentId, chars: e.text.length })
634 return next(e)
635 })
636
637 on('turn.complete', async ($, e, next) => {
638 if (e.agentId === undefined && e.turnId === turnId) turnId = undefined
639 const sid = await $.session.id().catch(() => '')
640 if (sid) spool($, sid, 'mod.turn.complete', { turnId: e.turnId, agent_id: e.agentId, reason: e.reason, durationMs: e.durationMs })
641 return next(e)
642 })
643
644 // Compactions: the classic PreCompact/PostCompact hooks record them too;
645 // this adds the trigger for a subagent's own and whether it was skipped.
646 on('session.compact', async ($, e, next) => {
647 if (e.trigger === 'precompute') return next(e)
648 const sid = await $.session.id().catch(() => '')
649 if (sid) spool($, sid, 'mod.compact', { phase: 'start', trigger: e.trigger, agent_id: e.agentId })
650 const r = await next(e)
651 if (sid) spool($, sid, 'mod.compact', { phase: 'done', trigger: e.trigger, agent_id: e.agentId, result: r.skip !== undefined ? 'skipped' : 'compacted' })
652 return r
653 })
654
655 // Authorization references: Claude Code's verdict for a real call (one
656 // with a tool_use_id; a $.tool.check query has none) that a rule, a hook or
657 // an ask decided. An observer: the verdict beneath goes back as it is.
658 on('tool.check', async ($, e, next) => {
659 const v = await next(e)
660 const id = e.tool_use_id
661 if (id && (v.decision !== 'allow' || v.rule || v.hook)) {
662 if (v.decision !== 'allow') {
663 if (verdicts.size >= VERDICTS_MAX) verdicts.delete(verdicts.keys().next().value as string)
664 verdicts.set(id, v.decision)
665 }
666 const sid = await $.session.id().catch(() => '')
667 if (sid) {
668 spool($, sid, 'mod.tool.check', {
669 tool_use_id: id, tool: e.tool, agent_id: e.agentId, decision: v.decision, rule: v.rule, hook: v.hook,
670 reason: typeof v.reason === 'string' ? v.reason.slice(0, 600) : undefined,
671 })
672 }
673 }
674 return v
675 })
676
677 // The gate. Fails closed: if it can't tell, the .catch denies.
678 on('tool.call', async ($, e, next) => {
679 const name = String(e.tool)
680 const cancel = canceling
681 if (cancel || (await isPaused($))) {
682 let allowed = allowedTool(name)
683 if (!allowed && TRACKER_TOOLS.includes(name)) {
684 const args = e as unknown as { file_path?: unknown; notebook_path?: unknown }
685 const target = String(args.file_path ?? args.notebook_path ?? '')
686 const cwd = await $.session.cwd()
687 const work = (await $.env.get('CHECKPOINT_WORK_DIR')) || '.work'
688 allowed = insideWorkDir(target, [await $.session.root(), cwd], work, cwd)
689 }
690 if (!allowed) {
691 if (e.agentId === undefined) {
692 denies += 1
693 if (denies > MAX_DENIES) void endTurn($)
694 }
695 // The journal: this call never ran (its intent must not read as in flight).
696 spool($, await $.session.id(), 'mod.tool.denied', { tool_use_id: e.tool_use_id, tool: name, agent_id: e.agentId, why: cancel ? 'canceling' : 'paused' })
697 return { deny: cancel ? cancelDenyReason(e.agentId !== undefined) : denyReason(e.agentId !== undefined) }
698 }
699 }
700 const id = e.tool_use_id ?? `${name}-${Date.now()}-${inflight.size}`
701 const command = name === 'Bash' ? String((e as unknown as { command?: unknown }).command ?? '') : undefined
702 inflight.set(id, { id, tool: name, command, agentId: e.agentId, started: Date.now() })
703 try {
704 const r = await next(e)
705 const asked = e.tool_use_id ? verdicts.get(e.tool_use_id) : undefined
706 if (asked !== undefined && e.tool_use_id) {
707 // What came of an ask (the person's answer, or auto mode's) or a deny:
708 // refused, an error, or a result (Python adds the call's own outcome).
709 verdicts.delete(e.tool_use_id)
710 const answered = typeof (r as { deny?: unknown }).deny === 'string' ? 'deny'
711 : (r as { isError?: unknown }).isError === true ? 'error' : 'result'
712 // Claude Code answers a refused call with an error result in its own
713 // words (a deny rule, the person's No, an ask no one could answer):
714 // that it never ran.
715 const text = String((r as { text?: unknown }).text ?? '')
716 const rejected = answered === 'error' && REFUSAL_RE.test(text)
717 const sid = await $.session.id().catch(() => '')
718 if (sid) spool($, sid, 'mod.tool.answer', { tool_use_id: e.tool_use_id, tool: name, agent_id: e.agentId, answered, rejected })
719 }
720 return r
721 } finally {
722 inflight.delete(id)
723 }
724 }).catch(($, e, next) => (next.called ? next(e) : { deny: GATE_FAILED }))
725
726 // No self-resume, and `pause` typed while a turn runs. A plain prompt
727 // typed mid-turn reaches prompt.submit at once (turnId set; a slash
728 // command waits in the queue instead): pause here, then let it go on, so
729 // the classic hook answers it ("already paused") once the turn is over. A
730 // typed prompt is never dropped: a dropped prompt comes back into the
731 // prompt box.
732 on('prompt.submit', async ($, e, next) => {
733 // Who sent it comes first, slash commands included: a scheduled task or
734 // /loop fires its stored prompt, often a slash command, and that must
735 // not resume a paused session either.
736 const typed = e.isMeta !== true && TYPED_ORIGINS.has(e.origin.kind)
737 const kw = typed && !e.command && e.turnId !== undefined ? parseSelfPause(e.text) : null
738 if (kw) {
739 $.ui.log((await pause($, kw.now, 'typed pause')).replace(/\n/g, ' ')) // a log line is one line
740 return next(e)
741 }
742 const paused = await isPaused($)
743 if (paused && !typed) {
744 const sid = await $.session.id()
745 // Unmarked, the classic hook would take it as the person's and resume:
746 // if the mark can't be written, hold the prompt back (fails closed).
747 if (!(await markNoresume($, sid))) return { drop: NORESUME_FAILED }
748 try {
749 return await next(e)
750 } finally {
751 await sh($, ['flag', 'clear-noresume', sid])
752 }
753 }
754 // The person's message resumes (the classic hook clears the flag): a
755 // plain message, /check:resume or another slash command, but not a pause
756 // keyword (`pause all`) or /check:status. A pause held only in memory
757 // (the flag couldn't be written) is lifted here, as the classic hook
758 // would lift the flag.
759 if (paused && typedResumes(e.text, e.command) && memoryOnly) {
760 memoryOnly = false
761 $.ui.log('checkpoint: resumed this session (its pause was held in memory only: the flag could not be written).')
762 }
763 const entered = await next(e)
764 if (paused) void refreshStatus($)
765 return entered
766 }).catch(($, e, next) =>
767 next.called || (e.isMeta !== true && TYPED_ORIGINS.has(e.origin.kind))
768 ? next(e)
769 : { drop: "checkpoint: held back a message that isn't yours, because checking the pause failed" })
770
771 on('command.run', { command: 'check-pause' }, async ($, e) => {
772 const a = parseSelfArgs(e.args)
773 if (a === null) {
774 return { text: 'checkpoint: /check-pause pauses this session (`now` also stops its running command). For other sessions: /check:pause all, or /check:pause <session>.' }
775 }
776 return { text: await pause($, a.now, '/check-pause') }
777 }).catch(() => ({ text: "checkpoint: /check-pause failed; type `pause` or /check:pause instead (they don't need the mod)." }))
778
779 on('command.run', { command: 'check:pause' }, async ($, e, next) => {
780 const a = parseSelfArgs(e.args)
781 if (a === null) return next(e) // all / <session> / --stop-jobs: the skill and its hook
782 return { text: await pause($, a.now, '/check:pause') }
783 }).catch(($, e, next) => next(e))
784
785 on('command.run', { command: 'check-save' }, async ($, e) => ({ text: await save($, e.args) }))
786 .catch(() => ({ text: "checkpoint: /check-save failed; /check:save asks the runtime instead (it doesn't need the mod)." }))
787
788 on('command.run', { command: 'check:save' }, async ($, e, next) => {
789 if (!(await runtimeReady($))) return next(e) // the skill and its hook say what happened
790 return { text: await save($, e.args) }
791 }).catch(($, e, next) => next(e))
792
793 on('command.run', { command: 'check-list' }, async $ => {
794 const text = await listCheckpoints($)
795 if (await runtimeReady($)) {
796 await loadPane($)
797 paneNote = []
798 void $.ui.open({ id: PANE, title: 'Checkpoints', focus: true, closeOnEscape: true }).catch(() => undefined)
799 }
800 return { text }
801 }).catch(() => ({ text: 'checkpoint: /check-list failed; /check:list asks the runtime instead.' }))
802
803 // The normal states as a dim mode label at the right of the prompt footer
804 // (the status line's warning sign is for a pause or a failure).
805 on('ui.render', { component: 'SessionMode' }, async ($, e, next) =>
806 modeLabel ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, modeLabel] } }) : next(e))
807
808 // The Checkpoints pane: each row diffs against now, or resumes from there.
809 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
810 const { Box, Text, Button } = $.ui.resolve(e)
811 return (
812 <Box flexDirection="column">
813 <Text bold>{paneTask ? `Checkpoints of task ${paneTask}` : 'No task journal yet'}</Text>
814 {paneRows.length === 0 && <Text dimColor>none yet: /check-save makes one (each pause saves one too)</Text>}
815 {paneRows.map(r => (
816 <Box key={`row-${r.id}`} flexDirection="column">
817 <Text>{rowText(r)}</Text>
818 <Box flexDirection="row">
819 <Button key={`diff-${r.id}`} dimColor onPress={() => void paneDiff($, r.id)}>diff with now</Button>
820 <Button key={`resume-${r.id}`} dimColor onPress={() => void paneResume($, r.id)}>resume from here</Button>
821 </Box>
822 </Box>
823 ))}
824 {paneNote.map((l, i) => <Text key={`note-${i}`}>{l || ' '}</Text>)}
825 <Button key="close" role="dismiss" onPress={() => void $.ui.close({ id: PANE })}>close</Button>
826 </Box>
827 )
828 })
829
830 on('command.run', { command: 'check-resume' }, async ($, e) => ({ text: await resumeNow($, e.args) }))
831 .catch(() => ({ text: "checkpoint: /check-resume failed; type a message or /check:resume (they don't need the mod)." }))
832
833 on('command.run', { command: 'check-cancel' }, async ($, e) => ({ text: await cancelTask($, e.args) }))
834 .catch(() => ({ text: 'checkpoint: /check-cancel failed; /check:cancel does the same without the mod.' }))
835
836 on('command.run', { command: 'check-stop' }, async ($, e) => {
837 // runPy's own failures already start with "checkpoint:"; Python's answer doesn't.
838 const out = await runPy($, 'stop-job', e.args.trim().split(/\s+/).filter(Boolean), 30000)
839 return { text: out.startsWith('checkpoint:') ? out : `checkpoint: ${out}` }
840 }).catch(() => ({ text: 'checkpoint: /check-stop failed; /check:stop does the same without the mod.' }))
841
842 on('command.run', { command: 'check-diff' }, async ($, e) => ({
843 text: await runPy($, 'diff-session', e.args.trim().split(/\s+/).filter(Boolean)),
844 })).catch(() => ({ text: 'checkpoint: /check-diff failed; /check:diff does the same without the mod.' }))
845
846 on('command.run', { command: 'check-doctor' }, async $ => ({ text: await doctor($) }))
847 .catch(() => ({ text: 'checkpoint: /check-doctor failed; /check:doctor asks the runtime instead.' }))
848
849 on('command.run', { command: 'check:doctor' }, async $ => ({ text: await doctor($) }))
850 .catch(($, e, next) => next(e))
851
852 on('command.run', { command: 'check:status' }, async ($, e, next) => {
853 const r = await sh($, ['run', 'status', '--session', await $.session.id()], 30000)
854 return r.ok && r.out.trim() ? { text: r.out.trim() } : next(e) // no runtime yet: the skill says so
855 }).catch(($, e, next) => next(e))
856}
857hooks/mod/logic.ts 279 lines1// Pure helpers for checkpoint's mod (register.tsx): ids, what stays allowed
2// while paused, the pause forms it answers, and the texts it shows. No `$`
3// here, so the mod's tests (tests/mod/) can check them directly.
4//
5// Kept in step with the Python side (tests/run_tests.py compares them):
6// ALLOW_WHILE_PAUSED and MAX_DENIES with src/checkpoint/state.py, safeId with
7// state.safe_id and hooks/lib.sh ck_safe, the paths with state.py's layout.
8
9/** Tools a paused session may still call (state.ALLOW_WHILE_PAUSED). */
10export const ALLOW_WHILE_PAUSED: readonly string[] = [
11 'Read', 'Glob', 'Grep', 'TodoWrite', 'TaskList', 'TaskGet', 'TaskCreate', 'TaskUpdate',
12 'mcp__ccd_turn__*',
13 'mcp__plugin_check_jobs__job_status',
14 'mcp__plugin_check_jobs__job_tail',
15 'mcp__plugin_check_jobs__job_stop',
16 'mcp__plugin_check_jobs__checkpoint_note',
17 'mcp__plugin_check_jobs__task_policy',
18 'mcp__plugin_gate_gw__terminal_read',
19 'mcp__plugin_gate_gw__terminal_list',
20 'mcp__plugin_gate_gw__job_status',
21 'mcp__plugin_gate_gw__job_tail',
22 'mcp__plugin_gate_gw__job_list',
23]
24
25/** Writes allowed while paused when they land in the project's work dir (trackers). */
26export const TRACKER_TOOLS: readonly string[] = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']
27
28/** Denied calls in one turn while paused before the turn is ended (state.MAX_DENIES). */
29export const MAX_DENIES = 3
30
31/** Prompt origins that are the person typing (or sending from a phone or the
32 * desktop app): only these resume a paused session or count as a typed pause. */
33export const TYPED_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
34
35/** A session id as a file name: state.safe_id, ck_safe in hooks/lib.sh. */
36export function safeId(s: string): string {
37 return (s || '').replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 128) || '_'
38}
39
40/** fnmatch for the allow list: a pattern is a name, or a prefix ending in `*`. */
41export function allowedTool(name: string): boolean {
42 return ALLOW_WHILE_PAUSED.some(p => (p.endsWith('*') ? name.startsWith(p.slice(0, -1)) : name === p))
43}
44
45/** Is `path` (absolute, or relative to `cwd`) inside `<dir>/<work>/` for one
46 * of `dirs`? Lexical: a path with `..` never is. The classic gate checks the
47 * same call again with symlinks resolved (hooks.allowed_while_paused). */
48export function insideWorkDir(path: string, dirs: readonly string[], work: string, cwd: string): boolean {
49 if (!path || !work || work.includes('..')) return false
50 const abs = path.startsWith('/') ? path : `${cwd}/${path}`
51 if (/(^|\/)\.\.(\/|$)/.test(abs)) return false
52 const norm = abs.replace(/\/+/g, '/').replace(/\/\.(?=\/|$)/g, '')
53 return dirs.some(d => {
54 if (!d || !d.startsWith('/')) return false
55 const w = `${d.replace(/\/+$/, '')}/${work.replace(/^\/+|\/+$/g, '')}/`
56 return norm.startsWith(w)
57 })
58}
59
60/** The pause forms the mod answers itself: this session only, with or without
61 * `now`. `all`, another session's id and `--stop-jobs` are left to the
62 * /check:pause skill and its Python hook (they reach other sessions and jobs). */
63export type SelfPause = { now: boolean }
64
65/** `/check:pause [now|interrupt]`, or the plain keyword `pause [now|interrupt]`
66 * (as transcript.parse_command): `interrupt` is `now`. */
67export function parseSelfPause(text: string): SelfPause | null {
68 const t = (text || '').trim()
69 const cmd = /^\/check:pause(?:\s+(now|interrupt))?\s*$/i.exec(t)
70 if (cmd) return { now: cmd[1] !== undefined }
71 const kw = /^pause(?:\s+(now|interrupt))?[.!]?$/i.exec(t)
72 if (kw) return { now: kw[1] !== undefined }
73 return null
74}
75
76/** How a paused session is resumed, as the status line and the pause ack say
77 * it: a message the person types does, and so does any slash command but
78 * checkpoint's own (`typedResumes`: another plugin's skill asks Claude to
79 * work, which the held gate would refuse anyway), and /check:resume. */
80export const RESUME_HOW = 'type a message (any non-/check command resumes) or /check:resume'
81
82/** Does a prompt the person typed resume a paused session? As
83 * hooks.handle_prompt decides with the flag on disk: `/check:resume` does,
84 * the other /check:* commands (pause, status, doctor, help) and the `pause`
85 * keyword (transcript.KEYWORD_RE) don't, and any other message or slash
86 * command does. The mod uses it for a pause it could only hold in memory. */
87export function typedResumes(text: string, command?: { name: string }): boolean {
88 if (command) {
89 const n = command.name
90 if (n === 'check:resume') return true
91 return !(n.startsWith('check:') || n.startsWith('check-'))
92 }
93 const t = (text || '').trim()
94 const cmd = /^\/check:(pause|resume|status|help|doctor|save|list|diff|cancel|stop|complete|export|forget)(?:\s|$)/.exec(t)
95 if (cmd) return cmd[1] === 'resume'
96 return !/^pause(?:\s+(now|interrupt|all|all\s+now|now\s+all|all\s+interrupt|interrupt\s+all))?[.!]?$/i.test(t)
97}
98
99/** A command's arguments (`/check-pause now`, `/check:pause`): '' or `now`
100 * (`interrupt` is the same: the in-flight command is stopped at once). */
101export function parseSelfArgs(args: string): SelfPause | null {
102 const a = (args || '').trim().toLowerCase()
103 if (a === '') return { now: false }
104 if (a === 'now' || a === 'interrupt') return { now: true }
105 return null
106}
107
108/** `/check-cancel [interrupt|detach] [note]`: the child-work policy for the
109 * task's running work ('' = each job's own, interrupt by default). */
110export function parseCancelArgs(args: string): { policy: '' | 'interrupt' | 'detach'; note: string } {
111 const words = (args || '').trim().split(/\s+/).filter(Boolean)
112 const policy = (words.find(w => w === 'interrupt' || w === 'detach') ?? '') as '' | 'interrupt' | 'detach'
113 return { policy, note: words.filter(w => w !== policy).join(' ') }
114}
115
116/** What the mod submits after an immediate /check-resume, so the task goes on
117 * at once: the classic hook adds the reconciliation and the recovery summary
118 * to it (the resume is pending), and the model reads it as the person's ask. */
119export const RESUME_PROMPT =
120 'Resume the paused task (I typed /check-resume). Do what checkpoint\'s reconciliation lists under "Check first" before anything else, then continue.'
121
122/** A one-line row for a checkpoint in the pane (and its fallback text). */
123export type CheckpointRow = {
124 id: string; time: string; seq_from: number; seq_to: number; line: string; staleness: string
125 stale_why?: string[]; newest?: boolean; reason?: string
126}
127
128export function rowText(r: CheckpointRow): string {
129 const st = r.staleness === 'current' ? 'current' : `stale: ${(r.stale_why ?? []).join(', ')}`
130 return `${r.id} ${r.time} seq ${r.seq_from}-${r.seq_to} ${r.line} [${st}]${r.newest ? ' (newest)' : ''}`
131}
132
133/** What a denied call's model reads (as hooks.gate words it). */
134export function denyReason(isSubagent: boolean): string {
135 return isSubagent
136 ? "Paused by the user (checkpoint). Don't call more tools: stop here and return a short report of what you did and what is left."
137 : "Paused by the user (checkpoint). Don't start new work. Bring the tracker up to date if there is one (Write/Edit to it are allowed), then end your turn with one line saying you're paused."
138}
139
140/** What a call denied while /check-cancel runs reads: the task is ending,
141 * its jobs are being stopped, nothing new may start meanwhile (a job_start
142 * then would escape the cancel). */
143export function cancelDenyReason(isSubagent: boolean): string {
144 return isSubagent
145 ? "The user is canceling this task (checkpoint). Don't call more tools: stop here and return a one-line report."
146 : "The user is canceling this task (checkpoint): its work is being stopped. Don't call more tools or start anything; end your turn with one line."
147}
148
149export const GATE_FAILED =
150 "checkpoint: checking whether this session is paused failed, so this call is held. Try again; /check-doctor says what is wrong."
151
152/** A prompt that isn't the person's, held back from a paused session because
153 * marking it as not theirs (noresume/<session>) failed: let through
154 * unmarked, the classic hook would resume the session. */
155export const NORESUME_FAILED =
156 "checkpoint: held back a message that isn't yours: this session is paused, and marking the message so it doesn't resume the session failed (/check-doctor)"
157
158/** One running tool call the mod saw start (tool.call) and not yet end. */
159export type Call = { id: string; tool: string; command?: string; agentId?: string; started: number; detail?: string }
160
161/** A job from jobs.json as the ack names it (`supervisor`: who runs it). */
162export type Job = { id: string; name: string; supervisor?: 'gateway' | 'checkpoint' }
163
164/** A gateway job's states that mean it may still run (gateway.LIVE_STATES;
165 * unknown, gone or not, counts too: the contract's unknown is never finished): jobs.is_live. */
166export const GATEWAY_LIVE: readonly string[] = ['starting', 'running', 'waiting_input', 'waiting_auth', 'suspended', 'disconnected']
167
168/** Is a jobs.json record live (running or possibly running), as jobs.is_live decides? */
169export function jobLive(j: { owner?: string; state?: string; gone?: boolean }): boolean {
170 if (j.owner === 'gateway') return GATEWAY_LIVE.includes(String(j.state)) || j.state === 'unknown'
171 return j.state === 'running' || j.state === 'stop_failed'
172}
173
174export type PauseReport = {
175 already: boolean
176 pausedSince?: string
177 wrote: boolean
178 writeError?: string
179 turn: string
180 calls: readonly Call[]
181 stopped: readonly Call[]
182 nowNote?: string
183 jobs: readonly Job[]
184 gateway?: string
185 tookMs: number
186}
187
188function quote(cmd: string | undefined): string {
189 const one = (cmd || '').replace(/\s+/g, ' ').trim()
190 return '`' + (one.length > 80 ? one.slice(0, 77) + '...' : one) + '`'
191}
192
193/** The acknowledgement: what was paused, and what still runs. */
194export function ackText(r: PauseReport): string {
195 const lines: string[] = []
196 if (r.already) {
197 lines.push(`checkpoint: this session is already paused${r.pausedSince ? ` (since ${r.pausedSince})` : ''}. ${r.turn}`)
198 } else {
199 lines.push(`checkpoint: paused this session (at once, in ${(r.tookMs / 1000).toFixed(2)} s). ${r.turn}`)
200 }
201 if (!r.wrote) {
202 lines.push(`Couldn't record the pause on disk (${r.writeError || 'unknown error'}): this session's tool calls are held while it runs, but a restart would lose the pause.`)
203 }
204 const stopped = new Set(r.stopped)
205 for (const c of r.stopped) lines.push(`Stopped Bash ${quote(c.command)} (pause now${c.detail ? ': ' + c.detail : ''}).`)
206 const bash = r.calls.filter(c => c.tool === 'Bash' && !stopped.has(c))
207 for (const c of bash) {
208 const who = c.agentId ? 'a subagent\'s ' : ''
209 lines.push(`Still running: ${who}Bash ${quote(c.command)}, left running (Claude Code keeps it as a background shell; stop it from its shell list, or pause with \`now\` next time).${r.nowNote ? ' ' + r.nowNote : ''}`)
210 }
211 const others = r.calls.filter(c => c.tool !== 'Bash')
212 if (others.length) {
213 lines.push(`Also in flight: ${others.map(c => c.tool).join(', ')} (it finishes; nothing new starts).`)
214 }
215 const own = r.jobs.filter(j => j.supervisor !== 'gateway')
216 if (own.length) {
217 lines.push(`Detached jobs keep running (checkpoint's runner): ${own.map(j => `${j.name} (${j.id})`).join(', ')}.`)
218 }
219 if (r.gateway) {
220 lines.push(r.gateway)
221 } else {
222 const gw = r.jobs.filter(j => j.supervisor === 'gateway')
223 if (gw.length) lines.push(`Gateway jobs, not told of the pause (their pause policies didn't apply): ${gw.map(j => `${j.name} (${j.id})`).join(', ')}.`)
224 }
225 lines.push(`Tool calls stay held until you resume: ${RESUME_HOW}.`)
226 return lines.join('\n')
227}
228
229/** What `env.sh state` answers (hooks/lib.sh ck_state). */
230export const RUNTIME_WORDS: readonly string[] = ['ready', 'older', 'building', 'failed', 'nopixi', 'missing']
231
232/** What checkpoint shows about itself, and whether it needs the person's
233 * attention. Claude Code draws every plugin status line ($.ui.status) with a
234 * warning sign (2.1.301: a pinned notice; $.ui.status takes only the text), so
235 * the normal states (recording, setting up, updating) go to the prompt
236 * footer's dim mode labels (`warn: false`), and only a pause, a failure or an
237 * unknown state uses the status line (`warn: true`). `runtime` is a
238 * RUNTIME_WORDS word, '' when nothing could say. */
239export function statusOf(s: { paused: boolean; runtime: string; recordingFailed: boolean }): { text: string; warn: boolean } {
240 if (s.paused) return { text: `paused · ${RESUME_HOW}`, warn: true }
241 switch (s.runtime) {
242 case 'building':
243 case 'missing':
244 return { text: 'setting up its runtime (first run); pausing works', warn: false }
245 case 'failed':
246 return { text: 'runtime setup FAILED · /check-doctor; pausing works', warn: true }
247 case 'nopixi':
248 return { text: 'no runtime: pixi not found · /check-doctor; pausing works', warn: true }
249 case 'ready':
250 case 'older':
251 break
252 default:
253 return { text: 'state unknown (/check-doctor)', warn: true }
254 }
255 if (s.recordingFailed) return { text: 'recording FAILED · /check-doctor', warn: true }
256 // An update's env builds in the background; the previous one records meanwhile.
257 if (s.runtime === 'older') return { text: 'recording · updating its runtime in the background', warn: false }
258 return { text: 'recording', warn: false }
259}
260
261/** Claude Code's data folder for this copy of the plugin (what hooks get as
262 * CLAUDE_PLUGIN_DATA; a mod doesn't): `<config>/plugins/data/<name>-<marketplace>`
263 * for an installed copy (`<config>/plugins/cache/<marketplace>/<name>/<version>`),
264 * else `<configDir>/plugins/data/<name>-inline` (`--plugin-dir`), '' when
265 * neither is known. env.sh uses it only until SessionStart recorded the env
266 * (rt/<session>): the mod's session.start runs before that classic hook. */
267export function pluginDataDir(root: string, name: string, configDir: string): string {
268 const safe = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, '-')
269 const m = /^(\/.*)\/plugins\/cache\/([^/]+)\/([^/]+)\/[^/]+\/?$/.exec(root)
270 if (m) return `${m[1]}/plugins/data/${safe(m[3]!)}-${safe(m[2]!)}`
271 return configDir.startsWith('/') && name ? `${configDir.replace(/\/+$/, '')}/plugins/data/${safe(name)}-inline` : ''
272}
273
274/** HH:MM:SS of an ISO-ish time (`2026-10-19T19:12:00+0200` or `...Z`). */
275export function clockOf(iso: string): string {
276 const m = /T(\d\d:\d\d:\d\d)/.exec(iso || '')
277 return m ? m[1]! : ''
278}
279