claude-code-kanban 4.25.0 → 4.27.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/README.md CHANGED
@@ -51,7 +51,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
51
51
  - **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
52
52
  - **Tool stats & impact** — Per-session tool usage breakdown and file impact
53
53
  - **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
54
- - **UI approvals (opt-in)** — Allow/deny permission asks and answer questions from the board — [docs](docs/ui-approvals.md)
54
+ - **UI approvals** — Allow/deny permission asks and answer questions from the board; on by default, opt out per config dir — [docs](docs/ui-approvals.md)
55
55
  - **Agent teams** — Color-coded team members, owner filtering, member count badges
56
56
  - **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
57
57
  - **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
package/install.js CHANGED
@@ -130,12 +130,27 @@ async function runInstall({ pluginOnly = false } = {}) {
130
130
  if (!upd.ok) console.log(` ${yellow('⚠')} Marketplace refresh failed: ${upd.error}`);
131
131
 
132
132
  const inst = runCLI('claude plugin install claude-code-kanban@claude-code-kanban', ['already installed', 'already exists']);
133
+ const alreadyInstalled = inst.idempotent || /already installed/i.test(inst.output || '');
133
134
  if (inst.ok) {
134
- console.log(` ${green('✓')} ${inst.idempotent ? 'Already installed' : 'Plugin installed'}`);
135
+ console.log(` ${green('✓')} ${alreadyInstalled ? 'Already installed' : 'Plugin installed'}`);
135
136
  } else {
136
137
  console.log(` ${red('✗')} Plugin install failed: ${inst.error}`);
137
138
  failed = true;
138
139
  }
140
+
141
+ // `install` is a no-op once the plugin is present — it exits 0 and only says so
142
+ // on stdout — so a re-copied plugin keeps running from the old cached version
143
+ // until `update` pulls the new one in. That is how a config dir ends up serving
144
+ // a plugin older than the board it talks to.
145
+ if (alreadyInstalled) {
146
+ const upgrade = runCLI('claude plugin update claude-code-kanban', ['already at the latest']);
147
+ if (upgrade.ok) {
148
+ const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
149
+ console.log(` ${green('✓')} ${line ? line.replace(/^[^A-Za-z]*/, '') : 'Already at the latest version'}`);
150
+ } else {
151
+ console.log(` ${yellow('⚠')} Plugin update failed: ${upgrade.error}`);
152
+ }
153
+ }
139
154
  } else {
140
155
  console.log(` ${dim('Skipped')}`);
141
156
  }
package/lib/approvals.js CHANGED
@@ -63,20 +63,51 @@ function isDecisionFile(name) {
63
63
  // (PERMISSION_TTL_MS hides the card at 30 min, so waiting longer than the UI
64
64
  // can show the ask is strictly worse — D11); keep them in sync with
65
65
  // approval-gate.sh.
66
- const WAIT_SECONDS_DEFAULT = 30;
66
+ const WAIT_SECONDS_DEFAULT = 1800;
67
67
  const WAIT_SECONDS_MAX = 1800;
68
68
  // Small grace over the gate's own deadline so a race never 410s a live hook
69
69
  const LAPSE_GRACE_MS = 5000;
70
-
71
70
  function waitSecondsFrom(cfg) {
72
71
  const raw = cfg && cfg.waitSeconds;
73
72
  if (Number.isInteger(raw) && raw >= 0) return Math.min(raw, WAIT_SECONDS_MAX);
74
73
  return WAIT_SECONDS_DEFAULT;
75
74
  }
76
75
 
76
+ // Normalizes the `approvals` section of <CCK_DIR>/config.json. The gate is on
77
+ // by default: only an explicit `enabled: false` turns it off, and anything
78
+ // unparseable falls back to defaults — the same parse approval-gate.sh does.
79
+ function approvalsFrom(cfg) {
80
+ const a = cfg && typeof cfg.approvals === 'object' && cfg.approvals ? cfg.approvals : {};
81
+ return {
82
+ enabled: a.enabled !== false,
83
+ mode: a.mode === 'permission' ? 'permission' : 'permission+question',
84
+ waitSeconds: waitSecondsFrom(a),
85
+ };
86
+ }
87
+
88
+ // Whether the hook waits for a board decision on a marker of this kind
89
+ function isKindGated(kind, approvals) {
90
+ if (!approvals.enabled) return false;
91
+ if (kind === 'question') return approvals.mode === 'permission+question';
92
+ return true;
93
+ }
94
+
95
+ // Why the board may not answer this marker, or null when it may. Shared by the
96
+ // session list (which hides the buttons) and the respond route (which refuses),
97
+ // so the two can never disagree.
98
+ function boardRefusal(marker, approvals, now = Date.now()) {
99
+ if (!isKindGated(marker.kind, approvals)) {
100
+ return { status: 410, error: 'UI approvals are off for this ask — answer it in the terminal' };
101
+ }
102
+ if (isLapsed(marker.timestamp, approvals.waitSeconds * 1000, now)) {
103
+ return { status: 410, error: 'Ask lapsed — answer it in the terminal' };
104
+ }
105
+ return null;
106
+ }
107
+
77
108
  function isLapsed(timestamp, waitMs, now = Date.now()) {
78
109
  if (!timestamp) return true;
79
110
  return now - new Date(timestamp).getTime() > waitMs + LAPSE_GRACE_MS;
80
111
  }
81
112
 
82
- module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed };
113
+ module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, approvalsFrom, isKindGated, boardRefusal, isLapsed };
@@ -235,4 +235,4 @@ async function inlineHtmlAssets(html, absHtmlPath) {
235
235
  return { html: out, bytes: ctx.spent, skipped: ctx.skipped };
236
236
  }
237
237
 
238
- module.exports = { inlineHtmlAssets, MAX_ASSET_BYTES };
238
+ module.exports = { inlineHtmlAssets, MIME_BY_EXT, MAX_ASSET_BYTES };
package/lib/parsers.js CHANGED
@@ -185,6 +185,14 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
185
185
  label = base + formatTaskUsage(notif.usage);
186
186
  safeText = notif.result || base;
187
187
  }
188
+ // A subagent, teammate or peer session handing work back through SendMessage is
189
+ // also model output, not user input. Callers on the `origin` path pass the parts
190
+ // ready-made; the text paths (type:'user', queued enqueue) parse the envelope.
191
+ const agentMsg = extras?.agent || (notif ? null : parseAgentMessage(safeText));
192
+ if (agentMsg) {
193
+ label = agentMsg.label;
194
+ safeText = agentMsg.body;
195
+ }
188
196
  const truncated = safeText.length > USER_TEXT_MAX;
189
197
  const msg = {
190
198
  type: 'user',
@@ -200,12 +208,26 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
200
208
  if (notif.taskId) msg.taskId = notif.taskId;
201
209
  if (notif.status) msg.taskStatus = notif.status;
202
210
  }
211
+ if (agentMsg) {
212
+ msg.agentMessage = true;
213
+ if (agentMsg.from) msg.agentFrom = agentMsg.from;
214
+ }
203
215
  if (extras) {
204
216
  if (extras.uuid) msg.uuid = extras.uuid;
205
217
  if (extras.images && extras.images.length) msg.images = extras.images;
206
218
  if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
207
219
  if (extras.queued) msg.queued = true;
208
220
  }
221
+ // One agent message reaches the log twice: the queued enqueue record and the
222
+ // delivered one right after it. Keep the first and drop the twin here, so no
223
+ // renderer has to know about the double write.
224
+ if (agentMsg) {
225
+ const prev = messages[messages.length - 1];
226
+ if (prev?.agentMessage && (prev.agentFrom || null) === (msg.agentFrom || null) && prev.text === msg.text) {
227
+ if (!msg.queued) delete prev.queued;
228
+ return;
229
+ }
230
+ }
209
231
  messages.push(msg);
210
232
  }
211
233
 
@@ -587,6 +609,43 @@ function parseTaskNotification(text) {
587
609
  };
588
610
  }
589
611
 
612
+ // A message another agent sent into this session (a subagent hand-back, a teammate
613
+ // or a peer session using SendMessage) is model output, not user input. Turn the
614
+ // sender and the raw body into the chip label plus the body alone, so it never
615
+ // renders under a person icon.
616
+ function agentMessageParts(from, rawBody, handback) {
617
+ let body = (rawBody || '').trimEnd();
618
+ // A hand-back frames the report with the harness note that it carries no user
619
+ // authority, and indents every line of the report by two spaces so a forged
620
+ // frame at column zero is impossible. Strip the frame and that indent.
621
+ const framed = body.match(/^\[Subagent hand-back\][\s\S]*?report follows:\n([\s\S]*)$/);
622
+ if (framed) body = framed[1].replace(/^ {2}/gm, '').trim();
623
+ const handBack = !!framed || !!handback;
624
+ const kind = handBack ? 'Subagent hand-back' : 'Agent message';
625
+ // Harness agent ids are long hex; a named sender ("code-review") is shown whole.
626
+ const short = from && /^[0-9a-f]{12,}$/i.test(from) ? from.slice(0, 8) : from;
627
+ return { from: from || null, handBack, label: short ? `${kind} · ${short}` : kind, body };
628
+ }
629
+
630
+ // The text delivery paths carry the body inside an <agent-message from="..."> envelope,
631
+ // with the harness notes outside it. The envelope must open the message (after the
632
+ // harness preamble, if any): prose that merely quotes the tag is a user message.
633
+ // The body is unescaped agent text that can quote the envelope it describes, so the
634
+ // real closing tag is the last one.
635
+ function parseAgentMessage(text) {
636
+ if (typeof text !== 'string' || !text.includes('<agent-message')) return null;
637
+ const start = text.indexOf('<agent-message');
638
+ const preamble = text.slice(0, start).trim();
639
+ if (preamble && !/^Another Claude session sent a message[^\n]*:$/.test(preamble)) return null;
640
+ const gt = text.indexOf('>', start);
641
+ const end = text.lastIndexOf('</agent-message>');
642
+ if (gt === -1 || end < gt) return null;
643
+ const from = (text.slice(start, gt).match(/from="([^"]*)"/) || [])[1] || null;
644
+ let bodyStart = gt + 1;
645
+ while (text[bodyStart] === '\n') bodyStart++;
646
+ return agentMessageParts(from, text.slice(bodyStart, end));
647
+ }
648
+
590
649
  function getSystemMessageLabel(text) {
591
650
  const taskMatch = text.match(/<summary>([^<]+)<\/summary>/);
592
651
  if (taskMatch) return taskMatch[1].trim();
@@ -801,6 +860,15 @@ function readRecentMessages(jsonlPath, limit = 10) {
801
860
  messages.push(msg);
802
861
  }
803
862
  }
863
+ } else if (obj.type === 'user' && obj.origin?.kind === 'peer') {
864
+ // Claude Code >= 2.1 files the delivered copy as isMeta (dropped by the
865
+ // branch below) and repeats the envelope's fields on `origin`. That
866
+ // record is always written; the queued enqueue is not, so read it from
867
+ // here and let pushUserMessage drop whichever twin arrives second.
868
+ pushUserMessage(messages, '', obj.timestamp, null, {
869
+ uuid: obj.uuid,
870
+ agent: agentMessageParts(obj.origin.from, obj.origin.body, obj.origin.handback)
871
+ });
804
872
  } else if (obj.type === 'user' && obj.message?.role === 'user' && !obj.isMeta) {
805
873
  if (obj.isCompactSummary) {
806
874
  // Surface the compaction summary as a single expandable "Compacted"
@@ -1296,6 +1364,112 @@ function buildAgentProgressMap(jsonlPath) {
1296
1364
  return buildSessionDigest(jsonlPath).progressMap;
1297
1365
  }
1298
1366
 
1367
+ const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
1368
+
1369
+ function toolResultText(content) {
1370
+ if (typeof content === 'string') return content;
1371
+ if (!Array.isArray(content)) return '';
1372
+ return content.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n');
1373
+ }
1374
+
1375
+ // Two kinds of line record a published artifact: the `frame-link` entry the
1376
+ // viewer writes, which carries the title, and the result of an Artifact publish,
1377
+ // which is there even when the artifact was never opened in a frame. Keyed by URL,
1378
+ // so the republishes of one page collapse into a single entry. A result counts
1379
+ // only when its tool_use_id belongs to a publish call — an artifact URL quoted
1380
+ // back by a grep or a file read would otherwise register another session's page as
1381
+ // this session's own, and a `read` of someone else's artifact is not a publish.
1382
+ function readArtifactLinks(jsonlPath) {
1383
+ const byUrl = new Map();
1384
+ const publishedFileByToolUseId = new Map();
1385
+ const upsert = (url, ts) => {
1386
+ let a = byUrl.get(url);
1387
+ if (!a) {
1388
+ a = { url, title: null, path: null, firstSeen: ts || null };
1389
+ byUrl.set(url, a);
1390
+ }
1391
+ if (ts && (!a.firstSeen || ts < a.firstSeen)) a.firstSeen = ts;
1392
+ return a;
1393
+ };
1394
+ try {
1395
+ const content = readFileSync(jsonlPath, 'utf8');
1396
+ for (const line of content.split('\n')) {
1397
+ if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
1398
+ continue;
1399
+ let obj;
1400
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1401
+ if (obj.type === 'frame-link' && obj.frameUrl) {
1402
+ const a = upsert(obj.frameUrl, obj.timestamp);
1403
+ if (obj.title) a.title = obj.title;
1404
+ if (obj.path) a.path = obj.path;
1405
+ continue;
1406
+ }
1407
+ const blocks = obj?.message?.content;
1408
+ if (!Array.isArray(blocks)) continue;
1409
+ for (const b of blocks) {
1410
+ if (b?.type === 'tool_use') {
1411
+ const input = b.name === 'Artifact' ? b.input || {} : null;
1412
+ // The publish action is the default one, so it is spelled by the file it
1413
+ // takes rather than by a name.
1414
+ if (b.id && input?.file_path && (!input.action || input.action === 'publish')) {
1415
+ publishedFileByToolUseId.set(b.id, input.file_path);
1416
+ }
1417
+ continue;
1418
+ }
1419
+ if (b?.type !== 'tool_result' || !publishedFileByToolUseId.has(b.tool_use_id)) continue;
1420
+ const m = toolResultText(b.content).match(ARTIFACT_URL_RE);
1421
+ if (!m) continue;
1422
+ const a = upsert(m[0], obj.timestamp);
1423
+ if (!a.path) a.path = publishedFileByToolUseId.get(b.tool_use_id);
1424
+ }
1425
+ }
1426
+ } catch (_) {}
1427
+ return [...byUrl.values()].sort((x, y) => (x.firstSeen < y.firstSeen ? -1 : x.firstSeen > y.firstSeen ? 1 : 0));
1428
+ }
1429
+
1430
+ const SCRATCH_NEW_RE = /\bscratch new\b/;
1431
+ // `scratch new` reports where it put the pad; the two spaces are the CLI's own column
1432
+ // padding, which is what makes the prefilter below narrow enough to be worth having.
1433
+ const SCRATCH_MANIFEST_RE = /^\s*manifest\s*:\s*(.+?)\s*$/m;
1434
+ // The name must sit immediately after `new`, and the bare-word branch must refuse a
1435
+ // leading `-`, so that a flag or a flag's value cannot be read as the name.
1436
+ const SCRATCH_NAME_RE = /\bscratch\s+new\s+(?:"([^"]+)"|'([^']+)'|(?!-)(\S+))/;
1437
+
1438
+ // The `scratch new` calls in a transcript, `{ ts, path, name }` per call. `path` is null
1439
+ // whenever the command piped the CLI's output away — the common case, not an edge one:
1440
+ // the manifest is the 4th of ~13 printed lines, so any `| tail -N` cuts it off. `name`
1441
+ // stands in because it is what the manifest stores as its own `name`; the path is not
1442
+ // recovered from the command, which would mean tracking the `cd` and a relative `--dir`.
1443
+ function readScratchpadCreations(jsonlPath) {
1444
+ const calls = new Map();
1445
+ try {
1446
+ const content = readFileSync(jsonlPath, 'utf8');
1447
+ for (const line of content.split('\n')) {
1448
+ // Substring reject before JSON.parse: transcripts reach tens of megabytes and
1449
+ // only a handful of lines can ever match.
1450
+ const isCall = line.includes('scratch new');
1451
+ if (!isCall && !line.includes('manifest :')) continue;
1452
+ let obj;
1453
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1454
+ const blocks = obj?.message?.content;
1455
+ if (!Array.isArray(blocks)) continue;
1456
+ const ts = Date.parse(obj.timestamp);
1457
+ for (const b of blocks) {
1458
+ if (b?.type === 'tool_use' && b.name === 'Bash' && SCRATCH_NEW_RE.test(b.input?.command || '')) {
1459
+ if (b.id && Number.isFinite(ts)) {
1460
+ const named = SCRATCH_NAME_RE.exec(b.input.command);
1461
+ calls.set(b.id, { ts, path: null, name: named?.slice(1).find(Boolean) ?? null });
1462
+ }
1463
+ } else if (b?.type === 'tool_result' && calls.has(b.tool_use_id)) {
1464
+ const m = SCRATCH_MANIFEST_RE.exec(toolResultText(b.content));
1465
+ if (m) calls.get(b.tool_use_id).path = m[1];
1466
+ }
1467
+ }
1468
+ }
1469
+ } catch (_) {}
1470
+ return [...calls.values()];
1471
+ }
1472
+
1299
1473
  function readCompactSummaries(jsonlPath) {
1300
1474
  const results = [];
1301
1475
  // Inline format: newer Claude Code stores the summary directly in the parent
@@ -1621,6 +1795,7 @@ module.exports = {
1621
1795
  parseSessionsIndex,
1622
1796
  parseJsonlLine,
1623
1797
  parseTaskNotification,
1798
+ parseAgentMessage,
1624
1799
  getSystemMessageLabel,
1625
1800
  readSessionInfoFromJsonl,
1626
1801
  readRecentMessages,
@@ -1634,6 +1809,8 @@ module.exports = {
1634
1809
  buildAgentProgressMap,
1635
1810
  buildSessionDigest,
1636
1811
  readCompactSummaries,
1812
+ readArtifactLinks,
1813
+ readScratchpadCreations,
1637
1814
  findTerminatedTeammates,
1638
1815
  extractPromptFromTranscript,
1639
1816
  extractModelFromTranscript,
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs').promises;
4
+ const path = require('node:path');
5
+ const { isContained } = require('./contain');
6
+
7
+ // Resolves a folder inside a session's scratchpad dir from the path the client sends
8
+ // back — the absolute path a listing handed out, or one relative to the root. Returns
9
+ // null when it escapes the root (`..`, another dir, a symlink out), so the endpoint
10
+ // never lists anything the root did not hand out.
11
+ function resolveScratchSubdir(root, sub) {
12
+ if (!sub) return root;
13
+ if (typeof sub !== 'string') return null;
14
+ const full = path.resolve(root, sub);
15
+ return isContained(full, root) ? full : null;
16
+ }
17
+
18
+ // One level of a scratchpad dir, newest first whatever the kind. Not recursive on
19
+ // purpose: sessions drop clones and build output in here, and the client asks for a
20
+ // folder's children only when the user opens it.
21
+ async function listScratchDir(dir) {
22
+ let entries;
23
+ try {
24
+ entries = await fs.readdir(dir, { withFileTypes: true });
25
+ } catch (_) {
26
+ return [];
27
+ }
28
+ const rows = await Promise.all(
29
+ entries
30
+ .filter((entry) => entry.isFile() || entry.isDirectory())
31
+ .map(async (entry) => {
32
+ const full = path.join(dir, entry.name);
33
+ try {
34
+ const { mtimeMs } = await fs.stat(full);
35
+ return { name: entry.name, path: full, kind: entry.isDirectory() ? 'dir' : 'file', mtimeMs };
36
+ } catch (_) {
37
+ return null;
38
+ }
39
+ }),
40
+ );
41
+ return rows
42
+ .filter(Boolean)
43
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
44
+ .map(({ mtimeMs, ...row }) => ({ ...row, modifiedAt: new Date(mtimeMs).toISOString() }));
45
+ }
46
+
47
+ module.exports = { resolveScratchSubdir, listScratchDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.25.0",
3
+ "version": "4.27.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
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
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"
@@ -1,13 +1,13 @@
1
1
  #!/bin/bash
2
2
  # Blocking approval gate: lets the cck board answer a permission ask or an
3
3
  # AskUserQuestion. Always writes the _waiting.json marker first (badge behavior
4
- # is unchanged when the feature is off), then — only when explicitly enabled and
5
- # the board's server is alive — waits for a decision file written by the server.
4
+ # is unchanged when the feature is off), then — unless config.json opts out, and
5
+ # only while the board's server is alive — waits for a decision file written by the server.
6
6
  #
7
7
  # Contract (_plans/cck-ui-approvals/decisions.md), rooted at <CLAUDE_CONFIG_DIR or ~/.claude>/.cck:
8
8
  # marker agent-activity/<sid>/_waiting.json (D8: + id, cwd, permissionSuggestions)
9
9
  # decision agent-activity/<sid>/_decision-<id>.json (server writes it, Phase 3)
10
- # config approvals.json {enabled, mode, waitSeconds} (D2: fail-open when absent)
10
+ # config config.json {approvals: {enabled, mode, waitSeconds}} (on by default; absent = defaults)
11
11
  # liveness server.json {port, pid} (D1: a dead board costs nothing)
12
12
  #
13
13
  # First writer wins (D5): a terminal answer deletes the marker via PostToolUse
@@ -61,37 +61,58 @@ echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" --arg id "$REQ_ID" '{
61
61
  # Every exit below leaves the marker in place for the badge; agent-spy.sh's
62
62
  # PostToolUse (or the server's TTL) retires it, exactly as before this feature.
63
63
 
64
- CONFIG="$CCK_DIR/approvals.json"
65
- [ -f "$CONFIG" ] || exit 0
66
- ENABLED=""
67
- eval "$(jq -r '
68
- @sh "ENABLED=\(.enabled // false)",
69
- @sh "MODE=\(.mode // "permission")",
70
- @sh "WAIT_SECONDS=\(.waitSeconds // 30)"
71
- ' < "$CONFIG" 2>/dev/null)"
72
- [ "$ENABLED" = "true" ] || exit 0
64
+ # Defaults mirror lib/approvals.js — keep in sync. A missing or unparseable
65
+ # config.json means defaults, i.e. the gate is on.
66
+ ENABLED="true"
67
+ MODE="permission+question"
68
+ WAIT_SECONDS=""
69
+ CONFIG="$CCK_DIR/config.json"
70
+ if [ -f "$CONFIG" ]; then
71
+ eval "$(jq -r '
72
+ @sh "ENABLED=\(if .approvals.enabled == false then "false" else "true" end)",
73
+ @sh "MODE=\(.approvals.mode // "permission+question")",
74
+ @sh "WAIT_SECONDS=\(.approvals.waitSeconds // "")"
75
+ ' < "$CONFIG" 2>/dev/null)"
76
+ fi
77
+ [ "$ENABLED" = "false" ] && exit 0
73
78
 
74
79
  if [ "$KIND" = "question" ] && [ "$MODE" != "permission+question" ]; then
75
80
  exit 0
76
81
  fi
77
82
 
78
- case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=30 ;; esac
83
+ case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=1800 ;; esac
79
84
  # PERMISSION_TTL_MS hides the card at 30 min — waiting longer than the UI can
80
85
  # show the ask is strictly worse than giving up (D11)
81
86
  [ "$WAIT_SECONDS" -gt 1800 ] && WAIT_SECONDS=1800
82
87
 
83
88
  SERVER_INFO="$CCK_DIR/server.json"
84
- [ -f "$SERVER_INFO" ] || exit 0
85
- SERVER_PORT=$(jq -r '.port // empty' < "$SERVER_INFO" 2>/dev/null)
86
- [ -n "$SERVER_PORT" ] || exit 0
89
+
87
90
  # A TCP connect beats a pid probe: it proves the board is actually serving, and
88
91
  # it works in the stripped environment Claude Code spawns hooks into, where
89
- # kill -0 cannot see native Windows pids and ps may be missing from PATH
90
- (: < "/dev/tcp/127.0.0.1/$SERVER_PORT") 2>/dev/null || exit 0
92
+ # kill -0 cannot see native Windows pids and ps may be missing from PATH. The
93
+ # port is re-read on every probe, not cached: under the hub every sub-app binds
94
+ # an ephemeral port, so a board that restarts mid-wait comes back on a new one.
95
+ # $(<file) and =~ keep the probe free of jq and cat spawns (~280 ms each here).
96
+ board_alive() {
97
+ local raw port
98
+ raw=$(<"$SERVER_INFO") 2>/dev/null || return 1
99
+ [[ $raw =~ \"port\"[[:space:]]*:[[:space:]]*([0-9]+) ]] || return 1
100
+ port=${BASH_REMATCH[1]}
101
+ (: < "/dev/tcp/127.0.0.1/$port") 2>/dev/null
102
+ }
91
103
 
92
104
  DECISION="$DIR/_decision-$REQ_ID.json"
93
105
  # EPOCHSECONDS (bash 5) keeps the poll loop free of `date` spawns
94
106
  DEADLINE=$((EPOCHSECONDS + WAIT_SECONDS))
107
+ # Probing once up front made one unreachable moment disarm the whole ask, so a
108
+ # board restarting on a new port (every hub-spawned sub-app binds an ephemeral
109
+ # one) took the ask with it. Probe on a cadence instead and only give up once
110
+ # the board has been gone for the whole grace. The terminal prompt stays live
111
+ # throughout either way.
112
+ BOARD_PROBE_SECONDS=5
113
+ BOARD_GRACE_SECONDS=15
114
+ NEXT_PROBE=0
115
+ UNREACHABLE_SINCE=0
95
116
 
96
117
  while :; do
97
118
  if [ -f "$DECISION" ]; then
@@ -136,6 +157,20 @@ while :; do
136
157
  IFS= read -r CUR_MARKER < "$MARKER" 2>/dev/null || exit 0
137
158
  case "$CUR_MARKER" in *"\"id\":\"$REQ_ID\""*) ;; *) exit 0 ;; esac
138
159
 
160
+ if [ "$EPOCHSECONDS" -ge "$NEXT_PROBE" ]; then
161
+ NEXT_PROBE=$((EPOCHSECONDS + BOARD_PROBE_SECONDS))
162
+ # No beacon at all = no board on this config dir, now or a moment ago: give
163
+ # up at once, exactly as before. A beacon whose port is closed is the
164
+ # restart window instead, so that one gets the grace.
165
+ [ -f "$SERVER_INFO" ] || exit 0
166
+ if board_alive; then
167
+ UNREACHABLE_SINCE=0
168
+ else
169
+ [ "$UNREACHABLE_SINCE" -eq 0 ] && UNREACHABLE_SINCE=$EPOCHSECONDS
170
+ [ $((EPOCHSECONDS - UNREACHABLE_SINCE)) -ge "$BOARD_GRACE_SECONDS" ] && exit 0
171
+ fi
172
+ fi
173
+
139
174
  [ "$EPOCHSECONDS" -ge "$DEADLINE" ] && exit 0
140
175
  sleep 0.5
141
176
  done