Test-only nosy plugin: records every event a third-party mod can hook, for privacy tests of the TUI harness. Never install it for real use.

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/observer.ts 142 lines1import type { Register } from 'claude-code'
2
3// tui-observer: a deliberately nosy third-party plugin for privacy tests.
4//
5// It stands where any installed mod could stand and records everything such a
6// mod can observe: the input of every event (`*`: tool calls, prompt edits and
7// submissions, UI input/press/message/render, http.fetch, process.run, fs.*,
8// state/store, env, settings, sessions, classic hook events, other plugins'
9// nouns), the result each call settles to, the chunks of the two streaming
10// events (turn.step, process.spawn), and the `collector` telemetry stream (the
11// `anthropic` stream is reserved for built-in plugins, so it cannot be hooked).
12//
13// Records go to $TUI_OBSERVER_OUT/seg-NNNNNN.jsonl (the harness creates the
14// directory), one JSON object per line:
15// {"n": 1, "t": <ms>, "ev": "tool.call", "phase": "in"|"out"|"chunk",
16// "trunc": false, "data": "<JSON text of e or of the result>"}
17// A test then searches these files for a canary (tui/scan.py). Without
18// TUI_OBSERVER_OUT the plugin records nothing. clock.* and engine.create are
19// skipped (no data; engine.create has no `$`). The plugin never changes what
20// it sees: every hook passes `e` on unchanged and returns what `next` gave.
21
22const SEG_MAX_RECORDS = 400
23const SEG_MAX_CHARS = 1_000_000
24const STREAMING = new Set(['turn.step', 'process.spawn'])
25
26let outDir: string | null | undefined // undefined: not read yet; null: off
27let maxField = 65536
28let seg = 1
29let lines: string[] = []
30let chars = 0
31let n = 0
32let writing: Promise<unknown> = Promise.resolve()
33
34function safeJson(v: unknown): string {
35 const seen = new WeakSet<object>()
36 try {
37 const s = JSON.stringify(v, (_k: string, x: unknown) => {
38 if (typeof x === 'bigint') return x.toString()
39 if (typeof x === 'function') return '[function]'
40 if (x !== null && typeof x === 'object') {
41 if (seen.has(x)) return '[seen]'
42 seen.add(x)
43 }
44 return x
45 })
46 return s === undefined ? 'null' : s
47 } catch {
48 try {
49 return JSON.stringify(String(v))
50 } catch {
51 return '"[unserializable]"'
52 }
53 }
54}
55
56async function dir($: any): Promise<string | null> {
57 if (outDir === undefined) {
58 try {
59 const v = await $.env.get('TUI_OBSERVER_OUT')
60 outDir = v ? String(v) : null
61 const m = await $.env.get('TUI_OBSERVER_MAX')
62 if (m && Number(m) > 0) maxField = Number(m)
63 } catch {
64 outDir = null
65 }
66 }
67 return outDir ?? null
68}
69
70// True for the observer's own housekeeping, which must pass straight through
71// (recording it would recurse, and waiting on the write queue would deadlock).
72function own(ev: string, e: any): boolean {
73 if (ev === 'fs.write' || ev === 'fs.read' || ev === 'fs.exists' || ev === 'fs.stat') {
74 return typeof outDir === 'string' && typeof e?.path === 'string' && e.path.startsWith(outDir)
75 }
76 if (ev === 'env.get') return typeof e?.name === 'string' && e.name.startsWith('TUI_OBSERVER')
77 return false
78}
79
80async function rec($: any, ev: string, phase: string, data: unknown): Promise<void> {
81 try {
82 const d = await dir($)
83 if (!d) return
84 let json = safeJson(data)
85 const trunc = json.length > maxField
86 if (trunc) json = json.slice(0, maxField)
87 n += 1
88 const line = JSON.stringify({ n, t: Date.now(), ev, phase, trunc, data: json })
89 lines.push(line)
90 chars += line.length
91 const path = `${d}/seg-${String(seg).padStart(6, '0')}.jsonl`
92 const text = lines.join('\n') + '\n'
93 if (lines.length >= SEG_MAX_RECORDS || chars >= SEG_MAX_CHARS) {
94 seg += 1
95 lines = []
96 chars = 0
97 }
98 // Writes are chained so a later snapshot of a segment never lands first.
99 writing = writing.then(() => $.fs.write(path, text)).catch(() => undefined)
100 await writing
101 } catch {
102 // An observer must never break the session.
103 }
104}
105
106export const register: Register = (on) => {
107 on('*', async ($: any, e: unknown, next: any) => {
108 const ev = String(next.event)
109 if (ev === 'engine.create' || ev.startsWith('clock.') || own(ev, e)) return next(e)
110 await rec($, ev, 'in', e)
111 if (STREAMING.has(ev)) return next(e)
112 const r = await next(e)
113 await rec($, ev, 'out', r)
114 return r
115 })
116
117 on('telemetry.*', { to: 'collector' }, async ($: any, e: unknown, next: any) => {
118 await rec($, String(next.event), 'in', e)
119 return next(e)
120 })
121
122 on('turn.step', async function* ($: any, e: unknown, next: any) {
123 const it = next(e)[Symbol.asyncIterator]()
124 while (true) {
125 const r = await it.next()
126 if (r.done) return r.value
127 await rec($, 'turn.step', 'chunk', r.value)
128 yield r.value
129 }
130 })
131
132 on('process.spawn', async function* ($: any, e: unknown, next: any) {
133 const it = next(e)[Symbol.asyncIterator]()
134 while (true) {
135 const r = await it.next()
136 if (r.done) return r.value
137 await rec($, 'process.spawn', 'chunk', r.value)
138 yield r.value
139 }
140 })
141}
142