claude-code-kanban 5.4.0 → 6.1.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.
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;
package/lib/retention.js CHANGED
@@ -3,7 +3,6 @@
3
3
  const fs = require('node:fs/promises');
4
4
  const path = require('node:path');
5
5
  const { readSettings } = require('./claude-settings');
6
- const { DISPATCH_OUTCOME } = require('./dispatch');
7
6
 
8
7
  const DAY_MS = 24 * 60 * 60 * 1000;
9
8
  const DEFAULT_CLEANUP_DAYS = 30;
@@ -11,7 +10,6 @@ const DEFAULT_CLEANUP_DAYS = 30;
11
10
  // read that gap as a deleted transcript.
12
11
  const GRACE_MS = 60 * 60 * 1000;
13
12
  const MAX_DISPATCHED = 500;
14
- const OUTCOMES = new Set(Object.keys(DISPATCH_OUTCOME));
15
13
 
16
14
  // Only the config dir's settings: retention is per config dir, not per project.
17
15
  function retentionMs(claudeDir) {
@@ -28,10 +26,10 @@ function isExpired(id, at, { known, maxAgeMs, now }) {
28
26
  }
29
27
 
30
28
  /**
31
- * Sessions started through `dispatch start`, kept after the dispatch settles so the card
29
+ * Sessions started through `dispatch start`, kept after their terminal ends so the card
32
30
  * keeps its marker.
33
31
  * @param {object} o
34
- * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, status, at}}}` or null
32
+ * @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, at}}}` or null
35
33
  * @param {(data: object) => void} o.save
36
34
  */
37
35
  function createDispatchedStore({ load, save, now = Date.now }) {
@@ -41,10 +39,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
41
39
  if (saved && typeof saved === 'object') {
42
40
  for (const [id, e] of Object.entries(saved)) {
43
41
  if (!e || !Number.isFinite(e.at)) continue;
44
- // The dispatch registry is in memory: a dispatch that was running when the server
45
- // stopped can never settle, so it reads as ended.
46
- const status = OUTCOMES.has(e.status) ? e.status : 'exited';
47
- entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, status, at: e.at });
42
+ entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, at: e.at });
48
43
  }
49
44
  }
50
45
 
@@ -52,18 +47,11 @@ function createDispatchedStore({ load, save, now = Date.now }) {
52
47
 
53
48
  function record(id, parent) {
54
49
  entries.delete(id);
55
- entries.set(id, { parent: parent || null, status: 'running', at: now() });
50
+ entries.set(id, { parent: parent || null, at: now() });
56
51
  if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
57
52
  persist();
58
53
  }
59
54
 
60
- function settle(id, status) {
61
- const e = entries.get(id);
62
- if (!e || e.status !== 'running' || !OUTCOMES.has(status)) return;
63
- e.status = status;
64
- persist();
65
- }
66
-
67
55
  // Returns how many entries went; writes only when one did.
68
56
  function prune({ known, maxAgeMs }) {
69
57
  const t = now();
@@ -78,7 +66,7 @@ function createDispatchedStore({ load, save, now = Date.now }) {
78
66
  return removed;
79
67
  }
80
68
 
81
- return { record, settle, prune, get: (id) => entries.get(id) || null };
69
+ return { record, prune, get: (id) => entries.get(id) || null };
82
70
  }
83
71
 
84
72
  // Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
@@ -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
 
@@ -79,8 +79,9 @@ function clampWait(sec) {
79
79
  return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
80
80
  }
81
81
 
82
- // Each topic rides its own bucket, so the dispatch postman never prints task moves
83
- // the user did not grant with the follow skill. No topic is the task-move bucket.
82
+ // Each topic rides its own bucket, so a postman on a topic never prints task moves the
83
+ // user did not grant with the follow skill. No topic is the task-move bucket. Plugins
84
+ // before 2.22.0 run a `--topic dispatch` postman, which must keep getting nothing.
84
85
  function topicKey(topic, sessionId) {
85
86
  return topic ? `${topic}:${sessionId}` : sessionId;
86
87
  }
@@ -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
+ });