SLOPSHOPPER

Foundation

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · fnd
│ ┃ Progress ✕ › fix the failing auth test and add an audit log call │ ┃ no task workspace — /fnd:save-task-context │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /fnd-band │ ⎿ fnd: fnd band debug │ ⎿ fnd: usage(): {"startedAt":1759998200000,"context":{"tokens":974 │ ⎿ fnd: cache: {"anchorMs":1760000000600,"ttlMs":3600000,"ttlSource │ ⎿ fnd: usage atom: {"ctxPct":49,"ctxTokens":97400,"window":200000, │ ⎿ fnd: progress: {"workId":null,"branch":"feat/auth-refresh"} │ ⎿ fnd: root: /work/app │ │ ──────────────────────────────────────────────────────────────────────────────────────────────────── cache 60m │ opus-5-5 ▾ │ ctx 49% │ 5h 31% │ [ Compact ] [ Clear ] [ Progress ] [ Log ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
──────────────────────────────────────────────────────────────────────────────────────────────────── cache 60m │ opus-5-5 ▾ │ ctx 49% │ 5h 31% │ [ Compact ] [ Clear ] [ Progress ] [ Log ]
Pane · Progress
no task workspace — /fnd:save-task-context
Pane · Log
08:53 session start
Source 15 files
hooks/mods/register.tsx 23 lines
1// 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}
23
hooks/mods/core/band.tsx 318 lines
1// 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}
318
hooks/mods/core/log.tsx 92 lines
1// 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}
92
hooks/mods/core/progress.tsx 345 lines
1// 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}
345
hooks/mods/core/usage.ts 274 lines
1// 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}
274
hooks/mods/fnd/guard.ts 88 lines
1// 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}
88
hooks/mods/fnd/marker.ts 48 lines
1// 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}
48
hooks/mods/fnd/prompt-slim.ts 71 lines
1// 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}
71
hooks/mods/fnd/slim.ts 187 lines
1// 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}
187
hooks/mods/core/events.ts 96 lines
1// 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}
96
hooks/mods/core/lib.ts 382 lines
1// 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}
382
hooks/mods/core/progress-parse.ts 57 lines
1// 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