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.
- package/README.md +4 -4
- package/cli.js +30 -108
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +62 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal.js +30 -5
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +257 -74
- package/public/index.html +12 -2
- package/public/project-match.js +6 -1
- package/public/style.css +99 -0
- package/server.js +146 -147
- package/skill-guides/dispatch.md +16 -54
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- 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
|
|
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
|
|
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>`</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.
|
|
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|
|
|
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
|
|
183
|
+
summary: 'Start a Claude Code session for a task in cck\'s terminal',
|
|
184
184
|
verbs: {
|
|
185
185
|
start: {
|
|
186
|
-
summary: 'Start
|
|
187
|
-
usage: 'claude-code-kanban dispatch start --cwd <dir> (--spec <text> | --spec-file <path>) [--name <n>] [--group <g>] [--
|
|
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 --
|
|
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
|
|
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
|
|
335
|
-
console.log(' --
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1041
|
-
|
|
1042
|
-
const
|
|
1043
|
-
|
|
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
|
-
|
|
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
|
|
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?${
|
|
1145
|
-
const
|
|
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.
|
|
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
|
|
19
|
-
const
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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({
|
|
84
|
-
console.log(`\n ${bold('claude-code-kanban')} —
|
|
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 (
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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/):
|
|
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.
|
|
5
|
-
//
|
|
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
|
|
8
|
-
//
|
|
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
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// (
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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';
|
package/lib/dispatch-groups.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
// Transient session groups: `dispatch start --group`
|
|
2
|
-
//
|
|
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}
|
|
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
|
|
43
|
+
function join(group, ids) {
|
|
50
44
|
let changed = false;
|
|
51
45
|
for (const id of ids) {
|
|
52
|
-
if (!id) continue;
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
85
|
+
return { join, snapshot };
|
|
108
86
|
}
|
|
109
87
|
|
|
110
88
|
module.exports = { createGroupStore, isGroupName, suggestGroupName, GRACE_MS };
|