SLOPSHOPPER

session-band

Context, rate-limit and git band above the prompt, plus the 70% context guard

newbandguardpromptmodelprocess
v0.1.0no licenseupdated 2026-10-09vietbui1999ru/dotfiles/session-band-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-band
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by session-band: Bash command "rm -rf build && git push --force origin main" was refused by blast-r ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM work/app ctx:49% 5h:31% (opus-5-5) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
work/app ctx:49% 5h:31% (opus-5-5)
README

dotfiles

Personal dotfiles managed with GNU Stow. Ansible handles provisioning on new machines.

Theme: Catppuccin Macchiato throughout.

Packages

Provisioning uses three mechanisms — know which applies before editing a package: stow (symlink, edits round-trip to git), materialized (copied once from a template by bootstrap-dirs.sh, then drifts silently — edit the template, not the live file), and sync-pushed (one-way push by sync-agent-rules.sh, live edits are invisible to git).

PackageStow targetProvisioning
zsh/~/.zshrc, ~/.zprofile, ~/.zsh/stow
starship/~/.config/starship.tomlstow
nvim/~/.config/nvim/stow
tmux/~/.tmux.conf, ~/.local/bin/tmux-chtstow
tmuxinator/~/.config/tmuxinator/dotfiles.ymlstow
kitty/~/.config/kitty/stow
git/~/.gitconfigstow
jj/~/.config/jj/config.tomlstow — not currently applied on this machine
aerospace/~/.aerospace.tomlstow (macOS tiling WM) — not in the stow line above; add when used
mouseless/~/Library/Application Support/Mouseless/configs/config.yamlstow (macOS Mouseless app) — not in the stow line above; back up the live file before first stow mouseless, restart the app after
sketchybar/~/.config/sketchybar/stow (macOS status bar) — not in the stow line above; add when used; sketchybar --reload after edits
i3/~/.config/i3/configLinux-only, applied via Ansible (not macOS stow)
claude/~/.claude/stow (partial) — including settings.json; skills/+agents/ mix dotfiles and llm-wiki sources
opencode/~/.config/opencode/partial — plugins/ stow; opencode.json materialized; agents//skills/ unmanaged
codex/~/.codex/no-op stub today — Codex is sync-pushed via sync-agent-rules.sh, not stowed
pi/~/.pi/stow (extensions only); ~/.pi/agent/ runtime state is unmanaged
herdr/~/.config/herdr/config.tomlstow — not in the stow line above; back up the live file before first stow herdr; ~/.config/herdr/ logs/sockets/session-history are unmanaged runtime state; herdr server reload-config after edits
launchd/~/Library/LaunchAgents/stow — user LaunchAgents such as the Herdr→Sketchybar bridge

Quick start (macOS)

# 1. Clone (submodules required — llm-wiki is bundled as a submodule)
git clone --recurse-submodules git@github.com:vietbui99/dotfiles.git ~/dotfiles

# 2. Install Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# 3. Install packages
cd ~/dotfiles && brew bundle

# 4. Create expected directories (uses a standalone ~/repos/llm-wiki clone if present)
./scripts/bootstrap-dirs.sh

# 5. Symlink configs
./scripts/restow.sh

# 6. Sync AI tool rules (AGENTS.md + MCP servers)
./scripts/sync-agent-rules.sh

Ansible (Linux / new machine)

Covers: base packages, Rust toolchain, modern CLI tools, zsh, starship, tmux, neovim. Kitty only on macOS/desktop.

# Local machine
ansible-playbook ansible/site.yml --limit localhost

# Remote Debian dev server
ansible-playbook ansible/site.yml --limit dev_debian -i ansible/inventory/hosts.yml

# Remote RedHat admin server
ansible-playbook ansible/site.yml --limit admin_redhat -i ansible/inventory/hosts.yml

Scripts

ScriptPurpose
scripts/bootstrap-dirs.shCreate directories, sync shared skills, and materialize default configs
scripts/restow.shRestow all managed packages — single source of truth for the package list
scripts/sync-agent-rules.shSync shared/AGENTS.md and MCP servers to Claude Code, Codex, OpenCode
scripts/agent-workflowAttach/detach/status/doctor for per-repo Commandr/Pi/Neovim workflow
scripts/agent-sessionLegacy per-repo session inbox kept for migration compatibility

Submodules

repos/llm-wiki is a git submodule — the shared upstream for Claude Code agents, skills, and rules.

# Update llm-wiki pin to latest upstream commit
git submodule update --remote repos/llm-wiki
git add repos/llm-wiki && git commit -m "chore(submodule): bump llm-wiki"

~/repos/llm-wiki and ~/dotfiles/repos/llm-wiki are independent clones on this machine. bootstrap-dirs.sh creates a redirect to the submodule only when no standalone clone exists; it never replaces an existing clone. Claude rules, agents, and skills resolve to the standalone clone.

AI Agents

ToolInstall
pi (pi-coding-agent)npm install -g @earendil-works/pi-coding-agent
GitHub stacked PRsgh extension install github/gh-stack (skill synced by bootstrap-dirs.sh)
omp (oh-my-pi)`curl -fsSL https://omp.sh/install \sh`
rtk (Rust Token Killer)brew install rtk (included in Brewfile)

omp binaries land in ~/.bun/bin/ — already on PATH via .zprofile.

The pi/ stow package includes these Pi extensions:

  • pi-statusline.ts — Catppuccin footer statusline (dir, git, ctx%, model) with Nerd Font icons
  • ~/repos/DiffViewer/pi-extension — Pi package for the measured review gate: persistent .pi/diff-review/ drafts and submitted decisions
  • pi-side-panel/ — persistent tmux/herdr side panel (session, DiffViewer review, verification, Commandr, Neovim, git); /panel toggles
  • post-run-verifier.ts — owns completed-run verification (format → lint → typecheck → focused tests), inferred per-project config persisted under ~/.pi/agent/verification/projects/, max 3 automatic repair cycles; /verify, /verify-final, /verify-status, /verify-config, /verify-init.
  • rtk.ts — transparently rewrites supported Bash commands through RTK to reduce tool-output tokens; set RTK_DISABLED=1 for passthrough

pi/.pi-lens/config.json is the stow-provisioned global pi-lens config: pi-lens owns non-mutating turn-end static safety/context signals; its LSP, test, format, and autofix runners are disabled. post-run-verifier.ts alone owns format, lint, typecheck, and focused behavioral tests.

Intentional generated/config forks

  • shared/AGENTS.md is one-way sync-pushed to OpenCode and Codex by scripts/sync-agent-rules.sh; edit the shared source, never the generated targets.
  • ~/.claude/plugins/known_marketplaces.json is materialized once from the llm-wiki template, then Claude Code manages it. Treat it as a deliberate local fork.
  • ~/.config/agent-workflow/config.json is materialized once from shared/agent-workflow.default.json. Treat it as a deliberate local fork; change the template for new machines and the local file for this machine.

Claude Code uses the equivalent rtk hook claude PreToolUse hook from claude/.claude/settings.json, with usage instructions in ~/.claude/RTK.md.

Research tool routing

Research tools have exclusive primary scopes:

  • Context7 — official library, framework, SDK, CLI, and API documentation
  • Ketch — public implementation examples through ketch code only
  • Firecrawl — general web search, URLs, news, crawling, and page extraction

Fallback is allowed only when the primary tool lacks coverage. Canonical policy lives in shared/research-tool-routing.md; bootstrap-dirs.sh syncs the scoped Firecrawl skill to ~/.agents/skills/firecrawl. It also syncs GitHub's gh-stack skill to ~/.agents/skills/gh-stack for Pi and other compatible agent harnesses.

Herdr agent flow

Herdr owns the live pi, claude, and codex integration files; do not stow them. scripts/bootstrap-dirs.sh runs herdr integration install for all three. The persistent Herdr workspaces are plan (Claude) and build (Pi). com.vietbui.herdr-sketchybar-bridge subscribes to Herdr agent-state events and drives Sketchybar's agents pill; clicking it focuses Pi and opens AeroSpace workspace T.

Agent workflow automation

Global defaults live in shared/agent-workflow.default.json and are copied to ~/.config/agent-workflow/config.json by scripts/bootstrap-dirs.sh. Override per repo with .agent-workflow.json or machine-locally with ignored .agent-workflow.local.json.

# Check global install state (including RTK binary + Pi extension)
scripts/agent-workflow doctor

# Attach a repo to the Commandr bus + DiffViewer sidecars + approval gate
scripts/agent-workflow attach ~/repos/example

# Inspect current bus/board state
scripts/agent-workflow status ~/repos/example

# Remove only the managed hook; keep task history by default
scripts/agent-workflow detach ~/repos/example

Stacked pull requests

Use GitHub's gh stack extension for a single feature split into dependent, reviewable layers. Plan layer boundaries before coding; review and approve each layer through the existing review gate before committing it. Keep unrelated work in separate stacks.

# From a clean trunk, create the first named layer (never invoke bare init).
gh stack init feature/foundation
# Commit its reviewed changes, then add the dependent layer.
gh stack add feature/integration
# Publish draft PRs without interactive prompts.
gh stack submit --auto
# Inspect or update the stack non-interactively.
gh stack view --json
gh stack sync --remote origin

Agent invocations must use named init/add branches, submit --auto, and view --json; see docs/workflows/stacked-prs.md for recovery and review rules.

Pi-first workflow

Pi is the only supported agent harness. Commandr is the task service, DiffView is the review service, and Obsidian is the human UI. Other vendors are model providers or explicit CLI bridges, not parallel harnesses.

# Pi TUI
/sessions
/review

Legacy scripts/agent-session and .agents/sessions/ remain compatibility surfaces. New workflow features should target Pi and Commandr, not add another harness-specific state store.

RTK is a rewrite-only optimization layer: it does not replace permission gates or context-mode. Inspect savings with rtk gain or rtk gain --history.

Notes

  • opencode/plugins/commandr-checkpoint.js and diffviewer.js are symlinks into ~/repos/Commandr and ~/repos/DiffViewer. Clone those repos first.
  • Machine-local overrides go in ~/.zshrc.local (not tracked).
  • nvim/.config/nvim/.claude/ is gitignored — Claude Code writes local state there.
  • claude/ rule files: learning.md and research.md are niche-domain rules, available at @~/.claude/rules/learning.md but not auto-loaded. @-import them in project CLAUDE.md when working in those domains.
  • opencode/opencode.json uses hardcoded absolute paths for skills.paths and instructions — opencode does not expand ${env:HOME} in those fields.
  • tmux extended-keys is disabled — it breaks readline Ctrl shortcuts (C-a/e/u/k/w/r/d) in zsh. Standard key sequences work correctly without it.
  • zsh/.zsh/functions.zsh wraps nvim to re-emit SGR reset, show-cursor, and bracketed-paste sequences after exit — nvim's terminal cleanup (rs2/RIS) wipes these and breaks Starship colors and fast-syntax-highlighting.
  • Language toolchains (Python, Node, Ruby) managed by mise — Ansible replaced pyenv/rbenv/nvm. Neovim detects interpreters dynamically via vim.fn.exepath().
Source 14 files
hooks/register.tsx 176 lines
1import { atom, read, update, type EngineInterface, type Register } from 'claude-code'
2
3import type { Usage } from '../types'
4import { drawBand } from './band'
5import { captureSession, register as registerBlast } from './blast'
6import { register as registerClear } from './clear'
7import { shortDir, shortModel, whole } from './format'
8import { toSnapshot, type GitFacts } from './git'
9import { toRepoInfo, type RepoInfo } from './repo'
10import { guardAgent, softStop, type SoftStopCheck } from './threshold'
11
12const usage = atom({ plugin: 'session-band', key: 'usage' } as const, {})
13const git = atom({ plugin: 'session-band', key: 'git' } as const, null)
14const outputStyle = atom({ plugin: 'session-band', key: 'outputStyle' } as const, null)
15const isNotified = atom({ plugin: 'session-band', key: 'isNotified' } as const, false)
16
17const asWhole = (percent: number | undefined) => (percent === undefined ? undefined : whole(percent))
18
19const measuredUsage = (context: { percent?: number }, rateLimits: readonly { kind: string; percentUsed: number }[]): Usage => ({
20  ctx: asWhole(context.percent),
21  five: asWhole(rateLimits.find(limit => limit.kind === 'five_hour')?.percentUsed),
22  seven: asWhole(rateLimits.find(limit => limit.kind === 'seven_day')?.percentUsed),
23})
24
25const refreshGit = async ($: EngineInterface) => {
26  const cwd = await $.session.cwd()
27  const run = async (args: string[]) => {
28    const r = await $.process.run(['git', '-C', cwd, '--no-optional-locks', ...args])
29    return r.exitCode === 0 ? r.stdout.trim() : undefined
30  }
31  const branch = await run(['branch', '--show-current'])
32  let facts: GitFacts = { isDirty: false }
33
34  if (branch) {
35    const [unstaged, staged, upstream, stash, gitDir, common] = await Promise.all([
36      $.process.run(['git', '-C', cwd, '--no-optional-locks', 'diff', '--quiet']),
37      $.process.run(['git', '-C', cwd, '--no-optional-locks', 'diff', '--cached', '--quiet']),
38      run(['rev-parse', '--abbrev-ref', `${branch}@{upstream}`]),
39      run(['stash', 'list']),
40      run(['rev-parse', '--absolute-git-dir']),
41      run(['rev-parse', '--path-format=absolute', '--git-common-dir']),
42    ])
43    const counts = upstream ? await run(['rev-list', '--left-right', '--count', `${upstream}...HEAD`]) : undefined
44    facts = { branch, isDirty: unstaged.exitCode !== 0 || staged.exitCode !== 0, upstream, counts, stash, gitDir, common }
45  }
46
47  await update($, git, () => toSnapshot(facts))
48}
49
50const resolveRepo = async ($: EngineInterface): Promise<RepoInfo> => {
51  const out = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
52  const [top, common] = out.stdout.trim().split('\n')
53
54  if (out.exitCode !== 0 || !top || !common) return {}
55
56  const taskId = (await $.fs.read(`${top}/.agent-task-id`).catch(() => '')).trim() || undefined
57
58  return toRepoInfo(common, taskId)
59}
60
61const settingStyle = async ($: EngineInterface): Promise<string | null> => {
62  const settings = await $.settings.read()
63  const style = settings.outputStyle
64
65  return typeof style === 'string' ? style : null
66}
67
68const styleForBand = (style: string | null) => (style === 'default' ? null : style)
69
70export const register: Register = on => {
71  registerBlast(on)
72  registerClear(on)
73
74  // The settings fallback only matters before the first prompt.compose names the style.
75  let hasComposed = false
76  let settingsStyle: Promise<string | null> | undefined
77
78  on('session.start', async ($, e, next) => {
79    captureSession(e.isInteractive)
80    const current = await $.session.usage()
81    await Promise.all([
82      update($, usage, () => measuredUsage(current.context, current.rateLimits)),
83      refreshGit($),
84    ])
85
86    return next(e)
87  })
88
89  on('session.measure', async ($, e, next) => {
90    await update($, usage, () => measuredUsage(e.context, e.rateLimits))
91
92    return next(e)
93  })
94
95  on('turn.complete', async ($, e, next) => {
96    await refreshGit($)
97
98    return next(e)
99  })
100
101  on('tool.call', async ($, e, next) => {
102    const { context } = await $.session.usage()
103    const r = await next(e)
104
105    // Not awaited: the band catches up on redraw, and the tool result must not wait on ~7 git calls.
106    if (e.tool === 'Bash') void refreshGit($).catch(() => undefined)
107    if ('deny' in r) return r
108
109    return (async () => {
110      const { taskId, statePath } = await resolveRepo($)
111      const lastSavedMs = statePath
112        ? await $.fs.stat(statePath).then(stat => stat.mtimeMs).catch(() => undefined)
113        : undefined
114      const filePath = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path : undefined
115      const check: SoftStopCheck = {
116        percent: asWhole(context.percent),
117        tool: e.tool,
118        filePath,
119        command: e.tool === 'Bash' ? e.command : undefined,
120        nowMs: await $.clock.now(),
121        isNotified: await read($, isNotified),
122        taskId,
123        lastSavedMs,
124      }
125      const stop = softStop(check)
126
127      if (stop.action === 'clear') {
128        await update($, isNotified, () => false)
129      } else if (stop.action === 'notify') {
130        await update($, isNotified, () => true)
131
132        return { ...r, context: [...(r.context ?? []), stop.text] }
133      }
134
135      return r
136    })().catch(() => r)
137  })
138
139  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
140    const { context } = await $.session.usage()
141    const denial = guardAgent(asWhole(context.percent), e.tool)
142
143    return denial ? { deny: denial } : next(e)
144  }).catch(($, e, next) => next.called ? next(e) : { deny: 'session-band guard failed' })
145
146  on('prompt.compose', async ($, e, next) => {
147    hasComposed = true
148    await update($, outputStyle, () => e.outputStyle?.name ?? null)
149
150    return next(e)
151  })
152
153  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
154    if (e.props.hasSurvey) return next(e)
155
156    const [cwd, home, model, currentUsage, currentGit, capturedStyle] = await Promise.all([
157      $.session.cwd(),
158      $.env.get('HOME'),
159      $.session.model(),
160      read($, usage),
161      read($, git),
162      read($, outputStyle),
163    ])
164    if (!hasComposed) settingsStyle ??= settingStyle($).catch(() => null)
165    const style = styleForBand(capturedStyle ?? (hasComposed ? null : await settingsStyle))
166
167    return drawBand($.ui.resolve(e), {
168      dir: shortDir(cwd, home),
169      model: shortModel(model),
170      usage: currentUsage,
171      git: currentGit,
172      style,
173    })
174  })
175}
176
hooks/band.tsx 35 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { GitSnapshot, Usage } from '../types'
4import { ctxTone, limitTone, PALETTE, type Tone } from './palette'
5
6type Elements = ReturnType<EngineInterface['ui']['resolve']>
7export type BandData = { dir: string; model: string; usage: Usage; git: GitSnapshot | null; style: string | null }
8
9// Order matches the old status line: dir | git | ctx | 5h | 7d | [style] | (model)
10export const drawBand = ({ Box, Text }: Elements, d: BandData) => {
11  const tone = (t: Tone, text: string) => <Text color={PALETTE[t]}>{text}</Text>
12  const pct = (label: string, v: number | undefined, toneOf: (n: number) => Tone) =>
13    v === undefined ? null : <Text color={PALETTE[toneOf(v)]}> {label}:{v}%</Text>
14  const g = d.git
15
16  return (
17    <Box>
18      <Text>
19        {tone('peach', d.dir)}
20        {g && tone(g.isDirty ? 'yellow' : 'green', ` ${g.branch}${g.isDirty ? '*' : ''}`)}
21        {g && !g.hasUpstream && tone('muted', ' local')}
22        {g && g.ahead > 0 && tone('green', ` ahd:${g.ahead}`)}
23        {g && g.behind > 0 && tone('yellow', ` bhd:${g.behind}`)}
24        {g && g.stash > 0 && tone('peach', ` stsh:${g.stash}`)}
25        {g?.worktree && tone('muted', ` [wt:${g.worktree}]`)}
26        {pct('ctx', d.usage.ctx, ctxTone)}
27        {pct('5h', d.usage.five, limitTone)}
28        {pct('7d', d.usage.seven, limitTone)}
29        {d.style && tone('muted', ` [${d.style}]`)}
30        {tone('mauve', ` (${d.model})`)}
31      </Text>
32    </Box>
33  )
34}
35
hooks/blast.tsx 155 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { classify, type Classification, type CommandFact } from './classify'
4import { preview, type PreviewFacts, type PreviewTarget } from './preview'
5
6const hasGlob = (value: string) => /[*?[]/.test(value)
7const cannotExpandGlob = (value: string) => value.includes('**') || value.includes('{') || value.includes('}')
8
9const inClass = (source: string, value: string) => {
10  for (let index = 0; index < source.length; index += 1) {
11    const start = source[index] ?? ''
12    const end = source[index + 2]
13    if (source[index + 1] === '-' && end !== undefined) {
14      if (start <= value && value <= end) return true
15      index += 2
16    } else if (start === value) return true
17  }
18  return false
19}
20
21const matches = (pattern: string, name: string) => {
22  let patternIndex = 0
23  let nameIndex = 0
24  let starIndex = -1
25  let retryNameIndex = 0
26
27  while (nameIndex < name.length) {
28    const token = pattern[patternIndex]
29    if (token === '*') {
30      starIndex = patternIndex
31      patternIndex += 1
32      retryNameIndex = nameIndex
33    } else if (token === '?' || token === name[nameIndex]) {
34      patternIndex += 1
35      nameIndex += 1
36    } else if (token === '[') {
37      const close = pattern.indexOf(']', patternIndex + 1)
38      if (close !== -1 && inClass(pattern.slice(patternIndex + 1, close), name[nameIndex] ?? '')) {
39        patternIndex = close + 1
40        nameIndex += 1
41      } else if (starIndex !== -1) {
42        patternIndex = starIndex + 1
43        retryNameIndex += 1
44        nameIndex = retryNameIndex
45      } else return false
46    } else if (starIndex !== -1) {
47      patternIndex = starIndex + 1
48      retryNameIndex += 1
49      nameIndex = retryNameIndex
50    } else return false
51  }
52
53  while (pattern[patternIndex] === '*') patternIndex += 1
54  return patternIndex === pattern.length
55}
56
57const globParts = (target: string) => {
58  const slash = target.lastIndexOf('/')
59  if (slash === -1) return { directory: '.', pattern: target, prefix: '' }
60  const directory = slash === 0 ? '/' : target.slice(0, slash)
61  return { directory, pattern: target.slice(slash + 1), prefix: target.slice(0, slash + 1) }
62}
63
64const tracked = async ($: EngineInterface, path: string) => {
65  const result = await $.process.run(['git', 'ls-files', '--error-unmatch', '--', path]).catch(() => undefined)
66  return result?.exitCode === 0
67}
68
69const expandedTargets = async ($: EngineInterface, target: string): Promise<{ targets: PreviewTarget[]; unexpanded?: string }> => {
70  if (!hasGlob(target)) {
71    const stat = await $.fs.stat(target).catch(() => undefined)
72    if (!stat) return { targets: [{ path: target }] }
73    return { targets: [{ path: target, bytes: stat.size, tracked: await tracked($, target) }] }
74  }
75  if (cannotExpandGlob(target)) return { targets: [], unexpanded: target }
76
77  const { directory, pattern, prefix } = globParts(target)
78  const entries = await $.fs.list(directory).catch(() => undefined)
79  if (!entries) return { targets: [], unexpanded: target }
80
81  return {
82    targets: await Promise.all(entries.flatMap(entry => {
83      if (!matches(pattern, entry.name)) return []
84      const path = `${prefix}${entry.name}`
85      return [(async () => ({ path, bytes: entry.size, tracked: await tracked($, path) }))()]
86    })),
87  }
88}
89
90const dryRun = async ($: EngineInterface, commands: readonly CommandFact[]) => {
91  const command = commands.find(item => item.program === 'git' && item.args[0] === 'clean')
92  if (command) {
93    const result = await $.process.run(['git', 'clean', '-n', ...command.args.slice(1)]).catch(() => undefined)
94    if (result) return { label: 'git clean -n', output: result.stdout || result.stderr }
95  }
96
97  if (commands.some(item => item.program === 'git' && ['reset', 'checkout', 'restore'].includes(item.args[0] ?? ''))) {
98    const result = await $.process.run(['git', 'diff', '--stat']).catch(() => undefined)
99    if (result) return { label: 'git diff --stat', output: result.stdout || result.stderr }
100  }
101
102  const rsync = commands.find(item => item.program === 'rsync')
103  if (rsync) {
104    const result = await $.process.run(['rsync', '-n', ...rsync.args]).catch(() => undefined)
105    if (result) return { label: 'rsync -n', output: result.stdout || result.stderr }
106  }
107
108  return undefined
109}
110
111const previewFacts = async ($: EngineInterface, command: string, result: Classification): Promise<PreviewFacts> => {
112  const expanded = await Promise.all(result.commands.flatMap(item => item.targets).map(target => expandedTargets($, target)))
113  const targets = expanded.flatMap(item => item.targets)
114  const totalBytes = targets.reduce((total, target) => total + (target.bytes ?? 0), 0)
115
116  return {
117    command,
118    reason: result.reason ?? 'this command may have a broad effect',
119    targets,
120    totalBytes: targets.some(target => target.bytes !== undefined) ? totalBytes : undefined,
121    unexpandedGlobs: expanded.flatMap(item => item.unexpanded ? [item.unexpanded] : []),
122    dryRun: await dryRun($, result.commands),
123  }
124}
125
126const refusal = (command: string, reason: string) => `Bash command ${JSON.stringify(command)} was refused by blast-radius: ${reason}.`
127
128let isInteractive = false
129
130export const captureSession = (interactive: boolean) => {
131  isInteractive = interactive
132}
133
134export const register: Register = on => {
135  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
136    const result = classify(e.command)
137    if (result.tier === 'pass') return next(e)
138    if (result.tier === 'deny') return { deny: refusal(e.command, result.reason ?? 'it is unconditionally dangerous') }
139    if (!isInteractive) return { deny: refusal(e.command, 'nobody is available to answer the blast-radius prompt') }
140
141    const facts = await previewFacts($, e.command, result).catch(() => ({
142      command: e.command,
143      reason: result.reason ?? 'this command may have a broad effect',
144    }))
145    try {
146      const answer = await $.ui.ask(`${preview(facts)}\n\nAllow this Bash command?`, ['Allow', 'Deny'])
147      return answer === 'Allow'
148        ? next(e)
149        : { deny: refusal(e.command, 'the blast-radius prompt was not allowed') }
150    } catch {
151      return { deny: refusal(e.command, 'the blast-radius prompt was dismissed') }
152    }
153  }).catch(($, e, next) => next.called ? next(e) : { deny: 'Bash command was refused by blast-radius: the guard failed closed.' })
154}
155
hooks/clear.tsx 188 lines
1import {
2  atom,
3  read,
4  update,
5  type CommandRunInput,
6  type CommandRunResult,
7  type EngineInterface,
8  type Next,
9  type Register,
10} from 'claude-code'
11
12import { toRepoInfo, type RepoInfo } from './repo'
13import {
14  buildDigest,
15  extractSavedPath,
16  isRealWork,
17  parseReply,
18  RECENT_SAVE_MS,
19  summaryPrompt,
20  SYSTEM_PROMPT,
21  taskStateFile,
22  type ParsedSummary,
23} from './summary'
24
25const edits = atom({ plugin: 'session-band', key: 'edits' } as const, 0)
26const prompts = atom({ plugin: 'session-band', key: 'prompts' } as const, 0)
27
28const REFUSED =
29  'Clear refused: the transcript is already gone, nothing to summarise. Run save-session first, or re-run /clear.'
30const CANCELLED = 'Clear cancelled: nothing was saved.'
31const SAVE_FAILED = 'Clear cancelled: the save step failed.'
32
33const reset = async ($: EngineInterface) => {
34  await update($, edits, () => 0)
35  await update($, prompts, () => 0)
36}
37
38const resolveRepo = async ($: EngineInterface): Promise<RepoInfo> => {
39  const out = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'])
40  const [top, common] = out.stdout.trim().split('\n')
41
42  if (out.exitCode !== 0 || !top || !common) return {}
43
44  const taskId = (await $.fs.read(`${top}/.agent-task-id`).catch(() => '')).trim() || undefined
45
46  return toRepoInfo(common, taskId)
47}
48
49const sessionsDir = async ($: EngineInterface): Promise<string | undefined> => {
50  const out = await $.process.run(['agent-session', 'path'])
51  return out.exitCode === 0 && out.stdout.trim() !== '' ? out.stdout.trim() : undefined
52}
53
54// A save in the last five minutes counts as "already saved by hand": the task
55// state file's mtime in a task worktree, else the newest file in the sessions
56// directory. (The non-task statePath is .claude/session-state.md, which the CLI
57// maintains, so it is not the signal here.)
58const recentlySaved = async ($: EngineInterface, repo: RepoInfo, nowMs: number): Promise<boolean> => {
59  if (repo.taskId !== undefined && repo.statePath !== undefined) {
60    const stat = await $.fs.stat(repo.statePath).catch(() => undefined)
61    return stat !== undefined && nowMs - stat.mtimeMs <= RECENT_SAVE_MS
62  }
63
64  const dir = await sessionsDir($)
65  if (dir === undefined) return false
66
67  const entries = await $.fs.list(dir).catch(() => undefined)
68  if (entries === undefined) return false
69
70  let newest: number | undefined
71  for (const entry of entries) {
72    // Only session files count; index.json is rewritten by any save, by any harness.
73    if (entry.kind === 'file' && entry.name.endsWith('.md') && (newest === undefined || entry.mtimeMs > newest)) {
74      newest = entry.mtimeMs
75    }
76  }
77
78  return newest !== undefined && nowMs - newest <= RECENT_SAVE_MS
79}
80
81const saveSession = async ($: EngineInterface, repo: RepoInfo, summary: ParsedSummary): Promise<string> => {
82  // Task worktrees write the claimed state file; the orchestrator goes through
83  // the agent-session CLI, which resolves the sessions directory itself.
84  if (repo.statePath !== undefined && repo.taskId !== undefined) {
85    await $.fs.write(repo.statePath, taskStateFile(repo.taskId, summary.body))
86    return repo.statePath
87  }
88
89  const root = await $.session.root()
90  const saved = await $.process.run(
91    ['agent-session', 'save', '--harness', 'cc', '--goal', summary.goal, '--stdin'],
92    { cwd: root, stdin: summary.body, timeoutMs: 30_000 },
93  )
94  if (saved.exitCode !== 0) throw new Error(`agent-session exit ${saved.exitCode}`)
95
96  const path = extractSavedPath(saved.stdout)
97  if (path !== undefined) return path
98
99  const active = await $.process.run(['agent-session', 'active'])
100  if (active.exitCode === 0 && active.stdout.trim() !== '') return active.stdout.trim()
101
102  throw new Error('agent-session printed no path')
103}
104
105const fail = async (
106  $: EngineInterface,
107  e: CommandRunInput,
108  next: Next<'command.run'>,
109  reason: string,
110): Promise<CommandRunResult> => {
111  // A plugin or headless run has nobody to answer; refuse rather than hang.
112  if (e.origin.kind !== 'composer') return { text: CANCELLED }
113
114  try {
115    const answer = await $.ui.ask(`Save failed (${reason}). Clear anyway?`, ['Clear anyway', 'Cancel'])
116    if (answer === 'Clear anyway') {
117      await reset($)
118      return next(e)
119    }
120    return { text: CANCELLED }
121  } catch {
122    return { text: CANCELLED }
123  }
124}
125
126export const register: Register = on => {
127  // The module's own counters: real work is a successful edit tool or enough
128  // prompts. The transcript may already be wiped by the time /clear runs.
129  on('prompt.submit', async ($, e, next) => {
130    await update($, prompts, n => n + 1)
131    return next(e)
132  })
133
134  on('tool.call', { tool: ['Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
135    const result = await next(e)
136    if (!('deny' in result)) {
137      await update($, edits, n => n + 1)
138    }
139    return result
140  })
141
142  on('command.run', { command: 'clear' }, async ($, e, next) => {
143    const [editCount, promptCount] = await Promise.all([read($, edits), read($, prompts)])
144    if (!isRealWork({ edits: editCount, prompts: promptCount })) {
145      await reset($)
146      return next(e)
147    }
148
149    const repo = await resolveRepo($)
150    const nowMs = await $.clock.now()
151    if (await recentlySaved($, repo, nowMs)) {
152      await reset($)
153      return next(e)
154    }
155
156    const messages = await $.session.messages()
157    if (messages.length === 0) {
158      // Counters say work happened but the transcript is gone: refuse, and do
159      // not reset, so a later save + /clear still reaches the recent-save skip.
160      return { text: REFUSED }
161    }
162
163    const reply = await $.model.complete({
164      model: 'sonnet',
165      system: SYSTEM_PROMPT,
166      prompt: summaryPrompt(buildDigest(messages)),
167      maxTokens: 2000,
168      effort: 'low',
169      timeoutMs: 60_000,
170    })
171
172    const summary = reply.isAnswered ? parseReply(reply.text) : undefined
173    if (summary === undefined) {
174      return fail($, e, next, reply.isAnswered ? 'the summary was not parseable' : `model ${reply.reason}`)
175    }
176
177    try {
178      const path = await saveSession($, repo, summary)
179      await reset($)
180      // The answer's text, not a toast: /clear ends the session and the toast went with it.
181      const result = await next(e)
182      return { ...result, text: `Session saved: ${path}` }
183    } catch (error) {
184      return fail($, e, next, String(error))
185    }
186  }).catch(($, e, next) => next.called ? next(e) : { text: SAVE_FAILED })
187}
188
hooks/format.ts 14 lines
1// Last two path segments, with ~ for home; a project root shows its basename alone.
2export const shortDir = (cwd: string, home: string | undefined): string => {
3  if (home && cwd === home) return '~'
4  const rel = home && cwd.startsWith(`${home}/`) ? cwd.slice(home.length + 1) : undefined
5  const parts = (rel ?? cwd).split('/').filter(Boolean)
6  if (rel !== undefined && parts.length <= 1) return rel
7  return parts.slice(-2).join('/')
8}
9
10// claude-opus-5-5 -> opus-5-5, as the status line did with display names.
11export const shortModel = (model: string): string => model.replace(/^claude-/, '')
12
13export const whole = (pct: number): number => Math.round(pct)
14
hooks/git.ts 35 lines
1import type { GitSnapshot } from '../types'
2
3// Raw outputs of the git calls register.tsx makes. The engine does not let `$` cross an
4// import, so the process calls stay in register.tsx and only this parsing lives here.
5export type GitFacts = {
6  branch?: string
7  isDirty: boolean
8  upstream?: string
9  counts?: string
10  stash?: string
11  gitDir?: string
12  common?: string
13}
14
15// Same facts as the retired shell line: branch, dirty, ahead/behind, stash, worktree.
16export const toSnapshot = (f: GitFacts): GitSnapshot | null => {
17  if (!f.branch) return null
18
19  const [behind = 0, ahead = 0] = (f.counts || '').split(/\s+/).map(Number)
20  const isLinked = !!f.gitDir && !!f.common && f.gitDir !== f.common
21  // A linked worktree's git dir is <main>/.git/worktrees/<name>; the shell script took
22  // the parent of that and so always printed "worktrees".
23  const worktree = isLinked ? f.gitDir?.split('/').pop()?.replace(/^worktree-/, '') : undefined
24
25  return {
26    branch: f.branch,
27    isDirty: f.isDirty,
28    ahead,
29    behind,
30    hasUpstream: !!f.upstream,
31    stash: f.stash ? f.stash.split('\n').length : 0,
32    worktree,
33  }
34}
35
hooks/repo.ts 16 lines
1export type RepoInfo = { taskId?: string; statePath?: string }
2
3// Mirrors the shell hook: an agent worktree carries .agent-task-id, and its
4// state lives in the main repo's .agents/claimed/, else .claude/session-state.md.
5// `common` is the absolute git-common-dir; its dirname is the main repo.
6export const toRepoInfo = (common: string, taskId: string | undefined): RepoInfo => {
7  const mainRepo = common.slice(0, common.lastIndexOf('/'))
8
9  return {
10    taskId,
11    statePath: taskId
12      ? `${mainRepo}/.agents/claimed/${taskId}.state.md`
13      : `${mainRepo}/.claude/session-state.md`,
14  }
15}
16
hooks/threshold.ts 40 lines
1import { DENY_AGENT, firstCrossing, stillOver } from './messages'
2
3export const THRESHOLD = 70
4export const SAVE_DEBOUNCE_MS = 5 * 60 * 1000
5
6const SAVE_PATHS = ['session-state.md', '.state.md', 'agents/claimed/', 'memory/', 'MEMORY.md', 'log.md']
7const SAVE_COMMANDS = ['session-state.md', 'agents/claimed/', '.state.md']
8
9export type SoftStopCheck = {
10  percent?: number
11  tool: string
12  filePath?: string
13  command?: string
14  lastSavedMs?: number
15  nowMs: number
16  isNotified: boolean
17  taskId?: string
18}
19
20export type SoftStop = { action: 'pass' } | { action: 'clear' } | { action: 'notify'; text: string }
21
22// PreToolUse half: only Agent spawns are refused, so save workflows never stall.
23export const guardAgent = (percent: number | undefined, tool: string): string | undefined =>
24  tool === 'Agent' && percent !== undefined && percent >= THRESHOLD ? DENY_AGENT : undefined
25
26const isSaveCritical = (c: SoftStopCheck): boolean =>
27  SAVE_PATHS.some(p => c.filePath?.includes(p)) ||
28  (c.tool === 'Bash' && SAVE_COMMANDS.some(p => c.command?.includes(p)))
29
30// PostToolUse half: one directive per crossing, a shorter re-fire while still over.
31export const softStop = (c: SoftStopCheck): SoftStop => {
32  if (c.percent === undefined) return { action: 'pass' }
33  if (c.percent < THRESHOLD) return { action: 'clear' }
34  if (isSaveCritical(c)) return { action: 'pass' }
35  // A save in the last 5 minutes is probably a post-clear false positive.
36  if (c.lastSavedMs !== undefined && c.nowMs - c.lastSavedMs <= SAVE_DEBOUNCE_MS) return { action: 'pass' }
37
38  return { action: 'notify', text: (c.isNotified ? stillOver : firstCrossing)(c.taskId) }
39}
40
hooks/palette.ts 20 lines
1// Gruvbox Material Dark Soft, the retired shell line's ANSI-256 values as hex.
2export const PALETTE = {
3  peach: '#d7875f',
4  green: '#a9b665',
5  teal: '#87afaf',
6  mauve: '#d787af',
7  yellow: '#d8a657',
8  red: '#d75f5f',
9  muted: '#878787',
10} as const
11
12export type Tone = keyof typeof PALETTE
13
14// Context warns early (the threshold guard fires at 70); limits warn late.
15export const ctxTone = (pct: number): Tone =>
16  pct >= 70 ? 'red' : pct >= 40 ? 'yellow' : 'green'
17
18export const limitTone = (pct: number): Tone =>
19  pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green'
20
hooks/classify.ts 359 lines
1export type Tier = 'pass' | 'ask' | 'deny'
2
3type Separator = ';' | '&&' | '||' | '|' | '&' | '\n'
4
5type Word = {
6  value: string
7  dynamic: boolean
8  // The word embeds a quoted-heredoc substitution, replaced by a literal placeholder.
9  heredoc?: true
10}
11
12export type CommandFact = {
13  command: string
14  program?: string
15  args: string[]
16  targets: string[]
17}
18
19export type Classification = {
20  tier: Tier
21  reason?: string
22  commands: CommandFact[]
23}
24
25type Segment = {
26  words: Word[]
27  separator?: Separator
28}
29
30type Tokenized = {
31  segments: Segment[]
32  cannotAnalyze?: boolean
33}
34
35const SYSTEM_DIRECTORY = /^\/(?:usr|etc|bin|System|Users)\/?$/
36const SHELL = new Set(['sh', 'bash', 'zsh'])
37const TIER_ORDER: Record<Tier, number> = { pass: 0, ask: 1, deny: 2 }
38
39const basename = (value: string) => value.split('/').filter(Boolean).at(-1) ?? value
40
41const isAssignment = (value: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(value)
42
43const hasRecursiveFlag = (args: readonly string[]) => args.some(arg => arg === '--recursive' || /^-[^-]*[rR]/.test(arg))
44
45const hasForceFlag = (args: readonly string[]) => args.some(arg => arg === '--force' || arg === '--force-with-lease' || /^-[^-]*f/.test(arg))
46
47const nonOptions = (args: readonly string[]) => {
48  const values: string[] = []
49  let afterDoubleDash = false
50  for (const arg of args) {
51    if (arg === '--') {
52      afterDoubleDash = true
53      continue
54    }
55    if (afterDoubleDash || !arg.startsWith('-') || arg === '-') values.push(arg)
56  }
57  return values
58}
59
60const stripLauncher = (words: readonly Word[]): Word[] => {
61  let remaining = [...words]
62
63  while (remaining.length > 0) {
64    while (remaining[0] && isAssignment(remaining[0].value)) remaining = remaining.slice(1)
65    const launcher = basename(remaining[0]?.value ?? '')
66
67    if (launcher === 'rtk') {
68      remaining = remaining.slice(1)
69      if (remaining[0]?.value === 'proxy') remaining = remaining.slice(1)
70      continue
71    }
72
73    if (launcher === 'command' || launcher === 'builtin' || launcher === 'exec' || launcher === 'nohup' || launcher === 'time') {
74      remaining = remaining.slice(1)
75      while (remaining[0]?.value.startsWith('-')) remaining = remaining.slice(1)
76      continue
77    }
78
79    if (launcher === 'env') {
80      remaining = remaining.slice(1)
81      while (remaining[0] && (remaining[0].value.startsWith('-') || isAssignment(remaining[0].value))) remaining = remaining.slice(1)
82      continue
83    }
84
85    if (launcher === 'sudo') {
86      remaining = remaining.slice(1)
87      while (remaining[0]?.value.startsWith('-')) {
88        const option = remaining[0].value
89        remaining = remaining.slice(1)
90        if (['-u', '-g', '-h', '-C', '--user', '--group', '--host', '--chdir', '--close-from', '--prompt', '--role', '--type'].includes(option)) {
91          remaining = remaining.slice(1)
92        }
93      }
94      continue
95    }
96
97    if (launcher === 'xargs') {
98      remaining = remaining.slice(1)
99      while (remaining[0]?.value.startsWith('-')) {
100        const option = remaining[0].value
101        remaining = remaining.slice(1)
102        if (['-a', '-d', '-E', '-e', '-I', '-i', '-L', '-l', '-n', '-P', '-s', '--arg-file', '--delimiter', '--eof', '--replace', '--max-lines', '--max-args', '--max-procs', '--max-chars'].includes(option)) {
103          remaining = remaining.slice(1)
104        }
105      }
106      continue
107    }
108
109    break
110  }
111
112  return remaining
113}
114
115const WHOLE_TREE = new Set(['/', '/*', '~', '~/', '~/*', '$HOME', '$HOME/', '$HOME/*'])
116
117const isAbsoluteOrHome = (target: string) =>
118  target.startsWith('/') || target === '~' || target.startsWith('~/') || target === '$HOME' || target.startsWith('$HOME/')
119
120// Deny the whole tree or a top-level system directory itself. Anything below
121// them is a question, unless it is the old hook's `rm -rf <absolute or home>`.
122const isDangerousRmTarget = (target: string, recursiveAndForce: boolean) =>
123  WHOLE_TREE.has(target) || SYSTEM_DIRECTORY.test(target) || (recursiveAndForce && isAbsoluteOrHome(target))
124
125const isMainRef = (value: string) => {
126  const destination = value.replace(/^\+/, '').split(':').at(-1) ?? ''
127  const branch = destination.replace(/^refs\/heads\//, '')
128
129  return branch === 'main' || branch === 'master'
130}
131
132const isShell = (program: string | undefined) => program !== undefined && SHELL.has(program)
133
134// `$(cat <<'TAG' ... TAG)` with a quoted tag expands nothing: the body is literal text.
135// Returns how many characters it spans, or undefined when it is anything else, including a
136// body holding a line equal to the tag (the shell would end the heredoc there and run the rest).
137const HEREDOC = /^\$\(cat <<'(\w+)'\n([\s\S]*?)\n\1[ \t]*\n?[ \t]*\)/
138
139const literalHeredoc = (rest: string): number | undefined => {
140  const match = HEREDOC.exec(rest)
141  if (!match) return undefined
142
143  return (match[2] ?? '').split('\n').includes(match[1] ?? '') ? undefined : match[0].length
144}
145
146// Only a commit, tag or note message, or a gh pr/issue/release body, may carry one: anywhere
147// else the text could be run (`bash -c`, `python -c`, `git rebase --exec`).
148const takesLiteralHeredoc = (program: string | undefined, args: readonly string[]) =>
149  (program === 'git' && ['commit', 'tag', 'notes'].includes(args[0] ?? '')) ||
150  (program === 'gh' && ['pr', 'issue', 'release'].includes(args[0] ?? ''))
151
152const containsOverwrite = (args: readonly string[]) => args.some((arg, index) => {
153  if (arg !== '>' && arg !== '>>') return false
154  const target = args[index + 1]
155  return target !== '/dev/null' && !/^&\d+$/.test(target ?? '')
156})
157
158const factFor = (segment: Segment): CommandFact => {
159  const normalized = stripLauncher(segment.words)
160  const program = normalized[0] ? basename(normalized[0].value) : undefined
161  const args = normalized.slice(1).map(word => word.value)
162  const values = nonOptions(args)
163  let targets: string[] = []
164
165  if (program === 'rm' || program === 'chmod' || program === 'find') targets = values
166  if (program === 'mv') targets = values.slice(-1)
167  if (program === 'git' && ['clean', 'checkout', 'restore'].includes(args[0] ?? '')) targets = values.slice(1)
168  if (containsOverwrite(args)) {
169    targets.push(...args.flatMap((arg, index) => {
170      const target = args[index + 1]
171      return (arg === '>' || arg === '>>') && target !== undefined && target !== '/dev/null' && !/^&\d+$/.test(target) ? [target] : []
172    }))
173  }
174
175  return { command: segment.words.map(word => word.value).join(' '), program, args, targets }
176}
177
178const classifySegment = (segment: Segment): Classification => {
179  const fact = factFor(segment)
180  const { program, args } = fact
181  const values = nonOptions(args)
182
183  if (segment.words.some(word => word.heredoc) && !takesLiteralHeredoc(program, args)) {
184    return { tier: 'ask', reason: "can't analyze a heredoc outside a git message or gh body", commands: [fact] }
185  }
186  if (containsOverwrite(args)) return { tier: 'ask', reason: 'overwriting a redirect', commands: [fact] }
187  if (program === 'eval') return { tier: 'ask', reason: "can't analyze eval", commands: [fact] }
188  if (isShell(program) && (args.includes('<<') || args.includes('<<-'))) return { tier: 'ask', reason: "can't analyze a here-doc feeding a shell", commands: [fact] }
189  if (segment.separator === '|' && isShell(program)) return { tier: 'ask', reason: "can't analyze a pipe into a shell", commands: [fact] }
190  if (isShell(program) && args.includes('-c')) {
191    const body = args[args.indexOf('-c') + 1]
192    const word = stripLauncher(segment.words).at(args.indexOf('-c') + 2)
193    if (!body || word?.dynamic) return { tier: 'ask', reason: "can't analyze a dynamic shell body", commands: [fact] }
194  }
195
196  if (program === 'rm') {
197    const recursiveAndForce = hasRecursiveFlag(args) && hasForceFlag(args)
198    if (values.some(target => isDangerousRmTarget(target, recursiveAndForce))) {
199      return { tier: 'deny', reason: 'rm targets a root, home, or system path', commands: [fact] }
200    }
201    return { tier: 'ask', reason: 'rm deletes files', commands: [fact] }
202  }
203
204  if (program === 'git') {
205    const subcommand = args[0]
206    // `+ref` forces by refspec without any flag; `HEAD:main` names main as the destination.
207    const refs = values.slice(1)
208    if (subcommand === 'push' && (hasForceFlag(args) || refs.some(ref => ref.startsWith('+'))) && refs.some(isMainRef)) {
209      return { tier: 'deny', reason: 'force-push targets main or master', commands: [fact] }
210    }
211    if (subcommand === 'reset' && args.includes('--hard')) {
212      const ref = values.slice(1).at(-1)
213      if (ref?.startsWith('origin/') || ref === '@{u}') return { tier: 'deny', reason: 'hard reset targets a remote upstream ref', commands: [fact] }
214      return { tier: 'ask', reason: 'git reset --hard discards work', commands: [fact] }
215    }
216    if (subcommand === 'clean') return { tier: 'ask', reason: 'git clean deletes files', commands: [fact] }
217    if ((subcommand === 'checkout' || subcommand === 'restore') && values.slice(1).includes('.')) return { tier: 'ask', reason: `git ${subcommand} . discards work`, commands: [fact] }
218    if (subcommand === 'stash' && ['drop', 'clear'].includes(args[1] ?? '')) return { tier: 'ask', reason: `git stash ${args[1]} discards work`, commands: [fact] }
219  }
220
221  if (program === 'mv') return { tier: 'ask', reason: 'mv may overwrite its destination', commands: [fact] }
222  if (program === 'find' && args.includes('-delete')) return { tier: 'ask', reason: 'find -delete removes files', commands: [fact] }
223  if (program === 'chmod' && hasRecursiveFlag(args)) return { tier: 'ask', reason: 'chmod -R changes permissions recursively', commands: [fact] }
224  if ((program === 'docker' || program === 'kubectl') && args.some(arg => ['rm', 'rmi', 'prune', 'delete', 'kill'].includes(arg))) {
225    return { tier: 'ask', reason: `${program} deletes resources`, commands: [fact] }
226  }
227
228  return { tier: 'pass', commands: [fact] }
229}
230
231export const tokenize = (command: string): Tokenized => {
232  const segments: Segment[] = []
233  let words: Word[] = []
234  let word = ''
235  let dynamic = false
236  let heredoc = false
237  let quote: 'single' | 'double' | undefined
238  let separator: Separator | undefined
239  let cannotAnalyze = false
240
241  const pushWord = () => {
242    if (word) words.push(heredoc ? { value: word, dynamic, heredoc: true } : { value: word, dynamic })
243    word = ''
244    dynamic = false
245    heredoc = false
246  }
247  const pushSegment = (nextSeparator: Separator) => {
248    pushWord()
249    if (words.length > 0) segments.push({ words, separator })
250    words = []
251    separator = nextSeparator
252  }
253
254  for (let index = 0; index < command.length; index += 1) {
255    const char = command[index] ?? ''
256    const next = command[index + 1]
257
258    if (char === '\\' && quote !== 'single') {
259      if (next === undefined) {
260        cannotAnalyze = true
261      } else {
262        word += next
263        index += 1
264      }
265      continue
266    }
267
268    if (char === "'" && quote !== 'double') {
269      quote = quote === 'single' ? undefined : 'single'
270      continue
271    }
272    if (char === '"' && quote !== 'single') {
273      quote = quote === 'double' ? undefined : 'double'
274      continue
275    }
276
277    if (quote !== 'single' && char === '$' && next === '(') {
278      const length = literalHeredoc(command.slice(index))
279      if (length) {
280        word += '<heredoc>'
281        heredoc = true
282        index += length - 1
283        continue
284      }
285    }
286
287    if (quote !== 'single' && char === '`') cannotAnalyze = true
288    if (quote !== 'single' && char === '$') {
289      dynamic = true
290      if (next === '(') cannotAnalyze = true
291    }
292
293    if (quote) {
294      word += char
295      continue
296    }
297
298    if (char === '>' || (char === '<' && next === '<')) {
299      pushWord()
300      if (char === '<') {
301        words.push({ value: next === '<' && command[index + 2] === '-' ? '<<-' : '<<', dynamic: false })
302        index += next === '<' && command[index + 2] === '-' ? 2 : 1
303      } else {
304        words.push({ value: next === '>' ? '>>' : '>', dynamic: false })
305        if (next === '>') index += 1
306        if (command[index + 1] === '&') {
307          let target = '&'
308          index += 1
309          while (/\d/.test(command[index + 1] ?? '')) {
310            target += command[index + 1]
311            index += 1
312          }
313          words.push({ value: target, dynamic: false })
314        }
315      }
316      continue
317    }
318
319    if (/\s/.test(char)) {
320      if (char === '\n') pushSegment('\n')
321      else pushWord()
322      continue
323    }
324
325    if (char === ';') {
326      pushSegment(';')
327      continue
328    }
329    if (char === '|' || char === '&') {
330      const joined = next === char ? `${char}${char}` as Separator : char as Separator
331      pushSegment(joined)
332      if (next === char) index += 1
333      continue
334    }
335
336    word += char
337  }
338
339  pushWord()
340  if (words.length > 0) segments.push({ words, separator })
341  if (quote) cannotAnalyze = true
342
343  return { segments, cannotAnalyze }
344}
345
346export const classify = (command: string): Classification => {
347  const parsed = tokenize(command)
348  if (parsed.cannotAnalyze) return { tier: 'ask', reason: "can't analyze this command", commands: parsed.segments.map(factFor) }
349
350  return parsed.segments.reduce<Classification>((strictest, segment) => {
351    const candidate = classifySegment(segment)
352    if (TIER_ORDER[candidate.tier] > TIER_ORDER[strictest.tier]) return {
353      ...candidate,
354      commands: [...strictest.commands, ...candidate.commands],
355    }
356    return { ...strictest, commands: [...strictest.commands, ...candidate.commands] }
357  }, { tier: 'pass', commands: [] })
358}
359
hooks/preview.ts 60 lines
1export type PreviewTarget = {
2  path: string
3  bytes?: number
4  tracked?: boolean
5}
6
7export type PreviewFacts = {
8  command: string
9  reason: string
10  targets?: readonly PreviewTarget[]
11  totalBytes?: number
12  unexpandedGlobs?: readonly string[]
13  dryRun?: {
14    label: string
15    output: string
16  }
17}
18
19const MAX_LINES = 40
20
21const bytes = (value: number) => {
22  if (value < 1024) return `${value} B`
23  if (value < 1024 * 1024) return `${Math.round(value / 1024)} KiB`
24  return `${(value / (1024 * 1024)).toFixed(1)} MiB`
25}
26
27const cap = (lines: readonly string[]) => {
28  if (lines.length <= MAX_LINES) return [...lines]
29
30  const omitted = lines.length - (MAX_LINES - 1)
31  return [...lines.slice(0, MAX_LINES - 1), `… ${omitted} preview lines omitted`]
32}
33
34export const preview = (facts: PreviewFacts): string => {
35  const targets = facts.targets ?? []
36  const lines = [
37    'Blast-radius check',
38    `Command: ${facts.command}`,
39    `Why: ${facts.reason}`,
40    `Targets: ${targets.length}${facts.totalBytes === undefined ? '' : `, ${bytes(facts.totalBytes)} total`}`,
41  ]
42
43  for (const target of targets) {
44    const size = target.bytes === undefined ? '' : ` (${bytes(target.bytes)})`
45    let tracked = ''
46    if (target.tracked === true) tracked = ', git-tracked'
47    if (target.tracked === false) tracked = ', not git-tracked'
48    lines.push(`  ${target.path}${size}${tracked}`)
49  }
50
51  for (const glob of facts.unexpandedGlobs ?? []) lines.push(`Glob not expanded: ${glob}`)
52
53  if (facts.dryRun) {
54    lines.push(`Dry run (${facts.dryRun.label}):`)
55    lines.push(...facts.dryRun.output.split('\n').flatMap(line => line ? [`  ${line}`] : []))
56  }
57
58  return cap(lines).join('\n')
59}
60
hooks/summary.ts 148 lines
1// Pure logic for the /clear auto-save flow. No engine imports: every `$` call
2// (`$.process`, `$.fs`, `$.model`, `$.ui`) stays in clear.tsx.
3
4export const REAL_WORK_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
5export const MIN_PROMPTS_FOR_REAL_WORK = 4
6export const RECENT_SAVE_MS = 5 * 60 * 1000
7
8export const DIGEST_LAST_MESSAGES = 30
9export const DIGEST_MESSAGE_CHARS = 600
10export const DIGEST_CAP = 30_000
11// Latest failures only: the oldest add the least and, uncapped, crowd the
12// recent messages out of the final cut.
13export const DIGEST_MAX_FAILURES = 10
14
15export const HEADINGS = [
16  'Completed',
17  'In Progress',
18  'Decisions Made',
19  'Blocked / Needs Input',
20  'Files Modified This Session',
21  'Next Session Should',
22] as const
23
24export type Counters = { edits: number; prompts: number }
25
26export type DigestToolUse = {
27  tool: string
28  input: Record<string, unknown>
29  isError?: true
30  text?: string
31}
32
33export type DigestToolResult = {
34  isError?: boolean
35  text: string
36}
37
38export type DigestMessage = {
39  role: 'user' | 'assistant'
40  text: string
41  toolUses: readonly DigestToolUse[]
42  toolResults?: readonly DigestToolResult[]
43}
44
45export type ParsedSummary = { goal: string; body: string }
46
47const cut = (text: string, limit: number): string =>
48  text.length <= limit ? text : `${text.slice(0, limit - 1)}…`
49
50// Real work is one successful edit tool, or a handful of prompts; counted by
51// clear.tsx's own counters, never the transcript (it may already be wiped).
52export const isRealWork = (counters: Counters): boolean =>
53  counters.edits >= 1 || counters.prompts >= MIN_PROMPTS_FOR_REAL_WORK
54
55// A bounded digest of the transcript for the summariser: the first user
56// prompt, the edited files, the failing tool results, and the last few
57// messages, each cut and the whole capped near DIGEST_CAP characters.
58export const buildDigest = (messages: readonly DigestMessage[]): string => {
59  const firstPrompt = messages.find(m => m.role === 'user' && m.text.trim() !== '')?.text ?? ''
60
61  const files = new Set<string>()
62  const failures: string[] = []
63
64  for (const message of messages) {
65    for (const use of message.toolUses) {
66      if (REAL_WORK_TOOLS.has(use.tool)) {
67        const filePath = use.input.file_path
68        if (typeof filePath === 'string' && filePath.trim() !== '') files.add(filePath)
69      }
70      if (use.isError) failures.push(cut(use.text ?? '', DIGEST_MESSAGE_CHARS))
71    }
72    for (const result of message.toolResults ?? []) {
73      if (result.isError) failures.push(cut(result.text, DIGEST_MESSAGE_CHARS))
74    }
75  }
76
77  const shownFailures = failures.slice(-DIGEST_MAX_FAILURES)
78
79  const recent = messages
80    .slice(-DIGEST_LAST_MESSAGES)
81    .map(m => `[${m.role}] ${cut(m.text, DIGEST_MESSAGE_CHARS)}`)
82
83  const parts = [
84    `First user prompt: ${cut(firstPrompt, DIGEST_MESSAGE_CHARS)}`,
85    files.size === 0
86      ? 'Files edited: (none)'
87      : `Files edited:\n${[...files].sort().map(f => `- ${f}`).join('\n')}`,
88    shownFailures.length === 0
89      ? 'Failing tool results: (none)'
90      : `Failing tool results:\n${shownFailures.map(f => `- ${f}`).join('\n')}`,
91    `Recent messages (${recent.length}):\n${recent.join('\n')}`,
92  ]
93
94  return cut(parts.join('\n\n'), DIGEST_CAP)
95}
96
97// Parses the model's reply: a `GOAL:` line then the six headings, in any
98// order. Returns undefined when the goal line or any heading is missing, so
99// a broken reply is never saved.
100export const parseReply = (text: string): ParsedSummary | undefined => {
101  const lines = text.split('\n')
102  const first = lines.find(line => line.trim() !== '')
103  if (first === undefined) return undefined
104
105  const match = /^GOAL:\s*(.+)$/i.exec(first.trim())
106  if (match === null) return undefined
107  const goal = (match[1] ?? '').trim()
108  if (goal === '') return undefined
109
110  const body = lines.slice(lines.indexOf(first) + 1).join('\n').trim()
111  if (body === '') return undefined
112
113  const missing = HEADINGS.find(heading => !body.includes(`## ${heading}`))
114  if (missing !== undefined) return undefined
115
116  return { goal, body }
117}
118
119// A task worktree's state file: frontmatter first (startup rules read it),
120// then the summary body.
121export const taskStateFile = (taskId: string, body: string): string =>
122  `---\nstatus: active\nagent_task: ${taskId}\n---\n\n${body}\n`
123
124// `agent-session save` prints "✓ Saved: <path>"; this picks the path back out.
125export const extractSavedPath = (stdout: string): string | undefined =>
126  /\.agents\/sessions\/[^\s]*\.md/.exec(stdout)?.[0]
127
128export const SYSTEM_PROMPT = `You summarise a finished Claude Code session into a handoff document.
129You see only a bounded digest of the transcript; never invent facts it does not contain.
130
131Reply in exactly this shape. The first line is the goal, then the six headings,
132in this order, each with bullet lines (write "nothing" under a heading that has no content):
133
134GOAL: <one sentence describing what the session set out to do>
135
136## Completed
137## In Progress
138## Decisions Made
139## Blocked / Needs Input
140## Files Modified This Session
141## Next Session Should
142
143Every one of the six headings must appear exactly once, spelled exactly as above.
144Add no other top-level heading, preamble or trailing text.`
145
146export const summaryPrompt = (digest: string): string =>
147  `Summarise this session from the digest below, in the format the system prompt specifies.\n\n${digest}`
148