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

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.
/session-board opens the board
/session-board close closes the board
inter or bg badge, this on the current session, and the state word.| Mark | State | Colour |
|---|---|---|
● | busy, working | success |
◐ | waiting, blocked, or any waitingFor | warning |
○ | idle, an unknown word, done, stopped | dim |
✕ | failed | error |
▶ Write the tests ▰▰▱▱ 2/4.<worktree> · <branch> · <time>, with est. <time> left when the board has an estimate.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.Sessions N local, is the number of cards shown.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.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.$.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.
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.claude agents does not list. The one exception is this session, before a poll lists it: its card shows not listed and (stale).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).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.claude is not on PATH, so other sessions cannot be listed. when the command cannot start.claude agents gives no end time. So an ended session shows ended with no age.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.claude --plugin-dir mods
claude plugin test mods/session-boardhooks/register.tsx 222 lines1import 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}
222hooks/agents.ts 264 lines1import 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}
264hooks/board.tsx 419 lines1import 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}
419hooks/progress.ts 163 lines1declare 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}
163types/index.d.ts 8 lines1export type SessionBoardReady = { root: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'session-board': { ready: SessionBoardReady }
6 }
7}
8