SLOPSHOPPER

tui-observer

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.

new
A shopper browsing a rack in a slop shop
README

checkpoint

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.

What changes when you install it

  • Every session is recorded. Prompts, tool calls and their outcomes, plan steps, file edits (as hashes), test runs, subagents and session changes go into a journal in ~/.local/state/checkpoint, scrubbed first. You don't ask for it, and no model is involved.
  • A footer label. The first session builds checkpoint's Python with pixi in the background: check: setting up its runtime (first run); pausing works, then check: recording.
  • Trackers. For tasks with several steps, Claude is asked to keep notes in <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.
  • New commands: /check:*, and /check-* forms that act at once, even mid-reply.
  • While paused, a status line: check: paused · type a message (any non-/check command resumes) or /check:resume.
  • After a crash, the next session in that project (within a week) shows: checkpoint: task t_01k8... was interrupted ("migrate the schema..."): claude --resume <id> continues its session, or /check:resume t_01k8... picks it up here.
  • A terminal helper at ~/.local/state/checkpoint/checkpoint.

Nothing is added to your settings or permission rules.

Requirements

  • The latest Claude Code, in the terminal or the desktop app; older versions are not supported. checkpoint uses Claude Code's mods for the immediate /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.
  • The latest pixi (from pixi.sh); older versions are not supported. Update it with 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).
  • git: Claude Code needs git for a GitHub marketplace; pixi can provide it (pixi global install git, with ~/.pixi/bin on your PATH).
  • macOS or Linux. Windows only through WSL 2 (untested). Tested on macOS (Apple silicon) and Debian 13 (ARM64); Intel Macs and Linux x86-64 are in the lock but untested.

Install

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.

First five minutes

  1. Start a new session (or /reload-plugins). The footer goes from check: setting up its runtime (first run); pausing works to check: recording.
  2. Run /check:help and /check:status.
  3. Ask Claude to "run 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.
  4. /check-save before the refactor, then /check-list.
  5. Kill that Claude from another terminal: 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.

Everyday use

CommandWhat 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-resumeResume after reconciling.
`/check:resume <task>\last\<checkpoint>`Pick up a task in this session, or continue from a checkpoint (nothing is rolled back).
/check:statusSessions, pause state, jobs, this project's unfinished tasks, this session's summary.
/check:save [why], /check-saveRecord a checkpoint now (synced to disk); stops nothing. Every pause saves one too.
/check:list, /check-listThe 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-doctorVersions, 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.

Configuration

No settings file; a few optional environment variables, set where Claude Code starts. The ones people use:

VariableDefault
CHECKPOINT_TRACKERSonoff: no tracker instructions, reminders or stop prompts
CHECKPOINT_KEEP_DAYS30days a finished task's journal is kept (0: forever)
CHECKPOINT_KEEP_OPEN_DAYS180the same for unfinished tasks
CHECKPOINT_HOME~/.local/state/checkpointthe state folder

All of them: docs/CONFIGURATION.md.

Security and privacy

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:

  • Local only. It sends nothing over the network; pixi downloads Python from conda-forge once. Resume notes go into Claude's context like any text.
  • Private files. The state folder is 0700, its files 0600.
  • Scrubbed before writing: the journal, the pause-time checkpoints and exports. File contents are never recorded.
  • Fails closed while paused: if checkpoint can't check a call, it is held. Not paused, the call goes ahead and /check:status says so.
  • No new rights. job_start goes through Claude Code's permission check.
  • Reconciling only reads. It never writes your files, the repository or a transcript.

What it doesn't:

  • Scrubbing is pattern-based. Known token shapes and credential forms are masked; a secret in a form no rule knows stays (the password is hunter2 stays). Review an export before sharing it. The full list: docs/dev/SCRUBBING.md.
  • Trackers are Claude's own text and aren't scrubbed.
  • A job's log from checkpoint's own runner is kept raw (capped).

Your data

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:

WhatWhereKept
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 leavesthe state folder7 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 logsjobs/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)
Exportsexports/, or the path you gaveuntil you delete them
Trackers (Claude's notes)<project>/.work/<task>/until you delete them
The runtime (Python)Claude Code's plugin data folderrebuilt 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.

Limits

  • The desktop app is untested (the same mod runs there).
  • Without mods (VS Code, 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.
  • An immediate pause leaves a running Bash command running unless you add now.
  • Whether a call changed something outside the repository is judged from its command and tool name. Anything else with an unknown outcome still says "check its effects".
  • A plugin can't send messages to other sessions: after resume all, each session continues with your next message to it.
  • Gateway jobs: /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.
  • A job still running after its Claude session ends can no longer use the gateway's keys, passwords or git credentials, except from entries with 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.

Update, disable, uninstall

WhatCommand
Updateclaude plugin marketplace update checkpoint, then claude plugin update check@checkpoint; then /reload-plugins or a new session
Turn off for nowclaude plugin disable check@checkpoint (back on: enable)
Uninstallclaude plugin uninstall check@checkpoint (--keep-data keeps the built Python)
Forget the marketplaceclaude 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:

  1. Optional, before uninstalling: ~/.local/state/checkpoint/checkpoint purge --dry-run names the projects it has seen, where .work/ folders may be.
  2. Uninstall, and restart open sessions.
  3. rm -rf ~/.local/state/checkpoint (or your $CHECKPOINT_HOME).
  4. In each project: 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.

Troubleshooting

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.

Status

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.

Develop

Start with CLAUDE.md, then docs/dev/ARCHITECTURE.md. The gateway contract is docs/CONTRACT-v1.md.

Reporting security issues

See SECURITY.md.

Source 1 files
hooks/observer.ts 142 lines
1import 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