claude-code-kanban 7.4.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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');
@@ -52,10 +52,11 @@ const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sess
52
52
  const { createProcStats } = require('./lib/proc-stats');
53
53
  const { createDispatchRegistry } = require('./lib/dispatch');
54
54
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
55
- const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, retentionMs } = require('./lib/retention');
55
+ const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, pruneTaskMaps, retentionMs } = require('./lib/retention');
56
56
  const { freshRateLimits } = require('./lib/rate-limits');
57
57
  const { createWorktreeStore } = require('./lib/worktrees');
58
- const { createScratchpadDirResolver } = require('./lib/scratchpad-dir');
58
+ const { createScratchpadDirResolver, scratchpadRoot } = require('./lib/scratchpad-dir');
59
+ const { readSettings } = require('./lib/claude-settings');
59
60
  const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
60
61
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
61
62
  const { createPaneStore, isOwnOrigin } = require('./lib/panes');
@@ -63,6 +64,7 @@ const { probeFraming, parseOrigin } = require('./lib/frame-policy');
63
64
  const { pickFolder } = require('./lib/folder-dialog');
64
65
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
65
66
  const { countTaskDir } = require('./lib/task-counts');
67
+ const { readTaskDir } = require('./lib/task-dir');
66
68
  const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
67
69
  const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
68
70
 
@@ -115,13 +117,25 @@ const PANES_FILE = path.join(CCK_DIR, 'panes.json');
115
117
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
116
118
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
117
119
  const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
118
- const TERMINALS_FILE = path.join(CCK_DIR, 'terminals.json');
120
+ // Every board on a config dir rewrites the whole file, so a test board points this elsewhere.
121
+ const TERMINALS_FILE = process.env.CCK_TERMINALS_FILE ? path.resolve(process.env.CCK_TERMINALS_FILE) : path.join(CCK_DIR, 'terminals.json');
119
122
  // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
120
123
  const TEMP_ROOT = (() => {
121
124
  try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
122
125
  })();
123
126
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
124
- const SCRATCHPAD_ROOT = path.join(TEMP_ROOT, 'claude');
127
+ // Used only for a transcript that does not record its scratchpad dir. Settings come first:
128
+ // cck often runs under the hub or a tray, not in the shell where `claude` ran.
129
+ const SCRATCHPAD_ROOT = (() => {
130
+ const root = scratchpadRoot({
131
+ platform: process.platform,
132
+ overrides: [readSettings(path.join(CLAUDE_DIR, 'settings.json'))?.env?.CLAUDE_CODE_TMPDIR, process.env.CLAUDE_CODE_TMPDIR],
133
+ tmpdir: os.tmpdir(),
134
+ uid: process.getuid?.(),
135
+ });
136
+ // Claude Code resolves the root with realpath too: /private/tmp/claude-<uid> on macOS.
137
+ return realpathDeepest(root);
138
+ })();
125
139
 
126
140
  // #endregion
127
141
 
@@ -136,25 +150,66 @@ function readPins() {
136
150
  }
137
151
 
138
152
  function writeJsonAtomic(file, obj, mode) {
153
+ mkdirSync(path.dirname(file), { recursive: true });
154
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
139
155
  try {
140
- mkdirSync(path.dirname(file), { recursive: true });
141
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
142
156
  writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
143
157
  renameSync(tmp, file);
158
+ } catch (e) {
159
+ rmSync(tmp, { force: true });
160
+ throw e;
161
+ }
162
+ }
163
+
164
+ // Claude Code reads task files while the board writes them, so it must never see half a file.
165
+ // On Windows a rename over a file another handle has open fails with EPERM or EBUSY until it closes.
166
+ let taskWriteSeq = 0;
167
+ async function writeTaskFile(file, task) {
168
+ const tmp = `${file}.${process.pid}.${++taskWriteSeq}.tmp`;
169
+ try {
170
+ await fs.writeFile(tmp, JSON.stringify(task, null, 2));
171
+ for (let attempt = 1; ; attempt++) {
172
+ try {
173
+ await fs.rename(tmp, file);
174
+ break;
175
+ } catch (e) {
176
+ if (attempt >= 10 || (e.code !== 'EPERM' && e.code !== 'EBUSY')) throw e;
177
+ await new Promise((r) => setTimeout(r, attempt * 10));
178
+ }
179
+ }
180
+ } catch (e) {
181
+ await fs.rm(tmp, { force: true });
182
+ throw e;
183
+ }
184
+ }
185
+
186
+ // For state that is also held in memory: a failed save is logged and the server goes on.
187
+ function writeJsonAtomicOrLog(file, obj, mode) {
188
+ try {
189
+ writeJsonAtomic(file, obj, mode);
144
190
  } catch (e) {
145
191
  console.error(`Failed to write ${path.basename(file)}:`, e.message);
146
192
  }
147
193
  }
148
194
 
195
+ function readJsonOrNull(file) {
196
+ try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return null; }
197
+ }
198
+
199
+ const jsonFile = (file) => ({
200
+ load: () => readJsonOrNull(file),
201
+ save: (data) => writeJsonAtomicOrLog(file, data),
202
+ });
203
+
149
204
  function writePins(pins) {
150
205
  writeJsonAtomic(PINS_FILE, pins);
151
206
  }
152
207
 
153
- // Port discovery for out-of-process helpers (the postman monitor, the CLI).
208
+ // Port discovery for out-of-process helpers (the plugin's doorbell mod, the CLI).
154
209
  // The pid rides along so a reader can tell a live server from a file left behind by
155
210
  // a crashed one.
156
211
  function writeServerInfo(port) {
157
- writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
212
+ writeJsonAtomicOrLog(SERVER_INFO_FILE, { port, pid: process.pid });
158
213
  }
159
214
 
160
215
  // For `dispatch start`: a local process as the same user can already run claude itself,
@@ -172,14 +227,14 @@ function writeTerminalToken(port) {
172
227
  if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
173
228
  }
174
229
  terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
175
- writeJsonAtomic(terminalTokenFile, { pid: process.pid, token: terminal.token }, 0o600);
230
+ writeJsonAtomicOrLog(terminalTokenFile,{ pid: process.pid, token: terminal.token }, 0o600);
176
231
  }
177
232
 
178
233
  function ownerPid(file) {
179
234
  try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
180
235
  }
181
236
 
182
- // A beacon that outlives its server sends the postman monitor and the CLI to a closed
237
+ // A beacon that outlives its server sends the doorbell mod and the CLI to a closed
183
238
  // port. The hub spawns every sub-app on
184
239
  // an ephemeral port, so a stale beacon never comes back on its own — drop ours on
185
240
  // the way out. Only when the file is still ours: a newer server on the same config
@@ -255,9 +310,14 @@ function persistAgent(dir, agent) {
255
310
  const CCK_CONFIG_FILE = path.join(CCK_DIR, 'config.json');
256
311
  const LEGACY_APPROVALS_FILE = path.join(CCK_DIR, 'approvals.json');
257
312
  const cckConfigCache = new Map();
313
+ const readCckConfig = () => JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'));
258
314
  function approvalsConfig() {
259
315
  return cachedByMtime(cckConfigCache, 'approvals', CCK_CONFIG_FILE,
260
- () => approvalsFrom(JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'))), approvalsFrom(null));
316
+ () => approvalsFrom(readCckConfig()), approvalsFrom(null));
317
+ }
318
+ function boardEventsEnabled() {
319
+ return cachedByMtime(cckConfigCache, 'boardEvents', CCK_CONFIG_FILE,
320
+ () => boardEventsOn(readCckConfig()), boardEventsOn(null));
261
321
  }
262
322
 
263
323
  // approvals.json predates config.json (and was opt-in). Fold it into
@@ -268,7 +328,7 @@ function migrateLegacyApprovalsConfig() {
268
328
  const legacy = JSON.parse(readFileSync(LEGACY_APPROVALS_FILE, 'utf8'));
269
329
  let cfg = {};
270
330
  try { cfg = JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8')) || {}; } catch { /* absent */ }
271
- if (!cfg.approvals) writeJsonAtomic(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
331
+ if (!cfg.approvals) writeJsonAtomicOrLog(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
272
332
  unlinkSync(LEGACY_APPROVALS_FILE);
273
333
  cckConfigCache.clear();
274
334
  } catch (e) {
@@ -352,12 +412,7 @@ function getGitBranch(cwd) {
352
412
  return branch;
353
413
  }
354
414
 
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
- });
415
+ const worktrees = createWorktreeStore(jsonFile(WORKTREES_FILE));
361
416
 
362
417
  function getSessionLogStat(meta) {
363
418
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
@@ -457,6 +512,11 @@ function resolveSessionId(sessionId) {
457
512
  return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
458
513
  }
459
514
 
515
+ function sessionMetaFor(sessionId) {
516
+ const metadata = loadSessionMetadata();
517
+ return metadata[sessionId] || metadata[resolveSessionId(sessionId)];
518
+ }
519
+
460
520
  // Recent Claude Code releases auto-create a single-member "self-team" for every
461
521
  // session: a teams/session-<id>/config.json whose only member is the "team-lead"
462
522
  // (the session itself). These are not real multi-agent teams — surfacing them
@@ -600,8 +660,8 @@ app.param('name', (_req, res, next, val) => {
600
660
  next();
601
661
  });
602
662
 
603
- // Parse JSON bodies
604
- app.use(express.json());
663
+ // Parse JSON bodies. A review of 50 comments at the field caps is about 450 kB.
664
+ app.use(express.json({ limit: '1mb' }));
605
665
  // #endregion
606
666
 
607
667
  // #region STATIC
@@ -840,6 +900,7 @@ function refreshSessionMetadataPath(jsonlPath) {
840
900
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
841
901
  existing.permissionMode = info.permissionMode;
842
902
  existing.cacheTtl = info.cacheTtl;
903
+ if (info.scratchpadDir) existing.scratchpadDir = info.scratchpadDir;
843
904
  existing.lastReply = info.lastReply;
844
905
  return true;
845
906
  }
@@ -932,7 +993,8 @@ function loadSessionMetadata() {
932
993
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
933
994
  permissionMode: sessionInfo.permissionMode,
934
995
  cacheTtl: sessionInfo.cacheTtl || null,
935
- lastReply: sessionInfo.lastReply || null
996
+ lastReply: sessionInfo.lastReply || null,
997
+ scratchpadDir: sessionInfo.scratchpadDir || null
936
998
  };
937
999
  sessionIds.push(sessionId);
938
1000
  }
@@ -1136,6 +1198,8 @@ function getSessionDisplayName(_sessionId, meta) {
1136
1198
  const getScratchpadDir = createScratchpadDirResolver({
1137
1199
  root: SCRATCHPAD_ROOT,
1138
1200
  resolveWorktree: worktrees.resolve,
1201
+ // Transcripts record the 8.3 short form on Windows; path checks and the watcher need the long one.
1202
+ toLong: realpathDeepest,
1139
1203
  });
1140
1204
 
1141
1205
  function buildSessionObject(id, meta, overrides = {}) {
@@ -1707,25 +1771,9 @@ function addOwnerSessions(tasks, listIdOf) {
1707
1771
 
1708
1772
  app.get('/api/sessions/:sessionId', async (req, res) => {
1709
1773
  try {
1710
- const sessionPath = taskDirFor(req.params.sessionId);
1711
-
1712
1774
  // A session that never used the board has no task dir. That is the common case and
1713
1775
  // 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
- }
1776
+ const tasks = (await readTaskDir(taskDirFor(req.params.sessionId))).map(({ task }) => task);
1729
1777
 
1730
1778
  // Sort by ID (numeric)
1731
1779
  tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
@@ -1739,41 +1787,24 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
1739
1787
  });
1740
1788
 
1741
1789
  // API: Get combined tasks for a project (all sessions + shared task lists)
1742
- app.get('/api/projects/:encodedPath/tasks', (req, res) => {
1790
+ app.get('/api/projects/:encodedPath/tasks', async (req, res) => {
1743
1791
  try {
1744
1792
  const projectPath = Buffer.from(req.params.encodedPath, 'base64').toString('utf8');
1745
1793
  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
- }
1794
+ const taskDirs = new Set(
1795
+ Object.entries(metadata)
1796
+ .filter(([, m]) => m.project === projectPath)
1797
+ .map(([id]) => taskDirFor(id)),
1798
+ );
1763
1799
 
1764
1800
  const tasks = [];
1765
- const seenKeys = new Set();
1766
1801
  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 (_) {}
1802
+ const seenIds = new Set();
1803
+ for (const { task } of await readTaskDir(dir)) {
1804
+ if (seenIds.has(task.id)) continue;
1805
+ seenIds.add(task.id);
1806
+ task._taskDir = path.basename(dir);
1807
+ tasks.push(task);
1777
1808
  }
1778
1809
  }
1779
1810
  tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
@@ -1788,8 +1819,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
1788
1819
  // API: Get session plan
1789
1820
  app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1790
1821
  try {
1791
- const metadata = loadSessionMetadata();
1792
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1822
+ const meta = sessionMetaFor(req.params.sessionId);
1793
1823
  // Most sessions have no saved plan, and the info modal asks for one every time it
1794
1824
  // opens, so "no plan" is a normal answer rather than a 404 in the console.
1795
1825
  const slug = meta?.slug;
@@ -1808,8 +1838,7 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1808
1838
 
1809
1839
  app.get('/api/sessions/:sessionId/loop', (req, res) => {
1810
1840
  try {
1811
- const metadata = loadSessionMetadata();
1812
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1841
+ const meta = sessionMetaFor(req.params.sessionId);
1813
1842
  if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
1814
1843
  const state = refreshLoopInfoState(meta.jsonlPath);
1815
1844
  const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
@@ -1851,8 +1880,7 @@ function getArtifactLinks(jsonlPath) {
1851
1880
 
1852
1881
  app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
1853
1882
  try {
1854
- const metadata = loadSessionMetadata();
1855
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1883
+ const meta = sessionMetaFor(req.params.sessionId);
1856
1884
  if (!meta?.jsonlPath) return res.json({ artifacts: [] });
1857
1885
  res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
1858
1886
  } catch (error) {
@@ -1956,8 +1984,7 @@ function getCreatedPads(meta) {
1956
1984
 
1957
1985
  app.get('/api/sessions/:sessionId/pads', async (req, res) => {
1958
1986
  try {
1959
- const metadata = loadSessionMetadata();
1960
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1987
+ const meta = sessionMetaFor(req.params.sessionId);
1961
1988
  if (!meta?.jsonlPath) return res.json({ pads: [] });
1962
1989
  res.json({ pads: await getCreatedPads(meta) });
1963
1990
  } catch (error) {
@@ -2290,8 +2317,7 @@ app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
2290
2317
  // API: Open session plan in VS Code
2291
2318
  app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
2292
2319
  try {
2293
- const metadata = loadSessionMetadata();
2294
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
2320
+ const meta = sessionMetaFor(req.params.sessionId);
2295
2321
  const slug = meta?.slug;
2296
2322
  if (!slug) return res.status(404).json({ error: 'No plan found' });
2297
2323
 
@@ -3006,8 +3032,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
3006
3032
  async function sendSessionMessages(req, res) {
3007
3033
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
3008
3034
  const before = req.query.before || null;
3009
- const metadata = loadSessionMetadata();
3010
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3035
+ const meta = sessionMetaFor(req.params.sessionId);
3011
3036
  const jsonlPath = meta?.jsonlPath;
3012
3037
  if (!jsonlPath) return res.json({ messages: [], hasMore: false, sessionId: req.params.sessionId });
3013
3038
  let messages, hasMore;
@@ -3097,8 +3122,7 @@ async function sendSessionMessages(req, res) {
3097
3122
  app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
3098
3123
 
3099
3124
  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)];
3125
+ const meta = sessionMetaFor(req.params.sessionId);
3102
3126
  const jsonlPath = meta?.jsonlPath;
3103
3127
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3104
3128
  const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
@@ -3203,8 +3227,7 @@ async function buildToolStats(jsonlPath) {
3203
3227
  }
3204
3228
 
3205
3229
  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)];
3230
+ const meta = sessionMetaFor(req.params.sessionId);
3208
3231
  const jsonlPath = meta?.jsonlPath;
3209
3232
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3210
3233
  try {
@@ -3217,8 +3240,7 @@ app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
3217
3240
  });
3218
3241
 
3219
3242
  async function sendTranscriptImage(req, res, read) {
3220
- const metadata = loadSessionMetadata();
3221
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3243
+ const meta = sessionMetaFor(req.params.sessionId);
3222
3244
  const jsonlPath = meta?.jsonlPath;
3223
3245
  if (!jsonlPath) return res.status(404).end();
3224
3246
  const img = await read(jsonlPath);
@@ -3306,10 +3328,7 @@ const terminal = createTerminalClient({
3306
3328
  isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
3307
3329
  sessionsDir: SESSIONS_DIR,
3308
3330
  token: process.env.CCK_TERMINAL_TOKEN,
3309
- load: () => {
3310
- try { return JSON.parse(readFileSync(TERMINALS_FILE, 'utf8')); } catch { return null; }
3311
- },
3312
- save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
3331
+ ...jsonFile(TERMINALS_FILE),
3313
3332
  // The project, not the last cwd: `claude --resume` finds a session under the
3314
3333
  // project dir it started in, and cwd drifts into subdirectories.
3315
3334
  resolveCwd: resolveSessionFolder,
@@ -3396,30 +3415,17 @@ app.get('/vendor/xterm/:file', (req, res) => {
3396
3415
  // #endregion
3397
3416
 
3398
3417
  // #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
- });
3418
+ const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
3405
3419
 
3406
3420
  const dispatches = createDispatchRegistry();
3407
3421
 
3408
3422
  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),
3423
+ ...jsonFile(DISPATCH_GROUPS_FILE),
3413
3424
  isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
3414
3425
  pinnedIds: () => new Set(Object.keys(readPins())),
3415
3426
  });
3416
3427
 
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
- });
3428
+ const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
3423
3429
 
3424
3430
  // The board places a session from these alone, so it never has to move it later.
3425
3431
  function withDispatchPlacement(sessions) {
@@ -3473,14 +3479,13 @@ app.get('/api/tasks/all', async (_req, res) => {
3473
3479
 
3474
3480
  const metadata = loadSessionMetadata();
3475
3481
  const { listToSessions } = loadAllTaskMaps();
3476
- const sessionDirs = readdirSync(TASKS_DIR, { withFileTypes: true })
3482
+ const sessionDirs = (await fs.readdir(TASKS_DIR, { withFileTypes: true }))
3477
3483
  .filter(d => d.isDirectory());
3484
+ const dirTasks = await Promise.all(sessionDirs.map((d) => readTaskDir(path.join(TASKS_DIR, d.name))));
3478
3485
 
3479
3486
  const allTasks = [];
3480
3487
 
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'));
3488
+ sessionDirs.forEach((sessionDir, i) => {
3484
3489
  const meta = metadata[sessionDir.name] || {};
3485
3490
 
3486
3491
  // For custom task list directories (non-UUID dirs), resolve project from the
@@ -3496,20 +3501,15 @@ app.get('/api/tasks/all', async (_req, res) => {
3496
3501
  }
3497
3502
  }
3498
3503
 
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
- }
3504
+ for (const { task } of dirTasks[i]) {
3505
+ allTasks.push({
3506
+ ...task,
3507
+ sessionId: sessionDir.name,
3508
+ sessionName: getSessionDisplayName(sessionDir.name, meta),
3509
+ project
3510
+ });
3511
3511
  }
3512
- }
3512
+ });
3513
3513
 
3514
3514
  res.json(allTasks);
3515
3515
  } catch (error) {
@@ -3519,6 +3519,7 @@ app.get('/api/tasks/all', async (_req, res) => {
3519
3519
  });
3520
3520
 
3521
3521
  const {
3522
+ boardEventsOn,
3522
3523
  enqueueSessionEvent,
3523
3524
  formatReviewSubmitted,
3524
3525
  formatTaskMoved,
@@ -3557,7 +3558,7 @@ app.post('/api/tasks/:sessionId', async (req, res) => {
3557
3558
 
3558
3559
  // No doorbell here, unlike a move: the user typed this task, so telling their session
3559
3560
  // 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));
3561
+ await writeTaskFile(path.join(sessionDir, `${id}.json`), task);
3561
3562
  res.json({ success: true, task });
3562
3563
  } catch (error) {
3563
3564
  console.error('Error creating task:', error);
@@ -3585,14 +3586,14 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
3585
3586
  if (description !== undefined) task.description = description;
3586
3587
  if (req.body.status !== undefined) task.status = req.body.status;
3587
3588
 
3588
- await fs.writeFile(taskPath, JSON.stringify(task, null, 2));
3589
+ await writeTaskFile(taskPath, task);
3589
3590
 
3590
3591
  // Ring the session only for a move. The direction has to ride in the line because
3591
3592
  // the write above destroyed the old status -- nothing downstream can recover it, and
3592
3593
  // which way a task moved is what decides whether to start work or stop it.
3593
- if (task.status !== prevStatus) {
3594
+ if (task.status !== prevStatus && boardEventsEnabled()) {
3594
3595
  // 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
3596
+ // not a session id -- and the doorbell polls with its own session id, so an unresolved
3596
3597
  // name would queue the line where nobody drains it.
3597
3598
  const line = formatTaskMoved(taskId, prevStatus, task);
3598
3599
  for (const sid of resolveSessionsForTaskDir(sessionId)) enqueueSessionEvent(sid, line);
@@ -3608,21 +3609,7 @@ app.put('/api/tasks/:sessionId/:taskId', async (req, res) => {
3608
3609
  // A deleted task can't block anything, so drop the dangling reference instead of
3609
3610
  // refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
3610
3611
  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
- }
3612
+ const tasks = await readTaskDir(dir);
3626
3613
  const doomed = tasks.filter((t) => shouldDelete(t));
3627
3614
  const doomedIds = new Set(doomed.map(({ task }) => task.id));
3628
3615
  const strip = (ids) => ids?.filter((id) => !doomedIds.has(id));
@@ -3635,7 +3622,7 @@ async function deleteTasks(dir, shouldDelete) {
3635
3622
  task.blockedBy = strip(task.blockedBy);
3636
3623
  task.blocks = strip(task.blocks);
3637
3624
  if (JSON.stringify(task) === before) return null;
3638
- return fs.writeFile(path.join(dir, file), JSON.stringify(task, null, 2));
3625
+ return writeTaskFile(path.join(dir, file), task);
3639
3626
  }),
3640
3627
  );
3641
3628
  // Delete the task file
@@ -3831,6 +3818,7 @@ function resolvePreviewPath(rawPath, base) {
3831
3818
  app.post('/api/preview', async (req, res) => {
3832
3819
  try {
3833
3820
  const { path: filePath, sessionId, base } = req.body || {};
3821
+ if (sessionId && !isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
3834
3822
  const abs = resolvePreviewPath(filePath, base);
3835
3823
  if (!abs) return res.status(400).json({ error: 'path is required' });
3836
3824
  // Validate here so the CLI still gets 400/404, but broadcast the path only —
@@ -3852,7 +3840,8 @@ app.post('/api/preview', async (req, res) => {
3852
3840
  app.post('/api/document/link', async (req, res) => {
3853
3841
  try {
3854
3842
  const { path: filePath, sessionId, unlink, open } = req.body || {};
3855
- if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
3843
+ if (!sessionId) return res.status(400).json({ error: 'sessionId is required' });
3844
+ if (!isSafeId(sessionId)) return res.status(400).json({ error: 'Invalid session ID' });
3856
3845
  const url = linkUrl(filePath);
3857
3846
  const abs = url || resolvePreviewPath(filePath);
3858
3847
  if (!abs) return res.status(400).json({ error: 'path is required' });
@@ -4026,10 +4015,10 @@ const panes = createPaneStore({
4026
4015
  const stamp = panesFileStamp();
4027
4016
  if (stamp === panesStamp) return undefined;
4028
4017
  panesStamp = stamp;
4029
- try { return JSON.parse(readFileSync(PANES_FILE, 'utf8')); } catch { return null; }
4018
+ return readJsonOrNull(PANES_FILE);
4030
4019
  },
4031
4020
  save: (data) => {
4032
- writeJsonAtomic(PANES_FILE, data);
4021
+ writeJsonAtomicOrLog(PANES_FILE, data);
4033
4022
  panesStamp = panesFileStamp();
4034
4023
  },
4035
4024
  onChange: (sessionId, layout) => {
@@ -4103,9 +4092,11 @@ app.get('/api/panes/:sessionId', (req, res) => {
4103
4092
 
4104
4093
  app.post('/api/panes/:sessionId', async (req, res) => {
4105
4094
  try {
4106
- const { target, base, title } = req.body || {};
4095
+ const { target, base, title, kind } = req.body || {};
4107
4096
  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 }));
4097
+ // A message target is a session log message id, which the board resolves when it shows the pane.
4098
+ const resolved = kind === 'message' ? { kind, target } : await resolvePaneTarget(target, base);
4099
+ res.json(panes.add(req.params.sessionId, { ...resolved, title }));
4109
4100
  } catch (error) {
4110
4101
  paneRouteError(res, error, 'POST /api/panes');
4111
4102
  }
@@ -4140,6 +4131,19 @@ app.get('/api/panes/:sessionId/:paneId/framing', async (req, res) => {
4140
4131
  }
4141
4132
  });
4142
4133
 
4134
+ app.patch('/api/panes/:sessionId/:paneId', (req, res) => {
4135
+ try {
4136
+ const { sessionId, paneId } = req.params;
4137
+ const title = req.body?.title;
4138
+ if (typeof title !== 'string') throw previewError(400, 'title must be a string', 'bad_title');
4139
+ const layout = panes.rename(sessionId, paneId, title);
4140
+ if (!layout) throw previewError(404, `No pane ${paneId} in session ${sessionId}`, 'no_pane');
4141
+ res.json({ layout });
4142
+ } catch (error) {
4143
+ paneRouteError(res, error, 'PATCH /api/panes/:paneId');
4144
+ }
4145
+ });
4146
+
4143
4147
  app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
4144
4148
  const { sessionId, paneId } = req.params;
4145
4149
  const layout = panes.remove(sessionId, paneId);
@@ -4223,7 +4227,7 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
4223
4227
  await fs.writeFile(file, markdown);
4224
4228
 
4225
4229
  let delivered = null;
4226
- if (hasSessionListener(sessionId)) {
4230
+ if (boardEventsEnabled() && hasSessionListener(sessionId)) {
4227
4231
  enqueueSessionEvent(sessionId, formatReviewSubmitted(src.items.length, src.label, file));
4228
4232
  delivered = 'doorbell';
4229
4233
  } else if (
@@ -4284,6 +4288,13 @@ app.use('/api', (_req, res) => {
4284
4288
  res.status(404).json({ error: 'Not found' });
4285
4289
  });
4286
4290
 
4291
+ app.use((err, req, res, next) => {
4292
+ if (res.headersSent || !req.path.startsWith('/api/')) return next(err);
4293
+ const status = err.status || 500;
4294
+ if (status >= 500) console.error(`Error in ${req.method} ${req.path}:`, err);
4295
+ res.status(status).json({ error: status >= 500 ? 'Internal error' : err.message });
4296
+ });
4297
+
4287
4298
  // Watch for file changes (chokidar handles non-existent paths)
4288
4299
  const watcher = chokidar.watch(TASKS_DIR, {
4289
4300
  persistent: true,
@@ -4498,8 +4509,10 @@ async function cleanupAgentActivity() {
4498
4509
  const now = Date.now();
4499
4510
  for (const entry of entries) {
4500
4511
  if (!entry.isDirectory()) continue;
4512
+ const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
4513
+ // Map files are rewritten in place, so the dir mtime does not track their use.
4514
+ if (dirPath === TASK_MAPS_DIR) continue;
4501
4515
  try {
4502
- const dirPath = path.join(AGENT_ACTIVITY_DIR, entry.name);
4503
4516
  const contents = await fs.readdir(dirPath);
4504
4517
  const stat = await fs.stat(dirPath);
4505
4518
  const age = now - stat.mtimeMs;
@@ -4533,8 +4546,9 @@ async function runRetention() {
4533
4546
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4534
4547
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4535
4548
  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`);
4549
+ const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4550
+ if (markers || layouts || reviews || contexts || wts || taskMaps) {
4551
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings`);
4538
4552
  }
4539
4553
  } catch (e) {
4540
4554
  console.warn('[retention] failed:', e.message);
@@ -4561,7 +4575,7 @@ const PREWARM_SLICE_MS = 10;
4561
4575
  // first list is sent, or after the fallback when no client asks.
4562
4576
  const PREWARM_FALLBACK_MS = 5000;
4563
4577
  const SESSION_CACHE_SAVE_MS = 30000;
4564
- const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomic(SESSION_CACHE_FILE, data));
4578
+ const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomicOrLog(SESSION_CACHE_FILE, data));
4565
4579
  let prewarmStarted = false;
4566
4580
  function startPrewarm() {
4567
4581
  if (prewarmStarted) return;
@@ -4595,8 +4609,8 @@ async function prewarmCaches() {
4595
4609
  const onReady = (actualPort) => {
4596
4610
  boardPort = Number(actualPort);
4597
4611
  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.
4612
+ // The port is configurable and falls back to a random one when taken, so the doorbell
4613
+ // mod cannot assume it -- publish the live one where it can read it.
4600
4614
  writeServerInfo(actualPort);
4601
4615
  writeTerminalToken(actualPort);
4602
4616
  console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
@@ -4637,7 +4651,5 @@ async function prewarmCaches() {
4637
4651
  throw err;
4638
4652
  }
4639
4653
  });
4640
-
4641
-
4642
- // #endregion
4643
4654
  }
4655
+ // #endregion