claude-code-kanban 4.29.0 → 4.31.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.
@@ -0,0 +1,38 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { isDefaultClaudeDir, displayPath } = require('./claude-dir');
4
+
5
+ const PLUGIN_ID = 'claude-code-kanban@claude-code-kanban';
6
+ const BUNDLED_MANIFEST = path.join(__dirname, '..', 'plugin', 'plugins', 'claude-code-kanban', '.claude-plugin', 'plugin.json');
7
+
8
+ function readJson(file) {
9
+ try {
10
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+
16
+ // The claude CLI records installs in <config dir>/plugins/installed_plugins.json, one entry per scope.
17
+ // A user-scope entry wins, because install.js installs at user scope.
18
+ function pluginStatus(claudeDir, bundledManifest = BUNDLED_MANIFEST) {
19
+ const bundled = readJson(bundledManifest)?.version ?? null;
20
+ const installs = readJson(path.join(claudeDir, 'plugins', 'installed_plugins.json'))?.plugins?.[PLUGIN_ID];
21
+ const entries = Array.isArray(installs) ? installs : [];
22
+ const entry = entries.find((e) => e?.scope === 'user') ?? entries[0];
23
+ const installed = typeof entry?.version === 'string' ? entry.version : null;
24
+ const enabled = readJson(path.join(claudeDir, 'settings.json'))?.enabledPlugins?.[PLUGIN_ID] !== false;
25
+ let state = 'ok';
26
+ if (!installed) state = 'missing';
27
+ else if (!enabled) state = 'disabled';
28
+ else if (bundled && installed !== bundled) state = 'mismatch';
29
+ return { bundled, installed, state, configDir: displayPath(claudeDir), installCommand: installCommand(claudeDir) };
30
+ }
31
+
32
+ // --dir rather than CLAUDE_CONFIG_DIR=…, so the same line runs in bash, PowerShell and cmd.
33
+ function installCommand(claudeDir) {
34
+ const base = 'claude-code-kanban --install --plugin-only';
35
+ return isDefaultClaudeDir(claudeDir) ? base : `${base} --dir "${claudeDir}"`;
36
+ }
37
+
38
+ module.exports = { pluginStatus, installCommand, PLUGIN_ID, BUNDLED_MANIFEST };
@@ -0,0 +1,34 @@
1
+ const fs = require('node:fs');
2
+ const { exportSessionCaches, importSessionCaches, sessionCachesDirty } = require('./parsers');
3
+ const { exportParentVerdicts, importParentVerdicts, parentVerdictsDirty } = require('./parent-cache');
4
+ const { exportTaskCounts, importTaskCounts, taskCountsDirty } = require('./task-counts');
5
+
6
+ // Bump when an entry's shape or meaning changes: a file with another version is ignored.
7
+ const VERSION = 2;
8
+ // A cache this large is not one cck wrote for a normal set of transcripts. Rebuild it cold.
9
+ const MAX_BYTES = 8 * 1024 * 1024;
10
+
11
+ function loadSessionCache(file) {
12
+ try {
13
+ if (fs.statSync(file).size > MAX_BYTES) {
14
+ fs.rmSync(file, { force: true });
15
+ return false;
16
+ }
17
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
18
+ if (data?.version !== VERSION) return false;
19
+ importSessionCaches(data);
20
+ importParentVerdicts(data.parents);
21
+ importTaskCounts(data.taskCounts);
22
+ return true;
23
+ } catch (_) {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ // write(data) must replace the file atomically; server.js passes writeJsonAtomic.
29
+ function saveSessionCache(write) {
30
+ if (!sessionCachesDirty() && !parentVerdictsDirty() && !taskCountsDirty()) return;
31
+ write({ version: VERSION, ...exportSessionCaches(), parents: exportParentVerdicts(), taskCounts: exportTaskCounts() });
32
+ }
33
+
34
+ module.exports = { loadSessionCache, saveSessionCache };
@@ -25,6 +25,7 @@ const sessionEventBuckets = new Map();
25
25
  // One cap for the whole line rather than one per field, because the description comes
26
26
  // last: truncation eats its tail first and leaves the machine-readable head intact.
27
27
  function sanitizeEventLine(line) {
28
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
28
29
  return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
29
30
  }
30
31
 
@@ -81,7 +82,7 @@ function handleSessionEvents(req, res) {
81
82
  // bucket is still evicted from the map.
82
83
  if (bucket && req.query.first === '1') drain(sessionId, bucket);
83
84
 
84
- if (bucket && bucket.queue.length) return res.json({ events: drain(sessionId, bucket) });
85
+ if (bucket?.queue.length) return res.json({ events: drain(sessionId, bucket) });
85
86
  if (!wait) return res.json({ events: [] });
86
87
 
87
88
  const pending = bucket || { queue: [], waiters: new Set() };
@@ -0,0 +1,90 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+
4
+ // A task file's status is reused while its size, mtime and inode are unchanged. An mtime can be
5
+ // as coarse as 2 s (FAT), so a file read within that window of its last write could be written
6
+ // again with the same stamp and size. Such a file is not stored and is read again next time.
7
+ const RACY_MS = 3000;
8
+ const SKIP = 'skip';
9
+ const STATUSES = new Set(['completed', 'in_progress', 'pending', SKIP]);
10
+
11
+ const dirs = new Map();
12
+ let dirty = false;
13
+
14
+ function readStatus(taskPath) {
15
+ const text = fs.readFileSync(taskPath, 'utf8');
16
+ let task;
17
+ try {
18
+ task = JSON.parse(text);
19
+ } catch (_) {
20
+ return SKIP;
21
+ }
22
+ if (task == null || task.metadata?._internal) return SKIP;
23
+ if (task.status === 'completed' || task.status === 'in_progress') return task.status;
24
+ return 'pending';
25
+ }
26
+
27
+ function countTaskDir(dir) {
28
+ const prev = dirs.get(dir);
29
+ const next = new Map();
30
+ const now = Date.now();
31
+ const tally = { completed: 0, in_progress: 0, pending: 0 };
32
+ let newestTaskMtime = null;
33
+
34
+ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
35
+ const taskPath = path.join(dir, file);
36
+ let st;
37
+ try {
38
+ st = fs.statSync(taskPath);
39
+ } catch (_) {
40
+ continue;
41
+ }
42
+ const e = prev?.get(file);
43
+ let status;
44
+ if (e && e.size === st.size && e.mtimeMs === st.mtimeMs && e.ino === st.ino) {
45
+ status = e.status;
46
+ next.set(file, e);
47
+ } else {
48
+ try {
49
+ status = readStatus(taskPath);
50
+ } catch (_) {
51
+ continue;
52
+ }
53
+ if (now - st.mtimeMs >= RACY_MS) {
54
+ next.set(file, { size: st.size, mtimeMs: st.mtimeMs, ino: st.ino, status });
55
+ dirty = true;
56
+ }
57
+ }
58
+ if (status === SKIP) continue;
59
+ tally[status]++;
60
+ if (!newestTaskMtime || st.mtime > newestTaskMtime) newestTaskMtime = st.mtime;
61
+ }
62
+
63
+ if (prev && prev.size !== next.size) dirty = true;
64
+ dirs.set(dir, next);
65
+ return { completed: tally.completed, inProgress: tally.in_progress, pending: tally.pending, newestTaskMtime };
66
+ }
67
+
68
+ const taskCountsDirty = () => dirty;
69
+
70
+ function exportTaskCounts() {
71
+ dirty = false;
72
+ return [...dirs].filter(([dir]) => fs.existsSync(dir)).map(([dir, files]) => [dir, [...files]]);
73
+ }
74
+
75
+ function importTaskCounts(entries) {
76
+ if (!Array.isArray(entries)) return;
77
+ const num = (x) => typeof x === 'number';
78
+ for (const pair of entries) {
79
+ if (typeof pair?.[0] !== 'string' || !Array.isArray(pair[1])) continue;
80
+ const files = new Map();
81
+ for (const item of pair[1]) {
82
+ const [file, e] = Array.isArray(item) ? item : [];
83
+ if (typeof file !== 'string' || !e || !num(e.size) || !num(e.mtimeMs) || !num(e.ino) || !STATUSES.has(e.status)) continue;
84
+ files.set(file, e);
85
+ }
86
+ dirs.set(pair[0], files);
87
+ }
88
+ }
89
+
90
+ module.exports = { countTaskDir, taskCountsDirty, exportTaskCounts, importTaskCounts };
package/lib/terminal.js CHANGED
@@ -60,7 +60,7 @@ function readTerminalConfig({ argv = process.argv, env = process.env, getArgValu
60
60
  return {
61
61
  enabled: argv.includes('--enable-terminal') || cfg.enabled === true,
62
62
  shell:
63
- (getArgValue && getArgValue('terminal-shell')) ||
63
+ getArgValue?.('terminal-shell') ||
64
64
  env.CCK_TERMINAL_SHELL ||
65
65
  (typeof cfg.shell === 'string' && cfg.shell) ||
66
66
  null,
@@ -151,6 +151,7 @@ function parseNewSpec(msg) {
151
151
  if (typeof worktree !== 'boolean' && !(typeof worktree === 'string' && WORKTREE_RE.test(worktree))) return 'worktree name';
152
152
  const model = msg.model || null;
153
153
  if (model && !MODELS.has(model)) return 'model';
154
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
154
155
  const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
155
156
  if (prompt.length > MAX_PROMPT) return 'prompt';
156
157
  return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null };
@@ -179,11 +180,15 @@ function screenText(term) {
179
180
 
180
181
  // Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
181
182
  // inside the dir when set, so the default dir must stay unset, not be spelled out.
182
- function ptyEnv({ claudeDir, isDefaultDir, noFlicker }) {
183
+ // CCK_URL pins the CLI and the postman to this board: two boards on one config dir share
184
+ // one server.json, and the last one started owns it.
185
+ function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl }) {
183
186
  const env = { ...process.env };
184
187
  for (const k of STRIP_ENV) delete env[k];
185
188
  if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
186
189
  else env.CLAUDE_CONFIG_DIR = claudeDir;
190
+ if (cckUrl) env.CCK_URL = cckUrl;
191
+ else delete env.CCK_URL;
187
192
  // xterm.js #5801: a clear inside a synchronized-output block jumps the viewport.
188
193
  if (noFlicker) env.CLAUDE_CODE_NO_FLICKER = '1';
189
194
  env.TERM = 'xterm-256color';
@@ -344,7 +349,7 @@ function createTerminalService(o) {
344
349
 
345
350
  function spawnSession(id, mode, cwdCandidate, cols, rows, spec, extraEnv) {
346
351
  const cwd = cwdCandidate && fs.existsSync(cwdCandidate) ? cwdCandidate : os.homedir();
347
- const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker }), ...extraEnv };
352
+ const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker, cckUrl: o.serverUrl?.() }), ...extraEnv };
348
353
  // Git Bash's login profile cds to $HOME unless this is set.
349
354
  if (shellFamily(shell) === 'gitbash') env.CHERE_INVOKING = '1';
350
355
  const proc = pty.spawn(shell, shellArgs(shell, claudeArgsFor(mode, id, spec)), {
@@ -596,4 +601,4 @@ function createTerminalService(o) {
596
601
  return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, shutdown, unavailableReason };
597
602
  }
598
603
 
599
- module.exports = { createTerminalService, readTerminalConfig, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
604
+ module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.29.0",
3
+ "version": "4.31.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
+ "type": "commonjs",
6
7
  "bin": {
7
8
  "claude-code-kanban": "server.js"
8
9
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
5
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -38,7 +38,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
38
  // Re-read every cycle rather than caching: it is how we follow the board across a
39
39
  // restart onto a different port. A file left behind by a crashed server names a port
40
40
  // something else may now hold, so trust it only while its pid is alive.
41
+ // CCK_URL comes from the board whose terminal started this session. That board owns the
42
+ // session's queue, and server.json may name another board on the same config dir.
41
43
  function serverUrl() {
44
+ if (process.env.CCK_URL) return process.env.CCK_URL.replace(/\/+$/, '');
42
45
  const { port, pid } = JSON.parse(fs.readFileSync(SERVER_INFO, 'utf8'));
43
46
  if (pid) process.kill(pid, 0);
44
47
  return `http://127.0.0.1:${port}`;
@@ -9,58 +9,28 @@ disable-model-invocation: true
9
9
 
10
10
  This session id is `${CLAUDE_SESSION_ID}`, substituted when the skill loads.
11
11
 
12
- An argument names the section below 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.
12
+ 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.
13
13
 
14
- To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:kanban-follow`.
15
-
16
- ## `open` — the current session
17
-
18
- Pins the session and switches the board to the Active tab.
19
-
20
- ```bash
21
- claude-code-kanban session open ${CLAUDE_SESSION_ID}
22
- ```
23
-
24
- ## `pin` — keep the session visible
25
-
26
- ```bash
27
- claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
28
- claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # always at the top
29
- claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
30
- claude-code-kanban session pins # list pinned; --sticky narrows
31
- ```
32
-
33
- ## `preview` — open a file in the modal
34
-
35
- Markdown or standalone HTML. HTML renders in a sandboxed iframe, so sibling assets like `./style.css` do not load. Relative paths are fine — the server resolves them.
36
-
37
- ```bash
38
- claude-code-kanban preview-doc <file.md|.html> --session ${CLAUDE_SESSION_ID}
39
- ```
40
-
41
- ## `link` — attach a doc without the modal
42
-
43
- Adds the file to the session's linked docs in the sidebar. Any extension, and nothing pops up, so it is the safe choice while the user is working.
14
+ The CLI help is the reference, and it always matches the installed binary. Read it before you run a command, instead of guessing flags:
44
15
 
45
16
  ```bash
46
- claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} # link
47
- claude-code-kanban link-doc <path> --session ${CLAUDE_SESSION_ID} --unlink # remove
17
+ claude-code-kanban help # every command
18
+ claude-code-kanban help <command> # its subcommands
19
+ claude-code-kanban help <command> <subcommand> # flags and examples
48
20
  ```
49
21
 
50
- ## `list` / `view` / `peek` — read-only
22
+ | Argument | Command |
23
+ |---|---|
24
+ | `open` (or none) | `session open ${CLAUDE_SESSION_ID}` |
25
+ | `pin` / `unpin` | `session pin ${CLAUDE_SESSION_ID}` (`--sticky`, `--unpin`) |
26
+ | `preview` | `preview-doc <file> --session ${CLAUDE_SESSION_ID}` — opens a modal on the user's screen |
27
+ | `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 |
28
+ | `list` / `search` | `session list`, `session search <text>` |
29
+ | `view` / `peek` | `session view <id>`, `session peek <id>` |
30
+ | tasks, projects | `task list`, `project list` |
51
31
 
52
- ```bash
53
- claude-code-kanban session list --active # recent active sessions
54
- claude-code-kanban session list --project <name> # filter by project
55
- claude-code-kanban session list --days 0.5 --limit all # touched in last 12h, uncapped
56
- claude-code-kanban session view ${CLAUDE_SESSION_ID} # full stats for current session
57
- claude-code-kanban session peek ${CLAUDE_SESSION_ID} --limit 20 # last 20 messages (server caps at 50)
58
- ```
59
-
60
- `session list` shows 10 rows and always includes pinned sessions, sticky first (`--no-pins` disables both). `--json` works on any list-style verb.
32
+ To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:kanban-follow`.
61
33
 
62
34
  ## Troubleshooting
63
35
 
64
- `claude-code-kanban help <command>` prints the authoritative flags — read it instead of guessing.
65
-
66
- - **"Cannot reach cck server…"** → the error names the port it tried. Ask the user to start the server with `claude-code-kanban`. If they run it elsewhere, set `PORT=<n>` when invoking the CLI.
36
+ - **"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.