claude-code-kanban 4.29.0 → 4.30.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 +3 -2
- package/cli.js +383 -70
- package/install.js +7 -8
- package/lib/approvals.js +2 -2
- package/lib/contain.js +2 -2
- package/lib/dispatch-groups.js +28 -11
- package/lib/dispatch.js +1 -0
- package/lib/file-url.js +1 -1
- package/lib/inline-assets.js +2 -2
- package/lib/linked-docs.js +56 -0
- package/lib/net-guard.js +1 -1
- package/lib/open-editor.js +5 -3
- package/lib/parent-cache.js +58 -0
- package/lib/parsers.js +175 -168
- package/lib/session-cache.js +34 -0
- package/lib/session-events.js +2 -1
- package/lib/task-counts.js +90 -0
- package/lib/terminal.js +9 -4
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +3 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +16 -46
- package/public/app.js +563 -313
- package/public/index.html +46 -23
- package/public/project-match.js +27 -0
- package/public/style.css +306 -94
- package/public/sw.js +1 -1
- package/server.js +167 -74
- package/skill-guides/dispatch.md +5 -4
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const { exportSessionCaches, importSessionCaches, sessionCachesDirty } = require('./parsers');
|
|
3
|
+
const { exportParentVerdicts, importParentVerdicts, parentVerdictsDirty } = require('./parent-cache');
|
|
4
|
+
const { exportTaskCounts, importTaskCounts, taskCountsDirty } = require('./task-counts');
|
|
5
|
+
|
|
6
|
+
// Bump when an entry's shape or meaning changes: a file with another version is ignored.
|
|
7
|
+
const VERSION = 2;
|
|
8
|
+
// A cache this large is not one cck wrote for a normal set of transcripts. Rebuild it cold.
|
|
9
|
+
const MAX_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
function loadSessionCache(file) {
|
|
12
|
+
try {
|
|
13
|
+
if (fs.statSync(file).size > MAX_BYTES) {
|
|
14
|
+
fs.rmSync(file, { force: true });
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
18
|
+
if (data?.version !== VERSION) return false;
|
|
19
|
+
importSessionCaches(data);
|
|
20
|
+
importParentVerdicts(data.parents);
|
|
21
|
+
importTaskCounts(data.taskCounts);
|
|
22
|
+
return true;
|
|
23
|
+
} catch (_) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// write(data) must replace the file atomically; server.js passes writeJsonAtomic.
|
|
29
|
+
function saveSessionCache(write) {
|
|
30
|
+
if (!sessionCachesDirty() && !parentVerdictsDirty() && !taskCountsDirty()) return;
|
|
31
|
+
write({ version: VERSION, ...exportSessionCaches(), parents: exportParentVerdicts(), taskCounts: exportTaskCounts() });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { loadSessionCache, saveSessionCache };
|
package/lib/session-events.js
CHANGED
|
@@ -25,6 +25,7 @@ const sessionEventBuckets = new Map();
|
|
|
25
25
|
// One cap for the whole line rather than one per field, because the description comes
|
|
26
26
|
// last: truncation eats its tail first and leaves the machine-readable head intact.
|
|
27
27
|
function sanitizeEventLine(line) {
|
|
28
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
28
29
|
return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -81,7 +82,7 @@ function handleSessionEvents(req, res) {
|
|
|
81
82
|
// bucket is still evicted from the map.
|
|
82
83
|
if (bucket && req.query.first === '1') drain(sessionId, bucket);
|
|
83
84
|
|
|
84
|
-
if (bucket
|
|
85
|
+
if (bucket?.queue.length) return res.json({ events: drain(sessionId, bucket) });
|
|
85
86
|
if (!wait) return res.json({ events: [] });
|
|
86
87
|
|
|
87
88
|
const pending = bucket || { queue: [], waiters: new Set() };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
|
|
4
|
+
// A task file's status is reused while its size, mtime and inode are unchanged. An mtime can be
|
|
5
|
+
// as coarse as 2 s (FAT), so a file read within that window of its last write could be written
|
|
6
|
+
// again with the same stamp and size. Such a file is not stored and is read again next time.
|
|
7
|
+
const RACY_MS = 3000;
|
|
8
|
+
const SKIP = 'skip';
|
|
9
|
+
const STATUSES = new Set(['completed', 'in_progress', 'pending', SKIP]);
|
|
10
|
+
|
|
11
|
+
const dirs = new Map();
|
|
12
|
+
let dirty = false;
|
|
13
|
+
|
|
14
|
+
function readStatus(taskPath) {
|
|
15
|
+
const text = fs.readFileSync(taskPath, 'utf8');
|
|
16
|
+
let task;
|
|
17
|
+
try {
|
|
18
|
+
task = JSON.parse(text);
|
|
19
|
+
} catch (_) {
|
|
20
|
+
return SKIP;
|
|
21
|
+
}
|
|
22
|
+
if (task == null || task.metadata?._internal) return SKIP;
|
|
23
|
+
if (task.status === 'completed' || task.status === 'in_progress') return task.status;
|
|
24
|
+
return 'pending';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function countTaskDir(dir) {
|
|
28
|
+
const prev = dirs.get(dir);
|
|
29
|
+
const next = new Map();
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const tally = { completed: 0, in_progress: 0, pending: 0 };
|
|
32
|
+
let newestTaskMtime = null;
|
|
33
|
+
|
|
34
|
+
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
|
35
|
+
const taskPath = path.join(dir, file);
|
|
36
|
+
let st;
|
|
37
|
+
try {
|
|
38
|
+
st = fs.statSync(taskPath);
|
|
39
|
+
} catch (_) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const e = prev?.get(file);
|
|
43
|
+
let status;
|
|
44
|
+
if (e && e.size === st.size && e.mtimeMs === st.mtimeMs && e.ino === st.ino) {
|
|
45
|
+
status = e.status;
|
|
46
|
+
next.set(file, e);
|
|
47
|
+
} else {
|
|
48
|
+
try {
|
|
49
|
+
status = readStatus(taskPath);
|
|
50
|
+
} catch (_) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (now - st.mtimeMs >= RACY_MS) {
|
|
54
|
+
next.set(file, { size: st.size, mtimeMs: st.mtimeMs, ino: st.ino, status });
|
|
55
|
+
dirty = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (status === SKIP) continue;
|
|
59
|
+
tally[status]++;
|
|
60
|
+
if (!newestTaskMtime || st.mtime > newestTaskMtime) newestTaskMtime = st.mtime;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (prev && prev.size !== next.size) dirty = true;
|
|
64
|
+
dirs.set(dir, next);
|
|
65
|
+
return { completed: tally.completed, inProgress: tally.in_progress, pending: tally.pending, newestTaskMtime };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const taskCountsDirty = () => dirty;
|
|
69
|
+
|
|
70
|
+
function exportTaskCounts() {
|
|
71
|
+
dirty = false;
|
|
72
|
+
return [...dirs].filter(([dir]) => fs.existsSync(dir)).map(([dir, files]) => [dir, [...files]]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function importTaskCounts(entries) {
|
|
76
|
+
if (!Array.isArray(entries)) return;
|
|
77
|
+
const num = (x) => typeof x === 'number';
|
|
78
|
+
for (const pair of entries) {
|
|
79
|
+
if (typeof pair?.[0] !== 'string' || !Array.isArray(pair[1])) continue;
|
|
80
|
+
const files = new Map();
|
|
81
|
+
for (const item of pair[1]) {
|
|
82
|
+
const [file, e] = Array.isArray(item) ? item : [];
|
|
83
|
+
if (typeof file !== 'string' || !e || !num(e.size) || !num(e.mtimeMs) || !num(e.ino) || !STATUSES.has(e.status)) continue;
|
|
84
|
+
files.set(file, e);
|
|
85
|
+
}
|
|
86
|
+
dirs.set(pair[0], files);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { countTaskDir, taskCountsDirty, exportTaskCounts, importTaskCounts };
|
package/lib/terminal.js
CHANGED
|
@@ -60,7 +60,7 @@ function readTerminalConfig({ argv = process.argv, env = process.env, getArgValu
|
|
|
60
60
|
return {
|
|
61
61
|
enabled: argv.includes('--enable-terminal') || cfg.enabled === true,
|
|
62
62
|
shell:
|
|
63
|
-
|
|
63
|
+
getArgValue?.('terminal-shell') ||
|
|
64
64
|
env.CCK_TERMINAL_SHELL ||
|
|
65
65
|
(typeof cfg.shell === 'string' && cfg.shell) ||
|
|
66
66
|
null,
|
|
@@ -151,6 +151,7 @@ function parseNewSpec(msg) {
|
|
|
151
151
|
if (typeof worktree !== 'boolean' && !(typeof worktree === 'string' && WORKTREE_RE.test(worktree))) return 'worktree name';
|
|
152
152
|
const model = msg.model || null;
|
|
153
153
|
if (model && !MODELS.has(model)) return 'model';
|
|
154
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
|
|
154
155
|
const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
|
|
155
156
|
if (prompt.length > MAX_PROMPT) return 'prompt';
|
|
156
157
|
return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null };
|
|
@@ -179,11 +180,15 @@ function screenText(term) {
|
|
|
179
180
|
|
|
180
181
|
// Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
|
|
181
182
|
// inside the dir when set, so the default dir must stay unset, not be spelled out.
|
|
182
|
-
|
|
183
|
+
// CCK_URL pins the CLI and the postman to this board: two boards on one config dir share
|
|
184
|
+
// one server.json, and the last one started owns it.
|
|
185
|
+
function ptyEnv({ claudeDir, isDefaultDir, noFlicker, cckUrl }) {
|
|
183
186
|
const env = { ...process.env };
|
|
184
187
|
for (const k of STRIP_ENV) delete env[k];
|
|
185
188
|
if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
|
|
186
189
|
else env.CLAUDE_CONFIG_DIR = claudeDir;
|
|
190
|
+
if (cckUrl) env.CCK_URL = cckUrl;
|
|
191
|
+
else delete env.CCK_URL;
|
|
187
192
|
// xterm.js #5801: a clear inside a synchronized-output block jumps the viewport.
|
|
188
193
|
if (noFlicker) env.CLAUDE_CODE_NO_FLICKER = '1';
|
|
189
194
|
env.TERM = 'xterm-256color';
|
|
@@ -344,7 +349,7 @@ function createTerminalService(o) {
|
|
|
344
349
|
|
|
345
350
|
function spawnSession(id, mode, cwdCandidate, cols, rows, spec, extraEnv) {
|
|
346
351
|
const cwd = cwdCandidate && fs.existsSync(cwdCandidate) ? cwdCandidate : os.homedir();
|
|
347
|
-
const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker }), ...extraEnv };
|
|
352
|
+
const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker, cckUrl: o.serverUrl?.() }), ...extraEnv };
|
|
348
353
|
// Git Bash's login profile cds to $HOME unless this is set.
|
|
349
354
|
if (shellFamily(shell) === 'gitbash') env.CHERE_INVOKING = '1';
|
|
350
355
|
const proc = pty.spawn(shell, shellArgs(shell, claudeArgsFor(mode, id, spec)), {
|
|
@@ -596,4 +601,4 @@ function createTerminalService(o) {
|
|
|
596
601
|
return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, shutdown, unavailableReason };
|
|
597
602
|
}
|
|
598
603
|
|
|
599
|
-
module.exports = { createTerminalService, readTerminalConfig, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
|
604
|
+
module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.30.0",
|
|
4
4
|
"description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
|
|
5
5
|
"main": "server.js",
|
|
6
|
+
"type": "commonjs",
|
|
6
7
|
"bin": {
|
|
7
8
|
"claude-code-kanban": "server.js"
|
|
8
9
|
},
|
|
@@ -38,7 +38,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
38
38
|
// Re-read every cycle rather than caching: it is how we follow the board across a
|
|
39
39
|
// restart onto a different port. A file left behind by a crashed server names a port
|
|
40
40
|
// something else may now hold, so trust it only while its pid is alive.
|
|
41
|
+
// CCK_URL comes from the board whose terminal started this session. That board owns the
|
|
42
|
+
// session's queue, and server.json may name another board on the same config dir.
|
|
41
43
|
function serverUrl() {
|
|
44
|
+
if (process.env.CCK_URL) return process.env.CCK_URL.replace(/\/+$/, '');
|
|
42
45
|
const { port, pid } = JSON.parse(fs.readFileSync(SERVER_INFO, 'utf8'));
|
|
43
46
|
if (pid) process.kill(pid, 0);
|
|
44
47
|
return `http://127.0.0.1:${port}`;
|
|
@@ -9,58 +9,28 @@ disable-model-invocation: true
|
|
|
9
9
|
|
|
10
10
|
This session id is `${CLAUDE_SESSION_ID}`, substituted when the skill loads.
|
|
11
11
|
|
|
12
|
-
An argument names the
|
|
12
|
+
An argument names the command that handles it; with no argument, open the current session. Prefer the bare `claude-code-kanban` binary, falling back to `npx claude-code-kanban` when it is off PATH or the user asks for npx.
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
## `open` — the current session
|
|
17
|
-
|
|
18
|
-
Pins the session and switches the board to the Active tab.
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
claude-code-kanban session open ${CLAUDE_SESSION_ID}
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
## `pin` — keep the session visible
|
|
25
|
-
|
|
26
|
-
```bash
|
|
27
|
-
claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
|
|
28
|
-
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # always at the top
|
|
29
|
-
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
|
|
30
|
-
claude-code-kanban session pins # list pinned; --sticky narrows
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## `preview` — open a file in the modal
|
|
34
|
-
|
|
35
|
-
Markdown or standalone HTML. HTML renders in a sandboxed iframe, so sibling assets like `./style.css` do not load. Relative paths are fine — the server resolves them.
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
claude-code-kanban preview-doc <file.md|.html> --session ${CLAUDE_SESSION_ID}
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## `link` — attach a doc without the modal
|
|
42
|
-
|
|
43
|
-
Adds the file to the session's linked docs in the sidebar. Any extension, and nothing pops up, so it is the safe choice while the user is working.
|
|
14
|
+
The CLI help is the reference, and it always matches the installed binary. Read it before you run a command, instead of guessing flags:
|
|
44
15
|
|
|
45
16
|
```bash
|
|
46
|
-
claude-code-kanban
|
|
47
|
-
claude-code-kanban
|
|
17
|
+
claude-code-kanban help # every command
|
|
18
|
+
claude-code-kanban help <command> # its subcommands
|
|
19
|
+
claude-code-kanban help <command> <subcommand> # flags and examples
|
|
48
20
|
```
|
|
49
21
|
|
|
50
|
-
|
|
22
|
+
| Argument | Command |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `open` (or none) | `session open ${CLAUDE_SESSION_ID}` |
|
|
25
|
+
| `pin` / `unpin` | `session pin ${CLAUDE_SESSION_ID}` (`--sticky`, `--unpin`) |
|
|
26
|
+
| `preview` | `preview-doc <file> --session ${CLAUDE_SESSION_ID}` — opens a modal on the user's screen |
|
|
27
|
+
| `link` | `link-doc <file> --session ${CLAUDE_SESSION_ID}` — no modal, so it is the safe choice while the user is working |
|
|
28
|
+
| `list` / `search` | `session list`, `session search <text>` |
|
|
29
|
+
| `view` / `peek` | `session view <id>`, `session peek <id>` |
|
|
30
|
+
| tasks, projects | `task list`, `project list` |
|
|
51
31
|
|
|
52
|
-
|
|
53
|
-
claude-code-kanban session list --active # recent active sessions
|
|
54
|
-
claude-code-kanban session list --project <name> # filter by project
|
|
55
|
-
claude-code-kanban session list --days 0.5 --limit all # touched in last 12h, uncapped
|
|
56
|
-
claude-code-kanban session view ${CLAUDE_SESSION_ID} # full stats for current session
|
|
57
|
-
claude-code-kanban session peek ${CLAUDE_SESSION_ID} --limit 20 # last 20 messages (server caps at 50)
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
`session list` shows 10 rows and always includes pinned sessions, sticky first (`--no-pins` disables both). `--json` works on any list-style verb.
|
|
32
|
+
To be driven *by* the board instead — card moves arriving as instructions — the user types `/claude-code-kanban:kanban-follow`.
|
|
61
33
|
|
|
62
34
|
## Troubleshooting
|
|
63
35
|
|
|
64
|
-
`claude-code-kanban
|
|
65
|
-
|
|
66
|
-
- **"Cannot reach cck server…"** → the error names the port it tried. Ask the user to start the server with `claude-code-kanban`. If they run it elsewhere, set `PORT=<n>` when invoking the CLI.
|
|
36
|
+
- **"Cannot reach cck server…"** → the error names the config dir and the port it tried. Ask the user to start the server with `claude-code-kanban`. If they run it elsewhere, set `PORT=<n>` or `CCK_URL=<url>` when invoking the CLI.
|