claude-code-kanban 4.31.1 → 5.0.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/cli.js CHANGED
@@ -427,14 +427,25 @@ function unreachable() {
427
427
 
428
428
  class CliUnreachable extends Error { constructor() { super(unreachable()); this.code = 'unreachable'; } }
429
429
 
430
+ // Windows sometimes fails a loopback connect with ETIMEDOUT after ~300 ms when many
431
+ // connects run at once. Nothing reached the server, so even a POST is safe to send again.
432
+ const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
433
+ const isConnectTimeout = (e) => e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
434
+
430
435
  async function cliFetch(urlPath, init) {
431
436
  const base = cliBaseUrl();
432
437
  if (!base) throw new CliUnreachable();
433
- try {
434
- return await fetch(`${base}${urlPath}`, init);
435
- } catch (e) {
436
- if (e.cause?.code === 'ECONNREFUSED' || /fetch failed/i.test(e.message)) throw new CliUnreachable();
437
- throw e;
438
+ for (let i = 0; ; i++) {
439
+ try {
440
+ return await fetch(`${base}${urlPath}`, init);
441
+ } catch (e) {
442
+ if (isConnectTimeout(e) && i < CONNECT_RETRY_MS.length) {
443
+ await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS[i]));
444
+ continue;
445
+ }
446
+ if (e.cause?.code === 'ECONNREFUSED' || /fetch failed/i.test(e.message)) throw new CliUnreachable();
447
+ throw e;
448
+ }
438
449
  }
439
450
  }
440
451
 
package/hub-app.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "manifest": 1,
3
+ "id": "kanban",
4
+ "name": "Kanban",
5
+ "icon": "columns",
6
+ "run": { "entry": "server.js", "defaultPort": 3541 },
7
+ "publishes": ["session.changed"],
8
+ "provides": {
9
+ "projects": { "path": "/api/projects" },
10
+ "terminal": { "liveWork": "/api/terminals" }
11
+ },
12
+ "loading": {
13
+ "verbs": [
14
+ "Shuffling cards…",
15
+ "Herding tasks…",
16
+ "Waking sessions…",
17
+ "Sorting the board…",
18
+ "Pinning sticky notes…",
19
+ "Moving cards to done…",
20
+ "Nudging agents…",
21
+ "Drawing columns…",
22
+ "Reading the backlog…"
23
+ ]
24
+ }
25
+ }
package/lib/net-guard.js CHANGED
@@ -36,6 +36,7 @@ const PORT_RE = /^[0-9]{1,5}$/;
36
36
  // The port is validated but not compared — the listen(0) fallbacks make the real
37
37
  // port dynamic, so only the host is allowlisted.
38
38
  const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
39
+ const KEEP_ALIVE_MS = 65000;
39
40
 
40
41
  function isLoopbackAddress(host) {
41
42
  if (!host) return false;
@@ -249,6 +250,10 @@ function createNetGuard(config = {}) {
249
250
  const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
250
251
  const makeServer = (handler) => {
251
252
  const s = http.createServer(serverOpts, handler);
253
+ // Node's 5 s default closes idle sockets that the hub proxy and the browser would reuse, and a
254
+ // fresh loopback connect is the one that some Windows network filters drop. The hub's agent
255
+ // drops its idle sockets before this, so it never reuses one the server is closing.
256
+ s.keepAliveTimeout = KEEP_ALIVE_MS;
252
257
  if (opts.onUpgrade) s.on('upgrade', opts.onUpgrade);
253
258
  return s;
254
259
  };
@@ -281,4 +286,4 @@ function createNetGuard(config = {}) {
281
286
  return { BIND_HOST, ALLOWED_HOSTS, EXPOSED, hostGuard, originGuard, upgradeVerdict, frameGuard, listenLoopback, exposureWarning };
282
287
  }
283
288
 
284
- module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress };
289
+ module.exports = { createNetGuard, parseHostHeader, isLoopbackAddress, KEEP_ALIVE_MS };
package/lib/parsers.js CHANGED
@@ -1622,11 +1622,22 @@ function formatStructuredResult(input) {
1622
1622
  return parts.length ? parts.join('\n\n') : null;
1623
1623
  }
1624
1624
 
1625
- // Workflow-spawned subagents given a schema end their run on a forced
1626
- // StructuredOutput tool call and never emit a text response — so their
1627
- // lastMessage is empty. Tail-read the transcript, take the LAST StructuredOutput
1628
- // tool_use, and format its input as the agent's result. Returns null when absent.
1629
- function extractStructuredResultFromTranscript(jsonlPath) {
1625
+ // Some subagents end their run on a tool call and never emit a text response, so
1626
+ // their lastMessage is empty: workflow subagents given a schema call StructuredOutput,
1627
+ // background subagents report through SubagentHandback. Tail-read the transcript and
1628
+ // take the LAST such tool_use as the agent's result. Returns null when absent.
1629
+ const RESULT_TOOLS = {
1630
+ StructuredOutput: formatStructuredResult,
1631
+ SubagentHandback: (input) => (typeof input.message === 'string' && input.message) || null,
1632
+ };
1633
+ const RESULT_TOOL_TEXT = Object.keys(RESULT_TOOLS).map((name) => `"${name}"`);
1634
+
1635
+ function resultFromBlock(b) {
1636
+ if (b?.type !== 'tool_use' || !b.input || typeof b.input !== 'object') return null;
1637
+ return RESULT_TOOLS[b.name]?.(b.input) ?? null;
1638
+ }
1639
+
1640
+ function extractAgentResultFromTranscript(jsonlPath) {
1630
1641
  const { openSync, readSync, closeSync } = fs;
1631
1642
  const stat = statSync(jsonlPath);
1632
1643
  const readSize = Math.min(1048576, stat.size);
@@ -1638,17 +1649,15 @@ function extractStructuredResultFromTranscript(jsonlPath) {
1638
1649
  const text = buf.toString('utf8');
1639
1650
  // Drop the leading partial line unless we read from the very start.
1640
1651
  const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
1641
- if (!clean.includes('"StructuredOutput"')) return null;
1642
- // Scan from the end for the last StructuredOutput tool call.
1652
+ if (!RESULT_TOOL_TEXT.some((t) => clean.includes(t))) return null;
1643
1653
  const lines = clean.split('\n');
1644
1654
  for (let i = lines.length - 1; i >= 0; i--) {
1645
- if (!lines[i].includes('"StructuredOutput"')) continue;
1655
+ if (!RESULT_TOOL_TEXT.some((t) => lines[i].includes(t))) continue;
1646
1656
  let obj;
1647
1657
  try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
1648
1658
  const content = obj?.message?.content;
1649
- if (!Array.isArray(content)) continue;
1650
- const block = content.find(b => b?.type === 'tool_use' && b.name === 'StructuredOutput' && b.input && typeof b.input === 'object');
1651
- if (block) return formatStructuredResult(block.input);
1659
+ const result = Array.isArray(content) && content.map(resultFromBlock).findLast(Boolean);
1660
+ if (result) return result;
1652
1661
  }
1653
1662
  } catch (_) {
1654
1663
  } finally {
@@ -1821,6 +1830,6 @@ module.exports = {
1821
1830
  findTerminatedTeammates,
1822
1831
  extractPromptFromTranscript,
1823
1832
  extractModelFromTranscript,
1824
- extractStructuredResultFromTranscript,
1833
+ extractAgentResultFromTranscript,
1825
1834
  extractTranscriptStats
1826
1835
  };
package/lib/terminal.js CHANGED
@@ -35,6 +35,16 @@ const LOW_WATER = 16 * 1024;
35
35
  const HELLO_TIMEOUT_MS = 5000;
36
36
  const BOOST_TRIES = 30;
37
37
  const MAX_PAYLOAD = 1024 * 1024;
38
+ // Each restored terminal is a full claude process, so they start one at a time.
39
+ const RESTORE_GAP_MS = 2000;
40
+ // The hub stops cck with `taskkill /f /t`, which can deliver the PTY exits to cck a moment
41
+ // before cck itself dies. The delay keeps that teardown out of the saved list.
42
+ const SAVE_DELAY_MS = 1000;
43
+ // A fork runs under its parent's id until claude names the new one, so it has no id to resume.
44
+ const RESTORABLE_MODES = new Set(['resume', 'new']);
45
+ // The size of a PTY no browser has attached to yet; the first attach resizes it.
46
+ const HEADLESS_COLS = 120;
47
+ const HEADLESS_ROWS = 32;
38
48
  // The hub hands its children PORT=0 and friends; a dev server started in the terminal
39
49
  // would otherwise bind a random port, and a nested `claude` would think it runs inside one.
40
50
  const STRIP_ENV = [
@@ -64,11 +74,12 @@ function readTerminalConfig({ argv = process.argv, env = process.env, getArgValu
64
74
  env.CCK_TERMINAL_SHELL ||
65
75
  (typeof cfg.shell === 'string' && cfg.shell) ||
66
76
  null,
67
- maxSessions: posInt(cfg.maxSessions, 20),
77
+ maxSessions: posInt(cfg.maxSessions, 30),
68
78
  fontFamily: typeof cfg.fontFamily === 'string' && cfg.fontFamily ? cfg.fontFamily : null,
69
79
  fontSize: posInt(cfg.fontSize, 13),
70
80
  scrollback: posInt(cfg.scrollback, 5000),
71
81
  noFlicker: cfg.noFlicker !== false,
82
+ restore: cfg.restore !== false,
72
83
  };
73
84
  }
74
85
 
@@ -228,6 +239,9 @@ function refuse(socket, status, reason) {
228
239
  * @param {(cmd: string) => string|null} o.which
229
240
  * @param {string} [o.token]
230
241
  * @param {() => void} [o.onChange] a terminal started, ended or moved to another session id
242
+ * @param {() => {sessions?: string[]}|null} [o.load] the saved list, read when config.restore is on
243
+ * @param {(data: {sessions: string[]}) => void} [o.save] the session ids to resume on the next start
244
+ * @param {object} [o.pty] stands in for @lydell/node-pty in tests
231
245
  */
232
246
  function createTerminalService(o) {
233
247
  const { config, net } = o;
@@ -240,6 +254,14 @@ function createTerminalService(o) {
240
254
  let Headless = null;
241
255
  let Serialize = null;
242
256
  let shell = null;
257
+ let shuttingDown = false;
258
+ let saveTimer = null;
259
+ let lastSaved = null;
260
+ const saveDelayMs = o.saveDelayMs ?? SAVE_DELAY_MS;
261
+ const restoreGapMs = o.restoreGapMs ?? RESTORE_GAP_MS;
262
+ // Read before any terminal starts, so the first save keeps the ids still waiting to restore.
263
+ const saved = config.restore ? o.load?.()?.sessions : null;
264
+ const restoreQueue = Array.isArray(saved) ? saved.filter((id) => typeof id === 'string' && UUID_RE.test(id)) : [];
243
265
 
244
266
  // Lazy and optional: a missing prebuilt or shell must hide the feature, not crash cck.
245
267
  function load() {
@@ -251,7 +273,7 @@ function createTerminalService(o) {
251
273
  return false;
252
274
  }
253
275
  try {
254
- pty = require('@lydell/node-pty');
276
+ pty = o.pty || require('@lydell/node-pty');
255
277
  ({ Terminal: Headless } = require('@xterm/headless'));
256
278
  ({ SerializeAddon: Serialize } = require('@xterm/addon-serialize'));
257
279
  const { WebSocketServer } = require('ws');
@@ -270,6 +292,47 @@ function createTerminalService(o) {
270
292
  return null;
271
293
  }
272
294
 
295
+ function changed() {
296
+ scheduleSave();
297
+ o.onChange?.();
298
+ }
299
+
300
+ function scheduleSave() {
301
+ if (!o.save || shuttingDown || saveTimer) return;
302
+ saveTimer = setTimeout(saveState, saveDelayMs);
303
+ saveTimer.unref();
304
+ }
305
+
306
+ function saveState() {
307
+ saveTimer = null;
308
+ const ids = new Set(restoreQueue);
309
+ for (const s of sessions.values()) if (!s.ended && RESTORABLE_MODES.has(s.mode)) ids.add(s.id);
310
+ const data = { sessions: [...ids] };
311
+ const json = JSON.stringify(data);
312
+ if (json === lastSaved) return;
313
+ lastSaved = json;
314
+ o.save(data);
315
+ }
316
+
317
+ // Starts `claude --resume` for each terminal open when cck last stopped, headless like
318
+ // startNew. A session that runs elsewhere or lost its transcript is dropped.
319
+ function restore() {
320
+ if (!restoreQueue.length || unavailableReason()) return;
321
+ console.log(`Restoring ${restoreQueue.length} terminal(s)`);
322
+ const next = () => {
323
+ while (restoreQueue.length && !shuttingDown) {
324
+ const id = restoreQueue.shift();
325
+ if (sessions.has(id) || o.isLiveElsewhere(id)) continue;
326
+ const r = start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
327
+ if (!r.error) break;
328
+ console.log(`Could not restore terminal ${id}: ${r.error}`);
329
+ }
330
+ scheduleSave();
331
+ if (restoreQueue.length && !shuttingDown) setTimeout(next, restoreGapMs).unref();
332
+ };
333
+ next();
334
+ }
335
+
273
336
  function send(ws, msg) {
274
337
  if (ws.readyState === 1) ws.send(JSON.stringify(msg));
275
338
  }
@@ -383,7 +446,7 @@ function createTerminalService(o) {
383
446
  clearInterval(s.boostTimer);
384
447
  if (sessions.get(s.id) === s) sessions.delete(s.id);
385
448
  o.onExit?.(s.id);
386
- if (!s.ended) o.onChange?.();
449
+ if (!s.ended) changed();
387
450
  for (const ws of s.sockets) {
388
451
  send(ws, { t: 'exit', code: exitCode, ended: s.ended });
389
452
  ws.close(1000);
@@ -392,7 +455,7 @@ function createTerminalService(o) {
392
455
  });
393
456
  sessions.set(id, s);
394
457
  priority.raiseConsoleHosts();
395
- o.onChange?.();
458
+ changed();
396
459
  return s;
397
460
  }
398
461
 
@@ -428,7 +491,7 @@ function createTerminalService(o) {
428
491
  s.id = picked;
429
492
  s.mode = 'resume';
430
493
  sessions.set(picked, s);
431
- o.onChange?.();
494
+ changed();
432
495
  for (const ws of s.sockets) send(ws, { t: 'rekey', id: picked, elsewhere });
433
496
  }, PICK_POLL_MS);
434
497
  }
@@ -554,7 +617,7 @@ function createTerminalService(o) {
554
617
  function kill(s) {
555
618
  s.ended = true;
556
619
  s.pty.kill();
557
- o.onChange?.();
620
+ changed();
558
621
  }
559
622
 
560
623
  function list() {
@@ -587,18 +650,20 @@ function createTerminalService(o) {
587
650
  function startNew(msg, extraEnv) {
588
651
  const reason = unavailableReason();
589
652
  if (reason) return { status: 403, error: reason };
590
- const r = start(crypto.randomUUID(), 'new', msg, 120, 32, extraEnv);
653
+ const r = start(crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
591
654
  return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
592
655
  }
593
656
 
594
657
  function shutdown() {
658
+ shuttingDown = true;
659
+ clearTimeout(saveTimer);
595
660
  for (const s of sessions.values()) {
596
661
  try { s.pty.kill(); } catch { /* already gone */ }
597
662
  }
598
663
  sessions.clear();
599
664
  }
600
665
 
601
- return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, shutdown, unavailableReason };
666
+ return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, restore, shutdown, unavailableReason };
602
667
  }
603
668
 
604
669
  module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.31.1",
3
+ "version": "5.0.0",
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",
@@ -52,6 +52,7 @@
52
52
  },
53
53
  "files": [
54
54
  "server.js",
55
+ "hub-app.json",
55
56
  "cli.js",
56
57
  "install.js",
57
58
  "plugin/**/*",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.15.0",
3
+ "version": "2.16.1",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
5
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -36,6 +36,18 @@
36
36
  ]
37
37
  }
38
38
  ],
39
+ "Stop": [
40
+ {
41
+ "matcher": "",
42
+ "hooks": [
43
+ {
44
+ "type": "command",
45
+ "command": "${CLAUDE_PLUGIN_ROOT}/scripts/agent-spy.sh",
46
+ "timeout": 5
47
+ }
48
+ ]
49
+ }
50
+ ],
39
51
  "TeammateIdle": [
40
52
  {
41
53
  "matcher": "",
@@ -37,10 +37,17 @@ if [ "$EVENT" = "SessionStart" ]; then
37
37
  exit 0
38
38
  fi
39
39
 
40
- # PostToolUse / non-waiting PreToolUse: clear waiting state
40
+ # Stop: the session finished its turn. The marker means unread; the board deletes it on open.
41
+ if [ "$EVENT" = "Stop" ]; then
42
+ DIR="$CCK_ACTIVITY/$SESSION_ID"
43
+ [ -d "$DIR" ] || mkdir -p "$DIR"
44
+ : > "$DIR/_stop.json"
45
+ exit 0
46
+ fi
47
+
48
+ # PostToolUse / non-waiting PreToolUse: clear waiting state, and unread since the session is working again
41
49
  if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ] && [ "$TOOL_NAME" != "ExitPlanMode" ]; }; then
42
- WFILE="$CCK_ACTIVITY/$SESSION_ID/_waiting.json"
43
- rm -f "$WFILE"
50
+ rm -f "$CCK_ACTIVITY/$SESSION_ID/_waiting.json" "$CCK_ACTIVITY/$SESSION_ID/_stop.json"
44
51
  [ "$EVENT" = "PostToolUse" ] && exit 0
45
52
  fi
46
53
 
@@ -26,6 +26,9 @@ const SERVER_INFO = path.join(CLAUDE_DIR, '.cck', 'server.json');
26
26
  // because an enqueue wakes the poll immediately.
27
27
  const WAIT_SEC = 120;
28
28
  const RETRY_MS = 15000;
29
+ // Windows sometimes fails a loopback connect with ETIMEDOUT while the board is up, so that
30
+ // error gets a few short waits before the normal one.
31
+ const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
29
32
  // `--topic dispatch` is the kanban-dispatch inbox: reports from sessions this one started.
30
33
  const TOPIC = process.argv.includes('--topic') ? process.argv[process.argv.indexOf('--topic') + 1] : null;
31
34
  // A dispatch report is a result, not an instruction, so a late attach still wants it.
@@ -64,13 +67,21 @@ async function poll(base) {
64
67
  }
65
68
 
66
69
  (async () => {
70
+ let connectRetries = 0;
67
71
  for (;;) {
68
72
  try {
69
73
  for (const line of await poll(serverUrl())) console.log(line);
70
- } catch (_) {
74
+ connectRetries = 0;
75
+ } catch (e) {
71
76
  // No board yet, or it went away. It may come back later in the session, so keep
72
77
  // waiting quietly -- a missing server is the normal case, not an error.
73
- await sleep(RETRY_MS);
78
+ const timedOut = e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
79
+ if (timedOut && connectRetries < CONNECT_RETRY_MS.length) {
80
+ await sleep(CONNECT_RETRY_MS[connectRetries++]);
81
+ } else {
82
+ connectRetries = 0;
83
+ await sleep(RETRY_MS);
84
+ }
74
85
  }
75
86
  }
76
87
  })();