SLOPSHOPPER

session-board

A board of every local Claude Code session: state, task, worktree and time worked.

newpanecommandprocesstimer
v0.2.0MITupdated 2026-10-09niksavis/handily/mods/session-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-board
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ Sessions │ ┃ Listing the local sessions… ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-board │ ⎿ session-board: Session board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
Sessions Listing the local sessions…
README

session-board

session-board shows each Claude Code session on this machine in one pane. Each session is a card: the state, the current task with its progress, the worktree and branch, and the time worked.

Use

/session-board          opens the board
/session-board close    closes the board
  • Each card has a header line and two detail lines, at every width. A dim rule line separates two cards.
  • The header shows a status mark in a theme colour, the name in bold, a dim inter or bg badge, this on the current session, and the state word.
MarkStateColour
●busy, workingsuccess
◐waiting, blocked, or any waitingForwarning
○idle, an unknown word, done, stoppeddim
✕failederror
  • The first detail line is the current task, cut to fit, and a progress bar with one cell per task, at most 8 cells: ▶ Write the tests ▰▰▱▱ 2/4.
  • The second detail line is <worktree> · <branch> · <time>, with est. <time> left when the board has an estimate.
  • This session comes first. Then come the working sessions, the waiting ones, the idle ones, and the ended ones last.
  • A background session whose state has not changed for over 24 hours is hidden. The board measures from startedAt, or from the last state change that a poll saw. A dim footer says N older background job(s) hidden. An interactive session and this session are never hidden.
  • The count in the header, Sessions N local, is the number of cards shown.
  • The board draws only theme colours, so it follows the terminal theme. It never draws a fixed colour.

What it reads

  • claude agents --json --all, every 15 s, only while the board is shown. The mod keeps one result with its time in its $.store under the key agents. When another session polled less than 15 s ago, the board uses that result and does not run the command again. A cached time in the future, or a cached value that the mod cannot read, counts as expired. Only one poll runs at a time in a session.
  • git -C <cwd> rev-parse and git -C <cwd> branch --show-current, only when the working directory of a session changes.
  • Each session that runs session-board writes its own store key session:<session id>. The key holds the current task, the done and total counts, and the time worked.
  • The task list comes from the $.state list of the task-pane mod. A session with its own key and no task list, or an empty one, shows no tasks. A session with no key shows a dim —.

The mod never reads the task files of the engine on disk, because their format is internal.

The fields

  • state is the word that claude agents --json gives: status for an interactive session (busy, idle, waiting) and state for a background session (working, blocked, done, failed, stopped). Claude Code 2.1.293 writes these words. A waitingFor value follows after a colon.
  • worked is the sum of the main-loop turns, from turn.start to turn.complete. Only a session that runs session-board has it. Other sessions show the time elapsed since startedAt. An open turn that started before startedAt of the session does not count, and a stale key shows only its stored time.
  • est. <time> left divides the completed tasks by all tasks and applies the result to the time since the first task. The board shows no estimate before one task is complete, and none for 5 minutes after a task is added. When the first list that the board sees already has a completed task, the time of the first task is unknown, and the board shows no estimate for that list.
  • The board shows no card for a store key whose session claude agents does not list. The one exception is this session, before a poll lists it: its card shows not listed and (stale).
  • A listed session whose key was written more than 60 s before its startedAt shows (stale) after its task, and no estimate. The key then comes from an earlier run of the same session. The 60 s margin exists because a session writes its key at start, up to about 2 s before or after the startedAt that claude agents reports. One live run measured both (-1622 ms and +885 ms).
  • A poll deletes a key of an unlisted session that is older than 24 hours before it saves its result. A session start deletes each key of another session that is older than 24 hours.

Errors

  • claude agents --json failed: exit <n>. when the command exits with an error.
  • claude agents --json failed: the output is not a JSON list. when the output is not a list.
  • claude agents --json failed: the output is over 4 MiB and was cut. when the output was cut.
  • An entry of the list that the mod cannot read is left out. The debug log names the count.
  • claude is not on PATH, so other sessions cannot be listed. when the command cannot start.

Limits

  • claude agents gives no end time. So an ended session shows ended with no age.
  • A session with no terminal surface, for example a session that the desktop app hosts, does not run claude agents. The board then shows this session and its subagents from $.agent.list(). Whether claude is on PATH in a desktop session is not measured.

Develop

claude --plugin-dir mods
claude plugin test mods/session-board
Source 5 files
hooks/register.tsx 222 lines
1import type { EngineInterface, Register, Timer } from 'claude-code'
2import {
3  AGENTS_CACHE_KEY,
4  POLL_INTERVAL_MS,
5  pollAgents,
6  readCache,
7  type AgentsCache,
8  type AgentsHost,
9} from './agents'
10import { renderBoard, renderDesktop } from './board'
11import {
12  completeTurn,
13  emptyProgress,
14  isExpired,
15  progressKey,
16  PROGRESS_PREFIX,
17  readProgress,
18  readTaskList,
19  startTurn,
20  withTasks,
21  type Progress,
22  type TaskView,
23} from './progress'
24
25export const PANE_ID = 'session-board'
26export const PANE_TITLE = 'Sessions'
27const TASK_LIST = { plugin: 'task-pane', key: 'list' } as const
28
29type Change = (progress: Progress, now: number, tasks: TaskView | null) => Progress
30
31async function canListSessions($: EngineInterface): Promise<boolean> {
32  return (await $.session.surfaces()).includes('terminal')
33}
34
35async function readTasks($: EngineInterface): Promise<TaskView | null> {
36  try {
37    return readTaskList((await $.state.get(TASK_LIST)).value)
38  } catch {
39    return null
40  }
41}
42
43async function readAllProgress($: EngineInterface): Promise<Progress[]> {
44  const keys = (await $.store.keys()).filter((key) => key.startsWith(PROGRESS_PREFIX))
45  const entries = await Promise.all(keys.map(async (key) => readProgress(await $.store.get(key))))
46  return entries.filter((entry): entry is Progress => entry !== null)
47}
48
49function agentsHost($: EngineInterface): AgentsHost {
50  return {
51    now: () => $.clock.now(),
52    run: (argv) => $.process.run(argv),
53    load: () => $.store.get(AGENTS_CACHE_KEY),
54    save: async (cache) => {
55      await pruneExpired($, cache)
56      await $.store.set(AGENTS_CACHE_KEY, cache)
57    },
58    log: (line) => {
59      $.ui.log(line, { to: 'debug' })
60    },
61  }
62}
63
64async function deleteExpired($: EngineInterface, live: ReadonlySet<string>): Promise<void> {
65  const now = await $.clock.now()
66  for (const entry of await readAllProgress($)) {
67    if (isExpired(entry, live, now)) await $.store.delete(progressKey(entry.sessionId))
68  }
69}
70
71async function pruneExpired($: EngineInterface, cache: AgentsCache): Promise<void> {
72  if (cache.outcome.kind !== 'ok') return
73  await deleteExpired($, new Set(cache.outcome.rows.flatMap((row) => row.sessionId ?? [])))
74}
75
76type Board = {
77  poll: Timer | undefined
78  inFlight: Promise<void> | undefined
79  writes: Promise<void>
80}
81
82function stopPolling(board: Board): void {
83  board.poll?.cancel()
84  board.poll = undefined
85}
86
87async function pollShownBoard($: EngineInterface, board: Board): Promise<void> {
88  const pane = (await $.ui.panes()).find((open) => open.id === PANE_ID)
89  if (!pane) {
90    stopPolling(board)
91    return
92  }
93  if (!pane.isShown) return
94  await pollAgents(agentsHost($))
95  $.ui.invalidate('ui.render')
96}
97
98function pollOnce($: EngineInterface, board: Board): Promise<void> {
99  if (board.inFlight) return board.inFlight
100  const running = pollShownBoard($, board).finally(() => {
101    board.inFlight = undefined
102  })
103  board.inFlight = running
104  return running
105}
106
107function startPolling($: EngineInterface, board: Board): void {
108  if (board.poll) return
109  const tick = () => {
110    pollOnce($, board).catch((error: unknown) => {
111      $.ui.log(`session-board: the poll failed: ${String(error)}`, { to: 'debug' })
112    })
113  }
114  board.poll = $.clock.every(POLL_INTERVAL_MS, tick)
115  $.clock.after(0, tick)
116}
117
118async function writeProgress($: EngineInterface, change: Change): Promise<void> {
119  const sessionId = await $.session.id()
120  const now = await $.clock.now()
121  const key = progressKey(sessionId)
122  const current =
123    readProgress(await $.store.get(key)) ?? emptyProgress(sessionId, await $.session.cwd(), now)
124  await $.store.set(key, change(current, now, await readTasks($)))
125  $.ui.invalidate('ui.render')
126}
127
128function updateProgress($: EngineInterface, board: Board, change: Change): Promise<void> {
129  const run = () => writeProgress($, change)
130  board.writes = board.writes.then(run, run)
131  return board.writes
132}
133
134export const register: Register = (on) => {
135  const board: Board = { poll: undefined, inFlight: undefined, writes: Promise.resolve() }
136
137  on('session.start', async ($, e, next) => {
138    const started = await next(e)
139    await $.command.register({
140      name: 'session-board',
141      description: 'Shows every local Claude Code session: state, task, worktree and time.',
142      argumentHint: '[close]',
143    })
144    await deleteExpired($, new Set([await $.session.id()]))
145    await updateProgress($, board, (progress, now, tasks) => withTasks(progress, tasks, now))
146    await $.state.set({ plugin: 'session-board', key: 'ready' }, { root: $.plugin.root })
147    return started
148  })
149
150  on('turn.start', async ($, e, next) => {
151    await updateProgress($, board, (progress, now, tasks) =>
152      withTasks(startTurn(progress, now), tasks, now),
153    )
154    return next(e)
155  })
156
157  on('turn.complete', async ($, e, next) => {
158    const completed = await next(e)
159    if (e.agentId === undefined) {
160      await updateProgress($, board, (progress, now, tasks) =>
161        withTasks(completeTurn(progress, now), tasks, now),
162      )
163    }
164    return completed
165  })
166
167  on('state.set', { plugin: 'task-pane', key: 'list' }, async ($, e, next) => {
168    const result = await next(e)
169    if (result.deny === undefined && result.value.isSet) {
170      await updateProgress($, board, (progress, now) =>
171        withTasks(progress, readTaskList(e.value), now),
172      )
173    }
174    return result
175  }).catch((_$, e, next) => next(e))
176
177  on('ui.close', { id: PANE_ID }, async (_$, e, next) => {
178    const closed = await next(e)
179    stopPolling(board)
180    return closed
181  }).catch((_$, e, next) => next(e))
182
183  on('command.run', { command: 'session-board' }, async ($, e) => {
184    const argument = e.args.trim()
185    if (argument === 'close') {
186      stopPolling(board)
187      await $.ui.close({ id: PANE_ID })
188      return { text: 'Session board closed.' }
189    }
190    if (argument !== '') {
191      return {
192        text: `Unknown argument "${argument}". Use /session-board or /session-board close.`,
193      }
194    }
195    await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
196    if (!(await canListSessions($))) {
197      return {
198        text: 'Session board opened. Other sessions are listed only in a terminal session.',
199      }
200    }
201    startPolling($, board)
202    return { text: 'Session board opened.' }
203  })
204
205  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
206    const elements = $.ui.resolve(e)
207    const now = await $.clock.now()
208    const ownSessionId = await $.session.id()
209    if (!(await canListSessions($))) {
210      const own = readProgress(await $.store.get(progressKey(ownSessionId))) ?? undefined
211      return renderDesktop(elements, { now, own, subagents: await $.agent.list() })
212    }
213    startPolling($, board)
214    const cache = readCache(await $.store.get(AGENTS_CACHE_KEY))
215    const progress = await readAllProgress($)
216    return renderBoard(elements, { now, ownSessionId, cache, progress }, e.props.bodyColumns)
217  }).catch(($, e, next) => {
218    $.ui.log(`session-board: the board could not be drawn (${next.error.kind})`, { to: 'debug' })
219    return next(e)
220  })
221}
222
hooks/agents.ts 264 lines
1import type { ProcessRunResult } from 'claude-code'
2
3export const POLL_INTERVAL_MS = 15_000
4export const AGENTS_ARGV: readonly string[] = ['claude', 'agents', '--json', '--all']
5export const AGENTS_CACHE_KEY = 'agents'
6
7const ENDED_STATES = new Set(['done', 'failed', 'stopped'])
8const NOT_ON_PATH = /ENOENT|not found|no such file|cannot find/i
9
10export type AgentKind = 'interactive' | 'background'
11
12export type AgentRow = {
13  key: string
14  sessionId: string | null
15  name: string
16  kind: AgentKind
17  word: string
18  waitingFor: string | null
19  cwd: string
20  startedAt: number
21  stateSince: number
22  isEnded: boolean
23}
24
25export type AgentsOutcome =
26  | { kind: 'ok'; rows: AgentRow[]; skipped: number }
27  | { kind: 'exit'; exitCode: number }
28  | { kind: 'not-on-path' }
29  | { kind: 'not-a-list' }
30  | { kind: 'did-not-run' }
31  | { kind: 'cut' }
32
33export type GitPlace = { worktree: string; branch: string | null }
34
35export type PlaceEntry = { cwd: string; place: GitPlace }
36
37export type AgentsCache = {
38  at: number
39  outcome: AgentsOutcome
40  places: Record<string, PlaceEntry>
41}
42
43export type AgentsHost = {
44  now: () => Promise<number>
45  run: (argv: readonly string[]) => Promise<ProcessRunResult>
46  load: () => Promise<unknown>
47  save: (cache: AgentsCache) => Promise<void>
48  log: (line: string) => void
49}
50
51function isRecord(value: unknown): value is Record<string, unknown> {
52  return typeof value === 'object' && value !== null && !Array.isArray(value)
53}
54
55function optionalText(value: unknown): string | null {
56  return typeof value === 'string' && value !== '' ? value : null
57}
58
59function rowKey(entry: Record<string, unknown>, sessionId: string | null): string {
60  if (sessionId !== null) return sessionId
61  if (typeof entry.id === 'string') return `bg:${entry.id}`
62  return `pid:${String(entry.pid)}`
63}
64
65function rowName(entry: Record<string, unknown>, sessionId: string | null): string {
66  return (
67    optionalText(entry.name) ??
68    optionalText(entry.id) ??
69    sessionId?.slice(0, 8) ??
70    `pid ${String(entry.pid)}`
71  )
72}
73
74function readRow(entry: unknown): AgentRow | null {
75  if (!isRecord(entry)) return null
76  const { kind, cwd, startedAt } = entry
77  if (kind !== 'interactive' && kind !== 'background') return null
78  if (typeof cwd !== 'string' || typeof startedAt !== 'number') return null
79  const sessionId = optionalText(entry.sessionId)
80  const word = optionalText(kind === 'background' ? entry.state : entry.status) ?? 'unknown'
81  return {
82    key: rowKey(entry, sessionId),
83    sessionId,
84    name: rowName(entry, sessionId),
85    kind,
86    word,
87    waitingFor: optionalText(entry.waitingFor),
88    cwd,
89    startedAt,
90    stateSince: startedAt,
91    isEnded: kind === 'background' && ENDED_STATES.has(word),
92  }
93}
94
95export function parseAgents(stdout: string): AgentsOutcome {
96  let parsed: unknown
97  try {
98    parsed = JSON.parse(stdout)
99  } catch {
100    return { kind: 'not-a-list' }
101  }
102  if (!Array.isArray(parsed)) return { kind: 'not-a-list' }
103  const rows = parsed.map(readRow).filter((row) => row !== null)
104  return { kind: 'ok', rows, skipped: parsed.length - rows.length }
105}
106
107function isTextOrNull(value: unknown): boolean {
108  return value === null || typeof value === 'string'
109}
110
111function isAgentRow(value: unknown): value is AgentRow {
112  return (
113    isRecord(value) &&
114    typeof value.key === 'string' &&
115    isTextOrNull(value.sessionId) &&
116    typeof value.name === 'string' &&
117    (value.kind === 'interactive' || value.kind === 'background') &&
118    typeof value.word === 'string' &&
119    isTextOrNull(value.waitingFor) &&
120    typeof value.cwd === 'string' &&
121    typeof value.startedAt === 'number' &&
122    typeof value.stateSince === 'number' &&
123    typeof value.isEnded === 'boolean'
124  )
125}
126
127const ERROR_KINDS = new Set(['not-on-path', 'not-a-list', 'did-not-run', 'cut'])
128
129function isOutcome(value: unknown): value is AgentsOutcome {
130  if (!isRecord(value) || typeof value.kind !== 'string') return false
131  if (value.kind === 'ok') {
132    return (
133      Array.isArray(value.rows) && value.rows.every(isAgentRow) && typeof value.skipped === 'number'
134    )
135  }
136  if (value.kind === 'exit') return typeof value.exitCode === 'number'
137  return ERROR_KINDS.has(value.kind)
138}
139
140function isPlaceEntry(value: unknown): value is PlaceEntry {
141  return (
142    isRecord(value) &&
143    typeof value.cwd === 'string' &&
144    isRecord(value.place) &&
145    typeof value.place.worktree === 'string' &&
146    isTextOrNull(value.place.branch)
147  )
148}
149
150export function readCache(value: unknown): AgentsCache | undefined {
151  if (!isRecord(value)) return undefined
152  const { at, outcome, places } = value
153  if (typeof at !== 'number' || !isOutcome(outcome) || !isRecord(places)) return undefined
154  if (!Object.values(places).every(isPlaceEntry)) return undefined
155  return value as AgentsCache
156}
157
158function skippedLine(skipped: number): string {
159  const rows = skipped === 1 ? 'row' : 'rows'
160  return `session-board: skipped ${String(skipped)} unreadable ${rows} of claude agents --json`
161}
162
163async function runAgents(host: AgentsHost): Promise<AgentsOutcome> {
164  let result: ProcessRunResult
165  try {
166    result = await host.run(AGENTS_ARGV)
167  } catch (error) {
168    const reason = error instanceof Error ? error.message : String(error)
169    host.log(`session-board: claude agents --json could not run: ${reason}`)
170    return NOT_ON_PATH.test(reason) ? { kind: 'not-on-path' } : { kind: 'did-not-run' }
171  }
172  if (result.exitCode !== 0) return { kind: 'exit', exitCode: result.exitCode }
173  if (result.isStdoutTruncated) return { kind: 'cut' }
174  const outcome = parseAgents(result.stdout)
175  if (outcome.kind === 'ok' && outcome.skipped > 0) host.log(skippedLine(outcome.skipped))
176  return outcome
177}
178
179function segments(path: string): string[] {
180  return path.split(/[\\/]+/).filter((part) => part !== '')
181}
182
183function lastSegments(path: string, count: number): string {
184  const parts = segments(path)
185  return parts.length === 0 ? path : parts.slice(-count).join('/')
186}
187
188export async function gitPlace(host: AgentsHost, cwd: string): Promise<GitPlace> {
189  const fallback = { worktree: lastSegments(cwd, 1), branch: null }
190  let paths: ProcessRunResult
191  try {
192    paths = await host.run([
193      'git',
194      '-C',
195      cwd,
196      'rev-parse',
197      '--path-format=absolute',
198      '--show-toplevel',
199      '--git-dir',
200      '--git-common-dir',
201    ])
202  } catch {
203    return fallback
204  }
205  const [top, gitDir, commonDir] = paths.stdout.trim().split(/\r?\n/)
206  if (paths.exitCode !== 0 || top === undefined || top === '') return fallback
207  const isLinked = gitDir !== undefined && commonDir !== undefined && gitDir !== commonDir
208  const worktree = lastSegments(top, isLinked ? 2 : 1)
209  let branch: ProcessRunResult
210  try {
211    branch = await host.run(['git', '-C', cwd, 'branch', '--show-current'])
212  } catch {
213    return { worktree, branch: null }
214  }
215  const name = branch.stdout.trim()
216  return { worktree, branch: branch.exitCode === 0 && name !== '' ? name : null }
217}
218
219async function placesFor(
220  host: AgentsHost,
221  rows: readonly AgentRow[],
222  previous: Record<string, PlaceEntry>,
223): Promise<Record<string, PlaceEntry>> {
224  const places: Record<string, PlaceEntry> = {}
225  for (const row of rows) {
226    const known = previous[row.key]
227    places[row.key] =
228      known?.cwd === row.cwd ? known : { cwd: row.cwd, place: await gitPlace(host, row.cwd) }
229  }
230  return places
231}
232
233function isSameState(row: AgentRow, seen: AgentRow): boolean {
234  return seen.word === row.word && seen.waitingFor === row.waitingFor
235}
236
237function withStateSince(
238  outcome: AgentsOutcome,
239  previous: AgentsCache | undefined,
240  now: number,
241): AgentsOutcome {
242  if (outcome.kind !== 'ok' || previous?.outcome.kind !== 'ok') return outcome
243  const seen = new Map(previous.outcome.rows.map((row) => [row.key, row]))
244  const rows = outcome.rows.map((row) => {
245    const before = seen.get(row.key)
246    if (!before) return row
247    return { ...row, stateSince: isSameState(row, before) ? before.stateSince : now }
248  })
249  return { ...outcome, rows }
250}
251
252export async function pollAgents(host: AgentsHost): Promise<AgentsCache> {
253  const now = await host.now()
254  const previous = readCache(await host.load())
255  const age = previous ? now - previous.at : -1
256  if (previous && age >= 0 && age < POLL_INTERVAL_MS) return previous
257  const outcome = withStateSince(await runAgents(host), previous, now)
258  const known = previous?.places ?? {}
259  const places = outcome.kind === 'ok' ? await placesFor(host, outcome.rows, known) : known
260  const cache = { at: now, outcome, places }
261  await host.save(cache)
262  return cache
263}
264
hooks/board.tsx 419 lines
1import type { AgentInfo, Elements, RenderElement, RenderSurface, TextProps } from 'claude-code'
2import { POLL_INTERVAL_MS, type AgentRow, type AgentsCache, type AgentsOutcome } from './agents'
3import { estimateLeftMs, isOlderThanStart, workedMs, type Progress } from './progress'
4
5export const HIDE_BACKGROUND_AFTER_MS = 24 * 60 * 60_000
6
7type BoardElements = Pick<Elements[RenderSurface], 'Box' | 'Text'>
8
9type Tone = 'success' | 'warning' | 'plain' | 'dim'
10
11export type StateGroup = 'working' | 'waiting' | 'idle' | 'ended' | 'failed'
12
13export type TaskCell =
14  | { kind: 'task'; current: string | null; done: number; total: number }
15  | { kind: 'no-tasks' }
16  | { kind: 'no-data' }
17
18export type BoardRow = {
19  key: string
20  name: string
21  kind: 'inter' | 'bg' | '—'
22  state: string
23  group: StateGroup
24  task: TaskCell
25  isOwn: boolean
26  isStale: boolean
27  isListed: boolean
28  place: string
29  time: string
30  est: string | null
31}
32
33export type BoardView = { rows: BoardRow[]; hidden: number }
34
35export type BoardData = {
36  now: number
37  ownSessionId: string
38  cache: AgentsCache | undefined
39  progress: readonly Progress[]
40}
41
42export type DesktopData = {
43  now: number
44  own: Progress | undefined
45  subagents: readonly AgentInfo[]
46}
47
48const WARNING_WORDS = new Set(['waiting', 'blocked'])
49const SUCCESS_WORDS = new Set(['busy', 'working'])
50const AGENT_WORDS: Partial<Record<AgentInfo['status'], string>> = { completed: 'done' }
51const RETRY_TEXT = `retry in ${String(POLL_INTERVAL_MS / 1000)} s`
52const GROUP_ORDER: Record<StateGroup, number> = {
53  working: 0,
54  waiting: 1,
55  idle: 2,
56  ended: 3,
57  failed: 3,
58}
59const BAR_CELLS_AT_MOST = 8
60const DETAIL_INDENT = 2
61const CARD_RULE = '─'
62
63type TextStyle = Omit<TextProps, 'children'>
64type BoxStyle = Omit<Parameters<BoardElements['Box']>[0], 'children'>
65type Child = RenderElement | null
66
67const MARKS: Record<StateGroup, { glyph: string; style: TextStyle }> = {
68  working: { glyph: '●', style: { color: 'success' } },
69  waiting: { glyph: '◐', style: { color: 'warning' } },
70  idle: { glyph: '○', style: { dimColor: true } },
71  ended: { glyph: '○', style: { dimColor: true } },
72  failed: { glyph: '✕', style: { color: 'error' } },
73}
74
75const GROUP_TONES: Record<StateGroup, Tone> = {
76  working: 'success',
77  waiting: 'warning',
78  idle: 'plain',
79  ended: 'dim',
80  failed: 'dim',
81}
82
83export function formatDuration(ms: number): string {
84  const minutes = Math.max(Math.floor(ms / 60_000), 0)
85  if (minutes < 60) return `${String(minutes)}m`
86  const hours = Math.floor(minutes / 60)
87  if (hours < 24) return `${String(hours)}h ${String(minutes % 60).padStart(2, '0')}m`
88  return `${String(Math.floor(hours / 24))}d ${String(hours % 24).padStart(2, '0')}h`
89}
90
91export function formatAge(ms: number): string {
92  const seconds = Math.max(Math.floor(ms / 1000), 0)
93  if (seconds < 60) return `${String(seconds)} s`
94  return `${String(Math.floor(seconds / 60))} min`
95}
96
97export function progressText(done: number, total: number): string {
98  const cells = Math.min(total, BAR_CELLS_AT_MOST)
99  const filled = total === 0 ? 0 : Math.floor((done * cells) / total)
100  return `${'▰'.repeat(filled)}${'▱'.repeat(cells - filled)} ${String(done)}/${String(total)}`
101}
102
103function lastSegment(path: string): string {
104  return (
105    path
106      .split(/[\\/]+/)
107      .filter((part) => part !== '')
108      .at(-1) ?? path
109  )
110}
111
112export function stateText(row: AgentRow): string {
113  return row.waitingFor === null ? row.word : `${row.word}: ${row.waitingFor}`
114}
115
116export function stateGroup(row: AgentRow): StateGroup {
117  if (row.isEnded) return row.word === 'failed' ? 'failed' : 'ended'
118  if (row.waitingFor !== null || WARNING_WORDS.has(row.word)) return 'waiting'
119  if (SUCCESS_WORDS.has(row.word)) return 'working'
120  return 'idle'
121}
122
123function taskCell(progress: Progress | undefined): TaskCell {
124  if (progress === undefined) return { kind: 'no-data' }
125  if (!progress.hasTasks || progress.total === 0) return { kind: 'no-tasks' }
126  return { kind: 'task', current: progress.task, done: progress.done, total: progress.total }
127}
128
129function estText(progress: Progress, now: number): string | null {
130  const left = estimateLeftMs(progress, now)
131  return left === null ? null : `est. ${formatDuration(left)} left`
132}
133
134function agentRow(
135  row: AgentRow,
136  progress: Progress | undefined,
137  data: BoardData,
138  cache: AgentsCache,
139): BoardRow {
140  const { now } = data
141  const known = cache.places[row.key]
142  const place = known
143    ? `${known.place.worktree} · ${known.place.branch ?? 'no branch'}`
144    : lastSegment(row.cwd)
145  const isStale = progress !== undefined && isOlderThanStart(progress, row.startedAt)
146  let time = `${formatDuration(now - row.startedAt)} elapsed`
147  if (row.isEnded) time = 'ended'
148  else if (progress) time = `${formatDuration(workedMs(progress, now, row.startedAt))} worked`
149  return {
150    key: row.key,
151    name: row.name,
152    kind: row.kind === 'interactive' ? 'inter' : 'bg',
153    state: stateText(row),
154    group: stateGroup(row),
155    task: taskCell(progress),
156    isOwn: row.sessionId === data.ownSessionId,
157    isStale,
158    isListed: true,
159    place,
160    time,
161    est: progress && !row.isEnded && !isStale ? estText(progress, now) : null,
162  }
163}
164
165function staleRow(progress: Progress): BoardRow {
166  return {
167    key: `stale:${progress.sessionId}`,
168    name: progress.sessionId.slice(0, 8),
169    kind: '—',
170    state: 'not listed',
171    group: 'idle',
172    task: taskCell(progress),
173    isOwn: true,
174    isStale: true,
175    isListed: false,
176    place: lastSegment(progress.cwd),
177    time: `${formatDuration(progress.workedMs)} worked`,
178    est: null,
179  }
180}
181
182export function isOldBackground(row: AgentRow, ownSessionId: string, now: number): boolean {
183  return (
184    row.kind === 'background' &&
185    row.sessionId !== ownSessionId &&
186    now - row.stateSince > HIDE_BACKGROUND_AFTER_MS
187  )
188}
189
190function boardOrder(a: BoardRow, b: BoardRow): number {
191  return Number(b.isOwn) - Number(a.isOwn) || GROUP_ORDER[a.group] - GROUP_ORDER[b.group]
192}
193
194export function boardView(data: BoardData): BoardView {
195  const { cache, now } = data
196  if (cache?.outcome.kind !== 'ok') return { rows: [], hidden: 0 }
197  const bySession = new Map(data.progress.map((entry) => [entry.sessionId, entry]))
198  const all = cache.outcome.rows
199  const listed = new Set(all.flatMap((row) => (row.sessionId === null ? [] : [row.sessionId])))
200  const shown = all.filter((row) => !isOldBackground(row, data.ownSessionId, now))
201  const rows = [
202    ...shown.map((row) =>
203      agentRow(row, row.sessionId === null ? undefined : bySession.get(row.sessionId), data, cache),
204    ),
205    ...data.progress
206      .filter((entry) => entry.sessionId === data.ownSessionId && !listed.has(entry.sessionId))
207      .map(staleRow),
208  ]
209  return { rows: rows.sort(boardOrder), hidden: all.length - shown.length }
210}
211
212function text(elements: BoardElements, children: string, style: TextStyle = {}): RenderElement {
213  return elements.Text({ ...style, children })
214}
215
216function box(elements: BoardElements, style: BoxStyle, children: readonly Child[]): RenderElement {
217  return elements.Box({ ...style, children: children.filter((child) => child !== null) })
218}
219
220function toneStyle(tone: Tone): TextStyle {
221  if (tone === 'success') return { color: 'success' }
222  if (tone === 'warning') return { color: 'warning' }
223  if (tone === 'dim') return { dimColor: true }
224  return {}
225}
226
227function kept(elements: BoardElements, child: RenderElement): RenderElement {
228  return box(elements, { flexShrink: 0 }, [child])
229}
230
231function taskLine(elements: BoardElements, row: BoardRow): RenderElement {
232  const { task } = row
233  const stale = row.isStale ? kept(elements, text(elements, ' (stale)', { dimColor: true })) : null
234  if (task.kind !== 'task') {
235    const label = task.kind === 'no-data' ? '—' : 'no tasks'
236    return box(elements, { flexDirection: 'row' }, [
237      kept(elements, text(elements, label, { dimColor: true })),
238      stale,
239    ])
240  }
241  const title = task.current ?? `all ${String(task.total)} done`
242  return box(elements, { flexDirection: 'row' }, [
243    kept(elements, text(elements, '▶ ', { dimColor: true })),
244    box(elements, { flexShrink: 1 }, [text(elements, title, { wrap: 'truncate-end' })]),
245    box(elements, { flexShrink: 0, marginLeft: 1 }, [
246      text(elements, progressText(task.done, task.total)),
247    ]),
248    stale,
249  ])
250}
251
252function cardHeader(elements: BoardElements, row: BoardRow): RenderElement {
253  const mark = MARKS[row.group]
254  return box(elements, { flexDirection: 'row' }, [
255    box(elements, { flexShrink: 0 }, [text(elements, `${mark.glyph} `, mark.style)]),
256    box(elements, { flexShrink: 1 }, [
257      text(elements, row.name, { bold: true, wrap: 'truncate-end' }),
258    ]),
259    row.kind === '—'
260      ? null
261      : box(elements, { flexShrink: 0, marginLeft: 2 }, [
262          text(elements, row.kind, { dimColor: true }),
263        ]),
264    row.isOwn
265      ? box(elements, { flexShrink: 0, marginLeft: 2 }, [
266          text(elements, 'this', { color: 'suggestion' }),
267        ])
268      : null,
269    box(elements, { flexShrink: 1, marginLeft: 2 }, [
270      text(elements, row.state, { ...toneStyle(GROUP_TONES[row.group]), wrap: 'truncate-end' }),
271    ]),
272  ])
273}
274
275function placeLine(row: BoardRow): string {
276  return [row.place, row.time, ...(row.est === null ? [] : [row.est])]
277    .filter((part) => part !== '')
278    .join(' · ')
279}
280
281function card(elements: BoardElements, row: BoardRow): RenderElement {
282  return box(elements, { key: `card:${row.key}`, flexDirection: 'column' }, [
283    cardHeader(elements, row),
284    box(elements, { paddingLeft: DETAIL_INDENT }, [taskLine(elements, row)]),
285    box(elements, { paddingLeft: DETAIL_INDENT }, [
286      text(elements, placeLine(row), { dimColor: true, wrap: 'truncate-end' }),
287    ]),
288  ])
289}
290
291function cards(elements: BoardElements, rows: readonly BoardRow[], columns: number): Child[] {
292  const rule = () => text(elements, CARD_RULE.repeat(columns), { dimColor: true })
293  return rows.flatMap((row, index) => [index === 0 ? null : rule(), card(elements, row)])
294}
295
296export function errorLines(outcome: AgentsOutcome): string[] {
297  if (outcome.kind === 'not-on-path') {
298    return ['claude is not on PATH, so other sessions cannot be listed.']
299  }
300  if (outcome.kind === 'exit') {
301    return [`claude agents --json failed: exit ${String(outcome.exitCode)}.`]
302  }
303  if (outcome.kind === 'not-a-list') {
304    return ['claude agents --json failed: the output is not a JSON list.']
305  }
306  if (outcome.kind === 'did-not-run') return ['claude agents --json could not run.']
307  if (outcome.kind === 'cut') {
308    return ['claude agents --json failed: the output is over 4 MiB and was cut.']
309  }
310  return []
311}
312
313function errorView(elements: BoardElements, outcome: AgentsOutcome): RenderElement {
314  const retry =
315    outcome.kind === 'not-on-path'
316      ? null
317      : box(elements, { flexDirection: 'row' }, [
318          text(elements, 'Run it in a shell to see why.', { color: 'error' }),
319          text(elements, `  ${RETRY_TEXT}`, { dimColor: true }),
320        ])
321  return box(elements, { flexDirection: 'column' }, [
322    text(elements, 'Sessions', { bold: true }),
323    ...errorLines(outcome).map((line) => text(elements, line, { color: 'error' })),
324    retry,
325  ])
326}
327
328function isOnlyThisSession(view: BoardView): boolean {
329  return view.hidden === 0 && view.rows.length === 1 && view.rows[0]?.isOwn === true
330}
331
332export function headerText(data: BoardData, view: BoardView): string {
333  const age = data.cache === undefined ? '' : `polled ${formatAge(data.now - data.cache.at)} ago`
334  if (isOnlyThisSession(view)) return age
335  return `${String(view.rows.length)} local · ${age}`
336}
337
338export function hiddenText(hidden: number): string {
339  const jobs = hidden === 1 ? 'job' : 'jobs'
340  return `${String(hidden)} older background ${jobs} hidden`
341}
342
343export function renderBoard(
344  elements: BoardElements,
345  data: BoardData,
346  bodyColumns: number,
347): RenderElement {
348  const { cache } = data
349  if (cache === undefined) {
350    return box(elements, { flexDirection: 'column' }, [
351      text(elements, 'Sessions', { bold: true }),
352      text(elements, 'Listing the local sessions…', { dimColor: true }),
353    ])
354  }
355  if (cache.outcome.kind !== 'ok') return errorView(elements, cache.outcome)
356  const view = boardView(data)
357  const note = (line: string) => text(elements, line, { dimColor: true })
358  return box(elements, { flexDirection: 'column' }, [
359    box(elements, { flexDirection: 'row' }, [
360      text(elements, 'Sessions', { bold: true }),
361      note(`  ${headerText(data, view)}`),
362    ]),
363    isOnlyThisSession(view) ? note('Only this session is running.') : null,
364    view.rows.every((row) => !row.isListed) ? note('No local sessions are listed.') : null,
365    ...cards(elements, view.rows, bodyColumns),
366    view.hidden > 0 ? note(hiddenText(view.hidden)) : null,
367  ])
368}
369
370function ownRow(data: DesktopData): BoardRow {
371  const { own, now } = data
372  const isWorking = own?.isWorking ?? false
373  return {
374    key: 'this-session',
375    name: 'this session',
376    kind: '—',
377    state: isWorking ? 'working' : 'idle',
378    group: isWorking ? 'working' : 'idle',
379    task: taskCell(own),
380    isOwn: false,
381    isStale: false,
382    isListed: false,
383    place: '',
384    time: `${formatDuration(own ? workedMs(own, now) : 0)} worked`,
385    est: own ? estText(own, now) : null,
386  }
387}
388
389function subagentLines(elements: BoardElements, subagents: readonly AgentInfo[]): Child {
390  if (subagents.length === 0) return null
391  return box(elements, { flexDirection: 'column' }, [
392    text(elements, 'subagents', { dimColor: true }),
393    ...subagents.map((agent) =>
394      box(elements, { key: `agent:${agent.id}`, flexDirection: 'row', paddingLeft: 2 }, [
395        text(elements, agent.type),
396        text(elements, `  ${AGENT_WORDS[agent.status] ?? agent.status}`, { dimColor: true }),
397        agent.description === ''
398          ? null
399          : text(elements, `  ${agent.description}`, { dimColor: true, wrap: 'truncate-end' }),
400      ]),
401    ),
402  ])
403}
404
405export function renderDesktop(elements: BoardElements, data: DesktopData): RenderElement {
406  return box(elements, { flexDirection: 'column' }, [
407    text(elements, 'Sessions', { bold: true }),
408    text(
409      elements,
410      'Other sessions are listed only in a terminal session (claude agents needs a CLI).',
411      { dimColor: true },
412    ),
413    box(elements, { marginTop: 1, flexDirection: 'column' }, [
414      card(elements, ownRow(data)),
415      subagentLines(elements, data.subagents),
416    ]),
417  ])
418}
419
hooks/progress.ts 163 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'task-pane': { list: unknown }
4  }
5}
6
7export const PROGRESS_PREFIX = 'session:'
8export const EST_QUIET_MS = 5 * 60_000
9export const STALE_KEEP_MS = 24 * 60 * 60_000
10export const STALE_MARGIN_MS = 60_000
11
12export type TaskView = {
13  current: string | null
14  done: number
15  total: number
16  ids: number[]
17}
18
19export type Progress = {
20  sessionId: string
21  cwd: string
22  updatedAt: number
23  isWorking: boolean
24  turnStartedAt: number | null
25  workedMs: number
26  hasTasks: boolean
27  task: string | null
28  done: number
29  total: number
30  taskIds: number[]
31  firstTaskAt: number | null
32  lastAddedAt: number | null
33}
34
35type TaskEntry = { id: number; title: string; status: string }
36
37function isRecord(value: unknown): value is Record<string, unknown> {
38  return typeof value === 'object' && value !== null && !Array.isArray(value)
39}
40
41function isTaskEntry(value: unknown): value is TaskEntry {
42  return (
43    isRecord(value) &&
44    typeof value.id === 'number' &&
45    typeof value.title === 'string' &&
46    typeof value.status === 'string'
47  )
48}
49
50export function progressKey(sessionId: string): string {
51  return `${PROGRESS_PREFIX}${sessionId}`
52}
53
54export function readTaskList(value: unknown): TaskView | null {
55  if (!isRecord(value) || !Array.isArray(value.tasks)) return null
56  const tasks = value.tasks.filter(isTaskEntry)
57  const current =
58    tasks.find((task) => task.status === 'in_progress') ??
59    tasks.find((task) => task.status === 'pending')
60  return {
61    current: current?.title ?? null,
62    done: tasks.filter((task) => task.status === 'completed').length,
63    total: tasks.length,
64    ids: tasks.map((task) => task.id),
65  }
66}
67
68export function emptyProgress(sessionId: string, cwd: string, now: number): Progress {
69  return {
70    sessionId,
71    cwd,
72    updatedAt: now,
73    isWorking: false,
74    turnStartedAt: null,
75    workedMs: 0,
76    hasTasks: false,
77    task: null,
78    done: 0,
79    total: 0,
80    taskIds: [],
81    firstTaskAt: null,
82    lastAddedAt: null,
83  }
84}
85
86export function readProgress(value: unknown): Progress | null {
87  if (!isRecord(value)) return null
88  const { sessionId, updatedAt, workedMs, taskIds } = value
89  if (typeof sessionId !== 'string' || typeof updatedAt !== 'number') return null
90  if (typeof workedMs !== 'number' || !Array.isArray(taskIds)) return null
91  return value as Progress
92}
93
94export function withTasks(progress: Progress, view: TaskView | null, now: number): Progress {
95  if (view === null || view.total === 0) {
96    return {
97      ...progress,
98      updatedAt: now,
99      hasTasks: view !== null,
100      task: null,
101      done: 0,
102      total: 0,
103      taskIds: [],
104      firstTaskAt: null,
105      lastAddedAt: null,
106    }
107  }
108  const isAdded = view.ids.some((id) => !progress.taskIds.includes(id))
109  return {
110    ...progress,
111    updatedAt: now,
112    hasTasks: true,
113    task: view.current,
114    done: view.done,
115    total: view.total,
116    taskIds: view.ids,
117    firstTaskAt: firstTaskTime(progress, view, now),
118    lastAddedAt: isAdded ? now : progress.lastAddedAt,
119  }
120}
121
122function firstTaskTime(progress: Progress, view: TaskView, now: number): number | null {
123  if (progress.taskIds.length > 0) return progress.firstTaskAt
124  return view.done > 0 ? null : now
125}
126
127export function startTurn(progress: Progress, now: number): Progress {
128  return { ...progress, updatedAt: now, isWorking: true, turnStartedAt: now }
129}
130
131export function completeTurn(progress: Progress, now: number): Progress {
132  const span = progress.turnStartedAt === null ? 0 : Math.max(now - progress.turnStartedAt, 0)
133  return {
134    ...progress,
135    updatedAt: now,
136    isWorking: false,
137    turnStartedAt: null,
138    workedMs: progress.workedMs + span,
139  }
140}
141
142export function workedMs(progress: Progress, now: number, openSince = -Infinity): number {
143  const start = progress.turnStartedAt
144  const running = start === null || start < openSince ? 0 : Math.max(now - start, 0)
145  return progress.workedMs + running
146}
147
148export function estimateLeftMs(progress: Progress, now: number): number | null {
149  if (progress.done < 1 || progress.done >= progress.total) return null
150  if (progress.firstTaskAt === null) return null
151  if (progress.lastAddedAt !== null && now - progress.lastAddedAt < EST_QUIET_MS) return null
152  const spent = now - progress.firstTaskAt
153  return (spent * (progress.total - progress.done)) / progress.done
154}
155
156export function isOlderThanStart(progress: Progress, startedAt: number): boolean {
157  return startedAt - progress.updatedAt > STALE_MARGIN_MS
158}
159
160export function isExpired(progress: Progress, liveSessionIds: ReadonlySet<string>, now: number) {
161  return !liveSessionIds.has(progress.sessionId) && now - progress.updatedAt > STALE_KEEP_MS
162}
163
types/index.d.ts 8 lines
1export type SessionBoardReady = { root: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'session-board': { ready: SessionBoardReady }
6  }
7}
8