Read it, then pass the check at the end to open step 7.
Run handed commands from a pane, one press each
Updated
What this step is for
Some commands should never run without a person: a deploy, a merge to main, a production environment change. In one week Claude handed us 37 commands like these to run by hand, and 7 of them hit the 120-second Bash timeout. This mod collects every command Claude hands you into a side pane with a Run button. Nothing runs until you press it, and the output goes straight back to Claude.
It is also the mod that taught us the last fix linked to this step. Our first version added the output to the conversation quietly, and Claude said nothing until we typed again. The hand-back has to be a prompt of its own.
Do it
mkdir -p ~/claude-mods/run-pane/.claude-plugin ~/claude-mods/run-pane/hooks ~/claude-mods/run-pane/types
plugin.json as in step 3, with "name": "run-pane" and "types": "./types/index.d.ts"; hooks.json
pointing at ./register.tsx. The contract, types/index.d.ts:
export type Handed = { id: string; command: string; cwd: string; blocked: string | null }
declare module 'claude-code' {
interface PluginState {
'run-pane': { items: Handed[] }
}
}
hooks/register.tsx:
import { atom, read, update } from 'claude-code'
import type { Register } from 'claude-code'
import type { Handed } from '../types'
const PANE = 'handed'
const items = atom({ plugin: 'run-pane', key: 'items' } as const, [])
// A handed command: a shell fence whose lines start with "! " (Claude Code's own prefix).
const FENCE = /```(?:bash|sh)?\n([\s\S]*?)```/g
// A command with an unfilled value (<project-id>, YOUR_TOKEN) never gets a Run button.
const UNFILLED = /<[A-Za-z][\w.-]*>|YOUR_\w*|\bxxx\b/
// What goes back into the conversation is scrubbed first.
const redact = (t: string) =>
t.replace(/\b(postgres(?:ql)?|mysql|rediss?):\/\/\S+/gi, '$1://[redacted]')
.replace(/\b(sk|pk)-[A-Za-z0-9_-]{12,}/g, '$1-[redacted]')
.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
export const register: Register = on => {
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'handed', description: 'Show the commands Claude handed you' })
return next(e)
})
on('command.run', { command: 'handed' }, async $ => {
await $.ui.open({ id: PANE, title: 'Handed commands' })
return { text: 'Pane open.' }
})
// Watch each finished answer for handed commands. Observe only: the answer passes unchanged.
on('turn.complete', async ($, e, next) => {
if (e.agentId) return next(e)
const cwd = await $.session.cwd()
const found = [...e.answer.matchAll(FENCE)]
.map(m => m[1]!.trim())
.filter(body => body.startsWith('! '))
.map((body, k): Handed => {
const command = body.replace(/^! /gm, '')
return { id: `${e.turnId}-${k}`, command, cwd, blocked: command.match(UNFILLED)?.[0] ?? null }
})
if (found.length > 0) {
await update($, items, list => [...list, ...found].slice(-10))
$.ui.toast(`${found.length} handed command(s) waiting: /handed`)
}
return next(e)
})
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
const { Box, Text, Code, Button } = $.ui.resolve(e)
const list = await read($, items)
const drop = (id: string) => update($, items, l => l.filter(i => i.id !== id))
// The only path to $.process.run in this module: a person's press.
const run = async (item: Handed) => {
await drop(item.id)
const r = await $.process.run(['bash', '-lc', item.command], { cwd: item.cwd, timeoutMs: 600_000 })
const tail = redact(`${r.stdout}${r.stderr}`).trimEnd().split('\n').slice(-40).join('\n')
const text = `Operator ran: ${redact(item.command)} → exit ${r.exitCode}\n\n\`\`\`\n${tail}\n\`\`\``
// A prompt of its own starts a turn, so Claude answers now. Refused, it waits in the box.
const sent = await $.prompt.submit({ text }).catch(() => undefined)
if (!sent || sent.drop !== undefined) await $.prompt.fill({ text }).catch(() => undefined)
}
return (
<Box flexDirection="column" gap={1}>
{list.length === 0 && <Text dimColor>No handed commands waiting.</Text>}
{list.map(item => (
<Box flexDirection="column">
<Text dimColor>{item.cwd}</Text>
<Code source={item.command} language="bash" />
<Box flexDirection="row" gap={2}>
{item.blocked === null ? (
<Button key={`run-${item.id}`} variant="primary" onPress={() => void run(item)}>Run</Button>
) : (
<Text color="error">Blocked: fill in {item.blocked} first</Text>
)}
<Button key={`dismiss-${item.id}`} role="dismiss" onPress={() => void drop(item.id)}>Dismiss</Button>
</Box>
</Box>
))}
</Box>
)
})
}
What decides the design:
$.process.runruns outside Claude Code’s Bash sandbox. That is the point, because your press is the approval, and it is why the press handler must stay the only caller.- The pane shows the command exactly as it will run. No rewriting.
$.prompt.submit, not$.session.append. An appended row is read at the next request and starts no turn; a submitted prompt starts one as soon as the session is idle.
Check it
claude --plugin-dir ~/claude-mods/run-pane
Ask Claude: Hand me this command to run myself, in a bash fence with the ! prefix: date.
A toast says a command is waiting. Run /handed: the pane shows date with Run. Press
it. Claude replies about the output without you typing anything. Then ask
for ! vercel inspect <deployment-url>: the pane shows it as blocked, with no Run button.
Check what you read in “Run handed commands from a pane, one press each”
Three questions drawn from a larger pool, so no two papers are quite the same. Two right passes it and opens the next step. The questions are public; scoring them needs an account, and the key never leaves the server.
Loading the check…