SLOPSHOPPER

check

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

newpanespinnerguardcommandstatus
v0.9.0no licenseupdated 2026-10-26p-w-rs/checkpoint/plugins/checkpoint
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · check
│ ┃ check-checkpoints ✕ › fix the failing auth test and add an audit log call │ ┃ No task journal yet │ ┃ none yet: /check-save makes one (each pause ⏺ Read(src/auth.ts) │ ┃ saves one too) ⎿ Read 6 lines │ ┃ [ close ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /check-pause │ ⎿ check: checkpoint: paused this session (at once, in 0.00 s). No │ ⎿ check: Gateway: its jobs of this task are being paused (their pa │ ⎿ check: Tool calls stay held until you resume: type a message (an │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ check: state unknown (/check-doctor)

Draws

Pane · check-checkpoints
No task journal yet none yet: /check-save makes one (each pause saves one too) [ close ]
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 2 files
hooks/mod/register.tsx 857 lines
1// 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}
857
hooks/mod/logic.ts 279 lines
1// 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