Read-only view of the reins hold queue inside Claude Code: a status line count and a band above the prompt. It shows the queue and never answers it.

Hold risky actions for your approval · block what must never run · see every agent on one screen · nudge one mid-run
Install · First run · What it does · Before you rely on it · Commands
<img src="assets/cockpit-review-queue.svg" alt="reins watch after an overnight run: two held actions and a worked-around guard under NEEDS YOU, six agents listed with a looping one first, and the detail of a held terraform apply showing its rule, reason, session, how long it has waited and the full proposed command with the matched line lifted above it" width="960">
Local-first. No daemon, no backend, no account. Nothing leaves your machine.
An agent works for hours while you do something else, or overnight. At some point it reaches a deploy, a publish or a push to main. Without reins you have two choices: let it run actions nobody approved, or stay at the terminal to answer every prompt.
reins gives you a third, built from Claude Code hooks. The risky action is parked, the agent carries on with other work, and you decide when you are back:
# the agent, at 02:14, nobody watching
⏺ Bash(npm publish)
[reins] ⏸ HELD npm publish
approve: reins approve ab12cd34 · see all: reins pending
# you, in the morning
$ reins pending
ab12cd34 7h 3b9f2a1c Bash npm publish [bash-npm-publish]
$ reins approve ab12cd34
✓ Approved ab12cd34 (Bash: npm publish)
The approval clears that exact call, once. It permits the retry and does not run anything itself: a still-running agent is told to retry, and a session that has ended needs resuming.
| When | What | Hardness |
|---|---|---|
| Before a tool runs | Hold: park the action in a queue until you approve it | Your call, later |
| Before a tool runs | Guard: deny a command or path, or ask you at the prompt | Hard veto, or your call |
| Any time | Cockpit: every agent and everything waiting on you, on one screen | Observe and decide |
| During the run | Steer: add a one-line note the agent reads at its next tool call | Soft. The model weighs it |
| After each tool | Loop alarm: warn when the same call repeats in a row | Warn |
| When a turn ends | Claim check: compare "done" with the tests and builds the session ran | Report |
| Always | Capture: every run in a SQLite file you own | Observe |
The story behind it: *I Built a Tool to Steer Running AI Agents. It Taught Me Where Their Real Cost Is.*
npm install -g @manishky/reins
reins version
npm install -g github:manishkumar/reins works too. For a local checkout: git clone, then npm install && npm run build && npm link.
Requires Node ≥ 18. Capture needs SQLite, which is built in from Node 22.5. Steering, guards and holds work without it. See compatibility.
cd your-project
reins init # creates .reins/ and merges the hooks into .claude/settings.json
Restart Claude Code in the project so it loads the hooks. reins init merges into your settings and never overwrites them. reins init --print prints the block instead, and reins init --local writes to settings.local.json.
Then add a rule for something you want to sign off, start any task in Claude Code, and open the cockpit in another terminal:
reins guard add bash "npm publish" --hold # park it for approval
reins watch # every agent, and everything waiting on you
Run reins doctor to check the setup: it reports the hooks, the Claude Code version, and whether a hook has run since the settings changed.
A hold rule parks the proposed action in a queue and lets the agent carry on with other work. The action does not run until you approve it.
reins guard add bash "git push" --hold # hold any push for approval
reins pending # what did the agents want to do?
# ab12cd34 7h 3b9f2a1c Bash git push origin main [bash-git-push]
reins approve ab12cd34 # clears that exact call, once
reins deny ab12cd34 --steer "open a PR instead of pushing to main"
An approval is bound to one proposal: the same input, from the same session and directory, one time. A changed retry parks again.
<img src="assets/hold-queue.svg" alt="The full hold-queue loop: a hold rule parks the agent's npm install and shows you a one-line HELD notice with the approve command, reins pending lists it, reins approve signs it off, and the agent's retry runs at its next tool boundary" width="720">
Transports, breach reporting and every caveat: docs/holds.md.
reins watchOne screen shows every agent in the repo and everything waiting on you. From it you approve or deny held actions and steer one agent or all of them. It is the control surface for unattended and headless runs and for several agents at once.
<img src="assets/cockpit-agent.svg" alt="reins watch with a long-running agent selected: the detail pane shows what it was asked, its branch, its claim check verdict, the files it edited by directory, the commands it ran, and its trajectory newest first" width="960">
<img src="assets/cockpit-approve.svg" alt="reins watch approve dialog: 'Approve this exact call, once?' with the rule, reason, session, directory and the exact input, the matched line marked, and y to approve or esc to cancel" width="960">
reins approve. The action is re-read when you press y, and nothing is approved if it changed.reins watch --once prints a plain snapshot for scripts.Keys, layout and caveats: docs/watch.md.
A guard matches a Bash command, a file path or a tool name before the call runs. Besides hold, it can deny the call outright or ask you at the prompt:
reins guard add bash "psql.*production" # deny: the call does not run
reins guard add bash "git push" --ask # ask: Claude Code shows you its permission prompt
reins guard add bash "npm publish" --hold # hold: park it for your later approval
reins guard add path "infra/**" # block writes to paths
reins guard add tool "mcp__stripe__*" --ask # match MCP tools by name
reins guard list
A deny holds under --permission-mode bypassPermissions. reins ships a default denylist: recursive rm outside build and scratch directories, force pushes, git reset --hard, DROP/TRUNCATE, curl … | sh, and writes to .env* and .git/**. reins scan proposes rules from your repo's own manifests, and reins policy upgrade refreshes shipped rules while keeping yours.
<img src="assets/guard-list.svg" alt="reins guard list output: the default denylist plus a hold rule, each with its hardness (deny/ask/hold), pattern, and reason" width="820">
Details, the measured false-positive rate, and reins audit --guards: docs/guards.md.
reins steer "<message>" queues a note for the next tool call. Two steers before that call both arrive. If the agent is already finishing and there is no next tool call, the Stop hook delivers the note, so a queued steer is not lost.
# terminal 1: the agent is mid-task and drifting
⏺ Edit(src/login/flow.ts)
# terminal 2: you, without ending the run
$ reins steer "focus on the token refresh path, leave the login flow alone"
✓ queued — lands at the agent's next tool call
# terminal 1: next tool call, same run, context intact
⏺ [reins — live steering from the developer] folded in
⏺ Edit(src/auth/refresh.ts)
Treat a steer as the detail you forgot to put in the prompt. A steer that contradicts the prompt is weighed down by the model. For a hard "never do X", use a guard.
With several agents in one repo, reins steer asks which one you mean, or takes --session <name>.
<img src="assets/steer-picker.svg" alt="reins steer with several live sessions: a picker lists each agent by name with its status and last tool call, and asks where the steer should land" width="820">
Details and caveats: docs/steering.md.
When the agent runs the same tool with identical input three times in a row, reins adds a warning at that tool call and records the loop. Re-running npm test after each edit does not count; the same call with nothing in between does. reins loops lists the sessions, and "loopThreshold" in .reins/config.json tunes it.
When a turn ends, reins compares what the session did with its own tool calls:
[reins] Claim check: the last test run failed (npm test). reins lastrun lists the calls.
The verdicts are failed, stale (files edited after the last run), unverified (edits and no run), unknown (a run whose result reins cannot see) and verified. It reports and never blocks. The footprint lists what a session edited and ran beside the prompt it was given, and leaves the comparison to you.
What it can and cannot see: docs/claim-check.md.
reins lastrun # a readable account of the last run
reins sessions # recent sessions, by title and name
reins audit # every gate decision
reins report --open # one self-contained HTML file, no network requests
Runs are stored in .reins/runs.db, three tables you can query with SQL. REINS_NO_SQLITE=1 turns capture off; steering, guards and holds keep working.
More: docs/capture.md.
reins init --mod installs a read-only Claude Code mod. /reins opens a pane beside the conversation with every hold and its full input. The status line shows the count. It has no approve button: approving stays in reins approve and reins watch.
Caveats: docs/mods.md.
These are the limits that decide whether reins fits your use. Each links to the full text.
rm -rf foo can delete another way. For a determined or adversarial agent, use OS-level sandboxing. docs/guards.mddefer transport is opt-in, because Claude Code honors it only in print mode and only for a solo tool call. A held action that ran anyway is reported as a HOLD BREACH after the fact. docs/holds.mdPostToolUseFailure. reins init leaves that hook out unless it can confirm your version, and reins doctor tells you whether a hook has actually run. docs/compatibility.mdreins makes zero network calls. No telemetry and no account. Your trajectories live in a SQLite file on your disk that you can read, query, back up or delete. The steering queue, the policy, the hold queue and the decisions are plain files under .reins/, so they work without the database.
.reins/steering.txt is security-sensitive: write access to it equals steering access. Anything that can write that file can inject context into your running agent at its next tool call. reins creates .reins/ as 0700 (owner-only) and git-ignores it, but be deliberate:
.reins/steering.txt is a hijack path into your agent. Treat write access to that file as you'd treat write access to your prompts.0700) and not pointing untrusted writers at it..reins/decided/ is approval access. One-shot hold decisions — approvals and refusals — are files there; anything that can write them can pre-approve (or fake-refuse) a parked action. Same posture, same mitigations.Guards, separately, are not a containment boundary (see what guards are and are not).
A Claude Code mod runs above reins. From Claude Code 2.1.287, a mod's tool.call hook runs before any PreToolUse command hook, reins included (measured in docs/mods-probe.md). A mod can rewrite a command before reins reads it, in which case reins matches, parks and approves the rewritten command, the one that would run. A mod can also answer a tool call itself, and then no PreToolUse hook runs and reins sees nothing. A mod is code you installed in your own Claude Code, so this is the same trust as any other local code. reins does not detect it.
A crashing hook fails open (the agent proceeds) so a bug in reins can never wedge your agent — which also means guards are best-effort if the hook itself errors.
reins init [--print|--local] Set up .reins/ and wire (or print) the hooks
reins init --mod Also install the read-only status mod
reins init --failure-hook Write PostToolUseFailure when the Claude Code
version can't be read (needs 2.0.56+)
reins uninstall [--purge] Remove the hooks (--purge also drops .reins/)
reins doctor Diagnose your setup
reins steer "<msg>" [--replace] Queue steering for the next tool call (appends;
several live agents + a TTY → a picker asks which)
reins steer "<msg>" --session <id|name> Target one agent (id/prefix/name/mnemonic)
reins steer "<msg>" --broadcast Skip the picker; whichever agent moves next gets it
reins steer [--clear] Show / clear pending steering
reins name <session> "<label>" Name a session; --clear reverts to the auto mnemonic
reins guard list|add|remove|reset (add takes --ask to escalate, --hold to park)
reins scan [--accept] Propose rules for what THIS repo can destroy
reins policy upgrade [--apply] Refresh shipped rules, keeping yours and your edits
reins policy version Your policy generation vs the shipped one
reins pending List actions parked by hold rules
reins approve <id> Approve a parked action (one-shot, exact call)
reins deny <id> [--steer "..."] Refuse a parked action, optionally steer instead
reins audit [session] [--json] Every gate decision (deny/ask/hold/allow/breach/bypass)
reins audit --guards [--json] Were the guards right? Every denial ever recorded,
scored: stale rules, and vetoes worked around anyway
reins lastrun [session] Readable account of a run (id prefix or name)
reins sessions [-n N] List recent sessions (with names)
reins watch [-n SECS] [--once] [--quiet] Cockpit: approve/deny holds, steer agents
reins report [--open] [-o FILE] Self-contained local HTML report of all runs
reins loops Sessions where the agent looped
reins hook pre-tool|post-tool|post-tool-failure|stop (invoked by Claude Code, not you)
| Steering | Delivery, the two caveats, several agents in one repo |
| Guards | Rules, --ask, the default denylist, what guards cannot do, audit --guards, scan, policy upgrade |
| Holds | The approval queue, transports, one-shot approvals, HOLD BREACH |
| The cockpit | reins watch: panes, keys, approving, narrow terminals |
| Claim check and footprint | Verdicts, and what the check can and cannot see |
| Capture and the report | lastrun, sessions, report, the SQLite schema |
| The status mod | The /reins pane inside Claude Code |
| Compatibility | Node versions, Claude Code versions, the PostToolUseFailure hook |
| How it works | The hooks, the files under .reins/, testing a hook by hand |
| SPEC.md | File formats and decision semantics, vendor-neutral |
See CONTRIBUTING.md. Keep it small and sharp.
MIT · no daemon, no backend, no account — just hooks, and files you own
hooks/register.tsx 259 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Held } from '../types'
5
6/**
7 * The reins hold queue, shown inside Claude Code. Read-only on purpose.
8 *
9 * It reads `.reins/pending/*.json` (the same plain files `reins pending`
10 * reads). A count sits in the status line and in one line above the prompt,
11 * a new hold is announced once, and `/reins` opens a pane beside the
12 * conversation with each hold's full input. The queue is
13 * the repository's: holds from other sessions are listed too, and this
14 * session's are marked. It has no approve or deny control, and it hooks no
15 * `tool.call`: approvals go through `reins approve` and the `reins watch`
16 * cockpit only (CLAUDE.md, invariant 13). The mod engine skips a hook that
17 * fails or runs out of time, so a gate here would fail open
18 * (docs/mods-probe.md). A view that fails shows nothing, which is safe.
19 */
20
21const PANE = 'reins'
22const PANE_TITLE = 'reins holds'
23/** How much of one input the pane carries. The rest is in `reins watch`. */
24const MAX_LINES = 150
25const MAX_LINE = 400
26/** How far up from the session's root to look for `.reins`. */
27const MAX_UP = 12
28const REFRESH_MS = 5000
29
30const held = atom({ plugin: 'reins-status', key: 'held' } as const, [] as readonly Held[])
31
32/** Text from an agent run is data. Control characters never reach the terminal. */
33export const clean = (s: string, max: number): string => {
34 const flat = s
35 .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)?/g, '')
36 .replace(/[\x00-\x1f\x7f-\x9f---]/g, ' ')
37 .replace(/\s+/g, ' ')
38 .trim()
39
40 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
41}
42
43const describe = (tool: string, input: unknown): string => {
44 if (input !== null && typeof input === 'object') {
45 const o = input as Record<string, unknown>
46 for (const k of ['command', 'file_path', 'path', 'url']) {
47 if (typeof o[k] === 'string') {
48 return o[k]
49 }
50 }
51 }
52
53 return tool
54}
55
56/** One line of an input for the pane: indentation kept, control characters gone. */
57const cleanLine = (s: string): string => {
58 const flat = s
59 .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(\x07|\x1b\\)?/g, '')
60 .replace(/\t/g, ' ')
61 .replace(/[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, ' ')
62 .trimEnd()
63
64 return flat.length > MAX_LINE ? `${flat.slice(0, MAX_LINE - 1)}…` : flat
65}
66
67/** The whole input as lines, and how many lines were left out. */
68export const inputLines = (text: string): { lines: string[]; cut: number } => {
69 const all = text.replace(/\r\n?/g, '\n').split('\n')
70
71 return { lines: all.slice(0, MAX_LINES).map(cleanLine), cut: Math.max(0, all.length - MAX_LINES) }
72}
73
74const clock = (ts: number): string => (ts > 0 ? new Date(ts).toTimeString().slice(0, 5) : '')
75
76/** One pending file as a row, or null when it is not a parked action. */
77export const parse = (text: string): Held | null => {
78 try {
79 const p = JSON.parse(text) as Record<string, unknown>
80 if (typeof p.id !== 'string' || typeof p.tool !== 'string') {
81 return null
82 }
83 const ts = Date.parse(String(p.ts))
84 const { lines, cut } = inputLines(describe(p.tool, p.input))
85
86 return {
87 id: clean(p.id, 16),
88 session: typeof p.session_id === 'string' ? p.session_id : '',
89 tool: clean(p.tool, 24),
90 rule: clean(String(p.rule_id ?? ''), 32),
91 what: clean(describe(p.tool, p.input), 70),
92 ts: Number.isFinite(ts) ? ts : 0,
93 reason: clean(String(p.reason ?? ''), 200),
94 cwd: clean(String(p.cwd ?? ''), 200),
95 lines,
96 cut,
97 }
98 } catch {
99 return null
100 }
101}
102
103/** The directories from `dir` up to the filesystem root, nearest first. */
104export const upward = (dir: string): string[] => {
105 const out: string[] = []
106 let at = dir.replace(/[\\/]+$/, '')
107 for (let i = 0; i < MAX_UP; i++) {
108 out.push(at === '' ? '/' : at)
109 const cut = Math.max(at.lastIndexOf('/'), at.lastIndexOf('\\'))
110 if (cut < 0) {
111 break
112 }
113 at = at.slice(0, cut)
114 if (at === '' || /^[A-Za-z]:$/.test(at)) {
115 out.push(at === '' ? '/' : `${at}\\`)
116 break
117 }
118 }
119
120 return out
121}
122
123/**
124 * The folder holding `.reins`, found the way reins finds it: the nearest one
125 * at or above where the session stands. A session started in a subdirectory
126 * still sees its repository's queue.
127 */
128const pendingDir = async ($: EngineInterface): Promise<string | null> => {
129 for (const dir of upward(await $.session.root())) {
130 const reins = `${dir.replace(/[\\/]$/, '')}/.reins`
131 const found = await $.fs.stat(reins).catch(() => null)
132 if (found?.kind === 'dir') {
133 return `${reins}/pending`
134 }
135 }
136
137 return null
138}
139
140// The ids already announced. Null until the first read, which announces
141// nothing: holds that were waiting when the session started are in the band.
142let known: Set<string> | null = null
143
144const refresh = async ($: EngineInterface): Promise<void> => {
145 let rows: Held[] = []
146 try {
147 const dir = await pendingDir($)
148 if (dir !== null) {
149 const entries = await $.fs.list(dir)
150 const files = entries.filter(f => f.kind === 'file' && f.name.endsWith('.json')).slice(0, 50)
151 const texts = await Promise.all(files.map(f => $.fs.read(`${dir}/${f.name}`).catch(() => '')))
152 const me = await $.session.id().catch(() => '')
153 rows = texts
154 .map(parse)
155 .filter((r): r is Held => r !== null)
156 .map(r => ({ ...r, mine: r.session !== '' && r.session === me }))
157 rows.sort((a, b) => a.ts - b.ts)
158 }
159 } catch {
160 // No .reins/pending here, or it is unreadable: nothing to show.
161 }
162 await update($, held, () => rows)
163 // Claude Code puts the mod's name in front of this, so it does not say "reins" again.
164 $.ui.status(rows.length > 0 ? `${rows.length} held · /reins` : undefined)
165
166 const fresh = known === null ? [] : rows.filter(r => !known?.has(r.id))
167 known = new Set(rows.map(r => r.id))
168 const first = fresh[0]
169 if (first !== undefined) {
170 const rest = fresh.length > 1 ? ` (+${fresh.length - 1} more)` : ''
171 $.ui.toast(`reins: held ${first.tool} [${first.rule}] ${first.what}${rest} · /reins to read it`, { timeoutMs: 8000 })
172 }
173}
174
175export const register: Register = on => {
176 on('session.start', async ($, e, next) => {
177 // Without the command there is no pane. The count and the line above the prompt still work.
178 await $.command
179 .register({ name: 'reins', description: 'Show the reins hold queue in a pane, with each full input' })
180 .catch(() => undefined)
181 await refresh($)
182 $.clock.every(REFRESH_MS, () => refresh($))
183
184 return next(e)
185 })
186
187 // A hold parks during a turn, at a tool call. Reading again here shows it
188 // without waiting for the timer.
189 on('turn.complete', async ($, e, next) => {
190 await refresh($)
191
192 return next(e)
193 })
194
195 // The pane opens only when asked for. Opened unasked on the main screen it
196 // would sit above the prompt and take the place of the conversation.
197 on('command.run', { command: 'reins' }, async $ => {
198 await refresh($)
199 const rows = await read($, held)
200 const opened = await $.ui.open({ id: PANE, title: PANE_TITLE })
201 const count = rows.length === 0 ? 'Nothing is held.' : `${rows.length} held.`
202
203 return { text: opened.isPlaced ? `${count} The reins pane is open.` : `${count} The reins pane could not be shown here: reins pending` }
204 })
205
206 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
207 const { Box, Text } = $.ui.resolve(e)
208 const rows = await read($, held)
209
210 return (
211 <Box flexDirection="column">
212 {rows.length === 0 && <Text dimColor>Nothing is held.</Text>}
213 {rows.map(r => (
214 <Box key={r.id} flexDirection="column" marginBottom={1}>
215 <Text bold>
216 {r.id} · {r.tool}
217 {clock(r.ts) === '' ? '' : ` · parked ${clock(r.ts)}`}
218 {r.mine ? ' · this session' : ''}
219 </Text>
220 <Text dimColor>
221 [{r.rule}] {r.reason}
222 </Text>
223 {r.cwd !== '' && <Text dimColor>in {r.cwd}</Text>}
224 {r.lines.map((line, i) => (
225 <Text key={`${r.id}-${i}`}>{line === '' ? ' ' : line}</Text>
226 ))}
227 {r.cut > 0 && <Text dimColor>… {r.cut} more lines. The whole input is in reins watch.</Text>}
228 <Text dimColor>
229 reins approve {r.id} · reins deny {r.id}
230 </Text>
231 </Box>
232 ))}
233 {rows.length > 0 && <Text dimColor>Answered in a terminal. This pane shows the queue and cannot answer it.</Text>}
234 </Box>
235 )
236 })
237
238 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
239 const rows = await read($, held)
240 if (e.props.hasSurvey || rows.length === 0) {
241 return next(e)
242 }
243 const { Box, Text } = $.ui.resolve(e)
244 const first = rows[0]
245
246 return (
247 <Box flexDirection="column">
248 <Text bold>
249 reins: {rows.length} held, waiting on you
250 </Text>
251 <Text dimColor>
252 {first?.id} {first?.tool} [{first?.rule}] {first?.what}
253 {rows.length > 1 ? ` (+${rows.length - 1} more)` : ''} · /reins to read {rows.length > 1 ? 'them' : 'it'}
254 </Text>
255 </Box>
256 )
257 })
258}
259types/index.d.ts 8 lines1export type Held = { id: string; session: string; tool: string; rule: string; what: string; ts: number; reason: string; cwd: string; lines: string[]; cut: number; mine?: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'reins-status': { held: readonly Held[] }
6 }
7}
8