claude-code-kanban 5.3.0 → 5.4.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
@@ -19,7 +19,7 @@ Watch the tour on YouTube: [light](https://youtu.be/QbvDBFyfC7s), [dark](https:/
19
19
 
20
20
  ## Getting started
21
21
 
22
- You need Node.js 20 or later, the `claude` CLI, and `jq` for the hook scripts.
22
+ You need Node.js 20 or later and the `claude` CLI.
23
23
 
24
24
  ### 1. Install the integration (one time)
25
25
 
@@ -27,7 +27,7 @@ You need Node.js 20 or later, the `claude` CLI, and `jq` for the hook scripts.
27
27
  npx claude-code-kanban --install
28
28
  ```
29
29
 
30
- The installer adds a Claude Code plugin with hooks and skills, and a statusLine script for context use and cost. It asks before each change and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
30
+ The installer adds a Claude Code plugin with hooks, skills and a mod for context use and cost (Claude Code 2.1.287 or later). It asks before it installs and keeps your other settings. Without the hooks, the board shows tasks only: no agent log, no live activity, no waiting prompts.
31
31
 
32
32
  To remove it, run `npx claude-code-kanban --uninstall`. For another Claude config dir, pass the same `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to `--install`, `--uninstall` and the server. See [Getting started](https://nikiforovall.blog/claude-code-kanban/getting-started/) for each install step.
33
33
 
package/cli.js CHANGED
@@ -331,8 +331,8 @@ function printTopHelp() {
331
331
  console.log(' --port <n> Port to listen on (default 3541)');
332
332
  console.log(' --dir <path> Override Claude config dir (default ~/.claude); also targets --install/--uninstall');
333
333
  console.log(' --open Open browser on start');
334
- console.log(' --install, --uninstall Install or remove the plugin, context spy, and statusline');
335
- console.log(' --plugin-only With --install: refresh only the plugin, skip context spy and statusline');
334
+ console.log(' --install, --uninstall Install or remove the plugin');
335
+ console.log(' --yes With --install: install without a prompt');
336
336
  console.log('\nEnvironment:');
337
337
  console.log(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
338
338
  console.log(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
@@ -431,6 +431,8 @@ class CliUnreachable extends Error { constructor() { super(unreachable()); this.
431
431
  // connects run at once. Nothing reached the server, so even a POST is safe to send again.
432
432
  const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
433
433
  const isConnectTimeout = (e) => e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
434
+ // A loopback connection can also be reset after it opens. A GET is safe to send again; a write may have landed.
435
+ const isRetryable = (e, init) => isConnectTimeout(e) || (!init?.method && e.cause?.code === 'ECONNRESET');
434
436
 
435
437
  async function cliFetch(urlPath, init) {
436
438
  const base = cliBaseUrl();
@@ -439,7 +441,7 @@ async function cliFetch(urlPath, init) {
439
441
  try {
440
442
  return await fetch(`${base}${urlPath}`, init);
441
443
  } catch (e) {
442
- if (isConnectTimeout(e) && i < CONNECT_RETRY_MS.length) {
444
+ if (isRetryable(e, init) && i < CONNECT_RETRY_MS.length) {
443
445
  await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS[i]));
444
446
  continue;
445
447
  }
@@ -810,9 +812,12 @@ async function runSessionViewCli(args) {
810
812
  ].filter(Boolean).join(' · ');
811
813
  lines.push(` Model: ${modelName}${modelExtras ? ` (${modelExtras})` : ''}`);
812
814
  if (cw.used_percentage != null) {
813
- lines.push(` Context: ${cw.used_percentage}% used · ${fmtTok(cw.total_input_tokens)} in / ${fmtTok(cw.total_output_tokens)} out · cache ${fmtTok(cw.current_usage?.cache_read_input_tokens)} read`);
815
+ lines.push(` Context: ${cw.used_percentage}% used · ${fmtTok(cw.total_input_tokens)} in${cw.total_output_tokens != null ? ` / ${fmtTok(cw.total_output_tokens)} out` : ''} · cache ${fmtTok(cw.current_usage?.cache_read_input_tokens)} read`);
814
816
  }
815
- lines.push(` Cost: ${fmtCost(cost.total_cost_usd)} · ${cost.total_api_duration_ms != null ? formatAge(cost.total_api_duration_ms) : '-'} api / ${cost.total_duration_ms != null ? formatAge(cost.total_duration_ms) : '-'} total · +${cost.total_lines_added || 0}/-${cost.total_lines_removed || 0}`);
817
+ const timing = cost.total_duration_ms != null
818
+ ? ` · ${cost.total_api_duration_ms != null ? formatAge(cost.total_api_duration_ms) : '-'} api / ${formatAge(cost.total_duration_ms)} total · +${cost.total_lines_added || 0}/-${cost.total_lines_removed || 0}`
819
+ : '';
820
+ lines.push(` Cost: ${fmtCost(cost.total_cost_usd)}${timing}`);
816
821
  if (rl.five_hour || rl.seven_day) {
817
822
  lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
818
823
  }
package/install.js CHANGED
@@ -10,14 +10,13 @@ const { PLUGIN_ID } = require('./lib/plugin-status');
10
10
  const CLAUDE_DIR = getClaudeDir();
11
11
  const CLI_ENV = claudeCliEnv(CLAUDE_DIR);
12
12
  const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
13
- const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
14
13
  const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
15
14
  const PLUGIN_SRC = path.join(__dirname, 'plugin');
16
15
  const PLUGIN_DEST = path.join(CCK_DIR, 'plugin');
16
+ // Earlier versions piped the statusLine through this script to capture context use; the plugin's mod does it now.
17
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);
18
+ const CTX_SCRIPT_DEST = path.join(CLAUDE_DIR, 'hooks', CTX_SCRIPT_NAME);
19
+ const MIN_CLAUDE_VERSION = [2, 1, 287];
21
20
 
22
21
  // ANSI helpers
23
22
  const green = s => `\x1b[32m${s}\x1b[0m`;
@@ -47,10 +46,37 @@ function runCLI(cmd, okPatterns = []) {
47
46
  }
48
47
  }
49
48
 
50
- function copyScript(src, dest) {
51
- fs.mkdirSync(path.dirname(dest), { recursive: true });
52
- fs.copyFileSync(src, dest);
53
- try { fs.chmodSync(dest, 0o755); } catch {}
49
+ function isBelowMinVersion(versionOutput) {
50
+ const parts = /(\d+)\.(\d+)\.(\d+)/.exec(versionOutput)?.slice(1).map(Number);
51
+ if (!parts) return false;
52
+ const i = parts.findIndex((n, k) => n !== MIN_CLAUDE_VERSION[k]);
53
+ return i !== -1 && parts[i] < MIN_CLAUDE_VERSION[i];
54
+ }
55
+
56
+ function removeContextSpy() {
57
+ if (fs.existsSync(CTX_SCRIPT_DEST)) {
58
+ fs.unlinkSync(CTX_SCRIPT_DEST);
59
+ console.log(` Old context spy: ${green('✓')} Removed ${dim(displayPath(CTX_SCRIPT_DEST))}`);
60
+ }
61
+ if (!fs.existsSync(SETTINGS_PATH)) return;
62
+ let settings;
63
+ try {
64
+ settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
65
+ } catch {
66
+ console.log(` Settings: ${red('✗')} Could not parse settings.json`);
67
+ return;
68
+ }
69
+ const cmd = settings.statusLine?.command;
70
+ if (!cmd?.includes(CTX_SCRIPT_NAME)) return;
71
+ const stripped = cmd.replace(/\S*context-status\.sh\s*\|?\s*/, '').trim();
72
+ if (stripped) {
73
+ settings.statusLine.command = stripped;
74
+ console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
75
+ } else {
76
+ delete settings.statusLine;
77
+ console.log(` StatusLine: ${green('✓')} Removed`);
78
+ }
79
+ fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
54
80
  }
55
81
 
56
82
  // Deletes files but never directories: on Windows the running Claude Code process holds
@@ -80,8 +106,8 @@ function copyDirSync(src, dest) {
80
106
  }
81
107
  }
82
108
 
83
- async function runInstall({ pluginOnly = false } = {}) {
84
- console.log(`\n ${bold('claude-code-kanban')} — ${pluginOnly ? 'Plugin installer' : 'Plugin & StatusLine installer'}\n`);
109
+ async function runInstall({ yes = false } = {}) {
110
+ console.log(`\n ${bold('claude-code-kanban')} — Plugin installer\n`);
85
111
  console.log(` Claude config dir: ${dim(displayPath(CLAUDE_DIR))}\n`);
86
112
  let failed = false;
87
113
 
@@ -90,23 +116,18 @@ async function runInstall({ pluginOnly = false } = {}) {
90
116
  const claude = runCLI('claude --version');
91
117
  if (claude.ok) {
92
118
  console.log(green(`✓ found (${claude.output})`));
119
+ if (isBelowMinVersion(claude.output)) {
120
+ console.log(` ${yellow(`⚠ context use and cost tracking needs Claude Code ${MIN_CLAUDE_VERSION.join('.')} or later — run \`claude update\``)}`);
121
+ }
93
122
  } else {
94
123
  console.log(red('✗ claude CLI not found'));
95
124
  console.log(` ${dim('Install Claude Code CLI first: https://docs.anthropic.com/en/docs/claude-code')}`);
96
125
  return;
97
126
  }
98
127
 
99
- process.stdout.write(' Checking jq... ');
100
- const jq = runCLI('jq --version');
101
- if (jq.ok) {
102
- console.log(green(`✓ found (${jq.output})`));
103
- } else {
104
- console.log(yellow('⚠ not found — hook scripts require jq for JSON parsing'));
105
- }
106
-
107
128
  // 2. Copy plugin to stable location & register marketplace
108
129
  console.log(`\n Plugin: ${dim(PLUGIN_DEST)}`);
109
- if (pluginOnly || await prompt(` Install claude-code-kanban plugin? [Y/n] `)) {
130
+ if (yes || await prompt(` Install claude-code-kanban plugin? [Y/n] `)) {
110
131
  process.stdout.write(` Copying plugin to ${displayPath(PLUGIN_DEST)}... `);
111
132
  try {
112
133
  clearFilesRecursive(PLUGIN_DEST);
@@ -155,75 +176,8 @@ async function runInstall({ pluginOnly = false } = {}) {
155
176
  console.log(` ${dim('Skipped')}`);
156
177
  }
157
178
 
158
- if (pluginOnly) {
159
- console.log(`\n ${dim('Context spy and statusline skipped (--plugin-only).')}`);
160
- printSummary(failed);
161
- return;
162
- }
163
-
164
- // 3. StatusLine setup (context-status.sh must be copied globally since statusLine is not plugin-scoped)
165
- console.log(`\n Context spy: ${dim(CTX_SCRIPT_DEST)}`);
166
- let ctxInstalled = false;
167
- if (fs.existsSync(CTX_SCRIPT_DEST)) {
168
- const existing = fs.readFileSync(CTX_SCRIPT_DEST, 'utf8');
169
- const bundled = fs.readFileSync(CTX_SCRIPT_SRC, 'utf8');
170
- if (existing === bundled) {
171
- console.log(` ${green('✓')} Up to date`);
172
- ctxInstalled = true;
173
- } else if (await prompt(` Different version found. Update? [Y/n] `)) {
174
- copyScript(CTX_SCRIPT_SRC, CTX_SCRIPT_DEST);
175
- console.log(` ${green('✓')} Updated`);
176
- ctxInstalled = true;
177
- } else {
178
- console.log(` ${dim('Skipped')}`);
179
- }
180
- } else if (await prompt(` Not found. Install? [Y/n] `)) {
181
- copyScript(CTX_SCRIPT_SRC, CTX_SCRIPT_DEST);
182
- console.log(` ${green('✓')} Installed`);
183
- ctxInstalled = true;
184
- } else {
185
- console.log(` ${dim('Skipped')}`);
186
- }
187
-
188
- // 4. StatusLine config in settings.json
189
- if (ctxInstalled) {
190
- let settings;
191
- try {
192
- settings = fs.existsSync(SETTINGS_PATH)
193
- ? JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'))
194
- : {};
195
- } catch {
196
- console.log(` ${red('✗')} Malformed JSON in settings.json — skipping statusline config`);
197
- printSummary(true);
198
- return;
199
- }
200
-
201
- const hasCtx = settings.statusLine?.command?.includes(CTX_SCRIPT_NAME);
202
- if (hasCtx) {
203
- console.log(`\n StatusLine: ${green('✓')} Already configured`);
204
- } else if (!settings.statusLine) {
205
- console.log(`\n StatusLine: ${dim('not configured')}`);
206
- if (await prompt(` Set up context tracking statusline? [Y/n] `)) {
207
- settings.statusLine = { type: 'command', command: CTX_COMMAND };
208
- fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
209
- console.log(` ${green('✓')} StatusLine configured`);
210
- } else {
211
- console.log(` ${dim('Skipped')}`);
212
- }
213
- } else {
214
- const existing = settings.statusLine.command;
215
- console.log(`\n StatusLine: ${dim(`current: ${existing}`)}`);
216
- if (await prompt(` Prepend context spy to existing statusline? [Y/n] `)) {
217
- settings.statusLine.type = 'command';
218
- settings.statusLine.command = `${CTX_COMMAND} | ${existing}`;
219
- fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
220
- console.log(` ${green('✓')} StatusLine updated`);
221
- } else {
222
- console.log(` ${dim('Skipped')}`);
223
- }
224
- }
225
- }
226
-
179
+ console.log('');
180
+ removeContextSpy();
227
181
  printSummary(failed);
228
182
  }
229
183
 
@@ -265,41 +219,7 @@ async function runUninstall() {
265
219
  console.log(` Plugin copy: ${dim('Not found')}`);
266
220
  }
267
221
 
268
- // 4. Remove context-status.sh copy
269
- if (fs.existsSync(CTX_SCRIPT_DEST)) {
270
- fs.unlinkSync(CTX_SCRIPT_DEST);
271
- console.log(` Context spy: ${green('✓')} Removed`);
272
- } else {
273
- console.log(` Context spy: ${dim('Not found')}`);
274
- }
275
-
276
- // 5. Clean up settings.json (statusLine)
277
- if (fs.existsSync(SETTINGS_PATH)) {
278
- try {
279
- const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
280
- let changed = false;
281
-
282
- // Strip context-status.sh from statusLine
283
- if (settings.statusLine?.command?.includes(CTX_SCRIPT_NAME)) {
284
- const cmd = settings.statusLine.command;
285
- const stripped = cmd.replace(new RegExp(`\\S*${CTX_SCRIPT_NAME.replace('.', '\\.')}\\s*\\|\\s*`), '').trim();
286
- if (stripped && stripped !== cmd) {
287
- settings.statusLine.command = stripped;
288
- console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
289
- } else {
290
- delete settings.statusLine;
291
- console.log(` StatusLine: ${green('✓')} Removed`);
292
- }
293
- changed = true;
294
- }
295
-
296
- if (changed) {
297
- fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
298
- }
299
- } catch {
300
- console.log(` Settings: ${red('✗')} Could not parse settings.json`);
301
- }
302
- }
222
+ removeContextSpy();
303
223
 
304
224
  console.log(`\n ${green('Uninstall complete.')}\n`);
305
225
  }
package/lib/approvals.js CHANGED
@@ -1,18 +1,18 @@
1
- // UI-driven approvals (_plans/cck-ui-approvals/): approval-gate.sh writes the
1
+ // UI-driven approvals (_plans/cck-ui-approvals/): the plugin's mod writes the
2
2
  // _waiting.json marker with a request id (D8) and polls for _decision-<id>.json.
3
3
  // buildDecision validates a board response against the live marker and shapes
4
- // the decision file; the route writes it. The hook deletes marker + decision on
5
- // consumption; orphans from a terminal deny (D13) are left for the sweep.
4
+ // the decision file; the route writes it. Mods cannot delete files, so the mod
5
+ // clears the marker and the sweep removes decision files.
6
6
 
7
- // Ids are hook-minted (uuidgen or a time-pid-random compound) — anything outside
8
- // this alphabet is either corruption or a path-traversal attempt (3.5).
7
+ // Ids are mod-minted UUIDs — anything outside this alphabet is either
8
+ // corruption or a path-traversal attempt (3.5).
9
9
  function sanitizeRequestId(raw) {
10
10
  return typeof raw === 'string' && /^[a-zA-Z0-9-]{1,64}$/.test(raw) ? raw : null;
11
11
  }
12
12
 
13
- // A decision whose shape doesn't match the ask's kind produces hook output
14
- // Claude Code rejects, surfacing as an opaque stall — reject at the API
15
- // boundary instead (D7). Returns { error, status } or { decision }.
13
+ // A decision whose shape doesn't match the ask's kind cannot answer the call,
14
+ // surfacing as an opaque stall — reject at the API boundary instead (D7).
15
+ // Returns { error, status } or { decision }.
16
16
  function buildDecision(marker, body) {
17
17
  if (!marker || marker.status !== 'waiting') {
18
18
  return { error: 'No pending ask', status: 410 };
@@ -45,7 +45,6 @@ function buildDecision(marker, body) {
45
45
  const decision = { behavior: body.behavior };
46
46
  if (typeof body.message === 'string' && body.message) decision.message = body.message;
47
47
  if (body.updatedInput && typeof body.updatedInput === 'object') decision.updatedInput = body.updatedInput;
48
- if (Array.isArray(body.updatedPermissions)) decision.updatedPermissions = body.updatedPermissions;
49
48
  return { decision };
50
49
  }
51
50
 
@@ -58,14 +57,14 @@ function isDecisionFile(name) {
58
57
  return name.startsWith('_decision-') && name.endsWith('.json');
59
58
  }
60
59
 
61
- // approval-gate.sh only polls for a decision for waitSeconds — after that the
62
- // ask belongs to the terminal. Default and clamp mirror the gate's own parse
63
- // (PERMISSION_TTL_MS hides the card at 30 min, so waiting longer than the UI
64
- // can show the ask is strictly worse — D11); keep them in sync with
65
- // approval-gate.sh.
60
+ // The mod only polls for a decision for waitSeconds — after that the ask
61
+ // belongs to the terminal. Default and clamp mirror the mod's own parse
62
+ // (boardWaitMs in the plugin's hooks/activity.ts; PERMISSION_TTL_MS hides the
63
+ // card at 30 min, so waiting longer than the UI can show the ask is strictly
64
+ // worse — D11); keep them in sync.
66
65
  const WAIT_SECONDS_DEFAULT = 1800;
67
66
  const WAIT_SECONDS_MAX = 1800;
68
- // Small grace over the gate's own deadline so a race never 410s a live hook
67
+ // Small grace over the mod's own deadline so a race never 410s a live ask
69
68
  const LAPSE_GRACE_MS = 5000;
70
69
  function waitSecondsFrom(cfg) {
71
70
  const raw = cfg?.waitSeconds;
@@ -75,7 +74,7 @@ function waitSecondsFrom(cfg) {
75
74
 
76
75
  // Normalizes the `approvals` section of <CCK_DIR>/config.json. The gate is on
77
76
  // 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.
77
+ // unparseable falls back to defaults — the same parse the mod does.
79
78
  function approvalsFrom(cfg) {
80
79
  const a = cfg && typeof cfg.approvals === 'object' && cfg.approvals ? cfg.approvals : {};
81
80
  return {
@@ -85,7 +84,7 @@ function approvalsFrom(cfg) {
85
84
  };
86
85
  }
87
86
 
88
- // Whether the hook waits for a board decision on a marker of this kind
87
+ // Whether the mod waits for a board decision on a marker of this kind
89
88
  function isKindGated(kind, approvals) {
90
89
  if (!approvals.enabled) return false;
91
90
  if (kind === 'question') return approvals.mode === 'permission+question';
@@ -0,0 +1,47 @@
1
+ // Read from HEAD rather than by spawning git: a sync spawn on the session-list path blocks the
2
+ // event loop for up to half a second per cwd on Windows, and terminal input waits behind it.
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const REF_RE = /^ref:\s*refs\/heads\/(.+)$/;
8
+ const GITDIR_RE = /^gitdir:\s*(.+)$/;
9
+
10
+ // `.git` is a directory in an ordinary checkout and a `gitdir:` pointer file in a linked
11
+ // worktree or a submodule.
12
+ function headFile(dir, readFile) {
13
+ const dotGit = path.join(dir, '.git');
14
+ let text;
15
+ try {
16
+ text = readFile(dotGit);
17
+ } catch (e) {
18
+ if (e.code === 'EISDIR') return path.join(dotGit, 'HEAD');
19
+ return null;
20
+ }
21
+ const m = GITDIR_RE.exec(text.trim());
22
+ return m ? path.join(path.resolve(dir, m[1]), 'HEAD') : null;
23
+ }
24
+
25
+ /**
26
+ * @param {string} cwd
27
+ * @param {(file: string) => string} [readFile]
28
+ * @returns {string|null} null outside a repo and on a detached HEAD
29
+ */
30
+ function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
31
+ for (let dir = path.resolve(cwd); ; ) {
32
+ const head = headFile(dir, readFile);
33
+ if (head) {
34
+ try {
35
+ const m = REF_RE.exec(readFile(head).trim());
36
+ return m ? m[1] : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+ const up = path.dirname(dir);
42
+ if (up === dir) return null;
43
+ dir = up;
44
+ }
45
+ }
46
+
47
+ module.exports = { readGitBranch };