SLOPSHOPPER

agent-lens

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

newpaneguardcommandtoastprompt
v0.1.1no licenseupdated 2026-10-14ionutm87/agent-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-lens
│ ┃ Agent Lens ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ AGENT LENS ○ idle ⏺ Read(src/auth.ts) │ ┃ │ model opus-5-5 ⎿ Read 6 lines │ ┃ │ routing opus (default) · [haiku]/[sonne…r: ⏺ Update(src/auth.ts) │ ┃ │ miniplan first in Flow: what I understa…n: ⎿ Added 2 lines, removed 1 line │ ┃ │ turn: 0 steps · 0↑ 0↓ · cache 0% ⏺ Read(/work/app/src/auth.ts) │ ┃ │ turn cost $0.000 · session $0.000 / all… ⎿ Denied by agent-lens: Publish the miniplan with mcp__agen │ ┃ │ context 49% 97k/200k 5h 31% · NaNm │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ [ Flow ] [ Git 0 ] [ Workers 0 ] [ Memories ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ ▸ fix the failing auth test and add an audi… › /lens │ ┃ ⎿ agent-lens: Agent Lens opened. │ ┃ No plan yet for this request. │ ┃ │ ┃ ▾ EXECUTION 0 steps 0↑ 0↓ │ ┃ No steps yet. Send a prompt. │ ┃ │ ┃ ▸ TOOLS IN SESSION │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agent Lens
╭─────────────────────────────────────────────────────────── │ ◆ AGENT LENS ○ idle │ model opus-5-5 │ routing opus (default) · [haiku]/[sonnet] in the promptr: │ miniplan first in Flow: what I understand, how I actn: [of │ turn: 0 steps · 0↑ 0↓ · cache 0% │ turn cost $0.000 · session $0.000 / all on opus $0.000 … │ context 49% 97k/200k 5h 31% · NaNm ╰─────────────────────────────────────────────────────────── [ Flow ] [ Git 0 ] [ Workers 0 ] [ Memories ] [ Style ] ▸ fix the failing auth test and add an audit log call No plan yet for this request. ▾ EXECUTION 0 steps 0↑ 0↓ No steps yet. Send a prompt. ▸ TOOLS IN SESSION
Source 11 files
hooks/register.tsx 1498 lines
1import { 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 lines
1// 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, '')
93
hooks/plan.ts 125 lines
1import 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') : ''
125
hooks/memories-tab.tsx 166 lines
1import 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}
166
hooks/workers-tab.tsx 166 lines
1import 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}
166
hooks/styles-tab.tsx 89 lines
1import 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}
89
hooks/styles.ts 42 lines
1// 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}
42
hooks/retro.ts 132 lines
1import 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    : ''
132
hooks/memories.ts 58 lines
1// 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}
58
hooks/router.ts 91 lines
1import 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}
91
types/index.d.ts 162 lines
1export 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