What a session left behind and a safety net: a /session pane with files changed, commits, denied calls and the longest gap, a retro band on the next start, a…

Shows what a Claude Code session left behind (files changed, commits, denied calls, longest gap between tool calls), shows it again as a band on your next start, lists stray claude -p children and stops one safely, guards dangerous commands and secret writes, keeps /memo notes, recaps before compacting at a % you set, keeps that recap for /handoff, /recap and /lessons, times tasks and shows the cost. Version 0.3.0. Requires Claude Code 2.1.292+ (hooks module). It needs nothing else from this marketplace.
/plugin install session@newkayak12-claude-skills
trophy rides along. From this version, the first interactive session after you install or update this plugin installs trophy (achievements) once, in user scope, if you don't have it. Nothing is sent until you say yes; uninstalling trophy is respected (it is never reinstalled). To opt out beforehand:
mkdir -p ~/.claude/plugins/.newkayak12-trophy-ride.done. Needssh(Windows without one is not covered).
/session opens one pane with two views, switched by the buttons or keys 1 / 2:
[1]: files Claude edited or wrote this session (with +added -deleted from git diff --numstat), commits made (hash and subject), denied tool calls (tool and reason), and the longest gap between two tool calls in one turn.[2]: claude -p children still running below this session, one row each: pid, command, age, [stop]. A line points to /tasks for background shells and subagents; this plugin does not list those. For the full diff of what changed, use /diff./session retro opens the Retro view; if nothing happened yet in this session it shows the summary the last session left.
When a session ends, the summary is saved (also for claude -p runs). The next interactive start shows one row above the prompt: last session: 4 files, 2 commits, 1 denied, longest gap 6m12s with [Retro] and [dismiss]. Dismissing deletes the saved summary. Nothing is written to your repository.
[stop] sends one SIGTERM to one process, after these checks:
No kill -9, no process groups, no pattern kills, no "stop all". A refusal shows a toast and sends nothing; a process that already ended just clears its row.
A wrapper shell and its claude -p child show as one row; [stop] targets the inner claude -p.
The plugin keeps one status line: ⧗ N claude -p child(ren) running while children are alive (updated at the end of each turn), and guard: N denied as soon as the guard denies something. When both apply they are joined with · . The line is cleared only when both are empty; headless runs show nothing. mods still has its own claude -p count, so both can show while both plugins are installed.
Before a Bash command or a Write/Edit runs, the guard checks it against a few rules and asks you first (or blocks, see Modes). It works in every repo, including bypass-permissions sessions.
| Rule | Fires on | Default |
|---|---|---|
recursive-delete | rm with recursive and force flags aimed at /, ~, $HOME, the repo root, a parent of the cwd, or a glob of those | ask |
force-push-protected | git push with -f, --force, --force-with-lease or a +branch refspec to main, master, trunk, the remote default branch, or guard_extra_protected_branches; a bare force push counts when the current branch is protected | ask |
hard-reset | git reset --hard, git clean -f (not -n) | ask |
worktree-dirty-remove | git worktree remove --force on a worktree with uncommitted changes | block |
secret-write | Write/Edit to .env (not .env.example), *.pem, *.key, id_rsa and the like, credentials under .aws, .ssh and similar, or guard_secret_paths | ask |
running-script | Write/Edit to a .sh file a process is running right now | block |
Commands are split before checking, so cd x && rm -rf / is caught and grep -r "rm -rf" . or rm -rf node_modules is not. A command it cannot parse passes.
Set guard_mode in /config:
confirm (default): rules marked ask show Run / Cancel; block rules deny with the reason.deny: ask rules also deny, with no question.off: nothing is checked. This is the escape hatch.Bypass means no native prompts, not no safety net: the guard still asks under bypass. Headless runs (claude -p) have no one to ask, so every rule passes and nothing is logged.
Not a sandbox. python -c, find -delete, dd, shell aliases and scripts that do the same thing are not checked. Secrets are matched by path only; the content of a write is never read.
/session-denials opens a pane of denied calls with [Copy rule], which shows the /permissions rule to add. Each entry holds the tool, a redacted call (tokens, NAME=value pairs and URL passwords are masked; a file call stores only its path), the reason and the source. It lives in plugin storage, newest 200, and nothing is written to your repository. log_enabled turns it off. It also records native permission denials it saw; your own "No" in a native dialog is not logged.
/memo keeps short notes that ride along with your next prompt, so the model sees them again after /compact or /clear.
| Command | Does |
|---|---|
/memo | open the read-only Memo pane (the same text the model gets, counts, and a "move to CLAUDE.md?" hint on notes 14 days or older) |
/memo add [--global] <text> | add a note (project by default) |
/memo list | print the notes |
/memo rm <n> | remove note n as numbered in list |
/memo clear [--global] | clear the project notes, or the global ones |
Two scopes: project (keyed by the repo root) and global. Caps count both together: 8 notes, 280 characters each, 1200 in total; an add past a cap is refused with the reason. The injected block is a fixed header plus [global] notes then [project] notes. It is sent once per conversation and again after /compact or /clear, or after any change, from the next prompt.
Not CLAUDE.md, not auto-memory: notes are not files, are never written to your repository, and are not shared. Put lasting rules in CLAUDE.md; use a memo for something you want pinned for now.
At a context % you choose, the session is first asked for a recap (goal, decisions, state, open items, next direction), then compacted with that recap as the summary instructions, so the compacted context keeps where you were heading.
| Command | What it does |
|---|---|
/smart-compact | print the current threshold (default 70%) |
/smart-compact <10-95> | set it; 60 and 60% both work |
The same value is the Smart compact threshold (%) row in /config. It runs after a main-loop turn that ended with an answer, in interactive sessions only; never for subagents. If the recap fails it does nothing and the built-in auto-compact takes over. Set it below the auto-compact point, or auto-compact fires first.
Every recap (from smart-compact or /handoff) is kept for this project, the last one only.
| Command | What it does |
|---|---|
/handoff | make a recap now, keep it, print it |
/handoff <session> | same, and send it to that session (a peer session name or id) |
/recap | print the project's last recap (under 7 days old) |
/lessons | the "corrected more than once" lines collected from recaps (newest 20); /lessons clear empties them |
On the next start a band shows last recap of this project, <age> with a Recap button that opens it in a pane. Lessons are never written anywhere for you: move the ones worth keeping to CLAUDE.md yourself.
/task <name> starts a task, /task shows it, /task done stops it, /task log prints today's totals by name. The status line shows ⏱ <name> 12m while it runs, refreshed each minute; starting a new task finishes the old one.
After each turn the status line shows the session's cost and the highest rate-limit use ($1.23 · 5h 42%). Set Cost budget (USD) in /config for one toast when the cost reaches it (0 = off).
Off by default (Prompt hint in /config). When on, a typed prompt of 20 characters or less that asks for work (fix/add/만들/고쳐…) and names no path, code or check gets one toast asking for scope or a check, at most every 10 minutes. The prompt itself is never changed.
[stop], the retro band, guard asks in auto and bypass mode, and /memo after /clear. The desktop Code tab is not checked yet.hooks/mod.tsx 458 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import {
5 addCommit, addDeny, addFile, addStep, emptyLedger, endTurn, fmtMs, isEmpty, leftSomething, parseNumstat, statOf, stepSpan, summarize,
6} from './ledger.ts'
7import type { Ledger, Summary } from './ledger.ts'
8import { checkKill } from './kill.ts'
9import type { Verdict } from './kill.ts'
10import { ancestorsOf, fmtAge, matchOrphans, parsePs, PS_ARGV } from './procs.ts'
11import type { Row } from './procs.ts'
12import { costText, statusLine } from './status.ts'
13import { isFresh, fmtAgo, recapKey } from './recap.ts'
14import type { Recap } from './recap.ts'
15import { appendLog, dayOf, parseTask, TASK_KEY, TASK_LOG_KEY, taskStatus, todayLines } from './timer.ts'
16import type { Done, Task } from './timer.ts'
17import { DENIALS_KEY, register as registerGuard } from './guard.tsx'
18import { register as registerMemo } from './memo.tsx'
19import { register as registerHint } from './hint.ts'
20import { RECAP_PANE, register as registerCompact } from './compact.tsx'
21
22const tab = atom({ plugin: 'session', key: 'tab' } as const, 'retro' as 'retro' | 'orphans')
23const band = atom({ plugin: 'session', key: 'band' } as const, false)
24const ledger = atom({ plugin: 'session', key: 'ledger' } as const, emptyLedger())
25
26const statsAtom = atom({ plugin: 'session', key: 'stats' } as const, {} as Record<string, string>)
27
28const lastAtom = atom({ plugin: 'session', key: 'last' } as const, null as Summary | null)
29// The project's last recap, when it is under 7 days old: the band offers it once per start.
30const recapAtom = atom({ plugin: 'session', key: 'recap' } as const, null as { ts: number } | null)
31
32const PANE = 'session'
33const LAST_KEY = 'session.last'
34
35async function openPane($: any) {
36 await refreshStats($)
37 await pollOrphans($)
38 await $.ui.open({ id: PANE, title: 'Session' })
39}
40
41// One `git diff --numstat` for the touched files, never in a draw. No git or a slow one: keep what was there.
42async function refreshStats($: any) {
43 try {
44 const files = (await read($, ledger)).files
45 if (files.length === 0) return
46 const r = await $.process.run(['git', 'diff', '--numstat', '--', ...files], { timeoutMs: 5000 })
47 if (r.exitCode === 0) await update($, statsAtom, () => parseNumstat(r.stdout))
48 } catch {}
49}
50
51const orphansAtom = atom({ plugin: 'session', key: 'orphans' } as const, [] as Row[])
52const engineAtom = atom({ plugin: 'session', key: 'engine' } as const, 0)
53
54// Windows has no `ps -o lstart`: the OS-process section is hidden and no process is run for it.
55const isWindows = async ($: any) => {
56 try {
57 return (await $.env.get('OS')) === 'Windows_NT'
58 } catch {
59 return false
60 }
61}
62
63// The engine pid is the parent of `sh -c 'echo $PPID'`; asked once, kept in state.
64async function engineOf($: any): Promise<number> {
65 let pid = await read($, engineAtom)
66 if (pid > 0) return pid
67 const r = await $.process.run(['sh', '-c', 'echo $PPID'], { timeoutMs: 3000 })
68 pid = Number(String(r.stdout).trim())
69 if (!Number.isInteger(pid) || pid <= 0) return 0
70 await update($, engineAtom, () => pid)
71 return pid
72}
73
74// One ps pass at turn end and pane open, never in a draw. Failure keeps the previous list.
75async function pollOrphans($: any) {
76 try {
77 if (await isWindows($)) return await update($, orphansAtom, () => [])
78 const engine = await engineOf($)
79 if (engine === 0) return
80 const ps = await $.process.run([...PS_ARGV], { timeoutMs: 5000 })
81 if (ps.exitCode !== 0) return
82 await update($, orphansAtom, () => matchOrphans(parsePs(ps.stdout), engine))
83 } catch {}
84}
85
86// A fresh ps and the verdict on `seen` against it. The cached rows are never trusted.
87async function judge($: any, seen: Row): Promise<Verdict> {
88 if (await isWindows($)) return { ok: false, reason: 'stopping is off on Windows' }
89 const engine = await engineOf($)
90 const ps = await $.process.run([...PS_ARGV], { timeoutMs: 5000 })
91 if (ps.exitCode !== 0) return { ok: false, reason: 'could not read the process table' }
92 const rows = parsePs(ps.stdout)
93 return checkKill(seen, rows, engine, ancestorsOf(rows, engine))
94}
95
96// One pid per press, SIGTERM only: judge, ask with the full command, judge again, then the signal.
97// A refusal toasts the reason and sends nothing; a pid that is already gone just clears its row.
98async function stopOrphan($: any, seen: Row) {
99 const say = (text: string) => void $.ui.toast(text, { timeoutMs: 6000 })
100 const refuse = async (v: Extract<Verdict, { ok: false }>) => {
101 if (v.gone) await update($, orphansAtom, rows => rows.filter(r => r.pid !== seen.pid))
102 say(`not stopped: ${v.reason}`)
103 }
104 try {
105 const before = await judge($, seen)
106 if (!before.ok) return await refuse(before)
107 const answer = await $.ui.ask(`Stop pid ${seen.pid}?\n${seen.cmd}`, ['Stop', 'Cancel']).catch(() => undefined)
108 if (answer !== 'Stop') return
109 const after = await judge($, seen)
110 if (!after.ok) return await refuse(after)
111 const r = await $.process.run(['kill', '-TERM', String(after.pid)], { timeoutMs: 5000 })
112 // exit 1 with "No such process" means it ended on its own: that is success
113 if (r.exitCode !== 0 && !/no such process/i.test(String(r.stderr))) say(`not stopped: ${String(r.stderr).trim().slice(0, 120)}`)
114 await pollOrphans($)
115 } catch {
116 say('not stopped: something went wrong, nothing was sent')
117 }
118}
119
120// The running /task, read from the store each time: a reload or another window sees the same task.
121const runningTask = async ($: any): Promise<Task | undefined> => {
122 try {
123 return ((await $.store.get(TASK_KEY)) as Task | undefined) ?? undefined
124 } catch {
125 return undefined
126 }
127}
128
129const paintTask = async ($: any) => {
130 $.ui.status(statusLine({ task: taskStatus(await runningTask($), (await $.clock.now()) as number) }))
131}
132
133// Repaints the elapsed minutes while a task runs; one loop per load (a reload drops the old one).
134let isTicking = false
135const tick = async ($: any) => {
136 if (isTicking) return
137 isTicking = true
138 try {
139 while (await runningTask($)) {
140 await $.clock.sleep(60_000)
141 await paintTask($)
142 }
143 } finally {
144 isTicking = false
145 }
146}
147
148// One toast per load when the session's cost first reaches the /config budget.
149let isBudgetToasted = false
150
151// The ledger never changes what the engine returns: any failure while recording is swallowed.
152const track = async ($: any, change: (l: Ledger) => Ledger) => {
153 try {
154 await update($, ledger, change)
155 } catch {}
156}
157
158// One hooks module per plugin on this build: the guard registers its hooks from here.
159export const register: Register = (on, options) => {
160
161 // Non-interactive sessions (every `claude -p`) get no command and no UI; the ledger still runs below.
162 on('session.start', async ($, e, next) => {
163 if (!e.isInteractive) return next(e)
164 // The guard's log comes back from the store; its command is registered here, as a module may hook an event once.
165 try {
166 const kept = ((await $.store.get(DENIALS_KEY)) as never[] | undefined) ?? []
167 await update($, { plugin: 'session', key: 'guard' } as const, () => ({ denials: kept }))
168 } catch {}
169 await $.command.register({ name: 'memo', description: 'Pin notes the model reads in every conversation of this project' })
170 await $.command.register({ name: 'session-denials', description: 'Calls the guard or the permission rules denied this session' })
171 await $.command.register({ name: 'smart-compact', description: 'Set the context % at which the session is recapped and compacted (/smart-compact 60)' })
172 await $.command.register({ name: 'handoff', description: 'Recap this session now and keep it for the next start; /handoff <session> also sends it there' })
173 await $.command.register({ name: 'recap', description: "Print this project's last recap (smart-compact or /handoff)" })
174 await $.command.register({ name: 'lessons', description: 'Corrections collected from recaps; /lessons clear empties them' })
175 await $.command.register({ name: 'task', description: 'Time a task: /task <name> starts, /task done stops, /task log shows today' })
176 try {
177 const r = (await $.store.get(recapKey(await $.session.root()))) as Recap | undefined
178 const fresh = isFresh(r, (await $.clock.now()) as number) ? { ts: r!.ts } : null
179 await update($, recapAtom, () => fresh)
180 } catch {
181 await update($, recapAtom, () => null)
182 }
183 if (await runningTask($)) {
184 await paintTask($)
185 void tick($).catch(() => {})
186 }
187 // First start of the session: defaults. A later start (hot reload) keeps what is there.
188 await update($, tab, t => t ?? 'retro')
189 await update($, band, b => b ?? false)
190 try {
191 const stored = (await $.store.get(LAST_KEY)) as Summary | undefined
192 // Shown once: the key goes as soon as it is read; lastAtom keeps it for /session retro.
193 if (stored !== undefined) {
194 await update($, lastAtom, () => stored)
195 await update($, band, () => true)
196 await $.store.delete(LAST_KEY)
197 } else {
198 await update($, band, () => false)
199 }
200 } catch {
201 await update($, band, () => false)
202 }
203 await $.command.register({
204 name: 'session',
205 description: 'What this session left behind: files, commits, denied calls; stray claude -p children',
206 })
207 return next(e)
208 }).catch(($, e, next) => next(e))
209
210 // Every tool call: stamp it for step timing, record edits and commits, count a deny from beneath.
211 on('tool.call', async ($, e, next) => {
212 let at = 0
213 try {
214 at = (await $.clock.now()) as number
215 } catch {}
216 const result = await next(e)
217 const r = result as { deny?: string; isError?: boolean; result?: { stdout?: string } }
218 await track($, l => {
219 let out = at > 0 ? addStep(l, at) : l
220 if (r.deny !== undefined) return addDeny(out, e.tool, String(r.deny))
221 if (e.tool === 'Edit' || e.tool === 'Write') out = addFile(out, e.file_path)
222 else if (e.tool === 'NotebookEdit') out = addFile(out, e.notebook_path)
223 else if (e.tool === 'Bash') out = addCommit(out, e.command, r.result?.stdout ?? '', !r.isError)
224 return out
225 })
226 return result
227 }).catch(($, e, next) => next(e))
228
229 // Registered after the ledger, so the ledger wraps the guard and counts its denials too.
230 registerGuard(on, options)
231 registerMemo(on, options)
232 registerCompact(on, options)
233 registerHint(on, options)
234
235 on('turn.complete', async ($, e, next) => {
236 if (e.agentId === undefined) {
237 await track($, endTurn)
238 if ((await $.session.surfaces()).length > 0) {
239 await refreshStats($)
240 await pollOrphans($)
241 // Count of claude -p children below this session, as of this turn end.
242 const n = (await read($, orphansAtom)).length
243 const usage = await $.session.usage().catch(() => undefined)
244 const cost = costText(usage?.cost?.usd, usage?.rateLimits ?? [])
245 $.ui.status(statusLine({ orphans: n, cost, task: taskStatus(await runningTask($), (await $.clock.now()) as number) }))
246 const budget = Number(options.cost_budget_usd ?? 0)
247 if (budget > 0 && !isBudgetToasted && (usage?.cost?.usd ?? 0) >= budget) {
248 isBudgetToasted = true
249 $.ui.toast(`session cost $${usage!.cost!.usd.toFixed(2)} reached the $${budget} budget`)
250 }
251 }
252 }
253 return next(e)
254 }).catch(($, e, next) => next(e))
255
256 on('command.run', { command: 'task' }, async ($, e) => {
257 const cmd = parseTask(e.args)
258 const now = (await $.clock.now()) as number
259 const t = await runningTask($)
260 if (cmd.op === 'show') return { text: t ? taskStatus(t, now) : 'No task running. /task <name> starts one.' }
261 if (cmd.op === 'log') {
262 const log = ((await $.store.get(TASK_LOG_KEY)) as Done[] | undefined) ?? []
263 const lines = todayLines(log, dayOf(now))
264 return { text: lines.length > 0 ? lines.join('\n') : 'No finished task today.' }
265 }
266 let text = ''
267 if (t) {
268 const log = ((await $.store.get(TASK_LOG_KEY)) as Done[] | undefined) ?? []
269 await $.store.set(TASK_LOG_KEY, appendLog(log, { name: t.name, ms: now - t.start, day: dayOf(t.start) }))
270 await $.store.delete(TASK_KEY)
271 text = `done: ${taskStatus(t, now).slice(2)}`
272 }
273 if (cmd.op === 'start') {
274 await $.store.set(TASK_KEY, { name: cmd.name, start: now })
275 text = [text, `started: ${cmd.name}`].filter(Boolean).join('\n')
276 void tick($).catch(() => {})
277 }
278 await paintTask($)
279 return { text: text || 'No task running.' }
280 }).catch(($, e, next) => next(e))
281
282 // The pane opens only from its command.
283 on('command.run', { command: 'session' }, async ($, e) => {
284 if (e.args.trim() === 'retro') await update($, tab, () => 'retro')
285 await openPane($)
286 return { text: 'Session pane opened.' }
287 }).catch(($, e, next) => next(e))
288
289 // Save the summary, then reset in place: /clear ends a session with no new session.start.
290 // No UI here and no git or ps: the terminal may be gone and the time is short. Headless runs too.
291 on('session.end', async ($, e, next) => {
292 try {
293 const l = await read($, ledger)
294 if (!isEmpty(l)) {
295 if (leftSomething(l)) {
296 const day = new Date((await $.clock.now()) as number).toISOString().slice(0, 10)
297 await $.store.set(LAST_KEY, summarize(l, day))
298 }
299 await update($, ledger, () => emptyLedger())
300 await update($, statsAtom, () => ({}))
301 }
302 } catch {}
303 return next(e)
304 }).catch(($, e, next) => next(e))
305
306 // One row above the prompt on the first start after a session that left something.
307 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
308 if (e.props.hasSurvey) return next(e)
309 const last = (await read($, band)) ? await read($, lastAtom) : null
310 const recap = await read($, recapAtom)
311 if (!last && !recap) return next(e)
312 const { Box, Button, Text } = $.ui.resolve(e)
313 const now = (await $.clock.now()) as number
314 return (
315 <Box flexDirection="column">
316 {recap && (
317 <Box borderStyle="round" borderDimColor paddingX={1} gap={1}>
318 <Box flexShrink={1}>
319 <Text wrap="truncate-end">{`last recap of this project, ${fmtAgo(now - recap.ts)}`}</Text>
320 </Box>
321 <Box flexShrink={0} gap={1}>
322 <Button key="recap" label="Recap" onPress={async () => { await $.ui.open({ id: RECAP_PANE, title: 'Recap' }) }} />
323 <Button key="recap-dismiss" label="dismiss" onPress={async () => { await update($, recapAtom, () => null) }} />
324 </Box>
325 </Box>
326 )}
327 {last && (
328 <Box borderStyle="round" borderDimColor paddingX={1} gap={1}>
329 <Box flexShrink={1}>
330 <Text wrap="truncate-end">
331 {`last session: ${last.files} files, ${last.commits} commits, ${last.denied} denied, longest gap ${fmtMs(last.longestMs)}`}
332 </Text>
333 </Box>
334 <Box flexShrink={0} gap={1}>
335 <Button
336 key="retro"
337 label="Retro"
338 onPress={async () => {
339 await update($, tab, () => 'retro')
340 await openPane($)
341 }}
342 />
343 <Button
344 key="dismiss"
345 label="dismiss"
346 onPress={async () => {
347 await update($, band, () => false)
348 await update($, lastAtom, () => null)
349 try {
350 await $.store.delete(LAST_KEY)
351 } catch {}
352 }}
353 />
354 </Box>
355 </Box>
356 )}
357 {await next(e)}
358 </Box>
359 )
360 }).catch(($, e, next) => next(e))
361
362 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
363 const { Box, Button, Text } = $.ui.resolve(e)
364 const current = await read($, tab)
365 const header = (
366 <Box borderStyle="round" borderColor="claude" gap={1}>
367 <Text key="title" bold>session</Text>
368 <Button
369 key="tab-retro"
370 label="Retro [1]"
371 hotkey="1"
372 variant={current === 'retro' ? 'primary' : undefined}
373 onPress={() => update($, tab, () => 'retro')}
374 />
375 <Button
376 key="tab-orphans"
377 label="Orphans [2]"
378 hotkey="2"
379 variant={current === 'orphans' ? 'primary' : undefined}
380 onPress={() => update($, tab, () => 'orphans')}
381 />
382 </Box>
383 )
384 if (current === 'orphans') {
385 if (await isWindows($)) {
386 return (
387 <Box flexDirection="column">
388 {header}
389 <Text dimColor>process list is off on Windows</Text>
390 <Text dimColor>background shells and subagents: see /tasks</Text>
391 </Box>
392 )
393 }
394 const rows = await read($, orphansAtom)
395 const now = (await $.clock.now()) as number
396 return (
397 <Box flexDirection="column">
398 {header}
399 <Text bold color="claude">{`claude -p children (${rows.length})`}</Text>
400 {rows.length === 0 && <Text dimColor>○ none</Text>}
401 {rows.map(r => (
402 <Box key={`o-${r.pid}`} gap={1}>
403 <Text color="warning">●</Text>
404 <Box flexShrink={0}><Text>{String(r.pid)}</Text></Box>
405 <Box flexShrink={1}><Text wrap="truncate-end">{r.cmd}</Text></Box>
406 <Box flexShrink={0}><Text dimColor>{fmtAge(r.start, now)}</Text></Box>
407 <Button key={`stop-${r.pid}`} label="stop" onPress={() => stopOrphan($, r)} />
408 </Box>
409 ))}
410 <Text dimColor>background shells and subagents: see /tasks</Text>
411 </Box>
412 )
413 }
414 const l = await read($, ledger)
415 const stats = await read($, statsAtom)
416 const last = await read($, lastAtom)
417 // Nothing yet this session: show what the previous one left, as `/session retro` promises.
418 if (isEmpty(l) && last) {
419 return (
420 <Box flexDirection="column">
421 {header}
422 <Text bold color="claude">{`last session ${last.day}`}</Text>
423 <Text bold color="claude">{`files (${last.files})`}</Text>
424 {last.fileList.map(f => <Text key={`f-${f}`} wrap="truncate-start">{`· ${f}`}</Text>)}
425 <Text>{`${last.commits} commits · ${last.denied} denied · longest gap ${fmtMs(last.longestMs)}`}</Text>
426 </Box>
427 )
428 }
429 return (
430 <Box flexDirection="column">
431 {header}
432 <Text bold color="claude">{`files (${l.files.length})`}</Text>
433 {l.files.length === 0 && <Text dimColor>○ none</Text>}
434 {l.files.map(f => (
435 <Text key={`f-${f}`} wrap="truncate-start">{`· ${f}${statOf(stats, f) ? ` ${statOf(stats, f)}` : ''}`}</Text>
436 ))}
437 <Text bold color="claude">{`commits (${l.commits.length})`}</Text>
438 {l.commits.length === 0 && <Text dimColor>○ none</Text>}
439 {l.commits.map(c => (
440 <Box key={`c-${c.hash}`} gap={1}>
441 <Text color="success">✔</Text>
442 <Text wrap="truncate-end">{`${c.hash} ${c.subject}`}</Text>
443 </Box>
444 ))}
445 <Text bold color="claude">{`denied (${l.denied.length})`}</Text>
446 {l.denied.length === 0 && <Text dimColor>○ none</Text>}
447 {l.denied.map((d, i) => (
448 <Box key={`d-${i}`} gap={1}>
449 <Text color="error">✘</Text>
450 <Text wrap="truncate-end">{`${d.tool} ${d.reason}`}</Text>
451 </Box>
452 ))}
453 <Text bold color="claude">{`longest gap ${fmtMs(stepSpan(l.steps))}`}</Text>
454 </Box>
455 )
456 }).catch(($, e, next) => next(e))
457}
458hooks/ledger.ts 87 lines1// Pure ledger logic: no engine calls here.
2
3export type Ledger = {
4 files: string[]
5 commits: { hash: string; subject: string }[]
6 denied: { tool: string; reason: string }[]
7 // tool.call timestamps (ms); 0 separates turns
8 steps: number[]
9}
10
11export type Summary = { day: string; files: number; commits: number; denied: number; longestMs: number; fileList: string[] }
12
13const STEPS_MAX = 2000
14const LIST_MAX = 500
15
16export const emptyLedger = (): Ledger => ({ files: [], commits: [], denied: [], steps: [] })
17
18// Worth storing for the next start: steps alone (only reads) are not.
19export const leftSomething = (l: Ledger) => l.files.length + l.commits.length + l.denied.length > 0
20
21export const isEmpty = (l: Ledger) => l.files.length + l.commits.length + l.denied.length + l.steps.length === 0
22
23export const addFile = (l: Ledger, path: string): Ledger =>
24 !path || l.files.includes(path) || l.files.length >= LIST_MAX ? l : { ...l, files: [...l.files, path] }
25
26// Only a `git commit` that exited 0: hash and subject from `[branch hash] subject`.
27export const addCommit = (l: Ledger, command: string, stdout: string, ok: boolean): Ledger => {
28 if (!ok || !/\bgit\s+(?:-\S+\s+)*commit\b/.test(command)) return l
29 const m = /\[[^\]]*?\s([0-9a-f]{7,40})\]\s*(.*)/.exec(stdout)
30 if (!m) return l
31 return { ...l, commits: [...l.commits, { hash: m[1] ?? '', subject: (m[2] ?? '').trim() }].slice(-LIST_MAX) }
32}
33
34export const addDeny = (l: Ledger, tool: string, reason: string): Ledger => ({
35 ...l,
36 denied: [...l.denied, { tool, reason }].slice(-LIST_MAX),
37})
38
39export const addStep = (l: Ledger, at: number): Ledger => ({ ...l, steps: [...l.steps, at].slice(-STEPS_MAX) })
40
41// A turn ends: the gap to the next turn's first call is the person's time, not a step.
42export const endTurn = (l: Ledger): Ledger =>
43 l.steps.length === 0 || l.steps[l.steps.length - 1] === 0 ? l : { ...l, steps: [...l.steps, 0] }
44
45// S4 (05 Task 4) not run: this is the fallback, the longest gap between two tool.call events of one
46// turn. If turn.step timing is proven, replace the timestamps with real step spans here.
47export const stepSpan = (steps: number[]): number => {
48 let best = 0
49 for (let i = 1; i < steps.length; i++) {
50 const a = steps[i - 1] ?? 0
51 const b = steps[i] ?? 0
52 if (a > 0 && b > 0) best = Math.max(best, b - a)
53 }
54 return best
55}
56
57// `git diff --numstat` lines `added<TAB>deleted<TAB>path` -> { path: '+a -d' }; binary files ('-') read +0 -0.
58export const parseNumstat = (text: string): Record<string, string> => {
59 const out: Record<string, string> = {}
60 for (const line of text.split('\n')) {
61 const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line)
62 if (m) out[m[3] ?? ''] = `+${m[1] === '-' ? 0 : m[1]} -${m[2] === '-' ? 0 : m[2]}`
63 }
64 return out
65}
66
67// numstat paths are repo-relative, touched paths absolute: match on the tail.
68export const statOf = (stats: Record<string, string>, path: string): string | undefined => {
69 for (const [rel, s] of Object.entries(stats)) if (path === rel || path.endsWith(`/${rel}`)) return s
70 return undefined
71}
72
73export const fmtMs =(ms: number): string => {
74 const s = Math.round(ms / 1000)
75 const m = Math.floor(s / 60)
76 return m > 0 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
77}
78
79export const summarize = (l: Ledger, day: string): Summary => ({
80 day,
81 files: l.files.length,
82 commits: l.commits.length,
83 denied: l.denied.length,
84 longestMs: stepSpan(l.steps),
85 fileList: l.files,
86})
87hooks/kill.ts 23 lines1import { descendants, matchOrphans } from './procs.ts'
2import type { Row } from './procs.ts'
3
4export type Verdict = { ok: true; pid: number } | { ok: false; reason: string; gone?: boolean }
5
6// What `$.process.run` itself starts for this module: never a target.
7const OWN = /^(ps -A |sh -c echo \$PPID)/
8
9// May `seen` (the row the person saw) be signalled, judged against a FRESH process table?
10// Every refusal is a reason for a toast; none sends anything.
11export const checkKill = (seen: Row, fresh: Row[], engine: number, ancestors: number[]): Verdict => {
12 if (!Number.isInteger(engine) || engine <= 0) return { ok: false, reason: 'the session process is unknown' }
13 if (seen.pid === engine) return { ok: false, reason: 'that is the session itself' }
14 if (ancestors.includes(seen.pid)) return { ok: false, reason: 'that process is above the session' }
15 const now = fresh.find(r => r.pid === seen.pid)
16 if (!now) return { ok: false, reason: 'already gone', gone: true }
17 if (!descendants(fresh, engine).some(r => r.pid === seen.pid)) return { ok: false, reason: 'no longer a child of this session' }
18 if (now.cmd !== seen.cmd || now.start !== seen.start) return { ok: false, reason: 'the process changed since it was listed (pid reused?)' }
19 if (OWN.test(now.cmd)) return { ok: false, reason: 'that is a helper of this plugin' }
20 if (!matchOrphans(fresh, engine).some(r => r.pid === seen.pid)) return { ok: false, reason: 'not a claude -p job' }
21 return { ok: true, pid: seen.pid }
22}
23hooks/procs.ts 56 lines1// Pure process-table logic over `ps -A -o pid=,ppid=,lstart=,command=` text.
2
3export type Row = { pid: number; ppid: number; start: string; cmd: string }
4
5export const PS_ARGV = ['ps', '-A', '-o', 'pid=,ppid=,lstart=,command='] as const
6
7const CLAUDE_P = /\bclaude\b.*\s-p(\s|$)/
8const LINE = /^\s*(\d+)\s+(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{1,2}:\d\d:\d\d\s+\d{4})\s+(.*)$/
9
10// A line that does not parse is skipped.
11export const parsePs = (text: string): Row[] =>
12 text.split('\n').flatMap(line => {
13 const m = LINE.exec(line)
14 return m ? [{ pid: Number(m[1]), ppid: Number(m[2]), start: (m[3] ?? '').replace(/\s+/g, ' '), cmd: m[4] ?? '' }] : []
15 })
16
17// Everything below `engine` in the tree, the engine itself excluded.
18export const descendants = (rows: Row[], engine: number): Row[] => {
19 const below = new Set<number>([engine])
20 for (let grew = true; grew; ) {
21 grew = false
22 for (const r of rows) {
23 if (!below.has(r.pid) && below.has(r.ppid)) {
24 below.add(r.pid)
25 grew = true
26 }
27 }
28 }
29 return rows.filter(r => below.has(r.pid) && r.pid !== engine)
30}
31
32// The pids from `engine` up to the root, engine included.
33export const ancestorsOf = (rows: Row[], engine: number): number[] => {
34 const byPid = new Map(rows.map(r => [r.pid, r]))
35 const out: number[] = [engine]
36 for (let at = byPid.get(engine); at && at.ppid > 0 && !out.includes(at.ppid); at = byPid.get(at.ppid)) out.push(at.ppid)
37 return out
38}
39
40// `claude -p` children of the engine; a wrapper shell and its child are one job: the innermost claude -p is listed,
41// so a SIGTERM reaches claude itself and not only the shell above it.
42export const matchOrphans = (rows: Row[], engine: number): Row[] => {
43 const hits = descendants(rows, engine).filter(r => CLAUDE_P.test(r.cmd))
44 return hits.filter(r => !hits.some(h => h.ppid === r.pid))
45}
46
47// `Wed Oct 8 10:09:00 2026` (local time) against now; '' when the text does not parse.
48export const fmtAge = (start: string, now: number): string => {
49 const t = Date.parse(start)
50 if (Number.isNaN(t) || now < t) return ''
51 const s = Math.floor((now - t) / 1000)
52 if (s < 60) return `${s}s`
53 const m = Math.floor(s / 60)
54 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
55}
56hooks/status.ts 23 lines1// The plugin owns one status line; the running task, the claude -p child count, the guard denial count and the cost share it.
2// Pure: callers pass the result to $.ui.status themselves ($ never crosses an import).
3const parts = { orphans: 0, denied: 0, task: '', cost: '' }
4
5export function statusLine(patch: Partial<typeof parts>): string | undefined {
6 Object.assign(parts, patch)
7 const shown = [
8 parts.task,
9 parts.orphans > 0 ? `⧗ ${parts.orphans} claude -p child(ren) running` : '',
10 parts.denied > 0 ? `guard: ${parts.denied} denied` : '',
11 parts.cost,
12 ].filter(Boolean)
13 return shown.length > 0 ? shown.join(' · ') : undefined
14}
15
16export const deniedCount = () => parts.denied
17
18// `$1.23 · 5h 42%`: the session's cost and the highest rate-limit use, '' when neither is known.
19export function costText(usd: number | undefined, limits: readonly { kind: string; percentUsed: number }[]): string {
20 const top = [...limits].sort((a, b) => b.percentUsed - a.percentUsed)[0]
21 return [usd === undefined ? '' : `$${usd.toFixed(2)}`, top ? `${top.kind} ${Math.round(top.percentUsed)}%` : ''].filter(Boolean).join(' · ')
22}
23hooks/recap.ts 57 lines1// The recap smart-compact and /handoff make, and what is kept of it. Pure: callers do the $ calls.
2
3export const RECAP_PROMPT = `Write a recap of this session that a fresh context can continue from. Use the session's language. Sections:
41. Goal: what the user is trying to achieve
52. Decisions: what was settled, with the reason
63. State: what is done, files touched, what is verified
74. Open: unfinished work, known problems
85. Direction: the next concrete steps
96. Corrections: things the user had to correct more than once, one per line starting with "- "; write "- none" if there were none
10Plain text, no preamble.`
11
12export type Recap = { text: string; ts: number; sessionId: string }
13
14export const RECAP_MAX = 8000
15export const LESSONS_MAX = 20
16export const RECAP_FRESH_MS = 7 * 24 * 60 * 60 * 1000
17
18export const recapKey = (root: string) => `recap.project:${root}`
19export const lessonsKey = (root: string) => `lessons.project:${root}`
20
21export const makeRecap = (text: string, ts: number, sessionId: string): Recap => ({
22 text: text.length > RECAP_MAX ? `${text.slice(0, RECAP_MAX)}\n…(cut)` : text,
23 ts,
24 sessionId,
25})
26
27export const isFresh = (r: Recap | undefined, now: number): r is Recap =>
28 r !== undefined && typeof r.text === 'string' && now - r.ts < RECAP_FRESH_MS
29
30// The "- " lines under the Corrections heading, up to the next numbered heading; "none" is dropped.
31export function corrections(text: string): string[] {
32 const lines = text.split('\n')
33 const start = lines.findIndex(l => /^\s*(6\.|#+)?\s*\**\s*corrections\b/i.test(l))
34 if (start < 0) return []
35 const out: string[] = []
36 for (const line of lines.slice(start + 1)) {
37 if (/^\s*\d+\.\s/.test(line)) break
38 const m = line.match(/^\s*[-*]\s+(.+?)\s*$/)
39 if (m && !/^none\.?$/i.test(m[1]!)) out.push(m[1]!)
40 }
41 return out
42}
43
44// Newest last; an exact duplicate is not added twice; the oldest go past the cap.
45export function mergeLessons(kept: readonly string[], fresh: readonly string[]): string[] {
46 const out = [...kept]
47 for (const l of fresh) if (!out.includes(l)) out.push(l)
48 return out.slice(-LESSONS_MAX)
49}
50
51export const fmtAgo = (ms: number): string => {
52 const m = Math.floor(ms / 60000)
53 if (m < 60) return `${m}m ago`
54 const h = Math.floor(m / 60)
55 return h < 48 ? `${h}h ago` : `${Math.floor(h / 24)}d ago`
56}
57hooks/timer.ts 40 lines1// /task: one running task per session store, a log of finished ones. Pure: callers do the $ calls.
2
3export type Task = { name: string; start: number }
4export type Done = { name: string; ms: number; day: string }
5
6export const TASK_KEY = 'task.current'
7export const TASK_LOG_KEY = 'task.log'
8export const LOG_MAX = 200
9
10export type TaskCmd = { op: 'show' } | { op: 'done' } | { op: 'log' } | { op: 'start'; name: string }
11
12export function parseTask(args: string): TaskCmd {
13 const a = args.trim()
14 if (!a) return { op: 'show' }
15 if (a === 'done' || a === 'stop') return { op: 'done' }
16 if (a === 'log') return { op: 'log' }
17 return { op: 'start', name: a.slice(0, 60) }
18}
19
20export const fmtDur = (ms: number): string => {
21 const m = Math.max(0, Math.floor(ms / 60000))
22 return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
23}
24
25export const dayOf = (ts: number): string => new Date(ts).toISOString().slice(0, 10)
26
27export const taskStatus = (t: Task | undefined, now: number): string => (t ? `⏱ ${t.name} ${fmtDur(now - t.start)}` : '')
28
29export const appendLog = (log: readonly Done[], d: Done): Done[] => [...log, d].slice(-LOG_MAX)
30
31// Today's finished tasks summed by name, longest first, then the total.
32export function todayLines(log: readonly Done[], day: string): string[] {
33 const sums = new Map<string, number>()
34 for (const d of log) if (d.day === day) sums.set(d.name, (sums.get(d.name) ?? 0) + d.ms)
35 if (sums.size === 0) return []
36 const rows = [...sums].sort((a, b) => b[1] - a[1]).map(([n, ms]) => `${fmtDur(ms).padStart(6)} ${n}`)
37 const total = [...sums.values()].reduce((a, b) => a + b, 0)
38 return [...rows, `${fmtDur(total).padStart(6)} total`]
39}
40hooks/guard.tsx 174 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { classify, classifyFile, pushRing, redact, ruleFor, splitCommands } from './guard-logic.ts'
5import type { Denial, Env, Verdict } from './guard-logic.ts'
6import { deniedCount, statusLine } from './status.ts'
7
8const PANE = 'guard-denials'
9export const DENIALS_KEY = 'session.denials'
10const CAP = 200
11
12export const guardAtom = atom({ plugin: 'session', key: 'guard' } as const, { denials: [] as Denial[] })
13const copyAtom = atom({ plugin: 'session', key: 'guardCopy' } as const, '')
14
15type Mode = 'confirm' | 'deny' | 'off'
16type Ctx = { tool: string; call: string; agentId?: string; log: boolean }
17
18let seq = 0
19
20const list = (s: unknown) => String(s ?? '').split(/[,\n]/).map(x => x.trim()).filter(Boolean)
21
22// One entry per stopped call. A broken store drops the entry, never the decision.
23async function record($: any, d: Omit<Denial, 'id' | 'ts'>) {
24 try {
25 const ts = (await $.clock.now()) as number
26 const cur = ((await $.store.get(DENIALS_KEY)) as Denial[] | undefined) ?? []
27 const next = pushRing(cur, { ...d, id: `${ts}-${++seq}`, ts }, CAP)
28 await $.store.set(DENIALS_KEY, next)
29 await update($, guardAtom, () => ({ denials: next }))
30 } catch {}
31}
32
33// A guard stop: log it, tell the person, answer the deny.
34async function stop($: any, ctx: Ctx, reason: string, source: 'guard' | 'declined') {
35 if (ctx.log) await record($, { tool: ctx.tool, call: ctx.call, reason, source, agentId: ctx.agentId })
36 try {
37 $.ui.toast(`guard: ${reason.split('\n')[0]}`, { timeoutMs: 6000 })
38 $.ui.status(statusLine({ denied: deniedCount() + 1 }))
39 } catch {}
40 return { deny: reason }
41}
42
43// The one place a confirm-class verdict becomes a question; a missing or failing ask counts as Cancel.
44async function askOrDeny($: any, v: NonNullable<Verdict>, e: any, next: (e: any) => any, mode: Mode, ctx: Ctx, label: string) {
45 if (mode === 'off') return next(e)
46 if (v.action === 'deny') return stop($, ctx, v.reason, 'guard')
47 if (mode === 'deny') return stop($, ctx, `${v.reason} Set guard_mode=off in /config to allow.`, 'guard')
48 let answer: string | undefined
49 try {
50 answer = await $.ui.ask(`Run \`${label.slice(0, 120)}\`?`, ['Run', 'Cancel'])
51 } catch {}
52 if (answer === 'Run') return next(e)
53 return stop($, ctx, `session: the person declined (${v.rule}).`, 'declined')
54}
55
56// Interactive sessions only: headless agents pass every rule and nothing is logged.
57const live = async ($: any, mode: Mode) => mode !== 'off' && (await $.session.surfaces()).length > 0
58
59const tryRun = async ($: any, argv: string[]) => {
60 try {
61 const r = await $.process.run(argv, { timeoutMs: 5000 })
62 return r.exitCode === 0 ? (r.stdout as string) : undefined
63 } catch {
64 return undefined
65 }
66}
67
68async function envOf($: any, extraBranches: string[]): Promise<Env> {
69 const cwd = (await $.session.cwd()) as string
70 let root: string | undefined
71 let home: string | undefined
72 try {
73 root = (await $.session.root()) as string
74 } catch {}
75 try {
76 home = (await $.env.get('HOME')) as string | undefined
77 } catch {}
78 return {
79 cwd,
80 root,
81 home,
82 extraBranches,
83 git: argv => tryRun($, ['git', ...argv]),
84 pgrep: path => tryRun($, ['pgrep', '-f', path]),
85 }
86}
87
88const callOf = (tool: string, input: any) =>
89 tool === 'Bash' ? redact(String(input?.command ?? '')).slice(0, 200) : String(input?.file_path ?? '')
90
91export const register: Register = (on, options) => {
92 const mode = ((options.guard_mode as Mode | undefined) ?? 'confirm') as Mode
93 const extraBranches = list(options.guard_extra_protected_branches)
94 const secretPaths = list(options.guard_secret_paths)
95 const logOn = options.log_enabled !== false
96
97 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
98 if (e.tool !== 'Bash' || !(await live($, mode))) return next(e)
99 const cmds = splitCommands(e.command)
100 if (cmds.length === 0) return next(e)
101 const v = await classify(cmds, await envOf($, extraBranches))
102 if (!v) return next(e)
103 const ctx = { tool: 'Bash', call: callOf('Bash', e), agentId: e.agentId, log: logOn }
104 return askOrDeny($, v, e, next, mode, ctx, e.command)
105 }).catch(($, e, next) => next(e))
106
107 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
108 if (e.tool !== 'Write' || !(await live($, mode))) return next(e)
109 const v = await classifyFile(e.file_path, secretPaths, await envOf($, extraBranches))
110 if (!v) return next(e)
111 const ctx = { tool: e.tool, call: e.file_path, agentId: e.agentId, log: logOn }
112 return askOrDeny($, v, e, next, mode, ctx, `${e.tool} ${e.file_path}`)
113 }).catch(($, e, next) => next(e))
114
115 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
116 if (e.tool !== 'Edit' || !(await live($, mode))) return next(e)
117 const v = await classifyFile(e.file_path, secretPaths, await envOf($, extraBranches))
118 if (!v) return next(e)
119 const ctx = { tool: e.tool, call: e.file_path, agentId: e.agentId, log: logOn }
120 return askOrDeny($, v, e, next, mode, ctx, `${e.tool} ${e.file_path}`)
121 }).catch(($, e, next) => next(e))
122
123 // Observer: a verdict from beneath is never altered; a native deny is only written down.
124 on('tool.check', async ($, e, next) => {
125 const r = await next(e)
126 try {
127 if (r.decision === 'deny' && logOn && (await $.session.surfaces()).length > 0) {
128 await record($, {
129 tool: e.tool,
130 call: callOf(e.tool, e.input),
131 reason: r.reason ?? 'denied',
132 source: 'native',
133 nativeRule: r.rule,
134 agentId: e.agentId,
135 })
136 }
137 } catch {}
138 return r
139 }).catch(($, e, next) => next(e))
140
141 on('command.run', { command: 'session-denials' }, async ($, e) => {
142 if ((await $.session.surfaces()).length === 0) return { text: 'Nothing to show here: no screen is attached.' }
143 await $.ui.open({ id: PANE, title: 'Denied calls' })
144 return { text: 'Denials pane opened.' }
145 }).catch(($, e, next) => next(e))
146
147 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
148 const { Box, Button, Text } = $.ui.resolve(e)
149 const rows = [...(await read($, guardAtom)).denials].reverse()
150 const shown = await read($, copyAtom)
151 return (
152 <Box flexDirection="column">
153 <Box borderStyle="round" borderColor="claude" gap={1}>
154 <Text key="title" bold>{`denied calls (${rows.length})`}</Text>
155 </Box>
156 {rows.length === 0 && <Text dimColor>no denials</Text>}
157 {rows.map(d => (
158 <Box key={`d-${d.id}`} flexDirection="column">
159 <Box gap={1}>
160 <Text dimColor>{new Date(d.ts).toISOString().slice(11, 16)}</Text>
161 <Text bold>{d.tool}</Text>
162 <Box flexShrink={1}><Text wrap="truncate-end">{d.call}</Text></Box>
163 <Text color={d.source === 'native' ? 'warning' : 'error'}>{d.source}</Text>
164 <Button key={`copy-${d.id}`} label="Copy rule" onPress={() => update($, copyAtom, () => d.id)} />
165 </Box>
166 <Text dimColor wrap="truncate-end">{d.reason.split('\n')[0]}</Text>
167 {shown === d.id && <Text>{`${ruleFor(d)} (add it via /permissions)`}</Text>}
168 </Box>
169 ))}
170 </Box>
171 )
172 }).catch(($, e, next) => next(e))
173}
174hooks/memo.tsx 135 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { ageHint, clean, MAX_NOTES, MAX_TOTAL, noteLines, parse, refusal, render, tokens, USAGE, visible } from './memo-logic.ts'
5import type { Note } from './memo-logic.ts'
6
7const PANE = 'memo'
8const GLOBAL = 'memo.global'
9const projectKey = (root: string) => `memo.project:${root}`
10
11// True until the block went to the model in this conversation; /compact, /clear and every write set it again.
12const armed = atom({ plugin: 'session', key: 'memo' } as const, { armed: true })
13
14async function load($: any): Promise<{ g: Note[]; p: Note[]; root: string }> {
15 const root = (await $.session.root()) as string
16 return { g: clean(await $.store.get(GLOBAL)), p: clean(await $.store.get(projectKey(root))), root }
17}
18
19const save = async ($: any, key: string, notes: Note[]) => {
20 await $.store.set(key, notes)
21 await update($, armed, () => ({ armed: true }))
22}
23
24// Everything the pane and /memo list show beyond the block itself: counts, truncation, age hints.
25function facts(g: Note[], p: Note[], now: number) {
26 const v = visible(g, p)
27 const shown = [...v.g, ...v.p]
28 const chars = shown.reduce((n, x) => n + x.text.length, 0)
29 const hints = shown.map((n, i) => ({ i: i + 1, hint: ageHint(n, now), tok: tokens(n) })).filter(h => h.hint)
30 return {
31 v,
32 count: `${shown.length}/${MAX_NOTES} notes · ${chars}/${MAX_TOTAL} chars · ~${shown.reduce((n, x) => n + tokens(x), 0)} tok`,
33 cut: v.cut ? 'truncated: the store is over its caps; only whole notes up to the caps are used' : '',
34 hints: hints.map(h => `note ${h.i}: ${h.hint}`),
35 }
36}
37
38export const register: Register = (on, _options) => {
39 on('command.run', { command: 'memo' }, async ($, e) => {
40 try {
41 const cmd = parse(e.args)
42 if (cmd.op === 'usage') return { text: USAGE }
43 const { g, p, root } = await load($)
44 const now = (await $.clock.now()) as number
45 const note = 'Applies from the next prompt.'
46 if (cmd.op === 'pane' || cmd.op === 'list') {
47 const surfaces = (await $.session.surfaces()) as unknown[]
48 if (cmd.op === 'pane' && surfaces.length > 0) {
49 await $.ui.open({ id: PANE, title: 'Memo' })
50 return { text: 'Memo pane opened.' }
51 }
52 if (g.length + p.length === 0) return { text: USAGE }
53 const f = facts(g, p, now)
54 const all = [...f.v.g, ...f.v.p]
55 const lines = noteLines(f.v.g, f.v.p).map((l, i) => {
56 const h = ageHint(all[i]!, now)
57 return `${i + 1}. ${l}${h ? ` (${h})` : ''}`
58 })
59 return { text: [...lines, f.count, f.cut].filter(Boolean).join('\n') }
60 }
61 if (cmd.op === 'add') {
62 const why = refusal(g, p, cmd.text)
63 if (why) {
64 try {
65 $.ui.toast(`memo: ${why}`, { timeoutMs: 6000 })
66 } catch {}
67 return { text: why }
68 }
69 const entry = { text: cmd.text, ts: now }
70 if (cmd.global) await save($, GLOBAL, [...g, entry])
71 else await save($, projectKey(root), [...p, entry])
72 return { text: `Note added (${cmd.global ? 'global' : 'project'}). ${g.length + p.length + 1}/${MAX_NOTES}. ${note}` }
73 }
74 if (cmd.op === 'rm') {
75 const all = [...g.map(n => ({ n, key: GLOBAL })), ...p.map(n => ({ n, key: projectKey(root) }))]
76 const hit = all[cmd.n - 1]
77 if (!hit) return { text: `No note ${cmd.n}. ${USAGE}` }
78 const rest = all.filter(x => x !== hit && x.key === hit.key).map(x => x.n)
79 await save($, hit.key, rest)
80 return { text: `Note ${cmd.n} removed. ${note}` }
81 }
82 if (cmd.op !== 'clear') return { text: USAGE }
83 await save($, cmd.global ? GLOBAL : projectKey(root), [])
84 return { text: `Cleared ${cmd.global ? 'global' : 'project'} notes. ${note}` }
85 } catch {
86 return { text: 'memo: the note store could not be read or written; nothing changed' }
87 }
88 }).catch(($, e, next) => next(e))
89
90 // The block rides once per conversation; a bad store leaves the prompt untouched.
91 on('prompt.submit', async ($, e, next) => {
92 try {
93 if (!(await read($, armed)).armed) return next(e)
94 const { g, p } = await load($)
95 const block = render(g, p)
96 if (!block) return next(e)
97 await update($, armed, () => ({ armed: false }))
98 return next({ ...e, context: [...(e.context ?? []), block] })
99 } catch {
100 return next(e)
101 }
102 }).catch(($, e, next) => next(e))
103
104 // A new context window (after /compact or /clear) has lost the block; resume and startup have not.
105 on('classic.SessionStart', async ($, e, next) => {
106 if (e.source === 'compact' || e.source === 'clear') await update($, armed, () => ({ armed: true }))
107 return next(e)
108 }).catch(($, e, next) => next(e))
109
110 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
111 const { Box, Text } = $.ui.resolve(e)
112 let g: Note[] = []
113 let p: Note[] = []
114 try {
115 ;({ g, p } = await load($))
116 } catch {}
117 const now = (await $.clock.now()) as number
118 const f = facts(g, p, now)
119 const lines = noteLines(f.v.g, f.v.p)
120 return (
121 <Box flexDirection="column">
122 <Box borderStyle="round" borderColor="claude" gap={1}>
123 <Text key="title" bold>memo</Text>
124 </Box>
125 {lines.length === 0 && <Text dimColor>{USAGE}</Text>}
126 {lines.length > 0 && <Text key="h" bold>{render(g, p).split('\n')[0]}</Text>}
127 {lines.map((l, i) => <Text key={`n-${i}`}>{l}</Text>)}
128 {lines.length > 0 && <Text dimColor>{f.count}</Text>}
129 {f.cut !== '' && <Text color="warning">{f.cut}</Text>}
130 {f.hints.map(line => <Text key={line} dimColor>{line}</Text>)}
131 </Box>
132 )
133 }).catch(($, e, next) => next(e))
134}
135hooks/hint.ts 29 lines1import type { On, PluginOptions } from 'claude-code'
2
3// A short task prompt with no path, code or check named: one toast, at most every 10 minutes. The prompt is never changed.
4const TASK_VERB = /(만들|고쳐|고치|추가|바꿔|수정|구현|지워|삭제|\b(fix|add|make|build|change|implement|remove|refactor)\b)/i
5const HAS_DETAIL = /[\/\\.`]|\d|test|테스트|확인|검증|verify|check/i
6export const HINT_MAX_CHARS = 20
7const GAP_MS = 10 * 60 * 1000
8
9export const needsHint = (text: string): boolean => {
10 const t = text.trim()
11 return t.length > 0 && t.length <= HINT_MAX_CHARS && !t.startsWith('/') && TASK_VERB.test(t) && !HAS_DETAIL.test(t)
12}
13
14let lastAt = -Infinity
15
16export const register = (on: On, options: PluginOptions) => {
17 // Matched on the person's own typing, so it sits beside memo's unmatched prompt.submit hook.
18 on('prompt.submit', { origin: { kind: 'composer' } }, async ($, e, next) => {
19 if (options.prompt_hint === true && needsHint(e.text)) {
20 const now = (await $.clock.now()) as number
21 if (now - lastAt >= GAP_MS) {
22 lastAt = now
23 $.ui.toast('short task prompt: scope or a check to verify missing?')
24 }
25 }
26 return next(e)
27 }).catch(($, e, next) => next(e))
28}
29hooks/compact.tsx 138 lines1import type { On, PluginOptions } from 'claude-code'
2
3import { corrections, fmtAgo, isFresh, lessonsKey, makeRecap, mergeLessons, RECAP_PROMPT, recapKey } from './recap.ts'
4import type { Recap } from './recap.ts'
5import { statusLine } from './status.ts'
6
7// Recap the session at a set context %, then compact with that recap as the summary instructions.
8// The threshold is the userConfig field, so /config shows it; /smart-compact <n> writes the same field.
9// Every recap (here or /handoff) is kept per project: the next start shows it, /lessons collects its Corrections.
10export const THRESHOLD_FIELD = 'smart_compact_threshold'
11export const RECAP_PANE = 'recap'
12const DEFAULT_THRESHOLD = 70
13const MIN = 10
14const MAX = 95
15
16const thresholdOf = (options: PluginOptions) => Number(options[THRESHOLD_FIELD] ?? DEFAULT_THRESHOLD)
17let isRunning = false
18
19// One fork over the transcript; kept as the project's recap, its Corrections added to the lessons.
20async function recapNow($: any): Promise<{ text: string } | { reason: string }> {
21 const r = await $.model.fork({ prompt: RECAP_PROMPT })
22 if (!r.isAnswered) return { reason: String(r.reason) }
23 try {
24 const root = await $.session.root()
25 const now = (await $.clock.now()) as number
26 await $.store.set(recapKey(root), makeRecap(r.text, now, await $.session.id()))
27 const kept = ((await $.store.get(lessonsKey(root))) as string[] | undefined) ?? []
28 const fresh = corrections(r.text)
29 if (fresh.length > 0) await $.store.set(lessonsKey(root), mergeLessons(kept, fresh))
30 } catch {}
31 return { text: r.text }
32}
33
34async function storedRecap($: any): Promise<Recap | undefined> {
35 try {
36 const r = (await $.store.get(recapKey(await $.session.root()))) as Recap | undefined
37 return isFresh(r, (await $.clock.now()) as number) ? r : undefined
38 } catch {
39 return undefined
40 }
41}
42
43export const register = (on: On, options: PluginOptions) => {
44 on('command.run', { command: 'smart-compact' }, async ($, e) => {
45 const current = thresholdOf(options)
46 const arg = e.args.trim().replace(/%$/, '')
47 if (!arg) return { text: `recap + compact at ${current}%. Change it with /smart-compact <${MIN}-${MAX}>.` }
48
49 const value = Number(arg)
50 if (!Number.isInteger(value) || value < MIN || value > MAX) {
51 return { text: `"${e.args.trim()}" is not a whole number from ${MIN} to ${MAX}; still ${current}%.` }
52 }
53 const set = await $.config.set({ key: `${$.plugin.name}.${THRESHOLD_FIELD}`, value })
54 if (set.deny !== undefined) return { text: `not changed: ${set.deny}` }
55 return { text: `recap + compact at ${value}% (was ${current}%).` }
56 }).catch(($, e, next) => next(e))
57
58 // /handoff [to]: a recap now, kept for the next start; with a session name or id, sent there too.
59 on('command.run', { command: 'handoff' }, async ($, e) => {
60 const r = await recapNow($)
61 if ('reason' in r) return { text: `no recap: ${r.reason}` }
62 const to = e.args.trim()
63 if (!to) return { text: r.text }
64 const address = /^session_|^[0-9a-f]{8}-[0-9a-f-]{27}$/.test(to) ? { sessionId: to } : to
65 const sent = await $.session.send({ to: address, text: `Handoff from another session:\n\n${r.text}` }).catch(
66 (err: unknown) => ({ isDelivered: false as const, reason: String(err) }),
67 )
68 return { text: `${r.text}\n\n${sent.isDelivered ? `sent to ${to}` : `not sent to ${to}: ${sent.reason}`}` }
69 }).catch(($, e, next) => next(e))
70
71 on('command.run', { command: 'recap' }, async $ => {
72 const r = await storedRecap($)
73 if (!r) return { text: 'No recap for this project in the last 7 days. /handoff makes one now.' }
74 return { text: `Recap from ${fmtAgo(((await $.clock.now()) as number) - r.ts)}:\n\n${r.text}` }
75 }).catch(($, e, next) => next(e))
76
77 on('command.run', { command: 'lessons' }, async ($, e) => {
78 const key = lessonsKey(await $.session.root())
79 if (e.args.trim() === 'clear') {
80 await $.store.delete(key)
81 return { text: 'Lessons cleared for this project.' }
82 }
83 const kept = ((await $.store.get(key)) as string[] | undefined) ?? []
84 if (kept.length === 0) return { text: 'No lessons yet: they come from the Corrections section of each recap.' }
85 const lines = kept.map((l, i) => `${i + 1}. ${l}`)
86 return { text: [...lines, '', 'Worth keeping? Move it to CLAUDE.md. /lessons clear empties the list.'].join('\n') }
87 }).catch(($, e, next) => next(e))
88
89 // The pane the band's Recap button opens: the stored text, read-only.
90 on('ui.render', { component: 'Pane', requestId: RECAP_PANE }, async ($, e) => {
91 const { Box, Text } = $.ui.resolve(e)
92 const r = await storedRecap($)
93 return (
94 <Box flexDirection="column" paddingX={1}>
95 <Text>{r ? r.text : 'No recap for this project in the last 7 days.'}</Text>
96 </Box>
97 )
98 })
99
100 // Matched on reason, so it sits beside mod.tsx's unmatched turn.complete hook.
101 on('turn.complete', { reason: 'answer' }, async ($, e, next) => {
102 const result = await next(e)
103 if (e.agentId || isRunning) return result
104 if ((await $.session.surfaces()).length === 0) return result
105
106 const percent = (await $.session.usage()).context.percent ?? 0
107 const threshold = thresholdOf(options)
108 if (percent < threshold) return result
109
110 isRunning = true
111 const recapThenCompact = async () => {
112 $.ui.status(`smart-compact: ${percent}% ≥ ${threshold}%, recapping`)
113 const recap = await recapNow($)
114 if ('reason' in recap) {
115 $.ui.toast(`smart-compact: recap failed (${recap.reason}), left to auto-compact`)
116 return
117 }
118 const instructions = `Keep this recap and direction intact in the summary:\n\n${recap.text}`
119 // compact rejects while a turn runs; retry until the turn has ended
120 for (let attempt = 0; attempt < 20; attempt++) {
121 try {
122 const done = await $.session.compact({ instructions })
123 $.ui.toast(done.skip ? `smart-compact: skipped (${done.skip})` : 'smart-compact: recapped and compacted')
124 return
125 } catch {
126 await $.clock.sleep(500)
127 }
128 }
129 $.ui.toast('smart-compact: could not compact, left to auto-compact')
130 }
131 void recapThenCompact().catch(() => {}).finally(() => {
132 $.ui.status(statusLine({}))
133 isRunning = false
134 })
135 return result
136 }).catch(($, e, next) => next(e))
137}
138hooks/guard-logic.ts 437 lines1// Pure logic of the guard: command splitter, rules, path rules, redaction, ring buffer. No engine calls.
2
3// `sub`: found inside a $( ), backticks, a heredoc body or a -c string, not at the top level of the line
4export type Cmd = { argv: string[]; sub?: boolean }
5export type Verdict = { rule: string; action: 'confirm' | 'deny'; reason: string } | undefined
6export type Denial = {
7 id: string
8 ts: number
9 tool: string
10 call: string
11 reason: string
12 source: 'guard' | 'native' | 'declined'
13 nativeRule?: string
14 agentId?: string
15}
16
17// What the rules may ask of the outside world; every answer is optional, a failure is `undefined`.
18export type Env = {
19 cwd: string
20 root?: string
21 home?: string
22 extraBranches: string[]
23 git: (argv: string[]) => Promise<string | undefined>
24 pgrep: (path: string) => Promise<string | undefined>
25}
26
27const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'python', 'python3', 'node'])
28const KEYWORDS = new Set(['{', '!', 'if', 'then', 'else', 'elif', 'do', 'while', 'until', 'time'])
29const base = (s: string) => s.slice(s.lastIndexOf('/') + 1)
30
31// ---- splitter -------------------------------------------------------------------------------
32
33// End index (of the matching `)`) for a `$(` whose body starts at `from`; -1 when unterminated.
34function closeParen(s: string, from: number): number {
35 let depth = 1
36 for (let i = from; i < s.length; i++) {
37 const c = s[i]
38 if (c === '\\') i++
39 else if (c === "'") {
40 const j = s.indexOf("'", i + 1)
41 if (j < 0) return -1
42 i = j
43 } else if (c === '"') {
44 for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === '\\') i++
45 if (i >= s.length) return -1
46 } else if (c === '(') depth++
47 else if (c === ')' && --depth === 0) return i
48 }
49 return -1
50}
51
52function parse(s: string): Cmd[] | null {
53 const cmds: Cmd[] = []
54 let cur: string[] = []
55 let word: string | null = null
56 const pending: { delim: string; strip: boolean }[] = []
57 const flushWord = () => {
58 if (word !== null) cur.push(word)
59 word = null
60 }
61 const endCmd = () => {
62 flushWord()
63 if (cur.length) cmds.push({ argv: cur })
64 cur = []
65 }
66 const add = (t: string) => {
67 word = (word ?? '') + t
68 }
69 const sub = (inner: string): boolean => {
70 const r = parse(inner)
71 if (r === null) return false
72 cmds.push(...r.map(c => ({ ...c, sub: true })))
73 return true
74 }
75 for (let i = 0; i < s.length; i++) {
76 const c = s[i]!
77 if (c === ' ' || c === '\t') flushWord()
78 else if (c === '\n') {
79 flushWord()
80 for (const h of pending.splice(0)) {
81 const lines = s.slice(i + 1).split('\n')
82 let n = 0
83 while (n < lines.length && (h.strip ? lines[n]!.replace(/^\t+/, '') : lines[n]) !== h.delim) n++
84 const body = lines.slice(0, n).join('\n')
85 i += 1 + (n > 0 ? body.length + 1 : 0) + (n < lines.length ? lines[n]!.length : 0)
86 let text = body
87 if (!SHELLS.has(base(unwrap(cur)[0] ?? ''))) text = '' // mutation:heredoc-skip
88 if (!sub(text)) return null
89 }
90 endCmd()
91 } else if (c === '\\') {
92 if (s[i + 1] === '\n') i++
93 else if (i + 1 < s.length) add(s[++i]!)
94 } else if (c === "'") {
95 const j = s.indexOf("'", i + 1)
96 if (j < 0) return null
97 add(s.slice(i + 1, j))
98 i = j
99 } else if (c === '"') {
100 let out = ''
101 let j = i + 1
102 for (; j < s.length && s[j] !== '"'; j++) {
103 const d = s[j]!
104 if (d === '\\' && j + 1 < s.length) out += s[++j]
105 else if (d === '$' && s[j + 1] === '(') {
106 const k = closeParen(s, j + 2)
107 if (k < 0 || !sub(s.slice(j + 2, k))) return null
108 out += '$(…)'
109 j = k
110 } else if (d === '`') {
111 const k = s.indexOf('`', j + 1)
112 if (k < 0 || !sub(s.slice(j + 1, k))) return null
113 out += '$(…)'
114 j = k
115 } else out += d
116 }
117 if (j >= s.length) return null
118 add(out)
119 i = j
120 } else if (c === '$' && s[i + 1] === '(') {
121 const k = closeParen(s, i + 2)
122 if (k < 0 || !sub(s.slice(i + 2, k))) return null
123 add('$(…)')
124 i = k
125 } else if (c === '`') {
126 const k = s.indexOf('`', i + 1)
127 if (k < 0 || !sub(s.slice(i + 1, k))) return null
128 add('$(…)')
129 i = k
130 } else if (c === '<' && s[i + 1] === '<' && s[i + 2] !== '<') {
131 let j = i + 2
132 const strip = s[j] === '-'
133 if (strip) j++
134 while (s[j] === ' ' || s[j] === '\t') j++
135 let delim = ''
136 for (; j < s.length && !/[\s;&|()<>]/.test(s[j]!); j++) {
137 const d = s[j]!
138 if (d === "'" || d === '"') {
139 const k = s.indexOf(d, j + 1)
140 if (k < 0) return null
141 delim += s.slice(j + 1, k)
142 j = k
143 } else if (d === '\\') delim += s[++j] ?? ''
144 else delim += d
145 }
146 pending.push({ delim, strip })
147 flushWord()
148 i = j - 1
149 } else if (c === ';' || c === '|' || c === '(' || c === ')') {
150 endCmd()
151 if ((c === '|' && s[i + 1] === '|') || (c === '|' && s[i + 1] === '&')) i++
152 } else if (c === '&') {
153 if (s[i + 1] === '&') {
154 endCmd()
155 i++
156 } else if (s[i - 1] === '>' || s[i - 1] === '<' || s[i + 1] === '>') add('&')
157 else endCmd()
158 } else if (c === '#' && word === null) {
159 while (i < s.length && s[i] !== '\n') i++
160 i--
161 } else add(c)
162 }
163 endCmd()
164 return cmds
165}
166
167// `a && rm -rf x; b | c $(d)` -> one argv per executable position. Heredoc bodies and quoted arguments are
168// text, not commands, unless the heredoc (or `-c` string) feeds a shell or interpreter. Unparseable -> [].
169export function splitCommands(src: string): Cmd[] {
170 const top = parse(src)
171 if (top === null) return []
172 const out: Cmd[] = [...top]
173 for (const { argv } of top) {
174 const a = unwrap(argv)
175 if (!SHELLS.has(base(a[0] ?? ''))) continue
176 const i = a.findIndex(x => /^-[a-z]*c[a-z]*$/.test(x))
177 const code = i > 0 ? a[i + 1] : undefined
178 const inner = code === undefined ? null : parse(code)
179 if (inner) out.push(...inner.map(c => ({ ...c, sub: true })))
180 }
181 return out
182}
183
184// The real command behind assignments, keywords and wrappers (`sudo`, `env`, `command`, `nohup` ...).
185export function unwrap(argv: string[]): string[] {
186 let a = argv
187 for (let n = 0; n < 8; n++) {
188 let i = 0
189 while (i < a.length && (/^[A-Za-z_]\w*=/.test(a[i]!) || KEYWORDS.has(a[i]!))) i++
190 a = a.slice(i)
191 const h = base(a[0] ?? '')
192 if (h === 'sudo' || h === 'doas') {
193 let j = 1
194 while (j < a.length && a[j]!.startsWith('-')) j += /^-[ug]$/.test(a[j]!) ? 2 : 1
195 a = a.slice(j)
196 } else if (h === 'env') {
197 let j = 1
198 while (j < a.length && (a[j]!.startsWith('-') || /^[A-Za-z_]\w*=/.test(a[j]!))) j++
199 a = a.slice(j)
200 } else if (['command', 'nohup', 'nice', 'exec', 'builtin'].includes(h)) {
201 let j = 1
202 while (j < a.length && a[j]!.startsWith('-')) j++
203 a = a.slice(j)
204 } else break
205 }
206 return a
207}
208
209// ---- paths ----------------------------------------------------------------------------------
210
211function normalize(base_: string, p: string): string {
212 const out: string[] = []
213 for (const seg of (p.startsWith('/') ? p : `${base_}/${p}`).split('/')) {
214 if (seg === '' || seg === '.') continue
215 if (seg === '..') out.pop()
216 else out.push(seg)
217 }
218 return `/${out.join('/')}`
219}
220
221// Test-only stand-in for a home that is not known: `~` still names a dangerous target then.
222const UNKNOWN_HOME = '/__home__'
223
224function dangerousTarget(t: string, env: Env): boolean {
225 let s = t
226 const glob = s.match(/^(.*)\/\*+$/)
227 if (glob) s = glob[1] || '/'
228 const home = env.home ? normalize('/', env.home) : UNKNOWN_HOME
229 s = s.replace(/^(~|\$HOME|\$\{HOME\})(?=\/|$)/, home).replace(/^(\$PWD|\$\{PWD\})(?=\/|$)/, env.cwd)
230 if (s === '' || /[$`]/.test(s)) return false
231 const r = normalize(env.cwd, s)
232 return r === '/' || r === home || r === env.root || env.cwd.startsWith(`${r}/`)
233}
234
235// The `rm` rule: recursive and force, any target at `/`, home, the repo root or above the cwd.
236function rmVerdict(a: string[], env: Env): Verdict {
237 let recursive = false
238 let force = false
239 const targets: string[] = []
240 let flags = true
241 for (const x of a.slice(1)) {
242 if (flags && x === '--') flags = false
243 else if (flags && x.startsWith('--')) {
244 if (x === '--recursive') recursive = true
245 if (x === '--force') force = true
246 } else if (flags && x.startsWith('-') && x.length > 1) {
247 if (/[rR]/.test(x)) recursive = true
248 if (x.includes('f')) force = true
249 } else targets.push(x)
250 }
251 if (!recursive || !force) return undefined
252 const hit = targets.find(t => dangerousTarget(t, env))
253 return hit === undefined
254 ? undefined
255 : { rule: 'recursive-delete', action: 'confirm', reason: `session: recursive force delete of ${hit} can wipe the project or home.` }
256}
257
258// ---- git ------------------------------------------------------------------------------------
259
260type Git = { dir?: string; sub: string; rest: string[] }
261
262function gitArgs(a: string[]): Git | undefined {
263 if (base(a[0] ?? '') !== 'git') return undefined
264 let dir: string | undefined
265 let i = 1
266 for (; i < a.length && a[i]!.startsWith('-'); i++) {
267 if (a[i] === '-C') dir = a[++i]
268 else if (a[i] === '-c') i++
269 }
270 const sub = a[i]
271 return sub === undefined ? undefined : { dir, sub, rest: a.slice(i + 1) }
272}
273
274const gitC = (g: Git, argv: string[]) => (g.dir ? ['-C', g.dir, ...argv] : argv)
275const branchOf = (r: string) => r.replace(/^refs\/heads\//, '')
276
277async function pushVerdict(g: Git, env: Env): Promise<Verdict> {
278 let flagForce = false
279 const pos: string[] = []
280 for (let i = 0; i < g.rest.length; i++) {
281 const x = g.rest[i]!
282 if (x.startsWith('--')) {
283 if (x === '--force' || x.startsWith('--force-with-lease')) flagForce = true
284 else if (['--repo', '--receive-pack', '--exec', '--push-option'].includes(x)) i++
285 } else if (x.startsWith('-') && x.length > 1) {
286 if (x.includes('f')) flagForce = true
287 if (x === '-o') i++
288 } else pos.push(x)
289 }
290 const specs = pos.slice(1)
291 const forced: string[] = []
292 const wantsCurrent: boolean[] = []
293 for (const sp of specs) {
294 const plus = sp.startsWith('+')
295 if (!plus && !flagForce) continue
296 const body = plus ? sp.slice(1) : sp
297 const dst = body.includes(':') ? body.slice(body.indexOf(':') + 1) : body
298 forced.push(branchOf(dst))
299 wantsCurrent.push(dst === 'HEAD' || dst === '@')
300 }
301 if (flagForce && specs.length === 0) {
302 forced.push('')
303 wantsCurrent.push(true)
304 }
305 if (forced.length === 0) return undefined
306 const protectedSet = new Set(['main', 'master', 'trunk', ...env.extraBranches])
307 const head = await env.git(gitC(g, ['symbolic-ref', 'refs/remotes/origin/HEAD']))
308 if (head?.trim()) protectedSet.add(head.trim().replace(/^refs\/remotes\/origin\//, ''))
309 for (let i = 0; i < forced.length; i++) {
310 let b = forced[i]!
311 if (wantsCurrent[i]) b = (await env.git(gitC(g, ['branch', '--show-current'])))?.trim() ?? ''
312 if (b && protectedSet.has(b)) {
313 return { rule: 'force-push-protected', action: 'confirm', reason: `session: force push to protected branch ${b} can erase shared history.` }
314 }
315 }
316 return undefined
317}
318
319function resetVerdict(g: Git): Verdict {
320 if (g.sub === 'reset' && g.rest.includes('--hard')) {
321 return { rule: 'hard-reset', action: 'confirm', reason: 'session: git reset --hard discards uncommitted work.' }
322 }
323 if (g.sub === 'clean') {
324 const f = g.rest.filter(x => x.startsWith('-'))
325 const short = (c: string) => f.some(x => !x.startsWith('--') && x.includes(c))
326 const force = short('f') || f.includes('--force')
327 const dry = short('n') || f.includes('--dry-run')
328 if (force && short('d') && !dry) {
329 return { rule: 'hard-reset', action: 'confirm', reason: 'session: git clean -fd deletes untracked files for good.' }
330 }
331 }
332 return undefined
333}
334
335async function worktreeVerdict(g: Git, env: Env): Promise<Verdict> {
336 if (g.sub !== 'worktree' || g.rest[0] !== 'remove') return undefined
337 const args = g.rest.slice(1)
338 if (!args.some(x => x === '--force' || x === '-f')) return undefined
339 const target = args.find(x => !x.startsWith('-'))
340 if (!target) return undefined
341 const path = normalize(g.dir ? normalize(env.cwd, g.dir) : env.cwd, target)
342 const st = await env.git(['-C', path, 'status', '--porcelain'])
343 if (!st?.trim()) return undefined
344 return {
345 rule: 'worktree-dirty-remove',
346 action: 'deny',
347 reason: `session: ${path} has uncommitted changes:\n${st.slice(0, 500)}\nCommit (and verify with git log) before removing it.`,
348 }
349}
350
351// The first rule that fires over the executable positions of a Bash command.
352export async function classify(cmds: Cmd[], env: Env): Promise<Verdict> {
353 for (const { argv } of cmds) {
354 const a = unwrap(argv)
355 const head = base(a[0] ?? '')
356 let v: Verdict
357 if (head === 'rm') v = rmVerdict(a, env)
358 else if (head === 'git') {
359 const g = gitArgs(a)
360 if (g?.sub === 'push') v = await pushVerdict(g, env)
361 else if (g) v = resetVerdict(g) ?? (await worktreeVerdict(g, env))
362 }
363 if (v) return v
364 }
365 return undefined
366}
367
368// ---- files ----------------------------------------------------------------------------------
369
370const KEY_FILES = new Set(['id_rsa', 'id_ed25519', 'id_ecdsa', 'id_dsa'])
371const SECRET_DIRS = /(^|\/)\.(aws|ssh|gnupg|kube|docker)\/|(^|\/)\.config\/gcloud\//
372
373const globRe = (g: string) =>
374 new RegExp(
375 `(^|/)${g
376 .trim()
377 .replace(/[.+^${}()|[\]\\]/g, '\\$&')
378 .replace(/\*\*/g, '\u0000')
379 .replace(/\*/g, '[^/]*')
380 .replace(/\?/g, '[^/]')
381 .replace(/\u0000/g, '.*')}$`,
382 )
383
384// Path-only: the content of a write is never read.
385export function secretPath(path: string, extra: string[]): boolean {
386 const name = base(path)
387 if (name === '.env' || (name.startsWith('.env.') && !/\.(example|sample|template)$/.test(name))) return true
388 if (/\.(pem|key|p12|pfx|jks|keystore)$/i.test(name) || KEY_FILES.has(name)) return true
389 if (SECRET_DIRS.test(path) && /^(credentials|tokens?)(\.json)?$/i.test(name)) return true
390 return extra.some(g => g.trim() && globRe(g).test(path))
391}
392
393// The rules that look at a Write or Edit path: secret-write, then running-script.
394export async function classifyFile(path: string, extra: string[], env: Env): Promise<Verdict> {
395 if (secretPath(path, extra)) {
396 return { rule: 'secret-write', action: 'confirm', reason: `session: ${path} looks like a secret file.` }
397 }
398 if (/\.(sh|bash)$/.test(path)) {
399 const out = (await env.pgrep(path))?.trim()
400 if (out) {
401 return {
402 rule: 'running-script',
403 action: 'deny',
404 reason: `session: ${path} is running (pid ${out.split('\n').join(', ')}). Copy it and edit the copy, or wait until it exits.`,
405 }
406 }
407 }
408 return undefined
409}
410
411// ---- log ------------------------------------------------------------------------------------
412
413// Token-shaped strings, NAME=value pairs and URL userinfo are masked before anything is stored.
414export function redact(call: string): string {
415 return call
416 .replace(/\bgh[pousr]_[A-Za-z0-9]{4,}/g, '***')
417 .replace(/\bsk-[A-Za-z0-9_-]{4,}/g, '***')
418 .replace(/\bAKIA[0-9A-Z]{4,}/g, '***')
419 .replace(/\bxox[a-z]-[A-Za-z0-9-]{4,}/g, '***')
420 .replace(/Bearer\s+\S+/g, 'Bearer ***')
421 .replace(/(?<![\w-])([A-Za-z_]\w*)=\S+/g, '$1=***')
422 .replace(/:\/\/[^\s/@]+@/g, '://***@')
423}
424
425export function pushRing<T>(list: readonly T[], item: T, cap: number): T[] {
426 const next = [...list, item]
427 return next.length > cap ? next.slice(next.length - cap) : next
428}
429
430// `Bash(git reset:*)` for a Bash call, `Write(path)` for a file call.
431export function ruleFor(d: { tool: string; call: string }): string {
432 if (d.tool !== 'Bash') return `${d.tool}(${d.call})`
433 const w = unwrap(d.call.split(/\s+/).filter(Boolean))
434 const g = gitArgs(w)
435 return `Bash(${g ? `git ${g.sub}` : (w[0] ?? '')}:*)`
436}
437