SLOPSHOPPER

Alvearium

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…

newcommandstatusnetworktimer
v0.4.4MITupdated 2026-10-09Iskandeur/alvearium
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · alvearium
› fix the failing auth test and add an audit log call ⏺ 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 › /board ⎿ alvearium: ⎿ alvearium: No tickets yet on this machine. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Alvearium (formerly session-board)

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
  …

The board, with priorities, a blocked ticket and the avatars of who holds each ticket

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.

What Alvearium runs, reads and sends

Alvearium is a Claude Code plugin plus an optional self-hosted server.

What runs

  • Hooks (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 (mcp/server.mjs, stdio): lets Claude create/update/list tickets through MCP tools (ticket_create, ticket_next, …).
  • Mod (hooks/hooks.json → modules: ["./mod.mjs"]): draws the status line and implements an instant /board in the terminal (Claude Code v2.1.287+).
  • Slash commands (commands/*.md): /board, /ticket, /install-cloud just run the Node scripts in scripts/.

What it reads

  • From the environment: SESSION_BOARD_URL, SESSION_BOARD_TOKEN, and optional knobs like SESSION_BOARD_SEND_TEXT, SESSION_BOARD_MIRROR_TASKS.
  • From disk (local mode): ~/.claude/session-board/board.db and small helper files under the same directory.
  • From plugin config: server_url and token (stored by Claude Code; token is sensitive).

What it sends

  • Nothing leaves the machine in local mode.
  • In server mode, it sends HTTPS requests only to the host of 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).
  • Authentication is a Bearer token (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.
  • Credentials in a command or a prompt are replaced by [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).

Tickets, sessions, and where tickets come from

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:

  1. Hooks, automatically. Each session gets one ticket that follows it (in progress while Claude works, review when the turn ends, failed on an API error, done when the session ends). A permission prompt or a question opens a waiting on you sub-ticket that closes by itself when the session moves on. One review ticket per session, not one per turn.

| 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: … |

  1. Claude, through MCP tools. The plugin ships a small MCP server (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.
  2. You, from the web page (create, edit title / status / labels / priority, comment, close, sub-tickets) or the terminal (/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.

Realtime

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).

Dependencies and priority

  • Priority 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.
  • "A blocks B": 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.
  • Manual order: drag a card in the To do column or a row in Next (POST /api/tickets/SB-5/move { before | after }). Dropped among tickets of another priority, it takes that priority.
  • Next (page view, 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.

Actors and threads

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 |

Agent tokens (0.4.0)

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:

  • is always attributed to its actor (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;
  • may read everything, create, update, comment, reorder and report hook events (the sessions it reports belong to it);
  • may not 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.

Feedback inbox

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.

Install

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.

Terminal sessions: a fresh install

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.

Updates

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:

  • Terminal sessions (the plugin from this marketplace): turn auto-update on once. Claude Code updates a plugin by itself only "when the marketplace they came from has auto-update turned on", and that is "off by default" for every marketplace that is not an official one; 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.
  • Cloud sessions, per environment: automatic since 0.3.3, with the setup script on 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.
  • Cloud sessions, per repository (committed copy): run /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.
  • The server: git pull && docker compose up -d --build. Update it first when a release says so.

Terminal sessions: updating from 0.2.x

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).

Pointing a machine at a server

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):

  1. environment: 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": "…" } } ``
  2. the plugin's own settings, asked at install time (the token is kept in secure storage). To change them later: /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;
  3. ~/.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.

Checking that it works

  1. /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.
  2. Ask Claude: “create a test ticket on the board”. It calls 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>.
  3. Under the prompt, a status line such as 2 for you · 3 in progress · 5 to do (empty counts are left out; Claude Code 2.1.287 or later).
  4. Nothing shows? Start one session with 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.

Storage and upgrades

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:

  • Local → server (you set up a server, or 0.2.0 kept Claude's tickets local because its MCP server did not receive the plugin settings): at the next session start, the hook sends the local 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.
  • If it cannot finish (server down, out of time, server older than 0.2.3): nothing is renamed, the start shows how many tickets are still local, once, and it retries at every start. To retry now: /alvearium:board sync.
  • Server → local, or one server → another: nothing is moved. The next start says where the tickets stayed.

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.

API

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.

The web page

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

Source 1 files
hooks/mod.mjs 210 lines
1// 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