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.
Files changed (50) hide show
  1. package/README.md +3 -2
  2. package/lib/auto-compact.js +42 -0
  3. package/lib/claude-dir.js +7 -1
  4. package/lib/claude-settings.js +21 -0
  5. package/lib/dispatch.js +11 -4
  6. package/lib/net-guard.js +41 -14
  7. package/lib/proc-stats.js +82 -0
  8. package/lib/retention.js +140 -0
  9. package/lib/session-events.js +30 -7
  10. package/lib/terminal.js +39 -6
  11. package/lib/worktrees.js +105 -0
  12. package/package.json +1 -1
  13. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
  14. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +15 -8
  15. package/plugin/plugins/claude-code-kanban/monitors.json +3 -3
  16. package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -2
  17. package/plugin/plugins/claude-code-kanban/skills/{kanban-dispatch → dispatch}/SKILL.md +3 -3
  18. package/plugin/plugins/claude-code-kanban/skills/{kanban-follow → follow}/SKILL.md +17 -9
  19. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +1 -2
  20. package/public/app.js +1332 -490
  21. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  22. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  23. package/public/fonts/fonts.css +244 -0
  24. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  30. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  31. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  32. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  33. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  34. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  35. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  36. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  37. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  38. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  39. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  40. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  41. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  42. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  43. package/public/index.html +27 -17
  44. package/public/project-match.js +6 -4
  45. package/public/style.css +528 -116
  46. package/public/terminal-frame.js +341 -0
  47. package/public/terminal.html +27 -0
  48. package/public/vendor/claude-hub-sdk.js +13 -3
  49. package/server.js +204 -60
  50. 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>&#96;</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 `kanban-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: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/)
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
- module.exports = { getArgValue, getClaudeDir, claudeCliEnv, isDefaultClaudeDir, displayPath, storageNamespace };
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 head = `cck:1 dispatch.${r.status} ${r.id} session=${r.session}`;
50
- return r.summary ? `${head} summary=${r.summary}` : head;
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
- const h = String(host).toLowerCase();
44
- return LOOPBACK_HOSTS.has(h) || h.startsWith('127.');
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
- function frameGuard(_req, res, next) {
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. Failures are swallowed: it is an optimization, not a requirement.
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
- actualPort = server.address().port;
265
- if (!EXPOSED) {
266
- const otherFamily = BIND_HOST.includes(':') ? '127.0.0.1' : '::1';
267
- try {
268
- secondary = makeServer(app);
269
- secondary.on('error', () => {}); // that family may not exist on this host
270
- secondary.listen({ host: otherFamily, port: actualPort, ipv6Only: otherFamily === '::1' });
271
- } catch { /* best effort */ }
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 };
@@ -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 };
@@ -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
- // Everything after `description=` is the description verbatim to end of line, so no amount
33
- // of board text can pose as a further field. That leaves the subject as the only value
34
- // that needs delimiting.
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 head = `cck:1 task.moved ${taskId} ${prevStatus || 'none'}>${task.status} subject="${subject}"`;
38
- return task.description ? `${head} description=${task.description}` : head;
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 kanban-dispatch postman never prints task moves
63
- // the user did not grant with kanban-follow. No topic is the task-move bucket.
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 = `\x1b[200~${prompt.replace(/\r?\n/g, '\r')}\x1b[201~`;
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 || /\btrust\b/i.test(screenText(s.term))) return;
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') return own?.pid;
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
- return findPickProcess(live, s, claimed);
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 };