Configurable tripwires for the rules you keep forgetting, and a handoff card of open loops left by earlier sessions

Five Claude Code mods built from how I actually use Claude Code: four to six sessions at a time, mostly in one repo, a lot of PRs going to production, and a pile of rules I keep forgetting to check.
Claude built all five. The full story, with screenshots, videos and the bugs a real run caught, is on my site: I asked Claude to read 30 of my Claude Code sessions and build the mods I needed

This is what I gave Claude Code. It read my last 30 sessions, came back with ten ideas, and built all ten as these five mods.
look at the last 30 sessions and see how we use claude to work - then look at https://code.claude.com/docs/en/plugins/mods/overview and suggest 10 possible mods for our workflow that would help me be better informed as to what each session is doing.
Run it on your own sessions. Your ten ideas will probably come out different from mine, and I'd like to see them.
| Mod | What it gives you | Commands |
|---|---|---|
| session-fleet | A board of every live session, a "needs you" row when another session is blocked on you, and a guard when two sessions write to the same worktree | /fleet |
| ship-tracker | A row per PR: CI → merged → production deployment → live, a warning when a merge never deploys, and a receipt line after every answer | /ship-track <pr> |
| session-hygiene | Configurable tripwires (typecheck before push, review before push, mutation-check new tests and more), plus a handoff card of loose ends from earlier sessions | /handoff, /handoff clear |
| session-activity | A ledger of everything that left the machine, a hold on database writes, and what each session is waiting on | /ledger, /waiting |
| context-gauge | Context fill in the status line, warnings before compaction, and a snapshot of in-flight work that survives compaction | /context-log |
They work on their own. Install any one, or all five.
You need a Claude Code version with mods (see the mods docs), git, and for ship-tracker and the handoff card, the GitHub CLI signed in.
A mod is code that runs with your permissions inside Claude Code. Read it before you install it. Each one is a single hooks/register.tsx file, and claude plugin validate <folder> lists exactly which events it hooks and what it calls.
In a Claude Code session:
/plugin marketplace add bennewton999/claude-code-mods
/plugin install session-fleet@bennewton-mods
/plugin install ship-tracker@bennewton-mods
/plugin install session-hygiene@bennewton-mods
/plugin install session-activity@bennewton-mods
/plugin install context-gauge@bennewton-mods
Then /reload-plugins, or start a new session.
git clone https://github.com/bennewton999/claude-code-mods ~/claude-code-mods
Load them in every session by adding the folders to env in ~/.claude/settings.json (colon-separated):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-code-mods/session-fleet:/Users/you/claude-code-mods/ship-tracker:/Users/you/claude-code-mods/session-hygiene:/Users/you/claude-code-mods/session-activity:/Users/you/claude-code-mods/context-gauge",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
Or try one for a single session:
claude --plugin-dir ~/claude-code-mods/session-fleet
CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop app sessions hot-reload a mod when its files change (an interactive terminal session already does). A session reads it at startup, so sessions that were already open pick up the mods, and the watch, after one restart.
Every session writes one record to a key-value store that all sessions on the machine share ($.store), with a heartbeat every 30 seconds. Three minutes without a heartbeat and the session counts as gone.
/fleet opens a pane with every live session: a status dot, its first prompt (there's no session title API), the worktrees it's touching, branch, PR, last action and how long ago. Waiting sessions sort to the top, and a worktree two live sessions share gets a ⚠.projectPills to Label:regex:color entries separated by ;, the regex tested against the repo folder name: "pluginConfigs": {
"session-fleet": {
"options": { "projectPills": "Work:acme|billing:#2563eb; Blog:blog:#059669" }
}
}

gh pr output, or add one with /ship-track 1107 or /ship-track owner/repo#1107.no prod deploy!. That's the silent Vercel git-trigger failure that got me before.
Tripwires for rules you keep forgetting. When one trips you get a transcript line and a toast, and Claude gets a reminder with the tool result so it can act on it.
Every rule is a plugin option. You set them on the install screen, or later in /config. Out of the box only the two generic rules are on; the rest stay off until you point them at your repos.
| Option | Default | What it does |
|---|---|---|
holdEnvSourcing | on | Asks Run it or Stop before a command sources a .env file into the shell |
mutationCheckTests | on | When Claude writes a new test file, reminds it to prove the test fails without the code it covers |
reviewBeforePush | off | Flags a git push with edits made since the last local /code-review |
uncheckedRepos | none | Path fragments (/my-app/) of repos whose CI doesn't typecheck or build. There it flags a push after TypeScript edits with no typecheck, and a merge after package.json or next.config changes with no local build |
middlewareRouteRepos | none | Next.js repos whose src/middleware.ts allowlists routes. A new top-level src/app/<route>/page.tsx gets a reminder to add it |
bannedStrings | none | Case-insensitive text that should never be written into a file |
supabase | off | Asks before a command uses a service-role key; after apply_migration, reminds Claude to run NOTIFY pgrst, 'reload schema' and to revoke default grants on new tables |
If you load the mod from a clone instead of the marketplace, set the same options in ~/.claude/settings.json:
{
"pluginConfigs": {
"session-hygiene": {
"options": {
"uncheckedRepos": ["/my-app/"],
"bannedStrings": ["lorem ipsum"],
"supabase": true
}
}
}
}
The handoff card: after each turn a session saves its open loops (open PRs, uncommitted or unpushed worktrees, a question it left you). A new session lists loops from sessions that ended or went quiet, re-checked live first. /handoff shows them again, /handoff clear dismisses them.

/ledger lists everything that left the machine: MCP write tools, git push, gh pr create and merge, gh api writes, Vercel deploys, curl writes, publishes, SQL writes. A button switches to all sessions in the last 24 hours. Reads stay out of it.execute_sql that writes (INSERT, UPDATE, DELETE, DDL, GRANT…) stops for a Run it or Stop question. SELECTs and NOTIFY pass. Words inside comments and quoted strings don't count.Thinking · 2 bg · agent 3m…), and a row above the prompt lists background commands, agents, workflows and monitors still running after Claude stops. /waiting lists them, /waiting clear resets.
ctx 62% · 124k/200k · compacted 1×./context-log lists every compaction this session with its snapshot.
Everything these mods record stays on your machine in Claude Code's per-plugin store. The only network calls are gh requests to GitHub for PR and deployment state (ship-tracker, and the handoff card's re-check).
/ledger reported the pane as opened but it never drew. It works in fresh sessions; the built-in diff pane seemed to be in front of it.I'm Ben Newton. I write about building with AI at benenewton.com, and I'm building BlackOps Center, the platform that runs my site, my posts and the notes these Claude sessions write into. If you want to see it, start at blackopscenter.com/start.
Claude Code wrote the mods with its built-in plugin-authoring skill. Issues and PRs are welcome.
MIT licensed.
hooks/register.tsx 458 lines1import type { EngineInterface as Engine, Register } from 'claude-code'
2
3// session-hygiene:
4// 8. tripwires, configured through the plugin's options (install screen or
5// /config): a dim transcript line + toast for the user, and a reminder
6// Claude reads after the tool result. Two rules hold the call with a
7// question instead (sourcing env files, a Supabase service-role key).
8// 10. handoff card: each session snapshots its open loops (open PRs, dirty or
9// unpushed worktrees, a question left unanswered); a new session lists the
10// ones still open, re-checked live. /handoff shows them again.
11
12// ---- config: every rule is set by the plugin's userConfig options ----------------
13type Config = {
14 holdEnvSourcing: boolean
15 mutationCheckTests: boolean
16 reviewBeforePush: boolean
17 uncheckedRepos: string[]
18 middlewareRouteRepos: string[]
19 bannedStrings: string[]
20 supabase: boolean
21}
22let cfg: Config = {
23 holdEnvSourcing: true,
24 mutationCheckTests: true,
25 reviewBeforePush: false,
26 uncheckedRepos: [],
27 middlewareRouteRepos: [],
28 bannedStrings: [],
29 supabase: false,
30}
31const list = (v: unknown): string[] =>
32 (Array.isArray(v) ? v : typeof v === 'string' ? v.split(',') : []).map(x => String(x).trim()).filter(Boolean)
33const flag = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
34const repoMatch = (fp: string, repos: string[]) => repos.find(r => fp.includes(r))
35const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
36// ------------------------------------------------------------------------------------
37
38const SNAP = 'h:'
39const SNAP_MAX_AGE = 7 * 24 * 60 * 60_000
40const QUIET_MS = 30 * 60_000 // a snapshot this fresh, not ended, is a session still at work
41
42type Item =
43 | { kind: 'pr'; repo: string; number: number; title: string }
44 | { kind: 'dirty'; tree: string; count: number }
45 | { kind: 'unpushed'; tree: string; branch: string; count: number }
46 | { kind: 'question'; text: string }
47
48type Snapshot = {
49 sessionId: string
50 task: string
51 updatedAt: number
52 ended: boolean
53 items: Item[]
54}
55
56// ---- session state ----------------------------------------------------------
57let sessionId = ''
58let task = ''
59const trees = new Set<string>()
60const prs = new Map<string, { repo: string; number: number }>()
61let lastQuestion: string | null = null
62let shownHandoff = false
63
64// tripwire state
65const fired = new Set<string>()
66let lastTsEdit = 0
67let lastTypecheck = 0
68let lastEdit = 0
69let lastReview = 0
70let lastBuildDepEdit = 0
71let lastBuild = 0
72let pendingNotify = false
73let migrations = 0
74
75const toplevelCache = new Map<string, string | null>()
76const PR_URL = /github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g
77const PR_REF = /\b([\w.-]+\/[\w.-]+)#(\d+)\b/g
78const HUMAN = new Set(['composer', 'bridge', 'sdk'])
79const WRITE_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']
80
81const short = (s: string, n: number) => {
82 const one = s.replace(/\s+/g, ' ').trim()
83 return one.length > n ? one.slice(0, n - 1) + '…' : one
84}
85const dirname = (p: string) => p.slice(0, p.lastIndexOf('/')) || '/'
86const shortTree = (p: string) => {
87 const wt = p.match(/\.claude\/worktrees\/([^/]+)$/)
88 if (wt) return `${(p.split('/.claude/')[0] ?? '').split('/').pop()}:${wt[1]}`
89 return p.replace(/^\/Users\/[^/]+/, '~')
90}
91const ago = (ms: number) => {
92 const m = Math.round(ms / 60_000)
93 return m < 60 ? `${m}m` : m < 2880 ? `${Math.round(m / 60)}h` : `${Math.round(m / 1440)}d`
94}
95
96async function toplevel($: Engine, dir: string): Promise<string | null> {
97 let d = dir
98 for (let i = 0; i < 6 && d && d !== '/'; i++) {
99 if (toplevelCache.has(d)) return toplevelCache.get(d) ?? null
100 try {
101 if (await $.fs.exists(d)) {
102 const r = await $.process.run(['git', '-C', d, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
103 const top = r.exitCode === 0 ? r.stdout.trim() : null
104 toplevelCache.set(d, top)
105 return top
106 }
107 } catch {
108 return null
109 }
110 d = dirname(d)
111 }
112 return null
113}
114
115async function git($: Engine, tree: string, args: string[]): Promise<string | null> {
116 try {
117 const r = await $.process.run(['git', '-C', tree, ...args], { timeoutMs: 8000 })
118 return r.exitCode === 0 ? r.stdout.trim() : null
119 } catch {
120 return null
121 }
122}
123
124// ---- tripwires -------------------------------------------------------------
125
126// Tell the user (transcript line + toast) once per key; return the text for Claude.
127function trip($: Engine, key: string, forUser: string, forClaude: string): string | null {
128 if (fired.has(key)) return null
129 fired.add(key)
130 $.ui.log(`⚠ ${forUser}`)
131 $.ui.toast(forUser, { timeoutMs: 10_000 })
132 return `[session-hygiene tripwire] ${forClaude}`
133}
134
135type Call = { tool: string } & Record<string, unknown>
136
137// Rules checked before a call runs. Returns a deny when the user says stop.
138async function before($: Engine, call: Call): Promise<string | null> {
139 if (call.tool !== 'Bash' || typeof call.command !== 'string') return null
140 const c = call.command
141 const holds: Array<[RegExp, string]> = []
142 if (cfg.holdEnvSourcing)
143 holds.push([
144 /(^|[;&|(]\s*)(source|\.)\s+[^\s;&|]*\.env\b|set\s+-a[\s\S]*\.env/,
145 'This command sources an env file, loading every secret in it into the shell. Rule: read only the variables a script needs.',
146 ])
147 if (cfg.supabase)
148 holds.push([
149 /\$\{?\w*SERVICE_ROLE\w*|service_role_key\s*[=:]/i,
150 'This command uses the Supabase service-role key against the production database. Rule: ask the user before any direct prod DB access, reads included.',
151 ])
152 for (const [re, why] of holds) {
153 if (!re.test(c)) continue
154 let answer = 'Stop'
155 try {
156 answer = await $.ui.ask(`${why} Run it anyway?`, { header: 'Tripwire', options: ['Run it', 'Stop'] })
157 } catch {
158 answer = 'Stop'
159 }
160 if (answer !== 'Run it') return `session-hygiene: the user stopped this command. ${why}`
161 }
162 return null
163}
164
165// Rules checked after a call ran. Returns reminders for Claude.
166async function after($: Engine, call: Call, ok: boolean, isNewFile: boolean, now: number): Promise<string[]> {
167 const out: Array<string | null> = []
168 const fp = (call.file_path ?? call.notebook_path) as string | undefined
169
170 if (WRITE_TOOLS.includes(call.tool) && typeof fp === 'string' && ok) {
171 lastEdit = now
172 const unchecked = repoMatch(fp, cfg.uncheckedRepos)
173 if (unchecked && /\.(ts|tsx)$/.test(fp)) lastTsEdit = now
174 if (unchecked && /\/(package\.json|next\.config\.[cm]?[jt]s)$/.test(fp)) lastBuildDepEdit = now
175
176 const added = String(call.new_string ?? call.content ?? '')
177 for (const b of cfg.bannedStrings)
178 if (new RegExp(escapeRe(b), 'i').test(added))
179 out.push(trip($, `banned:${b}:${fp}`, `"${b}" written into ${fp.split('/').pop()}`, `The text just written to ${fp} contains "${b}", which the user has banned. Remove it.`))
180
181 if (cfg.mutationCheckTests && isNewFile && /\.(test|spec)\.[cm]?[jt]sx?$/.test(fp))
182 out.push(
183 trip($, `mutation:${fp}`, `New test ${fp.split('/').pop()}: mutation-check it`,
184 `${fp} is a new test file. Before claiming it covers anything, delete the production code it targets, confirm the test FAILS, then restore from a copy and confirm git diff is empty.`),
185 )
186
187 const mw = repoMatch(fp, cfg.middlewareRouteRepos)
188 const rel = mw ? fp.slice(fp.indexOf(mw) + mw.length) : ''
189 const route = rel.match(/^(?:\.claude\/worktrees\/[^/]+\/)?src\/app\/([^/()[\]]+)\/(?:.*\/)?page\.tsx$/)
190 if (isNewFile && route && route[1] !== 'api')
191 out.push(
192 trip($, `route:${route[1]}`, `New page under src/app/${route[1]}: needs middleware passthrough`,
193 `New page under src/app/${route[1]}/. In production it 404s unless /${route[1]}/ is added to the route allowlist in src/middleware.ts (every host branch it has). CI cannot catch this; curl the production URL after deploy.`),
194 )
195 }
196
197 if (call.tool === 'Bash' && typeof call.command === 'string') {
198 const c = call.command
199 if (ok && /\b(npm|bun|pnpm)\s+run\s+typecheck\b|\btsc\b[^|;&]*--noEmit/.test(c)) lastTypecheck = now
200 if (ok && /\b(npm|bun|pnpm)\s+run\s+build\b|\bnext\s+build\b/.test(c)) lastBuild = now
201
202 const shipping = /\bgit(?:\s+-C\s+\S+)?\s+push\b|\bgh\s+pr\s+create\b/.test(c)
203 if (shipping && lastTsEdit > lastTypecheck)
204 out.push(
205 trip($, `typecheck:${lastTsEdit}`, 'Pushed TypeScript edits without npm run typecheck',
206 'TypeScript files were edited since the last typecheck. The user marked this repo as one whose build and CI don\'t typecheck, so a type error ships to production. Run the repo\'s typecheck (e.g. `npm run typecheck` or `tsc --noEmit`) now and fix anything new.'),
207 )
208 if (cfg.reviewBeforePush && /\bgit(?:\s+-C\s+\S+)?\s+push\b/.test(c) && lastEdit > lastReview)
209 out.push(
210 trip($, `review:${lastEdit}`, 'Pushed without a local /code-review since the last edit',
211 'Code was pushed with edits made since the last local /code-review. Rule: run /code-review before every branch\'s first push and after fixing a finding.'),
212 )
213 if (/\bgh\s+pr\s+merge\b/.test(c) && lastBuildDepEdit > lastBuild)
214 out.push(
215 trip($, `build:${lastBuildDepEdit}`, 'Merging a build-config change without a local npm run build',
216 'package.json or next.config changed this session and no local build ran since. The user marked this repo as one whose PR checks don\'t build it, so the production build after merge is the first real one. Run the build locally first.'),
217 )
218 }
219
220 if (call.tool === 'Skill' && /code-review/.test(String(call.skill ?? ''))) lastReview = now
221 if (call.tool === 'ReportFindings') lastReview = now
222
223 if (cfg.supabase && /supabase__apply_migration$/.test(call.tool) && ok) {
224 pendingNotify = true
225 migrations += 1
226 const q = String(call.query ?? '')
227 out.push(
228 trip($, `notify:${now}`, 'Migration applied: run NOTIFY pgrst',
229 "A migration was just applied. Run `NOTIFY pgrst, 'reload schema';` via execute_sql now, then verify through the app's REST path, not raw SQL."),
230 )
231 if (/create\s+table/i.test(q) && !/revoke[\s\S]*from\s+authenticated/i.test(q))
232 out.push(
233 trip($, `grants:${now}`, 'New table keeps default authenticated grants',
234 'This migration creates a table without revoking the default grants from `authenticated`. Supabase grants it SELECT/INSERT/UPDATE/DELETE by default, and `grant update` is not column-scoped. Revoke them explicitly and check information_schema.role_table_grants.'),
235 )
236 }
237 if (/supabase__execute_sql$/.test(call.tool) && /notify\s+pgrst/i.test(String(call.query ?? ''))) pendingNotify = false
238
239 return out.filter((x): x is string => x !== null)
240}
241
242// ---- handoff ---------------------------------------------------------------
243
244function discover(text: string) {
245 for (const m of text.matchAll(PR_URL)) prs.set(`${m[1]}#${m[2]}`, { repo: m[1]!, number: Number(m[2]) })
246 for (const m of text.matchAll(PR_REF)) prs.set(`${m[1]}#${m[2]}`, { repo: m[1]!, number: Number(m[2]) })
247}
248
249async function prOpen($: Engine, repo: string, n: number): Promise<{ open: boolean; title: string } | null> {
250 try {
251 const r = await $.process.run(['gh', 'pr', 'view', String(n), '-R', repo, '--json', 'state,title'], { timeoutMs: 15_000 })
252 if (r.exitCode !== 0) return null
253 const v = JSON.parse(r.stdout) as { state: string; title: string }
254 return { open: v.state === 'OPEN', title: v.title }
255 } catch {
256 return null
257 }
258}
259
260async function treeItems($: Engine, tree: string): Promise<Item[]> {
261 if (!(await $.fs.exists(tree))) return []
262 const items: Item[] = []
263 const status = await git($, tree, ['status', '--porcelain'])
264 const dirty = status ? status.split('\n').filter(Boolean).length : 0
265 if (dirty) items.push({ kind: 'dirty', tree, count: dirty })
266 const branch = await git($, tree, ['rev-parse', '--abbrev-ref', 'HEAD'])
267 if (branch && !['main', 'master', 'HEAD'].includes(branch)) {
268 const n =
269 (await git($, tree, ['rev-list', '--count', '@{u}..HEAD'])) ??
270 (await git($, tree, ['rev-list', '--count', 'origin/main..HEAD']))
271 if (n && Number(n) > 0) items.push({ kind: 'unpushed', tree, branch, count: Number(n) })
272 }
273 return items
274}
275
276async function snapshot($: Engine) {
277 if (!sessionId) return
278 const items: Item[] = []
279 for (const p of prs.values()) {
280 const s = await prOpen($, p.repo, p.number)
281 if (s?.open) items.push({ kind: 'pr', repo: p.repo, number: p.number, title: s.title })
282 }
283 for (const t of trees) items.push(...(await treeItems($, t)))
284 if (lastQuestion) items.push({ kind: 'question', text: lastQuestion })
285 const snap: Snapshot = { sessionId, task, updatedAt: await $.clock.now(), ended: false, items }
286 await $.store.set(SNAP + sessionId, snap)
287}
288
289// Re-check a snapshot's items now; drop what has since been resolved.
290async function recheck($: Engine, s: Snapshot): Promise<Item[]> {
291 const out: Item[] = []
292 const seenTrees = new Set<string>()
293 for (const it of s.items) {
294 if (it.kind === 'pr') {
295 const st = await prOpen($, it.repo, it.number)
296 if (st?.open) out.push({ ...it, title: st.title })
297 } else if (it.kind === 'dirty' || it.kind === 'unpushed') {
298 if (seenTrees.has(it.tree)) continue
299 seenTrees.add(it.tree)
300 out.push(...(await treeItems($, it.tree)))
301 } else if (it.kind === 'question' && (await $.clock.now()) - s.updatedAt < 2 * 24 * 60 * 60_000) {
302 out.push(it)
303 }
304 }
305 return out
306}
307
308const describeItem = (it: Item) =>
309 it.kind === 'pr'
310 ? `PR ${it.repo.split('/').pop()}#${it.number} still open (${short(it.title, 40)})`
311 : it.kind === 'dirty'
312 ? `${shortTree(it.tree)}: ${it.count} uncommitted file${it.count === 1 ? '' : 's'}`
313 : it.kind === 'unpushed'
314 ? `${shortTree(it.tree)}: ${it.count} unpushed commit${it.count === 1 ? '' : 's'} on ${it.branch}`
315 : `left a question: ${short(it.text, 70)}`
316
317async function handoffReport($: Engine): Promise<{ lines: string[]; keys: string[] }> {
318 const now = await $.clock.now()
319 const keys = (await $.store.keys()).filter((k: string) => k.startsWith(SNAP))
320 const snaps: Snapshot[] = []
321 for (const k of keys) {
322 const s = (await $.store.get(k)) as Snapshot | undefined
323 if (!s || s.sessionId === sessionId) continue
324 if (now - s.updatedAt > SNAP_MAX_AGE) {
325 await $.store.delete(k)
326 continue
327 }
328 if (!s.ended && now - s.updatedAt < QUIET_MS) continue // still being worked on
329 if (s.items.length) snaps.push(s)
330 }
331 snaps.sort((a, b) => b.updatedAt - a.updatedAt)
332
333 const lines: string[] = []
334 const shownKeys: string[] = []
335 for (const s of snaps.slice(0, 6)) {
336 const items = await recheck($, s)
337 if (items.length === 0) {
338 await $.store.delete(SNAP + s.sessionId)
339 continue
340 }
341 await $.store.set(SNAP + s.sessionId, { ...s, items })
342 shownKeys.push(SNAP + s.sessionId)
343 lines.push(`↩ "${short(s.task || s.sessionId.slice(0, 8), 60)}" (${ago(now - s.updatedAt)} ago) left:`)
344 for (const it of items.slice(0, 4)) lines.push(` · ${describeItem(it)}`)
345 }
346 return { lines, keys: shownKeys }
347}
348
349// ---- hooks -------------------------------------------------------------------
350
351export const register: Register = (on, options) => {
352 const o = (options ?? {}) as Record<string, unknown>
353 cfg = {
354 holdEnvSourcing: flag(o.holdEnvSourcing, true),
355 mutationCheckTests: flag(o.mutationCheckTests, true),
356 reviewBeforePush: flag(o.reviewBeforePush, false),
357 uncheckedRepos: list(o.uncheckedRepos),
358 middlewareRouteRepos: list(o.middlewareRouteRepos),
359 bannedStrings: list(o.bannedStrings),
360 supabase: flag(o.supabase, false),
361 }
362
363 on('session.start', async ($, e, next) => {
364 sessionId = await $.session.id()
365 const prior = (await $.store.get(SNAP + sessionId)) as Snapshot | undefined
366 if (prior) task = prior.task
367 await $.command.register({
368 name: 'handoff',
369 description: 'Open loops left by earlier sessions (PRs, uncommitted or unpushed work, questions). /handoff clear dismisses them.',
370 argumentHint: '[clear]',
371 })
372 if (!shownHandoff) {
373 shownHandoff = true
374 void handoffReport($).then(r => {
375 if (r.lines.length) {
376 for (const line of ['Open loops from earlier sessions (/handoff to see again, /handoff clear to dismiss):', ...r.lines])
377 $.ui.log(line)
378 }
379 })
380 }
381 return next(e)
382 })
383
384 on('session.end', async ($, e, next) => {
385 const s = (await $.store.get(SNAP + sessionId)) as Snapshot | undefined
386 if (s) await $.store.set(SNAP + sessionId, { ...s, ended: true })
387 return next(e)
388 })
389
390 on('command.run', { command: 'handoff' }, async ($, e) => {
391 const r = await handoffReport($)
392 if ((e.args ?? '').trim() === 'clear') {
393 for (const k of r.keys) await $.store.delete(k)
394 return { text: r.keys.length ? `Dismissed open loops from ${r.keys.length} session(s).` : 'Nothing to dismiss.' }
395 }
396 return { text: r.lines.length ? r.lines.join('\n') : 'No open loops from earlier sessions.' }
397 })
398
399 on('prompt.submit', async ($, e, next) => {
400 const human = !e.origin || HUMAN.has(e.origin.kind)
401 if (human && !e.text.startsWith('<')) {
402 if (!task) task = short(e.text, 70)
403 lastQuestion = null
404 }
405 return next(e)
406 })
407
408 on('tool.call', async ($, e, next) => {
409 if (e.agentId !== undefined) return next(e)
410 const call = e as unknown as Call
411 const now = await $.clock.now()
412
413 const deny = await before($, call)
414 if (deny) return { deny }
415
416 const fp = (call.file_path ?? call.notebook_path) as string | undefined
417 const isNewFile = call.tool === 'Write' && typeof fp === 'string' && !(await $.fs.exists(fp))
418
419 const ran = await next(e)
420 if ('deny' in ran && ran.deny !== undefined) return ran
421 const ok = !ran.isError
422
423 // handoff bookkeeping
424 if (WRITE_TOOLS.includes(call.tool) && typeof fp === 'string') {
425 const t = await toplevel($, dirname(fp))
426 if (t) trees.add(t)
427 }
428 if (call.tool === 'Bash' && typeof call.command === 'string') {
429 const c = call.command
430 if (/\bgit\b/.test(c)) {
431 const dashC = c.match(/git\s+-C\s+("[^"]+"|'[^']+'|\S+)/)?.[1] ?? c.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|\S+)/)?.[1]
432 const dir = dashC ? dashC.replace(/^["']|["']$/g, '') : await $.session.cwd()
433 const t = await toplevel($, dir.startsWith('/') ? dir : `${await $.session.cwd()}/${dir}`)
434 if (t) trees.add(t)
435 }
436 if (/\bgh\s+pr\b/.test(c)) discover(`${c}\n${ran.text ?? ''}`)
437 }
438
439 const reminders = await after($, call, ok, isNewFile, now)
440 if (reminders.length === 0) return ran
441 return { ...ran, context: [...(ran.context ?? []), ...reminders] }
442 })
443
444 on('turn.complete', async ($, e, next) => {
445 const r = await next(e)
446 if (e.agentId !== undefined) return r
447 if (pendingNotify) {
448 trip($, `notify-turn:${migrations}`, "Turn ended with a migration applied and no NOTIFY pgrst, 'reload schema'", '')
449 }
450 const tail = e.answer.trim().slice(-400)
451 lastQuestion = /\?\s*(\*\*)?\s*$/.test(tail)
452 ? (tail.split(/(?<=[.!\n])\s+/).filter(s => s.includes('?')).pop() ?? null)
453 : null
454 void snapshot($)
455 return r
456 })
457}
458