Domaine Foundation skills for Claude Code — Agentic Assisted Development workflow for Shopify theme work.

hooks/mods/register.tsx 23 lines1// The fnd hooks module (Claude Code only): core = band, usage, progress, log; fnd = marker, guard, slim, prompt-slim.
2// Each feature file declares its own atoms and keeps its `$` code to itself: the validator follows `$` only within one file.
3import type { Register } from 'claude-code'
4import { registerBand } from './core/band.tsx'
5import { registerLog } from './core/log.tsx'
6import { registerProgress } from './core/progress.tsx'
7import { registerUsage } from './core/usage.ts'
8import { registerGuard } from './fnd/guard.ts'
9import { registerMarker } from './fnd/marker.ts'
10import { registerPromptSlim } from './fnd/prompt-slim.ts'
11import { registerSlim } from './fnd/slim.ts'
12
13export const register: Register = (on, options) => {
14 registerMarker(on)
15 registerUsage(on, options)
16 registerBand(on, options)
17 registerProgress(on)
18 registerLog(on)
19 registerGuard(on)
20 registerSlim(on)
21 registerPromptSlim(on)
22}
23hooks/mods/core/band.tsx 318 lines1// Status band: the AbovePrompt row drawn from the atoms, the Compact and Clear presses and the terminal's model
2// picker, which unfolds into the row itself: the band region clips anything drawn outside its own rows, so no
3// list can pop over the transcript. A desktop draws the buttons on a second row under the figures.
4// Render only reads atoms; usage.ts and progress.tsx write them. The Progress and Log presses are
5// answered by the `ui.press` hooks on elements `progress` (progress.tsx) and `log` (log.tsx).
6// While the band plugin is loaded it draws the band, so this one passes (one plugin draws AbovePrompt).
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, On, PluginOptions, RenderNode } from 'claude-code'
9import type { FndBandInfo, FndRate } from '../../../types'
10import { bandLive } from './events.ts'
11import {
12 CACHE_INIT,
13 CTX_PROPS,
14 GLYPH,
15 LEVEL_PROPS,
16 MODEL_MARK,
17 RULE,
18 SEP,
19 USAGE_INIT,
20 bandSegs,
21 cacheCard,
22 cells,
23 cacheView,
24 compactToast,
25 costCard,
26 ctxCard,
27 glyphText,
28 layout,
29 modelOptions,
30 pctLevel,
31 rateCard,
32 rateText,
33 splitLabel,
34} from './lib.ts'
35import { digestText } from './progress-parse.ts'
36
37const usage = atom({ plugin: 'fnd', key: 'usage' } as const, USAGE_INIT)
38const model = atom({ plugin: 'fnd', key: 'model' } as const, null)
39const cache = atom({ plugin: 'fnd', key: 'cache' } as const, CACHE_INIT)
40const tick = atom({ plugin: 'fnd', key: 'tick' } as const, 0)
41const progress = atom({ plugin: 'fnd', key: 'progress' } as const, null)
42const paneShown = atom({ plugin: 'fnd', key: 'paneShown' } as const, false)
43const bandFocused = atom({ plugin: 'fnd', key: 'bandFocused' } as const, false)
44const modelPicker = atom({ plugin: 'fnd', key: 'modelPicker' } as const, false)
45const bandInfo = atom({ plugin: 'band', key: 'info' } as const, null as FndBandInfo | null)
46
47type $ = EngineInterface
48
49/** Main-loop turn in flight: turn events flip it, each band draw syncs it; usage.ts clears it, as the engine allows one unmatched turn.complete hook. */
50export const turn = { running: false }
51
52function pressCompact($: $): void {
53 if (turn.running) {
54 $.ui.toast('turn is running — press Compact again when it ends')
55 return
56 }
57 const refused = (err: unknown) => $.ui.toast(`compact refused: ${err instanceof Error ? err.message : String(err)}`)
58 // A headless (SDK, desktop app) session refuses the op but still runs a typed /compact.
59 $.session.compact().then(
60 r => $.ui.toast(compactToast(r)),
61 () => $.command.run({ command: 'compact' }).then(r => $.ui.toast(r.text || 'compacted'), refused),
62 )
63}
64
65const CLEAR_QUESTION = 'Clear the conversation?'
66const CLEAR_YES = 'Yes'
67
68/** Always behind the engine's own Yes/No dialog: it takes the keyboard, so a stray click or hotkey never clears. */
69function pressClear($: $): void {
70 if (turn.running) {
71 $.ui.toast('turn is running — press Clear again when it ends')
72 return
73 }
74 const refused = (err: unknown) => $.ui.toast(`clear refused: ${err instanceof Error ? err.message : String(err)}`)
75 // A dismissed dialog rejects: that is a No.
76 $.ui.ask(CLEAR_QUESTION, [CLEAR_YES, 'No']).then(
77 answer => {
78 if (answer !== CLEAR_YES) return
79 $.command.run({ command: 'clear' }).then(r => $.ui.toast(r.text || 'cleared'), refused)
80 },
81 () => undefined,
82 )
83}
84
85/** A pick folds the picker and runs `/model <id>` as typed; the switch event then moves the segment. The current id only folds. */
86function pickModel($: $, id: string, current: string): void {
87 void setPicker($, false)
88 if (id === current) return
89 $.command.run({ command: 'model', args: id }).then(
90 r => $.ui.toast(r.text || `model ${id}`),
91 err => $.ui.toast(`model refused: ${err instanceof Error ? err.message : String(err)}`),
92 )
93}
94
95/** The surface the band last drew on; a desktop has no hotkey letters, so focus moves must not redraw it. */
96let drawnOn: string = 'terminal'
97
98/** The last render's surface and measured props, for /fnd-band. */
99export const lastRender: { surface: string | null; bodyColumns: number | undefined; maxRows: number | undefined } = {
100 surface: null,
101 bodyColumns: undefined,
102 maxRows: undefined,
103}
104
105/** Guarded write: a redraw between a click's focus-in and its press would swallow the press. */
106async function setFocused($: $, to: boolean): Promise<void> {
107 if (drawnOn !== 'desktop' && (await read($, bandFocused)) !== to) await update($, bandFocused, () => to)
108}
109
110/** Guarded write, as setFocused; the desktop never unfolds. */
111async function setPicker($: $, to: boolean): Promise<void> {
112 if (drawnOn !== 'desktop' && (await read($, modelPicker)) !== to) await update($, modelPicker, () => to)
113}
114
115export function registerBand(on: On, options: PluginOptions): void {
116 // Hotkey letters are drawn only while the band holds the keyboard. A focus-in sets the flag; a
117 // press, a turn or /clear clears it, as there is no focus-out event. The hotkeys stay armed.
118 on('ui.focus', { component: 'AbovePrompt' }, async ($, e, next) => {
119 if (bandLive(await read($, bandInfo))) return next(e)
120 await setFocused($, true)
121 return next(e)
122 })
123 // Unfolding the model picker keeps the keyboard on the band: its models take hotkey letters of their own.
124 on('ui.press', { plugin: 'fnd' }, async ($, e, next) => {
125 if (e.element !== 'model') await setFocused($, false)
126 return next(e)
127 })
128 on('turn.start', async ($, e, next) => {
129 turn.running = true
130 await setFocused($, false)
131 await setPicker($, false)
132 return next(e)
133 })
134 on('session.end', { reason: 'clear' }, async ($, e, next) => {
135 turn.running = false
136 await setFocused($, false)
137 await setPicker($, false)
138 return next(e)
139 })
140
141 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142 // The engine's own truth: every draw re-aligns a flag a missed turn.complete or a hot reload left wrong.
143 turn.running = e.props.isWorking
144 if (bandLive(await read($, bandInfo))) return next(e)
145 if (options.statusBand === false || e.props.hasSurvey) return next(e)
146 const u = await read($, usage)
147 const m = await read($, model)
148 const c = await read($, cache)
149 const now = await read($, tick)
150 const p = await read($, progress)
151 const isPaneShown = await read($, paneShown)
152 const focused = await read($, bandFocused)
153 const unfolded = await read($, modelPicker)
154 if (u.ctxPct === null && u.rates.length === 0 && m === null && c.anchorMs === null) return next(e)
155
156 const isWorking = e.props.isWorking
157 const digest = p !== null && p.workId !== null && !isPaneShown ? digestText(p.workId, p) : null
158 const isDesktop = e.surface === 'desktop'
159 drawnOn = e.surface
160 lastRender.surface = e.surface
161 lastRender.bodyColumns = e.props.bodyColumns
162 lastRender.maxRows = e.props.maxRows
163 // A desktop draws proportional text: its bodyColumns do not measure the row, so nothing is dropped there.
164 const segs = layout(bandSegs({ usage: u, model: m, cache: c, nowMs: now, isWorking, digest }), isDesktop ? undefined : e.props.bodyColumns)
165 const { Box, Text, Button } = $.ui.resolve(e)
166
167 const label = (text: string) => (isDesktop ? glyphText(text) : text)
168 // Dim label, bold value (`cache` dim, `42m` bold); a desktop glyph label stays at full strength.
169 const dimLabel = isDesktop ? {} : { dimColor: true }
170 const labeled = (text: string, labelProps: Record<string, unknown>, valueProps: Record<string, unknown>): RenderNode => {
171 const [l, v] = splitLabel(text)
172 return (
173 <Box flexDirection="row">
174 <Text {...labelProps} wrap="truncate-end">
175 {v ? `${l} ` : l}
176 </Text>
177 {v ? (
178 <Text {...valueProps} wrap="truncate-end">
179 {v}
180 </Text>
181 ) : null}
182 </Box>
183 )
184 }
185 const cardMax = e.props.bodyColumns && e.props.bodyColumns > 0 ? e.props.bodyColumns : Infinity
186 // A keyed Box is a hover scope; its hidden child is the card. A surface without a pointer never reveals it.
187 // The card gets its own width: an absolute Box would otherwise shrink to its segment. The terminal clips
188 // it to the segment's columns, so there it pops up a row above, over the rule; the desktop lays it over.
189 const hoverable = (key: string, body: RenderNode, card: string): RenderNode => (
190 <Box key={key}>
191 {body}
192 <Box
193 position="absolute"
194 top={isDesktop ? 0 : -1}
195 left={0}
196 width={Math.min(cells(card), cardMax)}
197 display="none"
198 hover={{ display: 'flex' }}
199 >
200 <Text inverse wrap="truncate-end">
201 {card}
202 </Text>
203 </Box>
204 </Box>
205 )
206 const rate = (r: FndRate, i: number): RenderNode[] => [
207 ...(i > 0 ? [<Text dimColor> · </Text>] : []),
208 hoverable(`seg-rate-${r.kind}`, labeled(rateText(r), dimLabel, { bold: true, ...LEVEL_PROPS[pctLevel(r.pct)] }), rateCard(r, now)),
209 ]
210
211 // A desktop draws a hotkey as a badge on its native button, and its buttons are clicked: no hotkeys there.
212 const letters = focused && !isDesktop ? { plain: true as const } : null
213 const hot = (k: string) => (isDesktop ? {} : { hotkey: k })
214
215 // Unfolded, the row holds the models alone: four names plus letters outgrow a row that also holds the figures.
216 // The current one is at full strength and only folds; no Esc, as the engine raises no focus-out.
217 if (unfolded && !isDesktop && m !== null) {
218 const options = modelOptions(m)
219 const row: RenderNode[] = [<Text dimColor>model </Text>]
220 options.forEach((o, i) => {
221 if (i > 0) row.push(<Text>{' '}</Text>)
222 row.push(
223 <Button
224 key={`model:${o.value}`}
225 label={o.label}
226 plain
227 {...(letters && o.hotkey ? { hotkey: o.hotkey } : {})}
228 {...(o.value === m ? {} : { dimColor: true })}
229 onPress={() => pickModel($, o.value, m)}
230 />,
231 )
232 })
233 const ruleCols = e.props.bodyColumns && e.props.bodyColumns > 0 ? Math.min(e.props.bodyColumns, 400) : 80
234 return (
235 <Box flexDirection="column">
236 <Text dimColor>{RULE.repeat(ruleCols)}</Text>
237 <Box flexDirection="row">{row}</Box>
238 </Box>
239 )
240 }
241
242 const groups: RenderNode[][] = []
243 if (segs.cache !== null) {
244 const level = LEVEL_PROPS[cacheView(c, now, isWorking).level]
245 groups.push([hoverable('seg-cache', labeled(label(segs.cache), dimLabel, { bold: true, ...level }), cacheCard(c, now))])
246 }
247 // The terminal has no model menu of its own in reach, so its segment is the picker's button; the desktop app has one.
248 if (segs.model !== null && m !== null) {
249 groups.push([
250 isDesktop ? (
251 <Text wrap="truncate-end">{`${GLYPH.model} ${segs.model}`}</Text>
252 ) : (
253 <Button key="model" label={`${segs.model}${MODEL_MARK}`} plain {...(letters ? { hotkey: 'm' } : {})} onPress={() => setPicker($, true)} />
254 ),
255 ])
256 }
257 const ctxLevel = u.ctxPct === null ? {} : CTX_PROPS[pctLevel(u.ctxPct)]
258 groups.push([hoverable('seg-ctx', labeled(label(segs.ctx), dimLabel, { bold: true, ...ctxLevel }), ctxCard(u))])
259 if (segs.rates.length) {
260 groups.push([...(isDesktop ? [<Text>{`${GLYPH.rates} `}</Text>] : []), ...segs.rates.flatMap(rate)])
261 }
262 if (segs.cost !== null && u.costUsd !== null) {
263 groups.push([hoverable('seg-cost', labeled(label(segs.cost), dimLabel, { bold: true }), costCard(u.costUsd))])
264 }
265 if (segs.digest !== null) {
266 groups.push([
267 <Box key="seg-digest" flexDirection="row">
268 {isDesktop ? <Text>{`${GLYPH.digest} `}</Text> : null}
269 {labeled(segs.digest, { bold: true }, {})}
270 </Box>,
271 ])
272 }
273 const buttons: RenderNode[] = []
274 const look = letters ?? (segs.compact.plain ? {} : { variant: 'primary' as const })
275 buttons.push(<Button key="compact" label="Compact" {...hot('c')} {...look} onPress={() => pressCompact($)} />)
276 if (segs.clear !== null) {
277 buttons.push(<Text>{' '}</Text>)
278 buttons.push(<Button key="clear" label="Clear" {...hot('x')} {...(letters ?? { dimColor: true })} onPress={() => pressClear($)} />)
279 }
280 if (segs.progress !== null) {
281 buttons.push(<Text>{' '}</Text>)
282 buttons.push(<Button key="progress" label="Progress" {...hot('p')} {...(letters ?? { dimColor: true })} onPress={() => {}} />)
283 }
284 if (segs.log !== null) {
285 buttons.push(<Text>{' '}</Text>)
286 buttons.push(<Button key="log" label="Log" {...hot('l')} {...(letters ?? { dimColor: true })} onPress={() => {}} />)
287 }
288 // A desktop draws native buttons: in the figures' row they squash it and sit far right, so they get a row of
289 // their own below, left-aligned. The terminal keeps one row: its height is the scarce side there.
290 if (!isDesktop) groups.push(buttons)
291
292 const row = groups.flatMap((g, i) => (i === 0 ? g : [<Text dimColor>{SEP}</Text>, ...g]))
293 // No overflow="hidden" here: it would clip the terminal's cards on the rule row above.
294 const rowBox = (
295 <Box flexDirection="row">
296 {row}
297 </Box>
298 )
299 // A dim rule separates the band from the transcript above it; the desktop frames its panel itself.
300 if (isDesktop) {
301 // A row of air between the figures and the buttons, and around the whole, so the panel is not one dense block.
302 return (
303 <Box flexDirection="column" gap={1} padding={1}>
304 {rowBox}
305 <Box flexDirection="row">{buttons}</Box>
306 </Box>
307 )
308 }
309 const ruleCols = e.props.bodyColumns && e.props.bodyColumns > 0 ? Math.min(e.props.bodyColumns, 400) : 80
310 return (
311 <Box flexDirection="column">
312 <Text dimColor>{RULE.repeat(ruleCols)}</Text>
313 {rowBox}
314 </Box>
315 )
316 })
317}
318hooks/mods/core/log.tsx 92 lines1// Event log pane: /fnd-log and the band's Log button toggle it; it draws fnd's events merged with the slim
2// plugin's (an empty list when slim is not loaded), never writes either. Under the band plugin /fnd-log only
3// points at /band-log and fnd draws no Log button; a pane opened before band loaded keeps drawing until closed.
4import { atom, read } from 'claude-code'
5import type { EngineInterface, On } from 'claude-code'
6import type { FndBandInfo, FndEvent, FndForeignEvent } from '../../../types'
7import { LOG_PANE, MOVED, PREFIX_COLS, bandLive, hhmm, kindCell, merged, newestFitting } from './events.ts'
8
9const events = atom({ plugin: 'fnd', key: 'events' } as const, [] as FndEvent[])
10const slimEvents = atom({ plugin: 'slim', key: 'events' } as const, [] as FndForeignEvent[])
11const bandInfo = atom({ plugin: 'band', key: 'info' } as const, null as FndBandInfo | null)
12
13type $ = EngineInterface
14
15/** Only the terminal and the Desktop app draw a mod's panes; elsewhere the log goes out as text. */
16async function drawsPanes($: $): Promise<boolean> {
17 return (await $.session.surfaces()).some(s => s === 'terminal' || s === 'desktop')
18}
19
20async function allEvents($: $): Promise<FndForeignEvent[]> {
21 return merged(await read($, events), await read($, slimEvents))
22}
23
24async function logText($: $): Promise<string> {
25 const list = await allEvents($)
26 if (list.length === 0) return 'no events yet'
27 return list.map(ev => `${hhmm(ev.atMs)} ${kindCell(ev.kind)} ${ev.text}`).join('\n')
28}
29
30async function togglePane($: $): Promise<string> {
31 if (!(await drawsPanes($))) return await logText($)
32 if ((await $.ui.panes()).some(p => p.id === LOG_PANE && p.isShown)) {
33 await $.ui.close({ id: LOG_PANE })
34 return 'Log pane closed.'
35 }
36 const r = await $.ui.open({ id: LOG_PANE, title: 'Log', focus: true, closeOnEscape: true })
37 if (!r.isPlaced) {
38 $.ui.toast(`log pane not placed: ${r.reason}`)
39 return `Log pane not placed: ${r.reason}`
40 }
41 return 'Log pane opened.'
42}
43
44export function registerLog(on: On): void {
45 on('command.run', { command: 'fnd-log' }, async $ => {
46 if (bandLive(await read($, bandInfo).catch(() => null))) return { text: MOVED.log }
47 return { text: await togglePane($) }
48 })
49
50 // Answers without next, so the band Button's own closure never runs and the pane toggles once.
51 on('ui.press', { plugin: 'fnd', element: 'log' }, async ($, e) => {
52 await togglePane($)
53 return { element: e.element }
54 })
55
56 on('ui.render', { component: 'Pane', requestId: 'fnd-log' }, async ($, e) => {
57 const { Box, Text } = $.ui.resolve(e)
58 const list = await allEvents($)
59 if (list.length === 0) {
60 return (
61 <Box flexDirection="column" width={e.props.bodyColumns}>
62 <Text dimColor wrap="truncate-end">
63 no events yet
64 </Text>
65 </Box>
66 )
67 }
68 // The engine's window starts at the top and never follows the end: keep the newest rows in view,
69 // counting the rows a wrapped text takes.
70 const rows = e.props.scroll?.bodyRows ?? 0
71 const textCols = Math.max(1, e.props.bodyColumns - PREFIX_COLS)
72 const shown = newestFitting(list, rows, textCols)
73 const cut = shown.length < list.length
74 return (
75 <Box flexDirection="column" width={e.props.bodyColumns}>
76 {cut ? <Text dimColor wrap="truncate-end">{`… ${list.length - shown.length} earlier`}</Text> : null}
77 {shown.map((ev, i) => (
78 <Box key={`ev-${i}`} flexDirection="row">
79 <Box width={PREFIX_COLS} flexShrink={0}>
80 <Text dimColor>{`${hhmm(ev.atMs)} `}</Text>
81 <Text dimColor>{`${kindCell(ev.kind)} `}</Text>
82 </Box>
83 <Box width={textCols}>
84 <Text wrap="wrap">{ev.text}</Text>
85 </Box>
86 </Box>
87 ))}
88 </Box>
89 )
90 })
91}
92hooks/mods/core/progress.tsx 345 lines1// Progress: work-id resolver, digest refresh, /fnd-progress and its pane.
2// Writes the `progress` and `paneShown` atoms the band draws from. Under the band plugin band draws the pane
3// from fnd.progress: /fnd-progress then only pins and points at /band-progress.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On } from 'claude-code'
6import type { FndBandInfo, FndEvent, FndProgress } from '../../../types'
7import { LOG_COMMAND, MOVED, bandLive, pushEvent } from './events.ts'
8import { notesTail, parseProgress } from './progress-parse.ts'
9import { KEY, isWorkId, keyFromBranch, projectOf, slugFromBranch, ticketKeys } from './workid.ts'
10
11const PANE = 'fnd-progress'
12const COMMAND = {
13 name: 'fnd-progress',
14 description: 'Open the fnd task progress pane',
15 argumentHint: '[work-id]',
16 immediate: true,
17} as const
18const TICK_MS = 30_000
19const RESOLVE_EVERY = 4
20const FRESH_MS = 12 * 60 * 60_000
21const CHECKOUT = /\bgit\s+(checkout|switch|worktree)\b/
22const NO_WORKSPACE = 'no task workspace — /fnd:save-task-context'
23const NO_PROGRESS = 'no progress.md yet — /fnd:save-task-context'
24const GLYPH = { done: '✓', current: '▶', waiting: '◌', todo: '☐' } as const
25/** Prompt origins a person wrote; notifications, peers and schedules never set the conversation key. */
26const PERSON = new Set(['composer', 'bridge', 'sdk'])
27
28const progress = atom({ plugin: 'fnd', key: 'progress' } as const, null)
29const pin = atom({ plugin: 'fnd', key: 'pin' } as const, null)
30const lastKey = atom({ plugin: 'fnd', key: 'lastKey' } as const, null)
31const sessionId = atom({ plugin: 'fnd', key: 'sessionId' } as const, null)
32const paneShown = atom({ plugin: 'fnd', key: 'paneShown' } as const, false)
33const events = atom({ plugin: 'fnd', key: 'events' } as const, [] as FndEvent[])
34const bandInfo = atom({ plugin: 'band', key: 'info' } as const, null as FndBandInfo | null)
35
36type $ = EngineInterface
37
38/** Compared with the last logged workspace, not the atom: /clear nulls the atom while the work stays. */
39async function logWorkspace($: $, text: string): Promise<void> {
40 try {
41 if ((await $.env.get('FND_EVENT_LOG')) === '0') return
42 const atMs = await $.clock.now()
43 await update($, events, l => {
44 let last = 'none'
45 for (const ev of l) if (ev.kind === 'workspace') last = ev.text
46 return last === text ? l : pushEvent(l, { atMs, kind: 'workspace', text })
47 })
48 } catch {}
49}
50
51const tasksDir = (root: string) => `${root}/.claude/tasks`
52const workDir = (root: string, id: string) => `${tasksDir(root)}/${id}`
53
54async function branchOf($: $, root: string): Promise<string | null> {
55 try {
56 const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, timeoutMs: 5000 })
57 return r.exitCode === 0 ? r.stdout.trim() || null : null
58 } catch {
59 return null
60 }
61}
62
63/** The workspace directory itself: a ticket dir without progress.md still names the work (digest = the bare id). */
64async function hasWorkspace($: $, root: string, id: string | null): Promise<boolean> {
65 if (!id) return false
66 try {
67 return (await $.fs.stat(workDir(root, id))).kind === 'dir'
68 } catch {
69 return false
70 }
71}
72
73async function newestWorkspace($: $, root: string): Promise<string | null> {
74 let dirs
75 try {
76 dirs = (await $.fs.list(tasksDir(root))).filter(d => d.kind === 'dir' && isWorkId(d.name))
77 } catch {
78 return null
79 }
80 const now = await $.clock.now()
81 let best: string | null = null
82 let bestMs = 0
83 for (const d of dirs) {
84 try {
85 const s = await $.fs.stat(`${workDir(root, d.name)}/progress.md`)
86 if (s.kind === 'file' && now - s.mtimeMs <= FRESH_MS && s.mtimeMs > bestMs) {
87 best = d.name
88 bestMs = s.mtimeMs
89 }
90 } catch {}
91 }
92 return best
93}
94
95type Inputs = { pin: string | null; lastKey: string | null }
96
97const readInputs = async ($: $): Promise<Inputs> => ({ pin: await read($, pin), lastKey: await read($, lastKey) })
98
99/** The projects of the `.claude/tasks/<KEY>` dirs: what corroborates a bare key in a prompt. */
100async function knownProjects($: $, root: string): Promise<Set<string>> {
101 const out = new Set<string>()
102 try {
103 for (const d of await $.fs.list(tasksDir(root))) {
104 const project = d.kind === 'dir' ? projectOf(d.name) : null
105 if (project) out.add(project)
106 }
107 } catch {}
108 return out
109}
110
111/**
112 * pin (an existing dir) → conversation key, workspace or not: the ticket the developer named is the task →
113 * branch key → branch slug (existing dirs) → newest progress.md within 12 h → null.
114 */
115async function resolveWorkId($: $, root: string, branch: string | null, inputs: Inputs): Promise<string | null> {
116 if (await hasWorkspace($, root, inputs.pin)) return inputs.pin
117 if (inputs.lastKey) return inputs.lastKey
118 for (const id of [keyFromBranch(branch), slugFromBranch(branch)]) if (await hasWorkspace($, root, id)) return id
119 return newestWorkspace($, root)
120}
121
122/** The most recent ticket the prompt names (`ticketKeys`), else null: the held key stays. */
123async function conversationKey($: $, text: string): Promise<string | null> {
124 if (!KEY.test(text)) return null
125 const keys = ticketKeys(text, await knownProjects($, await $.session.root()))
126 return keys[0] ?? null
127}
128
129async function readText($: $, path: string): Promise<string> {
130 try {
131 return await $.fs.read(path)
132 } catch {
133 return ''
134 }
135}
136
137async function mtimeOf($: $, path: string): Promise<number> {
138 try {
139 return (await $.fs.stat(path)).mtimeMs
140 } catch {
141 return 0
142 }
143}
144
145/** Newest mtime of the workspace's progress.md and notes.md: what the tick compares. */
146async function workspaceMtime($: $, root: string, id: string): Promise<number> {
147 const dir = workDir(root, id)
148 return Math.max(await mtimeOf($, `${dir}/progress.md`), await mtimeOf($, `${dir}/notes.md`))
149}
150
151async function load($: $, root: string, workId: string, branch: string | null): Promise<FndProgress> {
152 const dir = workDir(root, workId)
153 const workspace = await hasWorkspace($, root, workId)
154 const mtimeMs = await workspaceMtime($, root, workId)
155 const parsed = parseProgress(await readText($, `${dir}/progress.md`))
156 const notes = notesTail(await readText($, `${dir}/notes.md`))
157 return { workId, branch, hasWorkspace: workspace, ...parsed, notesTail: notes, mtimeMs }
158}
159
160/** `resolve` re-runs git and the resolver; otherwise only the current workspace is re-read while it exists. */
161async function refresh($: $, resolve: boolean): Promise<void> {
162 const root = await $.session.root()
163 const cur = await read($, progress)
164 const inputs = await readInputs($)
165 const reload = !resolve && !!cur?.workId && (await hasWorkspace($, root, cur.workId))
166 let next: FndProgress
167 if (reload && cur?.workId) {
168 next = await load($, root, cur.workId, cur.branch)
169 } else {
170 const branch = await branchOf($, root)
171 const id = await resolveWorkId($, root, branch, inputs)
172 next = id ? await load($, root, id, branch) : { workId: null, branch }
173 }
174 // A pin or key change (or /clear) during the awaits started its own, newer resolve.
175 const now = await readInputs($)
176 if (now.pin !== inputs.pin || now.lastKey !== inputs.lastKey) return
177 const after = await update($, progress, prev => (!reload || prev?.workId === next.workId ? next : prev))
178 await logWorkspace($, after?.workId ?? 'none')
179}
180
181async function tick($: $, resolve: boolean): Promise<void> {
182 const cur = await read($, progress)
183 if (resolve || cur === null) return refresh($, true)
184 if (cur.workId === null) return
185 const mtimeMs = await workspaceMtime($, await $.session.root(), cur.workId)
186 if (mtimeMs !== cur.mtimeMs) await refresh($, false)
187}
188
189async function openPane($: $): Promise<string> {
190 const r = await $.ui.open({ id: PANE, title: 'Progress', focus: true, closeOnEscape: true })
191 if (!r.isPlaced) {
192 $.ui.toast(`progress pane not placed: ${r.reason}`)
193 return `Progress pane not placed: ${r.reason}`
194 }
195 await update($, paneShown, () => true)
196 return 'Progress pane opened.'
197}
198
199async function togglePane($: $): Promise<string> {
200 const panes = await $.ui.panes()
201 if (panes.some(p => p.id === PANE && p.isShown)) {
202 await $.ui.close({ id: PANE })
203 return 'Progress pane closed.'
204 }
205 return openPane($)
206}
207
208export function registerProgress(on: On): void {
209 on('session.start', async ($, e, next) => {
210 const r = await next(e)
211 let ticks = 0
212 $.clock.every(TICK_MS, () => {
213 ticks++
214 void tick($, ticks % RESOLVE_EVERY === 0).catch(() => undefined)
215 })
216 try {
217 const panes = await $.ui.panes()
218 const shown = panes.some(p => p.id === PANE && p.isPlaced)
219 await update($, paneShown, () => shown)
220 } catch {}
221 try {
222 const id = await $.session.id()
223 await update($, sessionId, () => id)
224 } catch {}
225 await $.command.register(COMMAND).catch(() => undefined)
226 await $.command.register(LOG_COMMAND).catch(() => undefined)
227 await refresh($, true).catch(() => undefined)
228 return r
229 })
230
231 on('prompt.submit', async ($, e, next) => {
232 const r = await next(e)
233 let resolve = false
234 const id = await $.session.id()
235 if (id !== (await read($, sessionId))) {
236 await update($, sessionId, () => id)
237 await $.command.register(COMMAND)
238 await $.command.register(LOG_COMMAND)
239 resolve = true
240 }
241 const key = PERSON.has(e.origin.kind) ? await conversationKey($, e.text) : null
242 if (key && key !== (await read($, lastKey))) {
243 await update($, lastKey, () => key)
244 resolve = true
245 }
246 if (resolve) await refresh($, true)
247 return r
248 })
249
250 on('session.end', { reason: 'clear' }, async ($, e, next) => {
251 await update($, progress, () => null)
252 await update($, lastKey, () => null)
253 return next(e)
254 })
255
256 on('tool.call', { tool: /^(Write|Edit)$/ }, async ($, e, next) => {
257 const r = await next(e)
258 if (e.tool !== 'Write' && e.tool !== 'Edit') return r
259 const root = await $.session.root()
260 if (!e.file_path.startsWith(`${tasksDir(root)}/`)) return r
261 const cur = await read($, progress)
262 const inCurrent = !!cur?.workId && e.file_path.startsWith(`${workDir(root, cur.workId)}/`)
263 await refresh($, !inCurrent)
264 return r
265 })
266
267 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
268 const r = await next(e)
269 if (e.tool === 'Bash' && CHECKOUT.test(e.command)) await refresh($, true)
270 return r
271 })
272
273 on('classic.CwdChanged', async ($, e, next) => {
274 const r = await next(e)
275 await refresh($, true)
276 return r
277 })
278
279 on('command.run', { command: 'fnd-progress' }, async ($, e) => {
280 const arg = e.args.trim()
281 const moved = bandLive(await read($, bandInfo).catch(() => null))
282 if (!arg) return { text: moved ? MOVED.progress : await togglePane($) }
283 if (arg !== '-' && !isWorkId(arg)) return { text: `Not a work id: ${arg}` }
284 await update($, pin, () => (arg === '-' ? null : arg))
285 await refresh($, true)
286 const text = moved ? `${arg === '-' ? 'Unpinned.' : `Pinned ${arg}.`} ${MOVED.progress}` : await openPane($)
287 const cur = await read($, progress)
288 if (arg !== '-' && cur?.workId !== arg) return { text: `${text} ${arg} has no task workspace.` }
289 return { text }
290 })
291
292 // Answers without next, so the band Button's own closure never runs and the pane toggles once.
293 on('ui.press', { plugin: 'fnd', element: 'progress' }, async ($, e) => {
294 await togglePane($)
295 return { element: e.element }
296 })
297
298 on('ui.close', { id: PANE }, async ($, e, next) => {
299 const r = await next(e)
300 if (e.origin.kind === 'plugin' || e.origin.kind === 'person') await update($, paneShown, () => false)
301 return r
302 })
303
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305 const { Box, Text } = $.ui.resolve(e)
306 const p = await read($, progress)
307 if (!p || p.workId === null) {
308 return (
309 <Box flexDirection="column" width={e.props.bodyColumns}>
310 <Text wrap="truncate-end">{NO_WORKSPACE}</Text>
311 </Box>
312 )
313 }
314 const header = [p.workId, p.branch, p.total ? `${p.done}/${p.total}` : null].filter(Boolean).join(' · ')
315 return (
316 <Box flexDirection="column" width={e.props.bodyColumns}>
317 <Text bold wrap="truncate-end">
318 {header}
319 </Text>
320 {!p.hasWorkspace ? (
321 <Text dimColor wrap="truncate-end">
322 {NO_WORKSPACE}
323 </Text>
324 ) : p.total === 0 ? (
325 <Text dimColor wrap="truncate-end">
326 {NO_PROGRESS}
327 </Text>
328 ) : null}
329 {p.rows.map((row, i) => (
330 <Box key={`row-${i}`}>
331 <Text wrap="truncate-end" dimColor={row.mark === 'done' || row.mark === 'waiting'} bold={row.mark === 'current'}>
332 {`${GLYPH[row.mark]} ${row.text}`}
333 </Text>
334 </Box>
335 ))}
336 {p.notesTail.map(line => (
337 <Text dimColor wrap="truncate-end">
338 {line}
339 </Text>
340 ))}
341 </Box>
342 )
343 })
344}
345hooks/mods/core/usage.ts 274 lines1// Status band writers: usage, model, cache and tick atoms plus the tick timer.
2// Nothing here draws; the band reads these atoms. While the band plugin is loaded it keeps these figures
3// itself, so every hook here stands down: two writers would toast the rate alarm and log each line twice.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On, PluginOptions } from 'claude-code'
6import type { FndBandInfo, FndEvent, FndEventKind, FndUsage } from '../../../types'
7import { MOVED, bandLive, fmtK, pushEvent } from './events.ts'
8import { CACHE_INIT, HOUR_MS, USAGE_INIT, alarmRate, compactedUsage, keepCtx, oneHourCacheTokens, rateCard, toUsage, ttlMsOf } from './lib.ts'
9import { lastRender, turn } from './band.tsx'
10
11const TICK_MS = 30_000
12const ALARM_TOAST_MS = 8000
13const STORE_TTL = 'cacheTtlMs'
14const DEBUG_COMMAND = { name: 'fnd-band', description: 'Show the raw figures behind the fnd status band (debug)' }
15
16const usage = atom({ plugin: 'fnd', key: 'usage' } as const, USAGE_INIT)
17const model = atom({ plugin: 'fnd', key: 'model' } as const, null)
18const cache = atom({ plugin: 'fnd', key: 'cache' } as const, CACHE_INIT)
19const tick = atom({ plugin: 'fnd', key: 'tick' } as const, 0)
20const progress = atom({ plugin: 'fnd', key: 'progress' } as const, null)
21const rateAlarmed = atom({ plugin: 'fnd', key: 'rateAlarmed' } as const, false)
22const events = atom({ plugin: 'fnd', key: 'events' } as const, [] as FndEvent[])
23const bandInfo = atom({ plugin: 'band', key: 'info' } as const, null as FndBandInfo | null)
24
25type $ = EngineInterface
26
27const yielded = async ($: $): Promise<boolean> => bandLive(await read($, bandInfo).catch(() => null))
28
29/** Appends one event-log line. FND_EVENT_LOG=0 skips the write. It never throws, because a throwing
30 * session.start hook would skip every fnd session.start hook. */
31async function logEvent($: $, kind: FndEventKind, text: string): Promise<void> {
32 try {
33 if ((await $.env.get('FND_EVENT_LOG')) === '0') return
34 const atMs = await $.clock.now()
35 await update($, events, l => pushEvent(l, { atMs, kind, text }))
36 } catch {}
37}
38
39/** FND_BAND_COST=1 (true/yes/on) shows the session's cost; off, the figure is dropped before it reaches the atom. */
40let costShown = false
41const ON = new Set(['1', 'true', 'yes', 'on'])
42
43function withCost(u: FndUsage): FndUsage {
44 return costShown ? u : { ...u, costUsd: null }
45}
46
47/** Writes the model atom and logs the change once; the same id again is a no-op. */
48async function adoptModel($: $, m: string): Promise<void> {
49 if ((await read($, model)) === m) return
50 await update($, model, () => m)
51 await logEvent($, 'model', m)
52}
53
54const SAME_COMPACTION_MS = 30_000
55let lastCompactMs = 0
56
57/**
58 * Cold cache, the context from the engine's count (absent = no reading), one log line. The
59 * session.compact chain and the classic PostCompact both report one compaction, in either order: a
60 * report within 30 s of the last is the same compaction, and then only a token count refines the context.
61 */
62async function applyCompaction($: $, trigger: string, tokensAfter?: number, tokensBefore?: number): Promise<void> {
63 const now = await $.clock.now()
64 const same = now - lastCompactMs < SAME_COMPACTION_MS
65 lastCompactMs = now
66 await update($, cache, c => ({ ...c, isCold: true }))
67 if (same && tokensAfter === undefined) return
68 await update($, usage, u => compactedUsage(u, tokensAfter))
69 if (same) return
70 const sizes = typeof tokensBefore === 'number' && typeof tokensAfter === 'number' ? ` ${fmtK(tokensBefore)} → ${fmtK(tokensAfter)}` : ''
71 await logEvent($, 'compact', `${trigger}${sizes}`)
72}
73
74async function refresh($: $): Promise<void> {
75 const now = await $.clock.now()
76 await update($, tick, () => now)
77 const u = withCost(toUsage(...(await $.session.usage().then(r => [r.context, r.rateLimits, r.cost] as const))))
78 await update($, usage, prev => keepCtx(prev, u))
79 await adoptSubscriptionTtl($, u)
80}
81
82/**
83 * An API key or a cloud provider bills per request and gets the 5 min cache; without them the session
84 * runs on a claude.ai account, whose cache lives 1 h. Only the presence of the variables is read.
85 */
86async function defaultTtl($: $): Promise<number> {
87 const billed = [
88 await $.env.get('ANTHROPIC_API_KEY'),
89 await $.env.get('CLAUDE_CODE_USE_BEDROCK'),
90 await $.env.get('CLAUDE_CODE_USE_VERTEX'),
91 await $.env.get('CLAUDE_CODE_USE_FOUNDRY'),
92 ].some(v => v !== undefined)
93 return billed ? CACHE_INIT.ttlMs : HOUR_MS
94}
95
96/**
97 * Rate-limit windows arrive only on a claude.ai subscription, whose prompt cache lives 1 h (5 min
98 * under an API key or in overage). Live evidence beats the default and a remembered value alike.
99 */
100async function adoptSubscriptionTtl($: $, u: FndUsage): Promise<void> {
101 if (u.rates.length === 0) return
102 await update($, cache, c =>
103 c.ttlSource === 'default' || c.ttlSource === 'store' ? { ...c, ttlMs: HOUR_MS, ttlSource: 'subscription' } : c,
104 )
105}
106
107/**
108 * Adopts a TTL the session reported. Only 1 h is remembered for the next sessions: a 5 min report is
109 * the host's fallback when it has seen no 1 h cache write yet (every session start on 2.1.289), and
110 * remembered it would outlive the session that made it.
111 */
112async function learnTtl($: $, ttlMs: number, ttlSource: 'model-switch' | 'agent'): Promise<void> {
113 await update($, cache, c => ({ ...c, ttlMs, ttlSource }))
114 if (ttlMs === HOUR_MS) await $.store.set(STORE_TTL, ttlMs)
115}
116
117export function registerUsage(on: On, options: PluginOptions): void {
118 const forcedTtl = ttlMsOf(options.cacheTtl)
119
120 // Matches every cwd: validate refuses a second unmatched session.start in one plugin.
121 on('session.start', { cwd: /./ }, async ($, e, next) => {
122 // /fnd-band answers a pointer under band, and the timer is armed either way: band may load after this start.
123 await $.command.register(DEBUG_COMMAND).catch(() => undefined)
124 $.clock.every(TICK_MS, () => {
125 void (async () => {
126 if (!(await yielded($))) await refresh($)
127 })().catch(() => undefined)
128 })
129 if (await yielded($)) return next(e)
130 // One throwing session.start hook skips every fnd session.start hook: each $ call fails alone.
131 let learned: number | null = null
132 if (forcedTtl === null) {
133 try {
134 const stored = await $.store.get(STORE_TTL)
135 learned = typeof stored === 'number' && stored > 0 ? stored : null
136 } catch {}
137 }
138 const ttlMs = forcedTtl ?? learned ?? (await defaultTtl($).catch(() => CACHE_INIT.ttlMs))
139 costShown = ON.has((await $.env.get('FND_BAND_COST').catch(() => undefined))?.trim().toLowerCase() ?? '')
140 const ttlSource = forcedTtl !== null ? 'option' : learned !== null ? 'store' : 'default'
141 await update($, cache, c => ({ ...c, ttlMs, ttlSource }))
142 try {
143 const m = await $.session.model()
144 await update($, model, () => m)
145 } catch {}
146 await refresh($).catch(() => undefined)
147 await logEvent($, 'session', 'start')
148 return next(e)
149 })
150
151 on('command.run', { command: DEBUG_COMMAND.name }, async ($) => {
152 if (await yielded($)) return { text: MOVED.debug }
153 const raw = await $.session.usage().catch(err => ({ error: String(err) }))
154 const root = await $.session.root().catch(err => `error: ${String(err)}`)
155 const c = await read($, cache)
156 const u = await read($, usage)
157 const p = await read($, progress)
158 const lines = [
159 'fnd band debug',
160 `usage(): ${JSON.stringify(raw)}`,
161 `cache: ${JSON.stringify(c)}`,
162 `usage atom: ${JSON.stringify(u)}`,
163 `progress: ${JSON.stringify(p === null ? null : { workId: p.workId, branch: p.branch })}`,
164 `root: ${root}`,
165 `render: ${JSON.stringify(lastRender)}`,
166 `tick: ${await read($, tick)}`,
167 `now: ${await $.clock.now()}`,
168 ]
169 return { text: lines.join('\n') }
170 })
171
172 on('session.measure', async ($, e, next) => {
173 const r = await next(e)
174 if (await yielded($)) return r
175 const u = withCost(toUsage(e.context, e.rateLimits, e.cost))
176 await update($, usage, prev => keepCtx(prev, u))
177 await adoptSubscriptionTtl($, u)
178 // No session.start follows a /clear and the seed may read null: the first measure fills the gap. A
179 // switch whose PostModelSwitch never reached the mod is caught here too.
180 try {
181 const m = await $.session.model()
182 if (m) await adoptModel($, m)
183 } catch {}
184 const hot = alarmRate(u.rates)
185 const isAlarmed = await read($, rateAlarmed)
186 if (hot && !isAlarmed) {
187 await update($, rateAlarmed, () => true)
188 const card = rateCard(hot, await $.clock.now())
189 $.ui.toast(card, { timeoutMs: ALARM_TOAST_MS })
190 await logEvent($, 'rate', card)
191 } else if (!hot && isAlarmed) {
192 await update($, rateAlarmed, () => false)
193 }
194 return r
195 })
196
197 on('turn.complete', async ($, e, next) => {
198 if (e.agentId === undefined) turn.running = false
199 if (await yielded($)) return next(e)
200 if (e.agentId === undefined && e.usage) {
201 const now = await $.clock.now()
202 await update($, cache, c => ({ ...c, anchorMs: now, isCold: false }))
203 await update($, tick, () => now)
204 }
205 return next(e)
206 })
207
208 on('session.compact', async ($, e, next) => {
209 const r = await next(e)
210 if (await yielded($)) return r
211 if (r.skip === undefined && r.messages && e.trigger !== 'precompute' && e.agentId === undefined) {
212 await applyCompaction($, e.trigger, r.tokensAfter, r.tokensBefore)
213 }
214 return r
215 })
216
217 // The engine's own report of a main-thread compaction. It reaches the mod when the session.compact
218 // chain does not (a Compact press on 2.1.289 left the band warm at the old ctx).
219 on('classic.PostCompact', async ($, e, next) => {
220 if (await yielded($)) return next(e)
221 if (e.agent_id === undefined) await applyCompaction($, e.trigger)
222 return next(e)
223 })
224
225 // Atom writes only: one 1.5 s bound covers every session.end hook and aborts a $ call in flight.
226 on('session.end', async ($, e, next) => {
227 if (await yielded($)) return next(e)
228 if (e.reason === 'clear' || e.reason === 'resume') {
229 await update($, cache, c => ({ ...c, anchorMs: null, isCold: false }))
230 await update($, usage, u => ({ ...u, ctxPct: null, ctxTokens: null }))
231 await update($, rateAlarmed, () => false)
232 }
233 // No session.start follows a /clear: this line marks where the conversation restarted.
234 if (e.reason === 'clear') await logEvent($, 'session', 'clear')
235 return next(e)
236 })
237
238 on('classic.PostModelSwitch', async ($, e, next) => {
239 if (await yielded($)) return next(e)
240 // The event's own field: $.session.model() may still answer the model before the switch here.
241 await adoptModel($, e.to_model)
242 // On a subscription (rate windows seen) the cache lives 1 h; a 5 min report there is the host's
243 // fallback, not a measurement. Overage does drop it to 5 min, and the band hides the segment then.
244 const ttlMs = ttlMsOf(e.cache_ttl)
245 const subscribed = (await read($, usage)).rates.length > 0
246 if (forcedTtl === null && ttlMs !== null && !(subscribed && ttlMs !== HOUR_MS)) await learnTtl($, ttlMs, 'model-switch')
247 // Caches are per model: a real switch forfeits the warm one. On resume the SessionStart seed decides.
248 if (e.source !== 'resume' && (e.from_model !== e.to_model || !e.prompt_cache_warm)) {
249 await update($, cache, c => ({ ...c, isCold: true }))
250 }
251 return next(e)
252 })
253
254 on('classic.SessionStart', { source: /^(resume|fork)$/ }, async ($, e, next) => {
255 if (await yielded($)) return next(e)
256 await logEvent($, 'session', e.source)
257 const secs = e.seconds_since_last_response
258 if (typeof secs === 'number') {
259 const now = await $.clock.now()
260 const isCold = e.prompt_cache_likely_expired === true
261 await update($, cache, c => ({ ...c, anchorMs: now - secs * 1000, isCold }))
262 await update($, tick, () => now)
263 }
264 return next(e)
265 })
266
267 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
268 const r = await next(e)
269 if (await yielded($)) return r
270 if (forcedTtl === null && oneHourCacheTokens(r.result) > 0) await learnTtl($, HOUR_MS, 'agent')
271 return r
272 })
273}
274hooks/mods/fnd/guard.ts 88 lines1// Scratch-path guard as a mod: tool.describe note + tool.call deny by delegation to scratch-path-guard.cjs.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, On } from 'claude-code'
4import type { FndEvent, FndEventKind } from '../../../types'
5import { bare, pushEvent, toolName } from '../core/events.ts'
6import { buildHookRun, omit, parseHookOut } from './node-hook.ts'
7
8/** Same source as the plugin.json PreToolUse matcher of scratch-path-guard.cjs (layout-assertions pins it). */
9export const GUARDED_RE =
10 /^mcp__.*__(take_screenshot|browser_take_screenshot|take_snapshot|get_network_request|browser_run_code_unsafe)$/
11
12// chrome-devtools' write tools (playwright's are browser_*): their server also accepts its OS temp dir.
13const TMPDIR_OK_RE = /__(take_screenshot|take_snapshot|get_network_request)$/
14
15// Constant on purpose: the describe answer is cached per session and any change spends the prompt cache.
16const NOTE_HEAD =
17 '\n\nfnd scratch-path guard: paths must resolve inside this project: `.claude/tasks/<work-id>/tmp/` ' +
18 '(`.claude/tmp/<work-id>/` in a git worktree) or `.claude/tmp/`; use an absolute path. '
19const NOTE = NOTE_HEAD + 'Paths outside the project are refused.'
20const NOTE_TMPDIR = NOTE_HEAD + "Paths outside the project, other than this server's own OS temp dir, are refused."
21
22export const DENY_FALLBACK = 'fnd scratch-path-guard: path outside the project'
23
24/** The launch root, latched once: the MCP servers' roots were fixed where they launched. */
25const guardRoot = atom({ plugin: 'fnd', key: 'guardRoot' } as const, null)
26const events = atom({ plugin: 'fnd', key: 'events' } as const, [] as FndEvent[])
27
28type $ = EngineInterface
29
30async function logEvent($: $, kind: FndEventKind, text: string): Promise<void> {
31 try {
32 if ((await $.env.get('FND_EVENT_LOG')) === '0') return
33 const atMs = await $.clock.now()
34 await update($, events, l => pushEvent(l, { atMs, kind, text }))
35 } catch {}
36}
37
38export function guardNote(tool: string): string {
39 return TMPDIR_OK_RE.test(tool) ? NOTE_TMPDIR : NOTE
40}
41
42export function registerGuard(on: On): void {
43 // The engine allows one unmatched hook per event per plugin; this matcher takes every session.
44 on('session.start', { cwd: /^/ }, async ($, e, next) => {
45 const launch = await $.session.root()
46 await update($, guardRoot, v => v ?? launch)
47 return next(e)
48 })
49
50 // Unlike tool.call, no spawned guard re-checks the switch here, and the answer lasts the session.
51 on('tool.describe', { tool: GUARDED_RE }, async ($, e, next) => {
52 const settingsEnv = (await $.settings.read()).env as Record<string, unknown> | undefined
53 if ((await $.env.get('FND_SCRATCH_GUARD')) === '0' || settingsEnv?.FND_SCRATCH_GUARD === '0') return next(e)
54 const d = await next(e)
55 return { ...d, description: d.description + guardNote(e.tool) }
56 })
57
58 on('tool.call', { tool: GUARDED_RE }, async ($, e, next) => {
59 if ((await $.env.get('FND_SCRATCH_GUARD')) === '0') return next(e)
60 let root = await read($, guardRoot)
61 if (root === null) {
62 // session.start's latch is skipped when any fnd session.start hook throws: the first call latches.
63 const live = await $.session.root()
64 root = (await update($, guardRoot, v => v ?? live)) ?? live
65 }
66 const { argv, init } = buildHookRun(
67 $.plugin.root,
68 'hooks/scratch-path-guard.cjs',
69 ['--from-mod'],
70 {
71 hook_event_name: 'PreToolUse',
72 tool_name: e.tool,
73 tool_input: omit(e, ['tool', 'tool_use_id', 'agentId']),
74 cwd: await $.session.cwd(),
75 },
76 { FND_HOST: 'claude', CLAUDE_PROJECT_DIR: root, CLAUDE_PLUGIN_ROOT: $.plugin.root },
77 10_000,
78 )
79 const out = await $.process.run(argv, init).then(parseHookOut, () => null)
80 const hso = out?.hookSpecificOutput
81 if (hso?.permissionDecision !== 'deny') return next(e)
82 const raw = hso.permissionDecisionReason
83 const reason = typeof raw === 'string' && raw.trim() ? raw : DENY_FALLBACK
84 await logEvent($, 'guard', `${toolName(e.tool)}: ${bare(reason.split('\n')[0] ?? '')}`)
85 return { deny: reason }
86 })
87}
88hooks/mods/fnd/marker.ts 48 lines1// Session marker: tells the classic UserPromptSubmit hook (mod-session.cjs) that this module is live, so its
2// context monitor goes silent — the band shows ctx and model. Rewritten on every prompt, because the classic
3// side trusts only a fresh mtime: a resumed session whose module no longer loads must not inherit a stale file.
4// Never deleted ($.fs cannot remove); old empty markers stay in tmpdir.
5// Under the band plugin band writes the same file, or nobody does while band is disabled.
6import { atom, read } from 'claude-code'
7import type { EngineInterface, On } from 'claude-code'
8import type { FndBandInfo } from '../../../types'
9import { bandLive } from '../core/events.ts'
10
11type $ = EngineInterface
12
13const MARK_MS = 500 // a stalled fs.write must not hold the prompt
14
15const bandInfo = atom({ plugin: 'band', key: 'info' } as const, null as FndBandInfo | null)
16
17export function registerMarker(on: On): void {
18 let broken: string | null = null // a session whose tmpdir failed once is not retried on every prompt
19 // Matches every prompt: a module holds one matcherless prompt.submit hook, and progress has it.
20 on('prompt.submit', { text: /^/ }, async ($, e, next) => {
21 if (bandLive(await read($, bandInfo).catch(() => null))) return next(e)
22 const sid = await sessionId($).catch(() => '')
23 if (sid && sid !== broken) {
24 const ok = await Promise.race([
25 mark($, sid).then(() => true),
26 $.clock.sleep(MARK_MS, { signal: next.signal }).then(() => false),
27 ]).catch(() => false)
28 if (!ok) broken = sid
29 }
30 return next(e)
31 })
32}
33
34async function sessionId($: $): Promise<string> {
35 return String(await $.session.id()).replace(/[^A-Za-z0-9_.-]/g, '')
36}
37
38/** Writes the empty `<tmpdir>/fnd-mod-session-<sid>`. */
39async function mark($: $, sid: string): Promise<void> {
40 await $.fs.write(`${await tmpdir($)}/fnd-mod-session-${sid}`, '')
41}
42
43/** Node's os.tmpdir() on POSIX (TMPDIR, TMP, TEMP, else /tmp) without trailing slashes: '/' gives '', so the joined path matches path.join. */
44async function tmpdir($: $): Promise<string> {
45 const dir = (await $.env.get('TMPDIR')) || (await $.env.get('TMP')) || (await $.env.get('TEMP')) || '/tmp'
46 return dir.replace(/\/+$/, '')
47}
48hooks/mods/fnd/prompt-slim.ts 71 lines1// Pasted JSON as a mod: prompt-json-guard.cjs --from-mod spills each big blob and replaces it in place instead of blocking the prompt.
2import { atom, update } from 'claude-code'
3import type { EngineInterface, On } from 'claude-code'
4import type { FndEvent, FndEventKind } from '../../../types'
5import { bare, pushEvent } from '../core/events.ts'
6import { buildHookRun, parseHookOut, toastMs } from './node-hook.ts'
7
8const PROMPT_MIN = 10240 // prompt-json-guard.cjs's gate in UTF-8 bytes (layout-assertions pins it); one UTF-16 unit is at most 3 of them
9const RUN_MS = 20_000
10const CONTEXT_MAX = 100_000 // past this the engine gives the model a head and a path, not the line
11
12type Rewrite = { text: string; context: string; summary: string }
13
14const events = atom({ plugin: 'fnd', key: 'events' } as const, [] as FndEvent[])
15
16type $ = EngineInterface
17
18async function logEvent($: $, kind: FndEventKind, text: string): Promise<void> {
19 try {
20 if ((await $.env.get('FND_EVENT_LOG')) === '0') return
21 const atMs = await $.clock.now()
22 await update($, events, l => pushEvent(l, { atMs, kind, text }))
23 } catch {}
24}
25
26export function registerPromptSlim(on: On): void {
27 on('prompt.submit', { origin: { kind: ['composer', 'bridge', 'sdk'] } }, async ($, e, next) => {
28 if (!due(e.text)) return next(e)
29 let rw: Rewrite | null = null
30 try {
31 if ((await $.env.get('FND_PROMPT_JSON')) !== '0') {
32 const { argv, init } = buildHookRun(
33 $.plugin.root,
34 'hooks/prompt-json-guard.cjs',
35 ['--from-mod'],
36 { prompt: e.text, cwd: await $.session.root() },
37 { FND_HOST: 'claude', CLAUDE_PLUGIN_ROOT: $.plugin.root },
38 RUN_MS,
39 )
40 rw = rewriteOf(await $.process.run(argv, init).then(parseHookOut, () => null))
41 }
42 } catch {
43 rw = null
44 }
45 // Aborted: the dispatch already went on without this hook, so a next(e) here could run the hooks beneath twice.
46 if (next.signal.aborted) return { drop: 'interrupted' }
47 if (!rw) return next(e)
48 const r = await next({ ...e, text: rw.text, context: [...(e.context ?? []), rw.context] })
49 // After next: a failing env read must not turn the accepted prompt into an error.
50 if (r.drop === undefined && (await $.env.get('FND_SLIM_TOAST').catch(() => undefined)) !== '0') {
51 $.ui.toast(rw.summary, { timeoutMs: toastMs(await $.env.get('FND_SLIM_TOAST_MS').catch(() => undefined)) })
52 }
53 if (r.drop === undefined) await logEvent($, 'prompt', bare(rw.summary))
54 return r
55 })
56}
57
58/** A prompt the classic guard could block: possibly ≥ PROMPT_MIN bytes, a container opener, not a slash or `!` command. */
59function due(text: string): boolean {
60 return text.length * 3 >= PROMPT_MIN && /[{[]/.test(text) && !/^\s*(?:\/[\w:.-]+(?:\s|$)|!)/.test(text)
61}
62
63/** The script's rewrite when it is complete, else null. */
64function rewriteOf(out: Record<string, unknown> | null): Rewrite | null {
65 if (!out) return null
66 const { text, context, summary } = out
67 if (typeof text !== 'string' || typeof context !== 'string' || typeof summary !== 'string') return null
68 if (!text || !context || !summary || context.length > CONTEXT_MAX) return null
69 return { text, context, summary }
70}
71hooks/mods/fnd/slim.ts 187 lines1// MCP result slimming as a mod: host-stub replacement and the savings toast (FND_SLIM_TOAST=0 silences it,
2// FND_SLIM_TOAST_MS sets how long it stays). FND_COMPRESSION=proxy hands the job to the slim plugin while
3// its slim.info snapshot lists the MCP channel; without it this module compresses every result itself.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, On } from 'claude-code'
6import type { FndEvent, FndEventKind, FndSlimInfo } from '../../../types'
7import { bare, pushEvent, toolName } from '../core/events.ts'
8import { buildHookRun, omit, parseHookOut, slimFigureIn, stubBytes, stubText, toastMs } from './node-hook.ts'
9
10const events = atom({ plugin: 'fnd', key: 'events' } as const, [] as FndEvent[])
11const proxyNotice = atom({ plugin: 'fnd', key: 'proxyNotice' } as const, null as string | null)
12const proxySweep = atom({ plugin: 'fnd', key: 'proxySweep' } as const, null as string | null)
13const slimInfo = atom({ plugin: 'slim', key: 'info' } as const, null as FndSlimInfo | null)
14
15// mcp-slim's GATE_BYTES: below it the classic hook passes a result through untouched.
16const PROXY_GATE_BYTES = 4096
17// The labels slim's emissions carry, and fnd's own (which mcp-slim would pass through anyway).
18const STATS_LINE = /^(slim|fnd-mcp-slim): (?:compressed|stub) [\d,]+ B → [\d,]+ B \([-+−]?\d+(?:\.\d+)?%\)$/gm
19const SLIM_HEADS = ['<<slim stub>>']
20const OWN_HEADS = ['<<fnd-mcp-slim stub>>', '<<fnd-jsx-slim>>']
21
22type $ = EngineInterface
23
24async function logEvent($: $, kind: FndEventKind, text: string): Promise<void> {
25 try {
26 if ((await $.env.get('FND_EVENT_LOG')) === '0') return
27 const atMs = await $.clock.now()
28 await update($, events, l => pushEvent(l, { atMs, kind, text }))
29 } catch {}
30}
31
32/** `<type> · ` for a subagent's line (`fnd:` dropped, `agent` when unlisted); '' on the main loop. */
33async function loopLabel($: $, agentId: string | undefined): Promise<string> {
34 if (agentId === undefined) return ''
35 try {
36 const type = (await $.agent.list()).find(a => a.id === agentId)?.type
37 if (type) return `${type.replace(/^fnd:/, '')} · `
38 } catch {}
39 return 'agent · '
40}
41
42function serialized(result: unknown): string {
43 if (typeof result === 'string') return result
44 try {
45 return JSON.stringify(result) ?? ''
46 } catch {
47 return ''
48 }
49}
50
51function utf8Bytes(s: string): number {
52 return new TextEncoder().encode(s).length
53}
54
55/** The texts the model reads in an MCP result: the string, or each text block's text. */
56function textsOf(result: unknown): string[] {
57 if (typeof result === 'string') return [result]
58 const blocks = Array.isArray(result) ? result : (result as { content?: unknown } | null)?.content
59 if (Array.isArray(blocks)) {
60 return blocks.flatMap(b => (b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string' ? [(b as { text: string }).text] : []))
61 }
62 const t = (result as { text?: unknown } | null)?.text
63 return typeof t === 'string' ? [t] : []
64}
65
66/**
67 * Already compressed output, judged within the bound the emitter can reach (a quote in a big payload
68 * is not one): slim's labels only, or fnd's too when `withFnd`.
69 */
70function slimTagged(texts: string[], limit: number, withFnd: boolean): boolean {
71 if (texts.reduce((n, t) => n + utf8Bytes(t), 0) > limit + 1200) return false
72 const heads = withFnd ? [...SLIM_HEADS, ...OWN_HEADS] : SLIM_HEADS
73 return texts.some(t => heads.some(h => t.includes(h)) ||
74 (t.includes('<<full=') && [...t.matchAll(STATS_LINE)].some(m => withFnd || m[1] === 'slim')))
75}
76
77/** Once per session: mcp-slim's exit sweep (scratch spills, playwright output) still runs while slim compresses. */
78async function sweepOnce($: $, tool: string): Promise<void> {
79 const sid = await $.session.id()
80 if ((await read($, proxySweep)) === sid) return
81 let prev = null as string | null
82 await update($, proxySweep, p => {
83 prev = p
84 return sid
85 })
86 if (prev === sid) return
87 const { argv, init } = buildHookRun(
88 $.plugin.root,
89 'hooks/mcp-slim.cjs',
90 [],
91 { cwd: await $.session.cwd(), tool_name: tool },
92 { FND_HOST: 'claude', FND_COMPRESSION: 'proxy', CLAUDE_PLUGIN_ROOT: $.plugin.root },
93 30_000,
94 )
95 // Hygiene only: nothing waits on it, and a failure costs nothing but the sweep.
96 $.process.run(argv, init).catch(() => null)
97}
98
99/**
100 * Once per session each: the Log line on the first call, the toast on the first main-loop call (a
101 * subagent's call must not spend it). proxyNotice holds `<sid>` once logged, `<sid>:toast` once shown;
102 * the claim is made inside update(), so two parallel calls cannot both win it.
103 */
104async function noticeOnce($: $, isMain: boolean, info: FndSlimInfo | null): Promise<void> {
105 const sid = await $.session.id()
106 const shown = `${sid}:toast`
107 let prev = null as string | null
108 await update($, proxyNotice, p => {
109 prev = p
110 return isMain || p === shown ? shown : sid
111 })
112 const logged = prev === sid || prev === shown
113 if (logged && (!isMain || prev === shown)) return
114 const text = info
115 ? "FND_COMPRESSION=proxy, but slim's MCP channel is off (SLIM_MCP=0) — fnd compresses"
116 : 'FND_COMPRESSION=proxy, but slim is not loaded (or older than 0.3.0) — fnd compresses'
117 if (isMain && prev !== shown) $.ui.toast(text)
118 if (!logged) await logEvent($, 'slim', text)
119}
120
121export function registerSlim(on: On): void {
122 on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
123 // Never race next: returning while it is pending aborts what runs beneath.
124 const r = await next(e)
125 if (r.deny !== undefined || (await $.env.get('FND_MCP_SLIM')) === '0') return r
126 const isMain = e.agentId === undefined
127
128 let proxyInfo: FndSlimInfo | null = null
129 const proxy = (await $.env.get('FND_COMPRESSION')) === 'proxy'
130 if (proxy) {
131 proxyInfo = await read($, slimInfo)
132 if (Array.isArray(proxyInfo?.channels) && proxyInfo.channels.includes('mcp')) {
133 await sweepOnce($, e.tool)
134 return r
135 }
136 }
137
138 const limit = stubBytes(await $.env.get('FND_MCP_SLIM_STUB_BYTES'))
139 // slim beneath already compressed it: a slim output that quotes the overflow phrase is no host stub.
140 if (slimTagged(textsOf(r.result), limit, false)) return r
141 const stub = stubText(r.result)
142 // Under proxy the classic hook never ran beneath, so the whole result is this module's to compress.
143 const whole = proxy && !stub
144 if (whole) {
145 if (utf8Bytes(serialized(r.result)) <= PROXY_GATE_BYTES) return r
146 if (slimTagged(textsOf(r.result), limit, true)) return r
147 }
148 if (!stub && !whole) {
149 // The classic hook already slimmed it beneath us: only the figure is left to show.
150 const figure = slimFigureIn(r.result, limit, r.text)
151 if (isMain && figure && (await $.env.get('FND_SLIM_TOAST')) !== '0') {
152 $.ui.toast(figure, { timeoutMs: toastMs(await $.env.get('FND_SLIM_TOAST_MS')) })
153 }
154 if (figure) await logEvent($, 'slim', `${await loopLabel($, e.agentId)}${toolName(e.tool)}: ${bare(figure)}`)
155 return r
156 }
157
158 const { argv, init } = buildHookRun(
159 $.plugin.root,
160 'hooks/mcp-slim.cjs',
161 ['--from-mod', '--overflow=expand'],
162 {
163 hook_event_name: 'PostToolUse',
164 tool_name: e.tool,
165 tool_input: omit(e, ['tool', 'tool_use_id', 'agentId']),
166 tool_response: r.result,
167 cwd: await $.session.cwd(),
168 session_id: await $.session.id(),
169 },
170 { FND_HOST: 'claude', CLAUDE_PLUGIN_ROOT: $.plugin.root },
171 120_000,
172 )
173 const out = await $.process.run(argv, init).then(parseHookOut, () => null)
174 if (proxy) await noticeOnce($, isMain, proxyInfo)
175 const hso = out?.hookSpecificOutput
176 const result = hso?.updatedMCPToolOutput ?? hso?.updatedToolOutput
177 if (result === undefined) return r
178 const figure = typeof out?.systemMessage === 'string' ? out.systemMessage : ''
179 if (isMain && figure && (await $.env.get('FND_SLIM_TOAST')) !== '0') {
180 $.ui.toast(figure, { timeoutMs: toastMs(await $.env.get('FND_SLIM_TOAST_MS')) })
181 }
182 if (figure) await logEvent($, 'slim', `${await loopLabel($, e.agentId)}${toolName(e.tool)}: ${bare(figure)}`)
183 // A fresh object: returning `r` itself would make core reuse its own messages verbatim.
184 return r.context?.length ? { result, context: r.context } : { result }
185 })
186}
187hooks/mods/core/events.ts 96 lines1// Pure event-log helpers shared by the writer files and the log pane, plus the band-plugin yield test.
2// No `$` here: each writer keeps its own `logEvent` wrapper, as the validator follows `$` only within one file.
3import type { FndEvent, FndEventKind, FndForeignEvent } from '../../../types'
4
5export const EVENT_CAP = 200
6export const LOG_PANE = 'fnd-log'
7export const LOG_COMMAND = { name: 'fnd-log', description: 'Open the fnd event log pane', immediate: true } as const
8
9/** band.info is written at band's every session.start, so any object means band is loaded and draws instead of fnd. */
10export function bandLive(info: unknown): boolean {
11 return info !== null && typeof info === 'object'
12}
13
14/** What fnd's commands answer while band draws the band and the panes. */
15export const MOVED = {
16 log: 'band draws the panes now: /band-log',
17 progress: 'band draws the panes now: /band-progress',
18 debug: 'band draws the band now: /band-debug',
19} as const
20
21/** Kinds written once per MCP call or prompt: they would otherwise rotate the rare kinds out. */
22const ROUTINE: ReadonlySet<FndEventKind> = new Set(['slim', 'prompt'])
23
24/** Appends `ev`, oldest first, at most EVENT_CAP: over the cap the oldest routine line goes, else the oldest. */
25export function pushEvent(list: readonly FndEvent[], ev: FndEvent): FndEvent[] {
26 if (list.length < EVENT_CAP) return [...list, ev]
27 const i = Math.max(0, list.findIndex(e => ROUTINE.has(e.kind)))
28 return [...list.slice(0, i), ...list.slice(i + 1), ev]
29}
30
31/**
32 * fnd's log and another plugin's in one list, oldest first; on equal times fnd's line comes first.
33 * A foreign entry without a finite `atMs` or a string `kind`/`text` is dropped: another plugin writes it.
34 * Beside slim's lines fnd's own compression lines read `fnd-slim`, so the kind cell names who compressed.
35 */
36export function merged(fnd: readonly FndEvent[], foreign: readonly unknown[]): FndForeignEvent[] {
37 const ok = (ev: unknown): ev is FndForeignEvent => {
38 const e = ev as Partial<FndForeignEvent> | null
39 return !!e && Number.isFinite(e.atMs) && typeof e.kind === 'string' && typeof e.text === 'string'
40 }
41 const theirs = Array.isArray(foreign) ? foreign.filter(ok).map(e => ({ atMs: e.atMs, kind: e.kind, text: e.text })) : []
42 if (theirs.length === 0) return [...fnd]
43 const mine = fnd.map(e => (e.kind === 'slim' ? { ...e, kind: 'fnd-slim' } : e))
44 return [...mine, ...theirs].map((e, i) => ({ e, i })).sort((a, b) => a.e.atMs - b.e.atMs || a.i - b.i).map(x => x.e)
45}
46
47/** `fnd-mcp-slim: compressed …` → `compressed …`: the kind column already names the source. */
48export function bare(text: string): string {
49 return text.replace(/^fnd[ -][a-z -]+?: /, '')
50}
51
52/** `mcp__plugin_fnd_atlassian__getJiraIssue` → `getJiraIssue`. */
53export function toolName(tool: string): string {
54 return tool.split('__').pop() ?? tool
55}
56
57const pad2 = (n: number) => String(n).padStart(2, '0')
58
59/** Local `09:05`. */
60export function hhmm(ms: number): string {
61 const d = new Date(ms)
62 return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`
63}
64
65/** 412_345 → `412k`; below 1000 the number as is. */
66export function fmtK(n: number): string {
67 return n < 1000 ? String(n) : `${Math.round(n / 1000)}k`
68}
69
70/** The kind padded to the longest kind, `workspace`, so the texts line up. */
71export function kindCell(kind: string): string {
72 return kind.padEnd(9)
73}
74
75/** Cells the time and kind columns take before the text: `09:05 ` and `workspace `. */
76export const PREFIX_COLS = 7 + 9 + 2
77
78/** Rows one text takes wrapped at `cols` cells, at least one. */
79export function textRows(text: string, cols: number): number {
80 return Math.max(1, Math.ceil(text.length / Math.max(1, cols)))
81}
82
83/**
84 * The newest events whose wrapped rows fit in `rows` (all of them when `rows` is 0 or they all fit);
85 * when some are left out, one row is kept for the `… N earlier` line.
86 */
87export function newestFitting<T extends { text: string }>(list: readonly T[], rows: number, cols: number): T[] {
88 if (rows <= 0) return [...list]
89 let used = 0
90 let i = list.length
91 while (i > 0 && used + textRows(list[i - 1]!.text, cols) <= rows) used += textRows(list[--i]!.text, cols)
92 if (i === 0) return [...list]
93 while (i < list.length && used > rows - 1) used -= textRows(list[i++]!.text, cols)
94 return list.slice(i)
95}
96hooks/mods/core/lib.ts 382 lines1// Pure band helpers: the ctx/rate/cache colour ladders, rate labels, remaining-time text and the
2// one-row width model with its drop order. No `$` here: atoms and engine calls
3// stay in the file that hooks the event.
4import type { FndCache, FndRate, FndUsage } from '../../../types'
5
6export type Level = 'plain' | 'warning' | 'crit'
7
8/** The alarm look; needs no theme key beyond `warning` until another is proven live on every theme. */
9export const CRIT = { color: 'warning', bold: true, inverse: true } as const
10
11/** Text props per level. */
12export const LEVEL_PROPS: Record<Level, { color?: string; bold?: boolean; inverse?: boolean }> = {
13 plain: {},
14 warning: { color: 'warning' },
15 crit: CRIT,
16}
17/** The ctx value alone is green while fine, as the classic notice's 🟢 was; the rest of the band stays plain there. */
18export const CTX_PROPS: typeof LEVEL_PROPS = { ...LEVEL_PROPS, plain: { color: 'success' } }
19
20/** ctx % and every rate window: ≤ 30 plain, > 30 warning, ≥ 80 crit, judged on the rounded figure the band shows. */
21export function pctLevel(pct: number): Level {
22 const shown = Math.round(pct)
23 if (shown >= 80) return 'crit'
24 if (shown > 30) return 'warning'
25 return 'plain'
26}
27
28const MIN = 60_000
29
30/**
31 * Minutes left of the cache: warning below 10 min, crit below 2 min for a 1 h TTL. A shorter TTL
32 * scales both: warning below 40 % of the TTL (when that is under 10 min), crit at a fifth of that.
33 */
34export function cacheLevel(remainingMs: number, ttlMs: number): Level {
35 const warnMs = Math.min(10 * MIN, 0.4 * ttlMs)
36 if (remainingMs < warnMs / 5) return 'crit'
37 if (remainingMs < warnMs) return 'warning'
38 return 'plain'
39}
40
41/** Remaining cache time at `nowMs`, or null before the first main-thread response. */
42export function cacheRemaining(cache: FndCache, nowMs: number): number | null {
43 return cache.anchorMs === null ? null : cache.anchorMs + cache.ttlMs - nowMs
44}
45
46/** `42m`, `<1m`; null → `—`. Callers render `cold` for remaining ≤ 0 themselves via cacheView. */
47export function fmtRemaining(ms: number | null): string {
48 if (ms === null) return '—'
49 if (ms < MIN) return '<1m'
50 return `${Math.floor(ms / MIN)}m`
51}
52
53export type CacheView = { text: string; level: Level }
54
55/** The cache segment: `cache ●` mid-turn, `cache —` unmeasured, `cache cold`, else the countdown. */
56export function cacheView(cache: FndCache, nowMs: number, isWorking: boolean): CacheView {
57 if (isWorking) return { text: 'cache ●', level: 'plain' }
58 const left = cacheRemaining(cache, nowMs)
59 if (left === null) return { text: 'cache —', level: 'plain' }
60 if (cache.isCold || left <= 0) return { text: 'cache cold', level: 'plain' }
61 return { text: `cache ${fmtRemaining(left)}`, level: cacheLevel(left, cache.ttlMs) }
62}
63
64export function ctxText(pct: number | null): string {
65 return pct === null ? 'ctx —' : `ctx ${Math.round(pct)}%`
66}
67
68const KNOWN_KINDS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: '$' }
69const LABEL_MAX = 10
70
71/** `five_hour` → `5h`; an unknown kind keeps its raw name, shortened (`seven_day_fable` → `7d·fable`). */
72export function rateLabel(kind: string): string {
73 const known = KNOWN_KINDS[kind]
74 if (known !== undefined) return known
75 const short = kind
76 .replace(/^five_hour/, '5h')
77 .replace(/^seven_day/, '7d')
78 .replace(/_+/g, '·')
79 .replace(/^·+|·+$/g, '')
80 return [...(short || kind)].slice(0, LABEL_MAX).join('')
81}
82
83/** Rounded for display; above 100 (a spend limit) reads `>100%`. */
84export function fmtPct(pct: number): string {
85 return pct > 100 ? '>100%' : `${Math.round(pct)}%`
86}
87
88export type RawRate = { kind: string; percentUsed: number; resetsAt?: string }
89
90/** Every window the API reports, in its order. */
91export function toRates(list: readonly RawRate[] | undefined): FndRate[] {
92 return (list ?? []).map(r => ({
93 kind: r.kind,
94 label: rateLabel(r.kind),
95 pct: r.percentUsed,
96 resetsAt: r.resetsAt ?? null,
97 }))
98}
99
100export function rateText(r: FndRate): string {
101 return `${r.label} ${fmtPct(r.pct)}`
102}
103
104export type BandButton = { key: string; label: string; hotkey: string; plain: boolean }
105
106/** The band's segments in row order; null/empty = not drawn. */
107export type BandSegs = {
108 /** null while a rate window is at or past 100 %: in overage the TTL is unknown, so nothing is shown. */
109 cache: string | null
110 model: string | null
111 ctx: string
112 rates: FndRate[]
113 /** `cost $1.23`; null without a ledger or at zero. */
114 cost: string | null
115 digest: string | null
116 compact: BandButton
117 clear: BandButton | null
118 progress: BandButton | null
119 log: BandButton | null
120}
121
122export const SEP = ' │ '
123/** The dim rule drawn above the row, one cell repeated across the band. */
124export const RULE = '─'
125const RATE_GAP = ' · '
126const BUTTON_GAP = ' '
127
128/** Width sample: the focused form `c: Compact` (letters show only while the band holds the keyboard) or `[ Compact ]`; they differ by one cell. */
129export function buttonText(b: BandButton): string {
130 return b.plain ? `${b.hotkey}: ${b.label}` : `[ ${b.label} ]`
131}
132
133/** The row as the terminal draws it: groups joined by ` │ `. */
134export function rowText(s: BandSegs): string {
135 const groups: string[] = s.cache === null ? [] : [s.cache]
136 if (s.model !== null) groups.push(`${s.model}${MODEL_MARK}`)
137 groups.push(s.ctx)
138 if (s.rates.length) groups.push(s.rates.map(rateText).join(RATE_GAP))
139 if (s.cost !== null) groups.push(s.cost)
140 if (s.digest !== null) groups.push(s.digest)
141 const buttons = [s.compact, s.clear, s.progress, s.log].filter((b): b is BandButton => b !== null)
142 groups.push(buttons.map(buttonText).join(BUTTON_GAP))
143 return groups.join(SEP)
144}
145
146/** Width in cells, one per code point. */
147export function cells(text: string): number {
148 return [...text].length
149}
150
151/**
152 * Drops segments until the row fits `bodyColumns`: the Log button (/fnd-log stays), the Clear button (/clear
153 * stays), the digest, the cost, then rate windows beyond the fullest (least full first), then the last rate,
154 * the model, the Progress button.
155 * Cache, ctx and Compact are never dropped. 0 or absent columns = a surface that did not measure: kept whole.
156 */
157export function layout(segs: BandSegs, bodyColumns: number | undefined): BandSegs {
158 if (!bodyColumns || bodyColumns <= 0) return segs
159 let s: BandSegs = segs
160 const fits = () => cells(rowText(s)) <= bodyColumns
161 if (fits()) return s
162 if (s.log !== null) s = { ...s, log: null }
163 if (!fits() && s.clear !== null) s = { ...s, clear: null }
164 if (!fits() && s.digest !== null) s = { ...s, digest: null }
165 if (!fits() && s.cost !== null) s = { ...s, cost: null }
166 while (!fits() && s.rates.length > 1) {
167 const fullest = s.rates.reduce((a, b) => (b.pct > a.pct ? b : a))
168 const victim = s.rates.reduce((a, b) => (b !== fullest && (a === fullest || b.pct <= a.pct) ? b : a))
169 s = { ...s, rates: s.rates.filter(r => r !== victim) }
170 }
171 if (!fits() && s.rates.length) s = { ...s, rates: [] }
172 if (!fits() && s.model !== null) s = { ...s, model: null }
173 if (!fits() && s.progress !== null) s = { ...s, progress: null }
174 return s
175}
176
177const HOUR = 60 * MIN
178export const DEFAULT_TTL_MS = 5 * MIN
179
180export const USAGE_INIT: FndUsage = { ctxPct: null, ctxTokens: null, window: 0, rates: [], costUsd: null }
181export const CACHE_INIT: FndCache = { anchorMs: null, ttlMs: DEFAULT_TTL_MS, ttlSource: 'default', isCold: false }
182
183/** `5m` / `1h` (the userConfig picker and the API's `cache_ttl`) in ms; anything else (`auto`) → null. */
184export function ttlMsOf(v: unknown): number | null {
185 if (v === '5m') return 5 * MIN
186 if (v === '1h') return HOUR
187 return null
188}
189
190export type RawContext = { tokens?: number; window: number; percent?: number }
191
192export type RawCost = { usd: number }
193
194/** `$.session.usage()` / `session.measure` figures as the usage atom holds them. */
195export function toUsage(context: RawContext, rateLimits: readonly RawRate[] | undefined, cost?: RawCost): FndUsage {
196 return {
197 ctxPct: context.percent ?? null,
198 ctxTokens: context.tokens ?? null,
199 window: context.window,
200 rates: toRates(rateLimits),
201 costUsd: typeof cost?.usd === 'number' ? cost.usd : null,
202 }
203}
204
205/** `$0.49`, `$139.14`: the host's /cost total, cents kept so a small session still moves. */
206export function fmtUsd(usd: number): string {
207 return `$${usd.toFixed(2)}`
208}
209
210/** `cost $139.14`; null without a ledger and while the session has cost nothing. */
211export function costText(usd: number | null): string | null {
212 return usd === null || usd <= 0 ? null : `cost ${fmtUsd(usd)}`
213}
214
215/** The cost hover card. */
216export function costCard(usd: number): string {
217 return `session cost: ${fmtUsd(usd)} at API prices, as /cost counts it (a subscription is not billed per request)`
218}
219
220/** `1,234,567`. */
221export function fmtInt(n: number): string {
222 return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
223}
224
225/** `in 42m`, `in 2h 05m`, `in 3d 4h`; null when absent or unparsable. */
226export function fmtResetIn(resetsAt: string | null, nowMs: number): string | null {
227 if (!resetsAt) return null
228 const at = Date.parse(resetsAt)
229 if (Number.isNaN(at)) return null
230 const mins = Math.max(0, Math.round((at - nowMs) / MIN))
231 if (mins < 60) return `in ${mins}m`
232 const h = Math.floor(mins / 60)
233 if (h < 24) return `in ${h}h ${String(mins % 60).padStart(2, '0')}m`
234 return `in ${Math.floor(h / 24)}d ${h % 24}h`
235}
236
237/** `5h window: 61% used, resets in 2h 05m`: the rate hover card and the ≥ 90 % toast. */
238export function rateCard(r: FndRate, nowMs: number): string {
239 const reset = fmtResetIn(r.resetsAt, nowMs)
240 return `${r.label} window: ${fmtPct(r.pct)} used${reset ? `, resets ${reset}` : ''}`
241}
242
243export const RATE_ALARM_PCT = 90
244
245/** The first window whose shown percentage is at or past the alarm line, or null. */
246export function alarmRate(rates: readonly FndRate[]): FndRate | null {
247 return rates.find(r => Math.round(r.pct) >= RATE_ALARM_PCT) ?? null
248}
249
250/** `1 h`, `5 min`. */
251export function ttlText(ms: number): string {
252 return ms % HOUR === 0 ? `${ms / HOUR} h` : `${Math.round(ms / MIN)} min`
253}
254
255export function cacheCard(cache: FndCache, nowMs: number): string {
256 const left = cacheRemaining(cache, nowMs)
257 if (left === null) return 'prompt cache: no response yet'
258 if (cache.isCold || left <= 0) return 'prompt cache: cold, the next request writes it again'
259 const mins = left < MIN ? '<1' : `~${Math.floor(left / MIN)}`
260 return `prompt cache: ${mins} min left (estimate: last response + ${ttlText(cache.ttlMs)} TTL)`
261}
262
263/**
264 * The context right after a compaction: the engine's own count of the kept conversation over the
265 * window already known. It holds until a response reports a measured reading.
266 */
267export function compactedUsage(u: FndUsage, tokensAfter: number | undefined): FndUsage {
268 const known = typeof tokensAfter === 'number' && tokensAfter >= 0
269 return { ...u, ctxTokens: known ? tokensAfter : null, ctxPct: known && u.window > 0 ? (tokensAfter / u.window) * 100 : null }
270}
271
272/** A measurement without a context reading (window only, as right after a compaction) keeps the last one. */
273export function keepCtx(prev: FndUsage, next: FndUsage): FndUsage {
274 return next.ctxPct === null ? { ...next, ctxPct: prev.ctxPct, ctxTokens: prev.ctxTokens } : next
275}
276
277export function ctxCard(u: FndUsage): string {
278 if (u.ctxPct === null) return 'context: no reading yet (fresh session or just compacted)'
279 const used = u.ctxTokens === null ? '' : `, ${fmtInt(u.ctxTokens)} used`
280 return `context: ${Math.round(u.ctxPct)}% of ${fmtInt(u.window)} tokens${used}`
281}
282
283/** Single code points only: a VS16/ZWJ sequence has no settled cell width. */
284export const GLYPH = { cache: '⏱', ctx: '\u{1F9E0}', rates: '⏳', model: '\u{1F916}', digest: '\u{1F4CB}', cost: '\u{1F4B0}' } as const
285
286/** The desktop label: the leading word of `cache 42m` / `ctx 47%` / `cost $1.23` becomes its glyph. */
287export function glyphText(text: string): string {
288 return text
289 .replace(/^cache /, `${GLYPH.cache} `)
290 .replace(/^ctx /, `${GLYPH.ctx} `)
291 .replace(/^cost /, `${GLYPH.cost} `)
292}
293
294export const COMPACT_LOUD_PCT = 80
295
296/** Always drawn first and always pressable, so the buttons never shift; `plain` = the normal look (never dim), the primary button from 80 % between turns. */
297export function compactButton(ctxPct: number | null, isWorking: boolean): BandButton {
298 const loud = ctxPct !== null && !isWorking && Math.round(ctxPct) >= COMPACT_LOUD_PCT
299 return { key: 'compact', label: 'Compact', hotkey: 'c', plain: !loud }
300}
301
302export const CLEAR_BUTTON: BandButton = { key: 'clear', label: 'Clear', hotkey: 'x', plain: true }
303export const PROGRESS_BUTTON: BandButton = { key: 'progress', label: 'Progress', hotkey: 'p', plain: true }
304export const LOG_BUTTON: BandButton = { key: 'log', label: 'Log', hotkey: 'l', plain: true }
305
306export type CompactOutcome = { skip?: string; tokensBefore?: number; tokensAfter?: number }
307
308export function compactToast(r: CompactOutcome): string {
309 if (r.skip !== undefined) return `compact skipped: ${r.skip}`
310 const n = (v: number | undefined) => (v === undefined ? '?' : fmtInt(v))
311 return `compacted ${n(r.tokensBefore)} → ${n(r.tokensAfter)} tokens`
312}
313
314export type BandInput = {
315 usage: FndUsage
316 model: string | null
317 cache: FndCache
318 nowMs: number
319 isWorking: boolean
320 digest: string | null
321}
322
323/** `claude-fable-5-1` → `fable-5-1`: every model id carries the prefix, so it says nothing. */
324export function shortModel(m: string | null): string | null {
325 return m === null ? null : m.replace(/^claude-/, '')
326}
327
328/** The ids the terminal's model picker offers, as `/model <id>` takes them; the engine lists no models itself. */
329export const MODEL_IDS = ['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-4-5-20251001'] as const
330
331/** The mark after the model id on the terminal: the segment is the picker's button. */
332export const MODEL_MARK = ' ▾'
333
334export type ModelOption = { value: string; label: string; hotkey?: string }
335
336/**
337 * The picker's options: the known ids plus the session's own when it is none of them (a pinned or dated id).
338 * Each takes its label's first letter as hotkey when it is one and still free (`f`, `o`, `s`, `h`).
339 */
340export function modelOptions(current: string | null): ModelOption[] {
341 const ids: string[] = current !== null && !MODEL_IDS.includes(current as (typeof MODEL_IDS)[number]) ? [current, ...MODEL_IDS] : [...MODEL_IDS]
342 const taken = new Set<string>()
343 return ids.map(value => {
344 const label = shortModel(value) as string
345 const letter = label[0]
346 if (!/^[a-z]$/.test(letter) || taken.has(letter)) return { value, label }
347 taken.add(letter)
348 return { value, label, hotkey: letter }
349 })
350}
351
352/** `cache 42m` → [`cache`, `42m`]: the dim label and the bold value; no space → the whole text is the label. */
353export function splitLabel(text: string): [string, string] {
354 const i = text.indexOf(' ')
355 return i < 0 ? [text, ''] : [text.slice(0, i), text.slice(i + 1)]
356}
357
358/** Every segment before the width drop. */
359export function bandSegs(i: BandInput): BandSegs {
360 return {
361 cache: i.usage.rates.some(r => r.pct >= 100) ? null : cacheView(i.cache, i.nowMs, i.isWorking).text,
362 model: shortModel(i.model),
363 ctx: ctxText(i.usage.ctxPct),
364 rates: i.usage.rates,
365 cost: costText(i.usage.costUsd),
366 digest: i.digest,
367 compact: compactButton(i.usage.ctxPct, i.isWorking),
368 clear: CLEAR_BUTTON,
369 progress: PROGRESS_BUTTON,
370 log: LOG_BUTTON,
371 }
372}
373
374export const HOUR_MS = HOUR
375
376/** `ephemeral_1h_input_tokens` of an Agent result's usage; 0 when absent. */
377export function oneHourCacheTokens(result: unknown): number {
378 const usage = (result as { usage?: { cache_creation?: { ephemeral_1h_input_tokens?: unknown } | null } } | null)?.usage
379 const n = usage?.cache_creation?.ephemeral_1h_input_tokens
380 return typeof n === 'number' ? n : 0
381}
382hooks/mods/core/progress-parse.ts 57 lines1// Pure parse of a task workspace's progress.md / notes.md into the band digest and pane rows.
2import type { FndRow } from '../../../types'
3
4const ROW = /^\s*- \[( |x|X)\] (.+)$/
5const CURRENT_MAX = 28
6const NOTES_TAIL = 3
7
8export type ParsedProgress = { done: number; total: number; current: string | null; rows: FndRow[] }
9
10/**
11 * Checkbox rows. `current` is the first unchecked row below the last checked one (the frontier),
12 * else the first unchecked row; it is cut to 28 code points plus `…`. Unchecked rows above the
13 * frontier are `waiting`, the rest `todo`.
14 */
15export function parseProgress(md: string): ParsedProgress {
16 const rows: FndRow[] = []
17 for (const line of md.split(/\r?\n/)) {
18 const m = ROW.exec(line)
19 if (m) rows.push({ mark: m[1] === ' ' ? 'todo' : 'done', text: (m[2] ?? '').trim() })
20 }
21 const lastDone = rows.map(r => r.mark).lastIndexOf('done')
22 let at = rows.findIndex((r, i) => i > lastDone && r.mark === 'todo')
23 if (at === -1) at = rows.findIndex(r => r.mark === 'todo')
24 rows.forEach((r, i) => {
25 if (i < at && r.mark === 'todo') r.mark = 'waiting'
26 })
27 const row = rows[at]
28 if (row) row.mark = 'current'
29 return {
30 done: rows.filter(r => r.mark === 'done').length,
31 total: rows.length,
32 current: row ? cut(row.text, CURRENT_MAX) : null,
33 rows,
34 }
35}
36
37/** The last three `- ` bullet lines of notes.md. */
38export function notesTail(md: string): string[] {
39 return md
40 .split(/\r?\n/)
41 .filter(l => l.startsWith('- '))
42 .slice(-NOTES_TAIL)
43 .map(l => l.trimEnd())
44}
45
46/** Band digest: `ELC-1591 3/5 ▶ Preview themes`, `ELC-1591 ✓ 5/5` when every row is checked, the id alone with no rows. */
47export function digestText(workId: string, p: { done: number; total: number; current: string | null }): string {
48 if (p.total === 0) return workId
49 if (p.current === null) return `${workId} ✓ ${p.done}/${p.total}`
50 return `${workId} ${p.done}/${p.total} ▶ ${p.current}`
51}
52
53function cut(text: string, max: number): string {
54 const cps = [...text]
55 return cps.length > max ? `${cps.slice(0, max).join('')}…` : text
56}
57