claude-code-kanban 5.1.1 → 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/README.md +3 -2
- package/lib/auto-compact.js +42 -0
- package/lib/claude-dir.js +7 -1
- package/lib/claude-settings.js +21 -0
- package/lib/dispatch.js +11 -4
- package/lib/net-guard.js +41 -14
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +140 -0
- package/lib/session-events.js +30 -7
- package/lib/terminal.js +39 -6
- 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 +15 -8
- package/plugin/plugins/claude-code-kanban/monitors.json +3 -3
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -2
- package/plugin/plugins/claude-code-kanban/skills/{kanban-dispatch → dispatch}/SKILL.md +3 -3
- package/plugin/plugins/claude-code-kanban/skills/{kanban-follow → follow}/SKILL.md +17 -9
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -2
- package/public/app.js +1332 -490
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +27 -17
- package/public/project-match.js +6 -4
- package/public/style.css +528 -116
- 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 +204 -60
- package/skill-guides/dispatch.md +5 -5
package/README.md
CHANGED
|
@@ -71,8 +71,9 @@ 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 `
|
|
75
|
-
- **Steer with card moves.** Run `/claude-code-kanban:
|
|
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. Add `--report` to get the outcome back. Needs the terminal. [Dispatch tasks to other sessions](https://nikiforovall.blog/claude-code-kanban/guides/dispatch/)
|
|
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
|
+
- **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/)
|
|
76
77
|
|
|
77
78
|
<picture>
|
|
78
79
|
<source media="(prefers-color-scheme: dark)" srcset="website/public/shots/themes/ember-11-waiting-prompt-dark.webp">
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const path = require('node:path');
|
|
2
|
+
const { readSettings } = require('./claude-settings');
|
|
3
|
+
|
|
4
|
+
const MIN_WINDOW = 100000;
|
|
5
|
+
const MAX_WINDOW = 1000000;
|
|
6
|
+
|
|
7
|
+
function toInt(v) {
|
|
8
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v) : NaN;
|
|
9
|
+
return Number.isFinite(n) ? n : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Claude Code merges settings as project local > project > user, and an env var beats the
|
|
13
|
+
// `autoCompactWindow` key. Shell env and managed settings are not visible from here.
|
|
14
|
+
// https://code.claude.com/docs/en/env-vars.md, https://code.claude.com/docs/en/settings-reference.md
|
|
15
|
+
function getAutoCompact(claudeDir, projectPath) {
|
|
16
|
+
const files = [path.join(claudeDir, 'settings.json')];
|
|
17
|
+
if (projectPath) {
|
|
18
|
+
files.push(path.join(projectPath, '.claude', 'settings.json'), path.join(projectPath, '.claude', 'settings.local.json'));
|
|
19
|
+
}
|
|
20
|
+
let enabled = true;
|
|
21
|
+
let envWindow = null;
|
|
22
|
+
let keyWindow = null;
|
|
23
|
+
let pct = null;
|
|
24
|
+
for (const file of files) {
|
|
25
|
+
const s = readSettings(file);
|
|
26
|
+
if (!s || typeof s !== 'object') continue;
|
|
27
|
+
if (typeof s.autoCompactEnabled === 'boolean') enabled = s.autoCompactEnabled;
|
|
28
|
+
keyWindow = toInt(s.autoCompactWindow) ?? keyWindow;
|
|
29
|
+
envWindow = toInt(s.env?.CLAUDE_CODE_AUTO_COMPACT_WINDOW) ?? envWindow;
|
|
30
|
+
pct = toInt(s.env?.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE) ?? pct;
|
|
31
|
+
}
|
|
32
|
+
if (!enabled) return null;
|
|
33
|
+
const window = envWindow ?? keyWindow;
|
|
34
|
+
const validPct = pct != null && pct >= 1 && pct <= 100 ? pct : null;
|
|
35
|
+
if (window == null && validPct == null) return null;
|
|
36
|
+
return {
|
|
37
|
+
window: window == null ? null : Math.min(MAX_WINDOW, Math.max(MIN_WINDOW, window)),
|
|
38
|
+
pct: validPct,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { getAutoCompact };
|
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 };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
|
|
3
|
+
const fileCache = new Map();
|
|
4
|
+
|
|
5
|
+
// Parsed Claude Code settings file, or null when it is missing or invalid. Re-read only
|
|
6
|
+
// after its mtime changes.
|
|
7
|
+
function readSettings(file) {
|
|
8
|
+
const mtime = fs.statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? null;
|
|
9
|
+
const hit = fileCache.get(file);
|
|
10
|
+
if (hit && hit.mtime === mtime) return hit.data;
|
|
11
|
+
let data = null;
|
|
12
|
+
if (mtime !== null) {
|
|
13
|
+
try {
|
|
14
|
+
data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
15
|
+
} catch {}
|
|
16
|
+
}
|
|
17
|
+
fileCache.set(file, { mtime, data });
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { readSettings };
|
package/lib/dispatch.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
const crypto = require('node:crypto');
|
|
10
10
|
const { tokenMatches } = require('./terminal');
|
|
11
|
-
const { clampWait } = require('./session-events');
|
|
11
|
+
const { clampWait, EVENT_PREFIX } = require('./session-events');
|
|
12
12
|
|
|
13
13
|
const OUTCOMES = new Set(['succeeded', 'failed']);
|
|
14
14
|
const MAX_SUMMARY = 4000;
|
|
@@ -45,9 +45,16 @@ function formatPreamble(record, cli = 'claude-code-kanban') {
|
|
|
45
45
|
|
|
46
46
|
// The line reaches the parent through the postman at hook trust level; the summary is
|
|
47
47
|
// last so no summary text can pose as a further field.
|
|
48
|
+
const DISPATCH_OUTCOME = {
|
|
49
|
+
succeeded: 'reported success',
|
|
50
|
+
failed: 'reported failure',
|
|
51
|
+
exited: 'ended without a report',
|
|
52
|
+
};
|
|
53
|
+
|
|
48
54
|
function formatDispatchLine(r) {
|
|
49
|
-
const
|
|
50
|
-
|
|
55
|
+
const outcome = DISPATCH_OUTCOME[r.status] || r.status;
|
|
56
|
+
const head = `${EVENT_PREFIX} Dispatch ${r.id} (session ${r.session}) ${outcome}.`;
|
|
57
|
+
return r.summary ? `${head} Summary: ${r.summary}` : head;
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
function publicView(r) {
|
|
@@ -169,4 +176,4 @@ function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
|
|
|
169
176
|
return { create, attach, discard, settle, sessionExited, list, wait };
|
|
170
177
|
}
|
|
171
178
|
|
|
172
|
-
module.exports = { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName };
|
|
179
|
+
module.exports = { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME };
|
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
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Expiry of per-session state cck writes. The rule and the sweep: docs/retention.md.
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readSettings } = require('./claude-settings');
|
|
6
|
+
const { DISPATCH_OUTCOME } = require('./dispatch');
|
|
7
|
+
|
|
8
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const DEFAULT_CLEANUP_DAYS = 30;
|
|
10
|
+
// A started session writes its transcript a moment after the record; the sweep must not
|
|
11
|
+
// read that gap as a deleted transcript.
|
|
12
|
+
const GRACE_MS = 60 * 60 * 1000;
|
|
13
|
+
const MAX_DISPATCHED = 500;
|
|
14
|
+
const OUTCOMES = new Set(Object.keys(DISPATCH_OUTCOME));
|
|
15
|
+
|
|
16
|
+
// Only the config dir's settings: retention is per config dir, not per project.
|
|
17
|
+
function retentionMs(claudeDir) {
|
|
18
|
+
const days = readSettings(path.join(claudeDir, 'settings.json'))?.cleanupPeriodDays;
|
|
19
|
+
return (Number.isInteger(days) && days > 0 ? days : DEFAULT_CLEANUP_DAYS) * DAY_MS;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// `known` is the set of session ids with a transcript, or null when the scan found none,
|
|
23
|
+
// so a failed scan never reads as every transcript gone.
|
|
24
|
+
function isExpired(id, at, { known, maxAgeMs, now }) {
|
|
25
|
+
const age = now - at;
|
|
26
|
+
if (age < GRACE_MS) return false;
|
|
27
|
+
return (known && !known.has(id)) || age > maxAgeMs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sessions started through `dispatch start`, kept after the dispatch settles so the card
|
|
32
|
+
* keeps its marker.
|
|
33
|
+
* @param {object} o
|
|
34
|
+
* @param {() => object|null} o.load returns `{version: 1, sessions: {[id]: {parent, status, at}}}` or null
|
|
35
|
+
* @param {(data: object) => void} o.save
|
|
36
|
+
*/
|
|
37
|
+
function createDispatchedStore({ load, save, now = Date.now }) {
|
|
38
|
+
// Insertion order is oldest first, so the cap drops the first key.
|
|
39
|
+
const entries = new Map();
|
|
40
|
+
const saved = load()?.sessions;
|
|
41
|
+
if (saved && typeof saved === 'object') {
|
|
42
|
+
for (const [id, e] of Object.entries(saved)) {
|
|
43
|
+
if (!e || !Number.isFinite(e.at)) continue;
|
|
44
|
+
// The dispatch registry is in memory: a dispatch that was running when the server
|
|
45
|
+
// stopped can never settle, so it reads as ended.
|
|
46
|
+
const status = OUTCOMES.has(e.status) ? e.status : 'exited';
|
|
47
|
+
entries.set(id, { parent: typeof e.parent === 'string' ? e.parent : null, status, at: e.at });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const persist = () => save({ version: 1, sessions: Object.fromEntries(entries) });
|
|
52
|
+
|
|
53
|
+
function record(id, parent) {
|
|
54
|
+
entries.delete(id);
|
|
55
|
+
entries.set(id, { parent: parent || null, status: 'running', at: now() });
|
|
56
|
+
if (entries.size > MAX_DISPATCHED) entries.delete(entries.keys().next().value);
|
|
57
|
+
persist();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function settle(id, status) {
|
|
61
|
+
const e = entries.get(id);
|
|
62
|
+
if (!e || e.status !== 'running' || !OUTCOMES.has(status)) return;
|
|
63
|
+
e.status = status;
|
|
64
|
+
persist();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Returns how many entries went; writes only when one did.
|
|
68
|
+
function prune({ known, maxAgeMs }) {
|
|
69
|
+
const t = now();
|
|
70
|
+
let removed = 0;
|
|
71
|
+
for (const [id, e] of entries) {
|
|
72
|
+
if (isExpired(id, e.at, { known, maxAgeMs, now: t })) {
|
|
73
|
+
entries.delete(id);
|
|
74
|
+
removed++;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (removed) persist();
|
|
78
|
+
return removed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { record, settle, prune, get: (id) => entries.get(id) || null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Names only, no stat or parse: the sweep needs which transcripts exist, not what they hold.
|
|
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
|
+
const ids = new Set();
|
|
89
|
+
const dirs = new Set();
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = await fs.readdir(projectsDir, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
for (const d of entries) {
|
|
97
|
+
if (!d.isDirectory()) continue;
|
|
98
|
+
try {
|
|
99
|
+
for (const f of await fs.readdir(path.join(projectsDir, d.name))) {
|
|
100
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
101
|
+
ids.add(f.slice(0, -'.jsonl'.length));
|
|
102
|
+
dirs.add(d.name);
|
|
103
|
+
}
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
return ids.size ? { ids, dirs } : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `<dir>/<session id>/<ts>.md`, one folder per session.
|
|
110
|
+
async function pruneSessionDirs(dir, { known, maxAgeMs, now = Date.now() }) {
|
|
111
|
+
let removed = 0;
|
|
112
|
+
let sessionDirs;
|
|
113
|
+
try {
|
|
114
|
+
sessionDirs = await fs.readdir(dir, { withFileTypes: true });
|
|
115
|
+
} catch {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
for (const d of sessionDirs) {
|
|
119
|
+
if (!d.isDirectory()) continue;
|
|
120
|
+
const sessionDir = path.join(dir, d.name);
|
|
121
|
+
try {
|
|
122
|
+
const files = await fs.readdir(sessionDir);
|
|
123
|
+
let kept = 0;
|
|
124
|
+
for (const f of files) {
|
|
125
|
+
const file = path.join(sessionDir, f);
|
|
126
|
+
const { mtimeMs } = await fs.stat(file);
|
|
127
|
+
if (isExpired(d.name, mtimeMs, { known, maxAgeMs, now })) {
|
|
128
|
+
await fs.rm(file, { force: true });
|
|
129
|
+
removed++;
|
|
130
|
+
} else kept++;
|
|
131
|
+
}
|
|
132
|
+
// An old mtime means no file was added since the read, so a review written right
|
|
133
|
+
// now cannot lose its folder.
|
|
134
|
+
if (!kept && now - (await fs.stat(sessionDir)).mtimeMs >= GRACE_MS) await fs.rmdir(sessionDir);
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
return removed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs, GRACE_MS, MAX_DISPATCHED, DAY_MS };
|
package/lib/session-events.js
CHANGED
|
@@ -29,13 +29,33 @@ function sanitizeEventLine(line) {
|
|
|
29
29
|
return line.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, 1500);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
32
|
+
// Lines are plain sentences so they still read right once the skill that explains them has
|
|
33
|
+
// left the agent's context. EVENT_PREFIX marks the board as the sender.
|
|
34
|
+
const EVENT_PREFIX = '[kanban board]';
|
|
35
|
+
|
|
36
|
+
// Everything after `Description:` is the description verbatim to end of line, so no amount
|
|
37
|
+
// of board text can pose as a further part of the line. That leaves the subject as the only
|
|
38
|
+
// value that needs delimiting.
|
|
35
39
|
function formatTaskMoved(taskId, prevStatus, task) {
|
|
36
40
|
const subject = String(task.subject || '').replace(/(["\\])/g, '\\$1');
|
|
37
|
-
const
|
|
38
|
-
|
|
41
|
+
const from = prevStatus ? ` from ${prevStatus}` : '';
|
|
42
|
+
const head = `${EVENT_PREFIX} The user moved task ${taskId} "${subject}"${from} to ${task.status}.`;
|
|
43
|
+
return task.description ? `${head} Description: ${task.description}` : head;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The comments themselves live in the review file; the line only points at it, because a
|
|
47
|
+
// batch of quotes would not survive the one-line cap. The path comes last and runs to end
|
|
48
|
+
// of line, so a space in the config dir cannot split it.
|
|
49
|
+
function formatReviewSubmitted(count, label, reviewFile) {
|
|
50
|
+
const comments = `${count} review comment${count === 1 ? '' : 's'}`;
|
|
51
|
+
return `${EVENT_PREFIX} The user left ${comments} on ${label}. Address them: ${reviewFile}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A postman between two polls has no waiter for a moment, so this can miss a live one.
|
|
55
|
+
// The caller then falls back to another route, which only costs a duplicate route, never
|
|
56
|
+
// a lost review.
|
|
57
|
+
function hasSessionListener(sessionId) {
|
|
58
|
+
return !!sessionEventBuckets.get(sessionId)?.waiters.size;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
function enqueueSessionEvent(sessionId, line) {
|
|
@@ -59,8 +79,8 @@ function clampWait(sec) {
|
|
|
59
79
|
return Math.min(Math.max(Number(sec) || 0, 0), MAX_WAIT_SEC);
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
// Each topic rides its own bucket, so the
|
|
63
|
-
// the user did not grant with
|
|
82
|
+
// Each topic rides its own bucket, so the dispatch postman never prints task moves
|
|
83
|
+
// the user did not grant with the follow skill. No topic is the task-move bucket.
|
|
64
84
|
function topicKey(topic, sessionId) {
|
|
65
85
|
return topic ? `${topic}:${sessionId}` : sessionId;
|
|
66
86
|
}
|
|
@@ -109,9 +129,12 @@ function drain(sessionId, bucket) {
|
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
module.exports = {
|
|
132
|
+
EVENT_PREFIX,
|
|
112
133
|
sessionEventBuckets,
|
|
113
134
|
sanitizeEventLine,
|
|
114
135
|
formatTaskMoved,
|
|
136
|
+
formatReviewSubmitted,
|
|
137
|
+
hasSessionListener,
|
|
115
138
|
enqueueSessionEvent,
|
|
116
139
|
handleSessionEvents,
|
|
117
140
|
topicKey,
|
package/lib/terminal.js
CHANGED
|
@@ -356,16 +356,24 @@ function createTerminalService(o) {
|
|
|
356
356
|
// Waits for claude to turn on bracketed paste (its input box is live), then for the
|
|
357
357
|
// screen to settle. A folder-trust question also takes input, and Enter there would
|
|
358
358
|
// answer it, so the prompt waits for as long as that question is on screen.
|
|
359
|
+
function bracketed(text) {
|
|
360
|
+
return `\x1b[200~${text.replace(/\r?\n/g, '\r')}\x1b[201~`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function onTrustScreen(s) {
|
|
364
|
+
return /\btrust\b/i.test(screenText(s.term));
|
|
365
|
+
}
|
|
366
|
+
|
|
359
367
|
function queuePrompt(s, prompt) {
|
|
360
368
|
let armed = false;
|
|
361
369
|
let timer = null;
|
|
362
|
-
const text =
|
|
370
|
+
const text = bracketed(prompt);
|
|
363
371
|
s.onOutput = (data) => {
|
|
364
372
|
if (!armed && data.includes('\x1b[?2004h')) armed = true;
|
|
365
373
|
if (!armed) return;
|
|
366
374
|
clearTimeout(timer);
|
|
367
375
|
timer = setTimeout(() => {
|
|
368
|
-
if (s.exited ||
|
|
376
|
+
if (s.exited || onTrustScreen(s)) return;
|
|
369
377
|
s.onOutput = null;
|
|
370
378
|
s.pty.write(text);
|
|
371
379
|
setTimeout(() => { if (!s.exited) s.pty.write('\r'); }, 100);
|
|
@@ -379,9 +387,13 @@ function createTerminalService(o) {
|
|
|
379
387
|
if (s.claudePid || s.mode === 'pick') return s.claudePid;
|
|
380
388
|
const live = o.liveSessions();
|
|
381
389
|
const own = live.find((l) => l.pid && l.sessionId === s.id && l.startedAt >= s.startedAt - PICK_START_SLACK_MS);
|
|
382
|
-
if (own || s.mode !== 'fork')
|
|
390
|
+
if (own || s.mode !== 'fork') {
|
|
391
|
+
s.claudePid = own?.pid;
|
|
392
|
+
return s.claudePid;
|
|
393
|
+
}
|
|
383
394
|
const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
|
|
384
|
-
|
|
395
|
+
s.claudePid = findPickProcess(live, s, claimed);
|
|
396
|
+
return s.claudePid;
|
|
385
397
|
}
|
|
386
398
|
|
|
387
399
|
// claude writes its registry entry a moment after it starts, so the pid is polled for.
|
|
@@ -396,7 +408,6 @@ function createTerminalService(o) {
|
|
|
396
408
|
clearInterval(s.boostTimer);
|
|
397
409
|
s.boostTimer = null;
|
|
398
410
|
if (!pid) return;
|
|
399
|
-
if (s.mode !== 'shell') s.claudePid = pid;
|
|
400
411
|
const before = priority.raise(pid);
|
|
401
412
|
s.boost = { pid, before };
|
|
402
413
|
};
|
|
@@ -627,6 +638,16 @@ function createTerminalService(o) {
|
|
|
627
638
|
}));
|
|
628
639
|
}
|
|
629
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
|
+
|
|
630
651
|
function isRunning(id) {
|
|
631
652
|
const s = sessions.get(id);
|
|
632
653
|
return !!s && !s.ended;
|
|
@@ -654,6 +675,18 @@ function createTerminalService(o) {
|
|
|
654
675
|
return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
|
|
655
676
|
}
|
|
656
677
|
|
|
678
|
+
// Pastes into claude's input box and stops there: Enter would also submit whatever the
|
|
679
|
+
// user had half-typed, and over a permission dialog it would answer it. A fork runs under
|
|
680
|
+
// a new session id, so its terminal id does not name the session the text is for. A
|
|
681
|
+
// pending queued prompt owns the input box until it is sent.
|
|
682
|
+
function paste(id, text) {
|
|
683
|
+
const s = sessions.get(id);
|
|
684
|
+
if (!s || s.ended || s.exited || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
|
|
685
|
+
if (onTrustScreen(s)) return false;
|
|
686
|
+
s.pty.write(bracketed(text));
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
|
|
657
690
|
function shutdown() {
|
|
658
691
|
shuttingDown = true;
|
|
659
692
|
clearTimeout(saveTimer);
|
|
@@ -663,7 +696,7 @@ function createTerminalService(o) {
|
|
|
663
696
|
sessions.clear();
|
|
664
697
|
}
|
|
665
698
|
|
|
666
|
-
return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, restore, shutdown, unavailableReason };
|
|
699
|
+
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
667
700
|
}
|
|
668
701
|
|
|
669
702
|
module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|