Claude Code sidebar: plan and execution flow per model, subagent workers, git, memories, and a delegation retrospective after every turn.

hooks/register.tsx 1498 lines1import { atom, read, update } from 'claude-code'
2import type { Engine, HookFor, Register, RenderChildren } from 'claude-code'
3
4import type { Answer, Change, Diff, GitNote, Memory, MemoryMeta, MemoryUse, MemoryView, Miss, Plan, PlanStep, Repo, Retro, Step, StepView, Style, StyleDraft, Tab, Totals, Validation, Worker } from '../types'
5import { branchLabel, cleanMessage, clip, outcomeOf, parseNumstat, parseStatus, syncSteps, withCounts } from './changes'
6import { PLAN_DESCRIPTION, PLAN_RULE, PLAN_SCHEMA, PLAN_TOOL, PLAN_TOOL_ID, answersMessage, layers, normalizePlan } from './plan'
7import { INDEX, memoriesView } from './memories-tab'
8import { workersView } from './workers-tab'
9import { stylesView } from './styles-tab'
10import { activeStyle, buildStyle, parseStyle, slugName, withOutputStyle } from './styles'
11import { retroMessage, retroOf } from './retro'
12import { namesIn, parseIndex, parseMemory, projectSlug } from './memories'
13import { ESCALATE, ESCALATE_RULE, MODEL_IDS, VALIDATOR, VALIDATOR_PROMPT, addMiss, costOf, escalationOf, learned, tagged, tierOf, verdictOf, wordsOf } from './router'
14import type { Tier } from './router'
15
16const PANE = 'agent-lens'
17
18const steps = atom({ plugin: 'agent-lens', key: 'steps' } as const, [])
19const workers = atom({ plugin: 'agent-lens', key: 'workers' } as const, [])
20const skillsUsed = atom({ plugin: 'agent-lens', key: 'skillsUsed' } as const, {})
21const toolCounts = atom({ plugin: 'agent-lens', key: 'toolCounts' } as const, {})
22const prompt = atom({ plugin: 'agent-lens', key: 'prompt' } as const, '')
23const tab = atom({ plugin: 'agent-lens', key: 'tab' } as const, 'think' as Tab)
24// What is open/closed in Flow and Workers, by key; a missing key = the row's default state.
25const retro = atom({ plugin: 'agent-lens', key: 'retro' } as const, null as Retro | null)
26const folds = atom({ plugin: 'agent-lens', key: 'fold' } as const, {} as Record<string, boolean>)
27const routing = atom({ plugin: 'agent-lens', key: 'routing' } as const, true)
28const edited = atom({ plugin: 'agent-lens', key: 'edited' } as const, [])
29const validations = atom({ plugin: 'agent-lens', key: 'validations' } as const, [])
30const totals = atom({ plugin: 'agent-lens', key: 'totals' } as const, { steps: 0, tokensIn: 0, tokensOut: 0, cacheRead: 0 } as Totals)
31const turnTier = atom({ plugin: 'agent-lens', key: 'turnTier' } as const, { tier: 'opus', why: 'default' } as { tier: Tier; why: string })
32const hasGraph = atom({ plugin: 'agent-lens', key: 'hasGraph' } as const, false)
33const graphFirst = atom({ plugin: 'agent-lens', key: 'graphFirst' } as const, true)
34const graphSeen = atom({ plugin: 'agent-lens', key: 'graphSeen' } as const, false)
35const changes = atom({ plugin: 'agent-lens', key: 'changes' } as const, [] as Change[])
36const repo = atom({ plugin: 'agent-lens', key: 'repo' } as const, { top: '', branch: '', loaded: false } as Repo)
37const commitMsg = atom({ plugin: 'agent-lens', key: 'commitMsg' } as const, '')
38const gitNote = atom({ plugin: 'agent-lens', key: 'gitNote' } as const, null as GitNote | null)
39const diff = atom({ plugin: 'agent-lens', key: 'diff' } as const, null as Diff | null)
40const drafting = atom({ plugin: 'agent-lens', key: 'drafting' } as const, false)
41const syncing = atom({ plugin: 'agent-lens', key: 'syncing' } as const, false)
42const stepView = atom({ plugin: 'agent-lens', key: 'stepView' } as const, null as StepView | null)
43const memories = atom({ plugin: 'agent-lens', key: 'memories' } as const, [] as Memory[])
44const memMeta = atom({ plugin: 'agent-lens', key: 'memMeta' } as const, { dir: '', loaded: false, context: [], remember: [], recalls: [] } as MemoryMeta)
45const memUse = atom({ plugin: 'agent-lens', key: 'memUse' } as const, {} as Record<string, MemoryUse>)
46const memView = atom({ plugin: 'agent-lens', key: 'memView' } as const, null as MemoryView | null)
47const memType = atom({ plugin: 'agent-lens', key: 'memType' } as const, 'all')
48const styles = atom({ plugin: 'agent-lens', key: 'styles' } as const, [] as Style[])
49const styleActive = atom({ plugin: 'agent-lens', key: 'styleActive' } as const, '')
50const styleDraft = atom({ plugin: 'agent-lens', key: 'styleDraft' } as const, null as StyleDraft | null)
51const styleFresh = atom({ plugin: 'agent-lens', key: 'styleFresh' } as const, '')
52const styleNote = atom({ plugin: 'agent-lens', key: 'styleNote' } as const, '')
53const styleLoaded = atom({ plugin: 'agent-lens', key: 'styleLoaded' } as const, false)
54const plan = atom({ plugin: 'agent-lens', key: 'plan' } as const, null as Plan | null)
55const answers = atom({ plugin: 'agent-lens', key: 'answers' } as const, {} as Record<string, Answer>)
56const busy = atom({ plugin: 'agent-lens', key: 'busy' } as const, false)
57const misses = atom({ plugin: 'agent-lens', key: 'misses' } as const, [] as Miss[])
58const planFirst = atom({ plugin: 'agent-lens', key: 'planFirst' } as const, true)
59const planAsked = atom({ plugin: 'agent-lens', key: 'planAsked' } as const, false)
60const turnAt = atom({ plugin: 'agent-lens', key: 'turnAt' } as const, 0)
61
62const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
63
64// ANSI colors by name: Omarchy writes the terminal's 16-color palette
65// (ghostty/alacritty/kitty) on every theme change, so the sidebar follows it on its own.
66const C = {
67 accent: 'green',
68 model: 'cyan',
69 ok: 'green',
70 run: 'yellow',
71 bad: 'red',
72 info: 'blue',
73 graph: 'cyan',
74} as const
75
76// One color per model: opus magenta, sonnet blue, haiku green.
77const TIER_COLOR: Record<Tier, string | undefined> = { opus: 'magenta', sonnet: 'blue', haiku: 'green' }
78const modelColor = (model: string) => TIER_COLOR[tierOf(model) ?? 'opus']
79
80const short = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
81
82// Pill with the tier's initial (H/S/O) in the tier's color, so the model of each step reads at a glance.
83const pill = (model: string) => (tierOf(model) ?? short(model)).slice(0, 1).toUpperCase()
84
85const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n))
86
87// "2h13m" until the limit window resets
88const until = (ms: number) => {
89 const m = Math.max(0, Math.round(ms / 60000))
90
91 return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
92}
93
94const WINDOWS: Record<string, string> = { five_hour: '5h', seven_day: '7 days', spend_limit: 'budget' }
95
96// green, yellow 20 points before the threshold, red from the threshold
97const pctColor = (pct: number, bad: number) => (pct >= bad ? C.bad : pct >= bad - 20 ? C.run : C.ok)
98
99const secs = (ms: number) => (ms >= 60000 ? `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s` : `${Math.round(ms / 1000)}s`)
100
101// "Read, Read, Read, Bash" → "Read×3 Bash"
102export const tally = (names: readonly string[]) => {
103 const counts = new Map<string, number>()
104 for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1)
105
106 return [...counts].map(([name, n]) => (n > 1 ? `${name}×${n}` : name)).join(' ')
107}
108
109// What a tool does, briefly: the main input field (command, file, search), otherwise JSON.
110export const brief = (input: unknown) => {
111 const o = (input ?? {}) as Record<string, unknown>
112 const main = ['command', 'file_path', 'pattern', 'query', 'description', 'prompt', 'url', 'skill'].map(key => o[key]).find(v => typeof v === 'string')
113
114 return String(main ?? JSON.stringify(input) ?? '').slice(0, 400)
115}
116
117// Each tool of the step gets the output of the first unconsumed transcript call with the same tool + input.
118export const pairUses = <U extends { tool: string; brief: string }, D extends U & { output?: string; isError?: boolean }>(uses: U[], done: D[]) => {
119 const taken = new Set<number>()
120
121 return uses.map(u => {
122 const i = done.findIndex((d, n) => !taken.has(n) && d.tool === u.tool && d.brief === u.brief)
123 if (i < 0) return { ...u }
124 taken.add(i)
125
126 return { ...u, output: done[i]?.output ? clip(done[i].output, 3000) : undefined, isError: done[i]?.isError }
127 })
128}
129
130// Learned failures belong to the project and carry over from one session to the next.
131const missKey = async ($: Engine) => `misses:${await $.session.root()}`
132
133const recordMiss = async ($: Engine, words: string[] | undefined, tier: Tier | undefined) => {
134 if (!words?.length || !tier || tier === 'opus') return
135 const list = addMiss(await read($, misses), { words, tier, at: await $.clock.now() })
136 await update($, misses, () => list)
137 await $.store.set(await missKey($), list)
138}
139
140const usd = (n: number) => `$${n.toFixed(n < 1 ? 3 : 2)}`
141
142const toolLabel = (tool: string) => tool.replace(/^mcp__(?:plugin_)?([^_]+(?:_[^_]+)*?)__/, '$1:')
143
144const git = ($: Engine, top: string, args: string[], timeoutMs = 20_000) =>
145 $.process.run(['git', '--no-optional-locks', '-C', top, ...args], { timeoutMs, env: { GIT_TERMINAL_PROMPT: '0' } })
146
147// Rendering doesn't run processes: the status is read here and lands in atoms.
148const refreshChanges = async ($: Engine) => {
149 let snap: { top: string; branch: string; list: Change[] } | null = null
150 try {
151 const found = await git($, await $.session.root(), ['rev-parse', '--show-toplevel'])
152 const top = found.stdout.trim()
153 const status = found.exitCode === 0 ? await git($, top, ['status', '--porcelain=v1', '-b', '-z', '--untracked-files=all']) : null
154 if (status?.exitCode === 0) {
155 const parsed = parseStatus(status.stdout)
156 const diff = await git($, top, ['diff', 'HEAD', '--numstat', '-z'])
157 snap = { top, branch: parsed.branch, list: withCounts(parsed.changes, diff.exitCode === 0 ? parseNumstat(diff.stdout) : {}) }
158 }
159 } catch {
160 snap = null
161 }
162 await update($, repo, () => ({ top: snap?.top ?? '', branch: snap?.branch ?? '', loaded: true }))
163 await update($, changes, () => snap?.list ?? [])
164}
165
166const stage = async ($: Engine, top: string, paths: string[] | 'all') =>
167 outcomeOf(await git($, top, paths === 'all' ? ['add', '-A'] : ['add', '--', ...paths]))
168
169// ponytail: `restore --staged` needs a HEAD; it fails before the repo's first commit
170const unstage = async ($: Engine, top: string, paths: string[] | 'all') =>
171 outcomeOf(await git($, top, ['restore', '--staged', '--', ...(paths === 'all' ? ['.'] : paths)]))
172
173// No --no-verify: the repo's hooks run, and their failure shows in the note.
174const commit = async ($: Engine, top: string, message: string) => outcomeOf(await git($, top, ['commit', '-m', message]))
175
176// Any git action of the tab: runs, keeps the result note, then re-reads the status.
177const gitAct = async ($: Engine, run: (top: string) => Promise<GitNote>) => {
178 const { top } = await read($, repo)
179 if (!top) return
180 const note = await run(top)
181 await update($, gitNote, () => note)
182 await refreshChanges($)
183}
184
185// Fetch, then only the missing steps (pull --ff-only / push) on the current branch. A timeout (network, SSH key
186// with a passphrase) throws: it ends up in the note as a failure and doesn't block the button.
187const syncRepo = async ($: Engine) => {
188 const { top } = await read($, repo)
189 if (!top || (await read($, syncing))) return
190 await update($, syncing, () => true)
191 let note: GitNote
192 try {
193 const fetched = outcomeOf(await git($, top, ['fetch', '--prune'], 120_000))
194 await refreshChanges($)
195 const branch = branchLabel((await read($, repo)).branch)
196 const steps = fetched.ok ? syncSteps(branch) : []
197 note = !fetched.ok ? fetched : branch.gone ? { ok: false, text: 'branch is gone from the remote (deleted after merge?)' } : { ok: true, text: 'up to date with the remote' }
198 for (const step of steps) {
199 const res = await git($, top, step, 120_000)
200 note = res.exitCode === 0 ? { ok: true, text: `${step[0]} ✓` } : outcomeOf(res)
201 if (!note.ok) break
202 }
203 if (steps.length > 1 && note.ok) note = { ok: true, text: 'pull + push ✓' }
204 } catch (err) {
205 note = { ok: false, text: `sync: ${err instanceof Error ? err.message : String(err)}` }
206 } finally {
207 await update($, syncing, () => false)
208 }
209 await update($, gitNote, () => note)
210 await refreshChanges($)
211}
212
213// An opened step: tool outputs don't exist at the end of the step (they arrive later), so they are looked up
214// in the transcript on open, by tool + input.
215const openStep = async ($: Engine, s: Step) => {
216 const messages = await $.session.messages(s.agentId ? { agentId: s.agentId } : {})
217 const done = messages.flatMap(m => m.toolUses).map(u => ({ tool: u.tool, brief: brief(u.input), output: u.text, isError: u.isError }))
218 await update($, stepView, () => ({ id: s.id, title: `${short(s.model)} #${s.index}`, answer: s.answer, uses: pairUses(s.uses ?? [], done) }))
219}
220
221// A file's diff against HEAD (index + working tree). A new, untracked file is compared with /dev/null;
222// `diff --no-index` exits with 1 when there are differences, so 0 and 1 both count as success.
223const openDiff = async ($: Engine, c: Change) => {
224 const { top } = await read($, repo)
225 if (!top) return
226 const res = await git($, top, c.code === '?' ? ['diff', '--no-index', '--', '/dev/null', c.path] : ['diff', 'HEAD', '--', c.path])
227 await update($, diff, () => ({ path: c.path, text: clip(res.exitCode <= 1 ? res.stdout : res.stderr, 90_000) }))
228}
229
230// The commit message from the staged files (not everything modified), written by Haiku in the style of
231// the repo's recent subjects. A single line: the commit field is single-line.
232const draftMessage = async ($: Engine) => {
233 const { top } = await read($, repo)
234 if (!top || (await read($, drafting))) return
235 if (!(await read($, changes)).some(c => c.staged)) {
236 return void (await update($, gitNote, () => ({ ok: false, text: 'Nothing staged: tick the files for the message.' })))
237 }
238 await update($, drafting, () => true)
239 try {
240 const [stat, patch, recent] = await Promise.all([
241 git($, top, ['diff', '--cached', '--stat']),
242 git($, top, ['diff', '--cached']),
243 git($, top, ['log', '-8', '--format=%s']),
244 ])
245 const res = await $.model.complete({
246 model: 'haiku',
247 effort: 'low',
248 maxTokens: 200,
249 timeoutMs: 30_000,
250 prompt: `Write a git commit message for the staged changes.
251Rules: one line, imperative, at most 72 characters, same language and style as the recent subjects; no quotes, no trailing period, no explanation.
252
253Recent subjects:
254${recent.stdout}
255Files:
256${stat.stdout}
257Diff:
258${clip(patch.stdout, 12_000)}`,
259 })
260 const text = res.isAnswered ? cleanMessage(res.text) : ''
261 if (text) {
262 await update($, commitMsg, () => text)
263 await update($, gitNote, () => null)
264 } else {
265 await update($, gitNote, () => ({ ok: false, text: `Haiku gave no message${res.isAnswered ? '' : ` (${res.reason})`}.` }))
266 }
267 } finally {
268 await update($, drafting, () => false)
269 }
270}
271
272// Answers from the Flow tab go to the model as soon as there is a way: in the running turn,
273// attached to the next tool result (without interrupting it), otherwise as a user prompt.
274const takeAnswers = async ($: Engine) => {
275 const pending = Object.entries(await read($, answers)).filter(([, a]) => a.state === 'pending')
276 if (!pending.length) return ''
277 await update($, answers, all => {
278 const next = { ...all }
279 for (const [ask] of pending) if (next[ask]?.state === 'pending') next[ask] = { ...next[ask], state: 'sent' }
280 return next
281 })
282
283 return answersMessage(pending.map(([ask, a]) => ({ ask, text: a.text })))
284}
285
286const submitAnswers = async ($: Engine) => {
287 const text = await takeAnswers($)
288 if (!text) return
289 await update($, busy, () => true)
290 void $.prompt.submit({ text, asUser: true })
291}
292
293const answer = async ($: Engine, ask: string, text: string) => {
294 if (!text.trim()) return
295 await update($, answers, all => ({ ...all, [ask]: { text: text.trim(), state: 'pending' as const } }))
296 if (!(await read($, busy))) await submitAnswers($)
297}
298
299const commitStaged = async ($: Engine) => {
300 const message = (await read($, commitMsg)).trim()
301 const list = await read($, changes)
302 if (!message) return void (await update($, gitNote, () => ({ ok: false, text: 'Write a commit message.' })))
303 if (!list.some(c => c.staged)) return void (await update($, gitNote, () => ({ ok: false, text: 'Nothing staged: tick files or [stage all].' })))
304 await gitAct($, async top => {
305 const res = await commit($, top, message)
306 if (res.ok) await update($, commitMsg, () => '')
307
308 return res
309 })
310}
311
312// After each main-loop turn: the execution analysis (what could have been delegated more cheaply) goes into Flow.
313const runRetro = async ($: Engine, turnId: string) => {
314 const all = await read($, steps)
315 const since = await read($, turnAt)
316 const turnWorkers = (await read($, workers)).filter(w => w.startedAt >= since && w.agentId)
317 const ids = new Set(turnWorkers.map(w => w.agentId))
318 const main = all.filter(s => s.turnId === turnId && !s.agentId)
319 if (!main.length) return
320 const r = retroOf(turnId, await $.clock.now(), main, turnWorkers, all.filter(s => s.agentId && ids.has(s.agentId)), await read($, plan))
321 await update($, retro, () => r)
322 if (!r.findings.length) return
323 $.ui.toast(`Retrospective: ${r.findings.length} possible optimizations${r.saving > 0 ? ` · ~${usd(r.saving)}` : ''}`)
324 // The line enters the conversation, like the validator's verdict: the model's next turn reads it.
325 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: retroMessage(r) }] } })
326}
327
328// The "Memories" tab: the current project's auto-memory as a graph, and how it reaches the model.
329// The directory comes from /context (the AutoMem file the engine actually loads); without it, the project slug.
330const memoryDir = async ($: Engine, context: MemoryMeta['context']) => {
331 const auto = context.find(f => f.type === 'AutoMem')
332 if (auto) return auto.path.slice(0, auto.path.lastIndexOf('/'))
333 const base = await $.process.run(['sh', '-c', 'printf %s "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'])
334
335 return `${base.stdout}/projects/${projectSlug(await $.session.root())}/memory`
336}
337
338// The "Style" tab: output styles are Markdown files in <config>/output-styles/; the active one is
339// "outputStyle" in <config>/settings.json. A change reaches the model at the next session start.
340const configDir = async ($: Engine) => (await $.process.run(['sh', '-c', 'printf %s "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"'])).stdout
341
342const styleNotice = (ok: boolean, text: string) => (ok ? `✓ ${text}` : `✗ ${text}`)
343
344const loadStyles = async ($: Engine) => {
345 try {
346 const base = await configDir($)
347 const entries = (await $.fs.list(`${base}/output-styles`).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.md'))
348 const list: Style[] = []
349 for (const f of entries) list.push(parseStyle(f.name, await $.fs.read(`${base}/output-styles/${f.name}`)))
350 const active = activeStyle(await $.fs.read(`${base}/settings.json`).catch(() => ''))
351 await update($, styles, () => list.sort((a, b) => a.name.localeCompare(b.name)))
352 await update($, styleActive, () => active)
353 } catch (err) {
354 await update($, styleNote, () => styleNotice(false, `could not read the styles: ${(err as Error).message}`))
355 }
356 await update($, styleLoaded, () => true)
357}
358
359const openStyle = async ($: Engine, file: string) => {
360 const s = (await read($, styles)).find(x => x.file === file)
361 if (!s) return
362 await update($, styleNote, () => '')
363 await update($, styleDraft, () => ({ file, description: s.description, lines: s.body.split('\n'), dirty: false }))
364}
365
366const editStyle = ($: Engine, fn: (d: StyleDraft) => StyleDraft) => void update($, styleDraft, d => (d ? { ...fn(d), dirty: true } : d))
367
368const saveStyle = async ($: Engine) => {
369 const d = await read($, styleDraft)
370 const s = d && (await read($, styles)).find(x => x.file === d.file)
371 if (!d || !s) return
372 try {
373 await $.fs.write(`${await configDir($)}/output-styles/${s.file}`, buildStyle({ ...s, description: d.description, body: d.lines.join('\n') }))
374 await loadStyles($)
375 await update($, styleDraft, cur => (cur ? { ...cur, dirty: false } : cur))
376 await update($, styleNote, () => styleNotice(true, 'saved'))
377 } catch (err) {
378 await update($, styleNote, () => styleNotice(false, `not saved: ${(err as Error).message}`))
379 }
380}
381
382const useStyle = async ($: Engine, file: string) => {
383 const s = (await read($, styles)).find(x => x.file === file)
384 if (!s) return
385 try {
386 const path = `${await configDir($)}/settings.json`
387 // Only a missing settings.json starts empty; a file that exists but cannot be read must not be overwritten.
388 await $.fs.write(path, withOutputStyle((await $.fs.exists(path)) ? await $.fs.read(path) : '', s.name))
389 await update($, styleActive, () => s.name)
390 await update($, styleNote, () => styleNotice(true, `${s.name} is the output style from the next session`))
391 } catch (err) {
392 await update($, styleNote, () => styleNotice(false, `settings.json not changed: ${(err as Error).message}`))
393 }
394}
395
396// A new style starts as a copy of the active one (or the first), so it is never an empty page.
397const createStyle = async ($: Engine, name: string) => {
398 const slug = slugName(name)
399 const list = await read($, styles)
400 if (!slug) return void (await update($, styleNote, () => styleNotice(false, 'give the style a name (letters or digits)')))
401 if (list.some(s => s.file === `${slug}.md`)) return void (await update($, styleNote, () => styleNotice(false, `${slug}.md already exists`)))
402 const active = await read($, styleActive)
403 const from = list.find(s => s.name === active) ?? list[0]
404 try {
405 await $.fs.write(`${await configDir($)}/output-styles/${slug}.md`, buildStyle({ file: `${slug}.md`, name: slug, description: from?.description ?? '', keep: from?.keep ?? true, body: from?.body ?? '' }))
406 await update($, styleFresh, () => '')
407 await loadStyles($)
408 await openStyle($, `${slug}.md`)
409 } catch (err) {
410 await update($, styleNote, () => styleNotice(false, `not created: ${(err as Error).message}`))
411 }
412}
413
414const loadMemories = async ($: Engine) => {
415 try {
416 const usage = await $.session.usage({ breakdown: 'summary', columns: 60 })
417 const context = (usage.context.breakdown?.memoryFiles ?? []).map(f => ({ path: f.path, type: f.type, tokens: f.tokens }))
418 const dir = await memoryDir($, context)
419 const entries = (await $.fs.list(dir).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.md'))
420 const index = entries.some(f => f.name === INDEX) ? parseIndex(await $.fs.read(`${dir}/${INDEX}`)) : {}
421 const list: Memory[] = []
422 for (const f of entries.filter(f => f.name !== INDEX)) {
423 const m = parseMemory(f.name, await $.fs.read(`${dir}/${f.name}`))
424 list.push({ name: m.name, file: f.name, type: m.type, description: m.description, links: m.links, indexed: f.name in index, hook: index[f.name]?.hook ?? '', size: f.size, mtimeMs: f.mtimeMs })
425 }
426 // The remember plugin keeps its log in the project (.remember/); the days are folded into a single row.
427 const root = await $.session.root()
428 const rem = (await $.fs.list(`${root}/.remember`).catch(() => [])).filter(f => f.kind === 'file' && f.name.endsWith('.md'))
429 const days = rem.filter(f => f.name.startsWith('today-'))
430 const remember = [
431 ...rem.filter(f => !f.name.startsWith('today-')).map(f => ({ name: f.name, size: f.size })),
432 ...(days.length ? [{ name: `today-*.md ×${days.length}`, size: days.reduce((n, f) => n + f.size, 0) }] : []),
433 ]
434 await update($, memories, () => list)
435 await update($, memMeta, meta => ({ ...meta, dir, loaded: true, context, remember }))
436 } catch {
437 await update($, memMeta, meta => ({ ...meta, loaded: true }))
438 }
439}
440
441const bump = ($: Engine, names: string[], key: keyof MemoryUse) =>
442 update($, memUse, use => {
443 const next = { ...use }
444 for (const n of names) next[n] = { ...(next[n] ?? { read: 0, write: 0, recalled: 0 }), [key]: (next[n]?.[key] ?? 0) + 1 }
445
446 return next
447 })
448
449const openMemory = async ($: Engine, name: string) => {
450 const { dir } = await read($, memMeta)
451 const m = (await read($, memories)).find(x => x.name === name)
452 if (!m) return
453 const text = await $.fs.read(`${dir}/${m.file}`).catch(() => '')
454 await update($, memView, () => ({ name, body: clip(parseMemory(m.file, text).body, 6000) }))
455}
456
457// Per tool, with a matcher: the mod already has a matcher-less `tool.call`, and a second one is rejected.
458const trackMemory: HookFor<'tool.call'> = async ($, e, next) => {
459 const { dir } = await read($, memMeta)
460 if (!dir) return next(e)
461 const input = e as { file_path?: string; command?: string }
462 const list = await read($, memories)
463 const path = input.file_path ?? ''
464 const isWrite = /^(Write|Edit)$/.test(String(e.tool))
465 if (path.startsWith(`${dir}/`)) {
466 const file = path.slice(dir.length + 1)
467 const name = file === INDEX ? INDEX : (list.find(m => m.file === file)?.name ?? file.replace(/\.md$/, ''))
468 await bump($, [name], isWrite ? 'write' : 'read')
469 } else if (String(e.tool) === 'Bash' && input.command?.includes(dir)) {
470 await bump($, namesIn(input.command, list), 'read')
471 }
472 const res = await next(e)
473 if (isWrite && path.startsWith(`${dir}/`)) void loadMemories($)
474
475 return res
476}
477
478export const register: Register = on => {
479 // Memory usage: reads and writes through tools (including subagents'), recalls through attachments.
480 on('tool.call', { tool: 'Read' }, trackMemory)
481 on('tool.call', { tool: 'Write' }, trackMemory)
482 on('tool.call', { tool: 'Edit' }, trackMemory)
483 on('tool.call', { tool: 'Bash' }, trackMemory)
484
485 on('prompt.attachment', async ($, e, next) => {
486 const res = await next(e)
487 const text = (e as { text?: string }).text ?? ''
488 if (/memor/i.test(e.type) && text) {
489 const names = namesIn(text, await read($, memories))
490 await update($, memMeta, meta => ({ ...meta, recalls: [...meta.recalls, { type: e.type, names }].slice(-20) }))
491 if (names.length) await bump($, names, 'recalled')
492 }
493
494 return res
495 })
496
497 on('session.start', async ($, e, next) => {
498 await $.command.register({ name: 'lens', description: 'Open the Agent Lens sidebar' })
499 await $.agent.register({
500 name: 'validator',
501 description: 'Final Opus validation of a turn with changes (started only by Agent Lens).',
502 prompt: VALIDATOR_PROMPT,
503 tools: ['Read', 'Bash'],
504 model: 'opus',
505 })
506 await $.tool.register({ name: PLAN_TOOL, description: PLAN_DESCRIPTION, inputSchema: PLAN_SCHEMA, isDeferred: false })
507 void refreshChanges($)
508 void loadMemories($)
509 void loadStyles($)
510 const saved = await $.store.get(await missKey($))
511 await update($, misses, () => (Array.isArray(saved) ? (saved as Miss[]) : []))
512 await update($, hasGraph, () => false)
513 if (await $.fs.exists('.codegraph')) await update($, hasGraph, () => true)
514 void $.ui.open({ id: PANE, title: 'Agent Lens' })
515
516 return next(e)
517 })
518
519 // Only the mod starts the validator, not the main model.
520 on('agent.offer', { agent: 'agent-lens:validator' }, () => ({ isOffered: false }))
521
522 on('command.run', { command: 'lens' }, async $ => {
523 await $.ui.open({ id: PANE, title: 'Agent Lens', focus: true })
524
525 return { text: 'Agent Lens opened.' }
526 })
527
528 // A prompt sent by a human counts as "requested": the panel settles at any width,
529 // and if it was closed by hand, it comes back. On an already open id it's a no-op.
530 on('prompt.submit', async ($, e, next) => {
531 void $.ui.open({ id: PANE, title: 'Agent Lens' })
532 const tag = tagged(e.text)
533 const now = await $.clock.now()
534 // Every new turn (yours, a notification, hook feedback) starts on Opus; only Opus delegates down.
535 await update($, turnTier, () => (tag.tier ? { tier: tag.tier, why: 'picked by you' } : { tier: 'opus', why: 'default' }))
536 if (!e.origin || e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
537 await update($, graphSeen, () => false)
538 await update($, planAsked, () => false)
539 await update($, turnAt, () => now)
540 await update($, plan, () => null)
541 await update($, answers, () => ({}))
542 await update($, folds, () => ({}))
543 await update($, retro, () => null)
544 }
545 await update($, busy, () => true)
546 await update($, prompt, () => tag.text)
547 await update($, edited, () => [])
548
549 return next(tag.tier ? { ...e, text: tag.text } : e)
550 })
551
552 // The mod's rules go into the system prompt: downward delegation when routing is on,
553 // CodeGraph before Grep/Read when the project has an index.
554 on('prompt.compose', async ($, e, next) => {
555 const res = await next(e)
556 const extra = []
557 if (await read($, routing)) {
558 extra.push({
559 id: 'agent-lens:delegate',
560 scope: 'session' as const,
561 text: `Whenever you can, delegate to a lower model through Agent with the model parameter: haiku for searches, inventories and code reading; sonnet for mechanical changes with a clear spec (renames, translations, tests following a pattern, precisely described point fixes). Keep on the current model only understanding the problem, design decisions and verifying the result. Don't delegate what you would solve in one or two tool calls. If a subagent replies "${ESCALATE} …", don't retry the task on the same model: take it over yourself or redelegate one tier up.`,
562 })
563 }
564 if (await read($, planFirst)) extra.push({ id: 'agent-lens:plan', scope: 'session' as const, text: PLAN_RULE })
565 if ((await read($, hasGraph)) && (await read($, graphFirst))) {
566 extra.push({
567 id: 'agent-lens:codegraph',
568 scope: 'session' as const,
569 text: 'The project has a CodeGraph index (.codegraph/). For any question about code and before any change, call codegraph_explore first (verbatim source, callers, blast radius); use Grep, Glob and Read only for what it does not cover. Tell subagents to use it too.',
570 })
571 }
572 extra.push({
573 id: 'agent-lens:language',
574 scope: 'session' as const,
575 text: "Reply to the user in the language of their latest prompt (Romanian prompt → Romanian reply, English prompt → English reply), including the fields you pass to the plan tool. Keep code, identifiers and commands as they are.",
576 })
577
578 return extra.length ? { sections: [...res.sections, ...extra] } : res
579 })
580
581 on('skill.prompt', async ($, e, next) => {
582 await update($, skillsUsed, used => ({ ...used, [e.skill]: (used[e.skill] ?? 0) + 1 }))
583
584 return next(e)
585 })
586
587 // The model's plan lands in the Flow tab; a republish keeps its place and bumps the revision.
588 // The literal (not the constant) so the validator sees the matcher; `as never` until the MCP types generated on load include the tool.
589 on('tool.call', { tool: 'mcp__agent-lens__plan' as never }, async ($, e) => {
590 const fresh = normalizePlan(e, await $.clock.now())
591 await update($, plan, old => ({ ...fresh, revision: old ? old.revision + 1 : 0 }))
592
593 return { result: `Plan shown in Agent Lens · Flow (${fresh.steps.length} steps).` }
594 })
595
596 on('tool.call', async ($, e, next) => {
597 const name = toolLabel(String(e.tool))
598 await update($, toolCounts, counts => ({ ...counts, [name]: (counts[name] ?? 0) + 1 }))
599 if (name.startsWith('codegraph:')) await update($, graphSeen, () => true)
600 // The miniplan comes before any tool: the first tool of a turn without a published plan is denied once.
601 if (!e.agentId && String(e.tool) !== PLAN_TOOL_ID && (await read($, planFirst)) && !(await read($, plan)) && !(await read($, planAsked))) {
602 await update($, planAsked, () => true)
603
604 return { deny: `Publish the miniplan with ${PLAN_TOOL_ID} first: what you understood (goal) and how you will act (approach), with the steps. Then repeat the tool.` }
605 }
606 // The turn's first classic search, without CodeGraph before it, is denied once; the second goes through.
607 if (!e.agentId && /^(Grep|Glob)$/.test(String(e.tool)) && (await read($, hasGraph)) && (await read($, graphFirst)) && !(await read($, graphSeen))) {
608 await update($, graphSeen, () => true)
609
610 return { deny: 'The project has a CodeGraph index: start with codegraph_explore (verbatim source and callers in a single call). If it does not cover what you are looking for, repeat Grep/Glob.' }
611 }
612 const path = (e as { file_path?: string; notebook_path?: string }).file_path ?? (e as { notebook_path?: string }).notebook_path
613 if (EDIT_TOOLS.has(String(e.tool)) && path) {
614 const running = await read($, validations)
615 const isValidator = running.some(v => v.agentId === e.agentId)
616 if (!isValidator) await update($, edited, files => (files.includes(path) ? files : [...files, path]))
617 // A FAIL verdict on a file edited afterwards is stale, even if the turn (on Opus) doesn't revalidate.
618 if (!isValidator) {
619 await update($, validations, list =>
620 list.map(v => (v.status === 'fail' && v.files.includes(path) ? { ...v, status: 'stale' as const } : v)),
621 )
622 }
623 }
624 if (e.agentId) {
625 await update($, workers, list =>
626 list.map(w => (w.agentId === e.agentId ? { ...w, tools: w.tools + 1 } : w)),
627 )
628 }
629
630 const res = await next(e)
631 if (e.agentId || res.deny !== undefined || res.isError) return res
632 const text = await takeAnswers($)
633
634 return text ? { ...res, context: [...(res.context ?? []), text] } : res
635 })
636
637 // The main loop runs on the model chosen for this turn (Opus by default); subagents get their model decided at spawn.
638 on('turn.step', async function* ($, e, next) {
639 if (!e.agentId && (await read($, routing))) {
640 const want = MODEL_IDS[(await read($, turnTier)).tier]
641 if (e.model !== want) e = { ...e, model: want }
642 }
643 const id = `${e.turnId}:${e.agentId ?? 'main'}:${e.index}`
644 const step: Step = {
645 id,
646 turnId: e.turnId,
647 index: e.index,
648 model: e.model,
649 effort: e.effort === undefined ? undefined : String(e.effort),
650 agentId: e.agentId,
651 isDone: false,
652 tools: [],
653 tokensIn: 0,
654 tokensOut: 0,
655 cacheRead: 0,
656 stop: null,
657 answer: '',
658 uses: [],
659 }
660 await update($, steps, list => [...list, step].slice(-60))
661
662 const res = yield* next(e)
663
664 const usage = res.usage
665 const cost = usage ? costOf(tierOf(usage.model ?? e.model) ?? 'opus', usage) : 0
666 const opusCost = usage ? costOf('opus', usage) : 0
667 await update($, steps, list =>
668 list.map(s =>
669 s.id === id
670 ? {
671 ...s,
672 isDone: true,
673 model: usage?.model ?? s.model,
674 tools: res.toolUses.map(u => toolLabel(u.name)),
675 tokensIn: usage ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens : 0,
676 tokensOut: usage?.output_tokens ?? 0,
677 cacheRead: usage?.cache_read_input_tokens ?? 0,
678 cost,
679 opusCost,
680 stop: res.stopReason,
681 answer: clip(res.answer, 4000),
682 uses: res.toolUses.map(u => ({ tool: u.name, brief: brief(u.input) })),
683 }
684 : s,
685 ),
686 )
687
688 if (usage) {
689 await update($, totals, t => ({
690 steps: t.steps + 1,
691 tokensIn: t.tokensIn + usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
692 tokensOut: t.tokensOut + usage.output_tokens,
693 cacheRead: t.cacheRead + usage.cache_read_input_tokens,
694 cost: (t.cost ?? 0) + cost,
695 opusCost: (t.opusCost ?? 0) + opusCost,
696 }))
697 }
698
699 return res
700 })
701
702 on('agent.spawn', async ($, e, next) => {
703 const id = e.tool_use_id || `${e.subagentType}:${await $.clock.now()}`
704 const isRoutable = !e.fork && !e.workflow && e.subagentType !== VALIDATOR && (await read($, routing))
705 const task = `${e.description} ${e.prompt.slice(0, 200)}`
706 const asked = clip(e.prompt, 1500)
707 // Opus picks the subagent's model; without one, the subagent inherits Opus.
708 const picked = isRoutable && e.model ? { model: e.model, why: 'requested' } : undefined
709 // What already failed on this model goes up a tier, even if the main model asked for another.
710 const route = picked && (learned(task, tierOf(picked.model) ?? 'opus', await read($, misses)) ?? picked)
711 const routeTier = route && tierOf(route.model)
712 if (route) e = { ...e, model: route.model, prompt: routeTier && routeTier !== 'opus' ? e.prompt + ESCALATE_RULE : e.prompt }
713 const worker: Worker = {
714 id,
715 type: e.subagentType,
716 description: e.description,
717 asked: e.model,
718 route: route ? `${route.model}: ${route.why}` : e.subagentType === VALIDATOR ? 'opus: final validation' : undefined,
719 model: e.model ?? e.parentModel,
720 parentModel: e.parentModel,
721 isBackground: Boolean(e.background),
722 status: 'running',
723 startedAt: await $.clock.now(),
724 tools: 0,
725 words: wordsOf(task),
726 prompt: asked,
727 }
728 await update($, workers, list => [...list, worker].slice(-30))
729
730 const res = await next(e)
731
732 await update($, workers, list =>
733 list.map(w =>
734 w.id === id
735 ? res.deny !== undefined
736 ? { ...w, status: 'denied' as const }
737 : { ...w, agentId: res.agentId, model: res.model }
738 : w,
739 ),
740 )
741
742 return res
743 })
744
745 on('turn.complete', async ($, e, next) => {
746 if (!e.agentId) {
747 void refreshChanges($)
748 await runRetro($, e.turnId)
749 await update($, busy, () => false)
750 await submitAnswers($)
751 }
752 const pending = (await read($, validations)).find(v => v.agentId === e.agentId)
753 if (pending) {
754 const verdict = verdictOf(e.answer)
755 await update($, validations, list =>
756 list.map(v => (v.agentId === pending.agentId ? { ...v, status: verdict.isOk ? ('ok' as const) : ('fail' as const), summary: verdict.line } : v)),
757 )
758 $.ui.toast(`Opus validation: ${verdict.line}`)
759 if (!verdict.isOk) await recordMiss($, pending.words, pending.tier)
760 // The line enters the conversation, so the model's next turn reads it.
761 await $.session.append({
762 message: { type: 'user', content: [{ type: 'text', text: `[Agent Lens · final Opus validation on ${pending.files.join(', ')}]\n${e.answer}` }] },
763 })
764 }
765
766 const files = await read($, edited)
767 const cheaper = (await read($, turnTier)).tier !== 'opus'
768 if (!e.agentId && e.reason === 'answer' && files.length > 0 && cheaper && (await read($, routing))) {
769 await update($, edited, () => [])
770 const request = await read($, prompt)
771 const failedTier = (await read($, turnTier)).tier
772 const spawned = await $.agent.spawn({
773 subagentType: VALIDATOR,
774 description: 'Final Opus validation',
775 prompt: `User request:\n${request}\n\nModified files:\n${files.join('\n')}`,
776 })
777 if (spawned.agentId) {
778 const validation: Validation = { agentId: spawned.agentId, files, status: 'running', summary: '', words: wordsOf(request), tier: failedTier }
779 await update($, validations, list => [...list, validation].slice(-10))
780 }
781 }
782
783 if (e.agentId) {
784 const escalated = escalationOf(e.answer ?? '')
785 const worker = (await read($, workers)).find(w => w.agentId === e.agentId)
786 if (escalated && worker) {
787 $.ui.toast(`${worker.type} on ${short(worker.model)} asks for a higher model: ${escalated}`)
788 await recordMiss($, worker.words, tierOf(worker.model))
789 }
790 await update($, workers, list =>
791 list.map(w =>
792 w.agentId === e.agentId
793 ? {
794 ...w,
795 escalated,
796 answer: e.answer ? clip(e.answer, 1500) : undefined,
797 status: e.isAborted ? ('aborted' as const) : ('done' as const),
798 durationMs: e.durationMs,
799 tokensOut: e.usage?.output_tokens,
800 }
801 : w,
802 ),
803 )
804 }
805
806 return next(e)
807 })
808
809 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
810 const { Box, Text, Button, Input, Code } = $.ui.resolve(e)
811 const isRouting = await read($, routing)
812 const list = await read($, changes)
813 const repoNow = await read($, repo)
814 const message = await read($, commitMsg)
815 const note = await read($, gitNote)
816 const diffNow = await read($, diff)
817 // Steps from before a reload have no `answer`/`uses`: their absence must not block the panel.
818 const opened = await read($, stepView)
819 const stepNow = opened && { ...opened, answer: opened.answer ?? '', uses: opened.uses ?? [] }
820 const isDrafting = await read($, drafting)
821 const planNow = await read($, plan)
822 const answered = await read($, answers)
823 const isSyncing = await read($, syncing)
824 const tierNow = await read($, turnTier)
825 const graphReady = await read($, hasGraph)
826 const isGraphFirst = await read($, graphFirst)
827 const lastCheck = (await read($, validations)).at(-1)
828 const allSteps = await read($, steps)
829 const allWorkers = await read($, workers)
830 const used = await read($, skillsUsed)
831 const counts = await read($, toolCounts)
832 const lastPrompt = await read($, prompt)
833 // A tab saved by an older version (Steps, Skills) falls back to Flow.
834 const stored = String(await read($, tab))
835 const current: Tab = ['think', 'changes', 'workers', 'memories', 'styles'].includes(stored) ? (stored as Tab) : 'think'
836 const foldNow = await read($, folds)
837 const retroNow = await read($, retro)
838 const sessionModel = await $.session.model()
839 const sum = await read($, totals)
840 const learnedNow = await read($, misses)
841 const isPlanFirst = await read($, planFirst)
842 const since = await read($, turnAt)
843 const allChecks = await read($, validations)
844 const { context, rateLimits } = await $.session.usage()
845 const now = await $.clock.now()
846 const room = Math.max(6, (e.viewport?.rows ?? 30) - 12)
847
848 const mainSteps = allSteps.filter(s => !s.agentId)
849 const last = mainSteps.at(-1)
850 const turnId = last?.turnId
851 const turnSteps = allSteps.filter(s => s.turnId === turnId || (s.agentId && allWorkers.some(w => w.agentId === s.agentId && w.status === 'running')))
852 const tokensIn = turnSteps.reduce((n, s) => n + s.tokensIn, 0)
853 const tokensOut = turnSteps.reduce((n, s) => n + s.tokensOut, 0)
854 const cacheRead = turnSteps.reduce((n, s) => n + s.cacheRead, 0)
855 const turnCost = turnSteps.reduce((n, s) => n + (s.cost ?? 0), 0)
856 const turnOpus = turnSteps.reduce((n, s) => n + (s.opusCost ?? 0), 0)
857 const saved = sum.opusCost ? Math.round((1 - (sum.cost ?? 0) / sum.opusCost) * 100) : 0
858 const running = allWorkers.filter(w => w.status === 'running').length
859 const workerType = (agentId: string) => allWorkers.find(w => w.agentId === agentId)?.type ?? 'worker'
860
861 const header = (
862 <Box flexDirection="column" borderStyle="round" borderColor={C.accent} paddingX={1}>
863 <Box justifyContent="space-between">
864 <Text bold color={C.accent}>◆ AGENT LENS</Text>
865 <Text color={running ? C.run : C.ok}>{running ? `● ${running} worker${running > 1 ? 's' : ''}` : '○ idle'}</Text>
866 </Box>
867 <Text>
868 <Text dimColor>model </Text>
869 <Text bold color={C.model}>{short(last?.model ?? String(sessionModel ?? '—'))}</Text>
870 {last?.effort && <Text dimColor> · effort </Text>}
871 {last?.effort && <Text color={C.info}>{last.effort}</Text>}
872 </Text>
873 <Box justifyContent="space-between">
874 <Text wrap="truncate-end">
875 <Text dimColor>routing </Text>
876 {isRouting ? (
877 <Text>
878 <Text bold color={TIER_COLOR[tierNow.tier]}>{tierNow.tier}</Text>
879 <Text dimColor> ({tierNow.why}) · [haiku]/[sonnet] in the prompt</Text>
880 </Text>
881 ) : (
882 <Text color={C.bad}>off: everything on the session model</Text>
883 )}
884 </Text>
885 <Button key="routing" plain label={isRouting ? '[off]' : '[on]'} hotkey="r" onPress={() => update($, routing, v => !v)} />
886 </Box>
887 {graphReady && (
888 <Box justifyContent="space-between">
889 <Text wrap="truncate-end">
890 <Text dimColor>codegraph </Text>
891 {isGraphFirst ? <Text color={C.graph}>first, before Grep/Glob</Text> : <Text dimColor>free</Text>}
892 </Text>
893 <Button key="graph" plain label={isGraphFirst ? '[off]' : '[on]'} hotkey="g" onPress={() => update($, graphFirst, v => !v)} />
894 </Box>
895 )}
896 <Box justifyContent="space-between">
897 <Text wrap="truncate-end">
898 <Text dimColor>miniplan </Text>
899 {isPlanFirst ? <Text color={C.accent}>first in Flow: what I understand, how I act</Text> : <Text dimColor>off</Text>}
900 </Text>
901 <Button key="planfirst" plain label={isPlanFirst ? '[off]' : '[on]'} hotkey="n" onPress={() => update($, planFirst, v => !v)} />
902 </Box>
903 {lastCheck && (
904 <Text wrap="truncate-end" dimColor={lastCheck.status === 'stale'} color={lastCheck.status === 'ok' ? C.ok : lastCheck.status === 'fail' ? C.bad : lastCheck.status === 'running' ? C.run : undefined}>
905 {lastCheck.status === 'running'
906 ? '● opus validation running…'
907 : lastCheck.status === 'stale'
908 ? '↻ stale verdict: files changed afterwards'
909 : `${lastCheck.status === 'ok' ? '✓' : '✗'} ${lastCheck.summary}`}
910 </Text>
911 )}
912 <Text dimColor>
913 turn: {turnSteps.length} steps · {k(tokensIn)}↑ {k(tokensOut)}↓ · cache {tokensIn ? Math.round((cacheRead / tokensIn) * 100) : 0}%
914 </Text>
915 {/* API list prices: on a subscription this measures savings, not the bill. */}
916 <Text wrap="truncate-end">
917 <Text dimColor>turn cost </Text>
918 <Text>{usd(turnCost)}</Text>
919 <Text dimColor> · session </Text>
920 <Text>{usd(sum.cost ?? 0)}</Text>
921 <Text dimColor> / all on opus {usd(sum.opusCost ?? 0)} </Text>
922 <Text color={saved > 0 ? C.ok : undefined}>({saved > 0 ? `−${saved}` : '0'}%)</Text>
923 </Text>
924 {learnedNow.length > 0 && (
925 <Box justifyContent="space-between">
926 <Text wrap="truncate-end">
927 <Text dimColor>learned </Text>
928 <Text color={C.run}>{learnedNow.length} failures</Text>
929 <Text dimColor> on cheap models · similar tasks go up a tier</Text>
930 </Text>
931 <Button key="forget" plain label="[forget]" onPress={async () => { await update($, misses, () => []); await $.store.delete(await missKey($)) }} />
932 </Box>
933 )}
934 {/* From /usage: the account's context and 5h / 7-day limits. */}
935 <Box gap={2}>
936 <Text wrap="truncate-end">
937 <Text dimColor>context </Text>
938 <Text color={pctColor(context.percent ?? 0, 85)}>{context.percent ?? '—'}%</Text>
939 <Text dimColor> {k(context.tokens ?? 0)}/{k(context.window)}</Text>
940 </Text>
941 {rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day').map(r => (
942 <Text key={`rl-${r.kind}`} wrap="truncate-end">
943 <Text dimColor>{WINDOWS[r.kind]} </Text>
944 <Text color={pctColor(r.percentUsed, 90)}>{r.percentUsed}%</Text>
945 {r.resetsAt && <Text dimColor> · {until(Date.parse(r.resetsAt) - now)}</Text>}
946 </Text>
947 ))}
948 </Box>
949 </Box>
950 )
951
952 const tabs = (
953 <Box gap={1}>
954 {(
955 [
956 ['think', 'Flow', 'f'],
957 ['changes', `Git ${list.length}`, 'c'],
958 ['workers', `Workers ${allWorkers.length}`, 'w'],
959 ['memories', 'Memories', 'e'],
960 ['styles', 'Style', 'y'],
961 ] as [Tab, string, string][]
962 ).map(([id, label, hotkey]) => (
963 <Button
964 key={`tab-${id}`}
965 label={label}
966 hotkey={hotkey}
967 variant={current === id ? 'primary' : 'secondary'}
968 onPress={() => {
969 void update($, tab, () => id)
970 if (id === 'changes') void refreshChanges($)
971 if (id === 'memories') void loadMemories($)
972 if (id === 'styles') void loadStyles($)
973 }}
974 />
975 ))}
976 </Box>
977 )
978
979 const toolIcon = (name: string) =>
980 name.startsWith('codegraph:') ? '◈' : /^(Read|Grep|Glob)$/.test(name) ? '⌕' : EDIT_TOOLS.has(name) ? '✎' : name === 'Bash' ? '$' : /^(Agent|Task)$/.test(name) ? '⤷' : name === 'Skill' ? '✦' : '•'
981 const toolColor = (name: string) => (name.startsWith('codegraph:') ? C.graph : EDIT_TOOLS.has(name) ? C.run : name === 'Skill' ? C.info : undefined)
982 const chips = (tools: string[]) => {
983 const counts = new Map<string, number>()
984 for (const name of tools) counts.set(name, (counts.get(name) ?? 0) + 1)
985
986 return [...counts].map(([name, n]) => (
987 <Text key={name} color={toolColor(name)} bold={name.startsWith('codegraph:')}>
988 {toolIcon(name)} {name.replace(/^codegraph:(codegraph_)?/, 'graph:')}
989 {n > 1 ? `×${n}` : ''}{' '}
990 </Text>
991 ))
992 }
993
994 const turnTools = turnSteps.flatMap(s => s.tools)
995 const graphN = turnTools.filter(t => t.startsWith('codegraph:')).length
996 const classicN = turnTools.filter(t => /^(Read|Grep|Glob)$/.test(t)).length
997 const graphCells = graphN + classicN === 0 ? 0 : Math.round((graphN / (graphN + classicN)) * 10)
998 const maxIn = Math.max(1, ...turnSteps.map(s => s.tokensIn))
999 const turnWorkers = allWorkers.filter(w => w.startedAt >= since)
1000
1001 // Everything foldable: sections, plan steps, execution steps. A missing key = the default state.
1002 const isOpen = (key: string, byDefault: boolean) => foldNow[key] ?? byDefault
1003 const toggle = (key: string, open: boolean) => void update($, folds, f => ({ ...f, [key]: open }))
1004 const section = (key: string, title: string, byDefault: boolean, body: () => RenderChildren, aside?: RenderChildren) => {
1005 const open = isOpen(key, byDefault)
1006
1007 return (
1008 <Box key={`sec-${key}`} flexDirection="column">
1009 <Box justifyContent="space-between">
1010 <Button key={`fold-${key}`} plain label={`${open ? '▾' : '▸'} ${title}`} onPress={() => toggle(key, !open)} />
1011 {aside}
1012 </Box>
1013 {open && body()}
1014 </Box>
1015 )
1016 }
1017
1018 // The Flow tab: how the model understood the request, the plan by level (steps on the same level
1019 // run in parallel: ┬ ├ └) and what was actually executed, step by step, each with its model.
1020 const TIER_ICON = { haiku: '⌕', sonnet: '✎', opus: '◆' } as const
1021 const TIER_JOB = { haiku: 'searches, reads, inventories', sonnet: 'mechanical changes with a clear spec', opus: 'me: understanding, decisions, verification' } as const
1022 const STATUS_MARK = { todo: ['○', undefined], run: ['●', C.run], done: ['✓', C.ok], skip: ['⊘', undefined] } as const
1023 const WORKER_COLOR = { running: C.run, done: C.ok, aborted: C.bad, denied: C.bad } as const
1024 const planLevels = planNow ? layers(planNow.steps) : []
1025 const mix = planNow ? (['haiku', 'sonnet', 'opus'] as const).map(t => [t, planNow.steps.filter(s => s.tier === t).length] as const) : []
1026 const doneN = planNow?.steps.filter(s => s.status === 'done' || s.status === 'skip').length ?? 0
1027 const clarityColor = (n: number) => (n >= 8 ? C.ok : n >= 5 ? C.run : C.bad)
1028 const skillList = Object.entries(used).sort((a, b) => b[1] - a[1])
1029 const pad = ' '
1030
1031 const planStep = (s: PlanStep, level: PlanStep[], li: number, si: number) => {
1032 const [mark, markColor] = STATUS_MARK[s.status]
1033 const branch = level.length === 1 ? '──' : si === 0 ? '┬─' : si === level.length - 1 ? '└─' : '├─'
1034 const key = `p-${s.id}`
1035 const open = isOpen(key, s.status === 'run')
1036 const deps = s.after.map(id => planNow?.steps.find(x => x.id === id)?.title ?? id)
1037 const peers = level.filter(x => x.id !== s.id).map(x => x.title)
1038 const onTier = turnWorkers.filter(w => (tierOf(w.model) ?? 'opus') === s.tier)
1039
1040 return (
1041 <Box key={`ps-${s.id}`} flexDirection="column">
1042 <Box>
1043 <Text dimColor>{si === 0 ? `${String(li + 1).padStart(2)} ` : ' '}{branch} </Text>
1044 <Button key={`fold-${key}`} plain label={open ? '▾' : '▸'} onPress={() => toggle(key, !open)} />
1045 <Text wrap="truncate-end" dimColor={s.status === 'skip'}>
1046 <Text color={markColor}> {mark} </Text>
1047 <Text color={TIER_COLOR[s.tier]} bold={s.tier === 'opus'}>{TIER_ICON[s.tier]} {s.tier} </Text>
1048 <Text strikethrough={s.status === 'skip'}>{s.title}</Text>
1049 </Text>
1050 </Box>
1051 {open && (
1052 <Box flexDirection="column">
1053 <Text wrap="wrap">
1054 <Text dimColor>{pad}model </Text>
1055 <Text bold color={TIER_COLOR[s.tier]}>{s.tier}</Text>
1056 <Text dimColor> · {TIER_JOB[s.tier]}</Text>
1057 </Text>
1058 {deps.length > 0 && <Text wrap="wrap" dimColor>{pad}after: {deps.join(', ')}</Text>}
1059 {peers.length > 0 && <Text wrap="wrap" dimColor>{pad}in parallel with: {peers.join(', ')}</Text>}
1060 {s.tier !== 'opus' && onTier.length === 0 && <Text dimColor>{pad}no {s.tier} subagent started yet</Text>}
1061 {s.tier !== 'opus' &&
1062 onTier.map(w => (
1063 <Text key={`pw-${s.id}-${w.id}`} wrap="truncate-end">
1064 <Text dimColor>{pad}⤷ </Text>
1065 <Text color={WORKER_COLOR[w.status]}>{w.type}</Text>
1066 <Text dimColor> {w.description}</Text>
1067 </Text>
1068 ))}
1069 </Box>
1070 )}
1071 </Box>
1072 )
1073 }
1074
1075 const execStep = (s: Step, i: number, all: Step[]) => {
1076 const isLast = i === all.length - 1
1077 const branch = s.agentId ? '│ ⤷' : isLast ? '└─' : '├─'
1078 const gutter = s.agentId || !isLast ? '│ ' : ' '
1079 const cells = Math.max(1, Math.round((s.tokensIn / maxIn) * 8))
1080 const key = `x-${s.id}`
1081 const open = isOpen(key, !s.isDone)
1082 const uses = s.uses ?? []
1083 const said = (s.answer ?? '').replace(/\s+/g, ' ').trim()
1084
1085 return (
1086 <Box key={s.id} flexDirection="column">
1087 <Box>
1088 <Text dimColor>{branch} </Text>
1089 <Button key={`fold-${key}`} plain label={open ? '▾' : '▸'} onPress={() => toggle(key, !open)} />
1090 <Text>
1091 {' '}
1092 {s.agentId && <Text color={C.run}>{workerType(s.agentId)} </Text>}
1093 <Text bold inverse color={modelColor(s.model)}> {pill(s.model)} </Text>
1094 <Text bold color={modelColor(s.model)}> {short(s.model)}</Text>
1095 {s.effort && <Text color={C.info}> {s.effort}</Text>}
1096 <Text dimColor> #{s.index} </Text>
1097 </Text>
1098 <Text wrap="truncate-end">
1099 {s.isDone ? (
1100 <Text>
1101 <Text color={modelColor(s.model)}>{'▮'.repeat(cells)}</Text>
1102 <Text dimColor>{'▯'.repeat(8 - cells)} {k(s.tokensIn)}↑ {k(s.tokensOut)}↓</Text>
1103 </Text>
1104 ) : (
1105 <Text color={C.run}>● running…</Text>
1106 )}
1107 </Text>
1108 </Box>
1109 {s.isDone && !open && (
1110 <Text wrap="truncate-end">
1111 <Text dimColor>{gutter}</Text>
1112 {s.tools.length ? chips(s.tools) : s.stop === 'end_turn' ? <Text color={C.ok}>✓ answer</Text> : <Text dimColor>■ {s.stop ?? '—'}</Text>}
1113 </Text>
1114 )}
1115 {open && (
1116 <Box flexDirection="column">
1117 {uses.map((u, ui) => {
1118 const name = toolLabel(u.tool)
1119
1120 return (
1121 <Text key={`xu-${s.id}-${ui}`} wrap="truncate-end">
1122 <Text dimColor>{gutter}</Text>
1123 <Text color={toolColor(name)}>{toolIcon(name)} {name} </Text>
1124 <Text dimColor>{u.brief.replace(/\s+/g, ' ')}</Text>
1125 </Text>
1126 )
1127 })}
1128 {said !== '' && (
1129 <Text wrap="truncate-end">
1130 <Text dimColor>{gutter}„</Text>
1131 {said}
1132 </Text>
1133 )}
1134 {s.isDone && (
1135 <Box>
1136 <Text dimColor>{gutter}</Text>
1137 <Button key={`step-${s.id}`} plain label="[tool outputs]" onPress={() => void openStep($, s)} />
1138 </Box>
1139 )}
1140 </Box>
1141 )}
1142 </Box>
1143 )
1144 }
1145
1146 const thinkView = (
1147 <Box flexDirection="column" gap={1}>
1148 {lastPrompt !== '' && (
1149 <Text wrap="truncate-end">
1150 <Text color={C.accent}>▸ </Text>
1151 <Text italic>{lastPrompt.replace(/\s+/g, ' ')}</Text>
1152 </Text>
1153 )}
1154 {!planNow && <Text dimColor>No plan yet for this request.</Text>}
1155 {planNow &&
1156 section('goal', 'UNDERSTAND', true, () => (
1157 <Box flexDirection="column">
1158 <Text wrap="wrap">
1159 <Text color={C.accent}>▸ </Text>
1160 {planNow.goal || '—'}
1161 </Text>
1162 <Text>
1163 <Text dimColor>clarity </Text>
1164 <Text color={clarityColor(planNow.clarity)}>{'▮'.repeat(planNow.clarity)}</Text>
1165 <Text dimColor>{'▯'.repeat(10 - planNow.clarity)} {planNow.clarity}/10</Text>
1166 {planNow.revision > 0 && <Text dimColor> · rev {planNow.revision}</Text>}
1167 </Text>
1168 {(planNow.approach ?? '') !== '' && (
1169 <Text wrap="wrap">
1170 <Text color={C.info}>→ </Text>
1171 {planNow.approach}
1172 </Text>
1173 )}
1174 </Box>
1175 ))}
1176 {planNow &&
1177 planNow.assumptions.length > 0 &&
1178 section('assume', `ASSUMPTIONS ${planNow.assumptions.length}`, false, () => (
1179 <Box flexDirection="column">
1180 {planNow.assumptions.map((a, i) => (
1181 <Text key={`as-${i}`} wrap="wrap">
1182 <Text color={C.run}>~ </Text>
1183 {a}
1184 </Text>
1185 ))}
1186 </Box>
1187 ))}
1188 {planNow &&
1189 section(
1190 'plan',
1191 `PLAN ${doneN}/${planNow.steps.length}`,
1192 true,
1193 () => (
1194 <Box flexDirection="column">
1195 {planLevels.map((level, li) => (
1196 <Box key={`lv-${li}`} flexDirection="column">
1197 {level.map((s, si) => planStep(s, level, li, si))}
1198 {li < planLevels.length - 1 && <Text dimColor>{' │'}</Text>}
1199 </Box>
1200 ))}hooks/changes.ts 93 lines1// The "Git" tab: only the modified files, not the whole tree. The commands (`status --porcelain=v1 -z`,
2// `diff HEAD --numstat -z`) follow the approach from data-goblin/claude-code-filetree (MIT).
3import type { Change } from '../types'
4
5export const parseStatus = (out: string): { branch: string; changes: Change[] } => {
6 const parts = out.split('\0').filter(Boolean)
7 const changes: Change[] = []
8 let branch = ''
9 for (let i = 0; i < parts.length; i++) {
10 const rec = parts[i] ?? ''
11 if (rec.startsWith('## ')) {
12 branch = rec.slice(3)
13 continue
14 }
15 const [x = ' ', y = ' '] = rec
16 const path = rec.slice(3)
17 if (x === 'R' || x === 'C') i++ // the next record is the old path
18 const isNew = x === '?'
19 const isConflict = x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')
20 changes.push({
21 path,
22 code: isNew ? '?' : isConflict ? 'U' : x !== ' ' ? x : y,
23 staged: !isNew && !isConflict && x !== ' ',
24 unstaged: isNew || isConflict || y !== ' ',
25 add: 0,
26 del: 0,
27 })
28 }
29
30 return { branch, changes }
31}
32
33export const parseNumstat = (out: string): Record<string, [number, number]> => {
34 const parts = out.split('\0')
35 const nums: Record<string, [number, number]> = {}
36 for (let i = 0; i < parts.length; i++) {
37 const m = (parts[i] ?? '').match(/^(\d+|-)\t(\d+|-)\t([\s\S]*)$/)
38 if (!m) continue
39 let path = m[3] ?? ''
40 if (path === '') {
41 i += 2 // rename: the old path follows, then the new one
42 path = parts[i] ?? ''
43 }
44 nums[path] = [Number(m[1]) || 0, Number(m[2]) || 0]
45 }
46
47 return nums
48}
49
50// "master...origin/master [ahead 1]" → { name, sync }
51export const branchLabel = (line: string) => {
52 const name = line.split('...')[0]?.replace(/^No commits yet on /, '') ?? ''
53 const ahead = line.match(/ahead (\d+)/)?.[1]
54 const behind = line.match(/behind (\d+)/)?.[1]
55
56 return { name, upstream: line.includes('...'), gone: line.includes('[gone]'), ahead: Number(ahead ?? 0), behind: Number(behind ?? 0), sync: `${ahead ? `↑${ahead}` : ''}${behind ? `↓${behind}` : ''}` }
57}
58
59// Sync = only what is missing, after a `fetch`: no upstream → push -u; behind → pull; ahead → push.
60// Upstream `[gone]` (branch deleted on the remote, usually after a merge): nothing, so it isn't re-created.
61// ponytail: pull is --ff-only only; on diverged branches it stops with git's error, rebase/merge stay manual
62export const syncSteps = (b: ReturnType<typeof branchLabel>): string[][] => {
63 if (b.gone) return []
64 if (!b.upstream) return [['push', '-u', 'origin', 'HEAD']]
65
66 return [...(b.behind ? [['pull', '--ff-only']] : []), ...(b.ahead ? [['push']] : [])]
67}
68
69export type Outcome = { ok: boolean; text: string }
70
71// The first useful line of a git command's output (without the `hint:` suggestions).
72export const outcomeOf = (run: { exitCode: number; stdout: string; stderr: string }): Outcome => {
73 const lines = (run.exitCode === 0 ? run.stdout : run.stderr || run.stdout).split('\n').map(l => l.trim()).filter(Boolean)
74
75 return { ok: run.exitCode === 0, text: lines.find(l => !l.startsWith('hint:')) ?? (run.exitCode === 0 ? 'done' : 'failed') }
76}
77
78// Whether or not `diff` was read, files come sorted by path and with line counts filled in.
79export const withCounts = (changes: Change[], nums: Record<string, [number, number]>) =>
80 changes.map(c => ({ ...c, add: nums[c.path]?.[0] ?? 0, del: nums[c.path]?.[1] ?? 0 })).sort((a, b) => a.path.localeCompare(b.path))
81
82// Cuts at a whole line (a hunk cut in the middle no longer parses); without a newline, cuts at the limit.
83export const clip = (text: string, max: number) => {
84 if (text.length <= max) return text
85 const cut = text.slice(0, max)
86
87 return cut.slice(0, cut.lastIndexOf('\n') + 1 || max)
88}
89
90// Haiku sometimes wraps the answer in quotes or a code fence: keep the first useful line.
91export const cleanMessage = (text: string) =>
92 (text.split('\n').map(l => l.trim()).find(l => l && !l.startsWith('```')) ?? '').replace(/^["'`]+|["'`]+$/g, '')
93hooks/plan.ts 125 lines1import type { Plan, PlanStep } from '../types'
2
3// The tool the main model uses to publish its plan: what it understood, what it assumes,
4// the step flow (who does it, after what) and what would make the request more precise.
5export const PLAN_TOOL = 'plan'
6export const PLAN_TOOL_ID = 'mcp__agent-lens__plan'
7
8const TIERS = ['haiku', 'sonnet', 'opus'] as const
9const STATUSES = ['todo', 'run', 'done', 'skip'] as const
10
11export const PLAN_SCHEMA = {
12 type: 'object',
13 properties: {
14 goal: { type: 'string', description: 'What you understood the user wants, in one sentence, in your own words.' },
15 approach: { type: 'string', description: 'How you will act, in 1-2 sentences: what you do first, what you delegate and to which model, how you verify.' },
16 clarity: { type: 'integer', minimum: 0, maximum: 10, description: 'How clear the request is: 10 = you know exactly what and where, 0 = you are guessing.' },
17 assumptions: { type: 'array', items: { type: 'string' }, description: 'What you assume without having been told.' },
18 questions: {
19 type: 'array',
20 items: {
21 type: 'object',
22 properties: {
23 ask: { type: 'string', description: 'A closed question, answered with a word or a choice.' },
24 why: { type: 'string', description: 'What would change in the work depending on the answer.' },
25 options: { type: 'array', items: { type: 'string' }, description: '2-4 short answers (1-4 words) the user picks with one click; the first is the recommended one.' },
26 default: { type: 'string', description: 'What you do if there is no answer; the work continues on this.' },
27 },
28 required: ['ask'],
29 },
30 description: 'Only what concretely changes the work (at most 3). The user answers from the tab; the answer reaches you in the same turn, after a tool result.',
31 },
32 better_prompt: { type: 'string', description: 'The request rewritten the way it should have been phrased, as a model for next time.' },
33 steps: {
34 type: 'array',
35 items: {
36 type: 'object',
37 properties: {
38 id: { type: 'string' },
39 title: { type: 'string' },
40 tier: { enum: [...TIERS], description: 'haiku = lookups/reads, sonnet = mechanical, opus = you (understanding, decisions, verification).' },
41 after: { type: 'array', items: { type: 'string' }, description: 'Ids of the steps that must finish first; steps with no shared dependencies run in parallel.' },
42 status: { enum: [...STATUSES] },
43 },
44 required: ['id', 'title', 'tier'],
45 },
46 },
47 savings: { type: 'array', items: { type: 'string' }, description: 'Efficiencies: what you skip, what runs in parallel, what you delegate down and why.' },
48 },
49 required: ['goal', 'approach', 'clarity', 'steps'],
50}
51
52export const PLAN_DESCRIPTION =
53 'Publishes to the Agent Lens sidebar (Flow tab) how you understood the request and how you split it into steps: what you assume, who does each step (haiku/sonnet/opus), what runs in parallel, what you make more efficient and what the user should clarify. Call it once at the start of every request, however short, before any other tool; call it again with the whole plan only when the plan changes or a phase ends (status per step). Write the plan fields in the language of the user\'s latest prompt.'
54
55export const ANSWERS_TAG = '[Agent Lens · answers]'
56
57export const PLAN_RULE = `At the start of every request, however short (even ones of 1-2 tool calls), before any other tool, call ${PLAN_TOOL_ID} with a mini-plan, even if you are not in plan mode: what you understood (goal) and how you act (approach), the clarity, the assumptions, the steps with model and dependencies, the efficiencies, and the questions that would make the user more specific (each with options and what you do by default, so you do not stop and wait). Call it again only when the plan changes or a phase ends; at the end mark the steps done. The user answers questions from the Flow tab: the answers arrive marked ${ANSWERS_TAG}, either after a tool result or as a new message. They override your assumptions: apply them immediately, and if they change the steps, republish the plan without the answered questions. Write the plan fields in the language of the user's latest prompt.`
58
59const strs = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : [])
60const oneOf = <T extends string>(v: unknown, list: readonly T[], fallback: T) => (list.includes(v as T) ? (v as T) : fallback)
61
62// The input comes from the model: any missing or wrongly typed field gets a safe value.
63export const normalizePlan = (input: unknown, at: number): Plan => {
64 const o = (input ?? {}) as Record<string, unknown>
65 const raw = Array.isArray(o.steps) ? (o.steps as Record<string, unknown>[]) : []
66 const steps: PlanStep[] = raw.map((s, i) => ({
67 id: String(s?.id ?? i + 1),
68 title: String(s?.title ?? '—'),
69 tier: oneOf(s?.tier, TIERS, 'opus'),
70 after: strs(s?.after),
71 status: oneOf(s?.status, STATUSES, 'todo'),
72 }))
73 const questions = (Array.isArray(o.questions) ? o.questions : [])
74 .map(q =>
75 typeof q === 'string'
76 ? { ask: q, why: '', options: [], fallback: '' }
77 : { ask: String(q?.ask ?? ''), why: String(q?.why ?? ''), options: strs(q?.options).slice(0, 4), fallback: typeof q?.default === 'string' ? q.default : '' },
78 )
79 .filter(q => q.ask.trim() !== '')
80
81 return {
82 goal: String(o.goal ?? ''),
83 approach: typeof o.approach === 'string' ? o.approach : '',
84 clarity: Math.max(0, Math.min(10, Math.round(Number(o.clarity) || 0))),
85 assumptions: strs(o.assumptions),
86 questions,
87 betterPrompt: typeof o.better_prompt === 'string' ? o.better_prompt : '',
88 steps,
89 savings: strs(o.savings),
90 revision: 0,
91 at,
92 }
93}
94
95// Flow levels: a step sits on the level after its latest dependency,
96// so steps on the same level can run in parallel. Unknown dependencies are ignored;
97// a cycle lands on the last level instead of blocking the drawing.
98export const layers = (steps: readonly PlanStep[]): PlanStep[][] => {
99 const ids = new Set(steps.map(s => s.id))
100 const level = new Map<string, number>()
101 let changed = true
102 for (let pass = 0; changed && pass <= steps.length; pass++) {
103 changed = false
104 for (const s of steps) {
105 const deps = s.after.filter(d => ids.has(d) && d !== s.id)
106 if (!deps.every(d => level.has(d))) continue
107 const l = deps.length ? Math.max(...deps.map(d => level.get(d)!)) + 1 : 0
108 if (level.get(s.id) !== l) {
109 level.set(s.id, l)
110 changed = true
111 }
112 }
113 }
114 const last = level.size ? Math.max(...level.values()) + 1 : 0
115 const out: PlanStep[][] = []
116 for (const s of steps) (out[level.get(s.id) ?? last] ??= []).push(s)
117
118 return out.filter(Boolean)
119}
120
121// What the model reads when the user answers from the tab: the question next to the answer,
122// so it does not depend on question order in a newer plan revision.
123export const answersMessage = (answered: readonly { ask: string; text: string }[]) =>
124 answered.length ? [ANSWERS_TAG, ...answered.map(a => `- ${a.ask}\n → ${a.text}`)].join('\n') : ''
125hooks/memories-tab.tsx 166 lines1import type { Memory, MemoryMeta, MemoryUse, MemoryView } from '../types'
2import { backlinks, isolated } from './memories'
3
4// Memories tab, drawing only. Whatever touches `$` (reading the directory, tracking tools) lives in
5// register.tsx, with the atoms: the engine does not let `$` pass into an imported function and reads atoms only from their own file.
6
7export const INDEX = 'MEMORY.md'
8const TYPE_COLOR: Record<string, string> = { project: 'cyan', feedback: 'yellow', reference: 'blue', user: 'magenta' }
9// What each memory type is for, and why it matters to the model. The four types are the memory system's own taxonomy.
10const TYPE_HELP: Record<string, string> = {
11 user: 'user: who you are, your role and preferences. Shapes every answer.',
12 feedback: 'feedback: how to work with you, corrections and confirmed approaches, with the why. Applied to every similar task.',
13 project: 'project: ongoing work, goals and deadlines not visible in the code. Goes stale, so check its dates.',
14 reference: 'reference: pointers to external systems (dashboards, tickets, docs). Tells the model where to look.',
15}
16const UNTYPED = 'untyped: no type in the frontmatter, so the model cannot tell when it applies.'
17const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
18const tail = (path: string) => path.split('/').slice(-2).join('/')
19const clip = (text: string, n = 70) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
20
21export type MemoryData = { list: Memory[]; meta: MemoryMeta; use: Record<string, MemoryUse>; opened: MemoryView | null; filter: string; room: number }
22export type MemoryActions = { open: (name: string) => void; back: () => void; filter: (type: string) => void; refresh: () => void }
23type Ui = { Box: any; Text: any; Button: any; Markdown: any } // ponytail: terminal elements, untyped here; register.tsx provides them from $.ui.resolve
24
25export const memoriesView = ({ Box, Text, Button, Markdown }: Ui, { list, meta, use, opened, filter, room }: MemoryData, act: MemoryActions) => {
26 const back = backlinks(list)
27 const names = new Set(list.map(m => m.name))
28 const edges = list.reduce((n, m) => n + m.links.filter(l => names.has(l)).length, 0)
29 const lonely = isolated(list)
30 const marks = (name: string) => {
31 const u = use[name]
32
33 return u ? `${u.read ? ` ◉${u.read}` : ''}${u.write ? ` ✎${u.write}` : ''}${u.recalled ? ` ↺${u.recalled}` : ''}` : ''
34 }
35 const open = (name: string) => () => act.open(name)
36
37 if (!meta.loaded) return <Text dimColor>Reading the project memory…</Text>
38 if (!list.length) return <Text dimColor>The project has no memories in {meta.dir || '—'}.</Text>
39
40 // An open memory: its neighbors in the graph (what it cites, who cites it) and how it was used.
41 const node = opened && list.find(m => m.name === opened.name)
42 if (opened && node) {
43 const out = node.links
44 const inc = back[node.name] ?? []
45 const u = use[node.name]
46 const edge = (name: string, i: number, all: string[], dir: string) => (
47 <Box key={`${dir}-${name}`}>
48 <Text dimColor>{i === all.length - 1 ? ' └─ ' : ' ├─ '}</Text>
49 {names.has(name) ? (
50 <Button key={`go-${dir}-${name}`} plain label={name} onPress={open(name)} />
51 ) : (
52 <Text color="red">✗ {name} (does not exist yet)</Text>
53 )}
54 <Text dimColor>{marks(name)}</Text>
55 </Box>
56 )
57
58 return (
59 <Box flexDirection="column">
60 <Box justifyContent="space-between">
61 <Text bold color={TYPE_COLOR[node.type]} wrap="truncate-end">◆ {node.name}</Text>
62 <Button key="mem-back" plain label="← graf" hotkey="b" onPress={act.back} />
63 </Box>
64 <Text dimColor wrap="wrap">{node.type} · {k(node.size)} B · {node.description}</Text>
65 <Text dimColor wrap="wrap">{TYPE_HELP[node.type] ?? UNTYPED}</Text>
66 <Text wrap="wrap">
67 <Text dimColor>in prompt (MEMORY.md): </Text>
68 {node.indexed ? <Text>„{node.hook}”</Text> : <Text color="red">missing from the index: the model sees it only if it looks for it</Text>}
69 </Text>
70 <Text dimColor>
71 in session: read {u?.read ?? 0} · written {u?.write ?? 0} · recalled {u?.recalled ?? 0}
72 </Text>
73 <Box flexDirection="column" marginTop={1}>
74 <Text bold dimColor>LINKS TO → {out.length}</Text>
75 {out.length === 0 && <Text dimColor> —</Text>}
76 {out.map((n, i) => edge(n, i, out, 'out'))}
77 <Text bold dimColor>CITED BY ← {inc.length}</Text>
78 {inc.length === 0 && <Text dimColor> —</Text>}
79 {inc.map((n, i) => edge(n, i, inc, 'in'))}
80 </Box>
81 <Box marginTop={1} flexDirection="column">
82 <Markdown text={opened.body} />
83 </Box>
84 </Box>
85 )
86 }
87
88 const types = Object.entries(list.reduce<Record<string, number>>((acc, m) => ({ ...acc, [m.type]: (acc[m.type] ?? 0) + 1 }), {})).sort((a, b) => b[1] - a[1])
89 const shown = list
90 .filter(m => filter === 'all' || m.type === filter)
91 .map(m => ({ m, inN: back[m.name]?.length ?? 0, outN: m.links.length }))
92 .sort((a, b) => b.inN + b.outN - (a.inN + a.outN) || a.m.name.localeCompare(b.m.name))
93 const maxDeg = Math.max(1, ...shown.map(s => s.inN + s.outN))
94 const rows = Math.max(6, room - 10)
95 const idx = use[INDEX]
96 const reads = Object.entries(use).filter(([n, u]) => n !== INDEX && u.read).length
97 const writes = Object.values(use).reduce((n, u) => n + u.write, 0)
98 const recalled = Object.entries(use).filter(([, u]) => u.recalled).length
99 const missing = list.filter(m => !m.indexed).length
100
101 return (
102 <Box flexDirection="column">
103 <Text wrap="truncate-start" dimColor>{meta.dir}</Text>
104 <Text>
105 <Text bold>{list.length}</Text>
106 <Text dimColor> memories · </Text>
107 <Text bold>{edges}</Text>
108 <Text dimColor> links · {lonely.length} isolated</Text>
109 {missing > 0 && <Text color="red"> · {missing} outside the index</Text>}
110 </Text>
111 <Text wrap="wrap" dimColor>user = who you are · feedback = how to work with you · project = ongoing work · reference = where to look</Text>
112
113 <Box marginTop={1} flexDirection="column">
114 {meta.context.map(f => (
115 <Text key={`ctx-${f.path}`} wrap="truncate-end">
116 <Text color="green">in prompt </Text>
117 <Text>{tail(f.path)}</Text>
118 <Text dimColor> {f.type} · {k(f.tokens)} tok, on every request</Text>
119 </Text>
120 ))}
121 {meta.context.length === 0 && <Text dimColor>in prompt nothing from memory in context</Text>}
122 <Text wrap="truncate-end">
123 <Text color="cyan">◉ read {reads}</Text>
124 <Text color="blue"> ↺ recalled {recalled}</Text>
125 <Text color="yellow"> ✎ written {writes}</Text>
126 {idx?.read ? <Text dimColor> (MEMORY.md read ×{idx.read})</Text> : null}
127 </Text>
128 <Text dimColor wrap="wrap">◉ opened as a file · ↺ attached by the engine · ✎ edited in memory/ · ←cited by →links to</Text>
129 {meta.remember.length > 0 && (
130 <Text wrap="wrap" dimColor>
131 <Text color="magenta">+ .remember </Text>
132 {meta.remember.map(f => `${f.name} ${k(f.size)}`).join(' · ')} (remember plugin, at SessionStart)
133 </Text>
134 )}
135 </Box>
136
137 <Box marginTop={1} gap={1} flexWrap="wrap">
138 {[['all', list.length] as [string, number], ...types].map(([t, n]) => (
139 <Button key={`mt-${t}`} label={`${t} ${n}`} variant={filter === t ? 'primary' : 'secondary'} onPress={() => act.filter(t)} />
140 ))}
141 <Button key="mem-refresh" plain label="↻" onPress={act.refresh} />
142 </Box>
143 {filter !== 'all' && <Text wrap="wrap" dimColor>{TYPE_HELP[filter] ?? UNTYPED}</Text>}
144 {shown.slice(0, rows).map(({ m, inN, outN }) => {
145 const cells = Math.max(inN + outN ? 1 : 0, Math.round(((inN + outN) / maxDeg) * 6))
146 const text = m.description || m.hook
147
148 return (
149 <Box key={`mem-${m.name}`}>
150 <Text color={TYPE_COLOR[m.type]}>{'▮'.repeat(cells).padEnd(6, '·')} </Text>
151 <Button key={`mo-${m.name}`} plain label={m.name} onPress={open(m.name)} />
152 {text ? <Text dimColor> </Text> : null}
153 {text ? <Button key={`md-${m.name}`} plain label={clip(text)} onPress={open(m.name)} /> : null}
154 <Text dimColor>
155 {` ←${inN} →${outN}`}
156 </Text>
157 <Text color="green">{marks(m.name)}</Text>
158 {!m.indexed && <Text color="red"> !idx</Text>}
159 </Box>
160 )
161 })}
162 {shown.length > rows && <Text dimColor>… +{shown.length - rows} (filter by type)</Text>}
163 </Box>
164 )
165}
166hooks/workers-tab.tsx 166 lines1import type { Validation, Worker } from '../types'
2
3// Workers tab: each subagent is a foldable card. Closed: type, model, status, task.
4// Open: why this model, the prompt received, the tools, the answer. Drawing only, like Memories.
5
6type Tier = 'opus' | 'sonnet' | 'haiku'
7type Ui = { Box: any; Text: any; Button: any } // ponytail: terminal elements, untyped here; register.tsx provides them from $.ui.resolve
8
9export type WorkersData = {
10 routing: boolean
11 tier: { tier: Tier; why: string }
12 workers: Worker[]
13 since: number
14 validations: Validation[]
15 tools: Record<string, string>
16 fold: Record<string, boolean>
17 misses: number
18 cost: number
19 opusCost: number
20}
21export type WorkersActions = { toggle: (key: string, open: boolean) => void }
22
23const ICON: Record<Tier, string> = { haiku: '⌕', sonnet: '✎', opus: '◆' }
24const COLOR: Record<Tier, string | undefined> = { haiku: 'green', sonnet: 'blue', opus: undefined }
25const tierIn = (model: string): Tier => (/haiku/i.test(model) ? 'haiku' : /sonnet/i.test(model) ? 'sonnet' : 'opus')
26const short = (model: string) => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
27const secs = (ms: number) => (ms >= 60000 ? `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s` : `${Math.round(ms / 1000)}s`)
28const usd = (n: number) => `$${n.toFixed(n < 1 ? 3 : 2)}`
29const k = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n))
30
31// What comes out of a subagent's branch: escalation, validator verdict or a learned upgrade.
32export const outcomeOf = (w: Worker, check?: Validation) => {
33 if (w.escalated) return { mark: '⇡', color: 'yellow', text: `asks for a higher model: ${w.escalated} → failure recorded` }
34 if (check?.status === 'fail') return { mark: '✗', color: 'red', text: `${check.summary.replace(/^VERDICT:\s*/i, '')} → failure recorded` }
35 if (check?.status === 'ok') return { mark: '✓', color: 'green', text: 'validated' }
36 if (w.route?.includes('learned')) return { mark: '↑', color: 'yellow', text: 'upgraded from learned failures' }
37
38 return undefined
39}
40
41// Where the subagent's model comes from, in words.
42export const whyOf = (w: Worker) =>
43 w.route
44 ? w.route.replace(/^[^:]+:\s*/, '')
45 : w.asked
46 ? `requested by the main model (${w.asked})`
47 : short(w.model) === short(w.parentModel)
48 ? 'inherited from the main turn'
49 : 'from the agent definition'
50
51const STATUS: Record<Worker['status'], [string, string, string]> = {
52 running: ['●', 'yellow', 'running'],
53 done: ['✓', 'green', 'done'],
54 aborted: ['✗', 'red', 'stopped'],
55 denied: ['⊘', 'red', 'denied'],
56}
57
58export const workersView = ({ Box, Text, Button }: Ui, d: WorkersData, act: WorkersActions) => {
59 const current = d.workers.filter(w => w.startedAt >= d.since)
60 const older = d.workers.filter(w => w.startedAt < d.since).slice(-8).reverse()
61 const row = (label: string, value: string, key: string) => (
62 <Text key={key} wrap="wrap">
63 <Text dimColor>{label.padEnd(9)}</Text>
64 {value}
65 </Text>
66 )
67
68 const card = (w: Worker, openByDefault: boolean) => {
69 const t = tierIn(w.model)
70 const [mark, color, label] = STATUS[w.status]
71 const out = outcomeOf(w, d.validations.find(v => v.agentId === w.agentId))
72 const key = `w-${w.id}`
73 const isOpen = d.fold[key] ?? openByDefault
74
75 return (
76 <Box key={key} flexDirection="column" borderStyle="round" borderColor={w.status === 'running' ? 'yellow' : COLOR[t]} paddingX={1}>
77 <Box justifyContent="space-between">
78 <Button key={`fold-${key}`} plain label={`${isOpen ? '▾' : '▸'} ${w.type}`} onPress={() => act.toggle(key, !isOpen)} />
79 <Text color={color}>
80 {mark} {label}
81 {w.durationMs !== undefined ? ` · ${secs(w.durationMs)}` : ''}
82 </Text>
83 </Box>
84 <Text wrap="truncate-end">
85 <Text bold color={COLOR[t]}>
86 {ICON[t]} {t}
87 </Text>
88 <Text dimColor>
89 {' '}
90 {short(w.model)}
91 {w.isBackground ? ' · background' : ''} · {w.tools} tools
92 {w.tokensOut !== undefined ? ` · ${k(w.tokensOut)}↓` : ''}
93 </Text>
94 </Text>
95 <Text wrap={isOpen ? 'wrap' : 'truncate-end'}>{w.description || '—'}</Text>
96 {out && (
97 <Text wrap="truncate-end" color={out.color}>
98 {out.mark} {out.text}
99 </Text>
100 )}
101 {isOpen && (
102 <Box flexDirection="column" marginTop={1}>
103 {row('why', whyOf(w), 'why')}
104 {row('tools', (w.agentId && d.tools[w.agentId]) || '—', 'tools')}
105 {w.prompt && row('task', w.prompt, 'prompt')}
106 {w.answer && row('answer', w.answer, 'answer')}
107 {!w.answer && w.status === 'running' && <Text dimColor>the answer appears when it finishes</Text>}
108 </Box>
109 )}
110 </Box>
111 )
112 }
113
114 const mix = (['haiku', 'sonnet', 'opus'] as const).map(t => [t, current.filter(w => tierIn(w.model) === t).length] as const).filter(([, n]) => n > 0)
115 const running = current.filter(w => w.status === 'running').length
116 const rulesOpen = d.fold.rules ?? false
117
118 return (
119 <Box flexDirection="column" gap={1}>
120 <Box flexDirection="column">
121 <Text bold dimColor>THIS TURN</Text>
122 <Text wrap="truncate-end">
123 <Text color={COLOR[d.tier.tier]}>{ICON[d.tier.tier]} </Text>
124 <Text bold color={COLOR[d.tier.tier]}>turn on {d.routing ? d.tier.tier : 'session model'}</Text>
125 <Text dimColor> ({d.routing ? d.tier.why : 'routing off'})</Text>
126 </Text>
127 <Text wrap="truncate-end">
128 <Text dimColor>{current.length} workers </Text>
129 {mix.map(([t, n]) => (
130 <Text key={`mix-${t}`} color={COLOR[t]} bold={t === 'opus'}>
131 {ICON[t]}
132 {n}{' '}
133 </Text>
134 ))}
135 {running > 0 && <Text color="yellow">· ● {running} running</Text>}
136 </Text>
137 <Text wrap="truncate-end" dimColor>
138 {usd(d.cost)} this turn · on opus it would have been {usd(d.opusCost)}
139 {d.misses > 0 ? ` · ${d.misses} learned failures` : ''}
140 </Text>
141 </Box>
142 {d.workers.length === 0 && <Text dimColor>No subagents invoked in this session.</Text>}
143 {current.map((w, i) => card(w, w.status === 'running' || i === current.length - 1))}
144 {older.length > 0 && <Text bold dimColor>PREVIOUS TURNS</Text>}
145 {older.map(w => card(w, false))}
146 <Box flexDirection="column">
147 <Button key="fold-rules" plain label={`${rulesOpen ? '▾' : '▸'} how the model is chosen`} onPress={() => act.toggle('rules', !rulesOpen)} />
148 {rulesOpen && (
149 <Box flexDirection="column">
150 <Text>the request picks the turn's model</Text>
151 <Text dimColor> ├ [haiku]/[sonnet] in prompt ▶ pinned</Text>
152 <Text dimColor> ├ ≥2 similar failures ▶ +1 level</Text>
153 <Text dimColor> └ keywords ▶ haiku │ sonnet │ opus</Text>
154 <Text>cheap subagent (haiku/sonnet)</Text>
155 <Text dimColor> └ unsure ▶ ESCALATE ▶ failure recorded</Text>
156 <Text>cheap turn with modified files</Text>
157 <Text dimColor> └ opus validator ▶ FAIL ▶ failure recorded</Text>
158 <Text>recorded failures (per project)</Text>
159 <Text dimColor> └ only goes up: similar tasks +1</Text>
160 </Box>
161 )}
162 </Box>
163 </Box>
164 )
165}
166hooks/styles-tab.tsx 89 lines1import type { Style, StyleDraft } from '../types'
2
3// Output style tab, drawing only (like memories-tab.tsx): what touches `$` lives in register.tsx.
4
5const clip = (text: string, n = 60) => (text.length > n ? `${text.slice(0, n - 1)}…` : text)
6
7export type StyleData = { list: Style[]; active: string; draft: StyleDraft | null; fresh: string; note: string; loaded: boolean }
8export type StyleActions = {
9 open: (file: string) => void
10 back: () => void
11 use: (file: string) => void
12 desc: (v: string) => void
13 line: (i: number, v: string) => void
14 add: (i: number) => void
15 drop: (i: number) => void
16 save: () => void
17 fresh: (v: string) => void
18 create: (name: string) => void
19 refresh: () => void
20}
21type Ui = { Box: any; Text: any; Button: any; Input: any } // ponytail: terminal elements, untyped here; register.tsx provides them from $.ui.resolve
22
23export const stylesView = ({ Box, Text, Button, Input }: Ui, { list, active, draft, fresh, note, loaded }: StyleData, act: StyleActions) => {
24 if (!loaded) return <Text dimColor>Reading the output styles…</Text>
25
26 const status = note ? <Text wrap="wrap" color={note.startsWith('✗') ? 'red' : 'green'}>{note}</Text> : null
27 const style = draft && list.find(s => s.file === draft.file)
28
29 // An open style: description and instructions edited line by line. Enter in a field = save.
30 if (draft && style) {
31 const isActive = style.name === active
32
33 return (
34 <Box flexDirection="column">
35 <Box justifyContent="space-between">
36 <Text bold color={isActive ? 'green' : undefined} wrap="truncate-end">{isActive ? '● ' : '○ '}{style.name}</Text>
37 <Button key="st-back" plain label="← list" hotkey="b" onPress={act.back} />
38 </Box>
39 <Text dimColor wrap="truncate-start">{style.file}{draft.dirty ? ' · unsaved changes' : ''}</Text>
40 <Box gap={1} marginTop={1}>
41 <Button key="st-use" variant="primary" label={isActive ? 'in use' : 'use this style'} onPress={() => act.use(style.file)} />
42 <Button key="st-save" label="save" hotkey="s" onPress={act.save} />
43 </Box>
44 {status}
45 <Box marginTop={1} flexDirection="column">
46 <Input key="st-desc" label="description " placeholder="one line" value={draft.description} submitLabel="save" onInput={act.desc} onSubmit={act.save} />
47 <Text bold dimColor>INSTRUCTIONS · one line per field</Text>
48 {draft.lines.map((l, i) => (
49 <Box key={`st-row-${i}`}>
50 <Input key={`st-line-${i}`} placeholder="(empty line)" value={l} submitLabel="save" onInput={(v: string) => act.line(i, v)} onSubmit={act.save} />
51 <Button key={`st-add-${i}`} plain label=" +" onPress={() => act.add(i)} />
52 <Button key={`st-drop-${i}`} plain label=" ×" onPress={() => act.drop(i)} />
53 </Box>
54 ))}
55 </Box>
56 <Text dimColor wrap="wrap">+ inserts a line below · × removes it · a style change reaches the model from the next session.</Text>
57 </Box>
58 )
59 }
60
61 return (
62 <Box flexDirection="column">
63 <Text wrap="wrap">
64 <Text bold>{list.length}</Text>
65 <Text dimColor> styles · active: </Text>
66 <Text bold color="green">{active || 'default'}</Text>
67 <Button key="st-refresh" plain label=" ↻" onPress={act.refresh} />
68 </Text>
69 <Text dimColor wrap="wrap">Click a style to read and edit it · “use” writes outputStyle in settings.json, read at the start of the next session.</Text>
70 {status}
71 <Box marginTop={1} flexDirection="column">
72 {list.map(s => (
73 <Box key={`st-${s.file}`}>
74 <Text color={s.name === active ? 'green' : undefined}>{s.name === active ? '● ' : '○ '}</Text>
75 <Button key={`st-open-${s.file}`} plain label={s.name} onPress={() => act.open(s.file)} />
76 <Text dimColor> </Text>
77 <Button key={`st-desc-${s.file}`} plain label={clip(s.description)} onPress={() => act.open(s.file)} />
78 {s.name === active ? null : <Button key={`st-quick-${s.file}`} plain label=" use" onPress={() => act.use(s.file)} />}
79 </Box>
80 ))}
81 </Box>
82 <Box marginTop={1} flexDirection="column">
83 <Text bold dimColor>NEW STYLE</Text>
84 <Input key="st-new" label="name " placeholder={`name; starts as a copy of ${active || 'the first style'}`} value={fresh} submitLabel="create" onInput={act.fresh} onSubmit={act.create} />
85 </Box>
86 </Box>
87 )
88}
89hooks/styles.ts 42 lines1// Output style tab: the styles are plain Markdown files in <config>/output-styles/, the active one is
2// "outputStyle" in <config>/settings.json. Pure parsing/serialising here; the I/O lives in register.tsx.
3import type { Style } from '../types'
4
5const field = (front: string, key: string) => (front.match(new RegExp(`^${key}:[ \\t]*(.*)$`, 'm'))?.[1] ?? '').trim()
6
7// Frontmatter (name, description, keep-coding-instructions) + the instructions body.
8export const parseStyle = (file: string, raw: string): Style => {
9 const text = raw.replace(/\r\n/g, '\n')
10 const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
11 const front = m?.[1] ?? ''
12
13 return {
14 file,
15 name: field(front, 'name') || file.replace(/\.md$/, ''),
16 description: field(front, 'description'),
17 keep: field(front, 'keep-coding-instructions') !== 'false',
18 body: ((m ? m[2] : text) ?? '').trim(),
19 }
20}
21
22const oneLine = (s: string) => s.replace(/\s*\n\s*/g, ' ').trim()
23
24export const buildStyle = (s: Style) => `---\nname: ${oneLine(s.name)}\ndescription: ${oneLine(s.description)}\nkeep-coding-instructions: ${s.keep}\n---\n\n${s.body.trim()}\n`
25
26// "My style!" -> "my-style": a safe file name from what the person typed.
27export const slugName = (s: string) => s.toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
28
29// settings.json with `outputStyle` set; the file is plain JSON, written back with the same 2-space layout.
30export const withOutputStyle = (settingsText: string, name: string) =>
31 `${JSON.stringify({ ...(JSON.parse(settingsText || '{}') as Record<string, unknown>), outputStyle: name }, null, 2)}\n`
32
33export const activeStyle = (settingsText: string) => {
34 try {
35 const v = (JSON.parse(settingsText) as { outputStyle?: unknown }).outputStyle
36
37 return typeof v === 'string' ? v : ''
38 } catch {
39 return ''
40 }
41}
42hooks/retro.ts 132 lines1import type { Plan, Retro, RetroFinding, Step, Worker } from '../types'
2import { VALIDATOR, costOf, tierOf } from './router'
3import type { Tier } from './router'
4
5// Turn retrospective: after a turn ends, it looks at the real steps (model, tools, cost) and at the
6// workers, and says where cheaper delegation was possible. Fixed rules, no model call.
7
8const NEUTRAL = /^(agent-lens:plan|ToolSearch|Skill|TodoWrite|AskUserQuestion)$/
9const READ = /^(Read|Grep|Glob|WebFetch|WebSearch|codegraph:.*|filesystem:(read|list|search|get|directory).*)$/
10const READ_BASH = /^\s*(cd \S+ && )?(cat|sed -n|grep|rg|ls|find|head|tail|wc|tree|stat|file|git (status|log|diff|show|blame))\b/
11const EDIT = /^(Edit|Write|NotebookEdit)$/
12
13const label = (tool: string) => tool.replace(/^mcp__(?:plugin_)?([^_]+(?:_[^_]+)*?)__/, '$1:')
14
15export type Kind = 'read' | 'edit' | 'other'
16
17const PLAN_MIN_CALLS = 3
18
19// One call's kind, the same rules as a whole step's.
20const callKind = (tool: string, brief: string): Kind | null => {
21 const name = label(tool)
22 if (NEUTRAL.test(name)) return null
23 if (READ.test(name) || (name === 'Bash' && READ_BASH.test(brief))) return 'read'
24
25 return EDIT.test(name) ? 'edit' : 'other'
26}
27const callsOf = (list: Step[], kind: Kind) => list.flatMap(s => s.uses ?? []).filter(u => callKind(u.tool, u.brief) === kind).length
28
29// A step is "read-only" or "edit-only" only if all its tools (apart from neutral ones) are.
30export const kindOf = (s: Step): Kind => {
31 const uses = (s.uses ?? []).map(u => ({ name: label(u.tool), brief: u.brief })).filter(u => !NEUTRAL.test(u.name))
32 if (!uses.length) return 'other'
33 if (uses.every(u => READ.test(u.name) || (u.name === 'Bash' && READ_BASH.test(u.brief)))) return 'read'
34 if (uses.every(u => EDIT.test(u.name))) return 'edit'
35
36 return 'other'
37}
38
39// ponytail: a subagent starts with a small context, so the delegated cost is estimated with the prompt capped at 30k
40// and without the step where the main loop reads the summary; it is an order of magnitude, not the bill.
41const DELEGATE_CONTEXT = 30_000
42const costAs = (tier: Tier, s: Step, cap = Infinity) => {
43 const prompt = Math.min(s.tokensIn, cap)
44 const cached = Math.min(s.cacheRead, prompt)
45
46 return costOf(tier, { input_tokens: prompt - cached, cache_read_input_tokens: cached, cache_creation_input_tokens: 0, output_tokens: s.tokensOut })
47}
48const spent = (list: Step[]) => list.reduce((n, s) => n + (s.cost ?? 0), 0)
49const toolsOf = (list: Step[]) => {
50 const counts = new Map<string, number>()
51 for (const name of list.flatMap(s => s.tools).filter(t => !NEUTRAL.test(t))) counts.set(name, (counts.get(name) ?? 0) + 1)
52
53 return [...counts].map(([name, n]) => (n > 1 ? `${name}×${n}` : name)).join(' ')
54}
55
56// Runs of at least `min` consecutive steps of the same kind, on opus, in the main loop.
57const runsOf = (steps: Step[], kind: Kind, min: number) => {
58 const runs: Step[][] = []
59 let cur: Step[] = []
60 for (const s of steps) {
61 if (s.isDone && tierOf(s.model) === 'opus' && kindOf(s) === kind) cur.push(s)
62 else {
63 if (cur.length >= min) runs.push(cur)
64 cur = []
65 }
66 }
67 if (cur.length >= min) runs.push(cur)
68
69 return runs
70}
71
72export const retroOf = (turnId: string, at: number, main: Step[], workers: Worker[], workerSteps: Step[], plan: Plan | null): Retro => {
73 const findings: RetroFinding[] = []
74 const span = (run: Step[]) => (run.length > 1 ? `#${run[0]?.index}–#${run.at(-1)?.index}` : `#${run[0]?.index}`)
75
76 // "Don't delegate what you'd solve in one or two calls": a subagent is only worth it from 3 steps in a row.
77 for (const run of runsOf(main, 'read', 3)) {
78 const saving = spent(run) - run.reduce((n, s) => n + costAs('haiku', s, DELEGATE_CONTEXT), 0)
79 findings.push({ kind: 'read', text: `steps ${span(run)} on opus only read (${toolsOf(run)}): an Explore on haiku would have done them`, saving: Math.max(0, saving) })
80 }
81 for (const run of runsOf(main, 'edit', 3)) {
82 const saving = spent(run) - run.reduce((n, s) => n + costAs('sonnet', s, DELEGATE_CONTEXT), 0)
83 findings.push({ kind: 'edit', text: `steps ${span(run)} on opus were serial edits (${toolsOf(run)}): with the spec written, a sonnet subagent would have done them`, saving: Math.max(0, saving) })
84 }
85
86 for (const w of workers) {
87 const own = workerSteps.filter(s => s.agentId === w.agentId && s.isDone)
88 if (!own.length) continue
89 const t = tierOf(w.model) ?? 'opus'
90 if (t === 'opus' && w.type !== VALIDATOR && own.every(s => kindOf(s) === 'read' || !s.tools.length) && own.some(s => kindOf(s) === 'read')) {
91 const saving = spent(own) - own.reduce((n, s) => n + costAs('haiku', s), 0)
92 findings.push({ kind: 'worker', text: `${w.type} ("${w.description}") ran only reads on opus: model: haiku was enough`, saving: Math.max(0, saving) })
93 }
94 if (w.escalated) {
95 findings.push({ kind: 'escalated', text: `${w.type} on ${t} asked for a higher model (${w.escalated}): this task should go one level up directly`, saving: 0, wasted: spent(own) })
96 }
97 }
98
99 // The plan promised delegation, the execution did not deliver.
100 // Only when opus then did at least 3 calls of that kind itself (reads for haiku, edits for sonnet):
101 // a planned 1-2 call lookup is cheaper done in place than delegated.
102 for (const t of ['haiku', 'sonnet'] as const) {
103 const planned = plan?.steps.filter(s => s.tier === t && s.status !== 'skip') ?? []
104 const kind: Kind = t === 'haiku' ? 'read' : 'edit'
105 const done = callsOf(main.filter(s => s.isDone && tierOf(s.model) === 'opus'), kind)
106 if (planned.length && done >= PLAN_MIN_CALLS && !workers.some(w => tierOf(w.model) === t)) {
107 findings.push({ kind: 'plan', text: `the plan put ${planned.length} steps on ${t} (${planned.map(s => s.title).join(', ')}), but no ${t} subagent was started and opus did ${done} ${kind} calls itself`, saving: 0 })
108 }
109 }
110
111 const all = [...main, ...workerSteps]
112 const cost = spent(all)
113 const cheap = spent(all.filter(s => tierOf(s.model) !== 'opus'))
114
115 return {
116 turnId,
117 at,
118 steps: all.length,
119 cost,
120 cheapShare: cost ? cheap / cost : 0,
121 saving: findings.reduce((n, f) => n + f.saving, 0),
122 findings,
123 }
124}
125
126// What reaches the model after a turn with findings: the lesson, not the numbers; the next turn reads it from the conversation.
127export const RETRO_TAG = '[Agent Lens · turn retrospective]'
128export const retroMessage = (r: Retro) =>
129 r.findings.length
130 ? `${RETRO_TAG}\nWhere cheaper delegation was possible:\n${r.findings.map(f => `- ${f.text}`).join('\n')}\nApply in following turns: delegate such steps through Agent with the model parameter, without redoing this turn.`
131 : ''
132hooks/memories.ts 58 lines1// Memories tab: the current project's auto memory (~/.claude/projects/<slug>/memory) as a graph.
2// Only the open project's directory: other projects' memories live in other slugs and are not read.
3import type { Memory } from '../types'
4
5// Same slug as the engine: any non-alphanumeric character in the path becomes "-".
6export const projectSlug = (root: string) => root.replace(/[^a-zA-Z0-9]/g, '-')
7
8const field = (front: string, key: string) => {
9 const m = front.match(new RegExp(`^\\s*${key}:\\s*(.*)$`, 'm'))
10
11 return (m?.[1] ?? '').trim().replace(/^"(.*)"$/, '$1').replace(/\\"/g, '"')
12}
13
14// Frontmatter (name, description, type — also under `metadata:`) + the [[name]] links from the body.
15export const parseMemory = (file: string, text: string) => {
16 const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
17 const front = m?.[1] ?? ''
18 const body = (m ? m[2] : text) ?? ''
19 const links = [...new Set([...body.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g)].map(l => (l[1] ?? '').trim()))]
20
21 return {
22 name: field(front, 'name') || file.replace(/\.md$/, ''),
23 type: field(front, 'type') || 'untyped',
24 description: field(front, 'description'),
25 links,
26 body: body.trim(),
27 }
28}
29
30// MEMORY.md: "- [Title](file.md) — hook", one line per memory.
31export const parseIndex = (text: string) => {
32 const out: Record<string, { title: string; hook: string }> = {}
33 for (const m of text.matchAll(/^\s*-\s*\[([^\]]+)\]\(([^)]+\.md)\)\s*(?:[—–-]\s*(.*))?$/gm)) {
34 out[m[2] ?? ''] = { title: m[1] ?? '', hook: (m[3] ?? '').trim() }
35 }
36
37 return out
38}
39
40// Incoming links: who links to each memory.
41export const backlinks = (list: readonly Pick<Memory, 'name' | 'links'>[]) => {
42 const out: Record<string, string[]> = {}
43 for (const m of list) for (const to of m.links) (out[to] ??= []).push(m.name)
44
45 return out
46}
47
48// Which memories a text names (a Bash command, an attachment): by file, as "…/name.md", not as the suffix of another name.
49export const namesIn = (text: string, list: readonly Pick<Memory, 'name' | 'file'>[]) =>
50 list.filter(m => new RegExp(`(^|[/\\s"'(\\[])${m.file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`).test(text)).map(m => m.name)
51
52// Memories with no link at all, in either direction.
53export const isolated = (list: readonly Pick<Memory, 'name' | 'links'>[]) => {
54 const back = backlinks(list)
55
56 return list.filter(m => m.links.length === 0 && !back[m.name]?.length).map(m => m.name)
57}
58hooks/router.ts 91 lines1import type { Miss } from '../types'
2
3// Model routing: every turn runs on Opus; only Opus delegates down, through Agent's model parameter.
4export type Tier = 'opus' | 'sonnet' | 'haiku'
5
6export const MODEL_IDS: Record<Tier, string> = {
7 opus: 'claude-opus-5-5',
8 sonnet: 'claude-sonnet-5-5',
9 haiku: 'claude-haiku-5-5',
10}
11export const VALIDATOR = 'agent-lens:validator'
12
13// Quoted or pasted text (errors, UI snippets, logs) says nothing about the task's words.
14const QUOTED = /<pasted_content[^>]*>[\s\S]*?<\/pasted_content[^>]*>|```[\s\S]*?```|„[^”]*”|"[^"\n]*"|`[^`\n]*`|«[^»]*»/g
15
16// "[haiku] where is X" pins the turn's model; the tag does not reach the model.
17export const tagged = (text: string): { tier?: Tier; text: string } => {
18 const m = text.match(/^\s*\[(opus|sonnet|haiku)\]\s*/i)
19
20 return m ? { tier: m[1].toLowerCase() as Tier, text: text.slice(m[0].length) } : { text }
21}
22
23export const tierOf = (model: string): Tier | undefined => /opus|sonnet|haiku/i.exec(model)?.[0].toLowerCase() as Tier | undefined
24
25export const verdictOf = (answer: string) => {
26 const line = answer.match(/VERDICT:\s*(OK|FAIL)\b[^\n]*/i)?.[0]
27
28 return { isOk: /VERDICT:\s*OK/i.test(line ?? ''), line: line ?? 'VERDICT missing' }
29}
30
31export const VALIDATOR_PROMPT = `You are the final validator of a work turn. You receive the user's request and the modified files.
32Read the changes (git diff -- <files>; read an untracked file in full) and check:
33- it does what was asked, completely, with nothing left half-done;
34- obvious bugs, edge cases, mishandled errors;
35- nothing destructive or unrequested.
36Do not modify anything. Answer in the language of the user's request, at most 10 lines, with file:line where relevant.
37The last line is exactly one of: "VERDICT: OK" or "VERDICT: FAIL — <main reason>".`
38
39// A cheap subagent that is unsure stops instead of guessing; the mod sees the line and flags it.
40export const ESCALATE = 'ESCALATE:'
41export const ESCALATE_RULE = `\n\n[Agent Lens] You are running on a cheap model. If the task requires a design decision, touches more modules than you were given, or is ambiguous, do not guess and do not deliver half a job: stop and reply only with the line "${ESCALATE} <what is missing or what must be decided>".`
42// Only a line that starts with the marker counts: a report that quotes it mid-sentence is not an escalation.
43export const escalationOf = (answer: string) => answer.match(/^[ \t]*(?:ESCALATE|ESCALADEAZ[AĂ]):[ \t]*(.*)$/im)?.[1]?.trim() || undefined
44
45// USD per million tokens (claude-api skill, 2026-10-06); cache writes at 1h TTL, as Claude Code runs.
46// Haiku costs 5x above 100K prompt tokens.
47export const PRICES: Record<Tier, { input: number; output: number; write: number; read: number }> = {
48 opus: { input: 4, output: 20, write: 8, read: 0.2 },
49 sonnet: { input: 2, output: 10, write: 4, read: 0.2 },
50 haiku: { input: 0.1, output: 0.5, write: 0.2, read: 0.01 },
51}
52
53type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
54
55export const costOf = (tier: Tier, u: Usage) => {
56 const p = PRICES[tier]
57 const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
58 const long = tier === 'haiku' && prompt > 100_000 ? 5 : 1
59
60 return (long * (u.input_tokens * p.input + u.cache_creation_input_tokens * p.write + u.cache_read_input_tokens * p.read + u.output_tokens * p.output)) / 1e6
61}
62
63const RANK: Record<Tier, number> = { haiku: 0, sonnet: 1, opus: 2 }
64const UP: Record<Tier, Tier> = { haiku: 'sonnet', sonnet: 'opus', opus: 'opus' }
65const STOP = new Set('acest aceasta aceste acum asta astea care catre cand cele cine daca dupa este fara intr intre pentru prin sunt toate unde with from that this into what when have should could would file files code'.split(' '))
66
67export const wordsOf = (text: string) => {
68 const plain = text.replace(QUOTED, ' ').toLowerCase().normalize('NFD').replace(/\p{M}/gu, '')
69
70 return [...new Set(plain.match(/[\p{L}\p{N}]{4,}/gu) ?? [])].filter(w => !STOP.has(w)).slice(0, 40)
71}
72
73// The same text failing again on the same level counts once: the threshold needs different texts.
74export const addMiss = (misses: Miss[], miss: Miss) => {
75 const isSame = misses.some(m => m.tier === miss.tier && m.words.join(' ') === miss.words.join(' '))
76
77 return miss.words.length === 0 || isSame ? misses : [...misses, miss].slice(-40)
78}
79
80// ponytail: match on common words (>=2) with at least 2 failures; a model.complete classifier if it escalates from noise.
81// Only goes up: a failure on sonnet raises similar tasks to opus, never the reverse.
82export const learned = (text: string, tier: Tier, misses: Miss[]) => {
83 if (tier === 'opus') return undefined
84 const words = new Set(wordsOf(text))
85 const hits = misses.filter(m => RANK[m.tier] >= RANK[tier] && m.words.filter(w => words.has(w)).length >= 2)
86 if (hits.length < 2) return undefined
87 const worst = hits.reduce((a, b) => (RANK[b.tier] > RANK[a.tier] ? b : a)).tier
88
89 return { tier: UP[worst], why: `learned: ${hits.length} failures` }
90}
91types/index.d.ts 162 lines1export type Step = {
2 id: string
3 turnId: string
4 index: number
5 model: string
6 effort?: string
7 agentId?: string
8 isDone: boolean
9 tools: string[]
10 tokensIn: number
11 tokensOut: number
12 cacheRead: number
13 cost?: number
14 opusCost?: number
15 stop: string | null
16 answer: string
17 uses: { tool: string; brief: string }[]
18}
19
20// An open step in Flow: the answer text and each tool with its input and what it returned.
21export type StepView = { id: string; title: string; answer: string; uses: { tool: string; brief: string; output?: string; isError?: boolean }[] }
22
23export type Worker = {
24 id: string
25 agentId?: string
26 type: string
27 description: string
28 asked?: string
29 route?: string
30 model: string
31 parentModel: string
32 isBackground: boolean
33 status: 'running' | 'done' | 'aborted' | 'denied'
34 startedAt: number
35 durationMs?: number
36 tokensOut?: number
37 tools: number
38 words?: string[]
39 escalated?: string
40 prompt?: string
41 answer?: string
42}
43
44export type Validation = {
45 agentId: string
46 files: string[]
47 status: 'running' | 'ok' | 'fail' | 'stale'
48 summary: string
49 words?: string[]
50 tier?: 'opus' | 'sonnet' | 'haiku'
51}
52
53export type Tab = 'think' | 'changes' | 'workers' | 'memories' | 'styles'
54
55export type Change = { path: string; code: string; staged: boolean; unstaged: boolean; add: number; del: number }
56
57export type Repo = { top: string; branch: string; loaded: boolean }
58
59export type GitNote = { ok: boolean; text: string }
60
61export type Diff = { path: string; text: string }
62
63// cost: USD at each step's real model; opusCost: the same tokens priced on Opus.
64export type Totals = { steps: number; tokensIn: number; tokensOut: number; cacheRead: number; cost?: number; opusCost?: number }
65
66// A task that failed on a cheap model (FAIL verdict or ESCALATE): its words and the tier it failed on.
67export type Miss = { words: string[]; tier: 'opus' | 'sonnet' | 'haiku'; at: number }
68
69export type PlanStep = { id: string; title: string; tier: 'haiku' | 'sonnet' | 'opus'; after: string[]; status: 'todo' | 'run' | 'done' | 'skip' }
70
71// The plan published by the model through the `plan` tool: how it understood the request and how it splits it.
72export type Plan = {
73 goal: string
74 approach: string
75 clarity: number
76 assumptions: string[]
77 questions: { ask: string; why: string; options: string[]; fallback: string }[]
78 betterPrompt: string
79 steps: PlanStep[]
80 savings: string[]
81 revision: number
82 at: number
83}
84
85// The answer to a plan question, keyed by the question text: draft, on its way to the model, read.
86export type Answer = { text: string; state: 'draft' | 'pending' | 'sent' }
87
88
89// A memory from ~/.claude/projects/<slug>/memory, with [[name]] links to others.
90export type Memory = { name: string; file: string; type: string; description: string; links: string[]; indexed: boolean; hook: string; size: number; mtimeMs: number }
91
92// How it was used in this session: read (Read/Bash), written (Write/Edit), recalled (engine attachment).
93export type MemoryUse = { read: number; write: number; recalled: number }
94
95// What enters the context from memory (from /context) and the directory read.
96export type MemoryMeta = {
97 dir: string
98 loaded: boolean
99 context: { path: string; type: string; tokens: number }[]
100 remember: { name: string; size: number }[]
101 recalls: { type: string; names: string[] }[]
102}
103
104export type MemoryView = { name: string; body: string }
105
106// Output styles: Markdown files in <config>/output-styles/. The draft is the one being edited, line by line.
107export type Style = { file: string; name: string; description: string; keep: boolean; body: string }
108export type StyleDraft = { file: string; description: string; lines: string[]; dirty: boolean }
109
110// Turn retrospective: where cheaper delegation was possible. saving = USD estimate; wasted = cost lost on an escalation.
111export type RetroFinding = { kind: 'read' | 'edit' | 'worker' | 'escalated' | 'plan'; text: string; saving: number; wasted?: number }
112export type Retro = { turnId: string; at: number; steps: number; cost: number; cheapShare: number; saving: number; findings: RetroFinding[] }
113
114declare module 'claude-code' {
115 interface PluginState {
116 'agent-lens': {
117 steps: Step[]
118 workers: Worker[]
119 skillsUsed: Record<string, number>
120 toolCounts: Record<string, number>
121 prompt: string
122 tab: Tab
123 fold: Record<string, boolean>
124 retro: Retro | null
125 routing: boolean
126 edited: string[]
127 validations: Validation[]
128 totals: Totals
129 turnTier: { tier: 'opus' | 'sonnet' | 'haiku'; why: string }
130 hasGraph: boolean
131 graphFirst: boolean
132 graphSeen: boolean
133 changes: Change[]
134 repo: Repo
135 commitMsg: string
136 gitNote: GitNote | null
137 diff: Diff | null
138 drafting: boolean
139 syncing: boolean
140 stepView: StepView | null
141 plan: Plan | null
142 answers: Record<string, Answer>
143 busy: boolean
144 memories: Memory[]
145 memMeta: MemoryMeta
146 memUse: Record<string, MemoryUse>
147 memView: MemoryView | null
148 memType: string
149 styles: Style[]
150 styleActive: string
151 styleDraft: StyleDraft | null
152 styleFresh: string
153 styleNote: string
154 styleLoaded: boolean
155 misses: Miss[]
156 planFirst: boolean
157 planAsked: boolean
158 turnAt: number
159 }
160 }
161}
162