claude-code-kanban 6.0.0 → 6.1.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/README.md CHANGED
@@ -21,25 +21,35 @@ Watch the tour on YouTube: [light](https://youtu.be/QbvDBFyfC7s), [dark](https:/
21
21
 
22
22
  You need Node.js 20 or later and the `claude` CLI.
23
23
 
24
- ### 1. Install the integration (one time)
24
+ ### 1. Install the command
25
25
 
26
26
  ```bash
27
- npx claude-code-kanban --install
27
+ npm install -g claude-code-kanban
28
+ ```
29
+
30
+ To update, run the same command again.
31
+
32
+ To try it without an install, run `npx claude-code-kanban --open`. npx keeps a copy of each version in its cache and can run an old copy, so use the global install to keep the board and to update it.
33
+
34
+ ### 2. Install the integration (one time)
35
+
36
+ ```bash
37
+ claude-code-kanban --install
28
38
  ```
29
39
 
30
40
  The installer adds a Claude Code plugin with hooks, skills and a mod for context use and cost (Claude Code 2.1.287 or later). It asks before it installs and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
31
41
 
32
- To remove it, run `npx claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
42
+ To remove it, run `claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
33
43
 
34
- ### 2. Start the board
44
+ ### 3. Start the board
35
45
 
36
46
  ```bash
37
- npx claude-code-kanban --open
47
+ claude-code-kanban --open
38
48
  ```
39
49
 
40
- The board runs at `http://localhost:3541`. To install the command globally, run `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
50
+ The board runs at `http://localhost:3541`.
41
51
 
42
- ### 3. Use Claude Code as usual
52
+ ### 4. Use Claude Code as usual
43
53
 
44
54
  Run `claude` in any project. You do not configure anything per project. Claude Code writes task files and transcripts to the config dir, and the board watches them and sends each change to the browser.
45
55
 
@@ -107,10 +117,10 @@ Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.
107
117
  ## Configuration
108
118
 
109
119
  ```bash
110
- PORT=8080 npx claude-code-kanban # Custom port. If it is busy, the server uses a random free port.
111
- npx claude-code-kanban --dir=~/.claude-work # Another Claude config dir (or CLAUDE_CONFIG_DIR)
112
- npx claude-code-kanban --enable-terminal # Turn on the embedded terminal
113
- EDITOR="code -w" npx claude-code-kanban # Command for Open in editor (default: code)
120
+ PORT=8080 claude-code-kanban # Custom port. If it is busy, the server uses a random free port.
121
+ claude-code-kanban --dir=~/.claude-work # Another Claude config dir (or CLAUDE_CONFIG_DIR)
122
+ claude-code-kanban --enable-terminal # Turn on the embedded terminal
123
+ EDITOR="code -w" claude-code-kanban # Command for Open in editor (default: code)
114
124
  ```
115
125
 
116
126
  - The server listens on `127.0.0.1` only and has no authentication. To reach it from another machine, use `--host` and `--allowed-hosts`, and do it only on a network you trust.
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ // Claude Code's live-session registry: one <pid>.json per interactive claude.
7
+ function readLiveSessions(sessionsDir) {
8
+ const sessions = [];
9
+ let files;
10
+ try {
11
+ files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
12
+ } catch {
13
+ return sessions;
14
+ }
15
+ for (const file of files) {
16
+ try {
17
+ const s = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf8'));
18
+ if (s?.sessionId && s.kind === 'interactive') {
19
+ sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null, name: s.name || null });
20
+ }
21
+ } catch { /* skip invalid */ }
22
+ }
23
+ return sessions;
24
+ }
25
+
26
+ // EPERM means the process exists but belongs to someone else.
27
+ function isPidAlive(pid) {
28
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
29
+ }
30
+
31
+ // A registry file outlives a crashed claude, so the pid is probed rather than trusted.
32
+ function isSessionLive(sessions, sessionId, exceptPid = null) {
33
+ return sessions.some((s) => {
34
+ if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
35
+ return isPidAlive(s.pid);
36
+ });
37
+ }
38
+
39
+ module.exports = { readLiveSessions, isPidAlive, isSessionLive };
package/lib/parsers.js CHANGED
@@ -373,13 +373,20 @@ const modeOf = (data) => {
373
373
  return (data.type === 'user' || data.type === 'permission-mode') && data.permissionMode || null;
374
374
  };
375
375
 
376
+ // The TTL of the last prompt-cache write, from the API's 5m/1h split of cache_creation.
377
+ const cacheTtlOf = (data) => {
378
+ const cc = data.type === 'assistant' && !data.isSidechain && data.message?.usage?.cache_creation;
379
+ if (!cc) return null;
380
+ return cc.ephemeral_1h_input_tokens > 0 ? '1h' : cc.ephemeral_5m_input_tokens > 0 ? '5m' : null;
381
+ };
382
+
376
383
  // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
377
384
  // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
378
385
  // live branch must resolve it from cwd separately. Cache is reset on inode
379
386
  // change or truncation (size < scannedUpTo).
380
387
 
381
388
  function readSessionInfoFromJsonl(jsonlPath) {
382
- const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null };
389
+ const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null, cacheTtl: null };
383
390
  let stat;
384
391
  let fd;
385
392
  try {
@@ -400,6 +407,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
400
407
  logicalParentUuid: cached.logicalParentUuid || null,
401
408
  compactBoundaryUuid: cached.compactBoundaryUuid || null,
402
409
  permissionMode: cached.permissionMode || null,
410
+ cacheTtl: cached.cacheTtl || null,
403
411
  customTitle: readCustomTitle(jsonlPath, stat)
404
412
  };
405
413
  }
@@ -412,6 +420,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
412
420
  result.logicalParentUuid = cached.logicalParentUuid || null;
413
421
  result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
414
422
  result.permissionMode = cached.permissionMode || null;
423
+ result.cacheTtl = cached.cacheTtl || null;
415
424
  }
416
425
 
417
426
  let lastCwdSeen = result.cwd;
@@ -426,6 +435,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
426
435
  if (data.gitBranch) result.gitBranch = data.gitBranch;
427
436
  const mode = modeOf(data);
428
437
  if (mode) result.permissionMode = mode;
438
+ const ttl = cacheTtlOf(data);
439
+ if (ttl) result.cacheTtl = ttl;
429
440
  if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
430
441
  result.logicalParentUuid = data.logicalParentUuid;
431
442
  // The boundary record's own uuid: a fork copies it verbatim from the
@@ -498,19 +509,22 @@ function readSessionInfoFromJsonl(jsonlPath) {
498
509
  const lines = tailBuf.toString('utf8', 0, tn).split('\n');
499
510
  let latestTailCwd = null;
500
511
  let latestTailMode = null;
512
+ let latestTailTtl = null;
501
513
  for (let i = lines.length - 1; i >= 0; i--) {
502
514
  try {
503
515
  const data = JSON.parse(lines[i]);
516
+ if (!latestTailTtl) latestTailTtl = cacheTtlOf(data);
504
517
  if (!result.slug && data.slug) result.slug = data.slug;
505
518
  if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
506
519
  if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
507
520
  if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
508
521
  if (!latestTailMode) latestTailMode = modeOf(data);
509
- if (latestTailCwd && latestTailMode && result.slug && result.projectPath && result.gitBranch) break;
522
+ if (latestTailCwd && latestTailMode && latestTailTtl && result.slug && result.projectPath && result.gitBranch) break;
510
523
  } catch {}
511
524
  }
512
525
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
513
526
  if (latestTailMode) result.permissionMode = latestTailMode;
527
+ if (latestTailTtl) result.cacheTtl = latestTailTtl;
514
528
  }
515
529
  scannedUpTo = stat.size;
516
530
  }
@@ -533,7 +547,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
533
547
  cwd: result.cwd,
534
548
  logicalParentUuid: result.logicalParentUuid,
535
549
  compactBoundaryUuid: result.compactBoundaryUuid,
536
- permissionMode: result.permissionMode
550
+ permissionMode: result.permissionMode,
551
+ cacheTtl: result.cacheTtl
537
552
  });
538
553
  if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
539
554
  const firstKey = sessionInfoCache.keys().next().value;
@@ -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 = 3;
7
+ const VERSION = 4;
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
 
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ // cck's side of the terminal host (lib/terminal-host.js). cck keeps the gates it can check
4
+ // alone (enabled, exposure, Host and Origin, the token) and the ids the host last reported,
5
+ // so the board's sync callers stay sync; everything else is a call over IPC.
6
+
7
+ const crypto = require('node:crypto');
8
+ const { fork } = require('node:child_process');
9
+ const path = require('node:path');
10
+ const { tokenMatches, localUnavailableReason, refuseUpgrade, clientConfigFor } = require('./terminal');
11
+
12
+ const HOST = path.join(__dirname, 'terminal-host.js');
13
+ const CALL_TIMEOUT_MS = 15000;
14
+ const MAX_RESTARTS = 3;
15
+ const RESTART_WINDOW_MS = 60000;
16
+ const RESTART_DELAY_MS = 1000;
17
+
18
+ function hostError(message) {
19
+ return Object.assign(new Error(message), { status: 503 });
20
+ }
21
+
22
+ /**
23
+ * @param {object} o
24
+ * @param {ReturnType<typeof import('./terminal').readTerminalConfig>} o.config
25
+ * @param {{EXPOSED: boolean, upgradeVerdict: (req: any) => string|null}} o.net
26
+ * @param {string} o.claudeDir
27
+ * @param {boolean} o.isDefaultDir
28
+ * @param {string} o.sessionsDir claude's live-session registry
29
+ * @param {(id: string) => string|null|Promise<string|null>} o.resolveCwd
30
+ * @param {(dir: string) => boolean|Promise<boolean>} o.isAllowedFolder
31
+ * @param {string} [o.token]
32
+ * @param {() => {sessions?: string[]}|null} [o.load]
33
+ * @param {(data: {sessions: string[]}) => void} [o.save]
34
+ * @param {() => void} [o.onChange]
35
+ * @param {(id: string) => void} [o.onExit]
36
+ */
37
+ function createTerminalClient(o) {
38
+ const { config, net } = o;
39
+ const token = o.token || crypto.randomBytes(32).toString('hex');
40
+ const localReason = localUnavailableReason(config, net);
41
+ let child = null;
42
+ let hostReason = null;
43
+ let ids = [];
44
+ let serverUrl = null;
45
+ let restoreAsked = false;
46
+ let stopped = false;
47
+ let shuttingDown = false;
48
+ const restarts = [];
49
+ let nextCall = 0;
50
+ const calls = new Map();
51
+ let settle;
52
+ const started = new Promise((resolve) => { settle = resolve; });
53
+
54
+ function unavailableReason() {
55
+ if (localReason) return localReason;
56
+ if (stopped) return 'the terminal host stopped';
57
+ if (!child) return 'the terminal host is restarting';
58
+ return hostReason;
59
+ }
60
+
61
+ function spawn() {
62
+ hostReason = null;
63
+ const c = fork(HOST, [], { execArgv: [], windowsHide: true });
64
+ child = c;
65
+ c.on('message', (msg) => { if (c === child) onMessage(c, msg); });
66
+ c.on('exit', (code, signal) => { if (c === child) onHostExit(code, signal); });
67
+ c.on('error', () => {});
68
+ c.send({
69
+ t: 'init', config, claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, sessionsDir: o.sessionsDir, token, serverUrl,
70
+ saved: config.restore ? o.load?.() ?? null : null,
71
+ });
72
+ if (restoreAsked) call('restore').catch(() => {});
73
+ }
74
+
75
+ function onMessage(c, msg) {
76
+ if (msg.t === 'state') {
77
+ hostReason = msg.reason;
78
+ ids = msg.ids;
79
+ settle(hostReason);
80
+ o.onChange?.();
81
+ } else if (msg.t === 'exit') o.onExit?.(msg.id);
82
+ else if (msg.t === 'save') o.save?.(msg.data);
83
+ else if (msg.t === 'reply') {
84
+ const pending = calls.get(msg.id);
85
+ if (!pending) return;
86
+ calls.delete(msg.id);
87
+ clearTimeout(pending.timer);
88
+ if (msg.error) pending.reject(new Error(msg.error));
89
+ else pending.resolve(msg.value);
90
+ } else if (msg.t === 'ask') answer(c, msg);
91
+ }
92
+
93
+ const ASKS = {
94
+ resolveCwd: [o.resolveCwd, null],
95
+ isAllowedFolder: [o.isAllowedFolder, false],
96
+ };
97
+
98
+ async function answer(c, msg) {
99
+ const [fn, fallback] = ASKS[msg.op];
100
+ let value;
101
+ try {
102
+ value = await fn(msg.arg);
103
+ } catch {
104
+ value = fallback;
105
+ }
106
+ if (c.connected) c.send({ t: 'answer', id: msg.id, value });
107
+ }
108
+
109
+ // The PTYs die with the host, as they do with cck; a new host resumes terminals.json.
110
+ function onHostExit(code, signal) {
111
+ child = null;
112
+ for (const pending of calls.values()) {
113
+ clearTimeout(pending.timer);
114
+ pending.reject(hostError('the terminal host stopped'));
115
+ }
116
+ calls.clear();
117
+ if (shuttingDown) return;
118
+ console.log(`Terminal host exited (${signal || code})`);
119
+ const lost = ids;
120
+ ids = [];
121
+ for (const id of lost) o.onExit?.(id);
122
+ o.onChange?.();
123
+ const now = Date.now();
124
+ while (restarts.length && now - restarts[0] > RESTART_WINDOW_MS) restarts.shift();
125
+ if (restarts.length >= MAX_RESTARTS) {
126
+ stopped = true;
127
+ console.log(`Terminal unavailable: the host stopped ${MAX_RESTARTS} times in ${RESTART_WINDOW_MS / 1000} s`);
128
+ settle(unavailableReason());
129
+ return;
130
+ }
131
+ restarts.push(now);
132
+ setTimeout(() => { if (!shuttingDown) spawn(); }, RESTART_DELAY_MS * restarts.length).unref();
133
+ }
134
+
135
+ function call(op, ...args) {
136
+ const c = child;
137
+ if (!c?.connected) return Promise.reject(hostError(unavailableReason() || 'the terminal host is not running'));
138
+ return new Promise((resolve, reject) => {
139
+ const id = ++nextCall;
140
+ const timer = setTimeout(() => {
141
+ calls.delete(id);
142
+ reject(hostError('the terminal host did not answer'));
143
+ }, CALL_TIMEOUT_MS);
144
+ calls.set(id, { resolve, reject, timer });
145
+ c.send({ t: 'call', id, op, args });
146
+ });
147
+ }
148
+
149
+ function handleUpgrade(req, socket, head) {
150
+ if (refuseUpgrade(req, socket, () => unavailableReason() || net.upgradeVerdict(req))) return;
151
+ // The client sends nothing before the 101, so no byte is lost between the two processes.
152
+ child.send({ t: 'upgrade', method: req.method, url: req.url, headers: req.headers, head: head.toString('base64') }, socket, (err) => {
153
+ if (err) socket.destroy();
154
+ });
155
+ }
156
+
157
+ function authorized(providedToken) {
158
+ return tokenMatches(token, providedToken);
159
+ }
160
+
161
+ function isRunning(id) {
162
+ return ids.includes(id);
163
+ }
164
+
165
+ async function startNew(msg, extraEnv) {
166
+ if (localReason) return { status: 403, error: localReason };
167
+ return call('startNew', msg, extraEnv);
168
+ }
169
+
170
+ async function end(id, providedToken) {
171
+ if (!authorized(providedToken)) return 'auth';
172
+ if (localReason) return 'not-found';
173
+ return call('end', id, providedToken);
174
+ }
175
+
176
+ async function paste(id, text) {
177
+ if (!isRunning(id)) return false;
178
+ return call('paste', id, text).catch(() => false);
179
+ }
180
+
181
+ function restore() {
182
+ restoreAsked = true;
183
+ if (child) call('restore').catch(() => {});
184
+ }
185
+
186
+ function setServerUrl(url) {
187
+ serverUrl = url;
188
+ if (child?.connected) child.send({ t: 'port', url });
189
+ }
190
+
191
+ // With no host there are no terminals, which the hub's eviction check must still read.
192
+ async function sessions() {
193
+ return child?.connected ? call('sessions') : [];
194
+ }
195
+
196
+ async function stats() {
197
+ const c = child;
198
+ return { hostPid: c?.pid ?? null, claudePids: c?.connected ? await call('claudePids') : {} };
199
+ }
200
+
201
+ function clientConfig() {
202
+ return clientConfigFor(config, !unavailableReason());
203
+ }
204
+
205
+ // Closing the channel is what ends the host, so it ends its PTYs itself.
206
+ function shutdown() {
207
+ shuttingDown = true;
208
+ try { child?.disconnect(); } catch { /* already closed */ }
209
+ }
210
+
211
+ if (localReason) settle(localReason);
212
+ else spawn();
213
+
214
+ return {
215
+ token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, startNew, end, paste, restore,
216
+ setServerUrl, sessions, stats, shutdown, started,
217
+ ids: () => [...ids],
218
+ };
219
+ }
220
+
221
+ module.exports = { createTerminalClient };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ // The terminal's own process, forked by lib/terminal-client.js. It runs the terminal
4
+ // service unchanged; cck reaches it only over the IPC channel and ends it by closing that.
5
+
6
+ const { createTerminalService } = require('./terminal');
7
+ const { readLiveSessions, isSessionLive } = require('./live-sessions');
8
+ const { whichSync } = require('./open-editor');
9
+
10
+ let service = null;
11
+ let serverUrl = null;
12
+ let lastState = null;
13
+ let nextAsk = 0;
14
+ const asks = new Map();
15
+
16
+ function send(msg) {
17
+ if (process.connected) process.send(msg);
18
+ }
19
+
20
+ function ask(op, arg) {
21
+ return new Promise((resolve) => {
22
+ const id = ++nextAsk;
23
+ asks.set(id, resolve);
24
+ send({ t: 'ask', id, op, arg });
25
+ });
26
+ }
27
+
28
+ // The service reports every attach and detach too; cck needs only a change of ids or reason.
29
+ function pushState() {
30
+ const state = { t: 'state', reason: service.unavailableReason(), ids: service.list().map((s) => s.id) };
31
+ const key = JSON.stringify(state);
32
+ if (key === lastState) return;
33
+ lastState = key;
34
+ send(state);
35
+ }
36
+
37
+ function init(msg) {
38
+ serverUrl = msg.serverUrl;
39
+ const liveSessions = () => readLiveSessions(msg.sessionsDir);
40
+ service = createTerminalService({
41
+ config: msg.config,
42
+ // cck checked Host, Origin and exposure before it handed the socket over.
43
+ net: { EXPOSED: false, upgradeVerdict: () => null },
44
+ claudeDir: msg.claudeDir,
45
+ isDefaultDir: msg.isDefaultDir,
46
+ token: msg.token,
47
+ serverUrl: () => serverUrl,
48
+ which: whichSync,
49
+ liveSessions,
50
+ isLiveElsewhere: (id, exceptPid) => isSessionLive(liveSessions(), id, exceptPid),
51
+ resolveCwd: (id) => ask('resolveCwd', id),
52
+ isAllowedFolder: (dir) => ask('isAllowedFolder', dir),
53
+ load: () => msg.saved,
54
+ save: (data) => send({ t: 'save', data }),
55
+ onChange: pushState,
56
+ onExit: (id) => send({ t: 'exit', id }),
57
+ });
58
+ pushState();
59
+ }
60
+
61
+ const CALLS = {
62
+ startNew: (msg, extraEnv) => service.startNew(msg, extraEnv),
63
+ end: (id, token) => service.end(id, token),
64
+ paste: (id, text) => service.paste(id, text),
65
+ restore: () => service.restore(),
66
+ sessions: () => service.list(),
67
+ claudePids: () => service.claudePids(),
68
+ };
69
+
70
+ async function call(msg) {
71
+ try {
72
+ send({ t: 'reply', id: msg.id, value: await CALLS[msg.op](...msg.args) });
73
+ } catch (e) {
74
+ send({ t: 'reply', id: msg.id, error: e.message });
75
+ }
76
+ }
77
+
78
+ process.on('message', (msg, socket) => {
79
+ if (msg.t === 'init') init(msg);
80
+ else if (msg.t === 'port') serverUrl = msg.url;
81
+ else if (msg.t === 'upgrade') {
82
+ if (!socket) return;
83
+ service.handleUpgrade({ method: msg.method, url: msg.url, headers: msg.headers }, socket, Buffer.from(msg.head, 'base64'));
84
+ } else if (msg.t === 'call') call(msg);
85
+ else if (msg.t === 'answer') {
86
+ asks.get(msg.id)?.(msg.value);
87
+ asks.delete(msg.id);
88
+ }
89
+ });
90
+
91
+ // cck is gone, so no claude may outlive the board that started it.
92
+ process.on('disconnect', () => {
93
+ service?.shutdown();
94
+ process.exit(0);
95
+ });