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
interactive or background badge, this on the current session, and the state word in the colour of the mark.| Mark | Signal | State | Colour |
|---|---|---|---|
▶ | Doing | busy, working | claude |
◆ | Waits for you | waiting, blocked, or any waitingFor | warning |
○ | To do | idle, an unknown word | subtle |
✓ | Done | done, stopped | success |
■ | Blocked | failed | error |
docs/design.md. hooks/signals.ts holds the table. task-pane and agent-board keep the same table, and scripts/signals.test.mjs fails when two tables differ.idle draws as To do, not as Waits for you. An idle session is between turns and asks you nothing. A waitingFor value, such as permission, draws as Waits for you. stopped draws as Done, because the session left the work. The state word still says stopped.Write the tests 2 of 4. A session with no store key has no task line.<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 handily-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 no task line.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/handily-session-boardhooks/register.tsx 223 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: 'handily-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:
142 'handily · Shows every local Claude Code session: state, task, worktree and time.',
143 argumentHint: '[close]',
144 })
145 await deleteExpired($, new Set([await $.session.id()]))
146 await updateProgress($, board, (progress, now, tasks) => withTasks(progress, tasks, now))
147 await $.state.set({ plugin: 'handily-session-board', key: 'ready' }, { root: $.plugin.root })
148 return started
149 })
150
151 on('turn.start', async ($, e, next) => {
152 await updateProgress($, board, (progress, now, tasks) =>
153 withTasks(startTurn(progress, now), tasks, now),
154 )
155 return next(e)
156 })
157
158 on('turn.complete', async ($, e, next) => {
159 const completed = await next(e)
160 if (e.agentId === undefined) {
161 await updateProgress($, board, (progress, now, tasks) =>
162 withTasks(completeTurn(progress, now), tasks, now),
163 )
164 }
165 return completed
166 })
167
168 on('state.set', { plugin: 'handily-task-pane', key: 'list' }, async ($, e, next) => {
169 const result = await next(e)
170 if (result.deny === undefined && result.value.isSet) {
171 await updateProgress($, board, (progress, now) =>
172 withTasks(progress, readTaskList(e.value), now),
173 )
174 }
175 return result
176 }).catch((_$, e, next) => next(e))
177
178 on('ui.close', { id: PANE_ID }, async (_$, e, next) => {
179 const closed = await next(e)
180 stopPolling(board)
181 return closed
182 }).catch((_$, e, next) => next(e))
183
184 on('command.run', { command: 'session-board' }, async ($, e) => {
185 const argument = e.args.trim()
186 if (argument === 'close') {
187 stopPolling(board)
188 await $.ui.close({ id: PANE_ID })
189 return { text: 'Session board closed.' }
190 }
191 if (argument !== '') {
192 return {
193 text: `Unknown argument "${argument}". Use /session-board or /session-board close.`,
194 }
195 }
196 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
197 if (!(await canListSessions($))) {
198 return {
199 text: 'Session board opened. Other sessions are listed only in a terminal session.',
200 }
201 }
202 startPolling($, board)
203 return { text: 'Session board opened.' }
204 })
205
206 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
207 const elements = $.ui.resolve(e)
208 const now = await $.clock.now()
209 const ownSessionId = await $.session.id()
210 if (!(await canListSessions($))) {
211 const own = readProgress(await $.store.get(progressKey(ownSessionId))) ?? undefined
212 return renderDesktop(elements, { now, own, subagents: await $.agent.list() })
213 }
214 startPolling($, board)
215 const cache = readCache(await $.store.get(AGENTS_CACHE_KEY))
216 const progress = await readAllProgress($)
217 return renderBoard(elements, { now, ownSessionId, cache, progress }, e.props.bodyColumns)
218 }).catch(($, e, next) => {
219 $.ui.log(`session-board: the board could not be drawn (${next.error.kind})`, { to: 'debug' })
220 return next(e)
221 })
222}
223hooks/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 405 lines1import type { AgentInfo, Elements, RenderElement, RenderSurface, TextProps } from 'claude-code'
2import {
3 POLL_INTERVAL_MS,
4 type AgentKind,
5 type AgentRow,
6 type AgentsCache,
7 type AgentsOutcome,
8} from './agents'
9import { estimateLeftMs, isOlderThanStart, workedMs, type Progress } from './progress'
10import { SIGNALS, type Signal } from './signals'
11
12export const HIDE_BACKGROUND_AFTER_MS = 24 * 60 * 60_000
13
14type BoardElements = Pick<Elements[RenderSurface], 'Box' | 'Text'>
15
16export type StateGroup = 'working' | 'waiting' | 'idle' | 'ended' | 'failed'
17
18export type TaskCell =
19 | { kind: 'task'; current: string | null; done: number; total: number }
20 | { kind: 'no-tasks' }
21 | { kind: 'no-data' }
22
23export type BoardRow = {
24 key: string
25 name: string
26 kind: AgentKind | null
27 state: string
28 group: StateGroup
29 task: TaskCell
30 isOwn: boolean
31 isStale: boolean
32 isListed: boolean
33 place: string
34 time: string
35 est: string | null
36}
37
38export type BoardView = { rows: BoardRow[]; hidden: number }
39
40export type BoardData = {
41 now: number
42 ownSessionId: string
43 cache: AgentsCache | undefined
44 progress: readonly Progress[]
45}
46
47export type DesktopData = {
48 now: number
49 own: Progress | undefined
50 subagents: readonly AgentInfo[]
51}
52
53const WARNING_WORDS = new Set(['waiting', 'blocked'])
54const SUCCESS_WORDS = new Set(['busy', 'working'])
55const AGENT_WORDS: Partial<Record<AgentInfo['status'], string>> = { completed: 'done' }
56const RETRY_TEXT = `retry in ${String(POLL_INTERVAL_MS / 1000)} s`
57const GROUP_ORDER: Record<StateGroup, number> = {
58 working: 0,
59 waiting: 1,
60 idle: 2,
61 ended: 3,
62 failed: 3,
63}
64const DETAIL_INDENT = 2
65const CARD_RULE = '─'
66
67type TextStyle = Omit<TextProps, 'children'>
68type BoxStyle = Omit<Parameters<BoardElements['Box']>[0], 'children'>
69type Child = RenderElement | null
70
71const GROUP_SIGNALS: Record<StateGroup, Signal> = {
72 working: 'doing',
73 waiting: 'waitsForYou',
74 idle: 'toDo',
75 ended: 'done',
76 failed: 'blocked',
77}
78
79export function formatDuration(ms: number): string {
80 const minutes = Math.max(Math.floor(ms / 60_000), 0)
81 if (minutes < 60) return `${String(minutes)}m`
82 const hours = Math.floor(minutes / 60)
83 if (hours < 24) return `${String(hours)}h ${String(minutes % 60).padStart(2, '0')}m`
84 return `${String(Math.floor(hours / 24))}d ${String(hours % 24).padStart(2, '0')}h`
85}
86
87export function formatAge(ms: number): string {
88 const seconds = Math.max(Math.floor(ms / 1000), 0)
89 if (seconds < 60) return `${String(seconds)} s`
90 return `${String(Math.floor(seconds / 60))} min`
91}
92
93export function progressText(done: number, total: number): string {
94 return `${String(done)} of ${String(total)}`
95}
96
97function lastSegment(path: string): string {
98 return (
99 path
100 .split(/[\\/]+/)
101 .filter((part) => part !== '')
102 .at(-1) ?? path
103 )
104}
105
106export function stateText(row: AgentRow): string {
107 return row.waitingFor === null ? row.word : `${row.word}: ${row.waitingFor}`
108}
109
110export function stateGroup(row: AgentRow): StateGroup {
111 if (row.isEnded) return row.word === 'failed' ? 'failed' : 'ended'
112 if (row.waitingFor !== null || WARNING_WORDS.has(row.word)) return 'waiting'
113 if (SUCCESS_WORDS.has(row.word)) return 'working'
114 return 'idle'
115}
116
117function taskCell(progress: Progress | undefined): TaskCell {
118 if (progress === undefined) return { kind: 'no-data' }
119 if (!progress.hasTasks || progress.total === 0) return { kind: 'no-tasks' }
120 return { kind: 'task', current: progress.task, done: progress.done, total: progress.total }
121}
122
123function estText(progress: Progress, now: number): string | null {
124 const left = estimateLeftMs(progress, now)
125 return left === null ? null : `est. ${formatDuration(left)} left`
126}
127
128function agentRow(
129 row: AgentRow,
130 progress: Progress | undefined,
131 data: BoardData,
132 cache: AgentsCache,
133): BoardRow {
134 const { now } = data
135 const known = cache.places[row.key]
136 const place = known
137 ? `${known.place.worktree} · ${known.place.branch ?? 'no branch'}`
138 : lastSegment(row.cwd)
139 const isStale = progress !== undefined && isOlderThanStart(progress, row.startedAt)
140 let time = `${formatDuration(now - row.startedAt)} elapsed`
141 if (row.isEnded) time = 'ended'
142 else if (progress) time = `${formatDuration(workedMs(progress, now, row.startedAt))} worked`
143 return {
144 key: row.key,
145 name: row.name,
146 kind: row.kind,
147 state: stateText(row),
148 group: stateGroup(row),
149 task: taskCell(progress),
150 isOwn: row.sessionId === data.ownSessionId,
151 isStale,
152 isListed: true,
153 place,
154 time,
155 est: progress && !row.isEnded && !isStale ? estText(progress, now) : null,
156 }
157}
158
159function staleRow(progress: Progress): BoardRow {
160 return {
161 key: `stale:${progress.sessionId}`,
162 name: progress.sessionId.slice(0, 8),
163 kind: null,
164 state: 'not listed',
165 group: 'idle',
166 task: taskCell(progress),
167 isOwn: true,
168 isStale: true,
169 isListed: false,
170 place: lastSegment(progress.cwd),
171 time: `${formatDuration(progress.workedMs)} worked`,
172 est: null,
173 }
174}
175
176export function isOldBackground(row: AgentRow, ownSessionId: string, now: number): boolean {
177 return (
178 row.kind === 'background' &&
179 row.sessionId !== ownSessionId &&
180 now - row.stateSince > HIDE_BACKGROUND_AFTER_MS
181 )
182}
183
184function boardOrder(a: BoardRow, b: BoardRow): number {
185 return Number(b.isOwn) - Number(a.isOwn) || GROUP_ORDER[a.group] - GROUP_ORDER[b.group]
186}
187
188export function boardView(data: BoardData): BoardView {
189 const { cache, now } = data
190 if (cache?.outcome.kind !== 'ok') return { rows: [], hidden: 0 }
191 const bySession = new Map(data.progress.map((entry) => [entry.sessionId, entry]))
192 const all = cache.outcome.rows
193 const listed = new Set(all.flatMap((row) => (row.sessionId === null ? [] : [row.sessionId])))
194 const shown = all.filter((row) => !isOldBackground(row, data.ownSessionId, now))
195 const rows = [
196 ...shown.map((row) =>
197 agentRow(row, row.sessionId === null ? undefined : bySession.get(row.sessionId), data, cache),
198 ),
199 ...data.progress
200 .filter((entry) => entry.sessionId === data.ownSessionId && !listed.has(entry.sessionId))
201 .map(staleRow),
202 ]
203 return { rows: rows.sort(boardOrder), hidden: all.length - shown.length }
204}
205
206function text(elements: BoardElements, children: string, style: TextStyle = {}): RenderElement {
207 return elements.Text({ ...style, children })
208}
209
210function box(elements: BoardElements, style: BoxStyle, children: readonly Child[]): RenderElement {
211 return elements.Box({ ...style, children: children.filter((child) => child !== null) })
212}
213
214function kept(elements: BoardElements, child: RenderElement): RenderElement {
215 return box(elements, { flexShrink: 0 }, [child])
216}
217
218function taskLine(elements: BoardElements, row: BoardRow): Child {
219 const { task } = row
220 if (task.kind === 'no-data') return null
221 const stale = row.isStale ? kept(elements, text(elements, ' (stale)', { dimColor: true })) : null
222 if (task.kind === 'no-tasks') {
223 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
224 kept(elements, text(elements, 'no tasks', { dimColor: true })),
225 stale,
226 ])
227 }
228 const title = task.current ?? `all ${String(task.total)} done`
229 return box(elements, { flexDirection: 'row', paddingLeft: DETAIL_INDENT }, [
230 box(elements, { flexShrink: 1 }, [text(elements, title, { wrap: 'truncate-end' })]),
231 box(elements, { flexShrink: 0, marginLeft: 1 }, [
232 text(elements, progressText(task.done, task.total)),
233 ]),
234 stale,
235 ])
236}
237
238function cardHeader(elements: BoardElements, row: BoardRow): RenderElement {
239 const { mark, color } = SIGNALS[GROUP_SIGNALS[row.group]]
240 return box(elements, { flexDirection: 'row' }, [
241 box(elements, { flexShrink: 0 }, [text(elements, `${mark} `, { color })]),
242 box(elements, { flexShrink: 1 }, [
243 text(elements, row.name, { bold: true, wrap: 'truncate-end' }),
244 ]),
245 row.kind === null
246 ? null
247 : box(elements, { flexShrink: 0, marginLeft: 2 }, [
248 text(elements, row.kind, { dimColor: true }),
249 ]),
250 row.isOwn
251 ? box(elements, { flexShrink: 0, marginLeft: 2 }, [
252 text(elements, 'this', { color: 'suggestion' }),
253 ])
254 : null,
255 box(elements, { flexShrink: 1, marginLeft: 2 }, [
256 text(elements, row.state, { color, wrap: 'truncate-end' }),
257 ]),
258 ])
259}
260
261function placeLine(row: BoardRow): string {
262 return [row.place, row.time, ...(row.est === null ? [] : [row.est])]
263 .filter((part) => part !== '')
264 .join(' · ')
265}
266
267function card(elements: BoardElements, row: BoardRow): RenderElement {
268 return box(elements, { key: `card:${row.key}`, flexDirection: 'column' }, [
269 cardHeader(elements, row),
270 taskLine(elements, row),
271 box(elements, { paddingLeft: DETAIL_INDENT }, [
272 text(elements, placeLine(row), { dimColor: true, wrap: 'truncate-end' }),
273 ]),
274 ])
275}
276
277function cards(elements: BoardElements, rows: readonly BoardRow[], columns: number): Child[] {
278 const rule = () => text(elements, CARD_RULE.repeat(columns), { dimColor: true })
279 return rows.flatMap((row, index) => [index === 0 ? null : rule(), card(elements, row)])
280}
281
282export function errorLines(outcome: AgentsOutcome): string[] {
283 if (outcome.kind === 'not-on-path') {
284 return ['claude is not on PATH, so other sessions cannot be listed.']
285 }
286 if (outcome.kind === 'exit') {
287 return [`claude agents --json failed: exit ${String(outcome.exitCode)}.`]
288 }
289 if (outcome.kind === 'not-a-list') {
290 return ['claude agents --json failed: the output is not a JSON list.']
291 }
292 if (outcome.kind === 'did-not-run') return ['claude agents --json could not run.']
293 if (outcome.kind === 'cut') {
294 return ['claude agents --json failed: the output is over 4 MiB and was cut.']
295 }
296 return []
297}
298
299function errorView(elements: BoardElements, outcome: AgentsOutcome): RenderElement {
300 const retry =
301 outcome.kind === 'not-on-path'
302 ? null
303 : box(elements, { flexDirection: 'row' }, [
304 text(elements, 'Run it in a shell to see why.', { color: 'error' }),
305 text(elements, ` ${RETRY_TEXT}`, { dimColor: true }),
306 ])
307 return box(elements, { flexDirection: 'column' }, [
308 text(elements, 'Sessions', { bold: true }),
309 ...errorLines(outcome).map((line) => text(elements, line, { color: 'error' })),
310 retry,
311 ])
312}
313
314function isOnlyThisSession(view: BoardView): boolean {
315 return view.hidden === 0 && view.rows.length === 1 && view.rows[0]?.isOwn === true
316}
317
318export function headerText(data: BoardData, view: BoardView): string {
319 const age = data.cache === undefined ? '' : `polled ${formatAge(data.now - data.cache.at)} ago`
320 if (isOnlyThisSession(view)) return age
321 return `${String(view.rows.length)} local · ${age}`
322}
323
324export function hiddenText(hidden: number): string {
325 const jobs = hidden === 1 ? 'job' : 'jobs'
326 return `${String(hidden)} older background ${jobs} hidden`
327}
328
329export function renderBoard(
330 elements: BoardElements,
331 data: BoardData,
332 bodyColumns: number,
333): RenderElement {
334 const { cache } = data
335 if (cache === undefined) {
336 return box(elements, { flexDirection: 'column' }, [
337 text(elements, 'Sessions', { bold: true }),
338 text(elements, 'Listing the local sessions…', { dimColor: true }),
339 ])
340 }
341 if (cache.outcome.kind !== 'ok') return errorView(elements, cache.outcome)
342 const view = boardView(data)
343 const note = (line: string) => text(elements, line, { dimColor: true })
344 return box(elements, { flexDirection: 'column' }, [
345 box(elements, { flexDirection: 'row' }, [
346 text(elements, 'Sessions', { bold: true }),
347 note(` ${headerText(data, view)}`),
348 ]),
349 isOnlyThisSession(view) ? note('Only this session is running.') : null,
350 view.rows.every((row) => !row.isListed) ? note('No local sessions are listed.') : null,
351 ...cards(elements, view.rows, bodyColumns),
352 view.hidden > 0 ? note(hiddenText(view.hidden)) : null,
353 ])
354}
355
356function ownRow(data: DesktopData): BoardRow {
357 const { own, now } = data
358 const isWorking = own?.isWorking ?? false
359 return {
360 key: 'this-session',
361 name: 'this session',
362 kind: null,
363 state: isWorking ? 'working' : 'idle',
364 group: isWorking ? 'working' : 'idle',
365 task: taskCell(own),
366 isOwn: false,
367 isStale: false,
368 isListed: false,
369 place: '',
370 time: `${formatDuration(own ? workedMs(own, now) : 0)} worked`,
371 est: own ? estText(own, now) : null,
372 }
373}
374
375function subagentLines(elements: BoardElements, subagents: readonly AgentInfo[]): Child {
376 if (subagents.length === 0) return null
377 return box(elements, { flexDirection: 'column' }, [
378 text(elements, 'subagents', { dimColor: true }),
379 ...subagents.map((agent) =>
380 box(elements, { key: `agent:${agent.id}`, flexDirection: 'row', paddingLeft: 2 }, [
381 text(elements, agent.type),
382 text(elements, ` ${AGENT_WORDS[agent.status] ?? agent.status}`, { dimColor: true }),
383 agent.description === ''
384 ? null
385 : text(elements, ` ${agent.description}`, { dimColor: true, wrap: 'truncate-end' }),
386 ]),
387 ),
388 ])
389}
390
391export function renderDesktop(elements: BoardElements, data: DesktopData): RenderElement {
392 return box(elements, { flexDirection: 'column' }, [
393 text(elements, 'Sessions', { bold: true }),
394 text(
395 elements,
396 'Other sessions are listed only in a terminal session (claude agents needs a CLI).',
397 { dimColor: true },
398 ),
399 box(elements, { marginTop: 1, flexDirection: 'column' }, [
400 card(elements, ownRow(data)),
401 subagentLines(elements, data.subagents),
402 ]),
403 ])
404}
405hooks/progress.ts 163 lines1declare module 'claude-code' {
2 interface PluginState {
3 'handily-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}
163hooks/signals.ts 14 lines1import type { ThemeKey } from 'claude-code'
2
3export type Signal = 'doing' | 'done' | 'toDo' | 'blocked' | 'waitsForYou'
4
5export type SignalLook = { mark: string; color: ThemeKey }
6
7export const SIGNALS: Readonly<Record<Signal, SignalLook>> = {
8 doing: { mark: '▶', color: 'claude' },
9 done: { mark: '✓', color: 'success' },
10 toDo: { mark: '○', color: 'subtle' },
11 blocked: { mark: '■', color: 'error' },
12 waitsForYou: { mark: '◆', color: 'warning' },
13}
14types/index.d.ts 8 lines1export type SessionBoardReady = { root: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'handily-session-board': { ready: SessionBoardReady }
6 }
7}
8