SLOPSHOPPER

session

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…

newpanebandguardcommandtoast
★ 3v0.3.0MITupdated 2026-10-08newkayak12/claude-skills/session
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session
│ ┃ Memo ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭──────────────────────────────────────────╮ │ session │ │ ┃ │memo │ ⏺ Read(src/auth.ts) │ guard: session: the person declined │ │ ┃ ╰──────────────────────────────────────────╯ ⎿ Read 6 lines │ (force-push-protected). │ │ ┃ usage: /memo add [--global] <text> | list | ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ rm <n> | clear [--global] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by session: session: the person declined (force-pu │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /memo │ ⎿ session: Memo pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session: guard: 1 denied · $0.42 · five_hour 31%

Draws

Pane · Memo
╭──────────────────────────────────────────────────────────╮ │memo │ ╰──────────────────────────────────────────────────────────╯ usage: /memo add [--global] <text> | list | rm <n> | clear [--global]
Pane · Denied calls
╭──────────────────────────────────────────────────────────╮ │denied calls (1) │ ╰──────────────────────────────────────────────────────────╯ 08:53 Bash rm -rf build && git push --force origin main decl session: the person declined (force-push-protected).
Pane · Session
╭──────────────────────────────────────────────────────────╮ │session [ Retro [1] ] [ Orphans [2] ] │ ╰──────────────────────────────────────────────────────────╯ files (3) · /work/app/src/auth.ts +11 -3 · /work/app/src/audit.ts · /work/app/src/cache.ts commits (0) ○ none denied (1) ✘ Bash session: the person declined (force-push-protected). longest gap 0s
Pane · recap
OK
README

session

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.

Install

/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. Needs sh (Windows without one is not covered).

/session

/session opens one pane with two views, switched by the buttons or keys 1 / 2:

  • Retro [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.
  • Orphans [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.

Retro band

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.

Stopping a child safely

[stop] sends one SIGTERM to one process, after these checks:

  1. a fresh process table is read; the pid must still be below this session's process, and not the session itself or one of its ancestors;
  2. its command and start time must equal what you saw (a reused pid is refused);
  3. you confirm in a prompt that shows the full command;
  4. the checks run again right before the signal.

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.

Status line

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.

Guard

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.

Rules

RuleFires onDefault
recursive-deleterm with recursive and force flags aimed at /, ~, $HOME, the repo root, a parent of the cwd, or a glob of thoseask
force-push-protectedgit 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 protectedask
hard-resetgit reset --hard, git clean -f (not -n)ask
worktree-dirty-removegit worktree remove --force on a worktree with uncommitted changesblock
secret-writeWrite/Edit to .env (not .env.example), *.pem, *.key, id_rsa and the like, credentials under .aws, .ssh and similar, or guard_secret_pathsask
running-scriptWrite/Edit to a .sh file a process is running right nowblock

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.

Modes

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.

What it does not cover

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.

Denial log

/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

/memo keeps short notes that ride along with your next prompt, so the model sees them again after /compact or /clear.

Commands

CommandDoes
/memoopen 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 listprint the notes
/memo rm <n>remove note n as numbered in list
/memo clear [--global]clear the project notes, or the global ones

Scopes and caps

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.

Smart compact

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.

CommandWhat it does
/smart-compactprint 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.

Recap, handoff, lessons

Every recap (from smart-compact or /handoff) is kept for this project, the last one only.

CommandWhat it does
/handoffmake a recap now, keep it, print it
/handoff <session>same, and send it to that session (a peer session name or id)
/recapprint the project's last recap (under 7 days old)
/lessonsthe "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 timer

/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.

Cost

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).

Prompt hint

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.

Limits

  • "Longest gap" is the largest time between two tool calls in one turn, not a measured step.
  • Denied counts only denials this plugin saw as a tool call result.
  • Paths are plain text (no clickable links).
  • Windows: the orphan section is hidden and stopping is off; the Retro view and band work.
  • Interactive sessions only for UI; headless runs record the ledger and save the summary, nothing is drawn.
  • Checked in a live terminal session (2.1.294): panes, [stop], the retro band, guard asks in auto and bypass mode, and /memo after /clear. The desktop Code tab is not checked yet.
Source 14 files
hooks/mod.tsx 458 lines
1import { 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}
458
hooks/ledger.ts 87 lines
1// 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})
87
hooks/kill.ts 23 lines
1import { 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}
23
hooks/procs.ts 56 lines
1// 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}
56
hooks/status.ts 23 lines
1// 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}
23
hooks/recap.ts 57 lines
1// 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}
57
hooks/timer.ts 40 lines
1// /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}
40
hooks/guard.tsx 174 lines
1import { 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}
174
hooks/memo.tsx 135 lines
1import { 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}
135
hooks/hint.ts 29 lines
1import 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}
29
hooks/compact.tsx 138 lines
1import 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}
138
hooks/guard-logic.ts 437 lines
1// 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