Alvearium (formerly session-board): one ticket board for all your Claude Code sessions and agents, terminal and claude.ai/code cloud: what waits on you, what…

Plugin id:
alvearium(marketplace:session-board).This repository kept its name so existing setups (cloud scripts, clones, server deploys) keep working.
One ticket board for all your Claude Code work, terminal and claude.ai/code cloud alike, and for every agent and subagent that works with you: what is waiting on you, what is in progress, what to do next. Every ticket knows its session, repository, branch, machine and who opened it, so you can look at everything at once or at one repo, one session, one agent. The page updates live.
WAITING ON YOU (3)
⌨ SB-5 Approve Bash: npm run db:migrate -- --env staging · 6m · api@fix/token-refresh
⌨ SB-6 Add REFRESH_LOCK_TTL to the staging secrets · 38m · api@fix/token-refresh
☁ SB-7 Add a sliding-window rate limiter to the public API [review] · 22m · api@claude/rate-limit
PR: https://github.com/acme/api/pull/412
open: https://claude.ai/code/session_01…
IN PROGRESS (2)
⌨ SB-1 Fix the token refresh race in the auth middleware · 2h · api@fix/token-refresh
⌨ SB-17 Accessibility pass on the product page (WCAG AA) · 25m · web
TO DO (4)
⌨ SB-4 Add a regression test · 2h · api@fix/token-refresh
…

More in docs/screenshots: the Next view, a ticket with its dependencies and its who → whom history, the feedback inbox, light and dark, desktop and phone, and a change made in one tab showing up in another without a reload.
Alvearium is a Claude Code plugin plus an optional self-hosted server.
What runs
hooks/hooks.json → hooks/report.mjs): one zero-dependency Node script run by Claude Code on hook events (SessionStart, PreToolUse, Stop…). It turns the session state into tickets.mcp/server.mjs, stdio): lets Claude create/update/list tickets through MCP tools (ticket_create, ticket_next, …).hooks/hooks.json → modules: ["./mod.mjs"]): draws the status line and implements an instant /board in the terminal (Claude Code v2.1.287+).commands/*.md): /board, /ticket, /install-cloud just run the Node scripts in scripts/.What it reads
SESSION_BOARD_URL, SESSION_BOARD_TOKEN, and optional knobs like SESSION_BOARD_SEND_TEXT, SESSION_BOARD_MIRROR_TASKS.~/.claude/session-board/board.db and small helper files under the same directory.server_url and token (stored by Claude Code; token is sensitive).What it sends
SESSION_BOARD_URL (or the server_url option). It never follows redirects (a redirect is treated as a gateway/login page, and is reported as an error).SESSION_BOARD_TOKEN or the token option). In claude.ai/code, SESSION_BOARD_TOKEN=proxy means: do not send an Authorization header; the cloud agent proxy injects the configured API credential.[redacted] before they leave (header values, Bearer …, ?token=/?key= query values, *_TOKEN=/*_KEY= assignments, --token/--password flags, URL passwords, and well-known token shapes). The server applies the same filter to what older clients send (0.4.2).A ticket is the unit of the board: key (SB-12), title, markdown body, status, kind, assignee (user, claude, or any agent id), priority P0–P3, labels, sub-tickets, dependencies, links (PR, cloud session, file, URL), and a history of every change and comment, with who made it and for whom. A session is a context: a session has zero, one or many tickets, and you filter on it.
Statuses: todo, in_progress, waiting_on_user, review, done, cancelled, failed. The board's columns are derived: Waiting on you = waiting on you, in review or failed, assigned to you; In progress; To do; Done folded away. Done tickets older than 30 days leave the default views (they are never deleted; Show archived brings them back).
Tickets come from three places:
| Hook event | Ticket | | :- | :- | | UserPromptSubmit, PreToolUse, PostToolUse | session ticket in progress; open questions close (tool calls throttled to one report per 20 s) | | PermissionRequest, Elicitation, Notification (permission_prompt, elicitation_dialog, agent_needs_input) | a waiting on you ticket (Approve Bash: …, or the question) | | Stop, Notification (agent_completed) | session ticket review, with the end of Claude's last message and the PR link | | StopFailure | session ticket failed, with the error | | SessionEnd | session ticket done; unanswered questions cancelled | | TaskCreated, TaskCompleted | Claude's own task list mirrored as claude tickets (SESSION_BOARD_MIRROR_TASKS=0 to turn off) | | SubagentStart, SubagentStop | a line in the session ticket's history: Claude → Explore: started it, Explore → Claude: finished: … |
ticket_create, ticket_update, ticket_next, ticket_list, ticket_comment, ticket_get, board_feedback) and a skill telling Claude when to use them: an action item only you can do ("add the STRIPE_KEY secret", "review PR #412"), a long task split into prioritized sub-tickets with their dependencies, what to pick next, what is left at the end, and what wasted its time. Tickets attach to the current session and repo by themselves./ticket).The hook is one zero-dependency Node script. It never blocks or fails your session: network calls time out after 2 s, every error is swallowed, it always exits 0, and hot-path events run async.
The page keeps one connection open to GET /api/stream (Server-Sent Events) and redraws only the cards that changed, with a short flash, the moment a hook, an agent or another tab changes a ticket: no reload, no flicker. The dot in the header says Live; if the stream cannot be opened (a proxy that buffers, a network change) it reconnects by itself with backoff and polls every 10 s meanwhile (Polling). An open ticket refreshes too, unless you are editing it.
The stream sends one change event per committed change, with a minimal payload ({ type, op, key, fields, version }: type is ticket, comment, dependency, reorder or session), and a : heartbeat comment every 15 s. Its headers (Cache-Control: no-cache, X-Accel-Buffering: no) keep nginx and similar proxies from buffering it. If you put the server behind your own reverse proxy, make sure it streams responses (for nginx, proxy_buffering off is implied by the header; for a Node relay, pipe the body instead of reading it whole).
P0 (drop everything) to P3 (some day); 0.2 values are migrated (urgent→P0, high→P1, medium→P2, low→P3), and the API still accepts those names.blocked_by on create, blocked_by_add / blocked_by_remove on update (API, MCP, /ticket block SB-5 --by SB-3, or the Dependencies section of the ticket panel). Many to many; a dependency that would close a cycle is refused. Each ticket carries blocked (an open blocker remains), blocked_by and blocking. A blocked card is hatched with a ⛔ badge. When its last blocker is done (or cancelled), the ticket is unblocked by itself and its history says so; reopening the blocker blocks it again.POST /api/tickets/SB-5/move { before | after }). Dropped among tickets of another priority, it takes that priority.GET /api/tickets/next, MCP ticket_next, /ticket next): open tickets that nothing blocks (to do and in progress), most important first, then your manual order, then the oldest. Session, question and feedback tickets are not work and stay out. Same filters as the list (repo, session, actor, assignee, label…), plus the number of blocked tickets left aside.An actor is whoever acts on the board: { id, type: human | agent | subagent | system, name }. Built in: user (you), claude, and hook (the session itself). Every history line carries its actor and, when there is one, a target: CI bot → You for a comment addressed to you, Claude → Explore when Claude starts a subagent, Explore → Claude when it reports back (from the SubagentStart / SubagentStop hooks, agent_id and agent_type), Claude → ci-bot when a ticket is handed over. Tickets record created_by; assignee takes any actor id (user and claude work as before). Cards show the assignee's avatar; the Actor filter shows what an actor holds or opened.
Optional environment variables, all off by default (nothing changes when they are unset), read by the hooks and the MCP server of a session:
| Variable | Effect | | :- | :- | | SESSION_BOARD_ACTOR | id of the agent running this session (e.g. ci-bot, job-42), instead of claude; its subagents become ci-bot/Explore | | SESSION_BOARD_ACTOR_NAME | its display name | | SESSION_BOARD_THREAD | one stable name for a conversation that lives across several short sessions (a bot, a tmux loop, CI): all those sessions share one session ticket, which follows the latest session and is not closed at SessionEnd | | SESSION_BOARD_SESSION_TICKETS=0 | no automatic tickets at all for this session (session, questions, task mirror, subagent lines): for routine or scripted sessions. Tickets created explicitly through MCP still attach to the session |
SESSION_BOARD_ACTOR is a name an agent gives itself, so with the main token anything an agent sends without it (the /ticket CLI, a bare curl) counts as you. An agent or a bot that runs sessions unattended (a daemon, CI) should get its own token instead: the actor then comes from the token, not from a header.
On the server, put one line per agent in agent-tokens in the data directory (or the file named by SESSION_BOARD_AGENT_TOKENS_FILE), and restart it:
# <actor> <token> generate a token with: openssl rand -hex 32
ci-bot 3f9c…
Give that agent its token as SESSION_BOARD_TOKEN instead of the main one. A request made with it:
ci-bot), or to a sub-actor under it named by SESSION_BOARD_ACTOR / the header (ci-bot/job-42, and its subagents ci-bot/job-42/Explore). user, claude, hook or another agent's id in the header fall back to ci-bot; a display name You or user is dropped;POST /api/import or DELETE /api/sessions/<id>: both replay or erase history (403).user and hook cannot be agent actors, every token must be at least 24 characters and differ from the main token, and the server refuses to start on a line it cannot read.
A board-wide inbox for what does not work or could work better, written by the sessions themselves. The MCP tool board_feedback (title, detail, type: bug | suggestion | friction, optional about and priority) files a ticket of kind feedback, with its context attached (session, repo, branch, machine, actor, plugin version). The skill tells every session to use it as soon as a tool, an instruction or the board itself makes it lose time, or when it sees an improvement.
Feedback stays out of the board and the lists (ask for it with kind=feedback). The page has its own Feedback view, with the open count in the header, filters by type, actor and repo, and a resolution box: Mark handled (done) or Won't do (cancelled), with a comment kept in the history.
Requires Node.js 22.13 or later (the board is stored with the built-in node:sqlite), on the PATH of the shell that starts claude. On Windows, check it in that same shell (PowerShell, cmd or Git Bash): node --version.
claude plugin marketplace add Iskandeur/alvearium
claude plugin install alvearium@session-board
Then restart the sessions that are already open: a plugin loads when a session starts. That's it for local mode: tickets live in ~/.claude/session-board/board.db (on Windows %USERPROFILE%\.claude\session-board\board.db) and cover every session on this machine.
Each release bumps version (Claude Code caches a plugin by its version: "a manifest that pins version keeps every user on the cached copy until its author changes the string", plugin loading). Where each kind of session gets it:
marketplace.json "has no field to turn it on" (install and manage plugins, host a marketplace). So, once per machine: /plugin → Marketplaces → session-board → Enable auto-update. Or in ~/.claude/settings.json: ``json { "extraKnownMarketplaces": { "session-board": { "source": { "source": "github", "repo": "Iskandeur/alvearium" }, "autoUpdate": true } } } ` From then on, a few minutes after the first message of a session, Claude Code fetches the new version; it loads at the next start (or /reload-plugins in the open session). With a server, a session whose plugin is older than the server says so once, with the command to update now. DISABLE_AUTOUPDATER=1 also turns plugin updates off unless FORCE_AUTOUPDATE_PLUGINS=1` is set.main (see Cloud sessions): the clone-time hook checks the published version (one 5-second request at most every 10 minutes) and replaces the environment's copy when it is newer, even when the environment is cached and the setup script is skipped./alvearium:install-cloud again and commit; a stale copy says so at session start.claude --plugin-dir <clone> (agents, scripts): keep the clone on main (git pull --ff-only on a timer); the next session started loads it.git pull && docker compose up -d --build. Update it first when a release says so.claude plugin marketplace update session-board
claude plugin update alvearium@session-board
Then restart every open session (/exit, then claude --continue to pick the conversation back up). If you run a server, update it first (git pull && docker compose up -d --build): 0.3 migrates the database in place (priorities, creators) after a full copy next to it (board.db.schema1-<date>.bak), and the 0.3 page needs the 0.3 API. A local board.db left by 0.2.0 moves to the server by itself at the next start (see Storage and upgrades). From 0.1: your existing sessions are imported on first use (local sessions/*.json, or the server's board.json, which is kept as board.json.migrated).
Run the server once, anywhere reachable over HTTPS:
git clone https://github.com/Iskandeur/alvearium && cd alvearium
openssl rand -hex 32 > token && chmod 600 token
docker compose up -d --build # or: SESSION_BOARD_TOKEN=… node server/server.mjs
Then give each machine the URL and the token, in any of these ways (first match wins):
SESSION_BOARD_URL=https://board.example.com and SESSION_BOARD_TOKEN=…. The simplest place, identical on every OS, is the env block of ~/.claude/settings.json (Windows: %USERPROFILE%\.claude\settings.json): ``json { "env": { "SESSION_BOARD_URL": "https://board.example.com", "SESSION_BOARD_TOKEN": "…" } } ``/plugin → Installed tab → select the plugin → Configure options. The URL also shows in /config, the token does not (Claude Code leaves sensitive options out of /config). The hooks and the MCP server read them; the status line does not, so prefer 1 or 3 if you want it;~/.claude/session-board/config.json: { "url": "…", "token": "…" }.The hooks, /board, /ticket and the MCP server all use the same setting. Agents and scripts that start claude themselves can load the plugin without installing it (claude --plugin-dir <clone of this repo>) and name themselves with the variables of Actors and threads.
/alvearium:board (or /board) prints the board. Its first line ends with source: server when the URL and token are picked up (source: this machine (local mode) otherwise); an unreachable server prints could not load the board.ticket_create; the ticket shows on the page within a second (open https://board.example.com/#token=<token> once, the browser remembers the token). Delete the test session afterwards from the page, or with DELETE /api/sessions/<id>.2 for you · 3 in progress · 5 to do (empty counts are left out; Claude Code 2.1.287 or later).SESSION_BOARD_DEBUG=1 (PowerShell: $env:SESSION_BOARD_DEBUG=1; claude) to print the hook errors, and try curl https://board.example.com/healthz, which answers without a token.Sessions on claude.ai/code do not load plugins: see Cloud sessions below.
Tickets live in one place at a time: the server when a URL and token are set, else ~/.claude/session-board/board.db on this machine. When that changes:
board.db to the server once (POST /api/import): tickets with their status, dates, labels, links, sub-tickets, comments and history, plus the sessions the server does not know. Keys are renumbered on the server (SB-3 may become SB-57; the ticket history says which key it had). Each ticket is recorded with its origin, so a second import never creates a duplicate, and the session tickets the server already follows through the hooks are not copied twice. When everything is in, board.db is renamed to board.db.uploaded-<date> (never deleted) and the session shows one line: session-board: 4 tickets from this machine's local board moved to the board on board.example.com./alvearium:board sync.The import needs the server to be 0.2.3 or later: update it (git pull && docker compose up -d --build) before or with the plugin.
Every /api/* route needs Authorization: Bearer <token>, the main token or an agent token (see Agent tokens). A client may say who it is with x-session-board-actor: <id> (and x-session-board-actor-name); the plugin's MCP server does. With an agent token the header can only name the agent or a sub-actor under it. GET / serves the web page (open it once as /#token=<token>, the browser remembers it); GET /healthz answers without a token.
| Route | | | :- | :- | | GET /api/tickets | list, newest first, paginated (limit ≤ 500, offset) | | GET /api/tickets/board | the same filters, grouped in board columns | | GET /api/tickets/next | open, unblocked work by priority → manual rank → age (same filters), plus the blocked count | | POST /api/tickets/SB-12/move | { before } or { after }: manual order | | GET /api/stream | Server-Sent Events: one change per change, a heartbeat every 15 s | | GET /api/actors | actors with their type, name, parent and open-ticket count | | POST /api/tickets | create { title, body, status, kind, subtype, assignee, priority, labels, parent, blocked_by, links, session_id, repo, branch } | | GET /api/tickets/SB-12 | one ticket with its history and sub-tickets | | POST or PATCH /api/tickets/SB-12 | update any field, blocked_by_add / blocked_by_remove, optional comment (and to) in the same call | | POST /api/tickets/SB-12/comments | { text, to? } | | GET /api/facets | repos, sessions, branches, machines, kinds, labels and actors with counts, and the open feedback count | | GET /api/sessions | sessions, most recent first (?repo=) | | DELETE /api/sessions/<id> | remove a session and its tickets | | POST /api/import | { board, machine, sessions, tickets }: a local board.db, idempotent (used by the plugin) | | POST /api/event, GET /api/board, POST /api/dismiss | v0.1 routes, still served (hooks and old clients) |
Filters, on the list and the board: session, repo (owner/name, or just name), branch, machine, origin (terminal/cloud), status (also open, closed), assignee, kind, label, source (hook/claude/user), priority (P0…P3, none), actor (holds or opened it), created_by, subtype, blocked=1|0, parent, q (full text over title, body, labels and comments; a key like SB-12 finds that ticket), created_after, created_before, updated_after, updated_before (ISO or epoch ms), archived=1|only, sort=updated|created|priority|key|next. Comma-separate several values: ?repo=acme/api&status=todo,in_progress&label=deploy.
Search box, filter chips (click a repo, a session or a label on any card to filter on it), Board, Next and List views (the list is grouped by session) and the Feedback inbox, live updates, drag and drop in To do and Next, a side panel with the ticket's fields, description, sub-tickets, dependencies (Blocked by / Blocks, add one by key), links and the history (who → whom), and a comment box. Every filter lives in the URL: share it, bookmark it, use the back button. Light and dark follow your system (?theme=light|dark forces one). One-click done: the round ✓ on any open card or row (on hover with a mouse, always on a phone), the Done button of the panel, or d on the focused card or open ticket closes it, with Undo for 6 seconds (desktop, [phone](docs/screenshots/done-mobi
hooks/mod.mjs 210 lines1// Alvearium mod (Claude Code v2.1.287+): draws the board inside the terminal.
2// · a status line under the prompt — "2 for you · 3 in progress · 5 to do", refreshed every 20 s
3// · /board — prints the whole ticket board at once, with no Claude turn (runs even while Claude works)
4// Older Claude Code versions ignore this file; the settings hooks in hooks.json still report.
5// Mods cannot open SQLite: in local mode the hooks keep ~/.claude/session-board/summary.json fresh.
6//
7// Self-contained on purpose (no imports): the directory reviewer wants to know exactly what runs.
8// Rendering logic is a subset of lib/core.mjs, duplicated here.
9
10const REFRESH_MS = 20_000
11let lastStatus
12
13export function register(on, options) {
14 on('session.start', async ($, e, next) => {
15 // Cloud session with the agent proxy's credential: this mod's fetch would not carry it (see
16 // notJson). Stay out of the way, so /board is the plugin's Node command, which goes through the proxy.
17 if ((await remote($, options).catch(() => ({}))).token === 'proxy') return next(e)
18 $.clock.every(REFRESH_MS, async () => {
19 await refreshStatus($, options)
20 })
21 refreshStatus($, options).catch(() => {})
22 try {
23 await $.command.register({
24 name: 'board',
25 description: 'Show the ticket board of every Claude Code session: waiting on you, in progress, to do',
26 immediate: true,
27 })
28 } catch {}
29 return next(e)
30 })
31
32 on('command.run', { command: 'board' }, async ($, e) => {
33 try {
34 return { text: '\n' + (await boardText($, options)) }
35 } catch (err) {
36 return { text: 'board unavailable: ' + (err && err.message ? err.message : String(err)) }
37 }
38 })
39}
40
41async function refreshStatus($, options) {
42 let text
43 try {
44 text = await statusFor($, options)
45 } catch {
46 text = 'board offline'
47 }
48 if (text === lastStatus) return
49 lastStatus = text
50 $.ui.status(text)
51}
52
53async function readJson($, path) {
54 try {
55 return JSON.parse(await $.fs.read(path))
56 } catch {
57 return null
58 }
59}
60
61async function localDir($) {
62 const custom = await $.env.get('SESSION_BOARD_DIR')
63 if (custom) return custom
64 const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
65 return home + '/.claude/session-board'
66}
67
68async function remote($, options) {
69 const dir = await localDir($)
70 const file = (await readJson($, dir + '/config.json')) || {}
71 const opts = options || {}
72 const url = (opts.server_url || (await $.env.get('SESSION_BOARD_URL')) || file.url || '').trim().replace(/\/+$/, '')
73 const token = (opts.token || (await $.env.get('SESSION_BOARD_TOKEN')) || file.token || '').trim()
74 return { dir, url, token, on: Boolean(url && token) }
75}
76
77async function get($, cfg, path) {
78 const headers = cfg.token === 'proxy' ? {} : { authorization: 'Bearer ' + cfg.token }
79 const res = await $.http.fetch(cfg.url + path, { headers })
80 if (!res.ok) throw new Error('HTTP ' + res.status)
81 try {
82 return JSON.parse(res.text)
83 } catch {
84 throw new Error(notJson(cfg, res.text))
85 }
86}
87
88// A login gateway in front of the board answers with its login page. In a claude.ai/code cloud
89// session (token "proxy"), this fetch is Claude Code's own and does not carry the API credential
90// that the agent proxy adds; the /alvearium:board command runs in Node and goes through the proxy.
91function notJson(cfg, text) {
92 const page = /^\s*</.test(text || '') ? 'the server answered with a login page instead of the board' : 'the server did not answer with the board'
93 return cfg.token === 'proxy'
94 ? page + ' (this request does not carry the cloud API credential). The plugin\'s board command goes through the proxy: /alvearium:board.'
95 : page + ' (is the token right?). /alvearium:board doctor says more.'
96}
97
98/** Status line: tickets waiting on you first. Old servers (no ticket counts) fall back to sessions. */
99async function statusFor($, options) {
100 const cfg = await remote($, options)
101 if (cfg.on) {
102 const b = await get($, cfg, '/api/board')
103 return (b.tickets ? ticketStatusText(b.tickets.counts) : statusText(b.counts)) || undefined
104 }
105 const s = await readJson($, cfg.dir + '/summary.json')
106 return s ? ticketStatusText(s.counts) || undefined : undefined
107}
108
109async function boardText($, options) {
110 const cfg = await remote($, options)
111 if (cfg.on) {
112 try {
113 return renderTicketsText(await get($, cfg, '/api/tickets/board?limit=50'))
114 } catch {
115 return renderText(await get($, cfg, '/api/board'))
116 }
117 }
118 const s = await readJson($, cfg.dir + '/summary.json')
119 return s ? s.text : 'No tickets yet on this machine.'
120}
121
122// ---- rendering helpers (subset of lib/core.mjs)
123
124function truncate(text, max) {
125 if (typeof text !== 'string') return ''
126 const flat = text.replace(/\s+/g, ' ').trim()
127 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
128}
129
130function ago(ms, now = Date.now()) {
131 const s = Math.max(0, Math.round((now - ms) / 1000))
132 if (s < 60) return `${s}s`
133 if (s < 3600) return `${Math.round(s / 60)}m`
134 if (s < 86400) return `${Math.round(s / 3600)}h`
135 return `${Math.round(s / 86400)}d`
136}
137
138function statusText(counts) {
139 const parts = []
140 if (counts.waiting) parts.push(`${counts.waiting} waiting`)
141 if (counts.working) parts.push(`${counts.working} working`)
142 if (counts.review) parts.push(`${counts.review} review`)
143 return parts.join(' · ')
144}
145
146function ticketStatusText(counts) {
147 const parts = []
148 if (counts.waiting) parts.push(`${counts.waiting} for you`)
149 if (counts.inProgress) parts.push(`${counts.inProgress} in progress`)
150 if (counts.todo) parts.push(`${counts.todo} to do`)
151 return parts.join(' · ')
152}
153
154function renderText(board) {
155 const lines = []
156 const now = board.now
157 const label = (rec) => {
158 const where = rec.repo || (rec.cwd ? rec.cwd.split(/[\\/]/).filter(Boolean).pop() : '') || 'session'
159 const branch = rec.branch && rec.branch !== 'HEAD' ? `@${rec.branch}` : ''
160 return rec.name ? `${rec.name} (${where}${branch})` : `${where}${branch}`
161 }
162 const line = (r) => {
163 const where = r.surface === 'cloud' ? '☁' : '⌨'
164 const badge = r.display === 'failed' ? ' [FAILED]' : r.display === 'stale' ? ' [stale]' : ''
165 const out = [` ${where} ${label(r)}${badge} · ${ago(r.since ?? r.lastSeen, now)}`]
166 if (r.title) out.push(` task: ${truncate(r.title, 100)}`)
167 if (r.detail) out.push(` ${truncate(r.detail, 160)}`)
168 if (r.pr) out.push(` PR: ${r.pr}`)
169 if (r.url) out.push(` open: ${r.url}`)
170 return out.join('\n')
171 }
172 const section = (title, list) => {
173 lines.push(`${title} (${list.length})`)
174 lines.push(list.length ? list.map(line).join('\n') : ' —')
175 lines.push('')
176 }
177 section('WAITING ON YOU', board.waiting || [])
178 section('IN PROGRESS', board.inProgress || [])
179 section('READY FOR REVIEW', board.review || [])
180 if (board.idle?.length) lines.push(`(${board.idle.length} idle session${board.idle.length > 1 ? 's' : ''} not shown)`)
181 return lines.join('\n').trimEnd()
182}
183
184function renderTicketsText(board, { now = board.now ?? Date.now() } = {}) {
185 const out = []
186 const line = (t) => {
187 const where = [t.repo ? t.repo.split('/').pop() : '', t.branch && t.branch !== 'HEAD' ? t.branch : ''].filter(Boolean).join('@')
188 const flag = (t.status === 'failed' ? ' [FAILED]' : t.status === 'review' ? ' [review]' : t.stale ? ' [stale]' : '') + (t.blocked ? ' [blocked]' : '')
189 const rows = [` ${t.origin === 'cloud' ? '☁' : '⌨'} ${t.key}${t.priority ? ' ' + t.priority : ''} ${truncate(t.title, 90)}${flag} · ${ago(t.status_at ?? t.updated_at, now)}${where ? ' · ' + where : ''}`]
190 const pr = (t.links || []).find((l) => l.type === 'pr')
191 if (pr) rows.push(` PR: ${pr.url}`)
192 const sess = (t.links || []).find((l) => l.type === 'session')
193 if (sess) rows.push(` open: ${sess.url}`)
194 return rows.join('\n')
195 }
196 const section = (title, col) => {
197 const list = board.columns?.[col] || []
198 const total = board.counts?.[col] ?? list.length
199 out.push(`${title} (${total})`)
200 out.push(list.length ? list.map(line).join('\n') : ' —')
201 if (total > list.length) out.push(` … ${total - list.length} more`)
202 out.push('')
203 }
204 section('WAITING ON YOU', 'waiting')
205 section('IN PROGRESS', 'inProgress')
206 section('TO DO', 'todo')
207 if (board.counts?.done) out.push(`(${board.counts.done} done, not shown)`)
208 return out.join('\n').trimEnd()
209}
210