claude-code-kanban 6.1.1 → 7.0.0

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.
@@ -4,7 +4,7 @@ const { exportParentVerdicts, importParentVerdicts, parentVerdictsDirty } = requ
4
4
  const { exportTaskCounts, importTaskCounts, taskCountsDirty } = require('./task-counts');
5
5
 
6
6
  // Bump when an entry's shape or meaning changes: a file with another version is ignored.
7
- const VERSION = 4;
7
+ const VERSION = 5;
8
8
  // A cache this large is not one cck wrote for a normal set of transcripts. Rebuild it cold.
9
9
  const MAX_BYTES = 8 * 1024 * 1024;
10
10
 
@@ -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.1",
3
+ "version": "7.0.0",
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.23.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"
@@ -149,13 +149,14 @@ async function markStopped($: EngineInterface) {
149
149
  }
150
150
 
151
151
  // The name map lets TeammateIdle, which names the teammate, find its agent id.
152
- async function agentStarted($: EngineInterface, agentId: string, type: string, name?: string) {
152
+ async function agentStarted($: EngineInterface, agentId: string, type: string, model: string, name?: string) {
153
153
  const dir = await sessionDir($)
154
154
  const ts = new Date().toISOString()
155
155
  await Promise.all([
156
156
  appendLine($, `${dir}/${agentId}.jsonl`, {
157
157
  agentId,
158
158
  type,
159
+ model,
159
160
  event: 'start',
160
161
  status: 'active',
161
162
  startedAt: ts,
@@ -248,7 +249,7 @@ export const register: Register = on => {
248
249
  })
249
250
  on('agent.spawn', async ($, e, next) => {
250
251
  const result = await next(e)
251
- if (result.agentId) await agentStarted($, result.agentId, e.subagentType, e.name)
252
+ if (result.agentId) await agentStarted($, result.agentId, e.subagentType, result.model, e.name)
252
253
  return result
253
254
  })
254
255
  // An interrupted turn fires no Stop hook, so it does not mark the session unread either.
@@ -42,22 +42,17 @@ function cckDir($: EngineInterface) {
42
42
  return cck
43
43
  }
44
44
 
45
- let lastUsage: ModelUsage | undefined
46
- let lastModel: string | undefined
47
- let lastRequestAt: number | undefined
48
- const lastWritten = new Map<string, string>()
45
+ // Keyed by session id: `/clear` keeps the process and changes the id.
46
+ const sessions = new Map<string, { req?: { usage: ModelUsage; model: string; at: number }; written?: string }>()
49
47
 
50
- async function write($: EngineInterface, m: Measure) {
51
- const [sessionId, model, dir] = await Promise.all([
52
- $.session.id(),
53
- lastModel ?? $.session.model(),
54
- cckDir($),
55
- ])
56
- const text = JSON.stringify(toStatus(model, m, lastUsage, lastRequestAt))
57
- const file = `${dir}/context-status/${sessionId}.json`
58
- if (lastWritten.get(file) === text) return
59
- await $.fs.write(file, text)
60
- lastWritten.set(file, text)
48
+ async function write($: EngineInterface, m: Measure, id?: string) {
49
+ const [sessionId, dir] = await Promise.all([id ?? $.session.id(), cckDir($)])
50
+ const s = sessions.get(sessionId) ?? {}
51
+ const model = s.req?.model ?? (await $.session.model())
52
+ const text = JSON.stringify(toStatus(model, m, s.req?.usage, s.req?.at))
53
+ if (s.written === text) return
54
+ await $.fs.write(`${dir}/context-status/${sessionId}.json`, text)
55
+ sessions.set(sessionId, { ...s, written: text })
61
56
  }
62
57
 
63
58
  export const register: Register = on => {
@@ -65,10 +60,9 @@ export const register: Register = on => {
65
60
  const result = yield* next(e)
66
61
  if (e.agentId === undefined && result.usage) {
67
62
  const { model, ...usage } = result.usage
68
- lastUsage = usage
69
- lastModel = model
70
- lastRequestAt = Date.now()
71
- await write($, await $.session.usage())
63
+ const sessionId = await $.session.id()
64
+ sessions.set(sessionId, { ...sessions.get(sessionId), req: { usage, model, at: Date.now() } })
65
+ await write($, await $.session.usage(), sessionId)
72
66
  }
73
67
  return result
74
68
  })
@@ -77,4 +71,9 @@ export const register: Register = on => {
77
71
  await write($, e)
78
72
  return next(e)
79
73
  })
74
+
75
+ on('session.end', async ($, e, next) => {
76
+ sessions.delete(e.sessionId)
77
+ return next(e)
78
+ })
80
79
  }
@@ -1,35 +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
38
+
39
+ Read its transcript. `session view <id>` and `session search <text> --json` print the path.
30
40
 
31
- 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`.
32
42
 
33
43
  ## Troubleshooting
34
44
 
35
- - **"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.
@@ -253,7 +253,7 @@ test('a subagent spawn then finish records start and stop', async ($, on) => {
253
253
  await $.turn.complete(turn({ agentId: 'a1', answer: 'Task done with "quotes"' }))
254
254
 
255
255
  const [start, stop] = lines(files[`${SESSION}/a1.jsonl`])
256
- expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', event: 'start', status: 'active' })
256
+ expect(start).toMatchObject({ agentId: 'a1', type: 'general-purpose', model: 'haiku', event: 'start', status: 'active' })
257
257
  expect(stop).toMatchObject({ agentId: 'a1', event: 'stop', status: 'stopped', lastMessage: 'Task done with "quotes"' })
258
258
  expect('type' in stop).toBe(false)
259
259
  expect(files[`${SESSION}/_name-general-purpose.id`]).toBe('a1')
@@ -12,14 +12,14 @@ const MEASURE: SessionMeasureInput = {
12
12
  changed: ['context', 'rateLimits', 'cost'],
13
13
  }
14
14
 
15
- function engine(on: On, env: Record<string, string>, sessionId: string) {
15
+ function engine(on: On, env: Record<string, string>, sessionId: string | (() => string)) {
16
16
  const writes: { path: string; text: string }[] = []
17
17
  on('fs.write', (_$, e) => {
18
18
  writes.push({ path: e.path.replaceAll('\\', '/'), text: e.text })
19
19
  return { value: undefined }
20
20
  })
21
21
  on('env.get', (_$, e) => ({ value: env[e.name] }))
22
- on('session.id', () => ({ value: sessionId }))
22
+ on('session.id', () => ({ value: typeof sessionId === 'function' ? sessionId() : sessionId }))
23
23
  on('session.model', () => ({ value: 'claude-opus-5-5' }))
24
24
  on('session.measure', (_$, e) => ({ changed: [...e.changed] }))
25
25
  return writes
@@ -53,6 +53,37 @@ test('session.measure writes no cache entry before the first request', async ($,
53
53
  expect(JSON.parse(writes[0]?.text ?? '{}').cache).toBeUndefined()
54
54
  })
55
55
 
56
+ const USAGE = { input_tokens: 2, output_tokens: 100, cache_read_input_tokens: 5000, cache_creation_input_tokens: 300 }
57
+
58
+ test('after /clear the new session id does not get the old request', async ($, on) => {
59
+ let sid = 'sid-old'
60
+ const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, () => sid)
61
+ on('session.usage', () => ({ value: MEASURE }))
62
+ on('session.end', (_$, e) => ({ sessionId: e.sessionId }))
63
+ on('turn.step', async function* (_$, e) {
64
+ return { turnId: e.turnId, index: e.index, answer: 'ok', toolUses: [], stopReason: 'end_turn', usage: { ...USAGE, model: 'claude-fable-5-1' } }
65
+ })
66
+
67
+ const step = $.turn.step({ turnId: 't1', index: 0, model: 'claude-fable-5-1', messageCount: 1 })
68
+ for await (const _ of step);
69
+ await step.result
70
+ expect(writes.length).toBe(1)
71
+ const old = JSON.parse(writes.at(-1)?.text ?? '{}')
72
+ expect(old.cache?.last_request_at).toBeGreaterThan(0)
73
+ expect(old.context_window.current_usage).toEqual(USAGE)
74
+ expect(old.model.id).toBe('claude-fable-5-1')
75
+
76
+ await $.session.end({ reason: 'clear', sessionId: 'sid-old', resume: {} as never })
77
+ sid = 'sid-new'
78
+ await $.session.measure(MEASURE)
79
+
80
+ expect(writes.at(-1)?.path).toBe('C:/cfg/.cck/context-status/sid-new.json')
81
+ const fresh = JSON.parse(writes.at(-1)?.text ?? '{}')
82
+ expect(fresh.cache).toBeUndefined()
83
+ expect(fresh.context_window.current_usage).toBeNull()
84
+ expect(fresh.model.id).toBe('claude-opus-5-5')
85
+ })
86
+
56
87
  test('toStatus adds the last request time', async () => {
57
88
  const status = toStatus('claude-opus-5-5', MEASURE, undefined, 1_790_000_000_000)
58
89
  expect(status.cache).toEqual({ last_request_at: 1_790_000_000_000 })