claude-code-kanban 4.24.0 → 4.25.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
@@ -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);
@@ -178,8 +183,7 @@ async function runInstall({ pluginOnly = false } = {}) {
178
183
  return;
179
184
  }
180
185
 
181
- const CTX_COMMAND = '~/.claude/hooks/context-status.sh';
182
- const hasCtx = settings.statusLine?.command?.includes('context-status.sh');
186
+ const hasCtx = settings.statusLine?.command?.includes(CTX_SCRIPT_NAME);
183
187
  if (hasCtx) {
184
188
  console.log(`\n StatusLine: ${green('✓')} Already configured`);
185
189
  } else if (!settings.statusLine) {
@@ -218,6 +222,7 @@ function printSummary(failed = false) {
218
222
 
219
223
  async function runUninstall() {
220
224
  console.log(`\n ${bold('claude-code-kanban')} — Uninstaller\n`);
225
+ console.log(` Claude config dir: ${dim(displayPath(CLAUDE_DIR))}\n`);
221
226
 
222
227
  // 1. Uninstall plugin via Claude CLI
223
228
  process.stdout.write(' Removing plugin... ');
@@ -260,9 +265,9 @@ async function runUninstall() {
260
265
  let changed = false;
261
266
 
262
267
  // Strip context-status.sh from statusLine
263
- if (settings.statusLine?.command?.includes('context-status.sh')) {
268
+ if (settings.statusLine?.command?.includes(CTX_SCRIPT_NAME)) {
264
269
  const cmd = settings.statusLine.command;
265
- const stripped = cmd.replace(/~\/\.claude\/hooks\/context-status\.sh\s*\|\s*/, '').trim();
270
+ const stripped = cmd.replace(new RegExp(`\\S*${CTX_SCRIPT_NAME.replace('.', '\\.')}\\s*\\|\\s*`), '').trim();
266
271
  if (stripped && stripped !== cmd) {
267
272
  settings.statusLine.command = stripped;
268
273
  console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.24.0",
3
+ "version": "4.25.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.11.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
@@ -4,11 +4,11 @@
4
4
  # is unchanged when the feature is off), then — only when explicitly enabled and
5
5
  # 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 approvals.json {enabled, mode, waitSeconds} (D2: fail-open when absent)
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"
@@ -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.