claude-code-kanban 4.28.0 → 4.30.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,45 @@
1
+ 'use strict';
2
+
3
+ // Windows boosts the threads of the foreground window, and a process behind a hidden ConPTY never
4
+ // gets that, so the one the user types into competes on equal terms with every busy agent.
5
+ // Elsewhere a raise needs root, and a raised nice value would pass on to every child, so this is
6
+ // Windows only. There a child of an above-normal process starts at normal, so the tools it runs
7
+ // stay unboosted.
8
+ const { execFile } = require('node:child_process');
9
+ const os = require('node:os');
10
+
11
+ const { PRIORITY_ABOVE_NORMAL } = os.constants.priority;
12
+
13
+ function enabled(platform, env) {
14
+ return platform === 'win32' && env.CCK_PRIORITY_BOOST !== '0';
15
+ }
16
+
17
+ function raise(pid = 0, platform = process.platform, env = process.env) {
18
+ if (!enabled(platform, env)) return null;
19
+ try {
20
+ const before = os.getPriority(pid);
21
+ if (before <= PRIORITY_ABOVE_NORMAL) return null;
22
+ os.setPriority(pid, PRIORITY_ABOVE_NORMAL);
23
+ return before;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function restore(pid, before) {
30
+ try { os.setPriority(pid, before); } catch { /* the process is gone */ }
31
+ }
32
+
33
+ // ConPTY's console host relays every byte between cck and the shell, and node-pty does not expose
34
+ // its pid. Under load a normal-priority host starves for seconds, until Windows' anti-starvation
35
+ // boost picks it up.
36
+ function raiseConsoleHosts(done) {
37
+ if (!enabled(process.platform, process.env)) return done?.();
38
+ const query = `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${process.pid} AND (Name='conhost.exe' OR Name='OpenConsole.exe')").ProcessId`;
39
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', query], { windowsHide: true, timeout: 30000 }, (err, stdout) => {
40
+ if (!err) for (const pid of stdout.split(/\s+/).filter(Boolean)) raise(Number(pid));
41
+ done?.();
42
+ });
43
+ }
44
+
45
+ module.exports = { raise, restore, raiseConsoleHosts };
@@ -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
@@ -13,6 +13,7 @@ const fs = require('node:fs');
13
13
  const os = require('node:os');
14
14
  const path = require('node:path');
15
15
  const { isContained } = require('./contain');
16
+ const priority = require('./priority');
16
17
 
17
18
  const WS_PATH = '/api/terminal/ws';
18
19
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -32,6 +33,7 @@ const PROMPT_QUIET_MS = 400;
32
33
  const HIGH_WATER = 128 * 1024;
33
34
  const LOW_WATER = 16 * 1024;
34
35
  const HELLO_TIMEOUT_MS = 5000;
36
+ const BOOST_TRIES = 30;
35
37
  const MAX_PAYLOAD = 1024 * 1024;
36
38
  // The hub hands its children PORT=0 and friends; a dev server started in the terminal
37
39
  // would otherwise bind a random port, and a nested `claude` would think it runs inside one.
@@ -58,7 +60,7 @@ function readTerminalConfig({ argv = process.argv, env = process.env, getArgValu
58
60
  return {
59
61
  enabled: argv.includes('--enable-terminal') || cfg.enabled === true,
60
62
  shell:
61
- (getArgValue && getArgValue('terminal-shell')) ||
63
+ getArgValue?.('terminal-shell') ||
62
64
  env.CCK_TERMINAL_SHELL ||
63
65
  (typeof cfg.shell === 'string' && cfg.shell) ||
64
66
  null,
@@ -149,6 +151,7 @@ function parseNewSpec(msg) {
149
151
  if (typeof worktree !== 'boolean' && !(typeof worktree === 'string' && WORKTREE_RE.test(worktree))) return 'worktree name';
150
152
  const model = msg.model || null;
151
153
  if (model && !MODELS.has(model)) return 'model';
154
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
152
155
  const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
153
156
  if (prompt.length > MAX_PROMPT) return 'prompt';
154
157
  return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null };
@@ -177,11 +180,15 @@ function screenText(term) {
177
180
 
178
181
  // Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
179
182
  // inside the dir when set, so the default dir must stay unset, not be spelled out.
180
- 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 }) {
181
186
  const env = { ...process.env };
182
187
  for (const k of STRIP_ENV) delete env[k];
183
188
  if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
184
189
  else env.CLAUDE_CONFIG_DIR = claudeDir;
190
+ if (cckUrl) env.CCK_URL = cckUrl;
191
+ else delete env.CCK_URL;
185
192
  // xterm.js #5801: a clear inside a synchronized-output block jumps the viewport.
186
193
  if (noFlicker) env.CLAUDE_CODE_NO_FLICKER = '1';
187
194
  env.TERM = 'xterm-256color';
@@ -224,6 +231,7 @@ function refuse(socket, status, reason) {
224
231
  */
225
232
  function createTerminalService(o) {
226
233
  const { config, net } = o;
234
+ if (config.enabled) priority.raise();
227
235
  const token = o.token || crypto.randomBytes(32).toString('hex');
228
236
  const sessions = new Map();
229
237
  let pty = null;
@@ -302,9 +310,46 @@ function createTerminalService(o) {
302
310
  };
303
311
  }
304
312
 
313
+ // The process a viewer types into: claude, found through its registry entry, or the shell.
314
+ function inputPid(s) {
315
+ if (s.mode === 'shell') return s.pty.pid;
316
+ if (s.claudePid || s.mode === 'pick') return s.claudePid;
317
+ const live = o.liveSessions();
318
+ const own = live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= s.startedAt - PICK_START_SLACK_MS);
319
+ if (own || s.mode !== 'fork') return own?.pid;
320
+ const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
321
+ return findPickProcess(live, s, claimed);
322
+ }
323
+
324
+ // claude writes its registry entry a moment after it starts, so the pid is polled for.
325
+ function syncBoost(s) {
326
+ const want = s.sockets.size > 0 && !s.exited;
327
+ if (want && !s.boost) {
328
+ if (s.boostTimer) return;
329
+ let tries = 0;
330
+ const tryBoost = () => {
331
+ const pid = inputPid(s);
332
+ if (!pid && ++tries < BOOST_TRIES) return;
333
+ clearInterval(s.boostTimer);
334
+ s.boostTimer = null;
335
+ if (!pid) return;
336
+ if (s.mode !== 'shell') s.claudePid = pid;
337
+ const before = priority.raise(pid);
338
+ s.boost = { pid, before };
339
+ };
340
+ s.boostTimer = setInterval(tryBoost, PICK_POLL_MS);
341
+ tryBoost();
342
+ } else if (!want) {
343
+ clearInterval(s.boostTimer);
344
+ s.boostTimer = null;
345
+ if (s.boost && s.boost.before !== null && !s.exited) priority.restore(s.boost.pid, s.boost.before);
346
+ s.boost = null;
347
+ }
348
+ }
349
+
305
350
  function spawnSession(id, mode, cwdCandidate, cols, rows, spec, extraEnv) {
306
351
  const cwd = cwdCandidate && fs.existsSync(cwdCandidate) ? cwdCandidate : os.homedir();
307
- 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 };
308
353
  // Git Bash's login profile cds to $HOME unless this is set.
309
354
  if (shellFamily(shell) === 'gitbash') env.CHERE_INVOKING = '1';
310
355
  const proc = pty.spawn(shell, shellArgs(shell, claudeArgsFor(mode, id, spec)), {
@@ -335,6 +380,7 @@ function createTerminalService(o) {
335
380
  proc.onExit(({ exitCode }) => {
336
381
  s.exited = true;
337
382
  clearInterval(s.pickTimer);
383
+ clearInterval(s.boostTimer);
338
384
  if (sessions.get(s.id) === s) sessions.delete(s.id);
339
385
  o.onExit?.(s.id);
340
386
  if (!s.ended) o.onChange?.();
@@ -345,6 +391,7 @@ function createTerminalService(o) {
345
391
  term.dispose();
346
392
  });
347
393
  sessions.set(id, s);
394
+ priority.raiseConsoleHosts();
348
395
  o.onChange?.();
349
396
  return s;
350
397
  }
@@ -389,6 +436,7 @@ function createTerminalService(o) {
389
436
  function attach(ws, s, attached) {
390
437
  ws.unacked = 0;
391
438
  s.sockets.add(ws);
439
+ syncBoost(s);
392
440
  send(ws, { t: 'ready', attached });
393
441
  const snapshot = s.serializer.serialize();
394
442
  if (snapshot) sendOutput(ws, Buffer.from(snapshot, 'utf8'));
@@ -411,6 +459,7 @@ function createTerminalService(o) {
411
459
  });
412
460
  ws.on('close', () => {
413
461
  s.sockets.delete(ws);
462
+ syncBoost(s);
414
463
  if (!s.exited) updateFlow(s);
415
464
  });
416
465
  }
@@ -552,4 +601,4 @@ function createTerminalService(o) {
552
601
  return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, shutdown, unavailableReason };
553
602
  }
554
603
 
555
- 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.28.0",
3
+ "version": "4.30.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
  },
@@ -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> --session ${CLAUDE_SESSION_ID}` — no modal, so it is the safe choice while the user is working |
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.