A local kanban board whose cards are worked by headless Claude Code agents: builder, designer, researcher and an independent PR reviewer.

A local kanban board for Claude Code. Cards are dispatched to headless Claude workers (claude -p) under a macOS LaunchAgent or a Linux systemd user unit; the board is a SQLite file you read with the kanban CLI and the /kanban-board pane. No external service.
Status: stable (0.3.0). The plugin surface (agents, skills, hooks), setup, doctor and uninstall are in place; see CHANGELOG.
macOS and Linux. On macOS the daemon is a LaunchAgent. On Linux it is a systemd user unit, so you need systemctl --user, and lingering (loginctl enable-linger $USER, once) for a daemon that survives logout; without it the unit stops when your last session ends and the board stops dispatching. Setup warns when lingering is off and never turns it on. On a host without systemd (a container, WSL without systemd) pass --no-daemon and start the daemon yourself with kanban daemon; the rest of setup is the same.
You need uv (brew install uv, or curl -LsSf https://astral.sh/uv/install.sh | sh), git, gh and Claude Code 2.1.259 or newer (checked on 2.1.295); npx is optional (only the designer uses it). If your repositories live on a GitHub Enterprise host, log gh in there first: gh auth login --hostname <your GHE host>. Behind a proxy that re-signs TLS, export your CA bundle in ~/.zshrc and run setup from a new terminal:
export SSL_CERT_FILE=<path to your CA bundle>
export NODE_EXTRA_CA_CERTS=<path to your CA bundle>
Inside the Claude Code whose config directory the workers will use:
/plugin marketplace add DresvyanskiyDenis/claude-kanban-plugin
/plugin install kanban-claude@claude-kanban-plugin
/kanban-claude:setup
From a shell the first two are claude plugin marketplace add DresvyanskiyDenis/claude-kanban-plugin and claude plugin install kanban-claude@claude-kanban-plugin. The shell form is checked against claude plugin marketplace add --help (Claude Code 2.1.295: the source is a URL, a path or a GitHub repo); the slash form is the same command inside a session. Workers resolve --agent kanban-claude:<role> from the config directory they run under, so the plugin must be enabled at user scope there. The plugin depends on frontend-design@claude-plugins-official; the marketplace lists that marketplace as an allowed dependency source, so Claude Code installs it along with the plugin.
To install from a copy on GitHub Enterprise Server, the owner/repo shorthand always means github.com, so give the full clone URL:
/plugin marketplace add https://<your GHE host>/<org>/claude-kanban-plugin.git
Claude Code clones with the git credentials already on the machine and never prompts; if the host is not signed in yet, also run gh auth setup-git --hostname <your GHE host>.
The setup skill runs skills/setup/scripts/bootstrap.sh, which installs the runtime this plugin ships (uv tool install --force), checks that the installed kanban has the plugin's version, and runs kanban setup. Setup is idempotent. In order, it:
systemctl on Linux, uv, git, gh) and claude --version, and refuses a claude older than 2.1.259 with claude update as the fix;--claude-config-dir, else the one a previous setup recorded, else the session's CLAUDE_CONFIG_DIR, else ~/.claude. The default directory is never exported to a worker; any other one is;PATH (the directories of claude, gh, git, uv, npx, node plus the system ones), the CA bundle and proxy variables and LANG. A variable whose name contains TOKEN, KEY, SECRET, PASSWORD or AUTH is never captured unless you list it in worker.env_passthrough. A proxy URL keeps its password in the service file (the proxy needs it) and is masked as http://***@host in every log line;claude with the flags workers use (auto permission mode, your review model) under that captured environment alone, and explains a failure: not logged in (prints the login command), no Opus on this seat (kanban config set models.review sonnet, or rerun with --yes), or an organisation that disables auto mode (setup stops);claude.bin and claude.config_dir only where they differ from what is stored, so a rerun keeps everything you configured;kanban-claude.daemon in ~/Library/LaunchAgents, unloaded and loaded again in your gui/<uid> domain; on Linux the user unit kanban-claude.daemon.service in ~/.config/systemd/user, disabled and then enabled with systemctl --user enable --now. The unit sets KillMode=process, so a restart leaves the detached runners alone, as the LaunchAgent's AbandonProcessGroup does;$KANBAN_HOME/installed.json (the version, the Claude binary and config directory and the captured environment; data only, no hook executes it);$KANBAN_HOME/daemon.lock and then starts the scout agent once, the way a worker will, to prove that the plugin resolves. It finishes with kanban-claude X is set up.--no-daemon skips step 6 and step 8's daemon wait, and --yes accepts the Sonnet fallback and a deliberate downgrade. Progress is also written to $KANBAN_HOME/logs/upgrade.log.
Upgrading is the same skill on a newer plugin: when the recorded version is older, setup takes an exclusive flock on $KANBAN_HOME/upgrade.lock (a second run exits 0; the lock ends with its process, so a crashed setup leaves nothing to clear), migrates the database after a backup, restarts the daemon (launchctl kickstart -k, or systemctl --user restart on Linux) and rewrites installed.json. Runs in flight are not touched. A plugin older than the recorded version is refused unless you pass --yes. Nothing upgrades itself: a new plugin only announces itself at the next session start, and the daemon changes when you run the skill. Third-party marketplaces do not auto-update by default; run /plugin update or enable auto-update for this marketplace.
kanban-claude:smith, designer, scout and warden run cards; card-helper and review-helper are their subagents; card-checker (read-only by its instructions) checks card bodies for the orchestrator. The optional atlassian role makes one exact Jira or Confluence write per card (see Jira sync). Each agent file lists its tools explicitly.kanban (the orchestrator rulebook and CLI reference), kanban-code-review (warden's procedure and preflight.py), simplify-code-card (slop_gate.py).kanban stats reports it with the runtime by role, the cost per role and model, and the first-pass approval rate; kanban stats --backfill fills the runs from before 0.4.0. advisor.<role> (inherit, off or ab) switches the advisor tool off for a role or A/B-tests it per card, and kanban gate -- CMD runs a gate command with its full output in a log file and a short digest on the screen.runtime/src/kanban_claude/mcp/designer.json.Each worker session is named kanban <card-id> <role>. Ask your session to steer a running card and it sends your words there and records them on the card; a worker asks back only by blocking its card with needs_input.
Agents cannot enforce a deny list for Bash commands; the runner's --settings carries the rules for gh pr merge, force pushes and kanban grant, and branch protection on the remote is the backstop.
Workers do not inherit your own setup. In a run, every plugin your account enables is off except kanban-claude, its declared dependency and the names in worker.plugin_allowlist. MCP servers come only from a private per-run file (--strict-mcp-config, mode 0600) that holds the role's servers plus worker.mcp_allowlist, copied from the config directory's .claude.json. Each role also gets its own permission denies: no edits for warden, atlassian and (except its report) scout. Each role's built-in tools come from tools.<role> (kanban config set tools.smith '[...]' makes a list longer or shorter), which the runner passes as --tools; whatever the list says, warden and atlassian never get Edit, Write or NotebookEdit and scout never gets Edit. A PreToolUse hook in the run's settings lets a role start only its own helper, and a helper start nothing. The atlassian role gets only its own MCP server, started with ENABLED_TOOLS naming its nine read and eight write tools. Each card's workers share one TMPDIR under $KANBAN_WORK/tmp/<card>.
Spend is capped at claim: budget.max_usd_per_card (default 15 USD, 0 turns it off) and budget.max_usd_per_board_day (off by default). A card over a cap is blocked with a comment naming the key to raise.
To follow the board from a session, run kanban watch --actionable --wait --board <slug> as a background command. It blocks until something needs you, prints it and exits; run it again with --since <last event id>. notify.command (an argv list, for example kanban config set notify.command '["/abs/path/script"]') gets each such event line on stdin from the daemon, with no session open.
hooks/scripts/session_start.sh (POSIX sh, well under 50 ms, read-only) runs at the start of every session except a worker's. It prints nothing when the machine is not set up or all is well. When the plugin's version differs from installed.json it prints kanban-claude: daemon a.b.c, plugin x.y.z — run /kanban-claude:setup to upgrade. While dispatch is paused it prints one line of enumerated codes (manual, systemic, auth:<config dir>, quota:<config dir> until <time>) and kanban doctor as the way to the fix. It reads the two versions with a fixed sed pattern, queries the database with sqlite3 -readonly, never evaluates a file, never prints card or worker text, and never installs anything.
/kanban-board opens the board of the session's folder beside the transcript; /kanban-board SLUG pins another board and /kanban-board close closes it. It reads kanban --json list and kanban --json digest --since 24h every 15 s while the pane is open, and only then; r refreshes now and v switches between cards and a list while the pane has the keyboard (click it, or Ctrl+X then Tab); until then the footer reads ctrl+x tab: keys, since those keys would type into the prompt.
+N more; an empty lane keeps its place, drawn faint. Triage cards follow as rows, in the rows the lanes leave, and Enter opens one; in the list, Triage is a fifth lane. Recently merged and a bar of the board's spread follow.v switches to a compact list, one row a card, and back; the pane remembers the choice across sessions.back (or b) returns.kanban on PATH or at ~/.local/bin/kanban. Card workers never load it.kanban list, show and watch are the full view.
It is a Claude Code mod and needs Claude Code 2.1.287 or later; an organization's managed settings can disable mods. Narrower than 144 columns, Claude Code places a pane it was not asked for only from 110 columns, so a reopened pane may wait until the terminal is wide enough.
kanban web [--port N] [--board SLUG] [--open] serves a read-only dashboard at http://127.0.0.1:8742/ (the default port is fixed, and the command says so and exits when it is taken). It runs in the foreground until Ctrl-C and works offline: the page is plain HTML, CSS and JavaScript shipped in the package, with no CDN and no build step.
It opens the database read-only on every request and never migrates it, so it can run beside an older daemon: on a schema 5 database it shows the board, cards and logs, and the Stats page says it needs the schema 6 columns. It binds to 127.0.0.1 only, answers GET and HEAD only, rejects other Host headers, and sends a content security policy with no inline script or style. Log and prompt files are read only through the run rows of the database, and only under the kanban home.
kanban doctor runs twenty-two independent checks and exits 1 when any one fails (a warning leaves it at 0); --json gives the same list as data. Each line names what is wrong and the command that fixes it. --probe adds the two checks that start claude (the scout agent resolves and the login passes, in every config directory a board uses; this costs a few cents) and, for check 20, starts jira.mcp_command and confirms it lists the seven Jira tools. --fix repairs only what is safe: file modes ($KANBAN_HOME and $KANBAN_WORK 0700, the database, installed.json, logs and the service file 0600), a daemon service that is not loaded (it rebuilds a missing plist or unit file from installed.json), and a running card or run that disagrees with the other through the dispatcher's reconcile step. Everything else is yours to repair.
The checks, in order: 1 the daemon's service (LaunchAgent or systemd unit) loaded; 2 the daemon holds daemon.lock (its pid and version are read from it); 3 tool, installed.json, daemon and (inside a Claude session) plugin versions match; 4 PRAGMA quick_check; 5 schema version; 6 a running card has a running run and the other way round; 7 claude path and version; 8 every config directory exists; 9 the plugin is enabled at user scope in each (read from settings.json; --probe proves it); 10 gh auth status per host the boards use, with a warning when the login is a GH_TOKEN variable instead of the keychain; 11 uv; 12 CA variables of your shell are in the service file; 13 the service file's PATH still finds claude, gh, git, uv and the kanban entry point (npx and node only warn); 14 at least 2 GB free; 15 dispatch pause, logged-out accounts and quota walls with their times; 16 kept worktrees; 17 file modes; 18 a live runner for every running run; 19 branch protection; 20 Jira pairing (a paired board needs jira.assignee and jira.mcp_command; a paused sync is a warning that names the reason, and so is jira.claim with a jira.assignee that matches none of the assignees Jira has reported); 21 GitHub pairing (gh auth status per paired host; a board with --github-project also needs the project token scope, and --probe reads the repository and the project; a paused sync is a warning that names the reason); 22 designer's browser MCP (a warning when a board's repo lists playwright in its disabledMcpServers in the account's .claude.json: Claude applies that list to the card worktrees inside the repo, even to the server the designer brings, so the designer falls back to a Playwright script).
Workers push with your login, so the only server-side stop for a push to the wrong branch is branch protection (GIT-08). Check 19 asks GitHub, for the default branch and one existing feature/* branch of every repo a board uses, through both repos/<owner>/<repo>/rules/branches/<branch> (rulesets) and repos/<owner>/<repo>/branches/<branch> (classic protection); either one counts. A repo with neither is a warning in doctor, and never delays dispatch. One that cannot be read (you lack the rights) is listed as "could not be read" in doctor only. The answer is cached in the database and refreshed every six hours.
Setting it up needs admin rights on the repo. For the default branch (no direct pushes, no force pushes, a pull request required; use --hostname <host> for GitHub Enterprise):
gh api --method POST repos/<owner>/<repo>/rulesets --input - <<'JSON'
{
"name": "kanban-claude: default branch",
"target": "branch",
"enforcement": "active",
"conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}},
"rules": [
{"type": "pull_request", "parameters": {
"required_approving_review_count": 0, "dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false, "require_last_push_approval": false,
"required_review_thread_resolution": false}},
{"type": "non_fast_forward"},
{"type": "deletion"}
]
}
JSON
For feature/*, leave the pull request rule off: a part card of a feature pushes its own branch feature/<slug>-<part>, which matches the same pattern, so requiring a pull request there would stop the worker it is meant to serve. Forbid deleting and rewriting instead:
gh api --method POST repos/<owner>/<repo>/rulesets --input - <<'JSON'
{
"name": "kanban-claude: feature branches",
"target": "branch",
"enforcement": "active",
"conditions": {"ref_name": {"include": ["refs/heads/feature/*"], "exclude": []}},
"rules": [{"type": "non_fast_forward"}, {"type": "deletion"}]
}
JSON
Pair a board with one Jira project and its cards and issues stay in step both ways, with no model in the loop. Jira owns the text, the board owns execution. The sync talks to the atlassian MCP server (mcp-atlassian) that you add to Claude yourself; kanban setup copies only its command and args into jira.mcp_command, never its env or a token.
kanban config set jira.assignee you@example.com
kanban boards edit main --jira-project KAN # optional: --jira-jql 'project = KAN AND sprint in openSprints()'
kanban jira sync # one pass now; the daemon runs one every jira.poll_s (120 s)
project = KAN AND labels = "kanban-claude" AND assignee = currentUser()) become triage cards, Done ones excepted; a new card gets an issue with the jira.label, a kanban-<card id> label, jira.issue_type and jira.assignee; the id label lets a pass that timed out after creating the issue adopt it instead of making a second. Done and archived cards are not exported. kanban jira import|link|unlink do the same by hand, and an unlinked issue stays out until you import it.[jira] conflict comment. Jira's text on an approved card (todo, ready, blocked) puts it back in triage with a [jira] comment; a running or review card waits until it has left that state.jira.status_map; blocked and archived leave Jira alone, and an unrelated card edit (a title, a comment) never undoes a colleague's move in Jira. Without jira.claim a board pushes the status of any issue it holds, whoever else holds it too (see Team use). A missing transition, or one that needs fields, is a jira error badge and is tried again every pass until it passes. A Jira Done closes a card in triage, todo, ready or blocked; on a running or review card, or one whose todo child waits on it, it is a jira conflict badge instead. Leaving Done in Jira puts a done card back in triage.jira:<name>; your own comments (including the UNBLOCK: and REOPEN: ones a card gets when you unblock or reopen it) go to Jira, and so does the closing text of each work run that ends in complete, request-review or block, and each review verdict, under a label of its own: kanban completed:, kanban ready for review:, kanban blocked:, kanban review approved:, kanban review changes requested:, kanban review blocked:. Worker comments and [run] lines stay on the board. A comment that was being sent when the call's answer never came is found by its text and adopted, not sent twice (an equal text with no send under way is someone else's and stays theirs). Workers read them as quoted data.$KANBAN_HOME and Atlassian login) to one Jira project, and Jira is then all they share. By default nothing stops two boards that hold the same issue from both running it, so switch the claim on: kanban config set jira.claim true (one switch for every paired board of that home; a per-board setting is the upgrade path), set jira.assignee to your account id or email as Jira reports it (never your display name, which two people can share), and give each board the team query --jira-jql 'project = KAN AND labels = "kanban-claude" AND (assignee is EMPTY OR assignee = currentUser())'. The issue's assignee is then the lock. A ready or review card dispatches only when Jira says the issue is assigned to you. If nobody holds it, the next pass reads it, assigns it to you if the pass read it as empty, reads it back and stores what Jira says; the card dispatches after that, up to jira.poll_s later (an issue the board created is assigned to jira.assignee from the start and does not wait). An issue someone else holds shows skipped: claimed_elsewhere on the board, and an unclaimed one, or one whose claim failed (the Assign Issues permission is missing, Jira is unreachable), skipped: jira_unclaimed: dispatch fails closed. Only the board that holds the issue moves its status. An assignee the board has read is not overwritten (Jira has no compare-and-set, see the limits); if Jira gives the issue to someone else while your card runs, the run finishes and the card shows a jira conflict badge. Title, body, status, comments, the closing text of work runs and review verdicts cross; runs, costs, PR fields, worktrees, grants and the reviewer'shooks/board.tsx 899 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { KanbanCard, KanbanDetail, KanbanDigest, KanbanFacts, KanbanNeed, KanbanSnapshot } from '../types'
5
6// The board beside the transcript: what needs the owner, what runs, waits for review or comes next.
7// Hooks read `kanban --json list|digest|show` into $.state; the render hook only reads that state.
8
9const PANE = 'kanban'
10const POLL_MS = 15_000
11const RUN_MS = 10_000
12// process.run rejects both when the binary cannot start and when it times out: only a quick rejection means "not found".
13const SPAWN_MS = 1_000
14const SHOWN = 5
15// The card view: cards per lane in the dock, and the narrowest lane column (a card's text gets 4 cells less).
16const CARDS = 3
17const CARD_MIN = 22
18const LABEL = 10
19
20const SNAP = atom({ plugin: 'kanban-claude', key: 'snapshot' } as const, null)
21const FAULT = atom({ plugin: 'kanban-claude', key: 'fault' } as const, null)
22const PIN = atom({ plugin: 'kanban-claude', key: 'pin' } as const, null)
23const OPEN = atom({ plugin: 'kanban-claude', key: 'open' } as const, null)
24const DETAIL = atom({ plugin: 'kanban-claude', key: 'detail' } as const, null)
25const SEEN = atom({ plugin: 'kanban-claude', key: 'seen' } as const, null)
26const VIEW = atom({ plugin: 'kanban-claude', key: 'view' } as const, null)
27
28// Colors are theme keys, not hex, so the pane follows the person's theme. One accent per lane.
29type Lane = { key: string; name: string; statuses: string[]; glyph: string; color: string; meta: (c: KanbanCard, now: number) => string }
30const LANES: Lane[] = [
31 { key: 'running', name: 'Running', statuses: ['running'], glyph: '●', color: 'claude', meta: (c, now) => join(' · ', c.assignee, ago(now - c.updated_at)) },
32 { key: 'review', name: 'Review', statuses: ['review'], glyph: '◐', color: 'merged', meta: (c, now) => join(' · ', c.pr_number ? `#${c.pr_number}` : null, ago(now - c.updated_at)) },
33 { key: 'blocked', name: 'Blocked', statuses: ['blocked'], glyph: '✕', color: 'error', meta: c => words(c.block_kind) },
34 { key: 'next', name: 'Up next', statuses: ['ready', 'todo'], glyph: '○', color: 'suggestion', meta: c => (c.skip ? words(c.skip.reason) : (c.assignee ?? '')) },
35]
36const DONE = { name: 'done', statuses: ['done'], color: 'success' }
37// Raw ideas the orchestrator has yet to shape into cards: listed after the lanes, faint, never dispatched.
38const TRIAGE: Lane = { key: 'triage', name: 'Triage', statuses: ['triage'], glyph: '◇', color: 'inactive', meta: (c, now) => ago(now - c.updated_at) }
39const LEGEND = [DONE]
40// The status bar: shipped on the left, the lanes, raw ideas on the right; archived cards stay out.
41const BAR = [DONE, ...LANES, TRIAGE]
42
43type Raw = { [key: string]: any }
44type Ran = { exitCode: number; stdout: string; stderr: string }
45type Saved = { pin: string | null }
46
47// Module variables reset on a hot reload, and the old timer is cancelled; the board itself lives in the atoms above.
48let timer: { cancel: () => void } | null = null
49let bin: string | null = null
50// A running dispatch keeps reading $.state as it stood when it began, so a read out for an old pin
51// cannot see a new one there; `epoch` counts pin changes where every dispatch sees them at once.
52let epoch = 0
53let flight: { epoch: number; done: Promise<boolean> } | null = null
54
55const message = (err: unknown) => (err instanceof Error ? err.message : String(err))
56const str = (v: unknown) => (typeof v === 'string' ? v : v == null ? '' : String(v))
57// `needs_input` → `needs input`, `overlaps:t_1` → `overlaps t_1`.
58const words = (v: unknown) => str(v).replace(/^[a-z_]+/, k => k.replace(/_/g, ' ')).replace(':', ' ')
59const join = (sep: string, ...parts: unknown[]) => parts.filter(p => p !== null && p !== undefined && p !== '').join(sep)
60const list = (v: unknown): Raw[] => (Array.isArray(v) ? v : [])
61
62function ago(seconds: number): string {
63 if (seconds < 60) return `${Math.max(0, Math.floor(seconds))}s`
64 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
65 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
66 return `${Math.floor(seconds / 86400)}d`
67}
68
69// Terminal cells: CJK and emoji take two. shortcut: covers the common wide ranges, not every grapheme cluster.
70const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u
71const cells = (s: string) => [...s].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0)
72
73// Exactly `n` cells: cut with an ellipsis, or padded on the right (`left` pads on the left).
74function fit(s: string, n: number, left = false): string {
75 if (n <= 0) return ''
76 const flat = s.replace(/\s+/g, ' ')
77 if (cells(flat) > n) {
78 let out = ''
79 for (const ch of flat) {
80 if (cells(out + ch) > n - 1) break
81 out += ch
82 }
83 return out + '…' + ' '.repeat(n - 1 - cells(out))
84 }
85 const pad = ' '.repeat(n - cells(flat))
86 return left ? pad + flat : flat + pad
87}
88
89// A hint ends in the command to run, after its last arrow: cut the words before it, never the command.
90function fitHint(s: string, n: number): string {
91 const at = s.lastIndexOf(' → ')
92 const room = n - cells(s.slice(at + 3)) - 3
93 return cells(s) <= n || at < 0 || room < 12 ? fit(s, n) : `${fit(s.slice(0, at), room)} → ${s.slice(at + 3)}`
94}
95
96// Under a need, the reason and the command share a line when they fit, else the command gets its own.
97function hintLines(s: string, n: number): string[] {
98 const at = s.lastIndexOf(' → ')
99 return cells(s) <= n || at < 0 ? [fit(s, n)] : [fit(s.slice(0, at), n), fit(s.slice(at + 1), n)]
100}
101
102// Word-wrapped into at most `max` lines of `n` cells (the first line `first`), each padded to its width;
103// the last line takes the rest and ends in an ellipsis when it overflows.
104function wrap(s: string, first: number, n: number, max: number): string[] {
105 const words = s.replace(/\s+/g, ' ').trim().split(' ')
106 const out: string[] = []
107 let at = 0
108 while (out.length < max - 1 && at < words.length) {
109 const room = out.length ? n : first
110 let line = words[at++] ?? ''
111 while (at < words.length && cells(`${line} ${words[at] ?? ''}`) <= room) line += ` ${words[at++] ?? ''}`
112 if (at === words.length) return [...out, fit(line, room)]
113 out.push(fit(line, room))
114 }
115 return [...out, fit(words.slice(at).join(' '), out.length ? n : first)]
116}
117
118// Cells per segment, rounded on the running total so the segments always fill `width` exactly.
119// shortcut: a status holding a sliver of a big board can round to no cell; its lane still lists it.
120function segments(parts: { color: string; n: number }[], width: number) {
121 const total = parts.reduce((a, p) => a + p.n, 0)
122 let seen = 0
123 let drawn = 0
124 return parts.map(p => {
125 seen += p.n
126 const end = Math.round((seen / total) * width)
127 const n = end - drawn
128 drawn = end
129 return { color: p.color, n }
130 })
131}
132
133// What each actionable event asks of the owner, in a few words: why it waits, and what to do about it.
134function ask(n: KanbanNeed, now: number): { label: string; title: string; why: string; act: string; hint: string } {
135 const p = n.payload ?? {}
136 const id = n.task_id ?? ''
137 const title = n.title ?? ''
138 const why = str(n.reason)
139 const of = (label: string, t: string, w: unknown, act = '') => ({ label, title: t, why: str(w), act, hint: join(' → ', str(w), act) })
140 switch (n.kind) {
141 case 'blocked':
142 if (p.kind === 'needs_input') return of('question', title, why, `kanban unblock ${id} --reason "…"`)
143 if (p.kind === 'capability') return of('grant', title, why, `grant, then kanban unblock ${id}`)
144 return of('blocked', title, why || words(p.kind), `kanban unblock ${id}`)
145 case 'block_loop_detected':
146 return of('loop', title, `blocked ${str(p.recurrences)}× on the same thing`, 'rewrite the card')
147 case 'gave_up':
148 return of('gave up', title, p.failures ? `${str(p.failures)} failed runs` : why, `kanban log ${id} --tail 80`)
149 case 'merge_failed':
150 return of('merge', title, words(p.reason), `kanban merge-retry ${id}`)
151 case 'awaiting_owner_merge':
152 return of('merge PR', title, `${p.pr ? `PR #${str(p.pr)} ` : ''}approved, merge it yourself`)
153 case 'account_paused':
154 return of('login', `${str(p.config_dir) || 'account'} signed out`, p.login ? `run ${str(p.login)}` : '', 'kanban resume')
155 case 'quota_wall':
156 return of('quota', `${str(p.kind) || 'usage'} quota wall · ${Number(p.cards) === 1 ? '1 card waits' : `${str(p.cards || 0)} cards wait`}`, `lifts by itself in ${ago(Number(p.until) - now)}`)
157 case 'dispatch_paused':
158 return of('paused', 'Dispatch paused', p.detail, 'kanban resume')
159 default:
160 return of(words(n.kind), title, why)
161 }
162}
163
164async function kanban($: EngineInterface, args: string[], cwd: string): Promise<Ran> {
165 const opts = { cwd, timeoutMs: RUN_MS }
166 if (bin) return $.process.run([bin, ...args], opts)
167 const started = await $.clock.now()
168 try {
169 const r = await $.process.run(['kanban', ...args], opts)
170 bin = 'kanban'
171 return r
172 } catch (err) {
173 // A session started from a GUI or an IDE may not have ~/.local/bin on PATH.
174 const home = await $.env.get('HOME')
175 if (!home || (await $.clock.now()) - started > SPAWN_MS) throw err
176 const fallback = `${home}/.local/bin/kanban`
177 const r = await $.process.run([fallback, ...args], opts).catch(() => {
178 throw new Error(`kanban is not on PATH or at ${fallback}`)
179 })
180 bin = fallback
181 return r
182 }
183}
184
185function parsed(r: Ran): Raw {
186 if (r.exitCode !== 0) {
187 const last = r.stderr.trim().split('\n').pop()
188 throw new Error(last || `kanban exited ${r.exitCode}`)
189 }
190 return JSON.parse(r.stdout)
191}
192
193function digestOf(v: Raw): KanbanDigest {
194 return {
195 needs_you: list(v.needs_you) as KanbanNeed[],
196 merged: list(v.merged) as KanbanNeed[],
197 cost_usd: typeof v.cost_usd === 'number' ? v.cost_usd : null,
198 daemon: v.daemon && typeof v.daemon.alive === 'boolean' ? { alive: v.daemon.alive, version: v.daemon.version ?? null } : null,
199 paused: v.paused ? { reason: str(v.paused.reason), detail: v.paused.detail ?? null } : null,
200 }
201}
202
203function factsOf(v: Raw): KanbanFacts {
204 const t: Raw = v.task ?? {}
205 const runs = list(v.runs)
206 const r = runs[runs.length - 1]
207 return {
208 id: str(t.id),
209 title: str(t.title),
210 status: str(t.status),
211 assignee: t.assignee ?? null,
212 branch: t.branch_name ?? null,
213 base: t.base_branch ?? null,
214 pr_number: t.pr_number ?? null,
215 pr_url: t.pr_url ?? null,
216 block_kind: t.block_kind ?? null,
217 skip: t.skip ?? null,
218 summary: v.latest_summary ?? null,
219 run: r ? { role: r.role ?? null, status: r.status ?? null, outcome: r.outcome ?? null, cost_usd: r.cost_usd ?? null, started_at: r.started_at ?? null, ended_at: r.ended_at ?? null } : null,
220 comments: list(v.comments)
221 .slice(-3)
222 .map(c => ({ author: str(c.author), body: str(c.body), created_at: Number(c.created_at) || 0 })),
223 }
224}
225
226async function loadDetail($: EngineInterface, id: string) {
227 let next: KanbanDetail
228 try {
229 next = { id, facts: factsOf(parsed(await kanban($, ['--json', 'show', id], await $.session.cwd()))), error: null }
230 } catch (err) {
231 next = { id, facts: null, error: message(err) }
232 }
233 if ((await read($, OPEN)) !== id) return
234 // A failed re-read keeps the facts already on screen.
235 await update($, DETAIL, cur => (next.error && cur?.id === id && cur.facts ? { ...cur, error: next.error } : next))
236}
237
238// A toast per actionable event newer than the cursor. The first read only sets the cursor, so nothing replays.
239async function toast($: EngineInterface, snap: KanbanSnapshot) {
240 const needs = (snap.digest?.needs_you ?? []).filter(n => typeof n.id === 'number')
241 const seen = await read($, SEEN)
242 if (seen !== null) {
243 for (const n of needs) {
244 if ((n.id as number) <= seen) continue
245 const a = ask(n, snap.at / 1000)
246 $.ui.toast(`Kanban · ${a.label}: ${a.title}`)
247 }
248 }
249 const top = Math.max(seen ?? 0, ...needs.map(n => n.id as number))
250 if (top !== seen) await update($, SEEN, cur => Math.max(cur ?? 0, top))
251}
252
253async function fault($: EngineInterface, reason: string) {
254 const at = await $.clock.now()
255 await update($, FAULT, f => ({ reason, since: f?.since ?? at }))
256}
257
258// Resolves true when this read put a board on screen.
259async function cycle($: EngineInterface, mine: number): Promise<boolean> {
260 const pin = await read($, PIN)
261 const cwd = await $.session.cwd()
262 const scope = pin ? ['--board', pin] : []
263 const at = await $.clock.now()
264 const [cards, digest] = await Promise.allSettled([
265 kanban($, [...scope, '--json', 'list', '--sort', 'priority'], cwd).then(parsed),
266 // An older CLI without `digest` only hides the sections built on it.
267 kanban($, [...scope, '--json', 'digest', '--since', '24h'], cwd).then(parsed),
268 ])
269 if (mine !== epoch) return false
270 if (cards.status === 'rejected') {
271 await fault($, message(cards.reason))
272 return false
273 }
274 const snap: KanbanSnapshot = {
275 board: str(cards.value.board) || pin || 'board',
276 cards: list(cards.value.cards) as KanbanCard[],
277 digest: digest.status === 'fulfilled' ? digestOf(digest.value) : null,
278 at,
279 pin,
280 }
281 // A read the module started before a hot reload can still finish after a newer one landed.
282 await update($, SNAP, cur => (cur && cur.at > snap.at ? cur : snap))
283 await update($, FAULT, () => null)
284 await toast($, snap)
285 const open = await read($, OPEN)
286 if (open) await loadDetail($, open)
287 return true
288}
289
290// One refresh in flight per pin: a poll, `r` and the command join the read already out,
291// and a read for a pin since replaced is dropped when it returns.
292function refresh($: EngineInterface): Promise<boolean> {
293 if (flight?.epoch === epoch) return flight.done
294 const mine = epoch
295 const done: Promise<boolean> = cycle($, mine)
296 .catch(async err => {
297 if (mine === epoch) await fault($, message(err))
298 return false
299 })
300 .catch(() => false)
301 .finally(() => {
302 if (flight?.done === done) flight = null
303 })
304 flight = { epoch: mine, done }
305 return done
306}
307
308function poll($: EngineInterface) {
309 timer ??= $.clock.every(POLL_MS, () => void refresh($))
310}
311
312function stop() {
313 timer?.cancel()
314 timer = null
315}
316
317const savedKey = async ($: EngineInterface) => `pane:${await $.session.cwd()}`
318
319function openPane($: EngineInterface) {
320 return $.ui.open({ id: PANE, title: 'Kanban', columns: 48, rows: 16 })
321}
322
323async function show($: EngineInterface, id: string) {
324 await update($, OPEN, () => id)
325 await update($, DETAIL, cur => (cur?.id === id ? cur : { id, facts: null, error: null }))
326 await loadDetail($, id)
327}
328
329async function back($: EngineInterface) {
330 await update($, OPEN, () => null)
331 await update($, DETAIL, () => null)
332}
333
334// Cards or list: a look the person picks once, so it follows them into every folder.
335async function flip($: EngineInterface) {
336 const next = ((await read($, VIEW)) ?? 'cards') === 'cards' ? 'list' : 'cards'
337 await update($, VIEW, () => next)
338 await $.store.set('view', next).catch(() => undefined)
339}
340
341// Pinning another board drops what was read for the old one, and any read still out for it.
342async function pinTo($: EngineInterface, pin: string | null) {
343 if ((await read($, PIN)) === pin) return
344 epoch++
345 await update($, PIN, () => pin)
346 await update($, SNAP, () => null)
347 await update($, FAULT, () => null)
348 await back($)
349}
350
351// `r` also restarts polling, in case a reload cancelled the timer and no session.start followed.
352async function press($: EngineInterface) {
353 poll($)
354 await refresh($)
355}
356
357// Reopen only where the saved board still reads; a failed read leaves the pane closed for this session.
358async function reopen($: EngineInterface, saved: Saved) {
359 await pinTo($, saved.pin ?? null)
360 if (!(await refresh($))) return
361 poll($)
362 await openPane($)
363}
364
365export const register: Register = on => {
366 on('session.start', async ($, e, next) => {
367 // A card's own headless worker has no one to show a board to.
368 if (await $.env.get('KANBAN_RUN_ID')) return next(e)
369 await $.command.register({
370 name: 'kanban-board',
371 description: 'Show the kanban board beside the transcript, or close it',
372 argumentHint: '[BOARD-SLUG|close]',
373 })
374 const view = await $.store.get('view').catch(() => undefined)
375 if (view === 'cards' || view === 'list') await update($, VIEW, () => view)
376 // The pre-dev8 reopen key followed the person into every folder.
377 await $.store.delete('pane').catch(() => undefined)
378 if ((await $.ui.panes()).some(p => p.id === PANE)) {
379 // Still open from before a reload or a resume: pick the polling back up.
380 poll($)
381 void refresh($)
382 } else {
383 const saved = (await $.store.get(await savedKey($))) as Saved | undefined
384 if (saved) void reopen($, saved).catch(() => undefined)
385 }
386 return next(e)
387 })
388
389 on('command.run', { command: 'kanban-board' }, async ($, e) => {
390 const arg = e.args.trim()
391 const key = await savedKey($)
392 if (arg === 'close') {
393 await $.ui.close({ id: PANE })
394 await $.store.delete(key)
395 return { text: 'Kanban pane closed.' }
396 }
397 const pin = arg || null
398 await pinTo($, pin)
399 await $.store.set(key, { pin } satisfies Saved)
400 poll($)
401 await refresh($)
402 const opened = await openPane($)
403 return { text: opened.isPlaced ? 'Kanban pane open.' : `Kanban pane not shown: ${opened.reason}` }
404 })
405
406 on('ui.close', async ($, e, next) => {
407 if (e.id === PANE) {
408 stop()
409 if (e.origin.kind !== 'unload') {
410 await back($)
411 await $.store.delete(await savedKey($)).catch(() => undefined)
412 }
413 }
414 return next(e)
415 }).catch(($, e, next) => next(e)) // a failed cleanup never keeps the pane open
416
417 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
418 const { Box, Text, Button, Link } = $.ui.resolve(e)
419 const [snap, fault, open, detail, picked] = await Promise.all([read($, SNAP), read($, FAULT), read($, OPEN), read($, DETAIL), read($, VIEW)])
420 const now = (await $.clock.now()) / 1000
421 const width = e.props.bodyColumns
422 const narrow = width < 30
423 const refreshButton = <Button key="refresh" label="refresh" hotkey="r" plain dimColor onPress={() => press($)} />
424
425 if (!snap) {
426 return (
427 <Box flexDirection="column" width={width}>
428 <Box flexDirection="row" gap={1}>
429 <Text color="claude">◆</Text>
430 <Text bold>Kanban</Text>
431 </Box>
432 {fault ? (
433 <Box flexDirection="column">
434 <Box flexDirection="row" gap={1}>
435 <Text color="warning">▲</Text>
436 <Box flexShrink={1}>
437 <Text color="warning">{fault.reason}</Text>
438 </Box>
439 </Box>
440 <Text dimColor>/kanban-board SLUG pins a board.</Text>
441 </Box>
442 ) : (
443 <Text dimColor>Reading the board…</Text>
444 )}
445 <Box marginTop={1}>{refreshButton}</Box>
446 </Box>
447 )
448 }
449
450 // A failed refresh keeps the last good board on screen and says how old it is.
451 const stale = fault && (
452 <Box flexDirection="row" gap={1}>
453 <Box flexShrink={0}>
454 <Text color="warning">{`△ stale ${ago(now - snap.at / 1000)}`}</Text>
455 </Box>
456 <Box flexShrink={1}>
457 <Text dimColor wrap="truncate-end">
458 {fault.reason}
459 </Text>
460 </Box>
461 </Box>
462 )
463
464 if (open) {
465 const f = detail?.id === open ? detail.facts : null
466 const lane = LANES.find(l => f && l.statuses.includes(f.status))
467 const field = (label: string, value: string) => (
468 <Box key={label} flexDirection="row" gap={1}>
469 <Box width={8} flexShrink={0}>
470 <Text dimColor>{label}</Text>
471 </Box>
472 <Box flexShrink={1}>
473 <Text wrap="truncate-end">{value}</Text>
474 </Box>
475 </Box>
476 )
477 const run = f?.run
478 return (
479 <Box flexDirection="column" width={width}>
480 <Box flexDirection="row" justifyContent="space-between">
481 <Button key="back" label="back" hotkey="b" plain role="dismiss" onPress={() => back($)} />
482 {refreshButton}
483 </Box>
484 {stale}
485 {!f && <Text dimColor>{detail?.error ?? `Reading ${open}…`}</Text>}
486 {f && (
487 <Box flexDirection="column" marginTop={1}>
488 <Box flexDirection="row" gap={1}>
489 <Text color={lane?.color ?? 'inactive'}>{lane?.glyph ?? '■'}</Text>
490 <Text dimColor>{join(' · ', f.id, f.status, f.assignee)}</Text>
491 </Box>
492 <Text bold>{f.title}</Text>
493 <Box flexDirection="column" marginTop={1}>
494 {f.branch && field('branch', join(' → ', f.branch, f.base))}
495 {f.pr_number && (
496 <Box flexDirection="row" gap={1}>
497 <Box width={8} flexShrink={0}>
498 <Text dimColor>pr</Text>
499 </Box>
500 {f.pr_url ? <Link href={f.pr_url} label={`#${f.pr_number}`} /> : <Text>{`#${f.pr_number}`}</Text>}
501 </Box>
502 )}
503 {f.block_kind && f.status === 'blocked' && field('blocked', words(f.block_kind))}
504 {f.skip && field('waits', words(f.skip.reason))}
505 {run &&
506 field(
507 'last run',
508 join(
509 ' · ',
510 run.role,
511 run.outcome ?? run.status,
512 run.cost_usd != null ? `$${run.cost_usd.toFixed(2)}` : null,
513 run.started_at ? `${ago(now - (run.ended_at ?? run.started_at))} ago` : null,
514 ),
515 )}
516 </Box>
517 {f.summary && (
518 <Box marginTop={1}>
519 <Text dimColor>{f.summary}</Text>
520 </Box>
521 )}
522 {f.comments.length > 0 && (
523 <Box flexDirection="column" marginTop={1}>
524 <Text bold dimColor>
525 Comments
526 </Text>
527 {f.comments.map(c => (
528 <Box key={`${c.created_at}:${c.author}`} flexDirection="column">
529 <Text dimColor>{`${c.author} · ${ago(now - c.created_at)}`}</Text>
530 <Text>{c.body}</Text>
531 </Box>
532 ))}
533 </Box>
534 )}
535 {detail?.error && (
536 <Text color="warning" wrap="truncate-end">
537 {`△ ${detail.error}`}
538 </Text>
539 )}
540 </Box>
541 )}
542 </Box>
543 )
544 }
545
546 const of = (statuses: string[]) => snap.cards.filter(c => statuses.includes(c.status))
547 const lanes = LANES.map(l => ({ ...l, cards: of(l.statuses) })).filter(l => l.cards.length > 0)
548 const raw = of(TRIAGE.statuses)
549 // The list draws triage as one more lane; the card view lists it under the grid.
550 const listed = raw.length > 0 ? [...lanes, { ...TRIAGE, cards: raw }] : lanes
551 const parts = BAR.map(g => ({ color: g.color, n: of(g.statuses).length }))
552 const total = parts.reduce((a, p) => a + p.n, 0)
553 const d = snap.digest
554 const needs = d?.needs_you ?? []
555 const merged = (d?.merged ?? []).slice(-2).reverse()
556 const wide = e.props.placement === 'inline' && width >= 72
557 const view = picked ?? 'cards'
558 // Rows the header, bar, footer, need strip and stale line take when inline.
559 const used = 3 + (needs.length > 0 ? 1 + Math.min(needs.length, 2) + (needs.length > 2 ? 1 : 0) : 0) + (fault ? 1 : 0)
560
561 const health = (
562 <Box flexDirection="row" gap={2} flexShrink={0}>
563 {d?.daemon && !d.daemon.alive ? (
564 <Text color="error">○ daemon off</Text>
565 ) : d?.paused ? (
566 <Text color="warning">‖ paused</Text>
567 ) : d?.daemon?.alive ? (
568 <Text color="success">● live</Text>
569 ) : null}
570 <Text dimColor>{d?.cost_usd != null ? `$${d.cost_usd.toFixed(2)} 24h` : `${total - of(['done']).length} open`}</Text>
571 </Box>
572 )
573 const header = (
574 <Box flexDirection="row" justifyContent="space-between" gap={1}>
575 <Box flexDirection="row" gap={1} flexShrink={1}>
576 <Text color="claude">◆</Text>
577 <Text bold wrap="truncate-end">
578 {snap.board}
579 </Text>
580 </Box>
581 {health}
582 </Box>
583 )
584 const bar = total > 0 && (
585 <Box flexDirection="row">
586 {segments(parts, width).map(
587 (seg, i) =>
588 seg.n > 0 && (
589 <Text key={String(i)} color={seg.color}>
590 {'━'.repeat(seg.n)}
591 </Text>
592 ),
593 )}
594 </Box>
595 )
596 const footer = (
597 <Box flexDirection="row" justifyContent="space-between">
598 <Box flexDirection="row" gap={2}>
599 {LEGEND.map(g => (
600 <Box key={g.name} flexDirection="row" gap={1}>
601 <Text color={g.color}>■</Text>
602 <Text dimColor>{`${g.name} ${of(g.statuses).length}`}</Text>
603 </Box>
604 ))}
605 </Box>
606 {/* Hotkeys reach the pane only while it holds the keyboard; until then they would type into the prompt. */}
607 {e.props.isFocused ? (
608 <Box flexDirection="row" gap={2}>
609 <Button key="view" label={view === 'cards' ? 'list' : 'cards'} hotkey="v" plain dimColor onPress={() => flip($)} />
610 {refreshButton}
611 </Box>
612 ) : (
613 <Text dimColor>ctrl+x tab: keys</Text>
614 )}
615 </Box>
616 )
617
618 // A section header: glyph and name in the section's accent, the count dim on the right.
619 const head = (glyph: string, name: string, color: string, count: number | null, w: number) => (
620 <Box flexDirection="row" justifyContent="space-between" width={w}>
621 <Box flexDirection="row" gap={1}>
622 <Text color={color}>{glyph}</Text>
623 <Text bold color={color}>
624 {name}
625 </Text>
626 </Box>
627 {count !== null && <Text dimColor>{String(count)}</Text>}
628 </Box>
629 )
630
631 // A row is one Button, so focus inverts it and Enter opens the card. Its cells are padded by hand,
632 // so badges, titles and the meta column line up across lanes.
633 const row = (key: string, id: string | null, w: number, badge: number, metaW: number, c: { priority?: number; title: string; meta: string }) => {
634 const titleW = w - 2 - badge - (metaW ? metaW + 1 : 0)
635 const cellsOf = [
636 <Text key="pad">{' '}</Text>,
637 ...(badge
638 ? [
639 <Text key="badge" bold color="warning">
640 {fit(c.priority ? `P${c.priority}` : '', badge)}
641 </Text>,
642 ]
643 : []),
644 <Text key="title">{fit(c.title, titleW)}</Text>,
645 ...(metaW
646 ? [
647 <Text key="meta" dimColor>
648 {' ' + fit(c.meta, metaW, true)}
649 </Text>,
650 ]
651 : []),
652 ]
653 return id ? (
654 <Button key={key} plain onPress={() => show($, id)}>
655 {cellsOf}
656 </Button>
657 ) : (
658 <Box key={key} flexDirection="row">
659 {cellsOf}
660 </Box>
661 )
662 }
663
664 const metaWidth = (w: number, metas: string[]) => (narrow ? 0 : Math.min(Math.floor(w / 3), Math.max(0, ...metas.map(cells))))
665 const badgeWidth = (cs: KanbanCard[]) => (cs.some(c => c.priority > 0) ? Math.max(...cs.map(c => (c.priority > 0 ? `P${c.priority}`.length : 0))) + 1 : 0)
666
667 const strip = (max: number, oneLine: boolean) =>
668 needs.length > 0 && (
669 <Box flexDirection="column">
670 {head('▲', 'Needs you', 'warning', needs.length, width)}
671 {needs.slice(0, max).map((n, i) => {
672 const a = ask(n, now)
673 const lead = [
674 <Text key="pad">{' '}</Text>,
675 <Text key="label" color="warning">
676 {fit(a.label, LABEL)}
677 </Text>,
678 <Text key="title">{' ' + (oneLine ? fitHint(join(' · ', a.title, a.hint), width - 3 - LABEL) : fit(a.title, width - 3 - LABEL))}</Text>,
679 ]
680 return (
681 <Box key={String(i)} flexDirection="column">
682 {n.task_id ? (
683 <Button key={`need:${i}`} plain onPress={() => show($, n.task_id as string)}>
684 {lead}
685 </Button>
686 ) : (
687 <Box flexDirection="row">{lead}</Box>
688 )}
689 {!oneLine &&
690 a.hint &&
691 hintLines(a.hint, width - 3 - LABEL).map((s, j) => (
692 <Text key={`hint:${j}`} dimColor>
693 {' '.repeat(LABEL + 3) + s}
694 </Text>
695 ))}
696 </Box>
697 )
698 })}
699 {needs.length > max && <Text dimColor italic>{` +${needs.length - max} more`}</Text>}
700 </Box>
701 )
702
703 const recent = (w: number) =>
704 merged.length > 0 && (
705 <Box flexDirection="column">
706 {head('✓', 'Merged', 'success', null, w)}
707 {merged.map(m => row(`merged:${m.task_id}:${m.at}`, m.task_id, w, 0, narrow ? 0 : 4, { title: m.title ?? '', meta: ago(now - m.at) }))}
708 </Box>
709 )
710
711 if (view === 'cards') {
712 // The lanes as columns of cards, as many across as fit, spread evenly over the rows they need.
713 // Every lane keeps its place, an empty one drawn faint, so nothing moves when a lane empties.
714 const gap = 2
715 const grid = LANES.map(l => ({ ...l, cards: of(l.statuses) }))
716 const across = Math.min(Math.max(1, Math.floor((width + gap) / (CARD_MIN + gap))), grid.length)
717 const perRow = Math.ceil(grid.length / Math.ceil(grid.length / across))
718 const colW = Math.floor((width - gap * (perRow - 1)) / perRow)
719 const groups = Array.from({ length: Math.ceil(grid.length / perRow) }, (_, i) => grid.slice(i * perRow, (i + 1) * perRow))
720 const shown = needs.slice(0, CARDS).map(n => ask(n, now))
721 const lines = (c: KanbanCard) => {
722 const badge = c.priority > 0 ? `P${c.priority} ` : ''
723 return { badge, title: wrap(c.title, colW - 4 - cells(badge), colW - 4, 2) }
724 }
725 // As many cards a lane as the frame holds, so the footer and its keys stay in view.
726 // A tile is its border's two rows, its title lines and the meta line.
727 const lift = wide ? 0 : 1
728 const above =
729 3 +
730 lift +
731 (stale ? 1 : 0) +
732 (needs.length === 0 ? 0 : wide ? 1 + Math.min(needs.length, 2) + (needs.length > 2 ? 1 : 0) : lift + 1 + shown.reduce((a, x) => a + 3 + (x.why ? 1 : 0) + (x.act ? 1 : 0), 0) + (needs.length > CARDS ? 1 : 0)) +
733 (raw.length > 0 ? lift + 1 : 0) +
734 (!wide && merged.length > 0 ? lift + 1 + merged.length : 0)
735 const tall = (k: number) =>
736 groups.reduce((a, g) => a + lift + 1 + Math.max(...g.map(l => l.cards.slice(0, k).reduce((b, c) => b + 3 + lines(c).title.length, 0)), 1) + (g.some(l => l.cards.length > k) ? 1 : 0), 0)
737 let room = CARDS
738 while (room > 1 && above + tall(room) > e.props.scroll.bodyRows) room--
739 // The border sits on a Box, since a Button holds only text: its first title line is the Button,
740 // so the focus marks the card and Enter opens it.
741 const tile = (l: Lane, c: KanbanCard) => {
742 const {
743 badge,
744 title: [first, ...rest],
745 } = lines(c)
746 const top = [
747 ...(badge
748 ? [
749 <Text key="badge" bold color="warning">
750 {badge}
751 </Text>,
752 ]
753 : []),
754 <Text key="title">{first}</Text>,
755 ]
756 return (
757 <Box key={`box:${c.id}`} flexDirection="column" width={colW} borderStyle="round" borderColor={l.color} borderDimColor paddingX={1} hover={{ borderDimColor: false }}>
758 <Button key={`card:${c.id}`} plain onPress={() => show($, c.id)}>
759 {top}
760 </Button>
761 {rest.map((s, i) => (
762 <Text key={`line:${i}`}>{s}</Text>
763 ))}
764 <Text dimColor wrap="truncate-end">
765 {l.meta(c, now)}
766 </Text>
767 </Box>
768 )
769 }
770 // What waits on the owner, loudest on the pane: a card per need, the reason, then the command on its own line.
771 const asks = needs.length > 0 && (
772 <Box flexDirection="column" marginTop={1}>
773 {head('▲', 'Needs you', 'warning', needs.length, width)}
774 {shown.map((a, i) => {
775 const n = needs[i] as KanbanNeed
776 const top = [
777 <Text key="label" bold color="warning">
778 {fit(a.label, LABEL)}
779 </Text>,
780 <Text key="title">{' ' + fit(a.title, width - 5 - LABEL)}</Text>,
781 ]
782 return (
783 <Box key={`ask:${i}`} flexDirection="column" width={width} borderStyle="round" borderColor="warning" paddingX={1}>
784 {n.task_id ? (
785 <Button key={`need:${i}`} plain onPress={() => show($, n.task_id as string)}>
786 {top}
787 </Button>
788 ) : (
789 <Box flexDirection="row">{top}</Box>
790 )}
791 {a.why && <Text dimColor>{a.why}</Text>}
792 {a.act && <Text bold>{`→ ${a.act}`}</Text>}
793 </Box>
794 )
795 })}
796 {needs.length > CARDS && <Text dimColor italic>{` +${needs.length - CARDS} more`}</Text>}
797 </Box>
798 )
799 // Triage as rows, not tiles: a raw idea is a title and its age. The lanes come first: triage keeps
800 // its header and count, and lists as many ideas as the rows the lanes leave.
801 const spare = Math.max(0, e.props.scroll.bodyRows - above - tall(room))
802 const ideas = raw.slice(0, Math.min(CARDS, spare))
803 const ideaMeta = metaWidth(width, ideas.map(c => TRIAGE.meta(c, now)))
804 const triage = raw.length > 0 && (
805 <Box flexDirection="column" marginTop={lift}>
806 {head(TRIAGE.glyph, TRIAGE.name, TRIAGE.color, raw.length, width)}
807 {ideas.map(c => row(`card:${c.id}`, c.id, width, badgeWidth(ideas), ideaMeta, { priority: c.priority, title: c.title, meta: TRIAGE.meta(c, now) }))}
808 {raw.length > ideas.length && spare > ideas.length && <Text dimColor italic>{` +${raw.length - ideas.length} more`}</Text>}
809 </Box>
810 )
811 return (
812 <Box flexDirection="column" width={width}>
813 {header}
814 {bar}
815 {stale}
816 {wide ? strip(2, true) : asks}
817 {lanes.length === 0 && <Text dimColor>Nothing running or planned.</Text>}
818 {lanes.length > 0 &&
819 groups.map((g, i) => (
820 <Box key={`lanes:${i}`} flexDirection="row" gap={gap} marginTop={wide ? 0 : 1}>
821 {g.map(l => (
822 <Box key={`lane:${l.key}`} flexDirection="column" width={colW} flexShrink={0}>
823 {head(l.glyph, l.name, l.cards.length ? l.color : 'inactive', l.cards.length, colW)}
824 {l.cards.length === 0 && <Text dimColor>{' —'}</Text>}
825 {l.cards.slice(0, room).map(c => tile(l, c))}
826 {l.cards.length > room && <Text dimColor italic>{` +${l.cards.length - room} more`}</Text>}
827 </Box>
828 ))}
829 </Box>
830 ))}
831 {triage}
832 {!wide && merged.length > 0 && (
833 <Box flexDirection="column" marginTop={1}>
834 {recent(width)}
835 </Box>
836 )}
837 <Box flexDirection="column" marginTop={lift}>
838 {footer}
839 </Box>
840 </Box>
841 )
842 }
843
844 if (wide) {
845 // Inline: the lanes side by side, as many rows as the frame holds.
846 const gap = 2
847 const colW = Math.floor((width - gap * Math.max(0, listed.length - 1)) / Math.max(1, listed.length))
848 const room = Math.max(1, Math.min(SHOWN, e.props.scroll.bodyRows - used - 2))
849 return (
850 <Box flexDirection="column" width={width}>
851 {header}
852 {bar}
853 {stale}
854 {strip(2, true)}
855 {lanes.length === 0 && <Text dimColor>Nothing running or planned.</Text>}
856 <Box flexDirection="row" gap={gap}>
857 {listed.map(l => {
858 const cs = l.cards.slice(0, room)
859 const metaW = colW < 30 ? 0 : metaWidth(colW, cs.map(c => l.meta(c, now)))
860 const badge = badgeWidth(cs)
861 return (
862 <Box key={l.key} flexDirection="column" width={colW} flexShrink={0}>
863 {head(l.glyph, l.name, l.color, l.cards.length, colW)}
864 {cs.map(c => row(`card:${c.id}`, c.id, colW, badge, metaW, { priority: c.priority, title: c.title, meta: l.meta(c, now) }))}
865 {l.cards.length > cs.length && <Text dimColor italic>{` +${l.cards.length - cs.length} more`}</Text>}
866 </Box>
867 )
868 })}
869 </Box>
870 {footer}
871 </Box>
872 )
873 }
874
875 // Dock: one column, lanes as headed lists, one meta column shared by every lane.
876 const visible = listed.flatMap(l => l.cards.slice(0, SHOWN).map(c => ({ c, meta: l.meta(c, now) })))
877 const metaW = metaWidth(width, visible.map(v => v.meta))
878 const badge = badgeWidth(visible.map(v => v.c))
879 return (
880 <Box flexDirection="column" width={width}>
881 {header}
882 {bar}
883 {stale}
884 {strip(3, false)}
885 {lanes.length === 0 && <Text dimColor>Nothing running or planned.</Text>}
886 {listed.map(l => (
887 <Box key={l.key} flexDirection="column">
888 {head(l.glyph, l.name, l.color, l.cards.length, width)}
889 {l.cards.slice(0, SHOWN).map(c => row(`card:${c.id}`, c.id, width, badge, metaW, { priority: c.priority, title: c.title, meta: l.meta(c, now) }))}
890 {l.cards.length > SHOWN && <Text dimColor italic>{` +${l.cards.length - SHOWN} more`}</Text>}
891 </Box>
892 ))}
893 {recent(width)}
894 {footer}
895 </Box>
896 )
897 })
898}
899types/index.d.ts 93 lines1// The pane's state contract: what `hooks/board.tsx` keeps in `$.state`, read back while drawing.
2// Fields marked optional come from newer `kanban` CLIs; an older CLI leaves them out.
3
4export type KanbanSkip = { reason: string; at: number }
5
6export type KanbanCard = {
7 id: string
8 title: string
9 status: string
10 assignee: string | null
11 priority: number
12 updated_at: number
13 completed_at?: number | null
14 pr_number?: number | null
15 skip?: KanbanSkip | null
16 block_kind?: string | null
17}
18
19// One `needs_you` or `merged` item of `kanban --json digest`.
20export type KanbanNeed = {
21 id?: number
22 task_id: string | null
23 kind: string
24 at: number
25 title: string | null
26 payload: { [key: string]: unknown }
27 reason?: string | null
28}
29
30export type KanbanDigest = {
31 needs_you: KanbanNeed[]
32 merged: KanbanNeed[]
33 cost_usd: number | null
34 daemon: { alive: boolean; version: string | null } | null
35 paused: { reason: string; detail: string | null } | null
36}
37
38// The last good read of the board; `at` is the engine clock in ms when the read started.
39export type KanbanSnapshot = {
40 board: string
41 cards: KanbanCard[]
42 digest: KanbanDigest | null
43 at: number
44 pin: string | null
45}
46
47// Why the latest refresh failed, and since when refreshes have been failing.
48export type KanbanFault = { reason: string; since: number }
49
50export type KanbanComment = { author: string; body: string; created_at: number }
51
52export type KanbanRun = {
53 role: string | null
54 status: string | null
55 outcome: string | null
56 cost_usd: number | null
57 started_at: number | null
58 ended_at: number | null
59}
60
61// The facts the detail view shows, picked from `kanban --json show ID`.
62export type KanbanFacts = {
63 id: string
64 title: string
65 status: string
66 assignee: string | null
67 branch: string | null
68 base: string | null
69 pr_number: number | null
70 pr_url: string | null
71 block_kind: string | null
72 skip: KanbanSkip | null
73 summary: string | null
74 run: KanbanRun | null
75 comments: KanbanComment[]
76}
77
78export type KanbanDetail = { id: string; facts: KanbanFacts | null; error: string | null }
79
80declare module 'claude-code' {
81 interface PluginState {
82 'kanban-claude': {
83 snapshot: KanbanSnapshot | null
84 fault: KanbanFault | null
85 pin: string | null
86 open: string | null
87 detail: KanbanDetail | null
88 seen: number | null
89 view: 'cards' | 'list' | null
90 }
91 }
92}
93