/dash-it opens a widget dashboard of background shells, subagents, monitors and schedules the session runs out of sight.

Claude Code mod. /dash-it opens a live dashboard pane of background shells, monitors, subagents and schedules the session runs out of sight. /dash-it close closes it.
Widgets: timeline, background tasks, subagents, scheduled, activity. Add one by appending an entry to WIDGETS in hooks/dashboard.tsx.
/plugin install dash-it --marketplace mesolimbo/dash-it
Answer y to add the marketplace, then choose the user scope to load it in every session. Update with claude plugin update dash-it@dash-it.
claude plugin validate .
claude plugin test .hooks/register.tsx 292 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { dashboard } from './dashboard'
5import {
6 addTask,
7 clip,
8 endTask,
9 isAgentLive,
10 mergeSpawn,
11 pushFeed,
12 reconcileAgents,
13 reconcileTasks,
14 sameCrons,
15 scanTranscript,
16} from './model'
17import type { InFlight, Note } from './model'
18
19const PANE = 'dash-it'
20
21const tasks = atom({ plugin: 'dash-it', key: 'tasks' } as const, [])
22const agents = atom({ plugin: 'dash-it', key: 'agents' } as const, [])
23const crons = atom({ plugin: 'dash-it', key: 'crons' } as const, [])
24const feed = atom({ plugin: 'dash-it', key: 'feed' } as const, [])
25const collapsed = atom({ plugin: 'dash-it', key: 'collapsed' } as const, [])
26const now = atom({ plugin: 'dash-it', key: 'now' } as const, 0)
27const history = atom({ plugin: 'dash-it', key: 'history' } as const, [])
28const selected = atom({ plugin: 'dash-it', key: 'selected' } as const, '')
29const spawns = atom({ plugin: 'dash-it', key: 'spawns' } as const, {})
30const sync = atom({ plugin: 'dash-it', key: 'sync' } as const, { requestedAt: 0, doneAt: 0 })
31const SYNC_PROMPT = 'dash-it sync: reply with only "ok".'
32const SYNC_WAIT_MS = 30_000
33const TICK_MS = 250
34let ticks = 0
35
36async function note($: EngineInterface, notes: Note[]) {
37 if (notes.length === 0) return
38 const at = await $.clock.now()
39 await update($, feed, list => pushFeed(list, notes, at))
40}
41
42async function refresh($: EngineInterface) {
43 const at = await $.clock.now()
44 const prev = await read($, agents)
45 const [next, notes] = reconcileAgents(prev, await $.agent.list(), at)
46 if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, agents, () => next)
47 await note($, notes)
48
49 await touch($)
50}
51
52// Free: rebuilds what the conversation already shows and polls the agent list.
53async function backfill($: EngineInterface) {
54 const at = await $.clock.now()
55 const found = scanTranscript(await $.session.messages(), at)
56 await update($, tasks, list => found.tasks.reduce(addTask, list))
57 await update($, spawns, all =>
58 Object.entries(found.spawns).reduce(
59 (acc, [id, patch]) => (acc[id]?.prompt ? acc : mergeSpawn(acc, id, patch)),
60 all,
61 ),
62 )
63 await refresh($)
64}
65
66// Costs one short turn: its Stop event carries the engine's real in-flight list.
67async function requestSync($: EngineInterface) {
68 const at = await $.clock.now()
69 const cur = await read($, sync)
70 await backfill($)
71 const isWaiting = cur.requestedAt > cur.doneAt && at - cur.requestedAt < SYNC_WAIT_MS
72 if (isWaiting) return
73 await update($, sync, s => ({ ...s, requestedAt: at }))
74 void $.prompt.submit({ text: SYNC_PROMPT }).catch(() => undefined)
75}
76
77async function liveCount($: EngineInterface) {
78 const running = (await read($, tasks)).filter(t => t.status === 'running').length
79
80 return running + (await read($, agents)).filter(isAgentLive).length
81}
82
83// Moves the clock the pane animates from, only while something is running.
84async function touch($: EngineInterface) {
85 const wait = await read($, sync)
86 const isWaiting = wait.requestedAt > wait.doneAt
87 if ((await liveCount($)) === 0 && !isWaiting) return
88 const at = await $.clock.now()
89 await update($, now, () => at)
90}
91
92async function sample($: EngineInterface) {
93 const live = await liveCount($)
94 await update($, history, list =>
95 live === 0 && (list.at(-1) ?? 0) === 0 ? list : [...list, live].slice(-60),
96 )
97}
98
99async function tick($: EngineInterface, n: number) {
100 if (n % 4 === 0) await refresh($)
101 else await touch($)
102 if (n % 8 === 0) await sample($)
103}
104
105// Stop and SubagentStop carry the engine's own list of in-flight background work.
106async function syncInFlight(
107 $: EngineInterface,
108 inflight: InFlight[] | undefined,
109 schedules: { id: string; schedule: string; recurring: boolean; prompt: string }[] | undefined,
110) {
111 const at = await $.clock.now()
112 const before = await read($, tasks)
113 const after = reconcileTasks(before, inflight ?? [], at)
114 const started = after.filter(a => !before.some(b => b.id === a.id))
115 const ended = after.filter(
116 a => a.status === 'ended' && before.find(b => b.id === a.id)?.status === 'running',
117 )
118 await update($, tasks, () => after)
119 await note($, [
120 ...started.map(t => ({ text: `${t.kind} started: ${t.label}`, ref: `task:${t.id}` })),
121 ...ended.map(t => ({ text: `${t.kind} finished: ${t.label}`, ref: `task:${t.id}` })),
122 ])
123 if (schedules !== undefined && !sameCrons(await read($, crons), schedules)) {
124 await update($, crons, () => schedules)
125 }
126 await update($, now, () => at)
127}
128
129export const register: Register = on => {
130 on('session.start', async ($, e, next) => {
131 await $.command.register({
132 name: 'dash-it',
133 description: 'Open a dashboard of background shells, subagents and schedules',
134 })
135 $.clock.every(TICK_MS, () => {
136 ticks += 1
137 void tick($, ticks)
138 })
139
140 return next(e)
141 })
142
143 on('command.run', { command: 'dash-it' }, async ($, e) => {
144 if (e.args.trim() === 'close') {
145 await $.ui.close({ id: PANE })
146
147 return { text: 'Dashboard closed.' }
148 }
149 if (e.args.trim() === 'refresh') {
150 await requestSync($)
151
152 return { text: 'Refreshing: asked Claude for a short turn to sync background tasks.' }
153 }
154 await backfill($)
155 const opened = await $.ui.open({ id: PANE, title: 'Dashboard', rows: 40, columns: 84 })
156 if (!opened.isPlaced) return { text: `Dashboard is open but not drawn: ${opened.reason}` }
157 const where = e.presentation.isFullscreen && e.presentation.columns >= 110
158 ? 'docked to the right of the transcript'
159 : 'above the prompt (the docked layout needs fullscreen and 110+ columns)'
160
161 return { text: `Dashboard opened ${where}. Run /dash-it close to close it.` }
162 })
163
164 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
165 const ran = await next(e)
166 const out = ran.result as { backgroundTaskId?: string } | undefined
167 if (out?.backgroundTaskId) {
168 const at = await $.clock.now()
169 const label = e.description ?? e.command
170 await update($, tasks, list =>
171 addTask(list, {
172 id: out.backgroundTaskId as string,
173 kind: 'shell',
174 label,
175 detail: e.description ? e.command : undefined,
176 command: e.command,
177 description: e.description,
178 status: 'running',
179 startedAt: at,
180 }),
181 )
182 await note($, [{ text: `shell started: ${label}`, ref: `task:${out.backgroundTaskId}` }])
183 }
184
185 return ran
186 })
187
188 on('tool.call', { tool: 'Monitor' }, async ($, e, next) => {
189 const ran = await next(e)
190 const out = ran.result as { taskId?: string } | undefined
191 if (out?.taskId) {
192 const at = await $.clock.now()
193 await update($, tasks, list =>
194 addTask(list, {
195 id: out.taskId as string,
196 kind: 'monitor',
197 label: e.description,
198 detail: e.command,
199 command: e.command,
200 description: e.description,
201 status: 'running',
202 startedAt: at,
203 }),
204 )
205 await note($, [{ text: `monitor started: ${e.description}`, ref: `task:${out.taskId}` }])
206 }
207
208 return ran
209 })
210
211 on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
212 const ran = await next(e)
213 const id = e.task_id ?? e.shell_id
214 if (id !== undefined && ran.isError !== true && ran.deny === undefined) {
215 const at = await $.clock.now()
216 await update($, tasks, list => endTask(list, id, 'killed', at))
217 }
218
219 return ran
220 })
221
222 on('tool.call', async ($, e, next) => {
223 const id = e.agentId
224 if (id !== undefined) {
225 const tool = String(e.tool)
226 await update($, spawns, all => mergeSpawn(all, id, { lastTool: tool }, 1))
227 }
228
229 return next(e)
230 })
231
232 on('agent.spawn', async ($, e, next) => {
233 const out = await next(e)
234 const id = (out as { agentId?: string }).agentId
235 if (id !== undefined) {
236 const model = (out as { model?: string }).model
237 await update($, spawns, all =>
238 mergeSpawn(all, id, { prompt: clip(e.prompt, 6000), model, background: e.background }),
239 )
240 }
241
242 return out
243 })
244
245 on('classic.Stop', async ($, e, next) => {
246 await syncInFlight($, e.background_tasks, e.session_crons)
247 const at = await $.clock.now()
248 await update($, sync, s => ({ ...s, doneAt: at }))
249
250 return next(e)
251 })
252
253 on('classic.SubagentStop', async ($, e, next) => {
254 const report = e.last_assistant_message
255 if (report) {
256 await update($, spawns, all => mergeSpawn(all, e.agent_id, { result: clip(report, 6000) }))
257 }
258 await syncInFlight($, e.background_tasks, e.session_crons)
259 await refresh($)
260
261 return next(e)
262 })
263
264 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
265 const ui = $.ui.resolve(e)
266 const snapshot = {
267 tasks: await read($, tasks),
268 agents: await read($, agents),
269 crons: await read($, crons),
270 feed: await read($, feed),
271 collapsed: await read($, collapsed),
272 now: await read($, now),
273 history: await read($, history),
274 selected: await read($, selected),
275 spawns: await read($, spawns),
276 sync: await read($, sync),
277 width: e.props.bodyColumns,
278 }
279
280 const toggle = (id: string) =>
281 update($, collapsed, list =>
282 list.includes(id) ? list.filter(one => one !== id) : [...list, id],
283 )
284
285 const select = (ref: string) => update($, selected, () => ref)
286
287 const refreshNow = () => requestSync($)
288
289 return dashboard(ui, snapshot, { toggle, select, refresh: refreshNow })
290 })
291}
292hooks/dashboard.tsx 485 lines1import type { Elements } from 'claude-code'
2
3import type { DashAgent, DashAgentDetail, DashCron, DashFeedItem, DashTask } from '../types'
4import {
5 clip,
6 clockTime,
7 elapsed,
8 isAgentLive,
9 pad,
10 shimmer,
11 spark,
12 spinner,
13 taskCommand,
14 timelineBar,
15 truncate,
16} from './model'
17
18export type Snapshot = {
19 tasks: DashTask[]
20 agents: DashAgent[]
21 crons: DashCron[]
22 feed: DashFeedItem[]
23 collapsed: string[]
24 history: number[]
25 selected: string
26 sync: { requestedAt: number; doneAt: number }
27 spawns: Record<string, DashAgentDetail>
28 now: number
29 width: number
30}
31
32type Ui = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Code' | 'Markdown'>
33
34export type Handlers = {
35 toggle: (id: string) => void
36 select: (ref: string) => void
37 refresh: () => void
38}
39
40// A widget is one titled card: add an entry to WIDGETS and it shows up.
41// `count` is the badge on its header, `render` draws its body from the snapshot.
42export type Widget = {
43 id: string
44 title: string
45 accent: string
46 count: (s: Snapshot) => number
47 render: (s: Snapshot, ui: Ui, acts: Handlers) => JSX.Element
48}
49
50type Item = {
51 id: string
52 kind: string
53 name: string
54 status: string
55 startedAt: number
56 endedAt?: number
57 ref: string
58}
59
60const BAR = 8
61const TIME = 7
62
63const isLive = (status: string) => status === 'running' || isAgentLive({ status })
64const bad = (status: string) => status === 'killed' || status === 'failed'
65const tone = (status: string) => (isLive(status) ? 'success' : bad(status) ? 'error' : 'subtle')
66const icon = (status: string, at: number) => (isLive(status) ? spinner(at) : bad(status) ? '✗' : '✓')
67
68const items = (s: Snapshot): Item[] => [
69 ...s.tasks.map(t => ({
70 id: t.id,
71 kind: t.kind,
72 name: t.label,
73 status: t.status,
74 startedAt: t.startedAt,
75 endedAt: t.endedAt,
76 ref: `task:${t.id}`,
77 })),
78 ...s.agents.map(a => ({
79 id: a.id,
80 kind: 'agent',
81 name: a.name || a.type,
82 status: a.status,
83 startedAt: a.startedAt,
84 endedAt: a.endedAt,
85 ref: `agent:${a.id}`,
86 })),
87]
88
89const liveCount = (s: Snapshot) => items(s).filter(one => isLive(one.status)).length
90const inner = (s: Snapshot) => Math.max(36, s.width - 4)
91const empty = (ui: Ui, text: string) => <ui.Text dimColor>{text}</ui.Text>
92
93function workRow(
94 ui: Ui,
95 s: Snapshot,
96 acts: Handlers,
97 one: { id: string; status: string; startedAt: number; endedAt?: number },
98 title: string,
99 ref: string,
100) {
101 const { Box, Text, Button } = ui
102 const live = isLive(one.status)
103 const solid = '▰'.repeat(BAR)
104
105 return (
106 <Box flexDirection="row">
107 <Text color={tone(one.status)}>{icon(one.status, s.now)}</Text>
108 <Text> </Text>
109 <Button
110 plain
111 dimColor={!live}
112 key={`open-${ref}`}
113 label={pad(title, inner(s) - 4 - BAR - TIME)}
114 onPress={() => acts.select(ref)}
115 />
116 <Text> </Text>
117 <Text color={tone(one.status)} dimColor={!live}>
118 {live ? shimmer(s.now, BAR) : solid}
119 </Text>
120 <Text> </Text>
121 <Text dimColor>{elapsed((one.endedAt ?? s.now) - one.startedAt).padStart(TIME)}</Text>
122 </Box>
123 )
124}
125
126function timeline(s: Snapshot, ui: Ui, acts: Handlers) {
127 const { Box, Text, Button } = ui
128 const all = items(s)
129 if (all.length === 0) return empty(ui, 'Nothing has run yet. Launch a shell or subagent.')
130 const label = 16
131 const cells = Math.max(12, inner(s) - label - TIME - 4)
132 const earliest = Math.min(...all.map(one => one.startedAt))
133 const span = Math.min(900_000, Math.max(60_000, s.now - earliest))
134 const winStart = s.now - span
135 const shown = [...all]
136 .sort(
137 (a, b) =>
138 Number(isLive(b.status)) - Number(isLive(a.status)) || b.startedAt - a.startedAt,
139 )
140 .slice(0, 8)
141 const left = `-${elapsed(span)}`
142 const axis = `${left}${' '.repeat(Math.max(1, cells - left.length - 3))}now`
143
144 return (
145 <Box flexDirection="column">
146 <Text dimColor>{`${' '.repeat(label + 3)}${axis}`}</Text>
147 {shown.map(one => {
148 const live = isLive(one.status)
149 const bar = timelineBar(one.startedAt, one.endedAt ?? s.now, winStart, span, cells)
150
151 return (
152 <Box flexDirection="row">
153 <Text color={tone(one.status)}>{icon(one.status, s.now)}</Text>
154 <Text> </Text>
155 <Button
156 plain
157 key={`tl-${one.ref}`}
158 label={pad(one.name, label)}
159 onPress={() => acts.select(one.ref)}
160 />
161 <Text> </Text>
162 <Text dimColor>{'·'.repeat(bar.before)}</Text>
163 <Text color={tone(one.status)} dimColor={!live}>
164 {'━'.repeat(Math.max(0, bar.run - 1))}
165 {live ? '●' : '━'}
166 </Text>
167 <Text dimColor>{'·'.repeat(bar.after)}</Text>
168 <Text> </Text>
169 <Text dimColor>
170 {elapsed((one.endedAt ?? s.now) - one.startedAt).padStart(TIME - 1)}
171 </Text>
172 </Box>
173 )
174 })}
175 </Box>
176 )
177}
178
179const feedTone = (text: string) =>
180 /failed|killed/.test(text) ? 'error' : /started|resumed/.test(text) ? 'success' : 'subtle'
181
182export const WIDGETS: Widget[] = [
183 {
184 id: 'timeline',
185 title: 'Timeline',
186 accent: 'claude',
187 count: s => items(s).length,
188 render: timeline,
189 },
190 {
191 id: 'shells',
192 title: 'Background tasks',
193 accent: 'success',
194 count: s => s.tasks.filter(t => t.status === 'running').length,
195 render: (s, ui, acts) => (
196 <ui.Box flexDirection="column">
197 {s.tasks.length === 0 && empty(ui, 'No background shells or monitors.')}
198 {[...s.tasks]
199 .reverse()
200 .map(one =>
201 workRow(
202 ui,
203 s,
204 acts,
205 one,
206 `${one.kind}: ${one.label}${one.detail ? ` · ${one.detail}` : ''}`,
207 `task:${one.id}`,
208 ),
209 )}
210 </ui.Box>
211 ),
212 },
213 {
214 id: 'agents',
215 title: 'Subagents',
216 accent: 'permission',
217 count: s => s.agents.filter(isAgentLive).length,
218 render: (s, ui, acts) => (
219 <ui.Box flexDirection="column">
220 {s.agents.length === 0 && empty(ui, 'No subagents yet.')}
221 {[...s.agents]
222 .reverse()
223 .map(one =>
224 workRow(
225 ui,
226 s,
227 acts,
228 one,
229 `${one.name || one.type}: ${one.description}`,
230 `agent:${one.id}`,
231 ),
232 )}
233 </ui.Box>
234 ),
235 },
236 {
237 id: 'crons',
238 title: 'Scheduled',
239 accent: 'warning',
240 count: s => s.crons.length,
241 render: (s, ui) => (
242 <ui.Box flexDirection="column">
243 {s.crons.length === 0 && empty(ui, 'Nothing scheduled.')}
244 {s.crons.map(one => (
245 <ui.Box flexDirection="row" gap={1}>
246 <ui.Text color="warning">{one.recurring ? '↻' : '◷'}</ui.Text>
247 <ui.Text>{one.schedule}</ui.Text>
248 <ui.Text dimColor wrap="truncate-end">
249 {truncate(one.prompt, inner(s) - one.schedule.length - 4)}
250 </ui.Text>
251 </ui.Box>
252 ))}
253 </ui.Box>
254 ),
255 },
256 {
257 id: 'feed',
258 title: 'Activity',
259 accent: 'suggestion',
260 count: s => s.feed.length,
261 render: (s, ui, acts) => (
262 <ui.Box flexDirection="column">
263 {s.feed.length === 0 && empty(ui, 'Nothing yet.')}
264 {s.feed
265 .map((one, i) => ({ one, i }))
266 .reverse()
267 .slice(0, 10)
268 .map(({ one, i }) => {
269 const ref = one.ref
270 const text = truncate(one.text, inner(s) - 10)
271
272 return (
273 <ui.Box flexDirection="row" gap={1}>
274 <ui.Text dimColor>{clockTime(one.at)}</ui.Text>
275 {ref === undefined ? (
276 <ui.Text color={feedTone(one.text)}>{text}</ui.Text>
277 ) : (
278 <ui.Button
279 plain
280 key={`feed-${i}`}
281 label={text}
282 onPress={() => acts.select(ref)}
283 />
284 )}
285 </ui.Box>
286 )
287 })}
288 </ui.Box>
289 ),
290 },
291]
292
293function pill(ui: Ui, n: number, noun: string, color: string) {
294 return n > 0 ? (
295 <ui.Text inverse color={color}>{` ${n} ${noun} `}</ui.Text>
296 ) : (
297 <ui.Text dimColor>{` 0 ${noun} `}</ui.Text>
298 )
299}
300
301function header(s: Snapshot, ui: Ui, acts: Handlers) {
302 const { Box, Text, Button } = ui
303 const live = liveCount(s)
304 const shells = s.tasks.filter(t => t.status === 'running').length
305 const agents = s.agents.filter(isAgentLive).length
306 const peak = Math.max(0, ...s.history)
307 const isSyncing = s.sync.requestedAt > s.sync.doneAt && s.now - s.sync.requestedAt < 30_000
308 const status = isSyncing
309 ? `${spinner(s.now)} syncing with Claude…`
310 : s.sync.doneAt > 0
311 ? `synced ${clockTime(s.sync.doneAt)}`
312 : 'not synced yet'
313
314 return (
315 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
316 <Box flexDirection="row" justifyContent="space-between">
317 <Box flexDirection="row" gap={1}>
318 <Text bold color="claude">◆ dash-it</Text>
319 <Text dimColor>background activity</Text>
320 </Box>
321 {live > 0 ? (
322 <Text color="success" bold>{`${spinner(s.now)} LIVE ${live}`}</Text>
323 ) : (
324 <Text dimColor>○ idle</Text>
325 )}
326 </Box>
327 <Box flexDirection="row" gap={1} marginTop={1}>
328 {pill(ui, shells, 'shells', 'success')}
329 {pill(ui, agents, 'agents', 'permission')}
330 {pill(ui, s.crons.length, 'scheduled', 'warning')}
331 </Box>
332 <Box flexDirection="row" marginTop={1}>
333 <Text dimColor>{'load '}</Text>
334 <Text color="suggestion">{spark(s.history, Math.max(10, inner(s) - 16))}</Text>
335 <Text dimColor>{` peak ${peak}`}</Text>
336 </Box>
337 <Box flexDirection="row" gap={1} marginTop={1}>
338 <Button key="refresh" hotkey="r" variant="primary" label="↻ Refresh" onPress={() => acts.refresh()} />
339 <Text dimColor>{status}</Text>
340 </Box>
341 </Box>
342 )
343}
344
345const fmt = (at: number) => clockTime(at)
346
347function field(ui: Ui, label: string, value: string, color?: string) {
348 return (
349 <ui.Box flexDirection="row">
350 <ui.Text dimColor>{label.padEnd(10)}</ui.Text>
351 <ui.Text color={color}>{value}</ui.Text>
352 </ui.Box>
353 )
354}
355
356const heading = (ui: Ui, text: string, accent: string) => (
357 <ui.Box marginTop={1}>
358 <ui.Text bold color={accent}>{text}</ui.Text>
359 </ui.Box>
360)
361
362function frame(ui: Ui, s: Snapshot, acts: Handlers, accent: string, title: string, body: JSX.Element[]) {
363 const { Box, Text, Button } = ui
364
365 return (
366 <Box flexDirection="column">
367 <Box flexDirection="row" gap={1}>
368 <Button key="back" hotkey="b" variant="primary" label="← Back" onPress={() => acts.select('')} />
369 <Text dimColor>to the dashboard</Text>
370 </Box>
371 <Box flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1}>
372 <Text bold color={accent}>{truncate(title, inner(s))}</Text>
373 {body}
374 </Box>
375 </Box>
376 )
377}
378
379function taskDetail(s: Snapshot, ui: Ui, acts: Handlers, t: DashTask) {
380 const end = t.endedAt ?? s.now
381 const command = taskCommand(t)
382
383 return frame(ui, s, acts, 'success', `${icon(t.status, s.now)} ${t.kind}: ${t.label}`, [
384 heading(ui, 'Overview', 'success'),
385 field(ui, 'Status', t.status, tone(t.status)),
386 field(ui, 'Kind', t.kind),
387 field(ui, 'Task id', t.id),
388 field(ui, 'Started', fmt(t.startedAt)),
389 ...(t.endedAt === undefined ? [] : [field(ui, 'Ended', fmt(t.endedAt))]),
390 field(ui, 'Duration', elapsed(end - t.startedAt)),
391 ...(t.description && t.description !== command
392 ? [heading(ui, 'Why (description)', 'success'), <ui.Text>{t.description}</ui.Text>]
393 : []),
394 heading(ui, t.kind === 'shell' ? 'Command' : 'Script', 'success'),
395 command === undefined ? (
396 <ui.Text dimColor>Not captured: the engine did not report this task's command.</ui.Text>
397 ) : (
398 <ui.Code source={clip(command, 6000)} language="bash" wrap="wrap" />
399 ),
400 ])
401}
402
403function agentDetail(s: Snapshot, ui: Ui, acts: Handlers, a: DashAgent) {
404 const info = s.spawns[a.id]
405 const parent = s.agents.find(one => one.id === a.parentId)
406 const end = a.endedAt ?? s.now
407
408 return frame(ui, s, acts, 'permission', `${icon(a.status, s.now)} ${a.name || a.type}`, [
409 heading(ui, 'Overview', 'permission'),
410 field(ui, 'Status', a.status, tone(a.status)),
411 field(ui, 'Type', a.type),
412 field(ui, 'Agent id', a.id),
413 field(ui, 'Model', info?.model ?? 'unknown'),
414 field(ui, 'Mode', info === undefined ? 'unknown' : info.background ? 'background' : 'foreground'),
415 field(ui, 'Parent', a.parentId === undefined ? 'main session' : parent ? parent.name || parent.type : a.parentId),
416 field(ui, 'Started', fmt(a.startedAt)),
417 field(ui, 'Duration', elapsed(end - a.startedAt)),
418 field(ui, 'Tool calls', info === undefined ? '0' : `${info.tools}${info.lastTool ? ` (last: ${info.lastTool})` : ''}`),
419 heading(ui, 'Task', 'permission'),
420 <ui.Text>{a.description || 'No description.'}</ui.Text>,
421 heading(ui, 'Prompt (why it ran)', 'permission'),
422 info?.prompt ? (
423 <ui.Markdown text={info.prompt} />
424 ) : (
425 <ui.Text dimColor>Not captured: it started before the dashboard was loaded.</ui.Text>
426 ),
427 heading(ui, 'Final report', 'permission'),
428 info?.result ? (
429 <ui.Markdown text={info.result} />
430 ) : (
431 <ui.Text dimColor>{isLive(a.status) ? 'Still working…' : 'No report captured.'}</ui.Text>
432 ),
433 ])
434}
435
436function detail(s: Snapshot, ui: Ui, acts: Handlers) {
437 const [kind, ...rest] = s.selected.split(':')
438 const id = rest.join(':')
439 const task = kind === 'task' ? s.tasks.find(t => t.id === id) : undefined
440 const agent = kind === 'agent' ? s.agents.find(a => a.id === id) : undefined
441 if (task) return taskDetail(s, ui, acts, task)
442 if (agent) return agentDetail(s, ui, acts, agent)
443
444 return frame(ui, s, acts, 'subtle', 'Not tracked', [
445 <ui.Text dimColor>This item is no longer in the dashboard's history.</ui.Text>,
446 ])
447}
448
449export function dashboard(ui: Ui, s: Snapshot, acts: Handlers) {
450 const { Box, Text, Button } = ui
451
452 return (
453 <Box flexDirection="column">
454 {header(s, ui, acts)}
455 {s.selected !== '' ? (
456 detail(s, ui, acts)
457 ) : (
458 WIDGETS.map(w => {
459 const isOpen = !s.collapsed.includes(w.id)
460
461 return (
462 <Box
463 flexDirection="column"
464 borderStyle="round"
465 borderColor={isOpen ? w.accent : 'subtle'}
466 paddingX={1}
467 >
468 <Box flexDirection="row" gap={1}>
469 <Button
470 plain
471 key={`toggle-${w.id}`}
472 label={`${isOpen ? '▾' : '▸'} ${w.title}`}
473 onPress={() => acts.toggle(w.id)}
474 />
475 <Text dimColor>{`(${w.count(s)})`}</Text>
476 </Box>
477 {isOpen && w.render(s, ui, acts)}
478 </Box>
479 )
480 })
481 )}
482 </Box>
483 )
484}
485hooks/model.ts 308 lines1import type { DashAgent, DashAgentDetail, DashCron, DashFeedItem, DashTask } from '../types'
2
3export type InFlight = {
4 id: string
5 type: string
6 description: string
7 command?: string
8 agent_type?: string
9 name?: string
10 server?: string
11}
12
13export type AgentSeen = {
14 id: string
15 name?: string
16 type: string
17 description: string
18 status: string
19 parentId?: string
20}
21
22const LIVE_AGENT = ['pending', 'running', 'waiting', 'idle']
23const KEEP_ENDED = 20
24const KEEP_FEED = 40
25
26export const isAgentLive = (a: { status: string }) => LIVE_AGENT.includes(a.status)
27
28const trimEnded = <T extends { endedAt?: number }>(list: T[]): T[] => {
29 const ended = list.filter(one => one.endedAt !== undefined).slice(-KEEP_ENDED)
30 return list.filter(one => one.endedAt === undefined || ended.includes(one))
31}
32
33export type Note = { text: string; ref?: string }
34
35export function addTask(list: DashTask[], task: DashTask): DashTask[] {
36 if (list.some(one => one.id === task.id)) {
37 return list.map(one =>
38 one.id === task.id
39 ? {
40 ...one,
41 command: task.command ?? one.command,
42 description: task.description ?? one.description,
43 }
44 : one,
45 )
46 }
47
48 return trimEnded([...list, task])
49}
50
51export function endTask(
52 list: DashTask[],
53 id: string,
54 status: 'ended' | 'killed',
55 now: number,
56): DashTask[] {
57 return list.map(one =>
58 one.id === id && one.status === 'running' ? { ...one, status, endedAt: now } : one,
59 )
60}
61
62// Subagents are shown by the agents widget, so they are skipped here.
63export function reconcileTasks(
64 list: DashTask[],
65 inflight: InFlight[],
66 now: number,
67): DashTask[] {
68 const live = new Set(inflight.map(one => one.id))
69 let next = list.map(one => {
70 if (one.status === 'running' && !live.has(one.id)) {
71 return { ...one, status: 'ended' as const, endedAt: now }
72 }
73 if (one.status === 'ended' && live.has(one.id)) {
74 return { ...one, status: 'running' as const, endedAt: undefined }
75 }
76
77 return one
78 })
79 for (const one of inflight) {
80 if (one.type === 'subagent') continue
81 next = addTask(next, {
82 id: one.id,
83 kind: one.type,
84 label: one.command ?? one.description,
85 detail: one.command ? one.description : (one.name ?? one.server),
86 command: one.command,
87 description: one.description,
88 status: 'running',
89 startedAt: now,
90 })
91 }
92
93 return next
94}
95
96export function reconcileAgents(
97 prev: DashAgent[],
98 seen: AgentSeen[],
99 now: number,
100): [DashAgent[], Note[]] {
101 const notes: Note[] = []
102 const byId = new Map(seen.map(one => [one.id, one]))
103 const known = new Set(prev.map(one => one.id))
104 const next: DashAgent[] = prev.map(one => {
105 const cur = byId.get(one.id)
106 if (cur === undefined) {
107 if (one.endedAt !== undefined) return one
108 notes.push({ text: `agent ended: ${one.name || one.type}`, ref: `agent:${one.id}` })
109
110 return { ...one, status: 'ended', endedAt: now }
111 }
112 const wasLive = isAgentLive(one)
113 const live = isAgentLive(cur)
114 const who = cur.name || one.name || cur.type
115 const ref = `agent:${one.id}`
116 if (wasLive && !live) notes.push({ text: `agent ${cur.status}: ${who}`, ref })
117 if (!wasLive && live) notes.push({ text: `agent resumed: ${who}`, ref })
118
119 return {
120 ...one,
121 name: cur.name || one.name,
122 type: cur.type,
123 parentId: cur.parentId,
124 status: cur.status,
125 description: cur.description,
126 endedAt: live ? undefined : (one.endedAt ?? now),
127 }
128 })
129 for (const one of seen) {
130 if (known.has(one.id)) continue
131 notes.push({ text: `agent started: ${one.name || one.type}`, ref: `agent:${one.id}` })
132 next.push({
133 id: one.id,
134 name: one.name ?? '',
135 type: one.type,
136 description: one.description,
137 status: one.status,
138 parentId: one.parentId,
139 startedAt: now,
140 endedAt: isAgentLive(one) ? undefined : now,
141 })
142 }
143
144 return [trimEnded(next), notes]
145}
146
147export function sameCrons(a: DashCron[], b: DashCron[]): boolean {
148 return JSON.stringify(a) === JSON.stringify(b)
149}
150
151export function pushFeed(feed: DashFeedItem[], notes: Note[], now: number): DashFeedItem[] {
152 if (notes.length === 0) return feed
153
154 return [...feed, ...notes.map(n => ({ at: now, text: n.text, ref: n.ref }))].slice(-KEEP_FEED)
155}
156
157const KEEP_SPAWNS = 60
158const BLANK: DashAgentDetail = { prompt: '', background: false, tools: 0 }
159
160// The spawn hook, the per-agent tool tally and the stop report each fill part of one record.
161export function mergeSpawn(
162 all: Record<string, DashAgentDetail>,
163 id: string,
164 patch: Partial<DashAgentDetail>,
165 bump = 0,
166): Record<string, DashAgentDetail> {
167 const cur = all[id] ?? BLANK
168 const next = { ...all, [id]: { ...cur, ...patch, tools: cur.tools + bump } }
169 const keys = Object.keys(next)
170
171 return keys.length <= KEEP_SPAWNS
172 ? next
173 : Object.fromEntries(keys.slice(-KEEP_SPAWNS).map(k => [k, next[k] as DashAgentDetail]))
174}
175
176export function clip(text: string, max: number): string {
177 return text.length <= max ? text : `${text.slice(0, max)}\n… ${text.length - max} more characters`
178}
179
180export function elapsed(ms: number): string {
181 const s = Math.max(0, Math.floor(ms / 1000))
182 if (s < 60) return `${s}s`
183 const m = Math.floor(s / 60)
184 if (m < 60) return `${m}m ${s % 60}s`
185
186 return `${Math.floor(m / 60)}h ${m % 60}m`
187}
188
189export function clockTime(ms: number): string {
190 const d = new Date(ms)
191 const two = (n: number) => String(n).padStart(2, '0')
192
193 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
194}
195
196const SPARK = '▁▂▃▄▅▆▇█'
197const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
198
199export function spark(history: number[], cells: number): string {
200 const tail = history.slice(-cells)
201 const peak = Math.max(4, ...tail)
202 const bars = tail.map(n =>
203 n <= 0 ? '▁' : (SPARK[Math.min(7, Math.ceil((n / peak) * 8) - 1)] ?? '▁'),
204 )
205
206 return bars.join('').padStart(cells, '▁')
207}
208
209export const spinner = (at: number) => FRAMES[Math.floor(at / 120) % FRAMES.length] ?? '⠋'
210
211export function truncate(text: string, cells: number): string {
212 const one = text.replace(/\s+/g, ' ')
213 if (cells <= 0) return ''
214
215 return one.length <= cells ? one : `${one.slice(0, Math.max(0, cells - 1))}…`
216}
217
218export const pad = (text: string, cells: number) => truncate(text, cells).padEnd(cells)
219
220// A bright pair of cells bouncing across a bar: motion for work with no known progress.
221export function shimmer(at: number, cells: number): string {
222 const span = Math.max(1, cells * 2 - 2)
223 const raw = Math.floor(at / 110) % span
224 const head = raw < cells ? raw : span - raw
225
226 return Array.from({ length: cells }, (_, i) =>
227 Math.abs(i - head) <= 1 ? '▰' : '▱',
228 ).join('')
229}
230
231export function timelineBar(
232 start: number,
233 end: number,
234 winStart: number,
235 span: number,
236 cells: number,
237): { before: number; run: number; after: number } {
238 const from = Math.min(cells - 1, Math.max(0, Math.floor(((start - winStart) / span) * cells)))
239 const to = Math.min(cells, Math.max(from + 1, Math.ceil(((end - winStart) / span) * cells)))
240
241 return { before: from, run: to - from, after: cells - to }
242}
243
244// Records saved before commands were stored kept the command as `detail` (or as `label`).
245export function taskCommand(t: DashTask): string | undefined {
246 if (t.command !== undefined) return t.command
247
248 return t.description === undefined ? (t.detail ?? t.label) : undefined
249}
250
251export type TranscriptRow = {
252 role: string
253 text: string
254 toolUses: { tool: string; input: Record<string, unknown>; result?: unknown; agentId?: string }[]
255}
256
257const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
258
259// Rebuilds tasks launched before the dashboard loaded from the conversation itself.
260// A task with a completion notice is finished and skipped; the rest are candidates
261// that the next Stop event confirms or ends.
262export function scanTranscript(
263 rows: TranscriptRow[],
264 now: number,
265): { tasks: DashTask[]; spawns: Record<string, Partial<DashAgentDetail>> } {
266 const finished = new Set<string>()
267 for (const row of rows) {
268 if (row.role !== 'user') continue
269 for (const m of row.text.matchAll(/<task-id>([^<]+)<\/task-id>[\s\S]*?<status>([^<]+)<\/status>/g)) {
270 if (m[1]) finished.add(m[1])
271 }
272 }
273 const tasks: DashTask[] = []
274 const spawns: Record<string, Partial<DashAgentDetail>> = {}
275 for (const row of rows) {
276 for (const use of row.toolUses) {
277 const out = (use.result ?? {}) as Record<string, unknown>
278 const command = str(use.input.command)
279 const description = str(use.input.description)
280 const shellId = use.tool === 'Bash' ? str(out.backgroundTaskId) : undefined
281 const monitorId = use.tool === 'Monitor' ? str(out.taskId) : undefined
282 const id = shellId ?? monitorId
283 if (id !== undefined && !finished.has(id)) {
284 tasks.push({
285 id,
286 kind: shellId === undefined ? 'monitor' : 'shell',
287 label: description ?? command ?? id,
288 detail: description ? command : undefined,
289 command,
290 description,
291 status: 'running',
292 startedAt: now,
293 })
294 }
295 const prompt = str(use.input.prompt)
296 if (use.tool === 'Agent' && use.agentId !== undefined && prompt !== undefined) {
297 spawns[use.agentId] = {
298 prompt: clip(prompt, 6000),
299 model: str(use.input.model),
300 background: use.input.run_in_background === true,
301 }
302 }
303 }
304 }
305
306 return { tasks, spawns }
307}
308types/index.d.ts 58 lines1export type DashTask = {
2 id: string
3 kind: string
4 label: string
5 detail?: string
6 status: 'running' | 'ended' | 'killed'
7 startedAt: number
8 endedAt?: number
9 command?: string
10 description?: string
11}
12
13export type DashAgent = {
14 id: string
15 name: string
16 type: string
17 description: string
18 status: string
19 parentId?: string
20 startedAt: number
21 endedAt?: number
22}
23
24export type DashCron = {
25 id: string
26 schedule: string
27 recurring: boolean
28 prompt: string
29}
30
31export type DashFeedItem = { at: number; text: string; ref?: string }
32
33export type DashAgentDetail = {
34 prompt: string
35 model?: string
36 background: boolean
37 tools: number
38 lastTool?: string
39 result?: string
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'dash-it': {
45 tasks: DashTask[]
46 agents: DashAgent[]
47 crons: DashCron[]
48 feed: DashFeedItem[]
49 collapsed: string[]
50 now: number
51 history: number[]
52 selected: string
53 sync: { requestedAt: number; doneAt: number }
54 spawns: Record<string, DashAgentDetail>
55 }
56 }
57}
58