claude-code-kanban 6.1.2 → 7.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/panes.js ADDED
@@ -0,0 +1,174 @@
1
+ // Per-session pane layout: the tabs next to Board. The file is the only copy, shared by every
2
+ // board process on the config dir, the CLI and the agent. Which tab is on screen is each
3
+ // browser tab's own state, not stored here. Expiry: docs/retention.md.
4
+
5
+ const path = require('node:path');
6
+ const { httpError } = require('./http-error');
7
+ const { canonicalPath } = require('./linked-docs');
8
+ const { isLoopbackAddress } = require('./net-guard');
9
+ const { oneLine } = require('./one-line');
10
+ const { pruneMap } = require('./retention');
11
+
12
+ const MAX_PANES = 50;
13
+ const MAX_TITLE = 100;
14
+ const KINDS = new Set(['url', 'html', 'markdown', 'text', 'image']);
15
+
16
+ const portOf = (u) => Number(u.port || (u.protocol === 'https:' ? 443 : 80));
17
+
18
+ // A frame with allow-scripts plus allow-same-origin on the board's origin or the hub's can reach
19
+ // into its parent and drop its own sandbox. Every name either one is served under counts.
20
+ function isOwnOrigin(url, { boardPort, boardHosts = [], hubUrl }) {
21
+ let u;
22
+ try {
23
+ u = new URL(url);
24
+ } catch {
25
+ return false;
26
+ }
27
+ const port = portOf(u);
28
+ const loopback = isLoopbackAddress(u.hostname);
29
+ if (u.protocol === 'http:' && port === boardPort && (loopback || boardHosts.includes(u.hostname))) return true;
30
+ let hub;
31
+ try {
32
+ hub = new URL(hubUrl);
33
+ } catch {
34
+ return false;
35
+ }
36
+ const hubHost = u.hostname === hub.hostname || (loopback && isLoopbackAddress(hub.hostname));
37
+ return u.protocol === hub.protocol && port === portOf(hub) && hubHost;
38
+ }
39
+
40
+ const emptyLayout = () => ({ rev: 0, nextId: 1, panes: [], updatedAt: null });
41
+
42
+ const targetKey = (kind, target) => (kind === 'url' ? `url:${target}` : `file:${canonicalPath(target)}`);
43
+
44
+ function defaultTitle(kind, target) {
45
+ if (kind !== 'url') return path.basename(target);
46
+ try {
47
+ return new URL(target).host;
48
+ } catch {
49
+ return target;
50
+ }
51
+ }
52
+
53
+ const idNumber = (id) => Number(id.slice(1)) || 0;
54
+
55
+ function cleanLayout(raw) {
56
+ if (!raw || !Array.isArray(raw.panes) || !Number.isFinite(raw.updatedAt)) return null;
57
+ const ids = new Set();
58
+ const panes = raw.panes.filter((p) => {
59
+ const ok = p && typeof p.id === 'string' && !ids.has(p.id) && KINDS.has(p.kind) && typeof p.target === 'string' && p.target;
60
+ if (ok) ids.add(p.id);
61
+ return ok;
62
+ });
63
+ const nextId = Math.max(Number.isInteger(raw.nextId) ? raw.nextId : 1, ...panes.map((p) => idNumber(p.id) + 1));
64
+ const rev = Number.isInteger(raw.rev) && raw.rev > 0 ? raw.rev : 1;
65
+ return { rev, nextId, panes: panes.slice(0, MAX_PANES), updatedAt: raw.updatedAt };
66
+ }
67
+
68
+ const publicLayout = (l) => ({ rev: l.rev, panes: l.panes.map((p) => ({ ...p })), updatedAt: l.updatedAt });
69
+
70
+ /**
71
+ * Every write re-reads the file first, so two boards on one config dir add to each other's
72
+ * layouts instead of overwriting them. The read-to-rename window is left open: both writers
73
+ * are one user's own board tabs and CLI calls.
74
+ *
75
+ * @param {object} o
76
+ * @param {() => object|null|undefined} o.load returns `{version: 2, sessions: {[id]: {rev, nextId, panes, updatedAt}}}`,
77
+ * null when there is no file, or undefined when the file has not changed since the last load or save
78
+ * @param {(data: object) => void} o.save
79
+ * @param {(sessionId: string, layout: object) => void} [o.onChange] called for each layout whose rev
80
+ * moved, from this store's writes and from `reload()` alike
81
+ */
82
+ function createPaneStore({ load, save, onChange = () => {}, now = Date.now }) {
83
+ let bySession = new Map();
84
+
85
+ function reload() {
86
+ const data = load();
87
+ if (data === undefined) return;
88
+ const next = new Map();
89
+ const saved = data?.sessions;
90
+ if (saved && typeof saved === 'object') {
91
+ for (const [id, raw] of Object.entries(saved)) {
92
+ const layout = cleanLayout(raw);
93
+ if (layout) next.set(id, layout);
94
+ }
95
+ }
96
+ const before = bySession;
97
+ bySession = next;
98
+ for (const [id, layout] of next) {
99
+ if (before.get(id)?.rev !== layout.rev) onChange(id, publicLayout(layout));
100
+ }
101
+ }
102
+ reload();
103
+
104
+ const persist = () => save({ version: 2, sessions: Object.fromEntries(bySession) });
105
+
106
+ function write(sessionId, layout) {
107
+ layout.rev += 1;
108
+ layout.updatedAt = now();
109
+ bySession.set(sessionId, layout);
110
+ persist();
111
+ onChange(sessionId, publicLayout(layout));
112
+ return publicLayout(layout);
113
+ }
114
+
115
+ function get(sessionId) {
116
+ const layout = bySession.get(sessionId);
117
+ return publicLayout(layout || emptyLayout());
118
+ }
119
+
120
+ // The same target added again returns its pane where it is, so a repeated `pane add` is harmless.
121
+ function add(sessionId, { kind, target, title }) {
122
+ if (!KINDS.has(kind)) throw httpError(400, `Unknown pane kind: ${kind}`, 'bad_kind');
123
+ reload();
124
+ const layout = bySession.get(sessionId) || emptyLayout();
125
+ const key = targetKey(kind, target);
126
+ const existing = layout.panes.find((p) => targetKey(p.kind, p.target) === key);
127
+ if (existing) return { pane: { ...existing }, added: false, layout: publicLayout(layout) };
128
+ if (layout.panes.length >= MAX_PANES) throw httpError(409, `A session holds at most ${MAX_PANES} panes`, 'pane_limit');
129
+ const pane = {
130
+ id: `p${layout.nextId++}`,
131
+ kind,
132
+ target,
133
+ title: oneLine(title, MAX_TITLE) || oneLine(defaultTitle(kind, target), MAX_TITLE),
134
+ addedAt: now(),
135
+ };
136
+ layout.panes.push(pane);
137
+ return { pane: { ...pane }, added: true, layout: write(sessionId, layout) };
138
+ }
139
+
140
+ // Returns the new layout, or null when the session has no such pane. An emptied layout stays
141
+ // until retention drops it, so its ids and rev keep counting up.
142
+ function remove(sessionId, paneId) {
143
+ reload();
144
+ const layout = bySession.get(sessionId);
145
+ const i = layout ? layout.panes.findIndex((p) => p.id === paneId) : -1;
146
+ if (i < 0) return null;
147
+ layout.panes.splice(i, 1);
148
+ return write(sessionId, layout);
149
+ }
150
+
151
+ // `order` is every pane id of the session, in the new tab order.
152
+ function reorder(sessionId, order) {
153
+ reload();
154
+ const layout = bySession.get(sessionId);
155
+ const ids = layout ? layout.panes.map((p) => p.id) : [];
156
+ const valid = Array.isArray(order) && order.length === ids.length && new Set(order).size === ids.length;
157
+ if (!valid || !order.every((id) => ids.includes(id))) throw httpError(400, 'order must list every pane id once', 'bad_order');
158
+ if (!layout || order.every((id, i) => id === ids[i])) return get(sessionId);
159
+ layout.panes = order.map((id) => layout.panes.find((p) => p.id === id));
160
+ return write(sessionId, layout);
161
+ }
162
+
163
+ // Returns how many layouts went; writes only when one did.
164
+ function prune({ known, maxAgeMs }) {
165
+ reload();
166
+ const removed = pruneMap(bySession, (l) => l.updatedAt, { known, maxAgeMs, now: now() });
167
+ if (removed) persist();
168
+ return removed;
169
+ }
170
+
171
+ return { get, add, remove, reorder, prune, reload };
172
+ }
173
+
174
+ module.exports = { createPaneStore, isOwnOrigin, portOf, MAX_PANES, MAX_TITLE };
package/lib/retention.js CHANGED
@@ -26,6 +26,18 @@ function isExpired(id, at, { known, maxAgeMs, now }) {
26
26
  return (known && !known.has(id)) || age > maxAgeMs;
27
27
  }
28
28
 
29
+ // Deletes the expired entries of a session-keyed map and returns how many went.
30
+ function pruneMap(map, atOf, { known, maxAgeMs, now }) {
31
+ let removed = 0;
32
+ for (const [id, entry] of map) {
33
+ if (isExpired(id, atOf(entry), { known, maxAgeMs, now })) {
34
+ map.delete(id);
35
+ removed++;
36
+ }
37
+ }
38
+ return removed;
39
+ }
40
+
29
41
  /**
30
42
  * Sessions started through `dispatch start`, kept after their terminal ends so the card
31
43
  * keeps its marker.
@@ -55,14 +67,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
55
67
 
56
68
  // Returns how many entries went; writes only when one did.
57
69
  function prune({ known, maxAgeMs }) {
58
- const t = now();
59
- let removed = 0;
60
- for (const [id, e] of entries) {
61
- if (isExpired(id, e.at, { known, maxAgeMs, now: t })) {
62
- entries.delete(id);
63
- removed++;
64
- }
65
- }
70
+ const removed = pruneMap(entries, (e) => e.at, { known, maxAgeMs, now: now() });
66
71
  if (removed) persist();
67
72
  return removed;
68
73
  }
@@ -159,6 +164,7 @@ async function pruneContextStatus(dir, { known, maxAgeMs, now = Date.now(), max
159
164
  }
160
165
 
161
166
  module.exports = {
167
+ pruneMap,
162
168
  createDispatchedStore,
163
169
  scanTranscripts,
164
170
  pruneSessionDirs,
@@ -0,0 +1,41 @@
1
+ // Where the harness puts a session's scratchpad. The rule and its probes: docs/session-scanning.md.
2
+
3
+ const path = require('node:path');
4
+ const { existsSync, opendirSync } = require('node:fs');
5
+ const { encodeProjectDirName } = require('./claude-dir');
6
+
7
+ function isEmptyDir(dir) {
8
+ let handle;
9
+ try {
10
+ handle = opendirSync(dir);
11
+ return handle.readSync() === null;
12
+ } catch {
13
+ return true;
14
+ } finally {
15
+ handle?.closeSync();
16
+ }
17
+ }
18
+
19
+ function createScratchpadDirResolver({ root, resolveWorktree, exists = existsSync, isEmpty = isEmptyDir }) {
20
+ // A by-project dir that holds an entry always wins, so that answer is kept and never probed again.
21
+ const settled = new Map();
22
+
23
+ return function getScratchpadDir(id, meta) {
24
+ if (!meta.jsonlPath) return null;
25
+ const byProject = path.join(root, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
26
+ if (settled.get(id) === byProject) return byProject;
27
+
28
+ // Claude Code keys the scratchpad on the process's original cwd, while entering a worktree
29
+ // moves the transcript under the worktree.
30
+ const wt = resolveWorktree(meta.project);
31
+ const launch = wt ? wt.repo : meta.project;
32
+ if (!launch) return byProject;
33
+ const byLaunch = path.join(root, encodeProjectDirName(launch), id, 'scratchpad');
34
+ if (byLaunch === byProject || !exists(byLaunch)) return byProject;
35
+ if (isEmpty(byProject)) return byLaunch;
36
+ settled.set(id, byProject);
37
+ return byProject;
38
+ };
39
+ }
40
+
41
+ module.exports = { createScratchpadDirResolver };
@@ -3,6 +3,8 @@
3
3
  // bucket-map invariants here are the difference between a bounded queue and a map that
4
4
  // grows one permanent entry per session id ever named in a request path.
5
5
 
6
+ const { oneLine } = require('./one-line');
7
+
6
8
  // Tells a live session that the board moved one of its tasks. The postman monitor
7
9
  // (plugin/plugins/claude-code-kanban/scripts/postman.js) drains this queue and prints
8
10
  // each line, which Claude Code delivers into that session as a task notification.
@@ -24,10 +26,7 @@ const sessionEventBuckets = new Map();
24
26
  //
25
27
  // One cap for the whole line rather than one per field, because the description comes
26
28
  // last: truncation eats its tail first and leaves the machine-readable head intact.
27
- function sanitizeEventLine(line) {
28
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
29
- return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
30
- }
29
+ const sanitizeEventLine = (line) => oneLine(line, 1500);
31
30
 
32
31
  // Lines are plain sentences so they still read right once the skill that explains them has
33
32
  // left the agent's context. EVENT_PREFIX marks the board as the sender.
package/lib/terminal.js CHANGED
@@ -17,6 +17,7 @@ const fs = require('node:fs');
17
17
  const os = require('node:os');
18
18
  const path = require('node:path');
19
19
  const { isContained } = require('./contain');
20
+ const { isPidAlive } = require('./live-sessions');
20
21
  const priority = require('./priority');
21
22
 
22
23
  const WS_PATH = '/api/terminal/ws';
@@ -57,6 +58,8 @@ const RESTORE_GAP_MS = 2000;
57
58
  const SAVE_DELAY_MS = 1000;
58
59
  // A fork runs under its parent's id until claude names the new one, so it has no id to resume.
59
60
  const RESTORABLE_MODES = new Set(['resume', 'new']);
61
+ // Quitting claude drops the PTY to its shell, so the PTY's exit cannot tell cck that claude is gone.
62
+ const CLAUDE_EXIT_POLL_MS = 5000;
60
63
  // The size of a PTY no browser has attached to yet; the first attach resizes it.
61
64
  const HEADLESS_COLS = 120;
62
65
  const HEADLESS_ROWS = 32;
@@ -315,6 +318,10 @@ function createTerminalService(o) {
315
318
  let lastSaved = null;
316
319
  const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
317
320
  const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
321
+ const exitPollMs = o.exitPollMs ?? CLAUDE_EXIT_POLL_MS;
322
+ const pidAlive = o.isPidAlive || isPidAlive;
323
+ const exitTimer = setInterval(checkExits, exitPollMs);
324
+ exitTimer.unref();
318
325
  // Read before any terminal starts, so the first save keeps the ids still waiting to restore.
319
326
  const saved = config.restore ? o.load?.()?.sessions : null;
320
327
  const restoreQueue = Array.isArray(saved) ? saved.filter((id) => typeof id === 'string' && UUID_RE.test(id)) : [];
@@ -359,7 +366,7 @@ function createTerminalService(o) {
359
366
  function saveState() {
360
367
  saveTimer = null;
361
368
  const ids = new Set(restoreQueue);
362
- for (const s of sessions.values()) if (!s.ended && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
369
+ for (const s of sessions.values()) if (!s.ended && !s.claudeExitedAt && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
363
370
  const data = { sessions: [...ids] };
364
371
  const json = JSON.stringify(data);
365
372
  if (json === lastSaved) return;
@@ -435,11 +442,12 @@ function createTerminalService(o) {
435
442
  }
436
443
 
437
444
  // The process a viewer types into: claude, found through its registry entry, or the shell.
438
- function inputPid(s) {
445
+ function inputPid(s, registry) {
439
446
  if (s.mode === 'shell') return s.pty.pid;
447
+ if (s.claudeExitedAt) return null;
440
448
  if (s.claudePid || s.mode === 'pick') return s.claudePid;
441
- const live = o.liveSessions();
442
- const own = live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= s.startedAt - PICK_START_SLACK_MS);
449
+ const live = registry || o.liveSessions();
450
+ const own = ownEntry(live, s, s.startedAt);
443
451
  if (own || s.mode !== 'fork') {
444
452
  s.claudePid = own?.pid;
445
453
  return s.claudePid;
@@ -449,6 +457,36 @@ function createTerminalService(o) {
449
457
  return s.claudePid;
450
458
  }
451
459
 
460
+ // Claude deletes its registry entry on exit, and a crash leaves one with a dead pid. A terminal
461
+ // with no pid yet is still starting. Matching by pid keeps a /clear or /resume, which rewrite
462
+ // the entry's sessionId, from reading as an exit.
463
+ function checkExits() {
464
+ const watched = [...sessions.values()].filter((s) => !s.exited && !s.ended && s.mode !== 'shell');
465
+ if (!watched.length) return;
466
+ const live = o.liveSessions();
467
+ let moved = false;
468
+ for (const s of watched) {
469
+ if (s.claudeExitedAt) {
470
+ const back = ownEntry(live, s, s.claudeExitedAt);
471
+ if (!back || !pidAlive(back.pid)) continue;
472
+ s.claudeExitedAt = null;
473
+ s.claudePid = back.pid;
474
+ moved = true;
475
+ continue;
476
+ }
477
+ const pid = inputPid(s, live);
478
+ if (!pid || (live.some((l) => l.pid === pid) && pidAlive(pid))) continue;
479
+ s.claudeExitedAt = Date.now();
480
+ s.claudePid = null;
481
+ moved = true;
482
+ }
483
+ if (moved) changed();
484
+ }
485
+
486
+ function ownEntry(live, s, since) {
487
+ return live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= since - PICK_START_SLACK_MS);
488
+ }
489
+
452
490
  // claude writes its registry entry a moment after it starts, so the pid is polled for.
453
491
  function syncBoost(s) {
454
492
  const want = s.sockets.size > 0 && !s.exited;
@@ -699,9 +737,10 @@ function createTerminalService(o) {
699
737
 
700
738
  function claudePids() {
701
739
  const out = {};
740
+ const live = sessions.size ? o.liveSessions() : [];
702
741
  for (const s of sessions.values()) {
703
742
  if (s.ended || s.exited || s.mode === 'shell') continue;
704
- const pid = inputPid(s);
743
+ const pid = inputPid(s, live);
705
744
  if (pid) out[s.id] = pid;
706
745
  }
707
746
  return out;
@@ -741,7 +780,7 @@ function createTerminalService(o) {
741
780
  // pending queued prompt owns the input box until it is sent.
742
781
  function paste(id, text) {
743
782
  const s = sessions.get(id);
744
- if (!s || s.ended || s.exited || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
783
+ if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
745
784
  if (onTrustScreen(s)) return false;
746
785
  s.pty.write(bracketed(text));
747
786
  return true;
@@ -750,6 +789,7 @@ function createTerminalService(o) {
750
789
  function shutdown() {
751
790
  shuttingDown = true;
752
791
  clearTimeout(saveTimer);
792
+ clearInterval(exitTimer);
753
793
  for (const s of sessions.values()) {
754
794
  try { s.pty.kill(); } catch { /* already gone */ }
755
795
  }
package/lib/worktrees.js CHANGED
@@ -10,6 +10,11 @@ const { encodeProjectDirName } = require('./claude-dir');
10
10
  // main checkout is readable without spawning git. Path shape alone would not do: only some
11
11
  // worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
12
12
  const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
13
+ // A submodule's worktree points into the superproject: `<super>/.git/modules/<sub>/worktrees/<name>`,
14
+ // nested as `<a>/modules/<b>`. The checkout is taken to sit at `<super>/<sub>`, which holds while
15
+ // the submodule's name is its path, the git default; reading `core.worktree` would cost a read.
16
+ const GITDIR_SUBMODULE_WORKTREE_RE =
17
+ /^gitdir:\s*(.*)[/\\]\.git[/\\]modules[/\\](.+?)[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
13
18
  // Where `claude -w` puts a worktree. Used only once the `.git` file is gone, because Claude
14
19
  // Code removes worktrees while their transcripts stay.
15
20
  const CLAUDE_WORKTREE_RE = /^(.*)[/\\]\.claude[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
@@ -20,9 +25,13 @@ function readGitFile(dir) {
20
25
  }
21
26
 
22
27
  function fromGitFile(dir, text) {
23
- const m = GITDIR_WORKTREE_RE.exec(text.trim());
28
+ const pointer = text.trim();
29
+ const m = GITDIR_WORKTREE_RE.exec(pointer);
24
30
  // Git writes the pointer with forward slashes on Windows; the project path uses the OS spelling.
25
- return m ? { repo: path.resolve(dir, m[1]), name: m[2] } : null;
31
+ if (m) return { repo: path.resolve(dir, m[1]), name: m[2] };
32
+ const sub = GITDIR_SUBMODULE_WORKTREE_RE.exec(pointer);
33
+ if (!sub) return null;
34
+ return { repo: path.resolve(dir, sub[1], ...sub[2].split(/[/\\]modules[/\\]/)), name: sub[3] };
26
35
  }
27
36
 
28
37
  function fromPathShape(dir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "6.1.2",
3
+ "version": "7.0.1",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "type": "commonjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.24.0",
3
+ "version": "2.25.1",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
5
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -1,37 +1,45 @@
1
1
  ---
2
2
  name: kanban
3
- description: Drive the kanban board — open, pin, preview, link, inspect.
4
- argument-hint: '[open|pin|unpin|preview|link] [target]'
3
+ description: Kanban board CLI — show the user a file or page, link a doc or PR, add a pane, open or pin a session, find a session's transcript, list tasks.
4
+ argument-hint: '[doc|pane|session|task|project|dispatch] <subcommand> [target]'
5
5
  ---
6
6
 
7
7
  # Kanban Skill
8
8
 
9
- This session id is `${CLAUDE_SESSION_ID}`, substituted when the skill loads.
9
+ The skill is a thin wrapper over the `claude-code-kanban` CLI: the argument is a command and its subcommand, such as `doc link plan.md` or `session pin`. Run the bare binary, or `npx claude-code-kanban` when it is off PATH or the user asks for npx.
10
10
 
11
- An argument names the command that handles it; with no argument, open the current session. Prefer the bare `claude-code-kanban` binary, falling back to `npx claude-code-kanban` when it is off PATH or the user asks for npx.
11
+ This session id is `${CLAUDE_SESSION_ID}`. `--session` defaults to it through `$CLAUDE_CODE_SESSION_ID`, so pass the flag only for another session.
12
12
 
13
- The CLI help is the reference, and it always matches the installed binary. Read it before you run a command, instead of guessing flags:
13
+ ## Commands
14
14
 
15
- ```bash
16
- claude-code-kanban help # every command
17
- claude-code-kanban help <command> # its subcommands
18
- claude-code-kanban help <command> <subcommand> # flags and examples
19
- ```
20
-
21
- | Argument | Command |
15
+ | Command | Subcommands |
22
16
  |---|---|
23
- | `open` (or none) | `session open ${CLAUDE_SESSION_ID}` |
24
- | `pin` / `unpin` | `session pin ${CLAUDE_SESSION_ID}` (`--sticky`, `--unpin`) |
25
- | `preview` | `preview-doc <file> --session ${CLAUDE_SESSION_ID}` — opens a modal on the user's screen |
26
- | `link` | `link-doc <file\|url> --session ${CLAUDE_SESSION_ID}` — no modal, so it is the safe choice while the user is working. An http(s) URL (a PR, an artifact) opens in a new tab |
27
- | `list` / `search` | `session list`, `session search <text>` |
28
- | `view` / `peek` | `session view <id>`, `session peek <id>` |
29
- | tasks, projects | `task list`, `project list` |
17
+ | `doc` | `link`, `unlink` a file or URL to the session · `list` its links · `preview` a file in a modal |
18
+ | `pane` | `add` a URL or a local file as a background tab · `rm` · `list` |
19
+ | `session` | `list` · `search <text>` · `open <id>` focuses it · `view <id>` stats and transcript path · `plan <id>` · `agents <id>` · `pin <id>` |
20
+ | `task` | `list` for a session, a project or the whole board |
21
+ | `project` | `list` |
22
+ | `dispatch` | `start`, `list` — run `/claude-code-kanban:dispatch` instead |
23
+
24
+ ## Run a command
25
+
26
+ 1. Read `claude-code-kanban help <command> <subcommand>` for the flags. It matches the installed binary, so its flags are the ones that work.
27
+ 2. Run the command and report its output line to the user.
28
+
29
+ With no argument, run nothing on the board: show the user the table above.
30
+
31
+ ## Keep the board still
32
+
33
+ The user works in other windows while you run, so pick the quiet command. `doc link` and `pane add` add an entry the user opens when ready. `doc preview` is the one command that opens a modal on the user's screen: use it when the user asks to see something now.
34
+
35
+ To show the user a page, a prototype or a collage of screenshots, write it as HTML and link or preview that file. The preview renders HTML as authored in a sandboxed iframe and inlines its local stylesheets, scripts and images, so the file alone is enough.
36
+
37
+ ## Another session
30
38
 
31
- `preview` and `link` take markdown (`.md`) and HTML (`.html`). HTML renders as authored in a sandboxed iframe, and cck inlines its local stylesheets, scripts and images, `url()` refs in `<style>` blocks included. To show the user a page, a prototype or a collage of screenshots, write the HTML and preview that file: do not render it to an image first or serve it over HTTP.
39
+ Read its transcript. `session view <id>` and `session search <text> --json` print the path.
32
40
 
33
- To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:follow`.
41
+ To have card moves arrive in this session as instructions, the user types `/claude-code-kanban:follow`.
34
42
 
35
43
  ## Troubleshooting
36
44
 
37
- - **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If they run it elsewhere, set `PORT=<n>` or `CCK_URL=<url>` when invoking the CLI.
45
+ - **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If it runs elsewhere, set `PORT=<n>` or `CCK_URL=<url>` when invoking the CLI.