claude-code-kanban 5.2.0 → 5.3.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/lib/claude-dir.js +7 -1
- package/lib/net-guard.js +41 -14
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +12 -9
- package/lib/terminal.js +17 -4
- package/lib/worktrees.js +105 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +0 -1
- package/public/app.js +653 -372
- package/public/index.html +7 -3
- package/public/project-match.js +6 -4
- package/public/style.css +258 -79
- package/public/terminal-frame.js +341 -0
- package/public/terminal.html +27 -0
- package/public/vendor/claude-hub-sdk.js +13 -3
- package/server.js +57 -50
- package/skill-guides/dispatch.md +4 -4
package/lib/claude-dir.js
CHANGED
|
@@ -50,4 +50,10 @@ function displayPath(p) {
|
|
|
50
50
|
return rel.split(path.sep).join('/');
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// The harness spells a project path into a temp/log dir name by replacing every
|
|
54
|
+
// non-alphanumeric character with a dash, drive colon and separators included.
|
|
55
|
+
function encodeProjectDirName(p) {
|
|
56
|
+
return p.replace(/[^a-zA-Z0-9]/g, '-');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { getArgValue, getClaudeDir, claudeCliEnv, isDefaultClaudeDir, displayPath, storageNamespace, encodeProjectDirName };
|
package/lib/net-guard.js
CHANGED
|
@@ -37,11 +37,14 @@ const PORT_RE = /^[0-9]{1,5}$/;
|
|
|
37
37
|
// port dynamic, so only the host is allowlisted.
|
|
38
38
|
const validPort = (p) => PORT_RE.test(p) && Number(p) >= 1 && Number(p) <= 65535;
|
|
39
39
|
const KEEP_ALIVE_MS = 65000;
|
|
40
|
+
// A whole IPv4 literal: a prefix test would let a rebinding name like 127.evil.com through.
|
|
41
|
+
const LOOPBACK_V4_RE = /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
|
|
40
42
|
|
|
41
43
|
function isLoopbackAddress(host) {
|
|
42
44
|
if (!host) return false;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
+
// An Origin or URL host carries an IPv6 literal in brackets.
|
|
46
|
+
const h = String(host).toLowerCase().replace(/^\[(.*)\]$/, '$1');
|
|
47
|
+
return LOOPBACK_HOSTS.has(h) || LOOPBACK_V4_RE.test(h);
|
|
45
48
|
}
|
|
46
49
|
|
|
47
50
|
// Hand-parsed rather than via new URL(), which is far more permissive than we
|
|
@@ -102,7 +105,7 @@ function getFlag(name) {
|
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/**
|
|
105
|
-
* @param {{appName?: string}} [config]
|
|
108
|
+
* @param {{appName?: string, selfFramedPaths?: string[]}} [config]
|
|
106
109
|
*/
|
|
107
110
|
function createNetGuard(config = {}) {
|
|
108
111
|
const appName = config.appName || 'This app';
|
|
@@ -216,9 +219,18 @@ function createNetGuard(config = {}) {
|
|
|
216
219
|
CSP = "frame-ancestors 'none'";
|
|
217
220
|
}
|
|
218
221
|
|
|
219
|
-
|
|
222
|
+
// Alone, these pages may be framed by this app's own port under its loopback names, for a page
|
|
223
|
+
// that frames itself from its other name to get its own renderer process.
|
|
224
|
+
const selfFramed = new Set(config.selfFramedPaths || []);
|
|
225
|
+
|
|
226
|
+
function frameGuard(req, res, next) {
|
|
220
227
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
221
228
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
229
|
+
if (!hubOrigin && selfFramed.has(req.path)) {
|
|
230
|
+
const port = req.socket.localPort;
|
|
231
|
+
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' http://localhost:${port} http://127.0.0.1:${port}`);
|
|
232
|
+
return next();
|
|
233
|
+
}
|
|
222
234
|
res.setHeader('Content-Security-Policy', CSP);
|
|
223
235
|
if (!hubOrigin) res.setHeader('X-Frame-Options', 'DENY');
|
|
224
236
|
next();
|
|
@@ -232,7 +244,10 @@ function createNetGuard(config = {}) {
|
|
|
232
244
|
* the OS resolver orders A/AAAA records - binding only 127.0.0.1 breaks hosts
|
|
233
245
|
* where `localhost` resolves to ::1, and vice versa. Doing it on the *resolved*
|
|
234
246
|
* port means every existing URL, startup banner and postMessage origin is
|
|
235
|
-
* unchanged.
|
|
247
|
+
* unchanged. A family the host does not have is skipped. A port that another
|
|
248
|
+
* process holds on the other family is treated as busy: the primary closes and
|
|
249
|
+
* emits EADDRINUSE (port 0 picks another random port instead), because that process would answer `localhost:<port>` (and a
|
|
250
|
+
* cross-site frame on it) in our name. onReady waits for the second bind.
|
|
236
251
|
*
|
|
237
252
|
* Returns the primary server, so callers keep attaching their own 'error'
|
|
238
253
|
* handler for the EADDRINUSE fallback.
|
|
@@ -259,18 +274,30 @@ function createNetGuard(config = {}) {
|
|
|
259
274
|
};
|
|
260
275
|
const server = makeServer(app);
|
|
261
276
|
let secondary = null;
|
|
277
|
+
let randomTries = 0;
|
|
262
278
|
|
|
263
279
|
server.on('listening', () => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
280
|
+
const boundPort = server.address().port;
|
|
281
|
+
actualPort = boundPort;
|
|
282
|
+
// Not actualPort: the hub listens several servers through one guard, and another one can bind
|
|
283
|
+
// while this one waits for its second family.
|
|
284
|
+
const ready = () => { if (onReady) onReady(boundPort); };
|
|
285
|
+
if (EXPOSED) return ready();
|
|
286
|
+
const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
|
|
287
|
+
try {
|
|
288
|
+
secondary = makeServer(app);
|
|
289
|
+
secondary.once('listening', ready);
|
|
290
|
+
secondary.on('error', (err) => {
|
|
291
|
+
if (err.code !== 'EADDRINUSE') return ready();
|
|
292
|
+
// A caller asking for port 0 has no fallback of its own, so it gets another random port here.
|
|
293
|
+
if (port === 0 && ++randomTries < 5) return server.close(() => server.listen(0, BIND_HOST));
|
|
294
|
+
server.close();
|
|
295
|
+
server.emit('error', err);
|
|
296
|
+
});
|
|
297
|
+
secondary.listen({ host: otherFamily, port: boundPort, ipv6Only: otherFamily === '::1' });
|
|
298
|
+
} catch {
|
|
299
|
+
ready();
|
|
272
300
|
}
|
|
273
|
-
if (onReady) onReady(actualPort);
|
|
274
301
|
});
|
|
275
302
|
server.on('close', () => { try { if (secondary) secondary.close(); } catch { /* already gone */ } });
|
|
276
303
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Memory and CPU of a few processes in one OS call. Only the processes named: a process-tree walk
|
|
4
|
+
// costs more than the number is worth. CPU is the change in cumulative CPU time since the last
|
|
5
|
+
// call, so the first call for a pid has none.
|
|
6
|
+
const { execFile } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const QUERY_TIMEOUT_MS = 10000;
|
|
9
|
+
// An older sample would turn the CPU number into an average over minutes.
|
|
10
|
+
const MAX_BASELINE_MS = 30000;
|
|
11
|
+
|
|
12
|
+
function parseCpuTime(text) {
|
|
13
|
+
const [days, clock] = text.includes('-') ? text.split('-') : ['0', text];
|
|
14
|
+
const seconds = clock.split(':').reduce((acc, part) => acc * 60 + Number.parseFloat(part), 0);
|
|
15
|
+
return (Number(days) * 86400 + seconds) * 1000;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Lines of "pid workingSetBytes cpuMs".
|
|
19
|
+
function parseWindows(stdout) {
|
|
20
|
+
const out = new Map();
|
|
21
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
22
|
+
const [pid, rss, cpuMs] = line.trim().split(/\s+/).map(Number);
|
|
23
|
+
if (pid && Number.isFinite(rss) && Number.isFinite(cpuMs)) out.set(pid, { rss, cpuMs });
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Lines of `ps -o pid=,rss=,time=`: rss in KiB, time as [dd-]hh:mm:ss on Linux or mm:ss.ss on macOS.
|
|
29
|
+
function parsePs(stdout) {
|
|
30
|
+
const out = new Map();
|
|
31
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
32
|
+
const [pid, rss, time] = line.trim().split(/\s+/);
|
|
33
|
+
if (!time) continue;
|
|
34
|
+
const cpuMs = parseCpuTime(time);
|
|
35
|
+
if (Number(pid) && Number.isFinite(cpuMs)) out.set(Number(pid), { rss: Number(rss) * 1024, cpuMs });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function query(pids, platform = process.platform) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const ids = pids.join(',');
|
|
43
|
+
const [file, args, parse] = platform === 'win32'
|
|
44
|
+
? ['powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
45
|
+
`Get-Process -Id ${ids} -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id) $($_.WorkingSet64) $([long]$_.TotalProcessorTime.TotalMilliseconds)" }`], parseWindows]
|
|
46
|
+
: ['ps', ['-o', 'pid=,rss=,time=', '-p', ids], parsePs];
|
|
47
|
+
// ps exits 1 when a pid is gone, and still prints the others.
|
|
48
|
+
execFile(file, args, { windowsHide: true, timeout: QUERY_TIMEOUT_MS }, (_err, stdout) => resolve(parse(stdout || '')));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createProcStats({ run = query, now = Date.now } = {}) {
|
|
53
|
+
let last = new Map();
|
|
54
|
+
let inFlight = null;
|
|
55
|
+
|
|
56
|
+
async function sample(pids) {
|
|
57
|
+
const at = now();
|
|
58
|
+
const found = await run(pids);
|
|
59
|
+
const next = new Map();
|
|
60
|
+
const result = {};
|
|
61
|
+
for (const pid of pids) {
|
|
62
|
+
const cur = found.get(pid);
|
|
63
|
+
if (!cur) continue;
|
|
64
|
+
const prev = last.get(pid);
|
|
65
|
+
const fresh = prev && at - prev.at <= MAX_BASELINE_MS && cur.cpuMs >= prev.cpuMs;
|
|
66
|
+
result[pid] = { rss: cur.rss, cpu: fresh ? Math.round(((cur.cpuMs - prev.cpuMs) / (at - prev.at)) * 100) : null };
|
|
67
|
+
next.set(pid, { at, cpuMs: cur.cpuMs });
|
|
68
|
+
}
|
|
69
|
+
last = next;
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Asks that overlap share one OS call.
|
|
74
|
+
return function stats(pids) {
|
|
75
|
+
const valid = [...new Set(pids)].filter((p) => Number.isInteger(p) && p > 0);
|
|
76
|
+
if (!valid.length) return Promise.resolve({});
|
|
77
|
+
inFlight ??= sample(valid).finally(() => { inFlight = null; });
|
|
78
|
+
return inFlight;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { createProcStats, parseWindows, parsePs, parseCpuTime };
|
package/lib/retention.js
CHANGED
|
@@ -82,25 +82,28 @@ function createDispatchedStore({ load, save, now = Date.now }) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
// Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
|
|
85
|
-
//
|
|
86
|
-
// transcript gone.
|
|
87
|
-
async function
|
|
85
|
+
// `ids` are session ids and `dirs` the project dir names holding at least one transcript. Null
|
|
86
|
+
// when none is found, so a missing or unreadable projects dir never reads as every transcript gone.
|
|
87
|
+
async function scanTranscripts(projectsDir) {
|
|
88
88
|
const ids = new Set();
|
|
89
|
-
|
|
89
|
+
const dirs = new Set();
|
|
90
|
+
let entries;
|
|
90
91
|
try {
|
|
91
|
-
|
|
92
|
+
entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
92
93
|
} catch {
|
|
93
94
|
return null;
|
|
94
95
|
}
|
|
95
|
-
for (const d of
|
|
96
|
+
for (const d of entries) {
|
|
96
97
|
if (!d.isDirectory()) continue;
|
|
97
98
|
try {
|
|
98
99
|
for (const f of await fs.readdir(path.join(projectsDir, d.name))) {
|
|
99
|
-
if (f.endsWith('.jsonl'))
|
|
100
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
101
|
+
ids.add(f.slice(0, -'.jsonl'.length));
|
|
102
|
+
dirs.add(d.name);
|
|
100
103
|
}
|
|
101
104
|
} catch {}
|
|
102
105
|
}
|
|
103
|
-
return ids.size ? ids : null;
|
|
106
|
+
return ids.size ? { ids, dirs } : null;
|
|
104
107
|
}
|
|
105
108
|
|
|
106
109
|
// `<dir>/<session id>/<ts>.md`, one folder per session.
|
|
@@ -134,4 +137,4 @@ async function pruneSessionDirs(dir, { known, maxAgeMs, now = Date.now() }) {
|
|
|
134
137
|
return removed;
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
module.exports = { createDispatchedStore,
|
|
140
|
+
module.exports = { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs, GRACE_MS, MAX_DISPATCHED, DAY_MS };
|
package/lib/terminal.js
CHANGED
|
@@ -387,9 +387,13 @@ function createTerminalService(o) {
|
|
|
387
387
|
if (s.claudePid || s.mode === 'pick') return s.claudePid;
|
|
388
388
|
const live = o.liveSessions();
|
|
389
389
|
const own = live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= s.startedAt - PICK_START_SLACK_MS);
|
|
390
|
-
if (own || s.mode !== 'fork')
|
|
390
|
+
if (own || s.mode !== 'fork') {
|
|
391
|
+
s.claudePid = own?.pid;
|
|
392
|
+
return s.claudePid;
|
|
393
|
+
}
|
|
391
394
|
const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
|
|
392
|
-
|
|
395
|
+
s.claudePid = findPickProcess(live, s, claimed);
|
|
396
|
+
return s.claudePid;
|
|
393
397
|
}
|
|
394
398
|
|
|
395
399
|
// claude writes its registry entry a moment after it starts, so the pid is polled for.
|
|
@@ -404,7 +408,6 @@ function createTerminalService(o) {
|
|
|
404
408
|
clearInterval(s.boostTimer);
|
|
405
409
|
s.boostTimer = null;
|
|
406
410
|
if (!pid) return;
|
|
407
|
-
if (s.mode !== 'shell') s.claudePid = pid;
|
|
408
411
|
const before = priority.raise(pid);
|
|
409
412
|
s.boost = { pid, before };
|
|
410
413
|
};
|
|
@@ -635,6 +638,16 @@ function createTerminalService(o) {
|
|
|
635
638
|
}));
|
|
636
639
|
}
|
|
637
640
|
|
|
641
|
+
function claudePids() {
|
|
642
|
+
const out = {};
|
|
643
|
+
for (const s of sessions.values()) {
|
|
644
|
+
if (s.ended || s.exited || s.mode === 'shell') continue;
|
|
645
|
+
const pid = inputPid(s);
|
|
646
|
+
if (pid) out[s.id] = pid;
|
|
647
|
+
}
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
|
|
638
651
|
function isRunning(id) {
|
|
639
652
|
const s = sessions.get(id);
|
|
640
653
|
return !!s && !s.ended;
|
|
@@ -683,7 +696,7 @@ function createTerminalService(o) {
|
|
|
683
696
|
sessions.clear();
|
|
684
697
|
}
|
|
685
698
|
|
|
686
|
-
return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
699
|
+
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
687
700
|
}
|
|
688
701
|
|
|
689
702
|
module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
package/lib/worktrees.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Which project paths are linked worktrees, and of which repo. The rule and the sweep:
|
|
2
|
+
// docs/retention.md.
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { GRACE_MS } = require('./retention');
|
|
7
|
+
const { encodeProjectDirName } = require('./claude-dir');
|
|
8
|
+
|
|
9
|
+
// A linked worktree's `.git` is a file holding `gitdir: <main>/.git/worktrees/<name>`, so the
|
|
10
|
+
// main checkout is readable without spawning git. Path shape alone would not do: only some
|
|
11
|
+
// worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
|
|
12
|
+
const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
13
|
+
// Where `claude -w` puts a worktree. Used only once the `.git` file is gone, because Claude
|
|
14
|
+
// Code removes worktrees while their transcripts stay.
|
|
15
|
+
const CLAUDE_WORKTREE_RE = /^(.*)[/\\]\.claude[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
16
|
+
const MISS_CACHE_MAX = 500;
|
|
17
|
+
|
|
18
|
+
function readGitFile(dir) {
|
|
19
|
+
return fs.readFileSync(path.join(dir, '.git'), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fromGitFile(dir, text) {
|
|
23
|
+
const m = GITDIR_WORKTREE_RE.exec(text.trim());
|
|
24
|
+
// Git writes the pointer with forward slashes on Windows; the project path uses the OS spelling.
|
|
25
|
+
return m ? { repo: path.resolve(dir, m[1]), name: m[2] } : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function fromPathShape(dir) {
|
|
29
|
+
const m = CLAUDE_WORKTREE_RE.exec(dir);
|
|
30
|
+
return m ? { repo: m[1], name: m[2] } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hits are saved, so a worktree keeps its repo after Claude Code deletes the checkout. Misses
|
|
35
|
+
* stay in memory: an ordinary checkout cannot become a linked worktree without being recreated.
|
|
36
|
+
* @param {object} o
|
|
37
|
+
* @param {() => object|null} o.load returns `{version: 1, worktrees: {[dir]: {repo, name, at}}}` or null
|
|
38
|
+
* @param {(data: object) => void} o.save
|
|
39
|
+
* @param {(fn: () => void) => void} [o.defer] when to save; one session list can resolve many new worktrees
|
|
40
|
+
*/
|
|
41
|
+
function createWorktreeStore({ load, save, read = readGitFile, now = Date.now, defer = setImmediate }) {
|
|
42
|
+
const hits = new Map();
|
|
43
|
+
const misses = new Set();
|
|
44
|
+
const saved = load()?.worktrees;
|
|
45
|
+
if (saved && typeof saved === 'object') {
|
|
46
|
+
for (const [dir, e] of Object.entries(saved)) {
|
|
47
|
+
if (e && typeof e.repo === 'string' && typeof e.name === 'string' && Number.isFinite(e.at)) hits.set(dir, e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let pending = false;
|
|
52
|
+
function persist() {
|
|
53
|
+
if (pending) return;
|
|
54
|
+
pending = true;
|
|
55
|
+
defer(() => {
|
|
56
|
+
pending = false;
|
|
57
|
+
save({ version: 1, worktrees: Object.fromEntries(hits) });
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolve(dir) {
|
|
62
|
+
if (!dir) return null;
|
|
63
|
+
const hit = hits.get(dir);
|
|
64
|
+
if (hit) return { repo: hit.repo, name: hit.name };
|
|
65
|
+
if (misses.has(dir)) return null;
|
|
66
|
+
|
|
67
|
+
let worktree = null;
|
|
68
|
+
try {
|
|
69
|
+
worktree = fromGitFile(dir, read(dir));
|
|
70
|
+
} catch (e) {
|
|
71
|
+
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR. That is the
|
|
72
|
+
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
73
|
+
if (e.code === 'ENOENT') worktree = fromPathShape(dir);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!worktree) {
|
|
77
|
+
misses.add(dir);
|
|
78
|
+
if (misses.size > MISS_CACHE_MAX) misses.delete(misses.values().next().value);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
hits.set(dir, { ...worktree, at: now() });
|
|
82
|
+
persist();
|
|
83
|
+
return worktree;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `knownDirs` holds the project dir names that still have a transcript, or null when the scan
|
|
87
|
+
// found none, so a failed scan never drops every entry. Returns how many entries went.
|
|
88
|
+
function prune(knownDirs) {
|
|
89
|
+
if (!knownDirs) return 0;
|
|
90
|
+
const t = now();
|
|
91
|
+
let removed = 0;
|
|
92
|
+
for (const [dir, e] of hits) {
|
|
93
|
+
if (t - e.at >= GRACE_MS && !knownDirs.has(encodeProjectDirName(dir))) {
|
|
94
|
+
hits.delete(dir);
|
|
95
|
+
removed++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (removed) persist();
|
|
99
|
+
return removed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { resolve, prune };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { createWorktreeStore };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.2",
|
|
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,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: dispatch
|
|
3
3
|
description: Dispatch a task to another Claude Code session through the kanban board (cck), fire-and-forget or with a report back. Use when the user asks to dispatch, delegate, or start a session for a task, or to collect or check on a dispatched session's result.
|
|
4
|
-
argument-hint: '<task> [--report] [--group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]]'
|
|
4
|
+
argument-hint: '<task> [--no-report] [--group <name>] [--model haiku|sonnet|opus|fable] [--worktree [name]]'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Kanban dispatch
|