SLOPSHOPPER

handily-session-board

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

newpanecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · handily-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 │ ⎿ handily-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 up to 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 interactive or background badge, this on the current session, and the state word in the colour of the mark.
MarkSignalStateColour
▶Doingbusy, workingclaude
◆Waits for youwaiting, blocked, or any waitingForwarning
○To doidle, an unknown wordsubtle
✓Donedone, stoppedsuccess
■Blockedfailederror
  • The marks and colours are the signals of 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.
  • The first detail line is the current task, cut to fit, and the count of done tasks: Write the tests 2 of 4. A session with no store key has no task line.
  • 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 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.

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/handily-session-board
Source 6 files
hooks/register.tsx 223 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: '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}
223
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 405 lines
1import 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}
405
hooks/progress.ts 163 lines
1declare 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}
163
hooks/signals.ts 14 lines
1import 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}
14
types/index.d.ts 8 lines
1export type SessionBoardReady = { root: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'handily-session-board': { ready: SessionBoardReady }
6  }
7}
8