A file tree of what Claude touches: what it is doing now, the files it touched this turn, and git status

Shows a file tree of your project beside the chat, with what Claude is reading, editing or committing right now and every file it has touched this turn.
/plugin marketplace add partalemre/MODZ
/plugin install filetree@modz
Or in one step: /plugin install filetree --marketplace partalemre/MODZ.
The MODZ panel (modz) shows its line here: files changed this turn, uncommitted changes, or the last turn's commit.
Open the pane with /filetree. It does not open by itself; once opened, it comes back when a session restarts.
≈1.5k tok). Reads are blue, writes orange, commits green, uncommitted changes amber.+N -N line counts on modified files, and counts on folders. Commits to another repo (cd other && git commit) show as committed there; if git refuses a commit, the reason is shown.r refresh, c collapse, s size/date, . hidden files, u parent folder, p project, x clear selection, y copies the relative path (Y, the absolute one), arrow keys or j/k move, Enter opens a file or toggles a folder.Each Read Claude makes (a subagent's too, counted toward the file) is measured from the sizes the API reports: every model request carries its whole prompt (input, cache read and cache write tokens), so the next request in the same loop, less the previous one and its answer, is what that step's results added.
13,321: the read was the only thing its step added, so the number is the API's own.≈1,481: the step added more than this read (other results, a reminder the engine attached, or the model's thinking), so the measured growth is shared by text length.~740: no request followed the read (the turn was stopped, or the context was compacted in between), so it is a guess from the text's length at the rate this session's measured steps showed.The turn total sums the files; the session total sums the turns. In the terminal the now line carries the turn's total and each tree row its file's.
The desktop app (Code tab) draws the tree directly. The terminal needs the fullscreen layout (/tui fullscreen) and a window at least 110 columns wide.
If a file is selected in the tree when you send a prompt, one line naming it is added to that prompt's context so "this" can mean it. Writing @path in a prompt reveals that file in the tree.
/filetree opens the tree on the working folder and follows it./filetree <path> pins the tree to another folder.| Option | Default | Meaning |
|---|---|---|
activity | reads and writes | What shimmers in the tree: reads and writes, writes, reads or none. Git status and line counts always show. |
glyphs | auto | auto uses Nerd Font icons when a Nerd Font is installed (plain Unicode in the desktop app), nerd or plain forces one. |
follow | on | on scrolls the tree to the file Claude reads, writes or commits. off keeps the view where you put it; highlights still show. |
column | date | What the right column shows at session start: date or size. The header button toggles it. |
du (a summed listing on Windows), worked out in the background for the rows on screen.cat, rg) light up in the tree but carry no count. A turn's counts reset when the next turn starts.Uses no model tokens beyond the one context line above.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
Adapted from data-goblin/claude-code-filetree, licensed MIT. The license file is in this folder, with the contributors in ACKNOWLEDGEMENTS.md.
hooks/register.tsx 300 lines1import { type EngineInterface, type On, type Register, update } from 'claude-code'
2
3import { drawDesktop, opening } from './desktop'
4import { HomeButton } from './home'
5import { gitDir } from './gitread'
6import type { Host, IoHost, StateHost, UiHost } from './host'
7import { HUB_PANE, isOpenFor } from './hub'
8import { setLang } from './lang'
9import { onRows, type RowsMessage, scrollBy } from './keys'
10import { refreshLook } from './look'
11import { followCwd, openFromHub, sync } from './pane'
12import { promptContext } from './prompt'
13import { loopDone, noteRead, stepDone, takeReads } from './readstep'
14import { settleBackground, touched } from './scan'
15import { sceneOf } from './scene'
16import { isOffStage, shownOf } from './stage'
17import { mem } from './session'
18import { pinned } from './pin'
19import { openByCommand, resumePane, startPane } from './start'
20import { drawTerminal } from './terminal'
21import { afterCall, beforeCall, callFailed, isWatched } from './toolcall'
22import { emptyTree, join, posix, useDrives } from './tree'
23import { NO_TURN, syncHub } from './turn'
24import { words } from './words'
25
26const TREE = { plugin: 'filetree', key: 'tree' } as const
27const THEME = { plugin: 'filetree', key: 'theme' } as const
28const ACTIVITY = { plugin: 'filetree', key: 'activity' } as const
29const TURN = { plugin: 'filetree', key: 'turn' } as const
30const READS = { plugin: 'filetree', key: 'reads' } as const
31const LOOK = { plugin: 'filetree', key: 'look' } as const
32const HUB = { plugin: 'filetree', key: 'hub' } as const
33const PANE = 'filetree'
34const ACTIVITY_KEEP = 6
35// Git writes its index and HEAD in steps; the tree reads them once the burst settles.
36const GIT_SETTLE_MS = 300
37const MIN_WIDTH = 24
38const DEFAULT_ROWS = 40
39// The "← MODZ" button's row (the desktop's column gap adds one more).
40const HOME_ROWS = 2
41
42function stateOf($: EngineInterface): StateHost {
43 return {
44 tree: async () => ({ ...emptyTree(''), ...(await $.state.get(TREE)).value }),
45 putTree: async fn => {
46 await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
47 },
48 activities: async () => (await $.state.get(ACTIVITY)).value ?? [],
49 setActivities: async fn => {
50 await update($, ACTIVITY, cur => fn(cur ?? []).slice(-ACTIVITY_KEEP))
51 },
52 turn: async () => ({ ...NO_TURN, ...(await $.state.get(TURN)).value }),
53 setTurn: async fn => {
54 await update($, TURN, cur => fn({ ...NO_TURN, ...cur }))
55 },
56 readTotal: async () => (await $.state.get(READS)).value,
57 setReadTotal: async fn => {
58 await update($, READS, cur => fn(cur))
59 },
60 hubCard: async () => (await $.state.get(HUB)).value,
61 setHubCard: async card => {
62 await $.state.set(HUB, card)
63 },
64 look: async () => (await $.state.get(LOOK)).value,
65 setLook: async look => {
66 await update($, LOOK, () => look)
67 },
68 theme: async () => (await $.state.get(THEME)).value,
69 setTheme: async theme => {
70 await $.state.set(THEME, theme)
71 },
72 isHubOn: async () => (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true,
73 goHome: async () => {
74 try {
75 await $.state.set({ plugin: 'filetree', key: 'home' }, { at: await $.clock.now() })
76 } catch (error) {
77 $.ui.log(`filetree: home failed (${String(error)})`, { to: 'debug' })
78 }
79 },
80 reportShown: async () => {
81 try {
82 if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
83 await $.state.set({ plugin: 'filetree', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
84 } catch (error) {
85 $.ui.log(`filetree: shown failed (${String(error)})`, { to: 'debug' })
86 }
87 },
88 }
89}
90
91function ioOf($: EngineInterface): IoHost {
92 return {
93 list: path => $.fs.list(path),
94 stat: (path, options) => $.fs.stat(path, options),
95 read: path => $.fs.read(path),
96 run: (argv, init) => $.process.run(argv, init),
97 now: () => $.clock.now(),
98 after: (ms, fn) => $.clock.after(ms, fn),
99 every: (ms, fn) => $.clock.every(ms, fn),
100 cwd: () => $.session.cwd(),
101 sessionId: () => $.session.id(),
102 messages: async agentId => {
103 const found = await $.session.messages(agentId ? { agentId, as: 'api' } : { as: 'api' })
104 return Array.isArray(found) ? found : []
105 },
106 tmpdir: () => $.env.get('TMPDIR'),
107 osEnv: () => $.env.get('OS'),
108 homeEnv: () => $.env.get('HOME'),
109 herdrEnv: () => $.env.get('HERDR_ENV'),
110 herdrBin: () => $.env.get('HERDR_BIN_PATH'),
111 }
112}
113
114function uiOf($: EngineInterface): UiHost {
115 return {
116 toast: text => $.ui.toast(text),
117 openPane: isFocused => opening(() => $.ui.open(isFocused ? { id: PANE, title: words().title, focus: true } : { id: PANE, title: words().title })),
118 isPaneOpen: async () => {
119 try {
120 return (await $.ui.panes()).some(p => p.id === PANE)
121 } catch {
122 return false
123 }
124 },
125 copy: args => $.ui.copy(args),
126 surfaces: () => $.session.surfaces(),
127 config: () => $.config.list(),
128 }
129}
130
131function hostOf($: EngineInterface): Host {
132 return { ...stateOf($), ...ioOf($), ...uiOf($) }
133}
134
135function onStart(on: On): void {
136 // DESIGN.md §9: the MODZ main panel asks for a pane by key; this only opens, never closes.
137 // modz's contract is not this mod's to import, so its address stays untyped here.
138 on('state.set', { plugin: 'modz', key: 'open' } as any, async ($: EngineInterface, e: any, next: any) => {
139 const done = await next(e)
140 if (isOpenFor(e.value, HUB_PANE)) await openFromHub(hostOf($))
141
142 return done
143 }).catch(($, e, next) => next(e))
144 // Another pane took the MODZ panel: this one steps aside rather than wait as a tab.
145 on('state.set', { plugin: 'modz', key: 'stage' } as any, async ($: EngineInterface, e: any, next: any) => {
146 const done = await next(e)
147 if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE })
148
149 return done
150 }).catch(($, e, next) => next(e))
151 on('session.start', async ($, e, next) => {
152 // The interface language (kit/lang.ts). Spelled out: the engine admits an env read by a literal name only.
153 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
154 await $.command.register({ name: 'filetree', description: words().command })
155 const isWindows = (await $.env.get('OS')) === 'Windows_NT'
156 useDrives(isWindows)
157 mem.home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
158 const host = hostOf($)
159 mem.activityId = Math.max(mem.activityId, ...(await host.activities()).map(a => a.id))
160 void startPane(host, { isWindows, isRemote: async () => Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY'))) })
161 return next(e)
162 })
163 on('command.run', { command: 'filetree' }, async ($, e) => ({ text: await openByCommand(hostOf($), e) }))
164}
165
166function onTools(on: On): void {
167 on('tool.call', async ($, e, next) => {
168 if (!isWatched(e.tool)) return next(e)
169 const host = hostOf($)
170 const call = await beforeCall(host, e)
171 let result: Awaited<ReturnType<typeof next>>
172 try {
173 result = await next(e)
174 } catch (err) {
175 await callFailed(host, call)
176 throw err
177 }
178 return afterCall(host, call, result)
179 })
180 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
181 const result = await next(e)
182 if (result.deny || result.isError || e.tool !== 'Read') return result
183 noteRead({ id: e.tool_use_id ?? '', file: e.file_path, text: result.text ?? '', agentId: e.agentId })
184 if (mem.showReads) void touched(hostOf($), [posix(e.file_path)], { tone: 'purple', isShown: true })
185 return result
186 })
187 // Each model request's size measures what the step before it read (readcost.ts); the request itself passes untouched.
188 on('turn.step', async function* ($, e, next) {
189 const reads = takeReads(e.agentId)
190 const step = yield* next(e)
191 void stepDone(hostOf($), { turnId: e.turnId, agentId: e.agentId, reads, usage: step.usage })
192 return step
193 }).catch(async function* ($, e, next) {
194 return yield* next(e)
195 })
196}
197
198function onTurns(on: On): void {
199 on('turn.start', async ($, e, next) => {
200 const host = hostOf($)
201 await host.setTurn(() => ({ ...NO_TURN, isRunning: true, hasRun: true }))
202 await syncHub(host)
203 return next(e)
204 })
205 on('turn.complete', async ($, e, next) => {
206 const result = await next(e)
207 const host = hostOf($)
208 await loopDone(host, e.agentId)
209 if (!e.agentId) await host.setTurn(cur => ({ ...NO_TURN, ...cur, isRunning: false }))
210 if (!e.agentId) await syncHub(host)
211 return result
212 })
213 on('ui.message', async ($, e, next) => {
214 if (e.requestId !== PANE || e.element !== 'rows' || !e.data || typeof e.data !== 'object') return next(e)
215 await onRows(hostOf($), e.data as RowsMessage, e.surface)
216 return {}
217 })
218}
219
220function onClassic(on: On): void {
221 on('classic.SessionStart', async ($, e, next) => {
222 const result = await next(e)
223 const host = hostOf($)
224 if (e.source === 'clear' || e.source === 'resume' || e.source === 'fork') void resumePane(host)
225 const gd = await gitDir(host, posix(e.cwd))
226 return gd ? { ...result, watchPaths: [...(result.watchPaths ?? []), join(gd, 'index'), join(gd, 'HEAD')] } : result
227 })
228 on('classic.CwdChanged', async ($, e, next) => {
229 const result = await next(e)
230 void followCwd(hostOf($))
231 return result
232 })
233 on('classic.FileChanged', async ($, e, next) => {
234 const result = await next(e)
235 if (/[\\/]\.git[\\/]|[\\/](index|HEAD)$/.test(e.file_path)) $.clock.after(GIT_SETTLE_MS, () => void sync(hostOf($), true))
236 return result
237 })
238}
239
240function onPane(on: On): void {
241 on('ui.focus', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
242 const result = await next(e)
243 const host = hostOf($)
244 void sync(host)
245 if (await host.look()) void refreshLook(host)
246 return result
247 })
248 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
249 await scrollBy(hostOf($), e.by)
250 return {}
251 })
252 on('prompt.submit', async ($, e, next) => {
253 const context = await promptContext(hostOf($), e)
254 return next(context.length ? { ...e, context } : e)
255 })
256 on('prompt.attachment', async ($, e, next) => {
257 void settleBackground(hostOf($), e.text)
258 return next(e)
259 })
260}
261
262function onRender(on: On): void {
263 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
264 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
265 if (e.surface === 'terminal' && e.props.placement === 'inline') {
266 mem.noDock = true
267 void $.ui.close({ id: PANE }).catch(() => undefined)
268 const { Box: Empty } = $.ui.resolve(e)
269 return <Empty />
270 }
271 const host = hostOf($)
272 const s = await sceneOf(host, e.surface === 'desktop')
273 const isHubOn = await host.isHubOn()
274 const room = { width: Math.max(MIN_WIDTH, e.props.bodyColumns), bodyRows: (e.props.scroll?.bodyRows ?? DEFAULT_ROWS) - (isHubOn ? HOME_ROWS : 0) }
275 if (e.surface === 'desktop') {
276 const desk = $.ui.resolve(e)
277 const home = isHubOn ? HomeButton(desk, () => void host.goHome()) : null
278 return pinned(e, drawDesktop(host, desk, { s, room, home }))
279 }
280 const ui = $.ui.resolve(e)
281 const home = isHubOn ? HomeButton(ui, () => void host.goHome()) : null
282 return pinned(e, drawTerminal(host, ui, { s, room, home }))
283 })
284}
285
286export const register: Register = (on, options) => {
287 mem.glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
288 const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
289 mem.showReads = activity.includes('reads')
290 mem.showWrites = activity.includes('writes')
291 mem.followClaude = options?.follow !== 'off'
292 mem.sizeDefault = options?.column === 'size'
293 onStart(on)
294 onTools(on)
295 onTurns(on)
296 onClassic(on)
297 onPane(on)
298 onRender(on)
299}
300hooks/desktop.tsx 123 lines1import type { ElementTable } from 'claude-code'
2
3import type { FileTree } from '../types'
4import { actionsOf } from './actions'
5import { type CostRow, type Drawn, type GitRow, heroCard, type LegendItem, sectionLine, touchedCard } from './cards'
6import { activityChip, footer } from './chrome'
7import { costText, turnCost } from './costtext'
8import type { Host } from './host'
9import { paneMotion } from './kit/moving'
10import { heroOf, touchedRows } from './now'
11import type { RowsProps } from './rows'
12import { desktopRow } from './rowspec'
13import { heroInput, type Room, rowCtx, type Scene, treeWindow } from './scene'
14import { jump, search } from './search'
15import { mem } from './session'
16import { unsized, wantSizes } from './sizes'
17import { paletteOf, roleOfTone, shimmerTones } from './theme'
18import { drawToolbar } from './toolbar'
19import { upper } from './lang'
20import { words } from './words'
21
22type Ui = ElementTable<'desktop'>
23
24const PX_PER_COL = 8
25const ROW_PX = 18
26const MIN_SVG_W = 240
27const MAX_SVG_W = 900
28// Rows the desktop toolbar takes: its one line, and with "More" open the extra buttons and hint.
29const TOOLBAR_ROWS = 3
30const TOOLBAR_OPEN_ROWS = 9
31const FOOTER_ROWS = 2
32const MIN_BODY = 5
33
34function gitRowOf(t: FileTree): GitRow | null {
35 if (!t.top || !t.branch) return null
36 const [add, del] = t.diff[t.root] ?? [0, 0]
37 const changed = (t.counts[t.root] ?? [0, 0, 0]).reduce((a, b) => a + b, 0)
38 return { branch: t.branch.head, upstream: t.branch.upstream, ahead: t.branch.ahead, behind: t.branch.behind, changed, add, del }
39}
40
41// Shown once the session has read: this turn's count, and the session's when earlier turns read too.
42function costRowOf(s: Scene): CostRow | null {
43 const total = s.readTotal
44 if (!total || total.reads === 0) return null
45 const turn = turnCost(s.turn)
46 return { turn: turn ? costText(turn) : null, session: total.reads > (turn?.reads ?? 0) ? costText(total) : null }
47}
48
49// Only the colors the view shows right now get a legend entry.
50function legendOf(s: Scene, git: GitRow | null): LegendItem[] {
51 const tones = s.t.flashOn ? Object.values(s.t.flashTones).map(roleOfTone) : []
52 const has = (role: 'read' | 'edit') => s.turn.touches.some(x => x.role === role) || tones.includes(role)
53 const { legend } = words()
54 return [
55 ...(has('read') ? [{ role: 'read' as const, label: legend.read }] : []),
56 ...(has('edit') ? [{ role: 'edit' as const, label: legend.edit }] : []),
57 ...(git?.changed ? [{ role: 'warn' as const, label: legend.warn }] : []),
58 ]
59}
60
61// The pane's drawings on the desktop (kit/moving.ts): reset as the pane opens, so they cascade in each time.
62export const motion = paneMotion()
63
64// Opens the pane anew: its drawings cascade in again.
65export function opening<T>(open: () => T): T {
66 motion.reset()
67
68 return open()
69}
70
71// The three cards above the tree, drawn at the pane's width. Each one's `shows` leaves out what reads put into
72// context: that count climbs with every read, so it redraws the card without the sweep a real change gets.
73function cardsOf(s: Scene, w: number) {
74 const { t, look } = s
75 const input = heroInput(s)
76 const git = gitRowOf(t)
77 const cost = costRowOf(s)
78 const hero = heroOf(input)
79 const legend = legendOf(s, git)
80 const touchedList = touchedRows(input)
81 const section = sectionLine({ left: words().allFiles, right: upper((t.top ? t.top : t.root).split('/').pop() ?? '') }, { look, w })
82 return {
83 hero: { drawn: heroCard(hero, { git, cost, legend }, { look, w }), shows: { hero, git, legend, isCounted: Boolean(cost?.turn), isSummed: Boolean(cost?.session), look, w } },
84 touched: touchedList.length ? { drawn: touchedCard(touchedList, look, w), shows: { rows: touchedList.map(({ cost: c, ...row }) => ({ ...row, isCounted: c !== undefined })), look, w } } : null,
85 section: { drawn: section },
86 }
87}
88
89export function drawDesktop(host: Host, ui: Ui, frame: { s: Scene; room: Room; home: JSX.Element | null }) {
90 const { Box, Client, Svg } = ui
91 const { s, room, home } = frame
92 const { t, look } = s
93 const pal = paletteOf(look.isLight)
94 const { hero, touched, section } = cardsOf(s, Math.max(MIN_SVG_W, Math.min(MAX_SVG_W, room.width * PX_PER_COL)))
95 const lines = (px: number) => Math.ceil(px / ROW_PX) + 1
96 const toolRows = t.showTools ? TOOLBAR_OPEN_ROWS : TOOLBAR_ROWS
97 const fixed = lines(hero.drawn.height) + (touched ? lines(touched.drawn.height) : 0) + lines(section.drawn.height) + toolRows + FOOTER_ROWS
98 const ctx = rowCtx(s, room.width - 1, true)
99 const win = treeWindow(s, Math.max(MIN_BODY, room.bodyRows - fixed), { toSpec: r => desktopRow(ctx, r), colors: { thumb: pal.lineStrong, track: pal.line, note: pal.lo } })
100 if (t.showSize) wantSizes(host, unsized(t, win.rows))
101 const chip = s.latest ? activityChip(ui, s.latest, { unicode: true, colorOf: tone => pal.roles[roleOfTone(tone)].fg }) : null
102 // The full body width, so the cards end where the tree's right column does. A frame needs its box, so the
103 // drawing's size goes with it; a narrower slot than the columns promise caps the frame's width, and the
104 // drawing's viewBox scales it down inside instead of cutting it.
105 const svg = (key: string, card: { drawn: Drawn; shows?: unknown }) => <Svg key={key} {...motion.pic(key, card.drawn, { shows: card.shows })} />
106 const find = { onInput: (v: string) => void search(host, v), onSubmit: (v: string) => void jump(host, v) }
107 const rows = { rows: win.specs, active: t.cursor, activeBg: pal.line, hoverBg: pal.inset, tones: shimmerTones(pal, look.isLight), pointer: mem.pointer, ...(win.bar ? { bar: win.bar } : {}) } satisfies RowsProps
108 return (
109 <Box flexDirection="column" gap={1} minHeight={Math.max(1, room.bodyRows)}>
110 {home}
111 {svg('ft-hero', hero)}
112 {drawToolbar(ui, { t, act: actionsOf(host, t), search: find })}
113 {touched ? svg('ft-touched', touched) : null}
114 <Box flexDirection="column">
115 {svg('ft-section', section)}
116 <Client key="rows" module="./rows.tsx" props={rows} />
117 </Box>
118 <Box flexGrow={1} />
119 {footer(ui, s, chip) ?? <Box height={1} />}
120 </Box>
121 )
122}
123hooks/home.tsx 33 lines1// MODZ kit — the way back to the hub from a mod's pane (DESIGN.md §9). While the hub runs it keeps
2// `{ plugin: 'modz', key: 'isOn' }` true; a pane then draws `HomeButton` first, and its press writes the mod's
3// own `{ plugin: '<mod>', key: 'home' }` value (`{ at }`). The hub hears it, comes forward, and opens again if
4// it was closed, so the person always has a way back, even with one pane left and no tab to click.
5
6// The resolved element table of the surface that draws the pane.
7type Table = Record<string, any>
8
9export const HOME_LABEL = '← MODZ'
10
11export function HomeButton(ui: Table, onPress: () => void) {
12 const { Box, Button } = ui
13
14 return (
15 <Box key="modz-home-row" flexDirection="row">
16 <Button key="modz-home" label={HOME_LABEL} variant="secondary" onPress={onPress} />
17 </Box>
18 )
19}
20
21// The pane's tree with the way back above it; null while the hub is off draws the pane as it is.
22export function withHome<T>(ui: Table, tree: T, onHome: (() => void) | null) {
23 if (onHome === null) return tree
24 const { Box } = ui
25
26 return (
27 <Box flexDirection="column" rowGap={1}>
28 {HomeButton(ui, onHome)}
29 {tree}
30 </Box>
31 )
32}
33hooks/gitread.ts 119 lines1import { mapper, parseGit, parseNumstat, rollCounts, rollUp } from './gitstatus'
2import { type Host, patch } from './host'
3import { mem } from './session'
4import { resolve } from './shell'
5import { dirname, join } from './tree'
6import { syncHub } from './turn'
7
8const GIT_TIMEOUT_MS = 20_000
9const QUICK_MS = 5_000
10// Upward from the session's folder, deeper than any real checkout.
11const GIT_DIR_LEVELS = 64
12
13let gitRun: Promise<void> | null = null
14let gitNext: Promise<void> | null = null
15
16export function git(h: Host, cwd: string, call: { args: string[]; timeoutMs?: number }) {
17 const { args, timeoutMs = GIT_TIMEOUT_MS } = call
18 return h.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
19}
20
21export async function detectRepo(h: Host): Promise<void> {
22 const t = await h.tree()
23 if (!t.root) return
24 let top = ''
25 let prefix = ''
26 try {
27 const run = await git(h, t.root, { args: ['rev-parse', '--show-prefix', '--show-toplevel'], timeoutMs: QUICK_MS })
28 const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
29 prefix = p.trim()
30 top = tl.trim()
31 } catch {
32 top = ''
33 }
34 await patch(h, cur => (cur.root === t.root ? { top, prefix } : {}))
35}
36
37async function untracked(h: Host, root: string, dirs: string[]): Promise<string[]> {
38 if (!dirs.length) return []
39 const others = await git(h, root, { args: ['ls-files', '-o', '--exclude-standard', '-z', '--', ...dirs.map(d => d.slice(root.length + 1) || '.')] })
40 return others.exitCode === 0 ? others.stdout.split('\0').filter(Boolean) : []
41}
42
43async function readGit(h: Host): Promise<void> {
44 const t = await h.tree()
45 if (!t.root || !t.top) return
46 const root = t.root
47 try {
48 const status = await git(h, root, { args: ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'] })
49 if (status.exitCode !== 0) return
50 const parsed = parseGit(status.stdout, root, t.prefix)
51 const head = await git(h, root, { args: ['diff', 'HEAD', '--numstat', '-z', '--', '.'] })
52 const diff = head.exitCode === 0 ? parseNumstat(head.stdout, mapper(root, t.prefix)) : {}
53 const files = { ...parsed.files }
54 for (const rel of await untracked(h, root, parsed.untrackedDirs)) files[join(root, rel)] = 'new'
55 for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
56 mem.dirty = { root, files }
57 const { git: marks, ignored, untrackedDirs, branch } = parsed
58 await patch(h, cur => (cur.root === root ? { git: marks, ignored, untrackedDirs, branch, counts: rollCounts(files, root), diff: rollUp(diff, root) } : {}))
59 await syncHub(h)
60 } catch {
61 return
62 }
63}
64
65export function refreshGit(h: Host): Promise<void> {
66 if (!gitRun) {
67 gitRun = readGit(h).finally(() => {
68 gitRun = null
69 })
70 return gitRun
71 }
72 const rerun = () => {
73 gitNext = null
74 return refreshGit(h)
75 }
76 gitNext ??= gitRun.then(rerun, rerun)
77 return gitNext
78}
79
80export async function headOf(h: Host, cwd: string): Promise<string> {
81 try {
82 const run = await git(h, cwd, { args: ['rev-parse', 'HEAD'], timeoutMs: QUICK_MS })
83 return run.exitCode === 0 ? run.stdout.trim() : ''
84 } catch {
85 return ''
86 }
87}
88
89// The files the last commit holds, or what is staged; as absolute paths under the tree's root.
90export async function gitPaths(h: Host, at: { root: string; prefix: string }, isCommitted: boolean): Promise<string[]> {
91 const map = mapper(at.root, at.prefix)
92 try {
93 const args = isCommitted ? ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'] : ['diff', '--cached', '--name-only', '-z']
94 const run = await git(h, at.root, { args, timeoutMs: QUICK_MS })
95 return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
96 } catch {
97 return []
98 }
99}
100
101export async function gitDir(h: Host, cwd: string): Promise<string> {
102 for (let dir = cwd, i = 0; i < GIT_DIR_LEVELS; i++) {
103 try {
104 const dot = join(dir, '.git')
105 const st = await h.stat(dot)
106 if (st.kind === 'dir') return dot
107 const ref = /^gitdir:\s*(.+)$/m.exec(String(await h.read(dot)))?.[1]?.trim()
108 if (ref) return resolve(dir, ref, mem.home)
109 } catch {
110 const up = dirname(dir)
111 if (up === dir) return ''
112 dir = up
113 continue
114 }
115 return ''
116 }
117 return ''
118}
119hooks/host.ts 69 lines1import type { EngineInterface, Timer } from 'claude-code'
2
3import type { Activity, FileTree, HubCard, Look, ReadCost, Theme, TurnLog } from '../types'
4import type { ApiMessage } from './readcost'
5
6type Engine = EngineInterface
7
8// The mod's own values in `$.state`, bound in register.tsx where their references stay literal.
9export type StateHost = {
10 tree(): Promise<FileTree>
11 putTree(fn: (t: FileTree) => FileTree): Promise<void>
12 activities(): Promise<Activity[]>
13 setActivities(fn: (list: Activity[]) => Activity[]): Promise<void>
14 turn(): Promise<TurnLog>
15 setTurn(fn: (cur: TurnLog) => TurnLog): Promise<void>
16 readTotal(): Promise<ReadCost | undefined>
17 setReadTotal(fn: (cur: ReadCost | undefined) => ReadCost): Promise<void>
18 hubCard(): Promise<HubCard | undefined>
19 setHubCard(card: HubCard): Promise<void>
20 look(): Promise<Look | undefined>
21 setLook(look: Look): Promise<void>
22 theme(): Promise<Theme | undefined>
23 setTheme(theme: Theme): Promise<void>
24 // Whether the MODZ hub runs, and a request for it to come forward (the "← MODZ" button).
25 isHubOn(): Promise<boolean>
26 goHome(): Promise<void>
27 // The pane, opened when asked, now holds the MODZ panel: the hub and every other pane step aside (kit/stage.ts).
28 reportShown(): Promise<void>
29}
30
31// Files, commands, the clock and the session's environment.
32export type IoHost = {
33 list: Engine['fs']['list']
34 stat: Engine['fs']['stat']
35 read(path: string): Promise<unknown>
36 run: Engine['process']['run']
37 now(): Promise<number>
38 after(ms: number, fn: () => void): Timer
39 every(ms: number, fn: () => void): Timer
40 cwd(): Promise<string>
41 sessionId(): Promise<string>
42 // The loop's transcript as the API reads it: the main conversation, or a subagent's by its id.
43 messages(agentId: string | undefined): Promise<readonly ApiMessage[]>
44 tmpdir(): Promise<string | undefined>
45 osEnv(): Promise<string | undefined>
46 homeEnv(): Promise<string | undefined>
47 herdrEnv(): Promise<string | undefined>
48 herdrBin(): Promise<string | undefined>
49}
50
51// The pane, the clipboard, toasts and Claude's own settings.
52export type UiHost = {
53 toast(text: string): void
54 // The pane by its own id: opened (focused when asked for) or retitled, and whether it is open now.
55 openPane(isFocused: boolean): Promise<{ isPlaced: boolean }>
56 isPaneOpen(): Promise<boolean>
57 copy: Engine['ui']['copy']
58 surfaces: Engine['session']['surfaces']
59 config: Engine['config']['list']
60}
61
62// The engine's reach, bound in register.tsx: the engine follows `$` only into functions of the hooks module's own
63// file, so every other module gets this port instead.
64export type Host = StateHost & IoHost & UiHost
65
66export function patch(h: Host, fn: (t: FileTree) => Partial<FileTree>): Promise<void> {
67 return h.putTree(t => ({ ...t, ...fn(t) }))
68}
69hooks/hub.ts 35 lines1import type { HubCard, HubTone } from '../types'
2import { words } from './words'
3
4// The MODZ main panel's side of this mod (DESIGN.md §9): a one-line summary card it collects,
5// and its "open" requests, which name a pane by key. Pure: register.tsx does the writing.
6
7export const HUB_PANE = 'files'
8
9export type HubShown = { detail: string; tone: HubTone }
10// `edited` counts the files Claude wrote or committed in the latest turn; `sha` is that turn's commit.
11export type HubInput = { isRunning: boolean; touched: number; hasRepo: boolean; changed: number; edited?: number; sha?: string }
12
13const SHA_SHOWN = 7
14
15// Live work first, then what git says is left to commit, then what the last turn did.
16export function hubOf(s: HubInput): HubShown {
17 const edited = s.edited ?? 0
18 const w = words()
19 if (s.isRunning && s.touched > 0) return { detail: w.hubLive(s.touched, edited), tone: 'live' }
20 if (!s.hasRepo) return edited > 0 ? { detail: w.hubEditedLast(edited), tone: 'idle' } : { detail: w.hubNoRepo, tone: 'idle' }
21 if (s.changed > 0) return { detail: w.hubUncommitted(s.changed), tone: 'warn' }
22 if (s.sha) return { detail: w.hubCommit(s.sha.slice(0, SHA_SHOWN)), tone: 'ok' }
23
24 return { detail: w.hubClean, tone: 'ok' }
25}
26
27// Written only when what the card shows changes, so the main panel does not redraw for nothing.
28export function isSameCard(card: HubCard | undefined, shown: HubShown): boolean {
29 return card !== undefined && card.detail === shown.detail && card.tone === shown.tone
30}
31
32export function isOpenFor(value: unknown, pane: string): boolean {
33 return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
34}
35hooks/lang.ts 24 lines1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12 current = langOf(value)
13
14 return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24hooks/keys.ts 82 lines1import type { FileNode, FileTree } from '../types'
2import { actionsOf } from './actions'
3import { type Host, patch } from './host'
4import { copyPath, openNode, press, sync, toggle } from './pane'
5import { mem } from './session'
6import { TOOL_KEYS, type ToolKey } from './toolbar'
7import { visibleRows } from './tree'
8
9// What the rows Client posts: a press, a key, a scrollbar drag or a copy.
10export type RowsMessage = { press?: unknown; key?: unknown; ctrl?: unknown; shift?: unknown; scrollTo?: unknown; copy?: unknown }
11type Key = { key: string; ctrl: unknown; shift: unknown; surface?: string }
12
13const PAGE = 10
14const STEPS = new Map<string, number>([
15 ['up', -1],
16 ['k', -1],
17 ['down', 1],
18 ['j', 1],
19 ['pageup', -PAGE],
20 ['pagedown', PAGE],
21])
22// A wheel tick scrolls at least this many rows.
23const MIN_SCROLL = 3
24
25async function pressed(h: Host, t: FileTree, data: RowsMessage): Promise<void> {
26 const n = t.nodes.find(x => x.id === data.press)
27 if (!n) return
28 if (t.scroll === null) await patch(h, () => ({ scroll: mem.view.from }))
29 if (data.ctrl || data.shift) await openNode(h, n)
30 else await press(h, n)
31}
32
33// A key on the row under the cursor; false when it is not one of the row keys.
34async function onNode(h: Host, t: FileTree, at: { cur: FileNode; k: Key }): Promise<boolean> {
35 const { cur, k } = at
36 const { key } = k
37 if (key === 'y' || key === 'Y') await copyPath(h, cur.id, { isAbsolute: key === 'Y' || Boolean(k.shift), surface: k.surface })
38 else if ((key === 'right' || key === 'l') && cur.kind === 'dir' && !t.expanded.includes(cur.id)) await toggle(h, cur)
39 else if (key === 'left' || key === 'h') {
40 if (cur.kind === 'dir' && t.expanded.includes(cur.id)) await toggle(h, cur)
41 else if (cur.parent !== t.root) await patch(h, () => ({ cursor: cur.parent }))
42 } else if (key === 'return') await (cur.kind !== 'dir' ? openNode(h, cur) : toggle(h, cur))
43 else if (key === ' ') await toggle(h, cur)
44 else return false
45 return true
46}
47
48async function onKey(h: Host, t: FileTree, k: Key): Promise<void> {
49 const rows = visibleRows(t)
50 const at = rows.findIndex(r => r.node.id === t.cursor)
51 const cur = rows[at]?.node
52 const move = (d: number) => {
53 const target = rows[Math.max(0, Math.min(rows.length - 1, (at < 0 ? 0 : at) + d))]
54 return target ? patch(h, () => ({ cursor: target.node.id, scroll: null })) : Promise.resolve()
55 }
56 const step = STEPS.get(k.key)
57 if (step !== undefined) await move(step)
58 else if (k.key === 'home') await move(-rows.length)
59 else if (k.key === 'end') await move(rows.length)
60 else if (cur && (await onNode(h, t, { cur, k }))) return
61 else if (TOOL_KEYS[k.key] && !k.ctrl) actionsOf(h, t)[TOOL_KEYS[k.key] as ToolKey]()
62}
63
64export async function onRows(h: Host, data: RowsMessage, surface?: string): Promise<void> {
65 void sync(h)
66 const t = await h.tree()
67 if (typeof data.copy === 'string') return copyPath(h, data.copy, { isAbsolute: Boolean(data.shift), surface })
68 if (typeof data.scrollTo === 'number') {
69 const to = Math.round(Math.max(0, Math.min(1, data.scrollTo)) * mem.view.max)
70 if (to !== t.scroll) await patch(h, () => ({ scroll: to }))
71 return
72 }
73 if (typeof data.press === 'string') return pressed(h, t, data)
74 if (typeof data.key === 'string') await onKey(h, t, { key: data.key, ctrl: data.ctrl, shift: data.shift, surface })
75}
76
77export async function scrollBy(h: Host, by: number): Promise<void> {
78 const t = await h.tree()
79 const to = Math.max(0, Math.min(mem.view.max, (t.scroll ?? mem.view.from) + Math.sign(by) * Math.max(MIN_SCROLL, Math.abs(by))))
80 if (to !== t.scroll) await patch(h, () => ({ scroll: to }))
81}
82hooks/look.ts 39 lines1import type { Look } from '../types'
2import type { Host } from './host'
3import { systemDark } from './platform'
4import { mem } from './session'
5import { lightByName } from './theme'
6import { DEFAULT_THEME, parseTheme } from './tree'
7
8const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
9
10// Claude's theme setting first; for `auto` the system's appearance (DESIGN.md §2).
11export async function refreshLook(h: Host): Promise<void> {
12 let theme: unknown
13 try {
14 theme = (await h.config()).find(row => row.key === 'theme')?.value
15 } catch {
16 theme = undefined
17 }
18 const named = lightByName(theme)
19 const dark = named === undefined ? await systemDark(h) : undefined
20 const look: Look = named === undefined ? { isLight: dark === false, isAuto: true } : { isLight: named, isAuto: false }
21 const cur = await h.look()
22 if (cur?.isLight !== look.isLight || cur?.isAuto !== look.isAuto) await h.setLook(look)
23}
24
25export async function loadTheme(h: Host): Promise<void> {
26 const path = `${(await h.homeEnv()) ?? ''}/${THEME_FILE}`
27 try {
28 const stat = await h.stat(path)
29 if (stat.kind !== 'file') throw new Error('no theme file')
30 if (stat.mtimeMs === mem.themeMtime) return
31 await h.setTheme(parseTheme(String(await h.read(path))))
32 mem.themeMtime = stat.mtimeMs
33 } catch {
34 if (mem.themeMtime === 0) return
35 await h.setTheme(DEFAULT_THEME)
36 mem.themeMtime = 0
37 }
38}
39hooks/pane.ts 120 lines1import type { FileNode } from '../types'
2import { cancelFlash } from './flash'
3import { detectRepo, refreshGit } from './gitread'
4import { type Host, patch } from './host'
5import { ancestorsOf, posix, emptyTree, inside } from './tree'
6import { loadDirs, openDirs, revealPaths } from './listing'
7import { openFile } from './platform'
8import { dropIndex } from './search'
9import { mem } from './session'
10import { forgetSizes } from './sizes'
11import { words } from './words'
12
13const DOUBLE_MS = 450
14const SYNC_MS = 2_000
15
16export async function cwdOf(h: Host): Promise<string> {
17 return posix(await h.cwd())
18}
19
20// Calm: the pane opens when the person asks; on its own the mod only retitles an open pane,
21// so only an asked-for open takes the MODZ panel.
22export async function showPane(h: Host, isAsked: boolean): Promise<void> {
23 if (isAsked) {
24 if ((await h.openPane(true)).isPlaced) await h.reportShown()
25 } else if (!mem.noDock && (await h.isPaneOpen())) await h.openPane(false)
26}
27
28export async function inApp(h: Host): Promise<boolean> {
29 try {
30 return (await h.surfaces()).some(s => s !== 'terminal')
31 } catch {
32 return false
33 }
34}
35
36export async function reset(h: Host, root: string, focus = false): Promise<void> {
37 const prev = await h.tree()
38 cancelFlash()
39 mem.lastRoot = root
40 dropIndex()
41 forgetSizes()
42 await h.putTree(() => ({ ...emptyTree(root), showHidden: prev.showHidden, showTools: prev.showTools, showSize: prev.root ? prev.showSize : mem.sizeDefault }))
43 await showPane(h, focus)
44 await loadDirs(h, [root])
45 await detectRepo(h)
46 await refreshGit(h)
47}
48
49// A request from the MODZ main panel: opens (or brings forward) the pane, never closes it.
50export async function openFromHub(h: Host): Promise<void> {
51 mem.noDock = false
52 const t = await h.tree()
53 if (!t.root || t.nodes.length === 0) await reset(h, await cwdOf(h), true)
54 else await showPane(h, true)
55}
56
57export async function followCwd(h: Host): Promise<boolean> {
58 if (!mem.follow) return false
59 const cwd = await cwdOf(h)
60 const t = await h.tree()
61 if (t.root === cwd) return false
62 await reset(h, cwd)
63 return true
64}
65
66export async function sync(h: Host, force = false): Promise<void> {
67 const now = await h.now()
68 if (!force && now - mem.lastSync < SYNC_MS) return
69 mem.lastSync = now
70 dropIndex()
71 const t = await h.tree()
72 if (!t.root) return
73 await loadDirs(h, [t.root, ...openDirs(t)])
74 if (t.top) await refreshGit(h)
75}
76
77export async function copyPath(h: Host, id: string, how: { isAbsolute: boolean; surface?: string }): Promise<void> {
78 const t = await h.tree()
79 const text = !how.isAbsolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
80 const done = await h.copy({ text, ...(how.surface ? { surface: how.surface as 'terminal' } : {}) })
81 h.toast(done.isCopied ? words().copied(text) : words().notCopied)
82}
83
84export async function toggle(h: Host, n: FileNode): Promise<void> {
85 if (n.kind === 'dir' && !(await h.tree()).expanded.includes(n.id)) await loadDirs(h, [n.id])
86 await patch(h, t => {
87 const open = new Set(t.expanded)
88 if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
89 return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
90 })
91}
92
93export async function openNode(h: Host, n: FileNode): Promise<void> {
94 if (n.kind === 'dir') {
95 mem.follow = false
96 await reset(h, n.id)
97 } else await openFile(h, n.id)
98}
99
100export async function press(h: Host, n: FileNode): Promise<void> {
101 const now = await h.now()
102 const isDouble = mem.lastPress.key === n.id && now - mem.lastPress.at < DOUBLE_MS
103 mem.lastPress = { key: isDouble ? '' : n.id, at: now }
104 if (!isDouble) {
105 await toggle(h, n)
106 return
107 }
108 await openNode(h, n)
109}
110
111export async function reveal(h: Host, paths: string[]): Promise<void> {
112 if (await followCwd(h)) return
113 await revealPaths(h, paths)
114 await patch(h, cur => {
115 const open = new Set(cur.expanded)
116 for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
117 return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
118 })
119}
120hooks/prompt.ts 45 lines1import type { Args } from 'claude-code'
2
3import type { FileTree } from '../types'
4import type { Host } from './host'
5import { exists } from './listing'
6import { followCwd, reveal, sync } from './pane'
7import { settleBackground } from './scan'
8import { mem } from './session'
9import { resolve } from './shell'
10import { inside } from './tree'
11
12// The tree's paths a prompt names with `@`, trailing punctuation dropped.
13function mentionsOf(text: string, t: FileTree): string[] {
14 return [...text.matchAll(/@([^\s"'`]+)/g)]
15 .map(m => (m[1] ?? '').replace(/[.,;:!?)]+$/, ''))
16 .filter(Boolean)
17 .map(p => resolve(t.root, p, mem.home))
18 .filter(p => inside(t.root, p))
19}
20
21async function revealMentions(h: Host, mentions: string[]): Promise<void> {
22 const found: string[] = []
23 for (const p of mentions) {
24 try {
25 await h.stat(p)
26 found.push(p.replace(/\/$/, ''))
27 } catch {
28 continue
29 }
30 }
31 if (found.length) await reveal(h, found)
32}
33
34// The prompt's context with the selected file named, so "this" in the prompt can mean it.
35export async function promptContext(h: Host, e: Args<'prompt.submit'>) {
36 void settleBackground(h, e.text)
37 if (!(await followCwd(h))) void sync(h)
38 const t = await h.tree()
39 const context = [...(e.context ?? [])]
40 if (t.selected && (await exists(h, t.selected))) context.push(`The user has this file selected in the file tree; "this" or "it" in the prompt likely refers to it: ${t.selected}`)
41 const mentions = mentionsOf(e.text, t)
42 if (mentions.length) void revealMentions(h, mentions)
43 return context
44}
45hooks/readstep.ts 61 lines1// The hooks' side of read costs (readcost.ts): a read waits in its loop until the loop's next request reports
2// how big its prompt grew. Steps of the main loop and of each subagent are measured apart, by `agentId`.
3
4import type { Host } from './host'
5import { addedOf, attribute, growthOf, type PendingRead, rateAfter, sizeOf, type Usage } from './readcost'
6import { type Loop, mem } from './session'
7import { posix } from './tree'
8import { noteCosts } from './turn'
9
10type Step = { turnId: string; agentId: string | undefined; reads: PendingRead[]; usage: Usage | null }
11
12const keyOf = (agentId: string | undefined): string => agentId ?? ''
13
14function loopOf(agentId: string | undefined): Loop {
15 const key = keyOf(agentId)
16 const loop = mem.loops.get(key) ?? { turnId: '', size: null, pending: [] }
17 mem.loops.set(key, loop)
18 return loop
19}
20
21// A Read that answered: its file and the length of the text Claude got wait for the loop's next request.
22export function noteRead(read: { id: string; file: string; text: string; agentId: string | undefined }): void {
23 if (!read.id) return
24 loopOf(read.agentId).pending.push({ id: read.id, file: posix(read.file), chars: read.text.length })
25}
26
27// At a request's start, the reads waiting are the step before's: this request's own tools have not run yet.
28export function takeReads(agentId: string | undefined): PendingRead[] {
29 const loop = loopOf(agentId)
30 const reads = loop.pending
31 loop.pending = []
32 return reads
33}
34
35async function measure(h: Host, step: { reads: PendingRead[]; agentId: string | undefined; grown: number }): Promise<void> {
36 const { reads, agentId, grown } = step
37 if (reads.length === 0) return
38 const added = grown > 0 ? addedOf(await h.messages(agentId), reads.map(r => r.id)) : null
39 const costs = attribute({ reads, added, grown }, mem.rate)
40 mem.rate = rateAfter(mem.rate, { added, grown })
41 await noteCosts(h, costs)
42}
43
44// After a request's response: its size, against the size of the request before, measures that step's reads.
45// The loop's size is kept at once, so the next step reads it whatever the measuring still awaits.
46export function stepDone(h: Host, step: Step): Promise<void> {
47 const loop = loopOf(step.agentId)
48 const before = loop.turnId === step.turnId ? loop.size : null
49 const after = sizeOf(step.usage)
50 loop.turnId = step.turnId
51 loop.size = after
52 return measure(h, { reads: step.reads, agentId: step.agentId, grown: growthOf(before, after) })
53}
54
55// A loop that ended, or was cut short, with reads no request followed: they keep a guess.
56export async function loopDone(h: Host, agentId: string | undefined): Promise<void> {
57 const reads = takeReads(agentId)
58 mem.loops.delete(keyOf(agentId))
59 await measure(h, { reads, agentId, grown: 0 })
60}
61