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
package/install.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const { execSync } = require('child_process');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const readline = require('node:readline');
|
|
6
|
+
const { execSync } = require('node:child_process');
|
|
8
7
|
const { getClaudeDir, claudeCliEnv, displayPath } = require('./lib/claude-dir');
|
|
9
8
|
|
|
10
9
|
const CLAUDE_DIR = getClaudeDir();
|
|
@@ -205,7 +204,7 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
205
204
|
console.log(`\n StatusLine: ${dim('not configured')}`);
|
|
206
205
|
if (await prompt(` Set up context tracking statusline? [Y/n] `)) {
|
|
207
206
|
settings.statusLine = { type: 'command', command: CTX_COMMAND };
|
|
208
|
-
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2)
|
|
207
|
+
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
|
|
209
208
|
console.log(` ${green('✓')} StatusLine configured`);
|
|
210
209
|
} else {
|
|
211
210
|
console.log(` ${dim('Skipped')}`);
|
|
@@ -216,7 +215,7 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
216
215
|
if (await prompt(` Prepend context spy to existing statusline? [Y/n] `)) {
|
|
217
216
|
settings.statusLine.type = 'command';
|
|
218
217
|
settings.statusLine.command = `${CTX_COMMAND} | ${existing}`;
|
|
219
|
-
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2)
|
|
218
|
+
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
|
|
220
219
|
console.log(` ${green('✓')} StatusLine updated`);
|
|
221
220
|
} else {
|
|
222
221
|
console.log(` ${dim('Skipped')}`);
|
|
@@ -294,7 +293,7 @@ async function runUninstall() {
|
|
|
294
293
|
}
|
|
295
294
|
|
|
296
295
|
if (changed) {
|
|
297
|
-
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2)
|
|
296
|
+
fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(settings, null, 2)}\n`);
|
|
298
297
|
}
|
|
299
298
|
} catch {
|
|
300
299
|
console.log(` Settings: ${red('✗')} Could not parse settings.json`);
|
package/lib/approvals.js
CHANGED
|
@@ -17,7 +17,7 @@ function buildDecision(marker, body) {
|
|
|
17
17
|
if (!marker || marker.status !== 'waiting') {
|
|
18
18
|
return { error: 'No pending ask', status: 410 };
|
|
19
19
|
}
|
|
20
|
-
const id = sanitizeRequestId(body
|
|
20
|
+
const id = sanitizeRequestId(body?.id);
|
|
21
21
|
if (!id) return { error: 'Invalid or missing request id', status: 400 };
|
|
22
22
|
if (marker.id !== id) return { error: 'Ask superseded by a newer one', status: 409 };
|
|
23
23
|
|
|
@@ -68,7 +68,7 @@ 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
70
|
function waitSecondsFrom(cfg) {
|
|
71
|
-
const raw = cfg
|
|
71
|
+
const raw = cfg?.waitSeconds;
|
|
72
72
|
if (Number.isInteger(raw) && raw >= 0) return Math.min(raw, WAIT_SECONDS_MAX);
|
|
73
73
|
return WAIT_SECONDS_DEFAULT;
|
|
74
74
|
}
|
package/lib/contain.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// CANONICAL COPY: scripts/security-lib/contain.js in the claude-code-hub repo.
|
|
12
12
|
// Run scripts/sync-security-lib.sh after editing.
|
|
13
13
|
|
|
14
|
-
const path = require('path');
|
|
15
|
-
const fs = require('fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const fs = require('node:fs');
|
|
16
16
|
|
|
17
17
|
const IS_WIN = process.platform === 'win32';
|
|
18
18
|
|
package/lib/dispatch-groups.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Transient session groups: `dispatch start --group` (or the starter's own group) puts
|
|
2
|
-
// the started session
|
|
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
|
|
3
4
|
// runs, and for a grace period after; once none runs, only pinned members stay in it.
|
|
4
5
|
// The map is on disk so a pinned group outlives a server restart.
|
|
5
6
|
|
|
@@ -24,28 +25,39 @@ function suggestGroupName(s) {
|
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* @param {object} o
|
|
27
|
-
* @param {() => object|null} o.load returns `{sessions: {[id]: group}}` or null
|
|
28
|
+
* @param {() => object|null} o.load returns `{sessions: {[id]: group}, starters?: {[id]: starterId}}` or null
|
|
28
29
|
* @param {(data: object) => void} o.save
|
|
29
30
|
* @param {(id: string) => boolean} o.isAlive
|
|
30
31
|
* @param {() => Set<string>} o.pinnedIds
|
|
31
32
|
*/
|
|
32
33
|
function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
|
|
33
34
|
const members = new Map();
|
|
35
|
+
const starterOf = new Map();
|
|
34
36
|
const liveAt = new Map();
|
|
35
|
-
const saved = load()
|
|
36
|
-
if (saved && typeof saved === 'object') {
|
|
37
|
-
for (const [id, g] of Object.entries(saved)) if (isGroupName(g)) members.set(id, g);
|
|
37
|
+
const saved = load();
|
|
38
|
+
if (saved?.sessions && typeof saved.sessions === 'object') {
|
|
39
|
+
for (const [id, g] of Object.entries(saved.sessions)) if (isGroupName(g)) members.set(id, g);
|
|
40
|
+
}
|
|
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);
|
|
38
43
|
}
|
|
39
44
|
for (const g of members.values()) liveAt.set(g, now());
|
|
40
45
|
|
|
41
|
-
const persist = () =>
|
|
46
|
+
const persist = () =>
|
|
47
|
+
save({ version: 1, sessions: Object.fromEntries(members), starters: Object.fromEntries(starterOf) });
|
|
42
48
|
|
|
43
|
-
function join(group, ids) {
|
|
49
|
+
function join(group, ids, starter) {
|
|
44
50
|
let changed = false;
|
|
45
51
|
for (const id of ids) {
|
|
46
|
-
if (!id
|
|
47
|
-
members.
|
|
48
|
-
|
|
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
|
+
}
|
|
49
61
|
}
|
|
50
62
|
liveAt.set(group, now());
|
|
51
63
|
if (changed) persist();
|
|
@@ -70,6 +82,7 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
|
|
|
70
82
|
for (const id of ids) {
|
|
71
83
|
if (pinned.has(id)) continue;
|
|
72
84
|
members.delete(id);
|
|
85
|
+
starterOf.delete(id);
|
|
73
86
|
changed = true;
|
|
74
87
|
}
|
|
75
88
|
if (!ids.some((id) => pinned.has(id))) liveAt.delete(g);
|
|
@@ -77,9 +90,13 @@ function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
|
|
|
77
90
|
if (changed) persist();
|
|
78
91
|
}
|
|
79
92
|
|
|
93
|
+
// A starter is not a member; it defaults to the group of a session it started.
|
|
80
94
|
function groupOf(id) {
|
|
81
95
|
refresh();
|
|
82
|
-
return members.get(id)
|
|
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;
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
function snapshot() {
|
package/lib/dispatch.js
CHANGED
|
@@ -112,6 +112,7 @@ function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
|
|
|
112
112
|
if (!tokenMatches(r.cap, cap)) return { status: 403, error: 'wrong capability' };
|
|
113
113
|
if (r.status !== 'running') return { status: 409, error: `already ${r.status}` };
|
|
114
114
|
if (!OUTCOMES.has(outcome)) return { status: 400, error: 'outcome must be succeeded or failed' };
|
|
115
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
115
116
|
const text = typeof summary === 'string' ? summary.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, MAX_SUMMARY) : '';
|
|
116
117
|
finish(r, outcome, text || null);
|
|
117
118
|
return null;
|
package/lib/file-url.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// spelling `file:\\\C:\dir\a.md`. Both are normalized to a plain OS path so the
|
|
7
7
|
// user does not have to hand-edit what their clipboard produced.
|
|
8
8
|
|
|
9
|
-
const { fileURLToPath } = require('url');
|
|
9
|
+
const { fileURLToPath } = require('node:url');
|
|
10
10
|
|
|
11
11
|
// Matches any number of leading slashes before a drive letter, so it re-spells both
|
|
12
12
|
// the well-formed `file:///C:/dir` and `file://C:/dir`, where the parser reads C: as
|
package/lib/inline-assets.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Remote refs (https:, //cdn, data:) are left untouched: they already resolve.
|
|
7
7
|
|
|
8
|
-
const path = require('path');
|
|
9
|
-
const fs = require('fs').promises;
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const fs = require('node:fs').promises;
|
|
10
10
|
|
|
11
11
|
// Individually large assets are the ones that blow up as base64 (+33%), and the total
|
|
12
12
|
// guards the modal: the whole document is handed to the client in one JSON response.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Server copy of each session's linked docs. The browser keeps its own list in localStorage
|
|
2
|
+
// and merges this one in when it connects, so a `link-doc` sent while no tab is open is not lost.
|
|
3
|
+
// Newest first, capped like the browser list.
|
|
4
|
+
|
|
5
|
+
const MAX_PER_SESSION = 20;
|
|
6
|
+
|
|
7
|
+
// Same rule as `canonicalPath` in public/app.js: one Windows file is reached as `C:\a` and `c:/a`.
|
|
8
|
+
function canonicalPath(p) {
|
|
9
|
+
const slashed = String(p).replace(/\\/g, '/');
|
|
10
|
+
return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} o
|
|
15
|
+
* @param {() => object|null} o.load returns `{sessions: {[id]: string[]}}` or null
|
|
16
|
+
* @param {(data: object) => void} o.save
|
|
17
|
+
*/
|
|
18
|
+
function createLinkedDocStore({ load, save }) {
|
|
19
|
+
const bySession = new Map();
|
|
20
|
+
const saved = load()?.sessions;
|
|
21
|
+
if (saved && typeof saved === 'object') {
|
|
22
|
+
for (const [id, paths] of Object.entries(saved)) {
|
|
23
|
+
const clean = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p) : [];
|
|
24
|
+
if (clean.length) bySession.set(id, clean.slice(0, MAX_PER_SESSION));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const persist = () => save({ version: 1, sessions: Object.fromEntries(bySession) });
|
|
29
|
+
|
|
30
|
+
function link(sessionId, filePath) {
|
|
31
|
+
const key = canonicalPath(filePath);
|
|
32
|
+
const rest = (bySession.get(sessionId) || []).filter((p) => canonicalPath(p) !== key);
|
|
33
|
+
bySession.set(sessionId, [filePath, ...rest].slice(0, MAX_PER_SESSION));
|
|
34
|
+
persist();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// With no path, forgets every doc of the session. Returns whether anything was removed.
|
|
38
|
+
function unlink(sessionId, filePath) {
|
|
39
|
+
const paths = bySession.get(sessionId);
|
|
40
|
+
if (!paths) return false;
|
|
41
|
+
const key = filePath == null ? null : canonicalPath(filePath);
|
|
42
|
+
const rest = key === null ? [] : paths.filter((p) => canonicalPath(p) !== key);
|
|
43
|
+
if (rest.length === paths.length) return false;
|
|
44
|
+
if (rest.length) bySession.set(sessionId, rest);
|
|
45
|
+
else bySession.delete(sessionId);
|
|
46
|
+
persist();
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const get = (sessionId) => [...(bySession.get(sessionId) || [])];
|
|
51
|
+
const all = () => Object.fromEntries([...bySession].map(([id, paths]) => [id, [...paths]]));
|
|
52
|
+
|
|
53
|
+
return { link, unlink, get, all };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { createLinkedDocStore, canonicalPath, MAX_PER_SESSION };
|
package/lib/net-guard.js
CHANGED
|
@@ -245,7 +245,7 @@ function createNetGuard(config = {}) {
|
|
|
245
245
|
* @param {{maxHeaderSize?: number, onUpgrade?: (req: any, socket: any, head: Buffer) => void}} [opts]
|
|
246
246
|
*/
|
|
247
247
|
function listenLoopback(app, port, onReady, opts = {}) {
|
|
248
|
-
const http = require('http');
|
|
248
|
+
const http = require('node:http');
|
|
249
249
|
const serverOpts = opts.maxHeaderSize ? { maxHeaderSize: opts.maxHeaderSize } : {};
|
|
250
250
|
const makeServer = (handler) => {
|
|
251
251
|
const s = http.createServer(serverOpts, handler);
|
package/lib/open-editor.js
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
// npm packages and cannot import from each other. Run scripts/sync-security-lib.sh
|
|
22
22
|
// after editing.
|
|
23
23
|
|
|
24
|
-
const { spawn } = require('child_process');
|
|
25
|
-
const path = require('path');
|
|
26
|
-
const fs = require('fs');
|
|
24
|
+
const { spawn } = require('node:child_process');
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const fs = require('node:fs');
|
|
27
27
|
|
|
28
28
|
const IS_WIN = process.platform === 'win32';
|
|
29
29
|
|
|
@@ -37,6 +37,7 @@ const VSCODE_FAMILY = new Set([
|
|
|
37
37
|
// escape it. Inside "..." cmd treats & | < > ^ ( ) as literal, so only these
|
|
38
38
|
// matter: '"' ends the quoted run, '%' triggers variable expansion whose value
|
|
39
39
|
// could itself contain a quote. Neither is legal in an NTFS path anyway.
|
|
40
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
|
|
40
41
|
const WIN_CMD_FORBIDDEN = /["%\r\n\u0000]/;
|
|
41
42
|
|
|
42
43
|
function fail(message, status) {
|
|
@@ -168,6 +169,7 @@ function resolveEditor() {
|
|
|
168
169
|
*/
|
|
169
170
|
function assertOpenTarget(value, label = 'path') {
|
|
170
171
|
if (typeof value !== 'string' || !value.trim()) throw fail(`${label} must be a non-empty string`, 400);
|
|
172
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: NUL is one of the rejected characters
|
|
171
173
|
if (/[\r\n\u0000]/.test(value)) throw fail(`${label} contains invalid characters`, 400);
|
|
172
174
|
const resolved = path.resolve(value);
|
|
173
175
|
if (!fs.existsSync(resolved)) throw fail(`${label} does not exist`, 404);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { countTranscriptsBornBefore } = require('./parsers');
|
|
4
|
+
|
|
5
|
+
// Verdicts of lookupParentSession in server.js. A verdict depends on the child's head and on
|
|
6
|
+
// the transcripts of its folder born before it, because only those can be its parent. So a new
|
|
7
|
+
// transcript cannot change it, but a replaced child or parent, a compact anchor seen later, or
|
|
8
|
+
// an older transcript moved into the folder can. Each entry records these and is checked on use.
|
|
9
|
+
const verdicts = new Map();
|
|
10
|
+
let dirty = false;
|
|
11
|
+
|
|
12
|
+
function getParentVerdict(sessionId, meta) {
|
|
13
|
+
const v = verdicts.get(sessionId);
|
|
14
|
+
if (!v || !meta?.jsonlPath || v.childPath !== meta.jsonlPath) return null;
|
|
15
|
+
if (v.logicalParentUuid !== (meta.logicalParentUuid || null)) return null;
|
|
16
|
+
try {
|
|
17
|
+
const self = fs.statSync(v.childPath);
|
|
18
|
+
if (self.ino !== v.childIno) return null;
|
|
19
|
+
if (v.parentIno !== null && fs.statSync(v.result.parentJsonlPath).ino !== v.parentIno) return null;
|
|
20
|
+
if (countTranscriptsBornBefore(path.dirname(v.childPath), self.birthtimeMs) !== v.olderCount) return null;
|
|
21
|
+
} catch (_) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return v.result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// self: the child's stat from the lookup. parentIno: null when no parent was found.
|
|
28
|
+
function setParentVerdict(sessionId, meta, self, parentIno, result) {
|
|
29
|
+
verdicts.set(sessionId, {
|
|
30
|
+
childPath: meta.jsonlPath,
|
|
31
|
+
childIno: self.ino,
|
|
32
|
+
logicalParentUuid: meta.logicalParentUuid || null,
|
|
33
|
+
parentIno,
|
|
34
|
+
olderCount: countTranscriptsBornBefore(path.dirname(meta.jsonlPath), self.birthtimeMs),
|
|
35
|
+
result,
|
|
36
|
+
});
|
|
37
|
+
dirty = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const parentVerdictsDirty = () => dirty;
|
|
41
|
+
|
|
42
|
+
function exportParentVerdicts() {
|
|
43
|
+
dirty = false;
|
|
44
|
+
return [...verdicts].filter(([, v]) => fs.existsSync(v.childPath));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function importParentVerdicts(entries) {
|
|
48
|
+
if (!Array.isArray(entries)) return;
|
|
49
|
+
const num = (x) => typeof x === 'number';
|
|
50
|
+
for (const pair of entries) {
|
|
51
|
+
const v = Array.isArray(pair) ? pair[1] : null;
|
|
52
|
+
if (typeof pair?.[0] !== 'string' || !v || typeof v.childPath !== 'string' || !num(v.childIno) || !num(v.olderCount)) continue;
|
|
53
|
+
if (!(v.parentIno === null || num(v.parentIno)) || typeof v.result?.relation !== 'string') continue;
|
|
54
|
+
verdicts.set(pair[0], v);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { getParentVerdict, setParentVerdict, parentVerdictsDirty, exportParentVerdicts, importParentVerdicts };
|