SLOPSHOPPER

dash-it

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

newpaneguardcommandtimeragents
v0.2.0no licenseupdated 2026-10-07mesolimbo/dash-it
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dash-it
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ◆ dash-it background activity ○ idle ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ 0 shells 0 agents 0 scheduled ⏺ Update(src/auth.ts) │ ┃ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ load ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ⏺ Bash(bun test) │ ┃ │ ⎿ 3 pass, 1 fail │ ┃ │ [ ↻ Refresh ] synced 08:53:20 │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▾ Timeline (0) │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ Nothing has run yet. Launch a shell or │ │ ┃ │ subagent. │ › /dash-it │ ┃ ╰──────────────────────────────────────────╯ ⎿ dash-it: Dashboard opened docked to the right of the transcript. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▾ Background tasks (0) │ │ ┃ │ No background shells or monitors. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▾ Subagents (0) │ │ ┃ │ No subagents yet. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▾ Scheduled (0) │ │ ┃ │ Nothing scheduled. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Dashboard
╭──────────────────────────────────────────────────────────╮ │ ◆ dash-it background activity ○ idle │ │ │ │ 0 shells 0 agents 0 scheduled │ │ │ │ load ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ peak 0 │ │ │ │ [ ↻ Refresh ] synced 08:53:20 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Timeline (0) │ │ Nothing has run yet. Launch a shell or subagent. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Background tasks (0) │ │ No background shells or monitors. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Subagents (0) │ │ No subagents yet. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Scheduled (0) │ │ Nothing scheduled. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ▾ Activity (0) │ │ Nothing yet. │ ╰──────────────────────────────────────────────────────────╯
README

dash-it

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.

Install

/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.

Check

claude plugin validate .
claude plugin test .
Source 4 files
hooks/register.tsx 292 lines
1import { 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}
292
hooks/dashboard.tsx 485 lines
1import 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}
485
hooks/model.ts 308 lines
1import 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}
308
types/index.d.ts 58 lines
1export 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