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/server.js CHANGED
@@ -16,6 +16,7 @@ const { isContained } = require('./lib/contain');
16
16
  const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
17
17
  const { fileUrlToPath } = require('./lib/file-url');
18
18
  const { pluginStatus } = require('./lib/plugin-status');
19
+ const { getAutoCompact } = require('./lib/auto-compact');
19
20
 
20
21
  const {
21
22
  readRecentMessages: _readRecentMessagesUncached,
@@ -38,15 +39,18 @@ const {
38
39
  } = require('./lib/parsers');
39
40
  const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
40
41
  const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
41
- const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
42
+ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
42
43
  const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
43
- const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
44
+ const { createProcStats } = require('./lib/proc-stats');
45
+ const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
44
46
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
47
+ const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
48
+ const { createWorktreeStore } = require('./lib/worktrees');
45
49
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
46
50
  const { pickFolder } = require('./lib/folder-dialog');
47
51
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
48
52
  const { countTaskDir } = require('./lib/task-counts');
49
- const { projectMatcher } = require('./public/project-match');
53
+ const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
50
54
  const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
51
55
 
52
56
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -67,7 +71,8 @@ const PORT = parseInt(getArgValue('port') || process.env.PORT || '3541', 10);
67
71
 
68
72
  // Mounted before express.json() so a rejected request never buffers a body, and
69
73
  // before the /api catch-all so no route escapes the check.
70
- const net = createNetGuard({ appName: 'Claude Task Kanban' });
74
+ // The board frames the terminal from its other loopback name, so Chrome runs it in its own process.
75
+ const net = createNetGuard({ appName: 'Claude Task Kanban', selfFramedPaths: ['/terminal.html'] });
71
76
  app.use(net.hostGuard);
72
77
  app.use(net.frameGuard);
73
78
  app.use(net.originGuard);
@@ -90,6 +95,8 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
90
95
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
91
96
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
92
97
  const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
98
+ const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
99
+ const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
93
100
  const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
94
101
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
95
102
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
@@ -200,6 +207,7 @@ const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
200
207
  const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
201
208
  const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
202
209
  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
210
+ const RETENTION_FIRST_RUN_MS = 5 * 60 * 1000;
203
211
  // #endregion
204
212
 
205
213
  // #region AGENT_ACTIVITY
@@ -286,6 +294,11 @@ function getContextStatus(sessionId, meta) {
286
294
  return contextStatusCache.get(sessionId) || (meta?.teamLeaderId ? contextStatusCache.get(meta.teamLeaderId) : null) || null;
287
295
  }
288
296
 
297
+ function getContextFields(sessionId, meta) {
298
+ const contextStatus = getContextStatus(sessionId, meta);
299
+ return { contextStatus, autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null };
300
+ }
301
+
289
302
  function isAgentFresh(agent) {
290
303
  if (isGhostAgent(agent)) return false;
291
304
  const ts = agent.updatedAt || agent.startedAt;
@@ -329,33 +342,12 @@ function getGitBranch(cwd) {
329
342
  return branch;
330
343
  }
331
344
 
332
- // A linked worktree's `.git` is a file holding `gitdir: <main>/.git/worktrees/<name>`, so the
333
- // main checkout is readable without spawning git. Path shape alone would not do: only some
334
- // worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
335
- // Cached without a TTL, misses included: a directory cannot turn from an ordinary checkout into
336
- // a linked worktree without being recreated, and a recreated path is a new cache key.
337
- const worktreeCache = new Map();
338
- const WORKTREE_CACHE_MAX = 500;
339
- const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
340
-
341
- function resolveWorktree(dir) {
342
- if (!dir) return null;
343
- if (worktreeCache.has(dir)) return worktreeCache.get(dir);
344
-
345
- let worktree = null;
346
- try {
347
- // An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
348
- // answer, and it costs one syscall instead of a stat followed by a read.
349
- const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
350
- if (m) worktree = { repo: m[1], name: m[2] };
351
- } catch (_) {}
352
-
353
- worktreeCache.set(dir, worktree);
354
- if (worktreeCache.size > WORKTREE_CACHE_MAX) {
355
- worktreeCache.delete(worktreeCache.keys().next().value);
356
- }
357
- return worktree;
358
- }
345
+ const worktrees = createWorktreeStore({
346
+ load: () => {
347
+ try { return JSON.parse(readFileSync(WORKTREES_FILE, 'utf8')); } catch { return null; }
348
+ },
349
+ save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
350
+ });
359
351
 
360
352
  // Only spawn git when cwd has diverged from the launch project — that's the
361
353
  // only case the JSONL value is wrong. Saves N spawns on a typical list build.
@@ -1131,12 +1123,6 @@ function getSessionDisplayName(_sessionId, meta) {
1131
1123
  return null;
1132
1124
  }
1133
1125
 
1134
- // The harness spells a project path into a temp/log dir name by replacing every
1135
- // non-alphanumeric character with a dash, drive colon and separators included.
1136
- function encodeProjectDirName(p) {
1137
- return p.replace(/[^a-zA-Z0-9]/g, '-');
1138
- }
1139
-
1140
1126
  // Derived by convention, not looked up: the harness creates the dir lazily, so a
1141
1127
  // stat here would report "missing" for every session that has not written a temp
1142
1128
  // file yet — and it would put IO on the session-list hot path. Pure string join.
@@ -1151,7 +1137,7 @@ function getScratchpadDir(id, meta) {
1151
1137
  // harness actually created. Probed only for worktree sessions, and only until one
1152
1138
  // of them exists — before that there is nothing to disambiguate and `byProject`,
1153
1139
  // right for a session started inside the worktree, stands.
1154
- const wt = resolveWorktree(meta.project);
1140
+ const wt = worktrees.resolve(meta.project);
1155
1141
  if (!wt || existsSync(byProject)) return byProject;
1156
1142
  const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
1157
1143
  return existsSync(byRepo) ? byRepo : byProject;
@@ -1170,7 +1156,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1170
1156
  cwd: meta.cwd || null,
1171
1157
  description: meta.description || null,
1172
1158
  gitBranch: resolveSessionGitBranch(meta),
1173
- worktree: resolveWorktree(meta.project),
1159
+ worktree: worktrees.resolve(meta.project),
1174
1160
  customTitle: meta.customTitle || null,
1175
1161
  taskCount: 0,
1176
1162
  completed: 0,
@@ -1191,7 +1177,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1191
1177
  tasksDir: null,
1192
1178
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
1193
1179
  scratchpadDir: getScratchpadDir(id, meta),
1194
- contextStatus: getContextStatus(id, meta),
1180
+ ...getContextFields(id, meta),
1195
1181
  ...getPlanInfo(meta.slug),
1196
1182
  ...getWorkflowInfoSummary(id),
1197
1183
  ...overrides,
@@ -1520,10 +1506,7 @@ app.get('/api/sessions', async (req, res) => {
1520
1506
  // Backfill contextStatus for already-built sessions that are pinned
1521
1507
  for (const pid of pinnedIds) {
1522
1508
  const s = sessionsMap.get(pid);
1523
- if (s && !s.contextStatus) {
1524
- const meta = metadata[pid];
1525
- s.contextStatus = getContextStatus(pid, meta);
1526
- }
1509
+ if (s && !s.contextStatus) Object.assign(s, getContextFields(pid, metadata[pid]));
1527
1510
  }
1528
1511
 
1529
1512
  // Ensure pinned sessions are in the map even if they weren't discovered
@@ -1572,7 +1555,7 @@ app.get('/api/sessions', async (req, res) => {
1572
1555
  // if the row is missing — that one is not a preference.
1573
1556
  if (projectFilter) {
1574
1557
  const matches = projectMatcher(String(projectFilter));
1575
- sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
1558
+ sessions = sessions.filter(s => matches(s.project, s.worktree?.repo) || includeIds.has(s.id));
1576
1559
  }
1577
1560
  // The sidebar's 24h filter: projects with any transcript written in the window, so an older
1578
1561
  // session of such a project stays in.
@@ -1644,14 +1627,30 @@ function projectActivity() {
1644
1627
  return activity;
1645
1628
  }
1646
1629
 
1647
- // API: Get distinct project paths with last-modified timestamps
1630
+ // API: Get distinct project paths with last-modified timestamps. A linked worktree is folded
1631
+ // into its repo, which lists it under `worktrees`; the repo row exists even when only its
1632
+ // worktrees have transcripts.
1648
1633
  app.get('/api/projects', (_req, res) => {
1649
1634
  res.setHeader('Cache-Control', 'no-store');
1650
- const projects = [...projectActivity()]
1651
- .map(([path, mtime]) => ({
1652
- path,
1653
- modifiedAt: mtime ? new Date(mtime).toISOString() : null,
1654
- ...(isTempPath(path) && { temp: true }),
1635
+ const byRepo = new Map();
1636
+ for (const [project, mtime] of projectActivity()) {
1637
+ const repo = worktrees.resolve(project)?.repo || project;
1638
+ const key = normalizeProjectPath(repo);
1639
+ let row = byRepo.get(key);
1640
+ if (!row) {
1641
+ row = { path: repo, mtime: 0, worktrees: [] };
1642
+ byRepo.set(key, row);
1643
+ }
1644
+ if (repo === project) row.path = project;
1645
+ else row.worktrees.push(project);
1646
+ row.mtime = Math.max(row.mtime, mtime || 0);
1647
+ }
1648
+ const projects = [...byRepo.values()]
1649
+ .map((r) => ({
1650
+ path: r.path,
1651
+ modifiedAt: r.mtime ? new Date(r.mtime).toISOString() : null,
1652
+ ...(isTempPath(r.path) && { temp: true }),
1653
+ ...(r.worktrees.length && { worktrees: r.worktrees.sort() }),
1655
1654
  }))
1656
1655
  .sort((a, b) => a.path.localeCompare(b.path));
1657
1656
  res.json(projects);
@@ -1750,7 +1749,7 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1750
1749
  if (!existsSync(planPath)) return res.json({ content: null });
1751
1750
 
1752
1751
  const content = await fs.readFile(planPath, 'utf8');
1753
- res.json({ content, slug });
1752
+ res.json({ content, slug, path: planPath });
1754
1753
  } catch (error) {
1755
1754
  console.error('Error reading plan:', error);
1756
1755
  res.status(500).json({ error: 'Failed to read plan' });
@@ -3292,6 +3291,16 @@ app.get('/api/terminals', (_req, res) => {
3292
3291
  res.json({ sessions: terminal.list() });
3293
3292
  });
3294
3293
 
3294
+ const terminalProcStats = createProcStats();
3295
+ app.get('/api/terminals/stats', async (_req, res) => {
3296
+ res.setHeader('Cache-Control', 'no-store');
3297
+ const pids = terminal.claudePids();
3298
+ const byPid = await terminalProcStats([process.pid, ...Object.values(pids)]);
3299
+ const terminals = {};
3300
+ for (const [id, pid] of Object.entries(pids)) if (byPid[pid]) terminals[id] = byPid[pid];
3301
+ res.json({ cck: byPid[process.pid] || null, terminals });
3302
+ });
3303
+
3295
3304
  app.delete('/api/terminals/:id', (req, res) => {
3296
3305
  const err = terminal.end(req.params.id, req.get('x-terminal-token'));
3297
3306
  if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
@@ -3318,9 +3327,17 @@ app.get('/vendor/xterm/:file', (req, res) => {
3318
3327
  // #endregion
3319
3328
 
3320
3329
  // #region DISPATCH
3330
+ const dispatched = createDispatchedStore({
3331
+ load: () => {
3332
+ try { return JSON.parse(readFileSync(DISPATCHED_FILE, 'utf8')); } catch { return null; }
3333
+ },
3334
+ save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
3335
+ });
3336
+
3321
3337
  const dispatches = createDispatchRegistry({
3322
3338
  onSettle: (r) => {
3323
3339
  if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
3340
+ if (r.session) dispatched.settle(r.session, r.status);
3324
3341
  broadcast({ type: 'dispatch-update' });
3325
3342
  },
3326
3343
  });
@@ -3343,17 +3360,19 @@ const linkedDocs = createLinkedDocStore({
3343
3360
 
3344
3361
  // The board places a session from these alone, so it never has to move it later.
3345
3362
  // `startedBy` lets it follow a starter the user put in a named group, which only the
3346
- // browser knows; it goes once the dispatch settles.
3363
+ // browser knows; it goes once the dispatch settles. `dispatched` stays for the card's marker.
3347
3364
  function withDispatchPlacement(sessions) {
3348
3365
  const groups = dispatchGroups.snapshot();
3349
- const starters = new Map(
3350
- dispatches.list().filter((r) => r.status === 'running' && r.parent && r.session).map((r) => [r.session, r.parent]),
3351
- );
3352
- if (!groups.size && !starters.size) return sessions;
3353
3366
  return sessions.map((s) => {
3354
3367
  const dispatchGroup = groups.get(s.id);
3355
- const startedBy = starters.get(s.id);
3356
- return dispatchGroup || startedBy ? { ...s, dispatchGroup, startedBy } : s;
3368
+ const marker = dispatched.get(s.id);
3369
+ if (!dispatchGroup && !marker) return s;
3370
+ return {
3371
+ ...s,
3372
+ dispatchGroup,
3373
+ startedBy: marker?.status === 'running' ? marker.parent || undefined : undefined,
3374
+ dispatched: marker ? { parent: marker.parent, outcome: DISPATCH_OUTCOME[marker.status] } : undefined,
3375
+ };
3357
3376
  });
3358
3377
  }
3359
3378
 
@@ -3377,6 +3396,7 @@ app.post('/api/dispatch', (req, res) => {
3377
3396
  return res.status(started.status).json({ error: started.error });
3378
3397
  }
3379
3398
  dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
3399
+ dispatched.record(started.id, parent);
3380
3400
  // The starter stays where it is: moving it would jump it under the user.
3381
3401
  if (target) dispatchGroups.join(target, [started.id], parent);
3382
3402
  broadcast({ type: 'dispatch-update' });
@@ -3458,7 +3478,14 @@ app.get('/api/tasks/all', async (_req, res) => {
3458
3478
  }
3459
3479
  });
3460
3480
 
3461
- const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents, topicKey } = require('./lib/session-events');
3481
+ const {
3482
+ enqueueSessionEvent,
3483
+ formatReviewSubmitted,
3484
+ formatTaskMoved,
3485
+ handleSessionEvents,
3486
+ hasSessionListener,
3487
+ topicKey,
3488
+ } = require('./lib/session-events');
3462
3489
  app.get('/api/sessions/:sessionId/events', handleSessionEvents);
3463
3490
 
3464
3491
  // API: Create a task
@@ -3912,6 +3939,102 @@ app.get('/api/file/resolve', async (req, res) => {
3912
3939
 
3913
3940
  // #endregion
3914
3941
 
3942
+ // #region REVIEW
3943
+ // API: Send review comments on something the board shows (a file preview today) to a
3944
+ // session. The source is described, not interpreted, so a new kind of preview needs a
3945
+ // client change only. The batch is always written to a file: the doorbell is lossy and
3946
+ // carries one line, and the file is what each route points at.
3947
+ const REVIEW_DIR = path.join(CCK_DIR, 'reviews');
3948
+ const REVIEW_KIND_RE = /^[a-z]{1,20}$/;
3949
+ const REVIEW_MAX_COMMENTS = 50;
3950
+ const REVIEW_MAX_CHARS = 4000;
3951
+
3952
+ function parseReviewBody(body) {
3953
+ const { source, comments } = body || {};
3954
+ if (!source || !REVIEW_KIND_RE.test(source.kind)) throw previewError(400, 'source.kind is required');
3955
+ // The label is pasted into the terminal; an ESC could end bracketed paste and submit text.
3956
+ const label = String(source.label || '')
3957
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
3958
+ .replace(/[\x00-\x1f\x7f]/g, ' ')
3959
+ .trim()
3960
+ .slice(0, 200);
3961
+ if (!label) throw previewError(400, 'source.label is required');
3962
+ if (!Array.isArray(comments) || !comments.length || comments.length > REVIEW_MAX_COMMENTS) {
3963
+ throw previewError(400, `comments must hold 1 to ${REVIEW_MAX_COMMENTS} items`);
3964
+ }
3965
+ const field = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().replace(/\s+/g, ' ').slice(0, max) : null);
3966
+ // Element and selector are printed inside a markdown code span.
3967
+ const code = (v, max) => field(v, max)?.replace(/`/g, "'") || null;
3968
+ const items = comments.map((c) => ({
3969
+ quote: String(c?.quote || '').trim().slice(0, REVIEW_MAX_CHARS),
3970
+ comment: String(c?.comment || '').trim().slice(0, REVIEW_MAX_CHARS),
3971
+ heading: field(c?.heading, 200),
3972
+ line: Number.isInteger(c?.line) && c.line > 0 ? c.line : null,
3973
+ element: code(c?.element, 300),
3974
+ selector: code(c?.selector, 500),
3975
+ }));
3976
+ if (items.some((c) => !c.comment)) throw previewError(400, 'every comment needs text');
3977
+ return { kind: source.kind, label, path: source.kind === 'file' ? source.path : null, items };
3978
+ }
3979
+
3980
+ function formatReviewMarkdown(src, items) {
3981
+ const parts = [
3982
+ `# Review of ${src.path || src.label}`,
3983
+ '',
3984
+ `Each comment is the user's instruction about the quoted text.${src.path ? ' A quote missing from the source means the source changed after the review: say so.' : ''}`,
3985
+ ];
3986
+ items.forEach((c, i) => {
3987
+ const where = [c.line && `line ${c.line}`, c.heading && `under "${c.heading}"`].filter(Boolean).join(', ');
3988
+ parts.push('', `## ${i + 1}${where ? ` (${where})` : ''}`, '');
3989
+ if (c.element) parts.push(`Element: \`${c.element}\`${c.selector ? ` at \`${c.selector}\`` : ''}`, '');
3990
+ if (c.quote) parts.push(...c.quote.split(/\r?\n/).map((l) => `> ${l}`), '');
3991
+ parts.push(c.comment);
3992
+ });
3993
+ return `${parts.join('\n')}\n`;
3994
+ }
3995
+
3996
+ function isWaitingOnUser(sessionId) {
3997
+ const meta = loadSessionMetadata()[sessionId] || {};
3998
+ return !!checkWaitingForUser(path.join(AGENT_ACTIVITY_DIR, sessionId), getSessionLogStat(meta).mtime);
3999
+ }
4000
+
4001
+ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4002
+ try {
4003
+ const sessionId = req.params.sessionId;
4004
+ if (!isUUID(sessionId)) return res.status(400).json({ error: 'invalid session id' });
4005
+ const src = parseReviewBody(req.body);
4006
+ if (src.kind === 'file') {
4007
+ src.path = resolvePreviewPath(src.path);
4008
+ if (!src.path) return res.status(400).json({ error: 'source.path is required for a file' });
4009
+ await statFileTarget(src.path);
4010
+ }
4011
+
4012
+ const markdown = formatReviewMarkdown(src, src.items);
4013
+ const dir = path.join(REVIEW_DIR, sessionId);
4014
+ await fs.mkdir(dir, { recursive: true });
4015
+ const file = path.join(dir, `${Date.now()}.md`);
4016
+ await fs.writeFile(file, markdown);
4017
+
4018
+ let delivered = null;
4019
+ if (hasSessionListener(sessionId)) {
4020
+ enqueueSessionEvent(sessionId, formatReviewSubmitted(src.items.length, src.label, file));
4021
+ delivered = 'doorbell';
4022
+ } else if (
4023
+ terminal.authorized(req.get('x-terminal-token')) &&
4024
+ terminal.isRunning(sessionId) &&
4025
+ !isWaitingOnUser(sessionId)
4026
+ ) {
4027
+ if (terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
4028
+ }
4029
+ res.json({ delivered, file, markdown });
4030
+ } catch (error) {
4031
+ if (!error.status) console.error('Error in POST /api/sessions/:id/review:', error);
4032
+ res.status(error.status || 500).json({ error: error.message || 'Review failed' });
4033
+ }
4034
+ });
4035
+
4036
+ // #endregion
4037
+
3915
4038
  // #region SSE
3916
4039
  // SSE endpoint for live updates
3917
4040
  app.get('/api/events', (req, res) => {
@@ -4201,10 +4324,31 @@ async function cleanupAgentActivity() {
4201
4324
  } catch { /* agent-activity dir may not exist */ }
4202
4325
  }
4203
4326
 
4327
+ // Dispatch markers, reviews and worktrees: see docs/retention.md.
4328
+ async function runRetention() {
4329
+ try {
4330
+ const scan = await scanTranscripts(PROJECTS_DIR);
4331
+ const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
4332
+ const markers = dispatched.prune(opts);
4333
+ const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4334
+ const wts = worktrees.prune(scan?.dirs);
4335
+ if (markers || reviews || wts) {
4336
+ console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${wts} worktrees`);
4337
+ }
4338
+ } catch (e) {
4339
+ console.warn('[retention] failed:', e.message);
4340
+ }
4341
+ }
4342
+
4204
4343
  cleanupAgentActivity();
4205
4344
  cleanupContextStatus();
4206
4345
  setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
4207
4346
  setInterval(cleanupContextStatus, 30 * 60 * 1000);
4347
+ // Later than the startup scan and prewarm, so the first sweep never competes with them.
4348
+ setTimeout(() => {
4349
+ runRetention();
4350
+ setInterval(runRetention, CLEANUP_INTERVAL_MS);
4351
+ }, RETENTION_FIRST_RUN_MS);
4208
4352
 
4209
4353
  // Warm the metadata + loop-info caches in the background so the first user
4210
4354
  // request lands warm. The cheap-probe in /api/sessions skips per-session
@@ -2,7 +2,7 @@
2
2
 
3
3
  A dispatch is one Claude Code session that cck starts for a task, in its embedded terminal. It is an ordinary session, not a child: it shows in the sidebar like any other, and the user can open its terminal at any time.
4
4
 
5
- A dispatch is **fire-and-forget** by default: you hand the task off, and the user watches it in the sidebar. Add `--report` only when you need the outcome back: the user asked you to collect it, or your next step depends on it.
5
+ A dispatch **reports back** by default: start it with `--report` and `--peer`, then collect and verify the outcome (With `--report`, below). When the user's request says `--no-report`, it is **fire-and-forget**: start it with neither flag (Fire-and-forget, below). `--no-report` lives only in the user's request; `dispatch start` has no such flag.
6
6
 
7
7
  ## Write the spec
8
8
 
@@ -19,12 +19,12 @@ Dispatch when the task can run on its own. Do the work yourself when it is small
19
19
  ## Start
20
20
 
21
21
  ```bash
22
- claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> [--report] --json
22
+ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> --report --json
23
23
  ```
24
24
 
25
25
  `claude-code-kanban help dispatch start` lists every flag (model, worktree, and the rest). `project list` shows the folders `--cwd` accepts. How to choose the values:
26
26
 
27
- - `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it whenever you have the `ListAgents` tool. cck then tells the started session to ask you with `SendMessage` instead of failing on a question. See [Peer](#peer).
27
+ - `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it when you have the `ListAgents` tool. cck then tells the started session to ask you with `SendMessage` instead of failing on a question. See [Peer](#peer).
28
28
  - `--spec-file` over `--spec` for anything longer than a line: no shell quoting.
29
29
  - `--name` is what the user sees in the sidebar. Kebab-case, saying what the session does: `fix-login-redirect`, not `task-1`.
30
30
  - `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session under that sidebar group. This session stays where it is. Pass it on your first dispatch; later dispatches join the same group without it. A group goes away when its sessions end, unless the user pins a member or keeps the group.
@@ -32,13 +32,13 @@ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name
32
32
 
33
33
  ## Fire-and-forget
34
34
 
35
- Tell the user the session name, its group, and the dispatch id, then carry on with your own work or end your turn. The user follows the dispatch in the sidebar. With `--peer`, its questions still reach you as new turns.
35
+ The started session gets the task alone and does not know your session exists. Tell the user the session name, its group, and the dispatch id. Your part ends with that message: the user follows the dispatch in the sidebar, and asks you when they want it checked.
36
36
 
37
37
  ## With `--report`
38
38
 
39
39
  The started session settles with one report, `succeeded` or `failed`, or as `exited` when its terminal ends first. Start every independent dispatch first, then collect. Two channels, use either or both:
40
40
 
41
- - **Inbox:** this skill armed it. Lines `cck:1 dispatch.<status> <id> ...` arrive on their own while you keep working.
41
+ - **Inbox:** this skill armed it. Lines `[kanban board] Dispatch <id> (session <uuid>) ...` arrive on their own while you keep working.
42
42
  - **Wait:** block until one settles.
43
43
 
44
44
  ```bash