claude-code-kanban 7.5.0 → 7.6.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.
Files changed (40) hide show
  1. package/hub-app.json +5 -0
  2. package/lib/panes.js +16 -2
  3. package/lib/parsers.js +27 -2
  4. package/lib/retention.js +29 -6
  5. package/lib/session-events.js +154 -42
  6. package/lib/show.js +385 -0
  7. package/lib/terminal-client.js +14 -1
  8. package/lib/terminal-host.js +2 -1
  9. package/lib/terminal.js +27 -7
  10. package/package.json +1 -1
  11. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
  12. package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +66 -10
  13. package/plugin/plugins/claude-code-kanban/hooks/index.ts +2 -0
  14. package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
  15. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
  16. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +2 -0
  17. package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
  18. package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
  19. package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
  20. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
  21. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
  22. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
  23. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
  24. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
  25. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
  26. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
  27. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
  28. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
  29. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
  30. package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
  31. package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
  32. package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +69 -10
  33. package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
  34. package/public/app.js +1149 -108
  35. package/public/index.html +7 -6
  36. package/public/style.css +770 -9
  37. package/public/terminal-frame.js +0 -2
  38. package/public/tree-highlight.js +137 -15
  39. package/server.js +71 -24
  40. package/lib/dispatch.js +0 -32
package/lib/show.js ADDED
@@ -0,0 +1,385 @@
1
+ // The show overlay's server side: posts a terminal's session makes through the plugin's show tool.
2
+ // The card belongs to the terminal and the posts to the session, so the store maps terminal id
3
+ // to its current session and keeps each session's posts in <scratchpad>/.cck/show/.
4
+
5
+ const crypto = require('node:crypto');
6
+ const fs = require('node:fs');
7
+ const path = require('node:path');
8
+ const express = require('express');
9
+ const { httpError } = require('./http-error');
10
+
11
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
12
+ const SHOW_PATH = '/api/terminals/:terminalId/show';
13
+ const BODY_LIMIT = '16kb';
14
+ const MAX_LABEL = 200;
15
+ const EXT = { markdown: 'md', html: 'html' };
16
+ const CONTENT_FILE_RE = new RegExp(`^(${UUID_RE.source.slice(1, -1)})\\.(md|html)$`, 'i');
17
+ // An editor can save in two steps, so a refresh waits for the writes to settle.
18
+ const SETTLE_MS = 200;
19
+
20
+ function isLabel(v) {
21
+ return typeof v === 'string' && v.trim() !== '' && v.length <= MAX_LABEL;
22
+ }
23
+
24
+ // A string is a reason the body is refused.
25
+ function parsePost(body) {
26
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return 'body must be a JSON object';
27
+ const { sessionId, title, key, kind = 'markdown', content, file } = body;
28
+ if (typeof sessionId !== 'string' || !UUID_RE.test(sessionId)) return 'invalid sessionId';
29
+ if (!isLabel(title)) return `title must be a non-empty string of at most ${MAX_LABEL} characters`;
30
+ if (key != null && !isLabel(key)) return `key must be a non-empty string of at most ${MAX_LABEL} characters`;
31
+ if (!Object.hasOwn(EXT, kind)) return 'kind must be markdown or html';
32
+ if (content != null) return 'content is not accepted: call show with only title and key to get a file to write the card into';
33
+ if (file != null && (typeof file !== 'string' || !path.isAbsolute(file))) return 'file must be an absolute path';
34
+ return { sessionId, title, key: key ?? null, kind, file: file ?? null };
35
+ }
36
+
37
+ function writeAtomic(file, text) {
38
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
39
+ try {
40
+ fs.writeFileSync(tmp, text, 'utf8');
41
+ fs.renameSync(tmp, file);
42
+ } catch (e) {
43
+ fs.rmSync(tmp, { force: true });
44
+ throw e;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {object} o
50
+ * @param {() => Object<string, string>|null} o.load terminal id → session id, as saved
51
+ * @param {() => void} o.onChange the map changed and should be saved
52
+ * @param {(sessionId: string) => string|null} o.resolveDir the session's scratchpad, null when its transcript is unknown
53
+ * @param {(terminalId: string) => string|null} [o.startedSession] the session a live terminal was started with
54
+ * @param {(e: object) => void} [o.onPosted] a post was made or its file changed on disk, once per terminal showing its session
55
+ * @param {() => number} [o.now]
56
+ */
57
+ function createShowStore(o) {
58
+ const now = o.now || Date.now;
59
+ const current = new Map();
60
+ const watchers = new Map();
61
+ // Post id → the claim file's mtime as cck last saw it, so a second watch event for one save is not a change.
62
+ const seen = new Map();
63
+ for (const [terminalId, sessionId] of Object.entries(o.load?.() || {})) {
64
+ if (UUID_RE.test(terminalId) && typeof sessionId === 'string' && UUID_RE.test(sessionId)) current.set(terminalId, sessionId);
65
+ }
66
+
67
+ function folder(sessionId) {
68
+ const dir = o.resolveDir(sessionId);
69
+ return dir ? path.join(dir, '.cck', 'show') : null;
70
+ }
71
+
72
+ function contentPath(dir, entry) {
73
+ return path.join(dir, `${entry.id}.${EXT[entry.kind]}`);
74
+ }
75
+
76
+ function postPath(dir, entry) {
77
+ return entry.file || contentPath(dir, entry);
78
+ }
79
+
80
+ function locate(terminalId, postId) {
81
+ const sessionId = sessionOf(terminalId);
82
+ const dir = sessionId && UUID_RE.test(postId) ? folder(sessionId) : null;
83
+ const posts = dir ? readPosts(dir) : [];
84
+ return { sessionId, dir, posts, at: posts.findIndex((p) => p.id === postId) };
85
+ }
86
+
87
+ function writeIndex(dir, sessionId, posts) {
88
+ writeAtomic(path.join(dir, 'index.json'), JSON.stringify({ sessionId, posts }, null, 2));
89
+ }
90
+
91
+ function notify(sessionId, entry, at, count, replaced) {
92
+ if (!o.onPosted) return;
93
+ for (const [terminalId, s] of current) {
94
+ if (s !== sessionId) continue;
95
+ o.onPosted({ terminalId, sessionId, id: entry.id, title: entry.title, key: entry.key, index: at + 1, count, replaced });
96
+ }
97
+ }
98
+
99
+ function touch(sessionId, dir, postId) {
100
+ const posts = readPosts(dir);
101
+ const at = posts.findIndex((p) => p.id === postId);
102
+ const entry = posts[at];
103
+ if (!entry || entry.file) return;
104
+ let mtime;
105
+ try {
106
+ mtime = fs.statSync(contentPath(dir, entry)).mtimeMs;
107
+ } catch {
108
+ return;
109
+ }
110
+ if (mtime === seen.get(postId)) return;
111
+ seen.set(postId, mtime);
112
+ entry.updatedAt = new Date(now()).toISOString();
113
+ writeIndex(dir, sessionId, posts);
114
+ notify(sessionId, entry, at, posts.length, true);
115
+ }
116
+
117
+ function watch(sessionId, dir) {
118
+ if (!o.onPosted || watchers.has(sessionId)) return;
119
+ let w;
120
+ try {
121
+ // libuv aborts the process on a change under an 8.3 short path (NIKIFO~1), so watch the long form.
122
+ w = fs.watch(fs.realpathSync.native(dir));
123
+ } catch {
124
+ return;
125
+ }
126
+ w.unref();
127
+ const timers = new Map();
128
+ // A watch left on a deleted folder keeps the process from exiting on Windows.
129
+ w.on('change', (event, name) => {
130
+ if (event === 'rename' && !fs.existsSync(dir)) return unwatch(sessionId);
131
+ const id = CONTENT_FILE_RE.exec(String(name ?? ''))?.[1];
132
+ if (!id) return;
133
+ clearTimeout(timers.get(id));
134
+ const t = setTimeout(() => {
135
+ timers.delete(id);
136
+ try {
137
+ touch(sessionId, dir, id);
138
+ } catch (e) {
139
+ console.error('[show] refresh failed:', e.message);
140
+ }
141
+ }, SETTLE_MS);
142
+ t.unref();
143
+ timers.set(id, t);
144
+ });
145
+ w.on('error', () => unwatch(sessionId));
146
+ watchers.set(sessionId, () => {
147
+ for (const t of timers.values()) clearTimeout(t);
148
+ w.close();
149
+ });
150
+ }
151
+
152
+ function unwatch(sessionId) {
153
+ watchers.get(sessionId)?.();
154
+ watchers.delete(sessionId);
155
+ }
156
+
157
+ function unwatchUnused() {
158
+ const used = new Set(current.values());
159
+ for (const sessionId of watchers.keys()) if (!used.has(sessionId)) unwatch(sessionId);
160
+ }
161
+
162
+ function readPosts(dir) {
163
+ try {
164
+ const index = JSON.parse(fs.readFileSync(path.join(dir, 'index.json'), 'utf8'));
165
+ if (Array.isArray(index.posts)) return index.posts;
166
+ } catch { /* no posts yet */ }
167
+ return [];
168
+ }
169
+
170
+ function removeContent(dir, id) {
171
+ for (const ext of Object.values(EXT)) fs.rmSync(path.join(dir, `${id}.${ext}`), { force: true });
172
+ }
173
+
174
+ // A session resumed in a new terminal gets a new terminal id, so a terminal with no posts yet
175
+ // takes the posts of the session it was started with.
176
+ function sessionOf(terminalId) {
177
+ const known = current.get(terminalId);
178
+ if (known) return known;
179
+ const started = o.startedSession?.(terminalId);
180
+ const dir = started && UUID_RE.test(started) ? folder(started) : null;
181
+ if (!dir || !readPosts(dir).length) return null;
182
+ bind(terminalId, started);
183
+ return started;
184
+ }
185
+
186
+ function bind(terminalId, sessionId) {
187
+ if (current.get(terminalId) === sessionId) return;
188
+ current.set(terminalId, sessionId);
189
+ unwatchUnused();
190
+ o.onChange();
191
+ }
192
+
193
+ // `kind` of a file post is the one readPreviewFile gave when it was posted. A post without a file
194
+ // is a claim: cck names the file and the session writes it. cck does not create it, because
195
+ // Claude Code's Write refuses to overwrite a file the session has not read.
196
+ function post(terminalId, { sessionId, title, key, kind, file }) {
197
+ const dir = folder(sessionId);
198
+ if (!dir) throw httpError(409, 'no transcript found for that session');
199
+ fs.mkdirSync(dir, { recursive: true });
200
+ const posts = readPosts(dir);
201
+ let at = key == null ? -1 : posts.findIndex((p) => p.key === key);
202
+ const old = at >= 0 ? posts[at] : null;
203
+ const id = old ? old.id : crypto.randomUUID();
204
+ const target = file == null ? contentPath(dir, { id, kind }) : null;
205
+ if (target && old && !old.file) {
206
+ const from = contentPath(dir, old);
207
+ if (from !== target && fs.existsSync(from)) fs.renameSync(from, target);
208
+ } else if (old) {
209
+ removeContent(dir, id);
210
+ seen.delete(id);
211
+ }
212
+ if (!old) at = posts.length;
213
+ const entry = { id, title, key, kind, file, updatedAt: new Date(now()).toISOString() };
214
+ posts[at] = entry;
215
+ writeIndex(dir, sessionId, posts);
216
+ bind(terminalId, sessionId);
217
+ watch(sessionId, dir);
218
+ notify(sessionId, entry, at, posts.length, !!old);
219
+ return { id, title, key, index: at + 1, count: posts.length, replaced: !!old, path: target };
220
+ }
221
+
222
+ function list(terminalId) {
223
+ const sessionId = sessionOf(terminalId);
224
+ if (!sessionId) return { sessionId: null, posts: [] };
225
+ const dir = folder(sessionId);
226
+ const posts = dir ? readPosts(dir) : [];
227
+ if (posts.length) watch(sessionId, dir);
228
+ return { sessionId, posts };
229
+ }
230
+
231
+ // The post's index entry and its stored content; `waiting` when a claim file is not written yet.
232
+ function get(terminalId, postId) {
233
+ const { dir, posts, at } = locate(terminalId, postId);
234
+ if (at < 0) return null;
235
+ const entry = posts[at];
236
+ const file = postPath(dir, entry);
237
+ if (entry.file) return { entry, content: null, path: file };
238
+ try {
239
+ return { entry, content: fs.readFileSync(file, 'utf8'), path: file };
240
+ } catch {
241
+ return { entry, content: null, path: file, waiting: true };
242
+ }
243
+ }
244
+
245
+ // Returns the session and the paths of the posts it had.
246
+ function clear(terminalId) {
247
+ const sessionId = sessionOf(terminalId);
248
+ const dir = sessionId && folder(sessionId);
249
+ const paths = [];
250
+ if (dir) {
251
+ unwatch(sessionId);
252
+ for (const p of readPosts(dir)) {
253
+ seen.delete(p.id);
254
+ paths.push(postPath(dir, p));
255
+ }
256
+ fs.rmSync(dir, { recursive: true, force: true });
257
+ }
258
+ return { sessionId, paths };
259
+ }
260
+
261
+ // A file post's file belongs to the session, so only cck's own copy is deleted.
262
+ function remove(terminalId, postId) {
263
+ const { sessionId, dir, posts, at } = locate(terminalId, postId);
264
+ if (at < 0) return null;
265
+ const [entry] = posts.splice(at, 1);
266
+ writeIndex(dir, sessionId, posts);
267
+ removeContent(dir, postId);
268
+ seen.delete(postId);
269
+ return { sessionId, count: posts.length, path: postPath(dir, entry) };
270
+ }
271
+
272
+ // Drops the terminals that `keep` no longer knows, and gives the map to save.
273
+ function prune(keep) {
274
+ for (const terminalId of current.keys()) if (!keep(terminalId)) current.delete(terminalId);
275
+ unwatchUnused();
276
+ return Object.fromEntries(current);
277
+ }
278
+
279
+ return { sessionOf, post, list, get, clear, remove, prune };
280
+ }
281
+
282
+ // Mounted before the global JSON parser: the parser that runs first owns the body.
283
+ function showBodyParser() {
284
+ return express.json({ limit: BODY_LIMIT });
285
+ }
286
+
287
+ /**
288
+ * @param {import('express').Express} app
289
+ * @param {object} o
290
+ * @param {ReturnType<typeof createShowStore>} o.store
291
+ * @param {(token: string|undefined) => boolean} o.authorized the terminal token
292
+ * @param {(terminalId: string) => boolean} o.hasTerminal a live terminal has this id
293
+ * @param {(absPath: string) => Promise<{content: string|null, kind: string|null}>} o.readPreviewFile
294
+ * @param {(event: object) => void} o.broadcast
295
+ * @param {(sessionId: string, paths: string[]) => void} [o.onRemoved] posts left the card; their files' panes go too
296
+ */
297
+ function mountShowRoutes(app, o) {
298
+ const { store } = o;
299
+
300
+ function guard(req, res) {
301
+ if (!o.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
302
+ if (!o.hasTerminal(req.params.terminalId)) return res.status(404).json({ error: 'no live terminal with that id' });
303
+ return null;
304
+ }
305
+
306
+ async function readFilePost(file) {
307
+ let read;
308
+ try {
309
+ read = await o.readPreviewFile(file);
310
+ } catch (e) {
311
+ if (e.status === 404) throw httpError(404, `file not found: ${file}`);
312
+ throw e;
313
+ }
314
+ if (!read.kind) throw httpError(400, 'not a markdown, HTML, text or image file');
315
+ // The previewer shows SVG as source; the card draws it in its sandboxed frame. The prolog
316
+ // would read as a whole document there.
317
+ if (read.kind === 'text' && /\.svg$/i.test(file)) {
318
+ return { kind: 'html', content: read.content.replace(/^(?:\s*(?:<\?xml[\s\S]*?\?>|<!doctype[^>]*>))*/i, '') };
319
+ }
320
+ return read;
321
+ }
322
+
323
+ const route = (fn) => (req, res) => fn(req, res).catch((e) => {
324
+ if (res.headersSent) return;
325
+ if (!e.status) console.error(`Error in ${req.method} ${req.path}:`, e);
326
+ res.status(e.status || 500).json({ error: e.status ? e.message : 'Internal error' });
327
+ });
328
+
329
+ app.post(SHOW_PATH, route(async (req, res) => {
330
+ if (guard(req, res)) return;
331
+ const p = parsePost(req.body);
332
+ if (typeof p === 'string') return res.status(400).json({ error: p });
333
+ if (p.file) {
334
+ try {
335
+ p.kind = (await readFilePost(p.file)).kind;
336
+ } catch (e) {
337
+ return res.status(400).json({ error: e.message });
338
+ }
339
+ }
340
+ res.json(store.post(req.params.terminalId, p));
341
+ }));
342
+
343
+ app.get(SHOW_PATH, (req, res) => {
344
+ res.setHeader('Cache-Control', 'no-store');
345
+ res.json(store.list(req.params.terminalId));
346
+ });
347
+
348
+ app.get(`${SHOW_PATH}/:postId`, route(async (req, res) => {
349
+ res.setHeader('Cache-Control', 'no-store');
350
+ const found = store.get(req.params.terminalId, req.params.postId);
351
+ if (!found) return res.status(404).json({ error: 'no such post' });
352
+ const { entry } = found;
353
+ const out = { id: entry.id, title: entry.title, key: entry.key, kind: entry.kind, content: found.content, file: entry.file, path: found.path, url: null };
354
+ if (found.waiting) out.waiting = true;
355
+ if (entry.file) {
356
+ const { content, kind } = await readFilePost(entry.file);
357
+ out.kind = kind;
358
+ out.content = content;
359
+ if (kind === 'image') out.url = `/api/preview/image?path=${encodeURIComponent(entry.file)}`;
360
+ }
361
+ res.json(out);
362
+ }));
363
+
364
+ app.delete(SHOW_PATH, route(async (req, res) => {
365
+ if (guard(req, res)) return;
366
+ const { sessionId, paths } = store.clear(req.params.terminalId);
367
+ if (sessionId) {
368
+ o.broadcast({ type: 'show:cleared', terminalId: req.params.terminalId, sessionId });
369
+ if (paths.length) o.onRemoved?.(sessionId, paths);
370
+ }
371
+ res.status(204).end();
372
+ }));
373
+
374
+ app.delete(`${SHOW_PATH}/:postId`, route(async (req, res) => {
375
+ if (guard(req, res)) return;
376
+ const { terminalId, postId } = req.params;
377
+ const removed = store.remove(terminalId, postId);
378
+ if (!removed) return res.status(404).json({ error: 'no such post' });
379
+ o.broadcast({ type: 'show:removed', terminalId, sessionId: removed.sessionId, id: postId, count: removed.count });
380
+ o.onRemoved?.(removed.sessionId, [removed.path]);
381
+ res.status(204).end();
382
+ }));
383
+ }
384
+
385
+ module.exports = { createShowStore, mountShowRoutes, showBodyParser, parsePost, SHOW_PATH };
@@ -41,6 +41,7 @@ function createTerminalClient(o) {
41
41
  let child = null;
42
42
  let hostReason = null;
43
43
  let ids = [];
44
+ let terminalIds = [];
44
45
  let serverUrl = null;
45
46
  let restoreAsked = false;
46
47
  let stopped = false;
@@ -76,6 +77,7 @@ function createTerminalClient(o) {
76
77
  if (msg.t === 'state') {
77
78
  hostReason = msg.reason;
78
79
  ids = msg.ids;
80
+ terminalIds = msg.terminalIds || [];
79
81
  settle(hostReason);
80
82
  o.onChange?.();
81
83
  } else if (msg.t === 'exit') o.onExit?.(msg.id);
@@ -118,6 +120,7 @@ function createTerminalClient(o) {
118
120
  console.log(`Terminal host exited (${signal || code})`);
119
121
  const lost = ids;
120
122
  ids = [];
123
+ terminalIds = [];
121
124
  for (const id of lost) o.onExit?.(id);
122
125
  o.onChange?.();
123
126
  const now = Date.now();
@@ -162,6 +165,16 @@ function createTerminalClient(o) {
162
165
  return ids.includes(id);
163
166
  }
164
167
 
168
+ function hasTerminal(terminalId) {
169
+ return terminalIds.includes(terminalId);
170
+ }
171
+
172
+ // The host sends both lists from one array of terminals.
173
+ function sessionOfTerminal(terminalId) {
174
+ const at = terminalIds.indexOf(terminalId);
175
+ return at < 0 ? null : ids[at];
176
+ }
177
+
165
178
  async function startNew(msg, extraEnv) {
166
179
  if (localReason) return { status: 403, error: localReason };
167
180
  return call('startNew', msg, extraEnv);
@@ -212,7 +225,7 @@ function createTerminalClient(o) {
212
225
  else spawn();
213
226
 
214
227
  return {
215
- token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, startNew, end, paste, restore,
228
+ token, handleUpgrade, clientConfig, unavailableReason, authorized, isRunning, hasTerminal, sessionOfTerminal, startNew, end, paste, restore,
216
229
  setServerUrl, sessions, stats, shutdown, started,
217
230
  ids: () => [...ids],
218
231
  };
@@ -27,7 +27,8 @@ function ask(op, arg) {
27
27
 
28
28
  // The service reports every attach and detach too; cck needs only a change of ids or reason.
29
29
  function pushState() {
30
- const state = { t: 'state', reason: service.unavailableReason(), ids: service.list().map((s) => s.id) };
30
+ const live = service.list();
31
+ const state = { t: 'state', reason: service.unavailableReason(), ids: live.map((s) => s.id), terminalIds: live.map((s) => s.terminalId) };
31
32
  const key = JSON.stringify(state);
32
33
  if (key === lastState) return;
33
34
  lastState = key;
package/lib/terminal.js CHANGED
@@ -74,6 +74,8 @@ const STRIP_ENV = [
74
74
  // session's child and writes no live-session registry entry, which the resume picker relies on.
75
75
  'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID', 'CLAUDE_CODE_SESSION_ATTENDED',
76
76
  'CLAUDE_CODE_MESSAGING_SOCKET', 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_EXECPATH',
77
+ // The parent session's effort level; a terminal session takes its own from settings or --effort.
78
+ 'CLAUDE_EFFORT',
77
79
  // A shared task list is opt-in per dispatch (taskList), never inherited from cck's own env.
78
80
  'CLAUDE_CODE_TASK_LIST_ID',
79
81
  // A cck started inside tmux passes these on, and claude then lists its session under a tmux pane it is not in.
@@ -252,14 +254,17 @@ function screenText(term) {
252
254
  // Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
253
255
  // inside the dir when set, so the default dir must stay unset, not be spelled out.
254
256
  // CCK_URL pins the CLI and the doorbell mod to this board: two boards on one config dir share
255
- // one server.json, and the last one started owns it.
256
- function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl }) {
257
+ // one server.json, and the last one started owns it. CCK_TERMINAL_ID names the terminal the
258
+ // show tool posts to.
259
+ function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl, terminalId }) {
257
260
  const env = { ...process.env };
258
261
  for (const k of STRIP_ENV) delete env[k];
259
262
  if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
260
263
  else env.CLAUDE_CONFIG_DIR = claudeDir;
261
264
  if (cckUrl) env.CCK_URL = cckUrl;
262
265
  else delete env.CCK_URL;
266
+ if (terminalId) env.CCK_TERMINAL_ID = terminalId;
267
+ else delete env.CCK_TERMINAL_ID;
263
268
  // xterm.js #5801: a clear inside a synchronized-output block jumps the viewport.
264
269
  if (noFlicker) env.CLAUDE_CODE_NO_FLICKER = '1';
265
270
  env.TERM = 'xterm-256color';
@@ -360,6 +365,11 @@ function createTerminalService(o) {
360
365
  for (const [id, list] of Object.entries(savedState?.taskLists || {})) {
361
366
  if (restoreQueue.includes(id) && typeof list === 'string' && TASK_LIST_RE.test(list)) savedTaskLists[id] = list;
362
367
  }
368
+ // A resumed terminal keeps its id, so its show card keeps its posts.
369
+ const savedTerminalIds = {};
370
+ for (const [id, terminalId] of Object.entries(savedState?.terminalIds || {})) {
371
+ if (restoreQueue.includes(id) && typeof terminalId === 'string' && UUID_RE.test(terminalId)) savedTerminalIds[id] = terminalId;
372
+ }
363
373
 
364
374
  // Lazy and optional: a missing prebuilt or shell must hide the feature, not crash cck.
365
375
  function load() {
@@ -402,13 +412,22 @@ function createTerminalService(o) {
402
412
  saveTimer = null;
403
413
  const ids = new Set(restoreQueue);
404
414
  const taskLists = {};
405
- for (const id of restoreQueue) if (savedTaskLists[id]) taskLists[id] = savedTaskLists[id];
415
+ const terminalIds = {};
416
+ for (const id of restoreQueue) {
417
+ if (savedTaskLists[id]) taskLists[id] = savedTaskLists[id];
418
+ if (savedTerminalIds[id]) terminalIds[id] = savedTerminalIds[id];
419
+ }
406
420
  for (const s of sessions.values()) {
407
421
  if (s.ended || s.claudeExitedAt || !RESTORABLE_MODES.has(s.mode)) continue;
408
422
  ids.add(s.id);
409
423
  if (s.taskList) taskLists[s.id] = s.taskList;
424
+ terminalIds[s.id] = s.terminalId;
410
425
  }
411
- const data = { sessions: [...ids], ...(Object.keys(taskLists).length && { taskLists }) };
426
+ const data = {
427
+ sessions: [...ids],
428
+ ...(Object.keys(taskLists).length && { taskLists }),
429
+ ...(Object.keys(terminalIds).length && { terminalIds }),
430
+ };
412
431
  const json = JSON.stringify(data);
413
432
  if (json === lastSaved) return;
414
433
  lastSaved = json;
@@ -556,7 +575,8 @@ function createTerminalService(o) {
556
575
 
557
576
  function spawnSession(id, mode, cwdCandidate, cols, rows, spec, extraEnv) {
558
577
  const cwd = cwdCandidate && fs.existsSync(cwdCandidate) ? cwdCandidate : os.homedir();
559
- const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker, cckUrl: o.serverUrl?.() }), ...extraEnv };
578
+ const terminalId = savedTerminalIds[id] || crypto.randomUUID();
579
+ const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker, cckUrl: o.serverUrl?.(), terminalId }), ...extraEnv };
560
580
  const taskList = spec?.taskList || savedTaskLists[id] || null;
561
581
  if (taskList) env.CLAUDE_CODE_TASK_LIST_ID = taskList;
562
582
  // Git Bash's login profile cds to $HOME unless this is set.
@@ -573,7 +593,7 @@ function createTerminalService(o) {
573
593
  const serializer = new Serialize();
574
594
  term.loadAddon(serializer);
575
595
  const s = {
576
- id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
596
+ id, terminalId, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
577
597
  name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
578
598
  forkOf: spec?.forkOf || null,
579
599
  };
@@ -782,7 +802,7 @@ function createTerminalService(o) {
782
802
 
783
803
  function list() {
784
804
  return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
785
- id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
805
+ id: s.id, terminalId: s.terminalId, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
786
806
  name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
787
807
  }));
788
808
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.5.0",
3
+ "version": "7.6.1",
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,5 +1,5 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "3.0.1",
3
+ "version": "3.5.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, board moves and reviews delivered to the session, and skills to drive the board from a session"
5
5
  }
@@ -37,31 +37,87 @@ async function findBoard($: EngineInterface) {
37
37
  }
38
38
  }
39
39
 
40
- async function poll($: EngineInterface, url: string, sessionId: string, first: boolean) {
41
- const query = `wait=${WAIT_SEC}${first ? '&first=1' : ''}`
40
+ type Line = { text: string; seq?: number }
41
+
42
+ // A board before acks sends no seqs and drops a line once it answers with it.
43
+ export function parseReply(text: string) {
44
+ const { events, seqs, board } = JSON.parse(text) as { events?: unknown; seqs?: unknown; board?: unknown }
45
+ const lines: Line[] = Array.isArray(events)
46
+ ? events.flatMap((e, i) =>
47
+ typeof e === 'string'
48
+ ? [{ text: e, seq: Array.isArray(seqs) && Number.isInteger(seqs[i]) ? (seqs[i] as number) : undefined }]
49
+ : [],
50
+ )
51
+ : []
52
+ return { lines, board: typeof board === 'string' ? board : '' }
53
+ }
54
+
55
+ async function poll($: EngineInterface, url: string, sessionId: string, query: string) {
42
56
  const r = await $.http.fetch(`${url}/api/sessions/${encodeURIComponent(sessionId)}/events?${query}`)
43
57
  if (!r.ok) throw new Error(`HTTP ${r.status}`)
44
- const { events } = JSON.parse(r.text) as { events?: unknown }
45
- return Array.isArray(events) ? events.filter((e): e is string => typeof e === 'string') : []
58
+ return parseReply(r.text)
46
59
  }
47
60
 
61
+ // The board keeps a line until a poll acks it, and the ack goes out only after the submit, so a
62
+ // line the mod never got to the session is sent again. Seqs count per board run: `board` names
63
+ // the run, and the board ignores `got` and `ack` from another one.
64
+ //
48
65
  // The session id changes on /clear, so it is read for every poll. The first poll for an id drops
49
- // what the board queued before this session listened: a move from hours ago is not an instruction.
66
+ // the task moves the board queued before this session listened (a move from hours ago is not an
67
+ // instruction) and names the id it leaves, so the board moves that id's reviews over.
50
68
  async function listen($: EngineInterface) {
51
69
  let attached: string | undefined
70
+ let board = ''
71
+ let got = 0
72
+ let acked = 0
73
+ const buffer: Line[] = []
74
+ let submitting = false
75
+
76
+ // A submit waits until the session is idle. The loop keeps polling meanwhile, so the board sees
77
+ // a live doorbell, and lines that come in wait here to go out as one prompt.
78
+ const flush = () => {
79
+ if (submitting || !buffer.length) return
80
+ const burst = buffer.splice(0)
81
+ const forBoard = board
82
+ submitting = true
83
+ void $.prompt
84
+ .submit({ text: burst.map(l => l.text).join('\n') })
85
+ .catch(() => {})
86
+ .finally(() => {
87
+ if (forBoard === board) for (const l of burst) if (l.seq !== undefined && l.seq > acked) acked = l.seq
88
+ submitting = false
89
+ flush()
90
+ })
91
+ }
92
+
52
93
  for (;;) {
53
94
  const [url, sessionId] = await Promise.all([findBoard($), $.session.id()])
54
- let events: string[] | undefined
95
+ const first = attached !== sessionId
96
+ const query = [`wait=${WAIT_SEC}`, `board=${encodeURIComponent(board)}`, `got=${got}`, `ack=${acked}`]
97
+ if (first) query.push('first=1')
98
+ if (first && attached) query.push(`prev=${encodeURIComponent(attached)}`)
99
+ let reply: ReturnType<typeof parseReply> | undefined
55
100
  try {
56
- if (url) events = await poll($, url, sessionId, attached !== sessionId)
101
+ if (url) reply = await poll($, url, sessionId, query.join('&'))
57
102
  } catch {}
58
- if (!events) {
103
+ if (!reply) {
59
104
  await $.clock.sleep(RETRY_MS)
60
105
  continue
61
106
  }
62
107
  attached = sessionId
63
- // A submit waits until the session is idle, and one prompt keeps a burst of moves in one turn.
64
- if (events.length) await $.prompt.submit({ text: events.join('\n') }).catch(() => {})
108
+ if (reply.board !== board) {
109
+ board = reply.board
110
+ got = 0
111
+ acked = 0
112
+ }
113
+ for (const line of reply.lines) {
114
+ if (line.seq !== undefined) {
115
+ if (line.seq <= got) continue
116
+ got = line.seq
117
+ }
118
+ buffer.push(line)
119
+ }
120
+ flush()
65
121
  }
66
122
  }
67
123
 
@@ -2,9 +2,11 @@ import type { Register } from 'claude-code'
2
2
  import { register as activity } from './activity'
3
3
  import { register as context } from './context'
4
4
  import { register as doorbell } from './doorbell'
5
+ import { register as show } from './show'
5
6
 
6
7
  export const register: Register = (on, options) => {
7
8
  context(on, options)
8
9
  activity(on, options)
9
10
  doorbell(on, options)
11
+ show(on, options)
10
12
  }