claude-code-kanban 4.24.0 → 4.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/cli.js +1 -1
- package/install.js +30 -10
- package/lib/approvals.js +34 -3
- package/lib/claude-dir.js +53 -0
- package/lib/open-editor.js +4 -1
- package/lib/parsers.js +118 -4
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +2 -2
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +58 -23
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +2 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -1
- package/public/app.js +428 -126
- package/public/index.html +21 -3
- package/public/style.css +216 -17
- package/server.js +141 -46
package/README.md
CHANGED
|
@@ -22,6 +22,8 @@ npx claude-code-kanban --install
|
|
|
22
22
|
|
|
23
23
|
Non-destructive — existing settings in `~/.claude/settings.json` are preserved. Uninstall anytime with `npx claude-code-kanban --uninstall`.
|
|
24
24
|
|
|
25
|
+
Using another Claude config dir? Pass `--dir=<path>` (or set `CLAUDE_CONFIG_DIR`) to both `--install` and `--uninstall`. The plugin, hooks and statusLine land in that dir, and the hooks write their data under it when Claude Code runs with the same `CLAUDE_CONFIG_DIR`.
|
|
26
|
+
|
|
25
27
|
### 2. Start the dashboard
|
|
26
28
|
|
|
27
29
|
```bash
|
|
@@ -49,7 +51,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
|
|
|
49
51
|
- **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
|
|
50
52
|
- **Tool stats & impact** — Per-session tool usage breakdown and file impact
|
|
51
53
|
- **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
|
|
52
|
-
- **UI approvals
|
|
54
|
+
- **UI approvals** — Allow/deny permission asks and answer questions from the board; on by default, opt out per config dir — [docs](docs/ui-approvals.md)
|
|
53
55
|
- **Agent teams** — Color-coded team members, owner filtering, member count badges
|
|
54
56
|
- **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
|
|
55
57
|
- **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
|
|
@@ -84,7 +86,7 @@ The script pipes through, so your existing statusline keeps working.
|
|
|
84
86
|
```bash
|
|
85
87
|
PORT=8080 npx claude-code-kanban # Custom port (falls back if busy)
|
|
86
88
|
npx claude-code-kanban --open # Auto-open browser
|
|
87
|
-
npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir
|
|
89
|
+
npx claude-code-kanban --dir=~/.claude-work # Custom Claude config dir (or CLAUDE_CONFIG_DIR)
|
|
88
90
|
```
|
|
89
91
|
|
|
90
92
|
Global install: `npm install -g claude-code-kanban`, then `claude-code-kanban --open`.
|
package/cli.js
CHANGED
|
@@ -155,7 +155,7 @@ function printTopHelp() {
|
|
|
155
155
|
console.log(' --version, -v Print version and exit');
|
|
156
156
|
console.log('\nServer mode (no subcommand):');
|
|
157
157
|
console.log(' --port <n> Port to listen on (default 3541)');
|
|
158
|
-
console.log(' --dir <path> Override Claude config dir (default ~/.claude)');
|
|
158
|
+
console.log(' --dir <path> Override Claude config dir (default ~/.claude); also targets --install/--uninstall');
|
|
159
159
|
console.log(' --open Open browser on start');
|
|
160
160
|
console.log(' --install, --uninstall Install or remove the plugin, context spy, and statusline');
|
|
161
161
|
console.log(' --plugin-only With --install: refresh only the plugin, skip context spy and statusline');
|
package/install.js
CHANGED
|
@@ -5,15 +5,19 @@ const path = require('path');
|
|
|
5
5
|
const os = require('os');
|
|
6
6
|
const readline = require('readline');
|
|
7
7
|
const { execSync } = require('child_process');
|
|
8
|
+
const { getClaudeDir, claudeCliEnv, displayPath } = require('./lib/claude-dir');
|
|
8
9
|
|
|
9
|
-
const CLAUDE_DIR =
|
|
10
|
+
const CLAUDE_DIR = getClaudeDir();
|
|
11
|
+
const CLI_ENV = claudeCliEnv(CLAUDE_DIR);
|
|
10
12
|
const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
|
|
11
13
|
const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
|
|
12
14
|
const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
|
|
13
15
|
const PLUGIN_SRC = path.join(__dirname, 'plugin');
|
|
14
16
|
const PLUGIN_DEST = path.join(CCK_DIR, 'plugin');
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
+
const CTX_SCRIPT_NAME = 'context-status.sh';
|
|
18
|
+
const CTX_SCRIPT_SRC = path.join(PLUGIN_SRC, 'plugins', 'claude-code-kanban', 'scripts', CTX_SCRIPT_NAME);
|
|
19
|
+
const CTX_SCRIPT_DEST = path.join(HOOKS_DIR, CTX_SCRIPT_NAME);
|
|
20
|
+
const CTX_COMMAND = displayPath(CTX_SCRIPT_DEST);
|
|
17
21
|
|
|
18
22
|
// ANSI helpers
|
|
19
23
|
const green = s => `\x1b[32m${s}\x1b[0m`;
|
|
@@ -34,7 +38,7 @@ function prompt(question) {
|
|
|
34
38
|
|
|
35
39
|
function runCLI(cmd, okPatterns = []) {
|
|
36
40
|
try {
|
|
37
|
-
const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
41
|
+
const out = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env: CLI_ENV }).trim();
|
|
38
42
|
return { ok: true, output: out };
|
|
39
43
|
} catch (e) {
|
|
40
44
|
const stderr = e.stderr?.trim() || e.message;
|
|
@@ -78,6 +82,7 @@ function copyDirSync(src, dest) {
|
|
|
78
82
|
|
|
79
83
|
async function runInstall({ pluginOnly = false } = {}) {
|
|
80
84
|
console.log(`\n ${bold('claude-code-kanban')} — ${pluginOnly ? 'Plugin installer' : 'Plugin & StatusLine installer'}\n`);
|
|
85
|
+
console.log(` Claude config dir: ${dim(displayPath(CLAUDE_DIR))}\n`);
|
|
81
86
|
let failed = false;
|
|
82
87
|
|
|
83
88
|
// 1. Check prerequisites
|
|
@@ -102,7 +107,7 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
102
107
|
// 2. Copy plugin to stable location & register marketplace
|
|
103
108
|
console.log(`\n Plugin: ${dim(PLUGIN_DEST)}`);
|
|
104
109
|
if (pluginOnly || await prompt(` Install claude-code-kanban plugin? [Y/n] `)) {
|
|
105
|
-
process.stdout.write(
|
|
110
|
+
process.stdout.write(` Copying plugin to ${displayPath(PLUGIN_DEST)}... `);
|
|
106
111
|
try {
|
|
107
112
|
clearFilesRecursive(PLUGIN_DEST);
|
|
108
113
|
copyDirSync(PLUGIN_SRC, PLUGIN_DEST);
|
|
@@ -125,12 +130,27 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
125
130
|
if (!upd.ok) console.log(` ${yellow('⚠')} Marketplace refresh failed: ${upd.error}`);
|
|
126
131
|
|
|
127
132
|
const inst = runCLI('claude plugin install claude-code-kanban@claude-code-kanban', ['already installed', 'already exists']);
|
|
133
|
+
const alreadyInstalled = inst.idempotent || /already installed/i.test(inst.output || '');
|
|
128
134
|
if (inst.ok) {
|
|
129
|
-
console.log(` ${green('✓')} ${
|
|
135
|
+
console.log(` ${green('✓')} ${alreadyInstalled ? 'Already installed' : 'Plugin installed'}`);
|
|
130
136
|
} else {
|
|
131
137
|
console.log(` ${red('✗')} Plugin install failed: ${inst.error}`);
|
|
132
138
|
failed = true;
|
|
133
139
|
}
|
|
140
|
+
|
|
141
|
+
// `install` is a no-op once the plugin is present — it exits 0 and only says so
|
|
142
|
+
// on stdout — so a re-copied plugin keeps running from the old cached version
|
|
143
|
+
// until `update` pulls the new one in. That is how a config dir ends up serving
|
|
144
|
+
// a plugin older than the board it talks to.
|
|
145
|
+
if (alreadyInstalled) {
|
|
146
|
+
const upgrade = runCLI('claude plugin update claude-code-kanban', ['already at the latest']);
|
|
147
|
+
if (upgrade.ok) {
|
|
148
|
+
const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
|
|
149
|
+
console.log(` ${green('✓')} ${line ? line.replace(/^[^A-Za-z]*/, '') : 'Already at the latest version'}`);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(` ${yellow('⚠')} Plugin update failed: ${upgrade.error}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
134
154
|
} else {
|
|
135
155
|
console.log(` ${dim('Skipped')}`);
|
|
136
156
|
}
|
|
@@ -178,8 +198,7 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
178
198
|
return;
|
|
179
199
|
}
|
|
180
200
|
|
|
181
|
-
const
|
|
182
|
-
const hasCtx = settings.statusLine?.command?.includes('context-status.sh');
|
|
201
|
+
const hasCtx = settings.statusLine?.command?.includes(CTX_SCRIPT_NAME);
|
|
183
202
|
if (hasCtx) {
|
|
184
203
|
console.log(`\n StatusLine: ${green('✓')} Already configured`);
|
|
185
204
|
} else if (!settings.statusLine) {
|
|
@@ -218,6 +237,7 @@ function printSummary(failed = false) {
|
|
|
218
237
|
|
|
219
238
|
async function runUninstall() {
|
|
220
239
|
console.log(`\n ${bold('claude-code-kanban')} — Uninstaller\n`);
|
|
240
|
+
console.log(` Claude config dir: ${dim(displayPath(CLAUDE_DIR))}\n`);
|
|
221
241
|
|
|
222
242
|
// 1. Uninstall plugin via Claude CLI
|
|
223
243
|
process.stdout.write(' Removing plugin... ');
|
|
@@ -260,9 +280,9 @@ async function runUninstall() {
|
|
|
260
280
|
let changed = false;
|
|
261
281
|
|
|
262
282
|
// Strip context-status.sh from statusLine
|
|
263
|
-
if (settings.statusLine?.command?.includes(
|
|
283
|
+
if (settings.statusLine?.command?.includes(CTX_SCRIPT_NAME)) {
|
|
264
284
|
const cmd = settings.statusLine.command;
|
|
265
|
-
const stripped = cmd.replace(
|
|
285
|
+
const stripped = cmd.replace(new RegExp(`\\S*${CTX_SCRIPT_NAME.replace('.', '\\.')}\\s*\\|\\s*`), '').trim();
|
|
266
286
|
if (stripped && stripped !== cmd) {
|
|
267
287
|
settings.statusLine.command = stripped;
|
|
268
288
|
console.log(` StatusLine: ${green('✓')} Restored to "${stripped}"`);
|
package/lib/approvals.js
CHANGED
|
@@ -63,20 +63,51 @@ function isDecisionFile(name) {
|
|
|
63
63
|
// (PERMISSION_TTL_MS hides the card at 30 min, so waiting longer than the UI
|
|
64
64
|
// can show the ask is strictly worse — D11); keep them in sync with
|
|
65
65
|
// approval-gate.sh.
|
|
66
|
-
const WAIT_SECONDS_DEFAULT =
|
|
66
|
+
const WAIT_SECONDS_DEFAULT = 1800;
|
|
67
67
|
const WAIT_SECONDS_MAX = 1800;
|
|
68
68
|
// Small grace over the gate's own deadline so a race never 410s a live hook
|
|
69
69
|
const LAPSE_GRACE_MS = 5000;
|
|
70
|
-
|
|
71
70
|
function waitSecondsFrom(cfg) {
|
|
72
71
|
const raw = cfg && cfg.waitSeconds;
|
|
73
72
|
if (Number.isInteger(raw) && raw >= 0) return Math.min(raw, WAIT_SECONDS_MAX);
|
|
74
73
|
return WAIT_SECONDS_DEFAULT;
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
// Normalizes the `approvals` section of <CCK_DIR>/config.json. The gate is on
|
|
77
|
+
// by default: only an explicit `enabled: false` turns it off, and anything
|
|
78
|
+
// unparseable falls back to defaults — the same parse approval-gate.sh does.
|
|
79
|
+
function approvalsFrom(cfg) {
|
|
80
|
+
const a = cfg && typeof cfg.approvals === 'object' && cfg.approvals ? cfg.approvals : {};
|
|
81
|
+
return {
|
|
82
|
+
enabled: a.enabled !== false,
|
|
83
|
+
mode: a.mode === 'permission' ? 'permission' : 'permission+question',
|
|
84
|
+
waitSeconds: waitSecondsFrom(a),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Whether the hook waits for a board decision on a marker of this kind
|
|
89
|
+
function isKindGated(kind, approvals) {
|
|
90
|
+
if (!approvals.enabled) return false;
|
|
91
|
+
if (kind === 'question') return approvals.mode === 'permission+question';
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Why the board may not answer this marker, or null when it may. Shared by the
|
|
96
|
+
// session list (which hides the buttons) and the respond route (which refuses),
|
|
97
|
+
// so the two can never disagree.
|
|
98
|
+
function boardRefusal(marker, approvals, now = Date.now()) {
|
|
99
|
+
if (!isKindGated(marker.kind, approvals)) {
|
|
100
|
+
return { status: 410, error: 'UI approvals are off for this ask — answer it in the terminal' };
|
|
101
|
+
}
|
|
102
|
+
if (isLapsed(marker.timestamp, approvals.waitSeconds * 1000, now)) {
|
|
103
|
+
return { status: 410, error: 'Ask lapsed — answer it in the terminal' };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
function isLapsed(timestamp, waitMs, now = Date.now()) {
|
|
78
109
|
if (!timestamp) return true;
|
|
79
110
|
return now - new Date(timestamp).getTime() > waitMs + LAPSE_GRACE_MS;
|
|
80
111
|
}
|
|
81
112
|
|
|
82
|
-
module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed };
|
|
113
|
+
module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, approvalsFrom, isKindGated, boardRefusal, isLapsed };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
const os = require('node:os');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
6
|
+
|
|
7
|
+
// --name <value> | --name=<value>; null when absent.
|
|
8
|
+
function getArgValue(name) {
|
|
9
|
+
const argv = process.argv;
|
|
10
|
+
const idx = argv.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
|
|
11
|
+
if (idx === -1) return null;
|
|
12
|
+
const arg = argv[idx];
|
|
13
|
+
if (arg.includes('=')) return arg.split('=').slice(1).join('=');
|
|
14
|
+
return argv[idx + 1] || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function expandHome(dir) {
|
|
18
|
+
return dir.replace(/^~/, os.homedir());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --dir | CLAUDE_CONFIG_DIR | CLAUDE_DIR | ~/.claude
|
|
22
|
+
function getClaudeDir() {
|
|
23
|
+
const dir = getArgValue('dir') || process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR;
|
|
24
|
+
return dir ? expandHome(dir) : DEFAULT_CLAUDE_DIR;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isDefaultClaudeDir(dir) {
|
|
28
|
+
return path.resolve(dir) === path.resolve(DEFAULT_CLAUDE_DIR);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// localStorage is per browser origin, so every config dir served from the same port would
|
|
32
|
+
// otherwise share last view, collapsed groups and sidebar layout. The default dir keeps the
|
|
33
|
+
// bare keys; any other dir gets a prefix derived from its resolved path.
|
|
34
|
+
function storageNamespace(dir) {
|
|
35
|
+
if (isDefaultClaudeDir(dir)) return null;
|
|
36
|
+
return `cfg-${crypto.createHash('sha1').update(path.resolve(dir)).digest('hex').slice(0, 8)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Env for spawning the claude CLI. Claude Code keeps .claude.json beside ~/.claude when
|
|
40
|
+
// CLAUDE_CONFIG_DIR is unset but inside the dir when it is set, so the default dir must
|
|
41
|
+
// stay unset rather than be spelled out.
|
|
42
|
+
function claudeCliEnv(dir) {
|
|
43
|
+
return isDefaultClaudeDir(dir) ? process.env : { ...process.env, CLAUDE_CONFIG_DIR: dir };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Home-relative, forward-slash form for messages and shell commands.
|
|
47
|
+
function displayPath(p) {
|
|
48
|
+
const home = os.homedir();
|
|
49
|
+
const rel = p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
50
|
+
return rel.split(path.sep).join('/');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { getArgValue, getClaudeDir, claudeCliEnv, displayPath, storageNamespace };
|
package/lib/open-editor.js
CHANGED
|
@@ -221,6 +221,7 @@ function execNoShell(cmd, args, opts = {}) {
|
|
|
221
221
|
let argv = args;
|
|
222
222
|
const spawnOpts = {
|
|
223
223
|
cwd: opts.cwd || undefined,
|
|
224
|
+
env: opts.env,
|
|
224
225
|
timeout: opts.timeout,
|
|
225
226
|
windowsHide: true,
|
|
226
227
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -258,7 +259,9 @@ function execNoShell(cmd, args, opts = {}) {
|
|
|
258
259
|
child.on('error', reject);
|
|
259
260
|
child.on('close', (code, signal) => {
|
|
260
261
|
if (signal) return reject(new Error(`Command terminated by ${signal}: ${stderr.trim()}`));
|
|
261
|
-
|
|
262
|
+
// The claude CLI reports its own failures on stdout, so stderr alone is
|
|
263
|
+
// often empty and the caller would surface only the exit code.
|
|
264
|
+
if (code !== 0) return reject(new Error(stderr.trim() || stdout.trim() || `Command exited with code ${code}`));
|
|
262
265
|
resolve({ stdout, stderr });
|
|
263
266
|
});
|
|
264
267
|
});
|
package/lib/parsers.js
CHANGED
|
@@ -303,14 +303,54 @@ function extractGoalFromLine(data) {
|
|
|
303
303
|
if (data.type === 'attachment' && data.attachment?.type === 'goal_status') {
|
|
304
304
|
return data.attachment.met ? null : { condition: data.attachment.condition || '' };
|
|
305
305
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
306
|
+
// `/goal clear` is a type:system local_command line carrying the command
|
|
307
|
+
// markup in `content`; a user-typed command line carries it in message.content.
|
|
308
|
+
const content = data.content ?? data.message?.content;
|
|
309
|
+
if (typeof content === 'string' && content.includes(GOAL_COMMAND_TAG) && /<command-args>\s*clear/.test(content)) {
|
|
309
310
|
return null;
|
|
310
311
|
}
|
|
311
312
|
return undefined;
|
|
312
313
|
}
|
|
313
314
|
|
|
315
|
+
const GOAL_COMMAND_TAG = '<command-name>/goal</command-name>';
|
|
316
|
+
const GOAL_MARKERS = ['"goal_status"', GOAL_COMMAND_TAG].map((m) => Buffer.from(m));
|
|
317
|
+
const SCAN_CHUNK = 65536;
|
|
318
|
+
const scanBuf = Buffer.allocUnsafe(SCAN_CHUNK);
|
|
319
|
+
|
|
320
|
+
// Streams [from, to) and calls onLine for the complete lines of every chunk
|
|
321
|
+
// that contains a marker. Chunks without one are never decoded, which keeps a
|
|
322
|
+
// full pass close to raw read speed (measured ~4x cheaper than decode+split).
|
|
323
|
+
function scanMarkedLines(fd, from, to, markers, onLine) {
|
|
324
|
+
let carry = Buffer.alloc(0);
|
|
325
|
+
let offset = from;
|
|
326
|
+
while (offset < to) {
|
|
327
|
+
const n = fs.readSync(fd, scanBuf, 0, Math.min(SCAN_CHUNK, to - offset), offset);
|
|
328
|
+
if (n === 0) break;
|
|
329
|
+
offset += n;
|
|
330
|
+
const view = carry.length ? Buffer.concat([carry, scanBuf.subarray(0, n)]) : scanBuf.subarray(0, n);
|
|
331
|
+
const cut = view.lastIndexOf(0x0a) + 1;
|
|
332
|
+
const body = view.subarray(0, cut);
|
|
333
|
+
carry = Buffer.from(view.subarray(cut));
|
|
334
|
+
if (markers.some((m) => body.includes(m))) for (const line of body.toString('utf8').split('\n')) onLine(line);
|
|
335
|
+
}
|
|
336
|
+
if (carry.length && markers.some((m) => carry.includes(m))) onLine(carry.toString('utf8'));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Goal events are rare and can sit anywhere in a long JSONL, outside both the
|
|
340
|
+
// head and tail windows. Returns the last goal update in [from, to), or
|
|
341
|
+
// undefined when there is none.
|
|
342
|
+
function scanGoalInRange(fd, from, to) {
|
|
343
|
+
let latest;
|
|
344
|
+
scanMarkedLines(fd, from, to, GOAL_MARKERS, (line) => {
|
|
345
|
+
if (!line.includes('"goal_status"') && !line.includes(GOAL_COMMAND_TAG)) return;
|
|
346
|
+
try {
|
|
347
|
+
const update = extractGoalFromLine(JSON.parse(line));
|
|
348
|
+
if (update !== undefined) latest = update;
|
|
349
|
+
} catch (_) {}
|
|
350
|
+
});
|
|
351
|
+
return latest;
|
|
352
|
+
}
|
|
353
|
+
|
|
314
354
|
// gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
|
|
315
355
|
// and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
|
|
316
356
|
// live branch must resolve it from cwd separately. Cache is reset on inode
|
|
@@ -392,6 +432,12 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
392
432
|
if (deltaLen > 0) {
|
|
393
433
|
const readLen = Math.min(deltaLen, DELTA_MAX);
|
|
394
434
|
const readStart = stat.size - readLen;
|
|
435
|
+
// The bytes an over-cap delta skips may hold a goal set, met or clear;
|
|
436
|
+
// scanned first so the delta's own lines win by order.
|
|
437
|
+
if (deltaLen > DELTA_MAX) {
|
|
438
|
+
const skipped = scanGoalInRange(fd, cached.scannedUpTo, readStart);
|
|
439
|
+
if (skipped !== undefined) result.goal = skipped;
|
|
440
|
+
}
|
|
395
441
|
const buf = Buffer.alloc(readLen);
|
|
396
442
|
const n = fs.readSync(fd, buf, 0, readLen, readStart);
|
|
397
443
|
if (n > 0) {
|
|
@@ -452,7 +498,11 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
452
498
|
} catch (e) {}
|
|
453
499
|
}
|
|
454
500
|
if (latestTailCwd) lastCwdSeen = latestTailCwd;
|
|
455
|
-
|
|
501
|
+
// A goal can be set, met or cleared between the head and tail windows.
|
|
502
|
+
// The scan starts at the head's last partial line and runs to EOF, so a
|
|
503
|
+
// tail event is simply seen again in order.
|
|
504
|
+
const goal = tailGoal !== undefined ? tailGoal : scanGoalInRange(fd, offset - Buffer.byteLength(leftover), stat.size);
|
|
505
|
+
if (goal !== undefined) result.goal = goal;
|
|
456
506
|
}
|
|
457
507
|
scannedUpTo = stat.size;
|
|
458
508
|
}
|
|
@@ -1246,6 +1296,69 @@ function buildAgentProgressMap(jsonlPath) {
|
|
|
1246
1296
|
return buildSessionDigest(jsonlPath).progressMap;
|
|
1247
1297
|
}
|
|
1248
1298
|
|
|
1299
|
+
const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
|
|
1300
|
+
|
|
1301
|
+
function toolResultText(content) {
|
|
1302
|
+
if (typeof content === 'string') return content;
|
|
1303
|
+
if (!Array.isArray(content)) return '';
|
|
1304
|
+
return content.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n');
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Two kinds of line record a published artifact: the `frame-link` entry the
|
|
1308
|
+
// viewer writes, which carries the title, and the result of an Artifact publish,
|
|
1309
|
+
// which is there even when the artifact was never opened in a frame. Keyed by URL,
|
|
1310
|
+
// so the republishes of one page collapse into a single entry. A result counts
|
|
1311
|
+
// only when its tool_use_id belongs to a publish call — an artifact URL quoted
|
|
1312
|
+
// back by a grep or a file read would otherwise register another session's page as
|
|
1313
|
+
// this session's own, and a `read` of someone else's artifact is not a publish.
|
|
1314
|
+
function readArtifactLinks(jsonlPath) {
|
|
1315
|
+
const byUrl = new Map();
|
|
1316
|
+
const publishedFileByToolUseId = new Map();
|
|
1317
|
+
const upsert = (url, ts) => {
|
|
1318
|
+
let a = byUrl.get(url);
|
|
1319
|
+
if (!a) {
|
|
1320
|
+
a = { url, title: null, path: null, firstSeen: ts || null };
|
|
1321
|
+
byUrl.set(url, a);
|
|
1322
|
+
}
|
|
1323
|
+
if (ts && (!a.firstSeen || ts < a.firstSeen)) a.firstSeen = ts;
|
|
1324
|
+
return a;
|
|
1325
|
+
};
|
|
1326
|
+
try {
|
|
1327
|
+
const content = readFileSync(jsonlPath, 'utf8');
|
|
1328
|
+
for (const line of content.split('\n')) {
|
|
1329
|
+
if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
|
|
1330
|
+
continue;
|
|
1331
|
+
let obj;
|
|
1332
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
1333
|
+
if (obj.type === 'frame-link' && obj.frameUrl) {
|
|
1334
|
+
const a = upsert(obj.frameUrl, obj.timestamp);
|
|
1335
|
+
if (obj.title) a.title = obj.title;
|
|
1336
|
+
if (obj.path) a.path = obj.path;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
const blocks = obj?.message?.content;
|
|
1340
|
+
if (!Array.isArray(blocks)) continue;
|
|
1341
|
+
for (const b of blocks) {
|
|
1342
|
+
if (b?.type === 'tool_use') {
|
|
1343
|
+
const input = b.name === 'Artifact' ? b.input || {} : null;
|
|
1344
|
+
// The publish action is the default one, so it is spelled by the file it
|
|
1345
|
+
// takes rather than by a name.
|
|
1346
|
+
if (b.id && input?.file_path && (!input.action || input.action === 'publish')) {
|
|
1347
|
+
publishedFileByToolUseId.set(b.id, input.file_path);
|
|
1348
|
+
}
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (b?.type !== 'tool_result' || !publishedFileByToolUseId.has(b.tool_use_id)) continue;
|
|
1352
|
+
const m = toolResultText(b.content).match(ARTIFACT_URL_RE);
|
|
1353
|
+
if (!m) continue;
|
|
1354
|
+
const a = upsert(m[0], obj.timestamp);
|
|
1355
|
+
if (!a.path) a.path = publishedFileByToolUseId.get(b.tool_use_id);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
} catch (_) {}
|
|
1359
|
+
return [...byUrl.values()].sort((x, y) => (x.firstSeen < y.firstSeen ? -1 : x.firstSeen > y.firstSeen ? 1 : 0));
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1249
1362
|
function readCompactSummaries(jsonlPath) {
|
|
1250
1363
|
const results = [];
|
|
1251
1364
|
// Inline format: newer Claude Code stores the summary directly in the parent
|
|
@@ -1584,6 +1697,7 @@ module.exports = {
|
|
|
1584
1697
|
buildAgentProgressMap,
|
|
1585
1698
|
buildSessionDigest,
|
|
1586
1699
|
readCompactSummaries,
|
|
1700
|
+
readArtifactLinks,
|
|
1587
1701
|
findTerminatedTeammates,
|
|
1588
1702
|
extractPromptFromTranscript,
|
|
1589
1703
|
extractModelFromTranscript,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Tracks subagent lifecycle: one append-only JSONL file per agent, grouped by session
|
|
3
|
-
# Layout: ~/.claude
|
|
3
|
+
# Layout: <CLAUDE_CONFIG_DIR or ~/.claude>/.cck/agent-activity/{sessionId}/{agentId}.jsonl
|
|
4
4
|
# Each line is a lifecycle event (start | idle | stop). Server folds last-line-wins.
|
|
5
5
|
|
|
6
6
|
INPUT=$(cat)
|
|
@@ -17,7 +17,7 @@ eval "$(echo "$INPUT" | jq -r '
|
|
|
17
17
|
|
|
18
18
|
[ -z "$SESSION_ID" ] && exit 0
|
|
19
19
|
|
|
20
|
-
CCK_ACTIVITY="$HOME/.claude/.cck/agent-activity"
|
|
20
|
+
CCK_ACTIVITY="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck/agent-activity"
|
|
21
21
|
|
|
22
22
|
# Map session to custom task list on session start
|
|
23
23
|
if [ "$EVENT" = "SessionStart" ]; then
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Blocking approval gate: lets the cck board answer a permission ask or an
|
|
3
3
|
# AskUserQuestion. Always writes the _waiting.json marker first (badge behavior
|
|
4
|
-
# is unchanged when the feature is off), then —
|
|
5
|
-
# the board's server is alive — waits for a decision file written by the server.
|
|
4
|
+
# is unchanged when the feature is off), then — unless config.json opts out, and
|
|
5
|
+
# only while the board's server is alive — waits for a decision file written by the server.
|
|
6
6
|
#
|
|
7
|
-
# Contract (_plans/cck-ui-approvals/decisions.md):
|
|
8
|
-
# marker
|
|
9
|
-
# decision
|
|
10
|
-
# config
|
|
11
|
-
# liveness
|
|
7
|
+
# Contract (_plans/cck-ui-approvals/decisions.md), rooted at <CLAUDE_CONFIG_DIR or ~/.claude>/.cck:
|
|
8
|
+
# marker agent-activity/<sid>/_waiting.json (D8: + id, cwd, permissionSuggestions)
|
|
9
|
+
# decision agent-activity/<sid>/_decision-<id>.json (server writes it, Phase 3)
|
|
10
|
+
# config config.json {approvals: {enabled, mode, waitSeconds}} (on by default; absent = defaults)
|
|
11
|
+
# liveness server.json {port, pid} (D1: a dead board costs nothing)
|
|
12
12
|
#
|
|
13
13
|
# First writer wins (D5): a terminal answer deletes the marker via PostToolUse
|
|
14
14
|
# and this gate exits silently; a decision arriving after the tool already ran
|
|
@@ -37,7 +37,7 @@ KIND="permission"
|
|
|
37
37
|
[ "$TOOL_NAME" = "AskUserQuestion" ] && KIND="question"
|
|
38
38
|
[ "$TOOL_NAME" = "ExitPlanMode" ] && KIND="plan"
|
|
39
39
|
|
|
40
|
-
CCK_DIR="$HOME/.claude/.cck"
|
|
40
|
+
CCK_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck"
|
|
41
41
|
DIR="$CCK_DIR/agent-activity/$SESSION_ID"
|
|
42
42
|
MARKER="$DIR/_waiting.json"
|
|
43
43
|
mkdir -p "$DIR"
|
|
@@ -61,37 +61,58 @@ echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" --arg id "$REQ_ID" '{
|
|
|
61
61
|
# Every exit below leaves the marker in place for the badge; agent-spy.sh's
|
|
62
62
|
# PostToolUse (or the server's TTL) retires it, exactly as before this feature.
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
ENABLED=""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
# Defaults mirror lib/approvals.js — keep in sync. A missing or unparseable
|
|
65
|
+
# config.json means defaults, i.e. the gate is on.
|
|
66
|
+
ENABLED="true"
|
|
67
|
+
MODE="permission+question"
|
|
68
|
+
WAIT_SECONDS=""
|
|
69
|
+
CONFIG="$CCK_DIR/config.json"
|
|
70
|
+
if [ -f "$CONFIG" ]; then
|
|
71
|
+
eval "$(jq -r '
|
|
72
|
+
@sh "ENABLED=\(if .approvals.enabled == false then "false" else "true" end)",
|
|
73
|
+
@sh "MODE=\(.approvals.mode // "permission+question")",
|
|
74
|
+
@sh "WAIT_SECONDS=\(.approvals.waitSeconds // "")"
|
|
75
|
+
' < "$CONFIG" 2>/dev/null)"
|
|
76
|
+
fi
|
|
77
|
+
[ "$ENABLED" = "false" ] && exit 0
|
|
73
78
|
|
|
74
79
|
if [ "$KIND" = "question" ] && [ "$MODE" != "permission+question" ]; then
|
|
75
80
|
exit 0
|
|
76
81
|
fi
|
|
77
82
|
|
|
78
|
-
case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=
|
|
83
|
+
case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=1800 ;; esac
|
|
79
84
|
# PERMISSION_TTL_MS hides the card at 30 min — waiting longer than the UI can
|
|
80
85
|
# show the ask is strictly worse than giving up (D11)
|
|
81
86
|
[ "$WAIT_SECONDS" -gt 1800 ] && WAIT_SECONDS=1800
|
|
82
87
|
|
|
83
88
|
SERVER_INFO="$CCK_DIR/server.json"
|
|
84
|
-
|
|
85
|
-
SERVER_PORT=$(jq -r '.port // empty' < "$SERVER_INFO" 2>/dev/null)
|
|
86
|
-
[ -n "$SERVER_PORT" ] || exit 0
|
|
89
|
+
|
|
87
90
|
# A TCP connect beats a pid probe: it proves the board is actually serving, and
|
|
88
91
|
# it works in the stripped environment Claude Code spawns hooks into, where
|
|
89
|
-
# kill -0 cannot see native Windows pids and ps may be missing from PATH
|
|
90
|
-
|
|
92
|
+
# kill -0 cannot see native Windows pids and ps may be missing from PATH. The
|
|
93
|
+
# port is re-read on every probe, not cached: under the hub every sub-app binds
|
|
94
|
+
# an ephemeral port, so a board that restarts mid-wait comes back on a new one.
|
|
95
|
+
# $(<file) and =~ keep the probe free of jq and cat spawns (~280 ms each here).
|
|
96
|
+
board_alive() {
|
|
97
|
+
local raw port
|
|
98
|
+
raw=$(<"$SERVER_INFO") 2>/dev/null || return 1
|
|
99
|
+
[[ $raw =~ \"port\"[[:space:]]*:[[:space:]]*([0-9]+) ]] || return 1
|
|
100
|
+
port=${BASH_REMATCH[1]}
|
|
101
|
+
(: < "/dev/tcp/127.0.0.1/$port") 2>/dev/null
|
|
102
|
+
}
|
|
91
103
|
|
|
92
104
|
DECISION="$DIR/_decision-$REQ_ID.json"
|
|
93
105
|
# EPOCHSECONDS (bash 5) keeps the poll loop free of `date` spawns
|
|
94
106
|
DEADLINE=$((EPOCHSECONDS + WAIT_SECONDS))
|
|
107
|
+
# Probing once up front made one unreachable moment disarm the whole ask, so a
|
|
108
|
+
# board restarting on a new port (every hub-spawned sub-app binds an ephemeral
|
|
109
|
+
# one) took the ask with it. Probe on a cadence instead and only give up once
|
|
110
|
+
# the board has been gone for the whole grace. The terminal prompt stays live
|
|
111
|
+
# throughout either way.
|
|
112
|
+
BOARD_PROBE_SECONDS=5
|
|
113
|
+
BOARD_GRACE_SECONDS=15
|
|
114
|
+
NEXT_PROBE=0
|
|
115
|
+
UNREACHABLE_SINCE=0
|
|
95
116
|
|
|
96
117
|
while :; do
|
|
97
118
|
if [ -f "$DECISION" ]; then
|
|
@@ -136,6 +157,20 @@ while :; do
|
|
|
136
157
|
IFS= read -r CUR_MARKER < "$MARKER" 2>/dev/null || exit 0
|
|
137
158
|
case "$CUR_MARKER" in *"\"id\":\"$REQ_ID\""*) ;; *) exit 0 ;; esac
|
|
138
159
|
|
|
160
|
+
if [ "$EPOCHSECONDS" -ge "$NEXT_PROBE" ]; then
|
|
161
|
+
NEXT_PROBE=$((EPOCHSECONDS + BOARD_PROBE_SECONDS))
|
|
162
|
+
# No beacon at all = no board on this config dir, now or a moment ago: give
|
|
163
|
+
# up at once, exactly as before. A beacon whose port is closed is the
|
|
164
|
+
# restart window instead, so that one gets the grace.
|
|
165
|
+
[ -f "$SERVER_INFO" ] || exit 0
|
|
166
|
+
if board_alive; then
|
|
167
|
+
UNREACHABLE_SINCE=0
|
|
168
|
+
else
|
|
169
|
+
[ "$UNREACHABLE_SINCE" -eq 0 ] && UNREACHABLE_SINCE=$EPOCHSECONDS
|
|
170
|
+
[ $((EPOCHSECONDS - UNREACHABLE_SINCE)) -ge "$BOARD_GRACE_SECONDS" ] && exit 0
|
|
171
|
+
fi
|
|
172
|
+
fi
|
|
173
|
+
|
|
139
174
|
[ "$EPOCHSECONDS" -ge "$DEADLINE" ] && exit 0
|
|
140
175
|
sleep 0.5
|
|
141
176
|
done
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Statusline spy: writes raw context data for kanban dashboard, passes input through
|
|
3
|
-
# Layout: ~/.claude
|
|
3
|
+
# Layout: <CLAUDE_CONFIG_DIR or ~/.claude>/.cck/context-status/{sessionId}.json
|
|
4
4
|
#
|
|
5
5
|
# Usage: pipe before your statusline command:
|
|
6
6
|
# "command": "~/.claude/hooks/context-status.sh | npx -y ccstatusline@latest"
|
|
@@ -9,7 +9,7 @@ INPUT=$(cat)
|
|
|
9
9
|
|
|
10
10
|
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""')
|
|
11
11
|
if [ -n "$SESSION_ID" ]; then
|
|
12
|
-
DIR="$HOME/.claude/.cck/context-status"
|
|
12
|
+
DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck/context-status"
|
|
13
13
|
mkdir -p "$DIR"
|
|
14
14
|
echo "$INPUT" > "$DIR/$SESSION_ID.json"
|
|
15
15
|
fi
|
|
@@ -19,7 +19,8 @@ const os = require('os');
|
|
|
19
19
|
const path = require('path');
|
|
20
20
|
|
|
21
21
|
const SESSION_ID = process.env.CLAUDE_CODE_SESSION_ID;
|
|
22
|
-
const
|
|
22
|
+
const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
23
|
+
const SERVER_INFO = path.join(CLAUDE_DIR, '.cck', 'server.json');
|
|
23
24
|
// The server caps its own wait at 120s. Sitting at the ceiling halves every recurring
|
|
24
25
|
// cost -- handshake, route walk, timer, empty response -- and costs no event latency,
|
|
25
26
|
// because an enqueue wakes the poll immediately.
|