claude-code-kanban 5.3.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +4 -4
  2. package/cli.js +30 -108
  3. package/install.js +43 -123
  4. package/lib/approvals.js +16 -17
  5. package/lib/dispatch-groups.js +9 -31
  6. package/lib/dispatch.js +19 -166
  7. package/lib/git-branch.js +62 -0
  8. package/lib/parsers.js +96 -59
  9. package/lib/plugin-status.js +1 -1
  10. package/lib/retention.js +5 -17
  11. package/lib/session-cache.js +1 -1
  12. package/lib/session-events.js +3 -2
  13. package/lib/terminal.js +30 -5
  14. package/package.json +1 -2
  15. package/plugin/.claude-plugin/marketplace.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
  17. package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
  20. package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
  21. package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
  22. package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
  23. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
  24. package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
  25. package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
  26. package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
  27. package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
  28. package/public/app.js +257 -74
  29. package/public/index.html +12 -2
  30. package/public/project-match.js +6 -1
  31. package/public/style.css +99 -0
  32. package/server.js +146 -147
  33. package/skill-guides/dispatch.md +16 -54
  34. package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
  35. package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
  36. package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
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
 
@@ -71,7 +71,7 @@ Run `claude` in any project. You do not configure anything per project. Claude C
71
71
  - **Answer prompts from the board.** When Claude asks for permission, asks a question or waits for plan approval, the session gets an amber highlight and the ask shows with Allow and Deny buttons or an answer form. The terminal prompt stays open, and the first answer wins. [Answer prompts from the board](https://nikiforovall.blog/claude-code-kanban/guides/waiting-prompts/)
72
72
  - **Embedded terminal.** Run a real Claude Code process for any session next to its board (<kbd>Ctrl</kbd>+<kbd>&#96;</kbd>). <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> resumes a past session and <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> swaps to the previous one. The terminal is off by default when the board runs alone. Start it with `--enable-terminal` and open the `#t=<token>` link the server prints. [Embedded terminal](https://nikiforovall.blog/claude-code-kanban/guides/embedded-terminal/)
73
73
  - **New session.** <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>N</kbd> opens a dialog to pick a folder, a name, a model, an optional git worktree and a first prompt. Needs the terminal.
74
- - **Dispatch.** Hand a written task to a new session with `claude-code-kanban dispatch start`, or ask Claude to do it with the `dispatch` skill. Add `--report` to get the outcome back. Needs the terminal. [Dispatch tasks to other sessions](https://nikiforovall.blog/claude-code-kanban/guides/dispatch/)
74
+ - **Dispatch.** Hand a written task to a new session with `claude-code-kanban dispatch start`, or ask Claude to do it with the `dispatch` skill. Args after `--` go to `claude` as they are, and the started session reports back with `SendMessage`. Needs the terminal. [Dispatch tasks to other sessions](https://nikiforovall.blog/claude-code-kanban/guides/dispatch/)
75
75
  - **Steer with card moves.** Run `/claude-code-kanban:follow` in a session, then drag its cards. Claude starts, parks or stops the task. [Claude Code plugin skills](https://nikiforovall.blog/claude-code-kanban/guides/plugin-skills/)
76
76
  - **Review comments.** Select text in a previewed file or the plan, add comments and send them to the session in one step. [Review comments](https://nikiforovall.blog/claude-code-kanban/guides/review-comments/)
77
77
 
@@ -100,7 +100,7 @@ With no subcommand, `claude-code-kanban` starts the server. Subcommands talk to
100
100
  - `session list|search|open|view|plan|agents|pin|pins|peek` to read and focus sessions.
101
101
  - `task list` and `project list` to read tasks and projects.
102
102
  - `preview-doc` and `link-doc` to show or link a file on the board.
103
- - `dispatch start|done|wait|list` to start sessions with a task and collect their reports.
103
+ - `dispatch start|list` to start sessions with a task in the board's terminal.
104
104
 
105
105
  Run `claude-code-kanban --help` or see the [CLI reference](https://nikiforovall.blog/claude-code-kanban/reference/cli/).
106
106
 
package/cli.js CHANGED
@@ -180,58 +180,30 @@ const COMMANDS = {
180
180
  },
181
181
  },
182
182
  dispatch: {
183
- summary: 'Start a Claude Code session for a task in cck and collect its report',
183
+ summary: 'Start a Claude Code session for a task in cck\'s terminal',
184
184
  verbs: {
185
185
  start: {
186
- summary: 'Start a session with a task; prints the dispatch id',
187
- usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--report] [--peer <name>] [--model <m>] [--worktree [name]] [--json]',
186
+ summary: 'Start claude in cck\'s terminal with a task; prints the session id',
187
+ usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--model <m>] [--worktree [name]] [--json] [-- <claude args>...]',
188
188
  flags: {
189
189
  '--cwd <dir>': 'Folder to run in (a known project, default: current dir)',
190
- '--spec <text>': 'The task, self-contained',
190
+ '--spec <text>': 'The task, self-contained; sent as the first message',
191
191
  '--spec-file <path>': 'Read the task from a file',
192
- '--name <n>': 'Session name',
192
+ '--name <n>': 'Session name; also its peer name for SendMessage',
193
193
  '--group <g>': 'Show it with this session in a kebab-case group (default: this session\'s group)',
194
- '--report': 'Ask it to report its outcome back to this session',
195
- '--peer <name>': 'This session\'s peer name; it sends questions and findings there with SendMessage',
196
194
  '--model <m>': 'fable, opus, sonnet or haiku',
197
195
  '--worktree [name]': 'Run in a new git worktree',
198
196
  '--json': 'Output JSON',
197
+ '-- <claude args>': 'Passed to claude as they are, e.g. --permission-mode auto. No quotes, % or control characters; cck sets --session-id, --name, --model and --worktree',
199
198
  },
200
- notes: 'Needs the terminal token, so it runs on the machine of the cck server. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
199
+ notes: 'Needs the terminal token, so it runs on the machine of the cck server. cck sends no report: say in the spec how the session reports back. Run `claude-code-kanban skills get dispatch` for how to write the spec.',
201
200
  examples: [
202
- 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --peer my-peer --report --json',
201
+ 'claude-code-kanban dispatch start --cwd . --spec-file spec.md --name fix-login-redirect --group auth-refactor --model sonnet -- --permission-mode auto',
203
202
  ],
204
203
  run: runDispatchStartCli,
205
204
  },
206
- done: {
207
- summary: 'Report the outcome of a dispatch (run by the started session)',
208
- usage: 'claude-code-kanban dispatch done <id> --cap <cap> --outcome succeeded|failed (--summary <text> | --summary-file <path>)',
209
- flags: {
210
- '<id>': 'Dispatch id from the preamble',
211
- '--cap <cap>': 'Capability from the preamble',
212
- '--outcome <o>': 'succeeded or failed',
213
- '--summary <text>': 'What changed, what was found, what remains',
214
- '--summary-file <path>': 'Read the summary from a file',
215
- },
216
- examples: [
217
- 'claude-code-kanban dispatch done d_1a2b3c --cap <cap> --outcome succeeded --summary-file summary.md',
218
- ],
219
- run: runDispatchDoneCli,
220
- },
221
- wait: {
222
- summary: 'Wait until a dispatch settles; a timeout is a checkpoint, not a failure',
223
- usage: 'claude-code-kanban dispatch wait [<id>...] [--timeout <dur>] [--json]',
224
- flags: {
225
- '<id>': 'Dispatches to wait on (default: all started by this session)',
226
- '--timeout <dur>': 'How long to wait, e.g. 90s, 15m, 1h (default: 10m)',
227
- '--json': 'Output JSON',
228
- },
229
- notes: 'Returns as soon as any watched dispatch settles, with settled, running and timeout.',
230
- examples: ['claude-code-kanban dispatch wait --timeout 15m --json'],
231
- run: runDispatchWaitCli,
232
- },
233
205
  list: {
234
- summary: 'List dispatches started by this session',
206
+ summary: 'List sessions this session started that still run in cck\'s terminal',
235
207
  usage: 'claude-code-kanban dispatch list [--all] [--json]',
236
208
  flags: {
237
209
  '--all': 'Every dispatch on this board',
@@ -331,8 +303,8 @@ function printTopHelp() {
331
303
  console.log(' --port <n> Port to listen on (default 3541)');
332
304
  console.log(' --dir <path> Override Claude config dir (default ~/.claude); also targets --install/--uninstall');
333
305
  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');
306
+ console.log(' --install, --uninstall Install or remove the plugin');
307
+ console.log(' --yes With --install: install without a prompt');
336
308
  console.log('\nEnvironment:');
337
309
  console.log(' CCK_URL Server base URL, e.g. http://127.0.0.1:4795 (wins over PORT)');
338
310
  console.log(' PORT Server port (default: the one this config dir\'s server reports, else 3541)');
@@ -431,6 +403,8 @@ class CliUnreachable extends Error { constructor() { super(unreachable()); this.
431
403
  // connects run at once. Nothing reached the server, so even a POST is safe to send again.
432
404
  const CONNECT_RETRY_MS = [250, 500, 1000, 2000];
433
405
  const isConnectTimeout = (e) => e.cause?.code === 'ETIMEDOUT' && e.cause?.syscall === 'connect';
406
+ // A loopback connection can also be reset after it opens. A GET is safe to send again; a write may have landed.
407
+ const isRetryable = (e, init) => isConnectTimeout(e) || (!init?.method && e.cause?.code === 'ECONNRESET');
434
408
 
435
409
  async function cliFetch(urlPath, init) {
436
410
  const base = cliBaseUrl();
@@ -439,7 +413,7 @@ async function cliFetch(urlPath, init) {
439
413
  try {
440
414
  return await fetch(`${base}${urlPath}`, init);
441
415
  } catch (e) {
442
- if (isConnectTimeout(e) && i < CONNECT_RETRY_MS.length) {
416
+ if (isRetryable(e, init) && i < CONNECT_RETRY_MS.length) {
443
417
  await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS[i]));
444
418
  continue;
445
419
  }
@@ -810,9 +784,12 @@ async function runSessionViewCli(args) {
810
784
  ].filter(Boolean).join(' · ');
811
785
  lines.push(` Model: ${modelName}${modelExtras ? ` (${modelExtras})` : ''}`);
812
786
  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`);
787
+ 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
788
  }
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}`);
789
+ const timing = cost.total_duration_ms != null
790
+ ? ` · ${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}`
791
+ : '';
792
+ lines.push(` Cost: ${fmtCost(cost.total_cost_usd)}${timing}`);
816
793
  if (rl.five_hour || rl.seven_day) {
817
794
  lines.push(` Limits: 5h ${rl.five_hour?.used_percentage ?? '-'}% · 7d ${rl.seven_day?.used_percentage ?? '-'}%`);
818
795
  }
@@ -1037,19 +1014,10 @@ function textArg(args, name) {
1037
1014
  return file ? fs.readFileSync(path.resolve(file), 'utf8') : getArgValue(args, name);
1038
1015
  }
1039
1016
 
1040
- function parseDuration(raw, fallbackSec) {
1041
- if (!raw) return fallbackSec;
1042
- const m = /^(\d+(?:\.\d+)?)(s|m|h)?$/.exec(raw);
1043
- if (!m) return null;
1044
- return Number(m[1]) * ({ s: 1, m: 60, h: 3600 }[m[2] || 's']);
1045
- }
1046
-
1047
- function printDispatch(r) {
1048
- const head = `${r.id} ${r.status.padEnd(9)} session=${r.session}${r.name ? ` ${r.name}` : ''}`;
1049
- console.log(r.summary ? `${head}\n ${r.summary}` : head);
1050
- }
1051
-
1052
- async function runDispatchStartCli(args) {
1017
+ async function runDispatchStartCli(argv) {
1018
+ const sep = argv.indexOf('--');
1019
+ const args = sep === -1 ? argv : argv.slice(0, sep);
1020
+ const claudeArgs = sep === -1 ? [] : argv.slice(sep + 1);
1053
1021
  const port = cliTargetPort();
1054
1022
  if (port === null) {
1055
1023
  console.error(unreachable());
@@ -1080,73 +1048,27 @@ async function runDispatchStartCli(args) {
1080
1048
  model: getArgValue(args, 'model'),
1081
1049
  worktree,
1082
1050
  group,
1083
- report: args.includes('--report'),
1084
- peer: getArgValue(args, 'peer') || null,
1051
+ claudeArgs,
1085
1052
  parent: process.env.CLAUDE_CODE_SESSION_ID || null,
1086
1053
  };
1087
1054
  try {
1088
1055
  const out = await cliPostJson('/api/dispatch', body, 'Dispatch', { 'x-terminal-token': token });
1089
1056
  if (!out) return 1;
1090
1057
  if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
1091
- else console.log(`Started ${out.dispatch} (session ${out.session}) in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
1092
- return 0;
1093
- } catch (e) { reportCliError(e); return 1; }
1094
- }
1095
-
1096
- async function runDispatchDoneCli(args) {
1097
- const [id] = positionals(args, ['--cap', '--outcome', '--summary', '--summary-file']);
1098
- let summary;
1099
- try { summary = textArg(args, 'summary'); } catch (e) { console.error(e.message); return 1; }
1100
- const body = { cap: getArgValue(args, 'cap'), outcome: getArgValue(args, 'outcome'), summary };
1101
- if (!id || !body.cap || !body.outcome) {
1102
- printLeafHelp(COMMANDS.dispatch.verbs.done);
1103
- return 1;
1104
- }
1105
- try {
1106
- if (!await cliPostJson(`/api/dispatch/${encodeURIComponent(id)}/done`, body, 'Report')) return 1;
1107
- console.log(`Reported ${id}: ${body.outcome}`);
1108
- return 0;
1109
- } catch (e) { reportCliError(e); return 1; }
1110
- }
1111
-
1112
- function dispatchQuery(ids, all = false) {
1113
- const q = new URLSearchParams();
1114
- if (ids.length) q.set('ids', ids.join(','));
1115
- else if (!all && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
1116
- return q;
1117
- }
1118
-
1119
- async function runDispatchWaitCli(args) {
1120
- const timeoutRaw = getArgValue(args, 'timeout');
1121
- const timeoutSec = parseDuration(timeoutRaw, 600);
1122
- if (timeoutSec === null) return usageError(COMMANDS.dispatch.verbs.wait, `Invalid --timeout value: ${timeoutRaw}`);
1123
- const q = dispatchQuery(positionals(args, ['--timeout']));
1124
- const deadline = Date.now() + timeoutSec * 1000;
1125
- try {
1126
- let out;
1127
- do {
1128
- q.set('wait', String(Math.max(1, Math.min(120, Math.ceil((deadline - Date.now()) / 1000)))));
1129
- const res = await cliFetch(`/api/dispatch?${q}`);
1130
- out = await res.json();
1131
- } while (out.timeout && Date.now() < deadline);
1132
- if (args.includes('--json')) console.log(JSON.stringify(out, null, 2));
1133
- else {
1134
- for (const r of out.settled) printDispatch(r);
1135
- if (out.running.length) console.log(`${out.timeout ? 'Timed out; still running' : 'Still running'}: ${out.running.map(r => r.id).join(' ')}`);
1136
- if (!out.settled.length && !out.running.length) console.log('No dispatches to wait on.');
1137
- }
1058
+ else console.log(`Started session ${out.session} in ${out.cwd}${out.group ? ` [${out.group}]` : ''}`);
1138
1059
  return 0;
1139
1060
  } catch (e) { reportCliError(e); return 1; }
1140
1061
  }
1141
1062
 
1142
1063
  async function runDispatchListCli(args) {
1064
+ const q = new URLSearchParams();
1065
+ if (!args.includes('--all') && process.env.CLAUDE_CODE_SESSION_ID) q.set('parent', process.env.CLAUDE_CODE_SESSION_ID);
1143
1066
  try {
1144
- const res = await cliFetch(`/api/dispatch?${dispatchQuery([], args.includes('--all'))}`);
1145
- const { settled, running } = await res.json();
1146
- const rows = [...running, ...settled].sort((a, b) => b.startedAt - a.startedAt);
1067
+ const res = await cliFetch(`/api/dispatch?${q}`);
1068
+ const rows = (await res.json()).running.sort((a, b) => b.startedAt - a.startedAt);
1147
1069
  if (args.includes('--json')) console.log(JSON.stringify(rows, null, 2));
1148
1070
  else if (!rows.length) console.log('No dispatches.');
1149
- else rows.forEach(printDispatch);
1071
+ else for (const r of rows) console.log(`${r.session}${r.name ? ` ${r.name}` : ''}${r.group ? ` [${r.group}]` : ''}`);
1150
1072
  return 0;
1151
1073
  } catch (e) { reportCliError(e); return 1; }
1152
1074
  }
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';
@@ -1,6 +1,5 @@
1
- // Transient session groups: `dispatch start --group` (or the starter's own group) puts
2
- // the started session in a group. The starter is not moved: it is only remembered, so its
3
- // later dispatches default to the same group. A group lives while any member's claude
1
+ // Transient session groups: `dispatch start --group` puts the started session in a group,
2
+ // and only an explicit `--group` does. A group lives while any member's claude
4
3
  // runs, and for a grace period after; once none runs, only pinned members stay in it.
5
4
  // The map is on disk so a pinned group outlives a server restart.
6
5
 
@@ -25,39 +24,28 @@ function suggestGroupName(s) {
25
24
 
26
25
  /**
27
26
  * @param {object} o
28
- * @param {() => object|null} o.load returns `{sessions: {[id]: group}, starters?: {[id]: starterId}}` or null
27
+ * @param {() => object|null} o.load returns `{sessions: {[id]: group}}` or null
29
28
  * @param {(data: object) => void} o.save
30
29
  * @param {(id: string) => boolean} o.isAlive
31
30
  * @param {() => Set<string>} o.pinnedIds
32
31
  */
33
32
  function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
34
33
  const members = new Map();
35
- const starterOf = new Map();
36
34
  const liveAt = new Map();
37
35
  const saved = load();
38
36
  if (saved?.sessions && typeof saved.sessions === 'object') {
39
37
  for (const [id, g] of Object.entries(saved.sessions)) if (isGroupName(g)) members.set(id, g);
40
38
  }
41
- if (saved?.starters && typeof saved.starters === 'object') {
42
- for (const [id, s] of Object.entries(saved.starters)) if (members.has(id) && typeof s === 'string') starterOf.set(id, s);
43
- }
44
39
  for (const g of members.values()) liveAt.set(g, now());
45
40
 
46
- const persist = () =>
47
- save({ version: 1, sessions: Object.fromEntries(members), starters: Object.fromEntries(starterOf) });
41
+ const persist = () => save({ version: 1, sessions: Object.fromEntries(members) });
48
42
 
49
- function join(group, ids, starter) {
43
+ function join(group, ids) {
50
44
  let changed = false;
51
45
  for (const id of ids) {
52
- if (!id) continue;
53
- if (members.get(id) !== group) {
54
- members.set(id, group);
55
- changed = true;
56
- }
57
- if (starter && starterOf.get(id) !== starter) {
58
- starterOf.set(id, starter);
59
- changed = true;
60
- }
46
+ if (!id || members.get(id) === group) continue;
47
+ members.set(id, group);
48
+ changed = true;
61
49
  }
62
50
  liveAt.set(group, now());
63
51
  if (changed) persist();
@@ -82,7 +70,6 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
82
70
  for (const id of ids) {
83
71
  if (pinned.has(id)) continue;
84
72
  members.delete(id);
85
- starterOf.delete(id);
86
73
  changed = true;
87
74
  }
88
75
  if (!ids.some((id) => pinned.has(id))) liveAt.delete(g);
@@ -90,21 +77,12 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
90
77
  if (changed) persist();
91
78
  }
92
79
 
93
- // A starter is not a member; it defaults to the group of a session it started.
94
- function groupOf(id) {
95
- refresh();
96
- if (members.has(id)) return members.get(id);
97
- let group = null;
98
- for (const [child, starter] of starterOf) if (starter === id) group = members.get(child);
99
- return group;
100
- }
101
-
102
80
  function snapshot() {
103
81
  refresh();
104
82
  return members;
105
83
  }
106
84
 
107
- return { join, groupOf, snapshot };
85
+ return { join, snapshot };
108
86
  }
109
87
 
110
88
  module.exports = { createGroupStore, isGroupName, suggestGroupName, GRACE_MS };