SLOPSHOPPER

agent-dashboard

One Dashboard pane showing usage limits and context bars, the task list, test runs, running sessions and subagents (/dashboard brings it to the front)

paneguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ Usage │ ┃ 5-hour [###-------] 31% ⏺ Read(src/auth.ts) │ ┃ Context [#####-----] 49% (this session, 97k ⎿ Read 6 lines │ ┃ tokens) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Tasks ⏺ Bash(bun test) │ ┃ No tasks in this session yet. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Tests ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No test runs in this session yet. │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Sessions (0) │ ┃ None running. › /dashboard │ ┃ ⎿ agent-dashboard: Dashboard opened. │ ┃ Subagents here (0 running) │ ┃ None yet. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
Usage 5-hour [###-------] 31% Context [#####-----] 49% (this session, 97k tokens) Tasks No tasks in this session yet. Tests No test runs in this session yet. Sessions (0) None running. Subagents here (0 running) None yet.
README

claude-mods

A small collection of Claude Code mods (plugins that hook into the session). Install only the ones you want.

ModWhat it does
agent-dashboardOne Dashboard pane with usage and context bars, tasks, tests, sessions and subagents
auto-handover/handover writes a handover note and starts (or proposes) a fresh session
auto-compactCompacts past 250k tokens and saves the full transcript before every compaction
build-trimmerTrims long xcodebuild / swift build output so it does not fill your context
prompt-upgrade/improve rewrites a rough prompt into a clear one
keep-goingStops Claude ending a turn on a vague "want me to…?": it keeps working or asks with options
memdbA searchable memory database Claude can query and add to
cache-taxKeeps the prompt cache warm during breaks and stops a cold send once with its price (third-party, MIT)
reflect-modTurns your corrections into CLAUDE.md rules, saved with one press (third-party, MIT)

Requirements

  • A Claude Code version that supports mods. Tested on 2.1.299.
  • Python 3.8 or newer for memdb (it also needs SQLite with FTS5, which most Python builds have) and for build-trimmer's xctrim.py.
  • build-trimmer is for Apple development (Xcode / SwiftPM). agent-dashboard and auto-handover work best in the desktop app, where panes and new sessions are available.

Set up a new machine

To get every mod and the settings this repo is used with (auto-compact at 200k, the built-in auto-compact at the same size), clone it and run the setup script:

git clone https://github.com/flyingpurplepizzaeater/claude-mods.git
cd claude-mods && ./setup.sh

It copies each mod to ~/.claude/mods/<mod> and adds three keys to the env of ~/.claude/settings.json: CLAUDE_CODE_PLUGIN_DIRS (the mods, kept alongside any plugin folders already listed), AUTO_COMPACT_THRESHOLD and CLAUDE_CODE_AUTO_COMPACT_WINDOW. Nothing else in the file changes, and the old one is kept as settings.json.bak. Name mods to install only those (./setup.sh memdb cache-tax), set AUTO_COMPACT_THRESHOLD=300k before it for another size, and run it again after git pull to update. New sessions load the mods. It needs rsync and python3, and follows CLAUDE_CONFIG_DIR if you set it.

Use either this or the marketplace install below for a mod, not both, or its hooks run twice.

Install

At the prompt of a terminal session (swap memdb for any mod name above):

/plugin install memdb --marketplace flyingpurplepizzaeater/claude-mods

Claude asks Add marketplace?: answer y, then pick a scope (user scope is the first one). If the mod has options, a screen lets you set them. You then see Installed memdb. Plugin is now active., and its hooks run from then on, with no reload needed.

From a shell:

claude plugin install memdb --marketplace flyingpurplepizzaeater/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with flyingpurplepizzaeater/claude-mods, then Add plugin.

If you have already added the marketplace, claude plugin install <mod>@claude-mods is enough.

The mods

agent-dashboard

Opens one Dashboard pane at session start and stacks everything in it:

  • Usage: your 5-hour and weekly limits with reset times, plus a context bar for the current session (yellow past 150k tokens, red past 400k, with a toast the first time).
  • Tasks: follows the session's task list (TodoWrite, TaskCreate or the desktop app's plan tool).
  • Tests: the last test runs (xcodebuild, swift, pytest, npm, go, cargo and similar), including background runs, with pass/fail.
  • Sessions: every Claude Code session running on the machine.
  • Subagents: running and recent subagents of this session.

/dashboard brings the pane to the front. The pane refreshes every 5 seconds.

Options (all can be changed later in /config):

OptionDefaultMeaning
planNoteonTell Claude to write long or multi-part requests into its task list first
queueMessagesonA message sent while Claude is working goes to the end of its task list instead of derailing it. Start the message with !now to act on it immediately
buildReminderoffAfter a long build or test run in the foreground, remind Claude to use the background next time

auto-handover

/handover asks Claude to write a short handover note (goal, state, done, next steps, key files, gotchas) to ~/.claude/handovers/ and then starts a fresh session that continues from it. In the desktop app it starts the session directly (or proposes it as a card); elsewhere it leaves the "continue" prompt in the prompt box for you to send after /clear.

/handover                    write the note and hand over now
/handover status             show whether auto mode is on, the threshold and this session's size
/handover on | off           automatic handover past a context threshold (off by default)
/handover threshold 200k     set the threshold (at least 1k; never above 80% of the window)

The threshold can also be set with the environment variable AUTO_HANDOVER_THRESHOLD or per project in .claude/auto-handover.json ({ "threshold": "120k" }). Notes go to $CLAUDE_CONFIG_DIR/handovers if that variable is set. No plugin options.

auto-compact

After each answer, if the context is past 250k tokens, it compacts the conversation, telling the summary to keep the goal, decisions, files changed and next steps. Before every compaction (your /compact, the built-in auto-compact, or its own) it saves the full conversation as markdown to ~/.claude/compact-saves/: every message and each tool call with its result, long ones clipped. Common secrets (API keys, tokens, JWTs, private keys, password: …) are redacted, and the folder is owner-only (700, files 600). A failed save never blocks the compaction. With memdb installed, the saves become searchable there.

/autocompact status            on or off, the threshold, this session's size and the last save
/autocompact on | off          automatic compaction (on by default; saves happen either way)
/autocompact threshold 250k    set the threshold (at least 10k)
/autocompact now               save and compact now

The threshold can also be set with the environment variable AUTO_COMPACT_THRESHOLD. Saves go to $CLAUDE_CONFIG_DIR/compact-saves if that variable is set. If the model's context window is no larger than the threshold, the built-in auto-compact fires first (the transcript is still saved). No plugin options.

Its own compaction runs between turns, so after one it sends a short prompt telling the session to carry on where it left off (or say in one line that it was done or waiting for you); /autocompact now doesn't. Long autonomous runs are smoother if the built-in auto-compact, which compacts mid-turn and keeps going, fires at the same size: set CLAUDE_CODE_AUTO_COMPACT_WINDOW to the threshold plus 13000 (e.g. 213000 for 200k). The mod then stays as a backup and still saves the transcript before every compaction.

build-trimmer

Wraps xcodebuild, swift build and swift test commands that Claude runs in Bash. The full output goes to a log file and Claude sees only the errors, failing tests, the summary and the tail, plus the log path. The exit code is preserved. Logs are in ~/.claude/build-trimmer/logs and are deleted after 7 days. Add # no-trim to a command to leave it alone.

Options:

OptionDefaultMeaning
developerDirempty (off)DEVELOPER_DIR to build with, e.g. /Applications/Xcode-beta.app/Contents/Developer
developerDirPathMatchemptyUse developerDir only when the command or working folder contains this text, e.g. /MyApp. Empty means every build
extraBuildScriptsemptyComma-separated script names that run xcodebuild or swift test themselves, e.g. test.sh,ci.sh

A command that already sets DEVELOPER_DIR is never changed. If python3 is missing the full log is shown instead.

prompt-upgrade

/improve <rough prompt> rewrites a rough prompt into a clear, specific one, using what the session already knows about your project, and puts it in the prompt box so you can review and edit it. Run /improve send to submit the last one. It keeps your intent and does not add goals you did not ask for. No options.

keep-going

When Claude ends a turn by handing the next move back in plain text ("Want me to…?", "Should I…?", "Let me know if…"), the mod blocks that stop once and tells Claude to either keep working, routing around anything blocked, or ask properly with AskUserQuestion (2–4 options, recommendation first). A turn that already asked with AskUserQuestion, plan approval or the desktop app's wrap-up buttons is let through, and so is the second stop in a row, so it never loops. Safety confirmations (push, send, delete) still get asked, as options. The phrases it looks for are in hooks/check.ts. No options.

memdb

A searchable memory database (SQLite full-text search, ranked and stemmed) for Claude. It indexes your Claude memory files (~/.claude/projects/*/memory/*.md) automatically, and also holds:

  • notes: small facts kept only in the database (memdb_add)
  • docs: long markdown files such as plans or old handover notes, split by heading (memdb_index)
  • transcripts: if you use auto-compact, each conversation it saves to ~/.claude/compact-saves/ is indexed automatically, one entry per exchange (your prompt and the work that answered it), titled with the date and the prompt's first line and filed under the folder the session ran in. Search ranks memories and notes above docs and docs above transcripts, shows at most two old conversations by default (the best exchange of each) and says how many more matched; search with kind transcript for all of them. Deleting a save removes it; the database file is owner-only (600).

Claude gets these tools: memdb_search, memdb_add, memdb_get, memdb_index and memdb_forget, and a short note in the system prompt telling it to search before rereading long notes. You get /memdb <words> to search, plus /memdb stats, /memdb list, /memdb get <name> and /memdb sync.

If Python is missing, older than 3.8, or lacks FTS5, the tools answer with a clear message instead of failing.

Options:

OptionDefaultMeaning
pythonpython3Python command to run the script
dbPathemptyDatabase file. Empty means ~/.claude/memdb/memory.db (or under $CLAUDE_CONFIG_DIR)
promptNoteonAdd the short "memdb is available" note to the system prompt

The script also works on its own, with no Claude involved. After installing, find it under the plugin folder (or clone this repo):

python3 plugins/memdb/bin/memdb.py --help
python3 plugins/memdb/bin/memdb.py search "database port"
python3 plugins/memdb/bin/memdb.py add staging-port "It is 5433" -d "staging DB port"

MEMDB_PATH overrides the database location.

cache-tax

cache-tax by Karan Bansal (MIT), copied here at upstream commit 432a08d (2.3.0) so it installs with the rest. /keepwarm keeps the one-hour prompt cache warm through a break by sending a tiny ping 50 minutes after the last request; /keepwarm always does that for every session, for up to six hours after each turn. When the cache has gone cold, it stops the first send once and shows what the rewrite will cost. /cache-tax shows the cache state and prices. See its README.

One change from upstream: after a compaction in the middle of a turn (the built-in auto-compact), it starts pinging again as soon as the next request comes back, rather than when the turn ends. Without it, a long autonomous turn that compacted and then sat at a permission prompt or a question went cold. Two tests at the end of tests/register.test.ts cover it.

reflect-mod

reflect-mod from claude-reflect by Bayram Annakov (MIT), copied unchanged at upstream commit b6c4232 (3.3.0). After a short prompt that corrects Claude, a model check decides whether it is a reusable rule; if so, a band above the prompt offers to save it to CLAUDE.md with one press. See its README.

Change options, update, uninstall

  • Change options: /config in a terminal session (each mod's options are rows there), or reinstall and answer the options screen.
  • Update: claude plugin update <mod> (or claude plugin marketplace update claude-mods first).
  • Turn off without removing: claude plugin disable <mod>; turn back on with claude plugin enable <mod>.
  • Uninstall: claude plugin uninstall <mod>. To drop the marketplace too: claude plugin marketplace remove claude-mods.
  • memdb keeps its database in ~/.claude/memdb/; delete that folder if you want the data gone. auto-handover notes stay in ~/.claude/handovers/, auto-compact saves in ~/.claude/compact-saves/ and build-trimmer logs in ~/.claude/build-trimmer/logs/.

Develop

Each mod is a folder under plugins/. Check one with:

claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
claude plugin validate .        # the marketplace file

MIT licensed, see LICENSE.

Source 5 files
hooks/register.tsx 341 lines
1import { atom, read, update } from 'claude-code'
2import type { Engine, Register } from 'claude-code'
3
4import type { SessionRow, SubagentRow, TestRun, UsageRow } from '../types'
5import { nextTodos } from './tasks'
6import { BUILD_NOTE, POLL_LIMIT, POLL_MS, TEST_COMMAND, isLongForeground, judge, testLabel } from './tests'
7import { CONTEXT_ADVICE, contextLevel } from './context'
8
9// Kept in this file: the engine follows $ only into functions declared in the same module.
10async function findTaskOutput($: Engine, taskId: string): Promise<string | undefined> {
11  const sid = await $.session.id()
12  const uid = String((await $.process.run(['/usr/bin/id', '-u']).catch(() => undefined))?.stdout ?? '').trim()
13  if (!/^\d+$/.test(uid)) return undefined
14  for (const root of [`/private/tmp/claude-${uid}`, `/tmp/claude-${uid}`]) {
15    const dirs = await $.fs.list(root).catch(() => [])
16    for (const dir of dirs) {
17      const path = `${root}/${dir.name}/${sid}/tasks/${taskId}.output`
18      if (dir.kind === 'dir' && (await $.fs.exists(path))) return path
19    }
20  }
21  return undefined
22}
23
24// Every running Claude Code process writes a record in <config dir>/sessions (pid, name, status, cwd).
25// The config dir is CLAUDE_CONFIG_DIR when set, else ~/.claude.
26async function locations($: Engine) {
27  const home = ((await $.env.get('HOME').catch(() => undefined)) ?? '').replace(/\/+$/, '')
28  const configDir = ((await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)) ?? '').replace(/\/+$/, '') || `${home}/.claude`
29  return { home, sessionsDir: `${configDir}/sessions` }
30}
31// The desktop app shows one pane at a time, so everything lives in this one pane, stacked.
32const PANE = 'agent-dashboard'
33const TITLE = 'Dashboard'
34const REFRESH_MS = 5000
35
36// Every value the pane draws, kept in this one plugin's state.
37const sessions = atom({ plugin: 'agent-dashboard', key: 'sessions' } as const, [])
38const subagents = atom({ plugin: 'agent-dashboard', key: 'subagents' } as const, [])
39const usage = atom({ plugin: 'agent-dashboard', key: 'usage' } as const, { limits: [] } as UsageRow)
40const todos = atom({ plugin: 'agent-dashboard', key: 'todos' } as const, [])
41const testLast = atom({ plugin: 'agent-dashboard', key: 'testLast' } as const, null)
42const testHistory = atom({ plugin: 'agent-dashboard', key: 'testHistory' } as const, [])
43// The highest context threshold already toasted this session (0 none, 1 = 150k, 2 = 400k).
44const contextWarned = atom({ plugin: 'agent-dashboard', key: 'contextWarned' } as const, 0)
45
46// Mid-turn requests: what the model is told when one arrives while it works.
47const QUEUE_NOTE = `[dashboard] The user sent the message above while you were in the middle of a task.
48- If it corrects, redirects or stops what you are doing right now, act on it now.
49- Otherwise it is a new request: do NOT switch to it yet. Add it as a new pending step at the END of your task list (update_plan, TodoWrite or TaskCreate, whichever this session uses), reply with one short line saying it is queued, finish the current step and the steps before it, then do it in order.`
50// New requests: plan multi-part or long work in the task list before starting, as Cowork does.
51const PLAN_NOTE = `[dashboard] If this request contains several tasks, or is long or complicated (more than about three steps), first write its steps to your task list (update_plan, TodoWrite or TaskCreate, whichever this session uses), one step per task, then work through them in order and keep the list current as each finishes. A simple one-step request needs no list.`
52
53// Who counts as the person typing: the composer, the desktop app (sdk), or Remote Control.
54const PERSON = new Set(['composer', 'sdk', 'bridge'])
55const NOW_PREFIX = /^!now\b\s*/i
56
57const LIMIT_LABELS: Record<string, string> = { five_hour: '5-hour', seven_day: 'Weekly' }
58
59// '[#####-----] 48%' at ten cells.
60const bar = (pct: number) => {
61  const filled = Math.max(0, Math.min(10, Math.round(pct / 10)))
62  return `[${'#'.repeat(filled)}${'-'.repeat(10 - filled)}] ${Math.round(pct)}%`
63}
64
65const resetIn = (now: number, at?: string) => {
66  if (!at) return ''
67  const mins = Math.round((Date.parse(at) - now) / 60000)
68  if (!Number.isFinite(mins) || mins <= 0) return ''
69  return mins < 60 ? ` · resets in ${mins}m` : mins < 48 * 60 ? ` · resets in ${Math.floor(mins / 60)}h ${mins % 60}m` : ` · resets in ${Math.round(mins / 1440)}d`
70}
71
72const ago = (now: number, then: number) => {
73  const s = Math.max(0, Math.round((now - then) / 1000))
74  if (s < 60) return `${s}s`
75  if (s < 3600) return `${Math.round(s / 60)}m`
76  return `${Math.round(s / 3600)}h`
77}
78
79const clock = (at: number) => new Date(at).toTimeString().slice(0, 5)
80const mark = (v: TestRun['verdict']) => (v === 'passed' ? '✓' : v === 'failed' ? '✗' : v === 'running' ? '…' : '?')
81
82async function refresh($: Engine) {
83  const u = await $.session.usage().catch(() => undefined)
84  if (u) {
85    await update($, usage, () => ({
86      limits: u.rateLimits.map(l => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })),
87      contextPercent: u.context.percent,
88      contextTokens: u.context.tokens,
89    }))
90    const level = contextLevel(u.context.tokens)
91    if (level > (await read($, contextWarned))) {
92      await update($, contextWarned, () => level)
93      $.ui.toast(`Context past ${level === 2 ? '400k' : '150k'} tokens: ${CONTEXT_ADVICE}`)
94    }
95  }
96  const selfId = await $.session.id()
97  const { home, sessionsDir } = await locations($)
98  const entries = await $.fs.list(sessionsDir)
99  const rows: SessionRow[] = []
100  for (const entry of entries) {
101    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
102    try {
103      const rec = JSON.parse(await $.fs.read(`${sessionsDir}/${entry.name}`))
104      rows.push({
105        pid: rec.pid,
106        name: rec.name ?? rec.sessionId ?? `pid ${rec.pid}`,
107        status: rec.status ?? 'unknown',
108        cwd: home ? String(rec.cwd ?? '').replace(home, '~') : String(rec.cwd ?? ''),
109        sinceMs: rec.statusUpdatedAt ?? rec.updatedAt ?? rec.startedAt ?? 0,
110        isSelf: rec.sessionId === selfId,
111      })
112    } catch {
113      // A record being rewritten mid-read; the next refresh picks it up.
114    }
115  }
116  // Records can outlive a crashed process: keep only pids that are still running.
117  if (rows.length > 0) {
118    const ps = await $.process.run(['/bin/ps', '-o', 'pid=', '-p', rows.map(r => r.pid).join(',')])
119    const alive = new Set(String(ps.stdout ?? '').split(/\s+/).filter(Boolean).map(Number))
120    await update($, sessions, () => rows.filter(r => alive.has(r.pid)))
121  } else {
122    await update($, sessions, () => [])
123  }
124}
125
126async function recordTest($: Engine, run: TestRun, toast: boolean) {
127  await update($, testLast, () => run)
128  if (run.verdict !== 'running') await update($, testHistory, list => [...list, run].slice(-8))
129  $.ui.status(`tests ${mark(run.verdict)} ${run.summary}`)
130  if (toast && run.verdict === 'failed') $.ui.toast(`Tests failed: ${run.summary} (${run.command})`)
131}
132
133export const register: Register = (on, options) => {
134  // Behaviour switches (plugin.json userConfig): the two prompt notes default on, the build reminder off.
135  const planNote = options.planNote !== false
136  const queueMessages = options.queueMessages !== false
137  const buildReminder = options.buildReminder === true
138
139  on('session.start', async ($, e, next) => {
140    // Open first: session start has a short budget, and the refresh (files, ps, usage) can outlast it.
141    void $.ui.open({ id: PANE, title: TITLE })
142    $.clock.after(0, () => {
143      void refresh($).catch(() => undefined)
144    })
145    $.clock.every(REFRESH_MS, () => {
146      void refresh($).catch(() => undefined)
147    })
148    // /agents is Claude Code's own command, so this is /dashboard. A refused name must never block the pane.
149    await $.command
150      .register({ name: 'dashboard', description: 'Show the dashboard: usage, tasks, tests, sessions and subagents' })
151      .catch(() => undefined)
152
153    return next(e)
154  })
155
156  on('command.run', { command: 'dashboard' }, async $ => {
157    await refresh($).catch(() => undefined)
158    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
159
160    return { text: opened.isPlaced ? 'Dashboard opened.' : `Dashboard waiting: ${opened.reason}` }
161  }).catch(($, e, next) => next(e))
162
163  // Subagents: one row per Agent call, running until it returns.
164  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
165    const row: SubagentRow = {
166      id: e.tool_use_id,
167      description: e.description ?? 'subagent',
168      type: e.subagent_type ?? 'general-purpose',
169      state: 'running',
170      startedAt: await $.clock.now(),
171    }
172    await update($, subagents, list => [...list, row].slice(-30))
173    const ran = await next(e)
174    const endedAt = await $.clock.now()
175    await update($, subagents, list =>
176      ran.deny !== undefined
177        ? list.filter(one => one.id !== row.id)
178        : list.map(one =>
179            one.id === row.id ? { ...one, state: ran.isError ? 'failed' : 'done', endedAt } : one,
180          ),
181    )
182
183    return ran
184  }).catch(($, e, next) => next(e)) // never block a subagent over a dashboard glitch
185
186  // Tests: verdict of any test command, following background runs to their end.
187  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
188    const ran = await next(e)
189    if (ran.deny !== undefined || !TEST_COMMAND.test(e.command)) return ran
190
191    const command = testLabel(e.command)
192    const result = ran.result
193    if (result.backgroundTaskId) {
194      await recordTest($, { command, verdict: 'running', summary: 'running', at: await $.clock.now() }, false)
195      const path = await findTaskOutput($, result.backgroundTaskId)
196      if (path === undefined) return ran
197      let polls = 0
198      const timer = $.clock.every(POLL_MS, () => {
199        void (async () => {
200          polls += 1
201          const verdict = judge(await $.fs.read(path))
202          if (verdict || polls > POLL_LIMIT) {
203            timer.cancel()
204            await recordTest($, { command, verdict: verdict?.verdict ?? 'unknown', summary: verdict?.summary ?? 'no result found', at: await $.clock.now() }, true)
205          }
206        })().catch(() => undefined)
207      })
208      return ran
209    }
210
211    const out = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
212    const verdict = judge(out) ?? (ran.isError ? { verdict: 'failed' as const, summary: 'exited with an error' } : { verdict: 'unknown' as const, summary: 'no result found' })
213    await recordTest($, { command, verdict: verdict.verdict, summary: verdict.summary, at: await $.clock.now() }, true)
214
215    return ran
216  }).catch(($, e, next) => next(e)) // never block a command over the dashboard
217
218  // Optional (buildReminder): long builds and tests run in the foreground get a reminder to use the background; the command itself is untouched.
219  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
220    const ran = await next(e)
221    if (!buildReminder || ran.deny !== undefined || !isLongForeground(e.command, e.run_in_background)) return ran
222
223    return { ...ran, context: [...(ran.context ?? []), BUILD_NOTE] }
224  }).catch(($, e, next) => next(e)) // never block a command over a reminder
225
226  // Tasks: follow whichever task-list tool the session uses.
227  on('tool.call', async ($, e, next) => {
228    const ran = await next(e)
229    if (ran.deny !== undefined || ran.isError) return ran
230    const args = e as unknown as Record<string, unknown>
231    const list = nextTodos(await read($, todos), e.tool, args, ran.result)
232    if (list !== undefined) await update($, todos, () => list)
233
234    return ran
235  }).catch(($, e, next) => next(e)) // never block a tool over the dashboard
236
237  // New requests get the plan-first note; requests typed while a turn runs go to the end of the task list instead of derailing it.
238  on('prompt.submit', async ($, e, next) => {
239    const isPerson = PERSON.has(e.origin.kind) && !e.command && !e.isMeta && e.text.trim() !== ''
240    if (!isPerson) return next(e)
241    if (e.turnId === undefined) return planNote ? next({ ...e, context: [...(e.context ?? []), PLAN_NOTE] }) : next(e)
242    if (!queueMessages) return next(e)
243    if (NOW_PREFIX.test(e.text)) return next({ ...e, text: e.text.replace(NOW_PREFIX, '') })
244
245    const firstLine = e.text.trim().split('\n')[0].slice(0, 80)
246    await update($, todos, list => [...list, { id: `q${list.length}-${firstLine.length}`, text: `⏳ ${firstLine}`, status: 'pending' }])
247    $.ui.toast(`Queued after the current task: ${firstLine.slice(0, 50)}`)
248
249    return next({ ...e, context: [...(e.context ?? []), QUEUE_NOTE] })
250  }).catch(($, e, next) => next(e)) // never lose a message over the queue
251
252  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
253    const { Box, Text } = $.ui.resolve(e)
254    const now = await $.clock.now()
255    const use = await read($, usage)
256    const level = contextLevel(use.contextTokens)
257    const tasks = await read($, todos)
258    const done = tasks.filter(t => t.status === 'completed').length
259    const current = await read($, testLast)
260    const runs = [...(await read($, testHistory))].reverse().slice(0, 3)
261    const ss = await read($, sessions)
262    const subs = await read($, subagents)
263    const running = subs.filter(s => s.state === 'running')
264    const recent = subs.filter(s => s.state !== 'running').slice(-3)
265
266    return (
267      <Box flexDirection="column">
268        <Text bold>Usage</Text>
269        {use.limits.length === 0 && <Text dimColor>No limit readings yet (they arrive with the next reply).</Text>}
270        {use.limits.map(l => (
271          <Text key={`u${l.kind}`} color={l.percentUsed >= 90 ? 'red' : l.percentUsed >= 75 ? 'yellow' : undefined}>
272            {(LIMIT_LABELS[l.kind] ?? l.kind).padEnd(8)} {bar(l.percentUsed)}
273            {resetIn(now, l.resetsAt)}
274          </Text>
275        ))}
276        {use.contextPercent !== undefined && (
277          <Text dimColor>
278            {'Context'.padEnd(8)} {bar(use.contextPercent)} (this session{use.contextTokens !== undefined ? `, ${Math.round(use.contextTokens / 1000)}k tokens` : ''})
279          </Text>
280        )}
281        {level > 0 && (
282          <Text color={level === 2 ? 'red' : 'yellow'} bold>
283            ⚠ Context past {level === 2 ? '400k' : '150k'} tokens: {CONTEXT_ADVICE}
284          </Text>
285        )}
286
287        <Text> </Text>
288        <Text bold>Tasks{tasks.length > 0 ? ` (${done}/${tasks.length} done)` : ''}</Text>
289        {tasks.length === 0 && <Text dimColor>No tasks in this session yet.</Text>}
290        {tasks.map(t => (
291          <Text
292            key={`t${t.id}`}
293            color={t.status === 'in_progress' ? 'yellow' : undefined}
294            dimColor={t.status === 'completed'}
295            strikethrough={t.status === 'completed'}
296          >
297            {t.status === 'completed' ? '☑' : t.status === 'in_progress' ? '▶' : '☐'} {t.text}
298          </Text>
299        ))}
300
301        <Text> </Text>
302        <Text bold>Tests</Text>
303        {current === null && <Text dimColor>No test runs in this session yet.</Text>}
304        {current?.verdict === 'running' && (
305          <Text color="yellow">… running since {clock(current.at)}  {current.command}</Text>
306        )}
307        {runs.map((run, i) => (
308          <Text key={`r${run.at}-${i}`} color={run.verdict === 'passed' ? 'green' : run.verdict === 'failed' ? 'red' : 'yellow'} dimColor={i > 0}>
309            {mark(run.verdict)} {run.summary.padEnd(14)} {clock(run.at)}  {run.command}
310          </Text>
311        ))}
312
313        <Text> </Text>
314        <Text bold>Sessions ({ss.length})</Text>
315        {ss.length === 0 && <Text dimColor>None running.</Text>}
316        {ss.map(s => (
317          <Text key={`s${s.pid}`} color={s.status === 'busy' ? 'green' : undefined} dimColor={s.status !== 'busy'}>
318            {s.status === 'busy' ? '● ' : '○ '}
319            {s.name}
320            {s.isSelf ? ' (this)' : ''} · {s.status} {ago(now, s.sinceMs)} · {s.cwd}
321          </Text>
322        ))}
323
324        <Text> </Text>
325        <Text bold>Subagents here ({running.length} running)</Text>
326        {subs.length === 0 && <Text dimColor>None yet.</Text>}
327        {running.map(s => (
328          <Text key={s.id} color="yellow">
329            ▶ {s.description} · {s.type} · {ago(now, s.startedAt)}
330          </Text>
331        ))}
332        {recent.map(s => (
333          <Text key={s.id} dimColor color={s.state === 'failed' ? 'red' : undefined}>
334            {s.state === 'failed' ? '✗' : '✓'} {s.description} · {s.type} · took {ago(s.endedAt ?? now, s.startedAt)}
335          </Text>
336        ))}
337      </Box>
338    )
339  })
340}
341
hooks/tasks.ts 64 lines
1import type { Todo, TodoStatus } from '../types'
2
3// The desktop app's task list tool; TodoWrite and TaskCreate/TaskUpdate are the terminal's.
4export const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
5// Ending the turn checks off the step still in progress; no update_plan call does it.
6export const WRAP_UP_TOOL = 'mcp__ccd_turn__wrap_up'
7
8export const asStatus = (s: unknown): TodoStatus =>
9  s === 'in_progress' || s === 'completed' ? s : 'pending'
10
11// update_plan's step-wise forms: advance, done: [n], start: n (1-based).
12export function applyPlan(list: Todo[], args: Record<string, unknown>): Todo[] {
13  if (Array.isArray(args.items)) {
14    return args.items.map((item: any, i: number) => ({
15      id: `p${i}`,
16      text: String(item?.content ?? ''),
17      status: asStatus(item?.status),
18    }))
19  }
20  let next = list.map(t => ({ ...t }))
21  if (args.advance === true) {
22    const cur = next.findIndex(t => t.status === 'in_progress')
23    if (cur >= 0) next[cur].status = 'completed'
24    const pending = next.findIndex((t, i) => i > cur && t.status === 'pending')
25    if (pending >= 0) next[pending].status = 'in_progress'
26  }
27  if (Array.isArray(args.done)) {
28    for (const n of args.done) if (next[Number(n) - 1]) next[Number(n) - 1].status = 'completed'
29  }
30  if (typeof args.start === 'number' && next[args.start - 1]) {
31    next = next.map(t => (t.status === 'in_progress' ? { ...t, status: 'pending' } : t))
32    next[args.start - 1].status = 'in_progress'
33  }
34  return next
35}
36
37// The task list after one tool call, or undefined when the call doesn't touch it.
38export function nextTodos(list: Todo[], tool: string, args: Record<string, unknown>, result: unknown): Todo[] | undefined {
39  if (tool === PLAN_TOOL) return applyPlan(list, args)
40  if (tool === WRAP_UP_TOOL) {
41    return list.some(t => t.status === 'in_progress')
42      ? list.map(t => (t.status === 'in_progress' ? { ...t, status: 'completed' } : t))
43      : undefined
44  }
45  if (tool === 'TodoWrite' && Array.isArray(args.todos)) {
46    return (args.todos as any[]).map((t, i) => ({ id: `w${i}`, text: String(t?.content ?? ''), status: asStatus(t?.status) }))
47  }
48  if (tool === 'TaskCreate') {
49    const task = (result as any)?.task
50    return task?.id ? [...list, { id: String(task.id), text: String(task.subject ?? args.subject ?? ''), status: 'pending' }] : undefined
51  }
52  if (tool === 'TaskUpdate' && args.taskId !== undefined) {
53    const id = String(args.taskId)
54    return args.status === 'deleted'
55      ? list.filter(t => t.id !== id)
56      : list.map(t =>
57          t.id === id
58            ? { ...t, status: args.status ? asStatus(args.status) : t.status, text: args.subject ? String(args.subject) : t.text }
59            : t,
60        )
61  }
62  return undefined
63}
64
hooks/tests.ts 81 lines
1export const TEST_COMMAND =
2  /\bxcodebuild\b[^|;&]*\btest(-without-building)?\b|\bswift test\b|\bxcrun xctest\b|\bpytest\b|\bpython3? -m (pytest|unittest)\b|\b(npm|pnpm|yarn) (run )?test\b|\bgo test\b|\bcargo test\b|\bplugin test\b|\bpio test\b|\btests?\/run\.sh\b|\brun[-_]?tests?\b/
3// Background Bash output lands in <tmp root>/<project key>/<session id>/tasks/<task id>.output,
4// the tmp root being claude-<uid> under /private/tmp (macOS) or /tmp.
5export const POLL_MS = 15_000
6export const POLL_LIMIT = 4 * 60 * 4 // four hours of polls
7
8// Reads a test log's own verdict lines; undefined when the run has not finished.
9// The summary is always plain counts, "12 passed" or "2 of 14 failed", whatever the runner.
10export function judge(out: string): { verdict: 'passed' | 'failed'; summary: string } | undefined {
11  const last = (re: RegExp) => [...out.matchAll(re)].pop()
12  const n = (m: RegExpMatchArray | undefined, i: number) => (m?.[i] ? Number(m[i]) : 0)
13  const verdict = (passed: number, failed: number, failedAnyway = false) =>
14    failed > 0 || failedAnyway
15      ? { verdict: 'failed' as const, summary: failed > 0 ? `${failed} of ${passed + failed} failed` : 'failed' }
16      : { verdict: 'passed' as const, summary: `${passed} passed` }
17
18  // xcodebuild / XCTest
19  const xc = last(/Executed (\d+) tests?, with (\d+) failures?/g)
20  if (xc) return verdict(n(xc, 1) - n(xc, 2), n(xc, 2), /\*\* TEST (FAILED|EXECUTE FAILED|BUILD FAILED) \*\*/.test(out))
21  // swift-testing
22  const st = last(/Test run with (\d+) tests?(?: in \d+ suites?)? (passed|failed)(?: after [\d.]+ seconds with (\d+) issues?)?/g)
23  if (st) return st[2] === 'passed' ? verdict(n(st, 1), 0) : verdict(Math.max(0, n(st, 1) - n(st, 3)), n(st, 3) || 0, true)
24  // bun test / claude plugin test: " 13 pass" and " 0 fail" on lines of their own
25  const bunPass = last(/^\s*(\d+) pass\s*$/gm)
26  const bunFail = last(/^\s*(\d+) fail\s*$/gm)
27  if (bunPass || bunFail) return verdict(n(bunPass, 1), n(bunFail, 1))
28  // jest / vitest
29  const js = last(/^\s*Tests:?\s+(?:(\d+) failed[, |]+)?(?:(\d+) passed)?/gm)
30  if (js && (js[1] || js[2])) return verdict(n(js, 2), n(js, 1))
31  // cargo
32  const cargo = last(/test result: (ok|FAILED)\. (\d+) passed; (\d+) failed/g)
33  if (cargo) return verdict(n(cargo, 2), n(cargo, 3), cargo[1] === 'FAILED')
34  // pytest summary line
35  const py = last(/^=+ (.*\b(?:passed|failed|error)\b.*) =+$/gm)
36  if (py) {
37    const failed = n(py[1].match(/(\d+) failed/) ?? undefined, 1) + n(py[1].match(/(\d+) errors?/) ?? undefined, 1)
38    return verdict(n(py[1].match(/(\d+) passed/) ?? undefined, 1), failed)
39  }
40  // unittest
41  const ran = last(/^Ran (\d+) tests? in /gm)
42  if (ran) {
43    const bad = out.match(/^FAILED \((?:failures=(\d+))?(?:, )?(?:errors=(\d+))?/m)
44    if (bad) return verdict(n(ran, 1) - n(bad, 1) - n(bad, 2), n(bad, 1) + n(bad, 2), true)
45    if (/^OK\b/m.test(out)) return verdict(n(ran, 1), 0)
46  }
47  // go test
48  if (/^(--- FAIL|FAIL\s)/m.test(out)) return verdict(0, 0, true)
49  if (/^ok\s+\S+/m.test(out)) return { verdict: 'passed', summary: 'passed' }
50  if (/\*\* TEST (FAILED|EXECUTE FAILED|BUILD FAILED) \*\*/.test(out)) return verdict(0, 0, true)
51  if (/\*\* TEST (SUCCEEDED|EXECUTE SUCCEEDED) \*\*/.test(out)) return { verdict: 'passed', summary: 'passed' }
52  return undefined
53}
54
55// A short name for a test command: the test step itself, with paths cut to their last part,
56// redirects and output filters dropped, and the folder it ran in when it starts with cd.
57export function testLabel(command: string): string {
58  const steps = command.split(/\s*(?:&&|\|\||;|\n)\s*/)
59  const step = steps.find(s => TEST_COMMAND.test(s)) ?? steps[steps.length - 1] ?? command
60  const dir = steps.find(s => /^cd\s/.test(s))?.replace(/^cd\s+/, '').replace(/["']/g, '').replace(/\/+$/, '')
61  const short = step
62    .split('|')[0]
63    .replace(/\s+\d?>&?\s*\S+/g, '')
64    .replace(/^(\w+=\S*\s+)+/, '')
65    .replace(/(?:~|\.{0,2})?\/(?:[^\s/]+\/)+([^\s/]+)/g, '$1')
66    .replace(/\s+/g, ' ')
67    .trim()
68  const where = dir && step !== steps[0] ? ` in ${dir.split('/').pop()}` : ''
69  const label = `${short}${where}`
70  return label.length > 60 ? `${label.slice(0, 59)}…` : label
71}
72
73// Long builds: any xcodebuild that builds (not -version, -list, -showBuildSettings and the like), swift build/test, and every test command.
74const XCODEBUILD_QUERY = /\bxcodebuild\b[^|;&]*\s-(version|list|show\w*|help|usage|license|checkFirstLaunchStatus|runFirstLaunch)\b/
75export const LONG_BUILD = /\bxcodebuild\b|\bswift (build|test)\b/
76
77export const BUILD_NOTE = `[dashboard] That was a long build or test run in the foreground. Next time run long builds and tests in the background (Bash run_in_background: true) so the session stays responsive.`
78
79export const isLongForeground = (command: string, background?: boolean) =>
80  background !== true && ((LONG_BUILD.test(command) && !XCODEBUILD_QUERY.test(command)) || TEST_COMMAND.test(command))
81
hooks/context.ts 10 lines
1// Context size warnings: yellow past 150k tokens, red past 400k.
2export const CONTEXT_WARN = 150_000
3export const CONTEXT_ALARM = 400_000
4
5export const CONTEXT_ADVICE = 'consider /compact, or start a fresh session for a new task'
6
7// 0 under 150k (or unknown), 1 past 150k, 2 past 400k.
8export const contextLevel = (tokens?: number) =>
9  tokens === undefined ? 0 : tokens >= CONTEXT_ALARM ? 2 : tokens >= CONTEXT_WARN ? 1 : 0
10
types/index.d.ts 49 lines
1export type SessionRow = {
2  pid: number
3  name: string
4  status: string
5  cwd: string
6  sinceMs: number
7  isSelf: boolean
8}
9
10export type SubagentRow = {
11  id: string
12  description: string
13  type: string
14  state: 'running' | 'done' | 'failed'
15  startedAt: number
16  endedAt?: number
17}
18
19export type UsageRow = {
20  limits: { kind: string; percentUsed: number; resetsAt?: string }[]
21  contextPercent?: number
22  contextTokens?: number
23}
24
25export type TodoStatus = 'pending' | 'in_progress' | 'completed'
26
27export type Todo = { id: string; text: string; status: TodoStatus }
28
29export type TestRun = {
30  command: string
31  verdict: 'running' | 'passed' | 'failed' | 'unknown'
32  summary: string
33  at: number
34}
35
36declare module 'claude-code' {
37  interface PluginState {
38    'agent-dashboard': {
39      sessions: SessionRow[]
40      subagents: SubagentRow[]
41      usage: UsageRow
42      todos: Todo[]
43      testLast: TestRun | null
44      testHistory: TestRun[]
45      contextWarned: number
46    }
47  }
48}
49