claude-code-kanban 4.24.0 → 4.26.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
@@ -22,6 +22,8 @@ npx claude-code-kanban --install
22
22
 
23
23
  Non-destructive — existing settings in `~/.claude/settings.json` are preserved. Uninstall anytime with `npx claude-code-kanban --uninstall`.
24
24
 
25
+ Using another Claude config dir? Pass `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to both `--install` and `--uninstall`. The plugin, hooks and statusLine land in that dir, and the hooks write their data under it when Claude Code runs with the same `CLAUDE_CONFIG_DIR`.
26
+
25
27
  ### 2. Start the dashboard
26
28
 
27
29
  ```bash
@@ -49,7 +51,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
49
51
  - **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
50
52
  - **Tool stats & impact** — Per-session tool usage breakdown and file impact
51
53
  - **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
52
- - **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)
53
55
  - **Agent teams** — Color-coded team members, owner filtering, member count badges
54
56
  - **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
55
57
  - **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
@@ -84,7 +86,7 @@ The script pipes through, so your existing statusline keeps working.
84
86
  ```bash
85
87
  PORT=8080 npx claude-code-kanban # Custom port (falls back if busy)
86
88
  npx claude-code-kanban --open # Auto-open browser
87
- npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir
89
+ npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir (or CLAUDE_CONFIG_DIR)
88
90
  ```
89
91
 
90
92
  Global install: `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
package/cli.js CHANGED
@@ -155,7 +155,7 @@ function printTopHelp() {
155
155
  console.log(' --version, -v Print version and exit');
156
156
  console.log('\nServer mode (no subcommand):');
157
157
  console.log(' --port <n> Port to listen on (default 3541)');
158
- console.log(' --dir <path> Override Claude config dir (default ~/.claude)');
158
+ console.log(' --dir <path> Override Claude config dir (default ~/.claude); also targets --install/--uninstall');
159
159
  console.log(' --open Open browser on start');
160
160
  console.log(' --install, --uninstall Install or remove the plugin, context spy, and statusline');
161
161
  console.log(' --plugin-only With --install: refresh only the plugin, skip context spy and statusline');
package/install.js CHANGED
@@ -5,15 +5,19 @@ const path = require('path');
5
5
  const os = require('os');
6
6
  const readline = require('readline');
7
7
  const { execSync } = require('child_process');
8
+ const { getClaudeDir, claudeCliEnv, displayPath } = require('./lib/claude-dir');
8
9
 
9
- const CLAUDE_DIR = path.join(os.homedir(), '.claude');
10
+ const CLAUDE_DIR = getClaudeDir();
11
+ const CLI_ENV = claudeCliEnv(CLAUDE_DIR);
10
12
  const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
11
13
  const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
12
14
  const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
13
15
  const PLUGIN_SRC = path.join(__dirname, 'plugin');
14
16
  const PLUGIN_DEST = path.join(CCK_DIR, 'plugin');
15
- const CTX_SCRIPT_SRC = path.join(PLUGIN_SRC, 'plugins', 'claude-code-kanban', 'scripts', 'context-status.sh');
16
- const CTX_SCRIPT_DEST = path.join(HOOKS_DIR, 'context-status.sh');
17
+ const CTX_SCRIPT_NAME = 'context-status.sh';
18
+ const CTX_SCRIPT_SRC = path.join(PLUGIN_SRC, 'plugins', 'claude-code-kanban', 'scripts', CTX_SCRIPT_NAME);
19
+ const CTX_SCRIPT_DEST = path.join(HOOKS_DIR, CTX_SCRIPT_NAME);
20
+ const CTX_COMMAND = displayPath(CTX_SCRIPT_DEST);
17
21
 
18
22
  // ANSI helpers
19
23
  const green = s => `\x1b[32m${s}\x1b[0m`;
@@ -34,7 +38,7 @@ function prompt(question) {
34
38
 
35
39
  function runCLI(cmd, okPatterns = []) {
36
40
  try {
37
- const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
41
+ const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env: CLI_ENV }).trim();
38
42
  return { ok: true, output: out };
39
43
  } catch (e) {
40
44
  const stderr = e.stderr?.trim() || e.message;
@@ -78,6 +82,7 @@ function copyDirSync(src, dest) {
78
82
 
79
83
  async function runInstall({ pluginOnly = false } = {}) {
80
84
  console.log(`\n ${bold('claude-code-kanban')} — ${pluginOnly ? 'Plugin installer' : 'Plugin & StatusLine installer'}\n`);
85
+ console.log(` Claude config dir: ${dim(displayPath(CLAUDE_DIR))}\n`);
81
86
  let failed = false;
82
87
 
83
88
  // 1. Check prerequisites
@@ -102,7 +107,7 @@ async function runInstall({ pluginOnly = false } = {}) {
102
107
  // 2. Copy plugin to stable location & register marketplace
103
108
  console.log(`\n Plugin: ${dim(PLUGIN_DEST)}`);
104
109
  if (pluginOnly || await prompt(` Install claude-code-kanban plugin? [Y/n] `)) {
105
- process.stdout.write(' Copying plugin to ~/.claude/.cck/plugin... ');
110
+ process.stdout.write(` Copying plugin to ${displayPath(PLUGIN_DEST)}... `);
106
111
  try {
107
112
  clearFilesRecursive(PLUGIN_DEST);
108
113
  copyDirSync(PLUGIN_SRC, PLUGIN_DEST);
@@ -125,12 +130,27 @@ async function runInstall({ pluginOnly = false } = {}) {
125
130
  if (!upd.ok) console.log(` ${yellow('⚠')} Marketplace refresh failed: ${upd.error}`);
126
131
 
127
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 || '');
128
134
  if (inst.ok) {
129
- console.log(` ${green('✓')} ${inst.idempotent ? 'Already installed' : 'Plugin installed'}`);
135
+ console.log(` ${green('✓')} ${alreadyInstalled ? 'Already installed' : 'Plugin installed'}`);
130
136
  } else {
131
137
  console.log(` ${red('✗')} Plugin install failed: ${inst.error}`);
132
138
  failed = true;
133
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
+ }
134
154
  } else {
135
155
  console.log(` ${dim('Skipped')}`);
136
156
  }
@@ -178,8 +198,7 @@ async function runInstall({ pluginOnly = false } = {}) {
178
198
  return;
179
199
  }
180
200
 
181
- const CTX_COMMAND = '~/.claude/hooks/context-status.sh';
182
- const hasCtx = settings.statusLine?.command?.includes('context-status.sh');
201
+ const hasCtx = settings.statusLine?.command?.includes(CTX_SCRIPT_NAME);
183
202
  if (hasCtx) {
184
203
  console.log(`\n StatusLine: ${green('✓')} Already configured`);
185
204
  } else if (!settings.statusLine) {
@@ -218,6 +237,7 @@ function printSummary(failed = false) {
218
237
 
219
238
  async function runUninstall() {
220
239
  console.log(`\n ${bold('claude-code-kanban')} — Uninstaller\n`);
240
+ console.log(` Claude config dir: ${dim(displayPath(CLAUDE_DIR))}\n`);
221
241
 
222
242
  // 1. Uninstall plugin via Claude CLI
223
243
  process.stdout.write(' Removing plugin... ');
@@ -260,9 +280,9 @@ async function runUninstall() {
260
280
  let changed = false;
261
281
 
262
282
  // Strip context-status.sh from statusLine
263
- if (settings.statusLine?.command?.includes('context-status.sh')) {
283
+ if (settings.statusLine?.command?.includes(CTX_SCRIPT_NAME)) {
264
284
  const cmd = settings.statusLine.command;
265
- const stripped = cmd.replace(/~\/\.claude\/hooks\/context-status\.sh\s*\|\s*/, '').trim();
285
+ const stripped = cmd.replace(new RegExp(`\\S*${CTX_SCRIPT_NAME.replace('.', '\\.')}\\s*\\|\\s*`), '').trim();
266
286
  if (stripped && stripped !== cmd) {
267
287
  settings.statusLine.command = stripped;
268
288
  console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
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 };
@@ -0,0 +1,53 @@
1
+ const crypto = require('node:crypto');
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+
5
+ const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
6
+
7
+ // --name <value> | --name=<value>; null when absent.
8
+ function getArgValue(name) {
9
+ const argv = process.argv;
10
+ const idx = argv.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
11
+ if (idx === -1) return null;
12
+ const arg = argv[idx];
13
+ if (arg.includes('=')) return arg.split('=').slice(1).join('=');
14
+ return argv[idx + 1] || null;
15
+ }
16
+
17
+ function expandHome(dir) {
18
+ return dir.replace(/^~/, os.homedir());
19
+ }
20
+
21
+ // --dir | CLAUDE_CONFIG_DIR | CLAUDE_DIR | ~/.claude
22
+ function getClaudeDir() {
23
+ const dir = getArgValue('dir') || process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR;
24
+ return dir ? expandHome(dir) : DEFAULT_CLAUDE_DIR;
25
+ }
26
+
27
+ function isDefaultClaudeDir(dir) {
28
+ return path.resolve(dir) === path.resolve(DEFAULT_CLAUDE_DIR);
29
+ }
30
+
31
+ // localStorage is per browser origin, so every config dir served from the same port would
32
+ // otherwise share last view, collapsed groups and sidebar layout. The default dir keeps the
33
+ // bare keys; any other dir gets a prefix derived from its resolved path.
34
+ function storageNamespace(dir) {
35
+ if (isDefaultClaudeDir(dir)) return null;
36
+ return `cfg-${crypto.createHash('sha1').update(path.resolve(dir)).digest('hex').slice(0, 8)}`;
37
+ }
38
+
39
+ // Env for spawning the claude CLI. Claude Code keeps .claude.json beside ~/.claude when
40
+ // CLAUDE_CONFIG_DIR is unset but inside the dir when it is set, so the default dir must
41
+ // stay unset rather than be spelled out.
42
+ function claudeCliEnv(dir) {
43
+ return isDefaultClaudeDir(dir) ? process.env : { ...process.env, CLAUDE_CONFIG_DIR: dir };
44
+ }
45
+
46
+ // Home-relative, forward-slash form for messages and shell commands.
47
+ function displayPath(p) {
48
+ const home = os.homedir();
49
+ const rel = p.startsWith(home) ? `~${p.slice(home.length)}` : p;
50
+ return rel.split(path.sep).join('/');
51
+ }
52
+
53
+ module.exports = { getArgValue, getClaudeDir, claudeCliEnv, displayPath, storageNamespace };
@@ -221,6 +221,7 @@ function execNoShell(cmd, args, opts = {}) {
221
221
  let argv = args;
222
222
  const spawnOpts = {
223
223
  cwd: opts.cwd || undefined,
224
+ env: opts.env,
224
225
  timeout: opts.timeout,
225
226
  windowsHide: true,
226
227
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -258,7 +259,9 @@ function execNoShell(cmd, args, opts = {}) {
258
259
  child.on('error', reject);
259
260
  child.on('close', (code, signal) => {
260
261
  if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
261
- if (code !== 0) return reject(new Error(stderr.trim() || `Command exited with code ${code}`));
262
+ // The claude CLI reports its own failures on stdout, so stderr alone is
263
+ // often empty and the caller would surface only the exit code.
264
+ if (code !== 0) return reject(new Error(stderr.trim() || stdout.trim() || `Command exited with code ${code}`));
262
265
  resolve({ stdout, stderr });
263
266
  });
264
267
  });
package/lib/parsers.js CHANGED
@@ -303,14 +303,54 @@ function extractGoalFromLine(data) {
303
303
  if (data.type === 'attachment' && data.attachment?.type === 'goal_status') {
304
304
  return data.attachment.met ? null : { condition: data.attachment.condition || '' };
305
305
  }
306
- if (data.type === 'user' && typeof data.message?.content === 'string'
307
- && data.message.content.includes('<command-name>/goal</command-name>')
308
- && /<command-args>\s*clear/.test(data.message.content)) {
306
+ // `/goal clear` is a type:system local_command line carrying the command
307
+ // markup in `content`; a user-typed command line carries it in message.content.
308
+ const content = data.content ?? data.message?.content;
309
+ if (typeof content === 'string' && content.includes(GOAL_COMMAND_TAG) && /<command-args>\s*clear/.test(content)) {
309
310
  return null;
310
311
  }
311
312
  return undefined;
312
313
  }
313
314
 
315
+ const GOAL_COMMAND_TAG = '<command-name>/goal</command-name>';
316
+ const GOAL_MARKERS = ['"goal_status"', GOAL_COMMAND_TAG].map((m) => Buffer.from(m));
317
+ const SCAN_CHUNK = 65536;
318
+ const scanBuf = Buffer.allocUnsafe(SCAN_CHUNK);
319
+
320
+ // Streams [from, to) and calls onLine for the complete lines of every chunk
321
+ // that contains a marker. Chunks without one are never decoded, which keeps a
322
+ // full pass close to raw read speed (measured ~4x cheaper than decode+split).
323
+ function scanMarkedLines(fd, from, to, markers, onLine) {
324
+ let carry = Buffer.alloc(0);
325
+ let offset = from;
326
+ while (offset < to) {
327
+ const n = fs.readSync(fd, scanBuf, 0, Math.min(SCAN_CHUNK, to - offset), offset);
328
+ if (n === 0) break;
329
+ offset += n;
330
+ const view = carry.length ? Buffer.concat([carry, scanBuf.subarray(0, n)]) : scanBuf.subarray(0, n);
331
+ const cut = view.lastIndexOf(0x0a) + 1;
332
+ const body = view.subarray(0, cut);
333
+ carry = Buffer.from(view.subarray(cut));
334
+ if (markers.some((m) => body.includes(m))) for (const line of body.toString('utf8').split('\n')) onLine(line);
335
+ }
336
+ if (carry.length && markers.some((m) => carry.includes(m))) onLine(carry.toString('utf8'));
337
+ }
338
+
339
+ // Goal events are rare and can sit anywhere in a long JSONL, outside both the
340
+ // head and tail windows. Returns the last goal update in [from, to), or
341
+ // undefined when there is none.
342
+ function scanGoalInRange(fd, from, to) {
343
+ let latest;
344
+ scanMarkedLines(fd, from, to, GOAL_MARKERS, (line) => {
345
+ if (!line.includes('"goal_status"') && !line.includes(GOAL_COMMAND_TAG)) return;
346
+ try {
347
+ const update = extractGoalFromLine(JSON.parse(line));
348
+ if (update !== undefined) latest = update;
349
+ } catch (_) {}
350
+ });
351
+ return latest;
352
+ }
353
+
314
354
  // gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
315
355
  // and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
316
356
  // live branch must resolve it from cwd separately. Cache is reset on inode
@@ -392,6 +432,12 @@ function readSessionInfoFromJsonl(jsonlPath) {
392
432
  if (deltaLen > 0) {
393
433
  const readLen = Math.min(deltaLen, DELTA_MAX);
394
434
  const readStart = stat.size - readLen;
435
+ // The bytes an over-cap delta skips may hold a goal set, met or clear;
436
+ // scanned first so the delta's own lines win by order.
437
+ if (deltaLen > DELTA_MAX) {
438
+ const skipped = scanGoalInRange(fd, cached.scannedUpTo, readStart);
439
+ if (skipped !== undefined) result.goal = skipped;
440
+ }
395
441
  const buf = Buffer.alloc(readLen);
396
442
  const n = fs.readSync(fd, buf, 0, readLen, readStart);
397
443
  if (n > 0) {
@@ -452,7 +498,11 @@ function readSessionInfoFromJsonl(jsonlPath) {
452
498
  } catch (e) {}
453
499
  }
454
500
  if (latestTailCwd) lastCwdSeen = latestTailCwd;
455
- if (tailGoal !== undefined) result.goal = tailGoal;
501
+ // A goal can be set, met or cleared between the head and tail windows.
502
+ // The scan starts at the head's last partial line and runs to EOF, so a
503
+ // tail event is simply seen again in order.
504
+ const goal = tailGoal !== undefined ? tailGoal : scanGoalInRange(fd, offset - Buffer.byteLength(leftover), stat.size);
505
+ if (goal !== undefined) result.goal = goal;
456
506
  }
457
507
  scannedUpTo = stat.size;
458
508
  }
@@ -1246,6 +1296,69 @@ function buildAgentProgressMap(jsonlPath) {
1246
1296
  return buildSessionDigest(jsonlPath).progressMap;
1247
1297
  }
1248
1298
 
1299
+ const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
1300
+
1301
+ function toolResultText(content) {
1302
+ if (typeof content === 'string') return content;
1303
+ if (!Array.isArray(content)) return '';
1304
+ return content.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n');
1305
+ }
1306
+
1307
+ // Two kinds of line record a published artifact: the `frame-link` entry the
1308
+ // viewer writes, which carries the title, and the result of an Artifact publish,
1309
+ // which is there even when the artifact was never opened in a frame. Keyed by URL,
1310
+ // so the republishes of one page collapse into a single entry. A result counts
1311
+ // only when its tool_use_id belongs to a publish call — an artifact URL quoted
1312
+ // back by a grep or a file read would otherwise register another session's page as
1313
+ // this session's own, and a `read` of someone else's artifact is not a publish.
1314
+ function readArtifactLinks(jsonlPath) {
1315
+ const byUrl = new Map();
1316
+ const publishedFileByToolUseId = new Map();
1317
+ const upsert = (url, ts) => {
1318
+ let a = byUrl.get(url);
1319
+ if (!a) {
1320
+ a = { url, title: null, path: null, firstSeen: ts || null };
1321
+ byUrl.set(url, a);
1322
+ }
1323
+ if (ts && (!a.firstSeen || ts < a.firstSeen)) a.firstSeen = ts;
1324
+ return a;
1325
+ };
1326
+ try {
1327
+ const content = readFileSync(jsonlPath, 'utf8');
1328
+ for (const line of content.split('\n')) {
1329
+ if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
1330
+ continue;
1331
+ let obj;
1332
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1333
+ if (obj.type === 'frame-link' && obj.frameUrl) {
1334
+ const a = upsert(obj.frameUrl, obj.timestamp);
1335
+ if (obj.title) a.title = obj.title;
1336
+ if (obj.path) a.path = obj.path;
1337
+ continue;
1338
+ }
1339
+ const blocks = obj?.message?.content;
1340
+ if (!Array.isArray(blocks)) continue;
1341
+ for (const b of blocks) {
1342
+ if (b?.type === 'tool_use') {
1343
+ const input = b.name === 'Artifact' ? b.input || {} : null;
1344
+ // The publish action is the default one, so it is spelled by the file it
1345
+ // takes rather than by a name.
1346
+ if (b.id && input?.file_path && (!input.action || input.action === 'publish')) {
1347
+ publishedFileByToolUseId.set(b.id, input.file_path);
1348
+ }
1349
+ continue;
1350
+ }
1351
+ if (b?.type !== 'tool_result' || !publishedFileByToolUseId.has(b.tool_use_id)) continue;
1352
+ const m = toolResultText(b.content).match(ARTIFACT_URL_RE);
1353
+ if (!m) continue;
1354
+ const a = upsert(m[0], obj.timestamp);
1355
+ if (!a.path) a.path = publishedFileByToolUseId.get(b.tool_use_id);
1356
+ }
1357
+ }
1358
+ } catch (_) {}
1359
+ return [...byUrl.values()].sort((x, y) => (x.firstSeen < y.firstSeen ? -1 : x.firstSeen > y.firstSeen ? 1 : 0));
1360
+ }
1361
+
1249
1362
  function readCompactSummaries(jsonlPath) {
1250
1363
  const results = [];
1251
1364
  // Inline format: newer Claude Code stores the summary directly in the parent
@@ -1584,6 +1697,7 @@ module.exports = {
1584
1697
  buildAgentProgressMap,
1585
1698
  buildSessionDigest,
1586
1699
  readCompactSummaries,
1700
+ readArtifactLinks,
1587
1701
  findTerminatedTeammates,
1588
1702
  extractPromptFromTranscript,
1589
1703
  extractModelFromTranscript,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.24.0",
3
+ "version": "4.26.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.10.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,6 +1,6 @@
1
1
  #!/bin/bash
2
2
  # Tracks subagent lifecycle: one append-only JSONL file per agent, grouped by session
3
- # Layout: ~/.claude/.cck/agent-activity/{sessionId}/{agentId}.jsonl
3
+ # Layout: <CLAUDE_CONFIG_DIR or ~/.claude>/.cck/agent-activity/{sessionId}/{agentId}.jsonl
4
4
  # Each line is a lifecycle event (start | idle | stop). Server folds last-line-wins.
5
5
 
6
6
  INPUT=$(cat)
@@ -17,7 +17,7 @@ eval "$(echo "$INPUT" | jq -r '
17
17
 
18
18
  [ -z "$SESSION_ID" ] && exit 0
19
19
 
20
- CCK_ACTIVITY="$HOME/.claude/.cck/agent-activity"
20
+ CCK_ACTIVITY="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck/agent-activity"
21
21
 
22
22
  # Map session to custom task list on session start
23
23
  if [ "$EVENT" = "SessionStart" ]; then
@@ -1,14 +1,14 @@
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
- # Contract (_plans/cck-ui-approvals/decisions.md):
8
- # marker ~/.claude/.cck/agent-activity/<sid>/_waiting.json (D8: + id, cwd, permissionSuggestions)
9
- # decision ~/.claude/.cck/agent-activity/<sid>/_decision-<id>.json (server writes it, Phase 3)
10
- # config ~/.claude/.cck/approvals.json {enabled, mode, waitSeconds} (D2: fail-open when absent)
11
- # liveness ~/.claude/.cck/server.json {port, pid} (D1: a dead board costs nothing)
7
+ # Contract (_plans/cck-ui-approvals/decisions.md), rooted at <CLAUDE_CONFIG_DIR or ~/.claude>/.cck:
8
+ # marker agent-activity/<sid>/_waiting.json (D8: + id, cwd, permissionSuggestions)
9
+ # decision agent-activity/<sid>/_decision-<id>.json (server writes it, Phase 3)
10
+ # config config.json {approvals: {enabled, mode, waitSeconds}} (on by default; absent = defaults)
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
14
14
  # and this gate exits silently; a decision arriving after the tool already ran
@@ -37,7 +37,7 @@ KIND="permission"
37
37
  [ "$TOOL_NAME" = "AskUserQuestion" ] && KIND="question"
38
38
  [ "$TOOL_NAME" = "ExitPlanMode" ] && KIND="plan"
39
39
 
40
- CCK_DIR="$HOME/.claude/.cck"
40
+ CCK_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck"
41
41
  DIR="$CCK_DIR/agent-activity/$SESSION_ID"
42
42
  MARKER="$DIR/_waiting.json"
43
43
  mkdir -p "$DIR"
@@ -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
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
2
  # Statusline spy: writes raw context data for kanban dashboard, passes input through
3
- # Layout: ~/.claude/.cck/context-status/{sessionId}.json
3
+ # Layout: <CLAUDE_CONFIG_DIR or ~/.claude>/.cck/context-status/{sessionId}.json
4
4
  #
5
5
  # Usage: pipe before your statusline command:
6
6
  # "command": "~/.claude/hooks/context-status.sh | npx -y ccstatusline@latest"
@@ -9,7 +9,7 @@ INPUT=$(cat)
9
9
 
10
10
  SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
11
11
  if [ -n "$SESSION_ID" ]; then
12
- DIR="$HOME/.claude/.cck/context-status"
12
+ DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck/context-status"
13
13
  mkdir -p "$DIR"
14
14
  echo "$INPUT" > "$DIR/$SESSION_ID.json"
15
15
  fi
@@ -19,7 +19,8 @@ const os = require('os');
19
19
  const path = require('path');
20
20
 
21
21
  const SESSION_ID = process.env.CLAUDE_CODE_SESSION_ID;
22
- const SERVER_INFO = path.join(os.homedir(), '.claude', '.cck', 'server.json');
22
+ const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
23
+ const SERVER_INFO = path.join(CLAUDE_DIR, '.cck', 'server.json');
23
24
  // The server caps its own wait at 120s. Sitting at the ceiling halves every recurring
24
25
  // cost -- handshake, route walk, timer, empty response -- and costs no event latency,
25
26
  // because an enqueue wakes the poll immediately.