SLOPSHOPPER

kanban-claude

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

panecommandtoastprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kanban-claude
│ ┃ Kanban ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Kanban │ ┃ ▲ JSON Parse error: Unexpected EOF ⏺ Read(src/auth.ts) │ ┃ /kanban-board SLUG pins a board. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ r: refresh ⎿ 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 │ │ › /kanban-board │ ⎿ kanban-claude: Kanban pane open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Kanban
◆ Kanban ▲ JSON Parse error: Unexpected EOF /kanban-board SLUG pins a board. r: refresh
README

claude-kanban-plugin

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.

Install

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:

  1. checks the host (macOS or Linux, systemctl on Linux, uv, git, gh) and claude --version, and refuses a claude older than 2.1.259 with claude update as the fix;
  2. picks the Claude config directory: --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;
  3. captures the environment for the daemon's service: 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;
  4. probes 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);
  5. creates the store, migrating it after a backup, and records claude.bin and claude.config_dir only where they differ from what is stored, so a rerun keeps everything you configured;
  6. writes the service file (0600): on macOS the LaunchAgent 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;
  7. writes $KANBAN_HOME/installed.json (the version, the Claude binary and config directory and the captured environment; data only, no hook executes it);
  8. waits up to 10 s for the daemon to hold $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.

What the plugin ships

  • Agents 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.
  • Skills: kanban (the orchestrator rulebook and CLI reference), kanban-code-review (warden's procedure and preflight.py), simplify-code-card (slop_gate.py).
  • Hooks: a lint check on the lines a builder just edited, a session-start status check, and the board pane mod.
  • Every run is measured from its own stream (turns, tools, skills, MCP servers, agents, hooks, cache writes by TTL, advisor calls, API time) and 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.
  • The designer runs Playwright MCP, pinned to an exact version in 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.

Session start

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.

Board pane

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

  • Needs you comes first: questions, grants, merges, logins and quota walls, each a card with its reason and, on its own line, the command that answers it. A new need raises one toast.
  • A health line shows whether the daemon is live, whether dispatch is paused, and the last 24 hours' spend.
  • Running, Review, Blocked and Up next are columns of cards: priority, the title on up to two lines, then owner and age, or PR number or block kind. A lane shows as many cards as the pane's height holds, then +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.
  • Select a card for its detail (branch, PR, last run, comments); back (or b) returns.
  • Card columns sit as many across as fit, at least 22 cells each: two in a dock, four inline at 120 columns. In the list, inline from 72 columns the lanes sit side by side; in a dock or narrower they stack. Colors are Claude Code theme keys, so the pane follows your theme.
  • A failed refresh keeps the last good read and says how stale it is and why.
  • The pane reopens next session only in the same folder, and only if that board still reads; one closed by hand stays closed. It needs 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.

Web dashboard

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.

  • The board has one lane per status, live cards with the last thing the worker did, and a dock that follows every running card's transcript.
  • A card opens in a drawer: Overview, Parameters (every card setting, then per run the command line, environment, what the session loaded and what it used), Runs, Logs and Comments.
  • Logs show a run's transcript with tools, results, hooks and subagent work folded under their parent, or the raw text log, with filters, search, paging for large streams, follow mode and a download of the original file. Long entries are cut on the page and expanded on request.
  • Stats shows spend per day, per role and per model, outcomes, review rounds and, on a schema 6 database, the harness numbers (skills, MCP, tools, subagents, hooks).

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.

Doctor

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

Branch protection

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

Jira sync (optional)

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)
  • Membership. Issues that match the board's JQL (default 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.
  • Text. Title and body are merged per field, each side against its own last synced value: Jira against what it read back, the card against its own text then. So a server that rewrites the markup never looks like an edit, and a card touched for any other reason sends nothing. One side changed: that side wins. Both changed: Jira wins, and the card's text goes into a [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.
  • Status. Only a change of the card's own status moves its issue, by one direct transition to the status in 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.
  • Comments. Jira comments become card comments by 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.
  • Team use. Several people can pair their own boards (each with its own $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's
Source 2 files
hooks/board.tsx 899 lines
1import { 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}
899
types/index.d.ts 93 lines
1// 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