claude-code-kanban 7.4.0 → 7.6.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 (48) hide show
  1. package/README.md +5 -3
  2. package/cli.js +95 -29
  3. package/hub-app.json +5 -0
  4. package/lib/panes.js +41 -6
  5. package/lib/parsers.js +42 -3
  6. package/lib/retention.js +54 -6
  7. package/lib/scratchpad-dir.js +19 -2
  8. package/lib/session-cache.js +1 -1
  9. package/lib/session-events.js +174 -47
  10. package/lib/show.js +385 -0
  11. package/lib/task-dir.js +26 -0
  12. package/lib/terminal-client.js +14 -1
  13. package/lib/terminal-host.js +2 -1
  14. package/lib/terminal.js +28 -8
  15. package/package.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -5
  17. package/plugin/plugins/claude-code-kanban/hooks/doorbell.ts +136 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/index.ts +4 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/show.ts +120 -0
  20. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +2 -0
  21. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +3 -3
  22. package/plugin/plugins/claude-code-kanban/skills/show/SKILL.md +45 -0
  23. package/plugin/plugins/claude-code-kanban/skills/show/references/NOTICE.md +27 -0
  24. package/plugin/plugins/claude-code-kanban/skills/show/references/chart.md +58 -0
  25. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/architecture.md +18 -0
  26. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/er.md +10 -0
  27. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/flow.md +13 -0
  28. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/layers.md +10 -0
  29. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/sequence.md +19 -0
  30. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/state.md +12 -0
  31. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/swimlane.md +10 -0
  32. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/timeline.md +9 -0
  33. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram/tree.md +10 -0
  34. package/plugin/plugins/claude-code-kanban/skills/show/references/diagram.md +87 -0
  35. package/plugin/plugins/claude-code-kanban/skills/show/references/explain.md +98 -0
  36. package/plugin/plugins/claude-code-kanban/skills/show/references/mockup.md +30 -0
  37. package/plugin/plugins/claude-code-kanban/tests/doorbell.test.ts +133 -0
  38. package/plugin/plugins/claude-code-kanban/tests/show.test.ts +137 -0
  39. package/public/app.js +2151 -1108
  40. package/public/index.html +19 -36
  41. package/public/style.css +1047 -125
  42. package/public/terminal-frame.js +0 -2
  43. package/public/tree-highlight.js +137 -15
  44. package/server.js +237 -178
  45. package/lib/dispatch.js +0 -32
  46. package/plugin/plugins/claude-code-kanban/monitors.json +0 -8
  47. package/plugin/plugins/claude-code-kanban/scripts/postman.js +0 -82
  48. package/plugin/plugins/claude-code-kanban/skills/follow/SKILL.md +0 -47
package/server.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const express = require('express');
5
5
  const path = require('node:path');
6
6
  const fs = require('node:fs').promises;
7
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
7
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, rmSync, mkdirSync, renameSync, openSync, readSync, closeSync, realpathSync } = require('node:fs');
8
8
  const _readline = require('node:readline');
9
9
  const chokidar = require('chokidar');
10
10
  const os = require('node:os');
@@ -12,7 +12,7 @@ const crypto = require('node:crypto');
12
12
  const { spawn } = require('node:child_process');
13
13
  const { assertOpenTarget, openInEditor, whichSync, exeBehindShim } = require('./lib/open-editor');
14
14
  const { createNetGuard } = require('./lib/net-guard');
15
- const { isContained } = require('./lib/contain');
15
+ const { isContained, realpathDeepest } = require('./lib/contain');
16
16
  const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
17
17
  const { fileUrlToPath } = require('./lib/file-url');
18
18
  const { httpError: previewError } = require('./lib/http-error');
@@ -28,6 +28,7 @@ const {
28
28
  modelDisplayName,
29
29
  buildSessionDigest,
30
30
  readCompactSummaries,
31
+ fillCompactSummaries,
31
32
  readArtifactLinks,
32
33
  readScratchpadCreations,
33
34
  extractPromptFromTranscript,
@@ -48,14 +49,15 @@ const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRef
48
49
  const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
49
50
  const { readTerminalConfig } = require('./lib/terminal');
50
51
  const { createTerminalClient } = require('./lib/terminal-client');
52
+ const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
51
53
  const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
52
54
  const { createProcStats } = require('./lib/proc-stats');
53
- const { createDispatchRegistry } = require('./lib/dispatch');
54
55
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
55
- const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, retentionMs } = require('./lib/retention');
56
+ const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, pruneTaskMaps, retentionMs } = require('./lib/retention');
56
57
  const { freshRateLimits } = require('./lib/rate-limits');
57
58
  const { createWorktreeStore } = require('./lib/worktrees');
58
- const { createScratchpadDirResolver } = require('./lib/scratchpad-dir');
59
+ const { createScratchpadDirResolver, scratchpadRoot } = require('./lib/scratchpad-dir');
60
+ const { readSettings } = require('./lib/claude-settings');
59
61
  const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
60
62
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
61
63
  const { createPaneStore, isOwnOrigin } = require('./lib/panes');
@@ -63,6 +65,7 @@ const { probeFraming, parseOrigin } = require('./lib/frame-policy');
63
65
  const { pickFolder } = require('./lib/folder-dialog');
64
66
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
65
67
  const { countTaskDir } = require('./lib/task-counts');
68
+ const { readTaskDir } = require('./lib/task-dir');
66
69
  const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
67
70
  const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
68
71
 
@@ -115,13 +118,25 @@ const PANES_FILE = path.join(CCK_DIR, 'panes.json');
115
118
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
116
119
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
117
120
  const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
118
- const TERMINALS_FILE = path.join(CCK_DIR, 'terminals.json');
121
+ // Every board on a config dir rewrites the whole file, so a test board points this elsewhere.
122
+ const TERMINALS_FILE = process.env.CCK_TERMINALS_FILE ? path.resolve(process.env.CCK_TERMINALS_FILE) : path.join(CCK_DIR, 'terminals.json');
119
123
  // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
120
124
  const TEMP_ROOT = (() => {
121
125
  try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
122
126
  })();
123
127
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
124
- const SCRATCHPAD_ROOT = path.join(TEMP_ROOT, 'claude');
128
+ // Used only for a transcript that does not record its scratchpad dir. Settings come first:
129
+ // cck often runs under the hub or a tray, not in the shell where `claude` ran.
130
+ const SCRATCHPAD_ROOT = (() => {
131
+ const root = scratchpadRoot({
132
+ platform: process.platform,
133
+ overrides: [readSettings(path.join(CLAUDE_DIR, 'settings.json'))?.env?.CLAUDE_CODE_TMPDIR, process.env.CLAUDE_CODE_TMPDIR],
134
+ tmpdir: os.tmpdir(),
135
+ uid: process.getuid?.(),
136
+ });
137
+ // Claude Code resolves the root with realpath too: /private/tmp/claude-<uid> on macOS.
138
+ return realpathDeepest(root);
139
+ })();
125
140
 
126
141
  // #endregion
127
142
 
@@ -136,25 +151,66 @@ function readPins() {
136
151
  }
137
152
 
138
153
  function writeJsonAtomic(file, obj, mode) {
154
+ mkdirSync(path.dirname(file), { recursive: true });
155
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
139
156
  try {
140
- mkdirSync(path.dirname(file), { recursive: true });
141
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
142
157
  writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
143
158
  renameSync(tmp, file);
159
+ } catch (e) {
160
+ rmSync(tmp, { force: true });
161
+ throw e;
162
+ }
163
+ }
164
+
165
+ // Claude Code reads task files while the board writes them, so it must never see half a file.
166
+ // On Windows a rename over a file another handle has open fails with EPERM or EBUSY until it closes.
167
+ let taskWriteSeq = 0;
168
+ async function writeTaskFile(file, task) {
169
+ const tmp = `${file}.${process.pid}.${++taskWriteSeq}.tmp`;
170
+ try {
171
+ await fs.writeFile(tmp, JSON.stringify(task, null, 2));
172
+ for (let attempt = 1; ; attempt++) {
173
+ try {
174
+ await fs.rename(tmp, file);
175
+ break;
176
+ } catch (e) {
177
+ if (attempt >= 10 || (e.code !== 'EPERM' && e.code !== 'EBUSY')) throw e;
178
+ await new Promise((r) => setTimeout(r, attempt * 10));
179
+ }
180
+ }
181
+ } catch (e) {
182
+ await fs.rm(tmp, { force: true });
183
+ throw e;
184
+ }
185
+ }
186
+
187
+ // For state that is also held in memory: a failed save is logged and the server goes on.
188
+ function writeJsonAtomicOrLog(file, obj, mode) {
189
+ try {
190
+ writeJsonAtomic(file, obj, mode);
144
191
  } catch (e) {
145
192
  console.error(`Failed to write ${path.basename(file)}:`, e.message);
146
193
  }
147
194
  }
148
195
 
196
+ function readJsonOrNull(file) {
197
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
198
+ }
199
+
200
+ const jsonFile = (file) => ({
201
+ load: () => readJsonOrNull(file),
202
+ save: (data) => writeJsonAtomicOrLog(file, data),
203
+ });
204
+
149
205
  function writePins(pins) {
150
206
  writeJsonAtomic(PINS_FILE, pins);
151
207
  }
152
208
 
153
- // Port discovery for out-of-process helpers (the postman monitor, the CLI).
209
+ // Port discovery for out-of-process helpers (the plugin's doorbell mod, the CLI).
154
210
  // The pid rides along so a reader can tell a live server from a file left behind by
155
211
  // a crashed one.
156
212
  function writeServerInfo(port) {
157
- writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
213
+ writeJsonAtomicOrLog(SERVER_INFO_FILE, { port, pid: process.pid });
158
214
  }
159
215
 
160
216
  // For `dispatch start`: a local process as the same user can already run claude itself,
@@ -172,14 +228,14 @@ function writeTerminalToken(port) {
172
228
  if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
173
229
  }
174
230
  terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
175
- writeJsonAtomic(terminalTokenFile, { pid: process.pid, token: terminal.token }, 0o600);
231
+ writeJsonAtomicOrLog(terminalTokenFile,{ pid: process.pid, token: terminal.token }, 0o600);
176
232
  }
177
233
 
178
234
  function ownerPid(file) {
179
235
  try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
180
236
  }
181
237
 
182
- // A beacon that outlives its server sends the postman monitor and the CLI to a closed
238
+ // A beacon that outlives its server sends the doorbell mod and the CLI to a closed
183
239
  // port. The hub spawns every sub-app on
184
240
  // an ephemeral port, so a stale beacon never comes back on its own — drop ours on
185
241
  // the way out. Only when the file is still ours: a newer server on the same config
@@ -255,9 +311,14 @@ function persistAgent(dir, agent) {
255
311
  const CCK_CONFIG_FILE = path.join(CCK_DIR, 'config.json');
256
312
  const LEGACY_APPROVALS_FILE = path.join(CCK_DIR, 'approvals.json');
257
313
  const cckConfigCache = new Map();
314
+ const readCckConfig = () => JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'));
258
315
  function approvalsConfig() {
259
316
  return cachedByMtime(cckConfigCache, 'approvals', CCK_CONFIG_FILE,
260
- () => approvalsFrom(JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'))), approvalsFrom(null));
317
+ () => approvalsFrom(readCckConfig()), approvalsFrom(null));
318
+ }
319
+ function boardEventsEnabled() {
320
+ return cachedByMtime(cckConfigCache, 'boardEvents', CCK_CONFIG_FILE,
321
+ () => boardEventsOn(readCckConfig()), boardEventsOn(null));
261
322
  }
262
323
 
263
324
  // approvals.json predates config.json (and was opt-in). Fold it into
@@ -268,7 +329,7 @@ function migrateLegacyApprovalsConfig() {
268
329
  const legacy = JSON.parse(readFileSync(LEGACY_APPROVALS_FILE, 'utf8'));
269
330
  let cfg = {};
270
331
  try { cfg = JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8')) || {}; } catch { /* absent */ }
271
- if (!cfg.approvals) writeJsonAtomic(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
332
+ if (!cfg.approvals) writeJsonAtomicOrLog(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
272
333
  unlinkSync(LEGACY_APPROVALS_FILE);
273
334
  cckConfigCache.clear();
274
335
  } catch (e) {
@@ -352,12 +413,7 @@ function getGitBranch(cwd) {
352
413
  return branch;
353
414
  }
354
415
 
355
- const worktrees = createWorktreeStore({
356
- load: () => {
357
- try { return JSON.parse(readFileSync(WORKTREES_FILE, 'utf8')); } catch { return null; }
358
- },
359
- save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
360
- });
416
+ const worktrees = createWorktreeStore(jsonFile(WORKTREES_FILE));
361
417
 
362
418
  function getSessionLogStat(meta) {
363
419
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
@@ -457,6 +513,11 @@ function resolveSessionId(sessionId) {
457
513
  return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
458
514
  }
459
515
 
516
+ function sessionMetaFor(sessionId) {
517
+ const metadata = loadSessionMetadata();
518
+ return metadata[sessionId] || metadata[resolveSessionId(sessionId)];
519
+ }
520
+
460
521
  // Recent Claude Code releases auto-create a single-member "self-team" for every
461
522
  // session: a teams/session-<id>/config.json whose only member is the "team-lead"
462
523
  // (the session itself). These are not real multi-agent teams — surfacing them
@@ -600,8 +661,9 @@ app.param('name', (_req, res, next, val) => {
600
661
  next();
601
662
  });
602
663
 
603
- // Parse JSON bodies
604
- app.use(express.json());
664
+ app.use(SHOW_PATH, showBodyParser());
665
+ // Parse JSON bodies. A review of 50 comments at the field caps is about 450 kB.
666
+ app.use(express.json({ limit: '1mb' }));
605
667
  // #endregion
606
668
 
607
669
  // #region STATIC
@@ -840,6 +902,7 @@ function refreshSessionMetadataPath(jsonlPath) {
840
902
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
841
903
  existing.permissionMode = info.permissionMode;
842
904
  existing.cacheTtl = info.cacheTtl;
905
+ if (info.scratchpadDir) existing.scratchpadDir = info.scratchpadDir;
843
906
  existing.lastReply = info.lastReply;
844
907
  return true;
845
908
  }
@@ -932,7 +995,8 @@ function loadSessionMetadata() {
932
995
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
933
996
  permissionMode: sessionInfo.permissionMode,
934
997
  cacheTtl: sessionInfo.cacheTtl || null,
935
- lastReply: sessionInfo.lastReply || null
998
+ lastReply: sessionInfo.lastReply || null,
999
+ scratchpadDir: sessionInfo.scratchpadDir || null
936
1000
  };
937
1001
  sessionIds.push(sessionId);
938
1002
  }
@@ -1136,6 +1200,8 @@ function getSessionDisplayName(_sessionId, meta) {
1136
1200
  const getScratchpadDir = createScratchpadDirResolver({
1137
1201
  root: SCRATCHPAD_ROOT,
1138
1202
  resolveWorktree: worktrees.resolve,
1203
+ // Transcripts record the 8.3 short form on Windows; path checks and the watcher need the long one.
1204
+ toLong: realpathDeepest,
1139
1205
  });
1140
1206
 
1141
1207
  function buildSessionObject(id, meta, overrides = {}) {
@@ -1707,25 +1773,9 @@ function addOwnerSessions(tasks, listIdOf) {
1707
1773
 
1708
1774
  app.get('/api/sessions/:sessionId', async (req, res) => {
1709
1775
  try {
1710
- const sessionPath = taskDirFor(req.params.sessionId);
1711
-
1712
1776
  // A session that never used the board has no task dir. That is the common case and
1713
1777
  // an answer, not a failure; a 404 only reached the browser console.
1714
- if (!existsSync(sessionPath)) {
1715
- return res.json([]);
1716
- }
1717
-
1718
- const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
1719
- const tasks = [];
1720
-
1721
- for (const file of taskFiles) {
1722
- try {
1723
- const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
1724
- tasks.push(task);
1725
- } catch (e) {
1726
- console.error(`Error parsing ${file}:`, e);
1727
- }
1728
- }
1778
+ const tasks = (await readTaskDir(taskDirFor(req.params.sessionId))).map(({ task }) => task);
1729
1779
 
1730
1780
  // Sort by ID (numeric)
1731
1781
  tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
@@ -1739,41 +1789,24 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
1739
1789
  });
1740
1790
 
1741
1791
  // API: Get combined tasks for a project (all sessions + shared task lists)
1742
- app.get('/api/projects/:encodedPath/tasks', (req, res) => {
1792
+ app.get('/api/projects/:encodedPath/tasks', async (req, res) => {
1743
1793
  try {
1744
1794
  const projectPath = Buffer.from(req.params.encodedPath, 'base64').toString('utf8');
1745
1795
  const metadata = loadSessionMetadata();
1746
- const { sessionToList } = loadAllTaskMaps();
1747
-
1748
- const projectSessionIds = Object.entries(metadata)
1749
- .filter(([, m]) => m.project === projectPath)
1750
- .map(([id]) => id);
1751
-
1752
- const taskDirs = new Set();
1753
- for (const sid of projectSessionIds) {
1754
- const listName = sessionToList[sid];
1755
- if (listName) {
1756
- const dir = path.join(TASKS_DIR, listName);
1757
- if (existsSync(dir)) taskDirs.add(dir);
1758
- } else {
1759
- const dir = path.join(TASKS_DIR, sid);
1760
- if (existsSync(dir)) taskDirs.add(dir);
1761
- }
1762
- }
1796
+ const taskDirs = new Set(
1797
+ Object.entries(metadata)
1798
+ .filter(([, m]) => m.project === projectPath)
1799
+ .map(([id]) => taskDirFor(id)),
1800
+ );
1763
1801
 
1764
1802
  const tasks = [];
1765
- const seenKeys = new Set();
1766
1803
  for (const dir of taskDirs) {
1767
- for (const file of readdirSync(dir).filter(f => f.endsWith('.json'))) {
1768
- try {
1769
- const task = JSON.parse(readFileSync(path.join(dir, file), 'utf8'));
1770
- const key = `${dir}:${task.id}`;
1771
- if (!seenKeys.has(key)) {
1772
- seenKeys.add(key);
1773
- task._taskDir = path.basename(dir);
1774
- tasks.push(task);
1775
- }
1776
- } catch (_) {}
1804
+ const seenIds = new Set();
1805
+ for (const { task } of await readTaskDir(dir)) {
1806
+ if (seenIds.has(task.id)) continue;
1807
+ seenIds.add(task.id);
1808
+ task._taskDir = path.basename(dir);
1809
+ tasks.push(task);
1777
1810
  }
1778
1811
  }
1779
1812
  tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
@@ -1788,8 +1821,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
1788
1821
  // API: Get session plan
1789
1822
  app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1790
1823
  try {
1791
- const metadata = loadSessionMetadata();
1792
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1824
+ const meta = sessionMetaFor(req.params.sessionId);
1793
1825
  // Most sessions have no saved plan, and the info modal asks for one every time it
1794
1826
  // opens, so "no plan" is a normal answer rather than a 404 in the console.
1795
1827
  const slug = meta?.slug;
@@ -1808,8 +1840,7 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1808
1840
 
1809
1841
  app.get('/api/sessions/:sessionId/loop', (req, res) => {
1810
1842
  try {
1811
- const metadata = loadSessionMetadata();
1812
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1843
+ const meta = sessionMetaFor(req.params.sessionId);
1813
1844
  if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
1814
1845
  const state = refreshLoopInfoState(meta.jsonlPath);
1815
1846
  const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
@@ -1851,8 +1882,7 @@ function getArtifactLinks(jsonlPath) {
1851
1882
 
1852
1883
  app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
1853
1884
  try {
1854
- const metadata = loadSessionMetadata();
1855
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1885
+ const meta = sessionMetaFor(req.params.sessionId);
1856
1886
  if (!meta?.jsonlPath) return res.json({ artifacts: [] });
1857
1887
  res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
1858
1888
  } catch (error) {
@@ -1956,8 +1986,7 @@ function getCreatedPads(meta) {
1956
1986
 
1957
1987
  app.get('/api/sessions/:sessionId/pads', async (req, res) => {
1958
1988
  try {
1959
- const metadata = loadSessionMetadata();
1960
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1989
+ const meta = sessionMetaFor(req.params.sessionId);
1961
1990
  if (!meta?.jsonlPath) return res.json({ pads: [] });
1962
1991
  res.json({ pads: await getCreatedPads(meta) });
1963
1992
  } catch (error) {
@@ -2290,8 +2319,7 @@ app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
2290
2319
  // API: Open session plan in VS Code
2291
2320
  app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
2292
2321
  try {
2293
- const metadata = loadSessionMetadata();
2294
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
2322
+ const meta = sessionMetaFor(req.params.sessionId);
2295
2323
  const slug = meta?.slug;
2296
2324
  if (!slug) return res.status(404).json({ error: 'No plan found' });
2297
2325
 
@@ -3006,8 +3034,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
3006
3034
  async function sendSessionMessages(req, res) {
3007
3035
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
3008
3036
  const before = req.query.before || null;
3009
- const metadata = loadSessionMetadata();
3010
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3037
+ const meta = sessionMetaFor(req.params.sessionId);
3011
3038
  const jsonlPath = meta?.jsonlPath;
3012
3039
  if (!jsonlPath) return res.json({ messages: [], hasMore: false, sessionId: req.params.sessionId });
3013
3040
  let messages, hasMore;
@@ -3020,7 +3047,10 @@ async function sendSessionMessages(req, res) {
3020
3047
  hasMore = messages.length > limit;
3021
3048
  if (hasMore) messages = messages.slice(-limit);
3022
3049
  }
3023
- const compactPromise = cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), []);
3050
+ const compactedMsgs = messages.filter(m => m.systemLabel === 'Compacted');
3051
+ const compactPromise = compactedMsgs.some(m => !m.compactSummary)
3052
+ ? cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), [])
3053
+ : null;
3024
3054
  const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
3025
3055
  if (agentMessages.length) {
3026
3056
  const progressMap = await getProgressMap(jsonlPath);
@@ -3074,16 +3104,7 @@ async function sendSessionMessages(req, res) {
3074
3104
  }
3075
3105
  }
3076
3106
  }
3077
- const compactSummaries = await compactPromise;
3078
- // Match compaction messages to summaries by chronological order
3079
- const compactedMsgs = messages
3080
- .filter(m => m.systemLabel === 'Compacted')
3081
- .sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
3082
- for (let i = 0; i < compactedMsgs.length; i++) {
3083
- if (i < compactSummaries.length) {
3084
- compactedMsgs[i].compactSummary = compactSummaries[i].summary;
3085
- }
3086
- }
3107
+ if (compactPromise) fillCompactSummaries(compactedMsgs, await compactPromise);
3087
3108
  // The client keeps needing toolUseId when it builds a follow-up URL from it:
3088
3109
  // lazy-fetching a truncated tool result, or fetching each tool-result image.
3089
3110
  const clientNeedsToolUseId = (msg) => msg.toolResultTruncated || msg.toolResultImageCount;
@@ -3097,8 +3118,7 @@ async function sendSessionMessages(req, res) {
3097
3118
  app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
3098
3119
 
3099
3120
  app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
3100
- const metadata = loadSessionMetadata();
3101
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3121
+ const meta = sessionMetaFor(req.params.sessionId);
3102
3122
  const jsonlPath = meta?.jsonlPath;
3103
3123
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3104
3124
  const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
@@ -3203,8 +3223,7 @@ async function buildToolStats(jsonlPath) {
3203
3223
  }
3204
3224
 
3205
3225
  app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
3206
- const metadata = loadSessionMetadata();
3207
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3226
+ const meta = sessionMetaFor(req.params.sessionId);
3208
3227
  const jsonlPath = meta?.jsonlPath;
3209
3228
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3210
3229
  try {
@@ -3217,8 +3236,7 @@ app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
3217
3236
  });
3218
3237
 
3219
3238
  async function sendTranscriptImage(req, res, read) {
3220
- const metadata = loadSessionMetadata();
3221
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3239
+ const meta = sessionMetaFor(req.params.sessionId);
3222
3240
  const jsonlPath = meta?.jsonlPath;
3223
3241
  if (!jsonlPath) return res.status(404).end();
3224
3242
  const img = await read(jsonlPath);
@@ -3299,6 +3317,16 @@ function resolveSessionFolder(id) {
3299
3317
  return meta ? meta.project || meta.cwd || null : null;
3300
3318
  }
3301
3319
 
3320
+ // The host saves its terminals; cck adds the show map (terminal id → session id) beside them.
3321
+ const terminalsFile = jsonFile(TERMINALS_FILE);
3322
+ let savedTerminals = terminalsFile.load() || {};
3323
+
3324
+ function saveTerminals() {
3325
+ const kept = new Set(Object.values(savedTerminals.terminalIds || {}));
3326
+ const showSessions = show.prune((id) => kept.has(id) || terminal.hasTerminal(id));
3327
+ terminalsFile.save({ ...savedTerminals, showSessions });
3328
+ }
3329
+
3302
3330
  const terminal = createTerminalClient({
3303
3331
  config: readTerminalConfig({ getArgValue }),
3304
3332
  net,
@@ -3306,17 +3334,18 @@ const terminal = createTerminalClient({
3306
3334
  isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
3307
3335
  sessionsDir: SESSIONS_DIR,
3308
3336
  token: process.env.CCK_TERMINAL_TOKEN,
3309
- load: () => {
3310
- try { return JSON.parse(readFileSync(TERMINALS_FILE, 'utf8')); } catch { return null; }
3337
+ load: terminalsFile.load,
3338
+ save: (data) => {
3339
+ savedTerminals = data;
3340
+ saveTerminals();
3311
3341
  },
3312
- save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
3313
3342
  // The project, not the last cwd: `claude --resume` finds a session under the
3314
3343
  // project dir it started in, and cwd drifts into subdirectories.
3315
3344
  resolveCwd: resolveSessionFolder,
3316
3345
  isAllowedFolder,
3317
3346
  onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
3318
3347
  onExit: (id) => {
3319
- if (dispatches.remove(id)) broadcast({ type: 'dispatch-update' });
3348
+ if (dispatched.get(id)) broadcast({ type: 'dispatch-update' });
3320
3349
  },
3321
3350
  });
3322
3351
 
@@ -3377,6 +3406,25 @@ app.delete('/api/terminals/:id', terminalRoute(async (req, res) => {
3377
3406
  res.status(204).end();
3378
3407
  }));
3379
3408
 
3409
+ const show = createShowStore({
3410
+ load: () => savedTerminals.showSessions,
3411
+ onChange: saveTerminals,
3412
+ onPosted: (e) => broadcast({ type: 'show:posted', ...e }),
3413
+ startedSession: terminal.sessionOfTerminal,
3414
+ resolveDir: (id) => {
3415
+ const meta = sessionMetaFor(id);
3416
+ return meta ? getScratchpadDir(id, meta) : null;
3417
+ },
3418
+ });
3419
+ mountShowRoutes(app, {
3420
+ store: show,
3421
+ authorized: (token) => terminal.authorized(token),
3422
+ hasTerminal: (id) => terminal.hasTerminal(id),
3423
+ readPreviewFile,
3424
+ broadcast,
3425
+ onRemoved: (sessionId, paths) => panes.removeShow(sessionId, paths),
3426
+ });
3427
+
3380
3428
  // Served from node_modules, never a CDN: any script on this page can use the token.
3381
3429
  const XTERM_FILES = {
3382
3430
  'xterm.js': '@xterm/xterm/lib/xterm.js',
@@ -3396,30 +3444,15 @@ app.get('/vendor/xterm/:file', (req, res) => {
3396
3444
  // #endregion
3397
3445
 
3398
3446
  // #region DISPATCH
3399
- const dispatched = createDispatchedStore({
3400
- load: () => {
3401
- try { return JSON.parse(readFileSync(DISPATCHED_FILE, 'utf8')); } catch { return null; }
3402
- },
3403
- save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
3404
- });
3405
-
3406
- const dispatches = createDispatchRegistry();
3447
+ const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
3407
3448
 
3408
3449
  const dispatchGroups = createGroupStore({
3409
- load: () => {
3410
- try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
3411
- },
3412
- save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
3450
+ ...jsonFile(DISPATCH_GROUPS_FILE),
3413
3451
  isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
3414
3452
  pinnedIds: () => new Set(Object.keys(readPins())),
3415
3453
  });
3416
3454
 
3417
- const linkedDocs = createLinkedDocStore({
3418
- load: () => {
3419
- try { return JSON.parse(readFileSync(LINKED_DOCS_FILE, 'utf8')); } catch { return null; }
3420
- },
3421
- save: (data) => writeJsonAtomic(LINKED_DOCS_FILE, data),
3422
- });
3455
+ const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
3423
3456
 
3424
3457
  // The board places a session from these alone, so it never has to move it later.
3425
3458
  function withDispatchPlacement(sessions) {
@@ -3448,8 +3481,7 @@ app.post('/api/dispatch', terminalRoute(async (req, res) => {
3448
3481
  }
3449
3482
  const started = await terminal.startNew({ cwd, name, model, worktree, taskList, prompt: spec.trim(), extraArgs: claudeArgs });
3450
3483
  if (started.error) return res.status(started.status).json({ error: started.error });
3451
- dispatches.add({ session: started.id, parent, cwd: started.cwd, name, group: group || null, worktree });
3452
- dispatched.record(started.id, parent);
3484
+ dispatched.record(started.id, { parent, cwd: started.cwd, name, group, worktree });
3453
3485
  // The starter stays where it is: moving it would jump it under the user.
3454
3486
  if (group) dispatchGroups.join(group, [started.id]);
3455
3487
  broadcast({ type: 'dispatch-update' });
@@ -3459,7 +3491,7 @@ app.post('/api/dispatch', terminalRoute(async (req, res) => {
3459
3491
  app.get('/api/dispatch', (req, res) => {
3460
3492
  res.setHeader('Cache-Control', 'no-store');
3461
3493
  const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
3462
- res.json({ running: dispatches.list({ parent }) });
3494
+ res.json({ running: dispatched.running(terminal.isRunning, { parent }) });
3463
3495
  });
3464
3496
  // #endregion
3465
3497
 
@@ -3473,14 +3505,13 @@ app.get('/api/tasks/all', async (_req, res) => {
3473
3505
 
3474
3506
  const metadata = loadSessionMetadata();
3475
3507
  const { listToSessions } = loadAllTaskMaps();
3476
- const sessionDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
3508
+ const sessionDirs = (await fs.readdir(TASKS_DIR, { withFileTypes: true }))
3477
3509
  .filter(d => d.isDirectory());
3510
+ const dirTasks = await Promise.all(sessionDirs.map((d) => readTaskDir(path.join(TASKS_DIR, d.name))));
3478
3511
 
3479
3512
  const allTasks = [];
3480
3513
 
3481
- for (const sessionDir of sessionDirs) {
3482
- const sessionPath = path.join(TASKS_DIR, sessionDir.name);
3483
- const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
3514
+ sessionDirs.forEach((sessionDir, i) => {
3484
3515
  const meta = metadata[sessionDir.name] || {};
3485
3516
 
3486
3517
  // For custom task list directories (non-UUID dirs), resolve project from the
@@ -3496,20 +3527,15 @@ app.get('/api/tasks/all', async (_req, res) => {
3496
3527
  }
3497
3528
  }
3498
3529
 
3499
- for (const file of taskFiles) {
3500
- try {
3501
- const task = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
3502
- allTasks.push({
3503
- ...task,
3504
- sessionId: sessionDir.name,
3505
- sessionName: getSessionDisplayName(sessionDir.name, meta),
3506
- project
3507
- });
3508
- } catch {
3509
- // Skip invalid files
3510
- }
3530
+ for (const { task } of dirTasks[i]) {
3531
+ allTasks.push({
3532
+ ...task,
3533
+ sessionId: sessionDir.name,
3534
+ sessionName: getSessionDisplayName(sessionDir.name, meta),
3535
+ project
3536
+ });
3511
3537
  }
3512
- }
3538
+ });
3513
3539
 
3514
3540
  res.json(allTasks);
3515
3541
  } catch (error) {
@@ -3519,11 +3545,13 @@ app.get('/api/tasks/all', async (_req, res) => {
3519
3545
  });
3520
3546
 
3521
3547
  const {
3548
+ boardEventsOn,
3522
3549
  enqueueSessionEvent,
3523
3550
  formatReviewSubmitted,
3524
3551
  formatTaskMoved,
3525
3552
  handleSessionEvents,
3526
- hasSessionListener,
3553
+ hasDoorbell,
3554
+ configureSessionEvents,
3527
3555
  } = require('./lib/session-events');
3528
3556
  app.get('/api/sessions/:sessionId/events', handleSessionEvents);
3529
3557
 
@@ -3557,7 +3585,7 @@ app.post('/api/tasks/:sessionId', async (req, res) => {
3557
3585
 
3558
3586
  // No doorbell here, unlike a move: the user typed this task, so telling their session
3559
3587
  // about it would only repeat what they just said. Dragging it to In Progress rings.
3560
- await fs.writeFile(path.join(sessionDir, `${id}.json`), JSON.stringify(task, null, 2));
3588
+ await writeTaskFile(path.join(sessionDir, `${id}.json`), task);
3561
3589
  res.json({ success: true, task });
3562
3590
  } catch (error) {
3563
3591
  console.error('Error creating task:', error);
@@ -3585,14 +3613,14 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
3585
3613
  if (description !== undefined) task.description = description;
3586
3614
  if (req.body.status !== undefined) task.status = req.body.status;
3587
3615
 
3588
- await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
3616
+ await writeTaskFile(taskPath, task);
3589
3617
 
3590
3618
  // Ring the session only for a move. The direction has to ride in the line because
3591
3619
  // the write above destroyed the old status -- nothing downstream can recover it, and
3592
3620
  // which way a task moved is what decides whether to start work or stop it.
3593
- if (task.status !== prevStatus) {
3621
+ if (task.status !== prevStatus && boardEventsEnabled()) {
3594
3622
  // The route param is a task *directory*, which for a shared list or team board is
3595
- // not a session id -- and the postman polls with its own session id, so an unresolved
3623
+ // not a session id -- and the doorbell polls with its own session id, so an unresolved
3596
3624
  // name would queue the line where nobody drains it.
3597
3625
  const line = formatTaskMoved(taskId, prevStatus, task);
3598
3626
  for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
@@ -3608,21 +3636,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
3608
3636
  // A deleted task can't block anything, so drop the dangling reference instead of
3609
3637
  // refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
3610
3638
  async function deleteTasks(dir, shouldDelete) {
3611
- let files;
3612
- try {
3613
- files = (await fs.readdir(dir)).filter((f) => f.endsWith('.json'));
3614
- } catch (e) {
3615
- if (e.code === 'ENOENT') return [];
3616
- throw e;
3617
- }
3618
- const tasks = [];
3619
- for (const file of files) {
3620
- try {
3621
- tasks.push({ file, task: JSON.parse(await fs.readFile(path.join(dir, file), 'utf8')) });
3622
- } catch (e) {
3623
- console.error(`Error parsing ${file}:`, e);
3624
- }
3625
- }
3639
+ const tasks = await readTaskDir(dir);
3626
3640
  const doomed = tasks.filter((t) => shouldDelete(t));
3627
3641
  const doomedIds = new Set(doomed.map(({ task }) => task.id));
3628
3642
  const strip = (ids) => ids?.filter((id) => !doomedIds.has(id));
@@ -3635,7 +3649,7 @@ async function deleteTasks(dir, shouldDelete) {
3635
3649
  task.blockedBy = strip(task.blockedBy);
3636
3650
  task.blocks = strip(task.blocks);
3637
3651
  if (JSON.stringify(task) === before) return null;
3638
- return fs.writeFile(path.join(dir, file), JSON.stringify(task, null, 2));
3652
+ return writeTaskFile(path.join(dir, file), task);
3639
3653
  }),
3640
3654
  );
3641
3655
  // Delete the task file
@@ -3831,6 +3845,7 @@ function resolvePreviewPath(rawPath, base) {
3831
3845
  app.post('/api/preview', async (req, res) => {
3832
3846
  try {
3833
3847
  const { path: filePath, sessionId, base } = req.body || {};
3848
+ if (sessionId && !isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
3834
3849
  const abs = resolvePreviewPath(filePath, base);
3835
3850
  if (!abs) return res.status(400).json({ error: 'path is required' });
3836
3851
  // Validate here so the CLI still gets 400/404, but broadcast the path only —
@@ -3852,7 +3867,8 @@ app.post('/api/preview', async (req, res) => {
3852
3867
  app.post('/api/document/link', async (req, res) => {
3853
3868
  try {
3854
3869
  const { path: filePath, sessionId, unlink, open } = req.body || {};
3855
- if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
3870
+ if (!sessionId) return res.status(400).json({ error: 'sessionId is required' });
3871
+ if (!isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
3856
3872
  const url = linkUrl(filePath);
3857
3873
  const abs = url || resolvePreviewPath(filePath);
3858
3874
  if (!abs) return res.status(400).json({ error: 'path is required' });
@@ -4026,10 +4042,10 @@ const panes = createPaneStore({
4026
4042
  const stamp = panesFileStamp();
4027
4043
  if (stamp === panesStamp) return undefined;
4028
4044
  panesStamp = stamp;
4029
- try { return JSON.parse(readFileSync(PANES_FILE, 'utf8')); } catch { return null; }
4045
+ return readJsonOrNull(PANES_FILE);
4030
4046
  },
4031
4047
  save: (data) => {
4032
- writeJsonAtomic(PANES_FILE, data);
4048
+ writeJsonAtomicOrLog(PANES_FILE, data);
4033
4049
  panesStamp = panesFileStamp();
4034
4050
  },
4035
4051
  onChange: (sessionId, layout) => {
@@ -4103,9 +4119,11 @@ app.get('/api/panes/:sessionId', (req, res) => {
4103
4119
 
4104
4120
  app.post('/api/panes/:sessionId', async (req, res) => {
4105
4121
  try {
4106
- const { target, base, title } = req.body || {};
4122
+ const { target, base, title, kind, show } = req.body || {};
4107
4123
  if (typeof target !== 'string' || !target) throw previewError(400, 'target is required', 'bad_target');
4108
- res.json(panes.add(req.params.sessionId, { ...(await resolvePaneTarget(target, base)), title }));
4124
+ // A message target is a session log message id, which the board resolves when it shows the pane.
4125
+ const resolved = kind === 'message' ? { kind, target } : await resolvePaneTarget(target, base);
4126
+ res.json(panes.add(req.params.sessionId, { ...resolved, title, show: show === true }));
4109
4127
  } catch (error) {
4110
4128
  paneRouteError(res, error, 'POST /api/panes');
4111
4129
  }
@@ -4140,6 +4158,19 @@ app.get('/api/panes/:sessionId/:paneId/framing', async (req, res) => {
4140
4158
  }
4141
4159
  });
4142
4160
 
4161
+ app.patch('/api/panes/:sessionId/:paneId', (req, res) => {
4162
+ try {
4163
+ const { sessionId, paneId } = req.params;
4164
+ const title = req.body?.title;
4165
+ if (typeof title !== 'string') throw previewError(400, 'title must be a string', 'bad_title');
4166
+ const layout = panes.rename(sessionId, paneId, title);
4167
+ if (!layout) throw previewError(404, `No pane ${paneId} in session ${sessionId}`, 'no_pane');
4168
+ res.json({ layout });
4169
+ } catch (error) {
4170
+ paneRouteError(res, error, 'PATCH /api/panes/:paneId');
4171
+ }
4172
+ });
4173
+
4143
4174
  app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
4144
4175
  const { sessionId, paneId } = req.params;
4145
4176
  const layout = panes.remove(sessionId, paneId);
@@ -4155,6 +4186,23 @@ app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
4155
4186
  // carries one line, and the file is what each route points at.
4156
4187
  const REVIEW_DIR = path.join(CCK_DIR, 'reviews');
4157
4188
  const REVIEW_KIND_RE = /^[a-z]{1,20}$/;
4189
+
4190
+ // `<ts>.pending` holds the doorbell line of a review the mod has not acked yet.
4191
+ configureSessionEvents({
4192
+ onDelivered: (entry) => fs.unlink(entry.marker).catch(() => {}),
4193
+ restore: async (sessionId) => {
4194
+ if (!isUUID(sessionId)) return [];
4195
+ const dir = path.join(REVIEW_DIR, sessionId);
4196
+ const names = (await fs.readdir(dir).catch(() => [])).filter((n) => n.endsWith('.pending')).sort();
4197
+ const lines = [];
4198
+ for (const name of names) {
4199
+ const marker = path.join(dir, name);
4200
+ const text = await fs.readFile(marker, 'utf8').catch(() => '');
4201
+ if (text) lines.push({ text, marker });
4202
+ }
4203
+ return lines;
4204
+ },
4205
+ });
4158
4206
  const REVIEW_MAX_COMMENTS = 50;
4159
4207
  const REVIEW_MAX_CHARS = 4000;
4160
4208
 
@@ -4223,8 +4271,11 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4223
4271
  await fs.writeFile(file, markdown);
4224
4272
 
4225
4273
  let delivered = null;
4226
- if (hasSessionListener(sessionId)) {
4227
- enqueueSessionEvent(sessionId, formatReviewSubmitted(src.items.length, src.label, file));
4274
+ if (boardEventsEnabled() && hasDoorbell(sessionId)) {
4275
+ const line = formatReviewSubmitted(src.items.length, src.label, file);
4276
+ const marker = file.replace(/\.md$/, '.pending');
4277
+ await fs.writeFile(marker, line);
4278
+ enqueueSessionEvent(sessionId, line, { marker });
4228
4279
  delivered = 'doorbell';
4229
4280
  } else if (
4230
4281
  terminal.authorized(req.get('x-terminal-token')) &&
@@ -4284,6 +4335,13 @@ app.use('/api', (_req, res) => {
4284
4335
  res.status(404).json({ error: 'Not found' });
4285
4336
  });
4286
4337
 
4338
+ app.use((err, req, res, next) => {
4339
+ if (res.headersSent || !req.path.startsWith('/api/')) return next(err);
4340
+ const status = err.status || 500;
4341
+ if (status >= 500) console.error(`Error in ${req.method} ${req.path}:`, err);
4342
+ res.status(status).json({ error: status >= 500 ? 'Internal error' : err.message });
4343
+ });
4344
+
4287
4345
  // Watch for file changes (chokidar handles non-existent paths)
4288
4346
  const watcher = chokidar.watch(TASKS_DIR, {
4289
4347
  persistent: true,
@@ -4498,8 +4556,10 @@ async function cleanupAgentActivity() {
4498
4556
  const now = Date.now();
4499
4557
  for (const entry of entries) {
4500
4558
  if (!entry.isDirectory()) continue;
4559
+ const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
4560
+ // Map files are rewritten in place, so the dir mtime does not track their use.
4561
+ if (dirPath === TASK_MAPS_DIR) continue;
4501
4562
  try {
4502
- const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
4503
4563
  const contents = await fs.readdir(dirPath);
4504
4564
  const stat = await fs.stat(dirPath);
4505
4565
  const age = now - stat.mtimeMs;
@@ -4533,8 +4593,9 @@ async function runRetention() {
4533
4593
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4534
4594
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4535
4595
  const wts = worktrees.prune(scan?.dirs);
4536
- if (markers || layouts || reviews || contexts || wts) {
4537
- console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees`);
4596
+ const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4597
+ if (markers || layouts || reviews || contexts || wts || taskMaps) {
4598
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings`);
4538
4599
  }
4539
4600
  } catch (e) {
4540
4601
  console.warn('[retention] failed:', e.message);
@@ -4561,7 +4622,7 @@ const PREWARM_SLICE_MS = 10;
4561
4622
  // first list is sent, or after the fallback when no client asks.
4562
4623
  const PREWARM_FALLBACK_MS = 5000;
4563
4624
  const SESSION_CACHE_SAVE_MS = 30000;
4564
- const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomic(SESSION_CACHE_FILE, data));
4625
+ const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomicOrLog(SESSION_CACHE_FILE, data));
4565
4626
  let prewarmStarted = false;
4566
4627
  function startPrewarm() {
4567
4628
  if (prewarmStarted) return;
@@ -4595,8 +4656,8 @@ async function prewarmCaches() {
4595
4656
  const onReady = (actualPort) => {
4596
4657
  boardPort = Number(actualPort);
4597
4658
  terminal.setServerUrl(`http://127.0.0.1:${actualPort}`);
4598
- // The port is configurable and falls back to a random one when taken, so the postman
4599
- // monitor cannot assume it -- publish the live one where it can read it.
4659
+ // The port is configurable and falls back to a random one when taken, so the doorbell
4660
+ // mod cannot assume it -- publish the live one where it can read it.
4600
4661
  writeServerInfo(actualPort);
4601
4662
  writeTerminalToken(actualPort);
4602
4663
  console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
@@ -4637,7 +4698,5 @@ async function prewarmCaches() {
4637
4698
  throw err;
4638
4699
  }
4639
4700
  });
4640
-
4641
-
4642
- // #endregion
4643
4701
  }
4702
+ // #endregion