SLOPSHOPPER

goal-board

Each session's goal, task progress and ETA above the prompt, and a /goals board of every running session on this machine

newpanebandguardcommandprompt
v0.1.0MITupdated 2026-10-11Spendry/claude-code-mods/goal-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goal-board
│ ┃ Goals ✕ › fix the failing auth test and add an audit log call │ ┃ ▸ app: fix the failing auth test and add an │ ┃ audit log… ⏺ Read(src/auth.ts) │ ┃ ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 0/0 · ○ idle just now ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /goal │ ⎿ goal-board: Goal: fix the failing auth test and add an audit log │ │ ◎ fix the failing auth test and add … ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◎ fix the failing auth test and add … ⟨Claude Code's own drawing⟩
Pane · Goals
▸ app: fix the failing auth test and add an audit log… ▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 0/0 · ○ idle just now
README

Claude Code Mods

Part of my portfolio.

Mods for Claude Code that change what it does and what it shows. Each one is a hooks module: it runs inside Claude Code, hooks prompts, tool calls and turns, and draws a line above the prompt or a pane beside the transcript. The five here are deviation-kernel, cache-meter, goal-board, file-tree and idea-agents. All five ship with tests that run without a live session.

ModWhat it doesCosts anything?
deviation-kernelKeeps the assumptions your project refuses in Claude's system prompt on every request, and catches an edit that brings one back at the step where it lands.One small Haiku call after each long answer (can be switched off)
cache-meterShows how long the prompt cache stays warm, what a miss would cost to rewrite, how full the context is, and the session's cost at API rates.No
goal-boardShows each session's goal, task progress and ETA, and /goals lists every running session on the machine.No
file-treeShows the working directory as a file tree in a pane on the right, with folders you open and close and the files Claude just changed marked.No
idea-agentsGives a project six exploration agents (three that explore ideas, a lazy "trogg" for each) and one ideas/ folder per run, and tells Claude how to run a round.Only when you run a round: the agents' own model use (the troggs run on Haiku)

Install any one of them on its own.

Set it up with Claude

Paste this into Claude Code, in a terminal session:

Set up mods from the GitHub repo Spendry/claude-code-mods for me. Work through these steps and stop where a step says to ask me.

1. Run `claude --version`. The mods need 2.1.287 or later; if it's older, tell me how to update and stop.
2. Show me the table of mods from the repo README (https://github.com/Spendry/claude-code-mods) and ask which ones I want. Don't pick for me.
3. Clone https://github.com/Spendry/claude-code-mods into a temporary folder. For each mod I chose, run `claude plugin validate <that folder>/<mod>`, read its `hooks/register.ts`, and tell me in plain words what it can touch (the `calls:` line lists it). Ask me whether to go ahead.
4. Run `claude plugin marketplace add Spendry/claude-code-mods`, then install each mod I approved with `claude plugin install <mod>@claude-code-mods --scope user`. A note that options are not yet set is expected; the defaults apply until I change them. Delete the temporary clone.
5. Run `claude plugin list` and confirm each one shows as enabled.
6. If I chose deviation-kernel: ask which project it's for, and interview me there for the assumptions that project refuses on purpose. Do not suggest refusals of your own; the point of the kernel is that they come from me. For each one I give you, help me word it, propose tells (words or /regex/ that would show up when it's broken) and an "instead:", then show me the KERNEL.md and write it to the project root only when I say so. Then ask which mode I want (ask, warn or block), explain the difference in one line each, and tell me to type `/kernel mode <mode>` in that project's session to set it.
7. If I chose cache-meter: ask whether I pay API rates or use a subscription, and explain that on a subscription its dollar figures are the API-equivalent, not my bill.
8. Finish with a short list of what I still have to do myself (from the "What only you can do" section of the README).

Claude runs the install. It can't decide your project's refusals or see the bands for you; that part stays yours.

What only you can do

  • Pick the mods. Each one changes how sessions behave. Read its section below before turning it on.
  • Decide what your project refuses. deviation-kernel is only as good as its KERNEL.md. Claude can help you word a refusal and suggest tells, but which assumptions to refuse is your design decision. Start with two or three. You can add more later with /kernel add.
  • Exempt your idea notes. Notes that weigh a refused assumption have to name it. List their folders under ## Exempt in KERNEL.md (for example - ideas/**) so the guard skips them; code everywhere else stays checked.
  • Check the tells after a few days. A tell that fires on harmless text wastes your attention. A refusal with no tell at all goes unguarded at the edit step. /kernel log shows what tripped and what you decided.
  • Look at it in a terminal. The bands and panes draw in a terminal session and in the Claude desktop app's Code tab. In the VS Code extension's chat panel the hooks still run (the kernel still guards edits) but nothing draws above the prompt, and /goals and /cache answer in text instead.
  • Keep cache-meter's prices current. They are typed into cache-meter/hooks/register.ts with the date they were checked. A model not in the table is priced at the first row's rates and shown with ≈.

Install by hand

At the prompt of a Claude Code terminal session, one line per mod:

/plugin install deviation-kernel --marketplace Spendry/claude-code-mods
/plugin install cache-meter --marketplace Spendry/claude-code-mods
/plugin install goal-board --marketplace Spendry/claude-code-mods
/plugin install file-tree --marketplace Spendry/claude-code-mods
/plugin install idea-agents --marketplace Spendry/claude-code-mods

Answer y to add the marketplace, then pick the user scope so the mod loads in every session. A mod with options shows a screen to set them. /plugin lists what's active.

To turn every mod off for one session: claude --safe-mode. To update: claude plugin update <mod>@claude-code-mods, then /reload-plugins.

Built and tested against Claude Code 2.1.296; needs 2.1.287 or later.


deviation-kernel

What it guarantees: the project's refused assumptions are in Claude's system prompt on every request, with nothing to paste and nothing lost to compaction. An edit that brings one back is caught at that step.

The problem it answers: a design that refuses a standard assumption tends to lose that refusal while the ordinary parts get filled in. Claude reaches for the conventional component in each slot, and the conventional component carries the assumption the design rejected.

Setup. Put a KERNEL.md at the project root (or .claude/KERNEL.md). /kernel init writes a template; KERNEL.example.md shows a filled-in one. Each refusal is one line:

- R1: one global clock paces every stage | tells: global clock, clock tree, /clk_?en/ | instead: each stage hands off by local request/acknowledge

In a folder with no kernel, the first prompt you type there asks whether Claude should help you write one: now, after that message, not this session, or never in that folder. Mid-project, Claude reads the project's own record first so its questions point at what you set out to do, then asks you for the refusals (it proposes none of its own) and writes KERNEL.md only when you say so. Your home folder is never asked. Turn the offer off with the offerKernel option.

What happens.

  • The kernel goes into the system prompt, along with instructions to check each filler against each refusal and to take the conventional answer wherever the kernel isn't touched.
  • When an Edit or Write brings in a tell that wasn't already in the text it replaces, the mod acts by mode:
  • ask (default): holds the edit and asks you. Refusing sends Claude the refusal and its instead:.
  • warn: lets it through and flags it to Claude with your next prompt.
  • block: refuses the edit outright.
  • After each answer over 300 characters, a Haiku call reads it against the kernel for drift the tells miss, such as a standard component that carries a refused assumption without naming it. Hits show in the band and go to Claude with your next prompt. Press drop in the band if a flag is wrong. Turn this off with the semanticCheck option if you don't want the extra calls.

Commands. /kernel opens the kernel and recent trips · /kernel init · /kernel add <text> | tells: a, b | instead: ... · /kernel mode ask|warn|block · /kernel check (a full audit of the conversation so far, served mostly from cache) · /kernel log · /kernel reload.

Limits. Tells are matched as whole words or regexes, so a tell also fires on a line that rejects the assumption ("no central scheduler"). The semantic check tells the two apart; the keyword check does not. The kernel file is never edited by Claude unless you ask.

cache-meter

What it guarantees: you can see how long the cached prefix has left, what a miss would cost to rewrite, context fill and session cost, without asking.

cache ▰▰▰▰▱▱▱▱ 2:41 · miss $0.50 · ctx 84k/200k 42% · session $4.10 API
  • A toast one minute before the cache goes cold while you're idle.
  • /cache gives the full breakdown, including the hit and miss price of the next request.
  • Cache lifetime. The cacheTtl option defaults to auto. It assumes the 5-minute cache until a request reads from cache after more than five idle minutes, which only the 1-hour cache can serve. It then switches to 1h, toasts once, and remembers that for later sessions. The rewrite price changes with it (1.25x input for 5m, 2x for 1h). Set 5m or 1h to fix it.
  • Prices are API list prices in a table at the top of hooks/register.ts. On a subscription the dollar figures are what the same work would cost at API rates, not what you're billed.

goal-board

What it guarantees: each session shows its goal, task progress and ETA, and one command shows every running session on this machine.

  • The goal is your first prompt (its first line, shortened) until you set one with /goal <text>. /goal clear resets it.
  • Progress comes from Claude's own task tools (TaskCreate and TaskUpdate, or TodoWrite on older builds).
  • ETA = average time per finished task × tasks left. It appears once the first task completes.
  • /goals opens the board of all sessions: working, idle or done, the current task, and the ETA.
  • Sessions share one store on your machine, and each writes only its own record. A session that ends with all its tasks done stays on the board as done. Any other session drops off when it ends. Every record is cleared after 12 hours idle. Nothing leaves your machine.

file-tree

What it guarantees: you can see the project's files while Claude works on them, and which ones it has changed this session.

╭──────────────────────────────────────────╮
│ work     ‹ up  home  refresh  collapse   │
│ ──────────────────────────────────────── │
│ ├─ ▸ notes                               │
│ └─ ▾ relay · here                        │
│    ├─ ▾ src                              │
│    │  ├─ ● main.ts                       │
│    │  └─   util.ts                       │
│    └─   README.md                        │
│                                          │
│ 1 changed · 1 up from relay              │
╰──────────────────────────────────────────╯
  • The pane opens beside the transcript when the session starts, in the fullscreen layout on a terminal 144 columns or wider. Below that, or on the main screen, /files opens it (above the prompt on the main screen). /files again closes it.
  • Click a folder, or Tab to it and press Enter, to open or close it. With the pane focused, r refreshes and c collapses every folder.
  • ‹ up (or u) moves the tree's root up one folder per press, like cd .., until it reaches the drive or filesystem root. The working folder stays open under its parent and is marked · here. home (or h) puts the root back on the working folder. Only the view moves; Claude's working directory stays where it was.
  • It's kept dark so it stays behind the conversation: a muted orange rounded border on a darker background, every name dimmed, and only changed files in (dim) green.
  • Options. background is the hex color painted behind the tree; the default #0f0f0f is half of Windows Terminal's Dark+ background. Set it to half of your own terminal's background, or leave it empty to keep the terminal's. coverFrame (on by default) draws the orange border over Claude Code's own square pane frame, so the side facing the conversation is orange; Claude Code still shows its thin grey line along the top. Turn it off to draw the border just inside the frame instead.
  • Long names and paths are cut to fit the pane with …: a file or folder name keeps its start, the path in the footer keeps its end.
  • A file Claude wrote or edited this session shows a green ●. A folder holding one has its name in green, so a change inside a closed folder still shows.
  • The tree refreshes after every Write, Edit, NotebookEdit, Bash or PowerShell call. A change made outside Claude shows on the next refresh.
  • The duel. At the bottom of the pane, above the footer, two small stick figures fence while Claude is working and meditate while it waits. The animation is a set of short clips (approach, lunge, exchange, feint, a win for either side; breathing and an aura while seated; disengage, bow and kneel, and rise between the two). Each clip ends on the resting frame of its stance, so the next one, picked at random from those that start there, joins on without a jump. It repaints only its own cells four times a second and hides when the pane is too narrow. Turn it off with the duel option.
  • .git, node_modules, __pycache__, .venv, venv and .next are left out. A tree is cut at 1,500 rows; close a folder to see the rest.

idea-agents

What it guarantees: a project can get a second set of eyes on a stuck problem, from agents that propose and size ideas but never touch its code, with every run kept in its own folder you can read later.

  • The six. premise-breaker lists the assumptions under a problem and flips each one. field-crosser brings mechanisms from other fields, at least half outside computing. idea-sizer checks the ideas against numbers the project already measured and gives each a kill test. Each has a lazy helper on Haiku, a "trogg": Skip asks "why do it at all?", Borrow "what already does this?", Poke "what's the dumbest quick try?". The upper agents read their trogg's note and say in one line what they took.
  • The offer. On your first typed prompt in a project without them, it asks once whether to install them into .claude/agents/. It never overwrites an agent the project already has. Then it asks whether Claude should tailor them to the project (its building blocks, gates, results files); Claude shows each change and writes it only when you say so. Home folders and folders you said never for are skipped.
  • One folder per run. /ideas new <topic> makes ideas/<date>-<nn>-<topic>/ (or under research/ideas/ if the project keeps research apart, or .claude/agents/ideas/ if that folder exists, to keep runs beside the agents) with a run card (README.md: question, read order, outcome, decisions) and 0-data.md to fill. The agents write 1-premises.md, 2-fields.md, 3-sizing.md and their lazy notes next to it; the main session writes 4-result.md.
  • The guide. Where the agents are installed, Claude carries a short guide for running a round: who runs when, that the main session runs the troggs (subagents can't start subagents), that a gate's bar never moves, and that held-out data is scored once.
  • /ideas shows what's installed and the latest run. /ideas install adds missing agents. /ideas tailor starts the tailoring.
  • New agent files load as agent types at the next session start. Until then Claude runs them through the general-purpose agent.

Working on the mods

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.ts (or .tsx for a mod that draws a pane) and tests/. The marketplace file at .claude-plugin/marketplace.json lists them.

  • claude plugin validate <mod folder> reports what the mod hooks and calls, and anything the engine would refuse.
  • claude plugin test <mod folder> runs its tests without a session.
  • To work on a clone, add the clone as a marketplace (claude plugin marketplace add <path to clone>) and install from it. A folder marketplace is read from the folder itself, so after an edit /reload-plugins picks up the change with no reinstall.

A new mod is a new folder plus one entry in marketplace.json.

License

MIT. See LICENSE.

Source 1 files
hooks/register.ts 302 lines
1import type { Register } from 'claude-code'
2
3// One record per session, in the store every session on this machine shares.
4// Each session writes only its own key, so sessions never overwrite each other.
5type Task = { id: string; subject: string; status: 'pending' | 'in_progress' | 'completed'; doneAt?: number }
6type Board = {
7  id: string
8  project: string
9  goal: string
10  isGoalAuto: boolean
11  tasks: Task[]
12  firstTaskAt?: number
13  startedAt: number
14  updatedAt: number
15  isWorking: boolean
16  isEnded?: boolean
17}
18
19const PANE = 'goal-board'
20const STALE_MS = 12 * 60 * 60_000
21
22let me: Board | null = null
23let others = 0
24
25function keyOf(id: string) {
26  return `board:${id}`
27}
28
29function bar(done: number, total: number, width = 10): string {
30  if (!total) return '▱'.repeat(width)
31  const full = Math.round((done / total) * width)
32  return '▰'.repeat(full) + '▱'.repeat(width - full)
33}
34
35function ago(ms: number): string {
36  const m = Math.round(ms / 60_000)
37  if (m < 1) return 'just now'
38  if (m < 60) return `${m}m ago`
39  return `${Math.round(m / 60)}h ago`
40}
41
42function eta(b: Board, now: number): string {
43  const done = b.tasks.filter(t => t.status === 'completed').length
44  const left = b.tasks.length - done
45  if (!left) return b.tasks.length ? 'done' : ''
46  if (!done || b.firstTaskAt === undefined) return ''
47  const per = (now - b.firstTaskAt) / done
48  const m = Math.max(1, Math.round((per * left) / 60_000))
49  return m >= 60 ? `~${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')} left` : `~${m}m left`
50}
51
52function current(b: Board): string {
53  return b.tasks.find(t => t.status === 'in_progress')?.subject ?? ''
54}
55
56function clip(text: string, n: number): string {
57  const one = text.replace(/\s+/g, ' ').trim()
58  return one.length > n ? `${one.slice(0, n - 1)}…` : one
59}
60
61async function save($: any) {
62  if (!me) return
63  me.updatedAt = await $.clock.now()
64  await $.store.set(keyOf(me.id), me)
65  $.ui.invalidate('ui.render')
66}
67
68async function readAll($: any): Promise<Board[]> {
69  const now = await $.clock.now()
70  const keys = ((await $.store.keys()) as string[]).filter(k => k.startsWith('board:'))
71  const boards: Board[] = []
72  for (const k of keys) {
73    const b = (await $.store.get(k)) as Board | undefined
74    if (!b) continue
75    if (now - b.updatedAt > STALE_MS) {
76      await $.store.delete(k)
77      continue
78    }
79    boards.push(b)
80  }
81  return boards.sort((a, b) => b.updatedAt - a.updatedAt)
82}
83
84async function fresh($: any): Promise<Board> {
85  const id = await $.session.id()
86  const cwd = (await $.session.cwd()) as string
87  const now = await $.clock.now()
88  return { id, project: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd, goal: '', isGoalAuto: true, tasks: [], startedAt: now, updatedAt: now, isWorking: false }
89}
90
91async function countOthers($: any) {
92  if (!me) return
93  const all = await readAll($)
94  others = all.filter(b => b.id !== me!.id && !b.isEnded && (b.tasks.length || b.goal)).length
95}
96
97export const register: Register = on => {
98  on('session.start', async ($, e, next) => {
99    const id = await $.session.id()
100    const stored = (await $.store.get(keyOf(id))) as Board | undefined
101    // values from the store may be frozen; work on a copy
102    me = stored ? (JSON.parse(JSON.stringify(stored)) as Board) : await fresh($)
103    await countOthers($)
104    // Keep the "+N sessions" count and the open board current.
105    $.clock.every(5000, async () => {
106      await countOthers($)
107      $.ui.invalidate('ui.render')
108    })
109    try {
110      await $.command.register({ name: 'goal', description: "Set or clear this session's goal", argumentHint: '[text | clear]', immediate: true })
111      await $.command.register({ name: 'goals', description: 'Board of every running session: goal, progress, ETA', immediate: true })
112    } catch {
113      // a name is taken
114    }
115    return next(e)
116  })
117
118  // /clear starts new work: start a new record for it
119  on('classic.SessionStart', { source: ['clear'] }, async ($, e, next) => {
120    me = await fresh($)
121    await save($)
122    return next(e)
123  })
124
125  // A session that finished its tasks stays on the board as done until the 12-hour cleanup;
126  // one that ends with work left over drops off, since nothing will move it forward.
127  on('session.end', async ($, e, next) => {
128    if (me) {
129      const isDone = me.tasks.length > 0 && me.tasks.every(t => t.status === 'completed')
130      if (isDone) {
131        me.isWorking = false
132        me.isEnded = true
133        await save($)
134      } else {
135        await $.store.delete(keyOf(me.id))
136      }
137    }
138    return next(e)
139  })
140
141  // The first prompt becomes the goal until you set one with /goal.
142  on('prompt.submit', async ($, e, next) => {
143    if (me && !me.goal && e.text.trim() && !e.text.trim().startsWith('/')) {
144      me.goal = clip(e.text.split('\n')[0] ?? '', 70)
145      me.isGoalAuto = true
146      await save($)
147    }
148    return next(e)
149  })
150
151  on('turn.start', async ($, e, next) => {
152    if (me && !me.isWorking) {
153      me.isWorking = true
154      await save($)
155    }
156    return next(e)
157  })
158
159  on('turn.complete', async ($, e, next) => {
160    if (me && !e.agentId) {
161      me.isWorking = false
162      await save($)
163    }
164    return next(e)
165  })
166
167  // ---------- tasks, from Claude's own task tools ----------
168
169  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
170    const ran = await next(e)
171    if (!me || ran.deny !== undefined || ran.isError) return ran
172    const created = (ran.result as { task?: { id?: string } } | undefined)?.task
173    const now = await $.clock.now()
174    me.tasks.push({ id: created?.id ?? `t${me.tasks.length + 1}`, subject: e.subject, status: 'pending' })
175    me.firstTaskAt ??= now
176    await save($)
177    return ran
178  })
179
180  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
181    const ran = await next(e)
182    if (!me || ran.deny !== undefined || ran.isError) return ran
183    const t = me.tasks.find(x => x.id === e.taskId)
184    if (t) {
185      if (e.subject) t.subject = e.subject
186      if (e.status === 'deleted') me.tasks = me.tasks.filter(x => x !== t)
187      else if (e.status) {
188        if (e.status === 'completed' && t.status !== 'completed') t.doneAt = await $.clock.now()
189        t.status = e.status
190      }
191      await save($)
192    }
193    return ran
194  })
195
196  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
197    const ran = await next(e)
198    if (!me || ran.deny !== undefined || ran.isError) return ran
199    const now = await $.clock.now()
200    const before = new Map(me.tasks.map(t => [t.subject, t]))
201    me.tasks = e.todos.map((todo, i) => {
202      const old = before.get(todo.content)
203      const doneAt = todo.status === 'completed' ? (old?.doneAt ?? now) : undefined
204      return { id: `todo${i}`, subject: todo.content, status: todo.status, ...(doneAt ? { doneAt } : {}) }
205    })
206    if (me.tasks.length) me.firstTaskAt ??= now
207    await save($)
208    return ran
209  })
210
211  // ---------- commands ----------
212
213  on('command.run', { command: 'goal' }, async ($, e) => {
214    if (!me) return { text: 'Goal board is not ready yet.' }
215    const text = (e.args ?? '').trim()
216    if (!text) return { text: me.goal ? `Goal: ${me.goal}${me.isGoalAuto ? ' (from your first prompt; /goal <text> to set it)' : ''}` : 'No goal yet. /goal <text> sets one.' }
217    if (text === 'clear') {
218      me.goal = ''
219      me.isGoalAuto = true
220    } else {
221      me.goal = clip(text, 90)
222      me.isGoalAuto = false
223    }
224    await save($)
225    return {}
226  })
227
228  on('command.run', { command: 'goals' }, async ($) => {
229    const placed = await $.ui.open({ id: PANE, title: 'Goals', focus: true, closeOnEscape: true })
230    if (placed.isPlaced) return {}
231    // No pane on this surface (the VS Code panel, claude -p): answer in text instead.
232    const now = await $.clock.now()
233    const all = await readAll($)
234    if (!all.length) return { text: 'No sessions with goals or tasks right now.' }
235    return {
236      text: all
237        .map(b => {
238          const done = b.tasks.filter(t => t.status === 'completed').length
239          return `${b.project}: ${b.goal || '(no goal)'}\n  ${bar(done, b.tasks.length)} ${done}/${b.tasks.length} ${eta(b, now)} · ${b.isEnded ? `ended ${ago(now - b.updatedAt)}` : b.isWorking ? 'working' : `idle ${ago(now - b.updatedAt)}`}${current(b) ? ` · now: ${current(b)}` : ''}`
240        })
241        .join('\n'),
242    }
243  })
244
245  // ---------- drawing ----------
246
247  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
248    if (!me || e.props.hasSurvey || (!me.goal && !me.tasks.length)) return next(e)
249    const { Box, Text } = $.ui.resolve(e)
250    const now = await $.clock.now()
251    const done = me.tasks.filter(t => t.status === 'completed').length
252    const total = me.tasks.length
253    const width = Math.max(20, e.props.bodyColumns)
254
255    const parts: any[] = [Text({ bold: true, children: [`◎ ${clip(me.goal || '(no goal set)', Math.floor(width * 0.35))}`] })]
256    if (total) {
257      parts.push(Text({ color: done === total ? 'green' : 'cyan', children: [`  ${bar(done, total)} ${done}/${total}`] }))
258      const left = eta(me, now)
259      if (left) parts.push(Text({ dimColor: true, children: [` · ${left}`] }))
260      const now2 = current(me)
261      if (now2) parts.push(Text({ dimColor: true, children: [` · now: ${clip(now2, Math.floor(width * 0.25))}`] }))
262    }
263    if (others) parts.push(Text({ dimColor: true, children: [` · +${others} session${others > 1 ? 's' : ''} (/goals)`] }))
264
265    const theirs = await next(e)
266    return Box({ flexDirection: 'column', children: [Box({ flexDirection: 'row', children: parts }), ...(theirs ? [theirs] : [])] })
267  })
268
269  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
270    const { Box, Text } = $.ui.resolve(e)
271    const now = await $.clock.now()
272    const all = await readAll($)
273    const width = Math.max(30, e.props.bodyColumns)
274    if (!all.length) return Text({ dimColor: true, children: ['No sessions with goals or tasks right now.'] })
275
276    const rows = all.map(b => {
277      const done = b.tasks.filter(t => t.status === 'completed').length
278      const isMe = me && b.id === me.id
279      const state = b.tasks.length && done === b.tasks.length ? (b.isEnded ? `✓ done, ended ${ago(now - b.updatedAt)}` : '✓ done') : b.isWorking ? '● working' : `○ idle ${ago(now - b.updatedAt)}`
280      const stateColor = state.startsWith('✓') ? 'green' : b.isWorking ? 'cyan' : 'gray'
281      return Box({
282        key: b.id,
283        flexDirection: 'column',
284        marginBottom: 1,
285        children: [
286          Text({ bold: true, children: [`${isMe ? '▸ ' : '  '}${b.project}: ${clip(b.goal || '(no goal)', width - b.project.length - 6)}`] }),
287          Box({
288            flexDirection: 'row',
289            children: [
290              Text({ color: 'cyan', children: [`  ${bar(done, b.tasks.length, 16)} ${done}/${b.tasks.length}`] }),
291              Text({ dimColor: true, children: [eta(b, now) ? ` · ${eta(b, now)}` : ''] }),
292              Text({ color: stateColor, children: [` · ${state}`] }),
293            ],
294          }),
295          ...(current(b) ? [Text({ dimColor: true, children: [`  now: ${clip(current(b), width - 8)}`] })] : []),
296        ],
297      })
298    })
299    return Box({ flexDirection: 'column', children: rows })
300  })
301}
302