claude-code-kanban 4.21.0 → 4.22.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
@@ -1,26 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // #region SETUP
3
4
  const express = require('express');
4
- const path = require('path');
5
- const fs = require('fs').promises;
6
- const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync } = require('fs');
7
- const readline = require('readline');
5
+ const path = require('node:path');
6
+ const fs = require('node:fs').promises;
7
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, mkdirSync, renameSync, openSync, readSync, closeSync } = require('node:fs');
8
+ const _readline = require('node:readline');
8
9
  const chokidar = require('chokidar');
9
- const os = require('os');
10
- const crypto = require('crypto');
11
- const { spawnSync } = require('child_process');
12
- const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
10
+ const os = require('node:os');
11
+ const crypto = require('node:crypto');
12
+ const { spawnSync, spawn } = require('node:child_process');
13
+ const { assertOpenTarget, openInEditor, whichSync, exeBehindShim } = require('./lib/open-editor');
13
14
  const { createNetGuard } = require('./lib/net-guard');
14
15
  const { isContained } = require('./lib/contain');
16
+ const { fileUrlToPath } = require('./lib/file-url');
15
17
 
16
18
  const {
17
19
  readRecentMessages: _readRecentMessagesUncached,
18
20
  readMessagesPage: _readMessagesPageUncached,
19
21
  readSessionInfoFromJsonl,
20
- buildAgentProgressMap,
21
22
  buildSessionDigest,
22
23
  readCompactSummaries,
23
- findTerminatedTeammates,
24
24
  extractPromptFromTranscript,
25
25
  extractModelFromTranscript,
26
26
  extractStructuredResultFromTranscript,
@@ -99,6 +99,9 @@ const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
99
99
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
100
100
  const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
101
101
 
102
+ // #endregion
103
+
104
+ // #region SERVER_STATE
102
105
  // Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
103
106
  function readPins() {
104
107
  try {
@@ -129,6 +132,8 @@ function writeServerInfo(port) {
129
132
  writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
130
133
  }
131
134
 
135
+ // #endregion
136
+
132
137
  // #region TIMINGS
133
138
  const PERMISSION_TTL_MS = 30 * 60 * 1000;
134
139
  const AGENT_TTL_MS = 60 * 60 * 1000;
@@ -144,6 +149,8 @@ const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
144
149
  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
145
150
  // #endregion
146
151
 
152
+ // #region AGENT_ACTIVITY
153
+
147
154
  function readAgentJsonl(filePath) {
148
155
  const raw = readFileSync(filePath, 'utf8');
149
156
  const merged = {};
@@ -165,8 +172,8 @@ function listAgentFiles(agentDir) {
165
172
  }
166
173
 
167
174
  function persistAgent(dir, agent) {
168
- const file = path.join(dir, agent.agentId + '.jsonl');
169
- fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
175
+ const file = path.join(dir, `${agent.agentId}.jsonl`);
176
+ fs.appendFile(file, `${JSON.stringify({ ...agent, event: 'server-update' })}\n`, 'utf8').catch(() => {});
170
177
  }
171
178
 
172
179
  // Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
@@ -179,7 +186,7 @@ function approvalsWaitMs() {
179
186
  let cfg = null;
180
187
  try {
181
188
  cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
182
- } catch (e) { /* missing config — gate default */ }
189
+ } catch { /* missing config — gate default */ }
183
190
  approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
184
191
  return approvalsWaitCache.ms;
185
192
  }
@@ -200,7 +207,7 @@ function checkWaitingForUser(agentDir, logMtime) {
200
207
  if (isWaitingLapsed(data)) return { ...data, lapsed: true };
201
208
  return data;
202
209
  }
203
- } catch (e) { /* skip — missing or invalid */ }
210
+ } catch { /* skip — missing or invalid */ }
204
211
  return null;
205
212
  }
206
213
 
@@ -274,7 +281,7 @@ function getSessionLogStat(meta) {
274
281
  try {
275
282
  const st = statSync(meta.jsonlPath);
276
283
  return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
277
- } catch (e) { return { mtime: null, hasMessages: false }; }
284
+ } catch { return { mtime: null, hasMessages: false }; }
278
285
  }
279
286
 
280
287
  function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
@@ -294,12 +301,15 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
294
301
  result.hasRunning = true;
295
302
  }
296
303
  if (result.hasRunning) break;
297
- } catch (e) { /* skip invalid */ }
304
+ } catch { /* skip invalid */ }
298
305
  }
299
- } catch (e) { /* ignore */ }
306
+ } catch { /* ignore */ }
300
307
  return result;
301
308
  }
302
309
 
310
+ // #endregion
311
+
312
+ // #region TEAMS
303
313
  // isContained canonicalises both sides with realpath, which costs ~100x a plain
304
314
  // path.join. This is called once per session on the /api/sessions hot path, and
305
315
  // the verdict for a given name cannot change while TEAMS_DIR is fixed — so the
@@ -336,14 +346,14 @@ function loadTeamConfig(teamName) {
336
346
  const data = JSON.parse(readFileSync(configPath, 'utf8'));
337
347
  teamConfigCache.set(teamName, { data, ts: Date.now() });
338
348
  return data;
339
- } catch (e) {
349
+ } catch {
340
350
  return null;
341
351
  }
342
352
  }
343
353
 
344
354
  function resolveSessionId(sessionId) {
345
355
  const teamConfig = loadTeamConfig(sessionId);
346
- return (teamConfig && teamConfig.leadSessionId) ? teamConfig.leadSessionId : sessionId;
356
+ return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
347
357
  }
348
358
 
349
359
  // Recent Claude Code releases auto-create a single-member "self-team" for every
@@ -457,6 +467,9 @@ function attachTeamTasks(card, teamTaskDir, teamName, counts) {
457
467
  }
458
468
  }
459
469
 
470
+ // #endregion
471
+
472
+ // #region SHARED_STATE
460
473
  // SSE clients for live updates
461
474
  const clients = new Set();
462
475
 
@@ -470,39 +483,48 @@ const METADATA_CACHE_TTL = 10000; // 10 seconds
470
483
  const dirtyMetadataPaths = new Set();
471
484
  let metadataNeedsFullScan = true;
472
485
 
486
+ // #endregion
487
+
488
+ // #region REQUEST_GUARDS
473
489
  const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
474
490
  function isSafeId(id) {
475
491
  return typeof id === 'string' && id.length > 0 && id.length <= 128 && SAFE_ID_RE.test(id);
476
492
  }
477
493
 
478
- app.param('sessionId', (req, res, next, val) => {
494
+ app.param('sessionId', (_req, res, next, val) => {
479
495
  if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid session ID' });
480
496
  next();
481
497
  });
482
- app.param('taskId', (req, res, next, val) => {
498
+ app.param('taskId', (_req, res, next, val) => {
483
499
  if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid task ID' });
484
500
  next();
485
501
  });
486
502
  // Team names are directory names under TEAMS_DIR, so /api/teams/:name was a
487
503
  // straight traversal before this.
488
- app.param('name', (req, res, next, val) => {
504
+ app.param('name', (_req, res, next, val) => {
489
505
  if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid team name' });
490
506
  next();
491
507
  });
492
508
 
493
509
  // Parse JSON bodies
494
510
  app.use(express.json());
511
+ // #endregion
512
+
513
+ // #region STATIC
495
514
 
496
515
  app.get('/hub-config', (_req, res) => {
497
516
  res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
498
517
  });
499
518
 
500
519
  // Serve static files
501
- app.get('/sw.js', (req, res) => {
520
+ app.get('/sw.js', (_req, res) => {
502
521
  res.setHeader('Cache-Control', 'no-cache');
503
522
  res.sendFile(path.join(__dirname, 'public', 'sw.js'));
504
523
  });
505
524
  app.use(express.static(path.join(__dirname, 'public')));
525
+ // #endregion
526
+
527
+ // #region SESSION_LIST
506
528
 
507
529
  const messageCache = new Map();
508
530
  const MESSAGE_CACHE_TTL = 5000;
@@ -545,9 +567,9 @@ function loadAllTaskMaps() {
545
567
  for (const sessionId of Object.keys(map)) {
546
568
  sessionToList[sessionId] = taskListName;
547
569
  }
548
- } catch (e) { /* skip invalid */ }
570
+ } catch { /* skip invalid */ }
549
571
  }
550
- } catch (e) { /* ignore */ }
572
+ } catch { /* ignore */ }
551
573
  sessionToTaskListCache = { sessionToList, listToSessions };
552
574
  lastTaskMapScan = now;
553
575
  return sessionToTaskListCache;
@@ -617,7 +639,7 @@ function getTaskCounts(sessionPath) {
617
639
  try {
618
640
  const taskPath = path.join(sessionPath, file);
619
641
  const task = JSON.parse(readFileSync(taskPath, 'utf8'));
620
- if (task.metadata && task.metadata._internal) continue;
642
+ if (task.metadata?._internal) continue;
621
643
  if (task.status === 'completed') completed++;
622
644
  else if (task.status === 'in_progress') inProgress++;
623
645
  else pending++;
@@ -625,7 +647,7 @@ function getTaskCounts(sessionPath) {
625
647
  if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
626
648
  newestTaskMtime = taskStat.mtime;
627
649
  }
628
- } catch (e) { /* skip invalid */ }
650
+ } catch { /* skip invalid */ }
629
651
  }
630
652
 
631
653
  const taskCount = completed + inProgress + pending;
@@ -764,7 +786,7 @@ function loadSessionMetadata() {
764
786
  for (const entry of indexEntries) {
765
787
  if (entry.projectPath) { indexProjectPath = entry.projectPath; break; }
766
788
  }
767
- } catch (e) {}
789
+ } catch {}
768
790
  }
769
791
 
770
792
  // First pass: read all JSONL files
@@ -784,7 +806,7 @@ function loadSessionMetadata() {
784
806
  // JSONLs that only hold custom-title/agent-name records when a session
785
807
  // is continued from a worktree). Don't let a weaker entry (no cwd, no
786
808
  // project) overwrite a previously resolved one — just merge scalars.
787
- if (existing && existing.project && !candidateProject) {
809
+ if (existing?.project && !candidateProject) {
788
810
  if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
789
811
  if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
790
812
  if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
@@ -926,7 +948,7 @@ function getWorkflowIndex() {
926
948
  function getWorkflowScripts(sessionId) {
927
949
  const idx = getWorkflowIndex();
928
950
  let list = idx.get(sessionId);
929
- if (!list || !list.length) {
951
+ if (!list?.length) {
930
952
  const alt = resolveSessionId(sessionId);
931
953
  if (alt && alt !== sessionId) list = idx.get(alt);
932
954
  }
@@ -935,7 +957,7 @@ function getWorkflowScripts(sessionId) {
935
957
 
936
958
  function getWorkflowInfoSummary(sessionId) {
937
959
  const list = getWorkflowIndex().get(sessionId);
938
- return { hasWorkflow: !!(list && list.length), workflowCount: list ? list.length : 0 };
960
+ return { hasWorkflow: !!(list?.length), workflowCount: list ? list.length : 0 };
939
961
  }
940
962
 
941
963
  function getPlanInfo(slug) {
@@ -946,7 +968,7 @@ function getPlanInfo(slug) {
946
968
  const head = readFileSync(planPath, 'utf8').slice(0, 512);
947
969
  const match = head.match(/^#\s+(.+)$/m);
948
970
  return { hasPlan: true, planTitle: match ? match[1].trim() : null, planPath };
949
- } catch (e) {
971
+ } catch {
950
972
  return { hasPlan: true, planTitle: null, planPath };
951
973
  }
952
974
  }
@@ -956,7 +978,7 @@ function getPlanInfo(slug) {
956
978
  const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
957
979
 
958
980
  function isWakeupActive(w, now = Date.now()) {
959
- if (!w || !w.timestamp || w.delaySeconds == null) return true;
981
+ if (!w?.timestamp || w.delaySeconds == null) return true;
960
982
  const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
961
983
  return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
962
984
  }
@@ -996,7 +1018,7 @@ function getLoopInfoSummary(meta) {
996
1018
  } catch (_) { return empty; }
997
1019
  }
998
1020
 
999
- function getSessionDisplayName(sessionId, meta) {
1021
+ function getSessionDisplayName(_sessionId, meta) {
1000
1022
  if (meta?.customTitle) return meta.customTitle;
1001
1023
  if (meta?.slug) return meta.slug;
1002
1024
  return null;
@@ -1052,6 +1074,9 @@ function buildSessionObject(id, meta, overrides = {}) {
1052
1074
  };
1053
1075
  }
1054
1076
 
1077
+ // #endregion
1078
+
1079
+ // #region SESSION_ROUTES
1055
1080
  // API: List all sessions
1056
1081
  app.get('/api/sessions', async (req, res) => {
1057
1082
  // Prevent browser caching
@@ -1235,7 +1260,7 @@ app.get('/api/sessions', async (req, res) => {
1235
1260
  hasWaitingForUser: true,
1236
1261
  }));
1237
1262
  }
1238
- } catch (e) { /* ignore */ }
1263
+ } catch { /* ignore */ }
1239
1264
  }
1240
1265
 
1241
1266
  // Enrich leader sessions with team info and remove team-named duplicates
@@ -1320,13 +1345,13 @@ app.get('/api/sessions', async (req, res) => {
1320
1345
 
1321
1346
  // Correlate plan sessions with their implementation sessions (same slug)
1322
1347
  const slugGroups = new Map();
1323
- for (const [sid, session] of sessionsMap) {
1348
+ for (const [_sid, session] of sessionsMap) {
1324
1349
  if (session.slug) {
1325
1350
  if (!slugGroups.has(session.slug)) slugGroups.set(session.slug, []);
1326
1351
  slugGroups.get(session.slug).push(session);
1327
1352
  }
1328
1353
  }
1329
- for (const [slug, group] of slugGroups) {
1354
+ for (const [_slug, group] of slugGroups) {
1330
1355
  if (group.length < 2) continue;
1331
1356
  group.sort((a, b) => new Date(a.modifiedAt) - new Date(b.modifiedAt));
1332
1357
  const planSession = group.find(s => s.hasPlan);
@@ -1428,7 +1453,7 @@ app.get('/api/sessions', async (req, res) => {
1428
1453
  });
1429
1454
 
1430
1455
  // API: Get distinct project paths with last-modified timestamps
1431
- app.get('/api/projects', (req, res) => {
1456
+ app.get('/api/projects', (_req, res) => {
1432
1457
  res.setHeader('Cache-Control', 'no-store');
1433
1458
  const metadata = loadSessionMetadata();
1434
1459
  const projectMap = {};
@@ -1467,7 +1492,7 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
1467
1492
  }
1468
1493
 
1469
1494
  // Sort by ID (numeric)
1470
- tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
1495
+ tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
1471
1496
 
1472
1497
  res.json(tasks);
1473
1498
  } catch (error) {
@@ -1514,7 +1539,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
1514
1539
  } catch (_) {}
1515
1540
  }
1516
1541
  }
1517
- tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
1542
+ tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
1518
1543
  res.json(tasks);
1519
1544
  } catch (error) {
1520
1545
  console.error('Error getting project tasks:', error);
@@ -1596,6 +1621,9 @@ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
1596
1621
  }
1597
1622
  });
1598
1623
 
1624
+ // #endregion
1625
+
1626
+ // #region WORKFLOWS
1599
1627
  // meta.phases and meta.name/description are pure literals per the Workflow tool
1600
1628
  // contract, so pull them by regex rather than executing the untrusted script.
1601
1629
  // name/description take the first match (the meta block is at the top of the file).
@@ -1609,9 +1637,7 @@ function parseWorkflowMeta(source) {
1609
1637
  const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
1610
1638
  const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
1611
1639
  if (phasesM) {
1612
- const re = /\{[\s\S]*?\}/g;
1613
- let m;
1614
- while ((m = re.exec(phasesM[1]))) {
1640
+ for (const m of phasesM[1].matchAll(/\{[\s\S]*?\}/g)) {
1615
1641
  const title = matchStr(m[0], 'title');
1616
1642
  if (title) meta.phases.push({ title, detail: matchStr(m[0], 'detail') });
1617
1643
  }
@@ -1781,6 +1807,9 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
1781
1807
  }
1782
1808
  });
1783
1809
 
1810
+ // #endregion
1811
+
1812
+ // #region OPEN_TARGETS
1784
1813
  // API: Open folder (and optionally a file within it) in editor
1785
1814
  app.post('/api/open-folder', (req, res) => {
1786
1815
  try {
@@ -1810,7 +1839,7 @@ app.post('/api/open-in-editor', (req, res) => {
1810
1839
  const safeName = (title || 'message').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
1811
1840
  const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
1812
1841
  const tmpFile = path.join(os.tmpdir(), `claude-kanban-${safeName}-${hash}.md`);
1813
- require('fs').writeFileSync(tmpFile, content, 'utf8');
1842
+ require('node:fs').writeFileSync(tmpFile, content, 'utf8');
1814
1843
 
1815
1844
  openInEditor([tmpFile]);
1816
1845
  res.json({ success: true, path: tmpFile });
@@ -1821,6 +1850,60 @@ app.post('/api/open-in-editor', (req, res) => {
1821
1850
  }
1822
1851
  });
1823
1852
 
1853
+ // A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
1854
+ // external `scratch` CLI. cck only recognizes the shape and launches the viewer —
1855
+ // it never parses the manifest, so the two stay independently versioned.
1856
+ const SCRATCHPAD_MANIFEST = 'scratchpad.json';
1857
+
1858
+ // Resolve a linked path to the pad the viewer wants: `<parent>/<name>` where
1859
+ // `<name>/scratchpad.json` exists. Accepts either the manifest or its directory.
1860
+ function resolveScratchpad(value) {
1861
+ const target = assertOpenTarget(value, 'path');
1862
+ const dir = path.basename(target).toLowerCase() === SCRATCHPAD_MANIFEST ? path.dirname(target) : target;
1863
+ if (!existsSync(path.join(dir, SCRATCHPAD_MANIFEST))) throw previewError(400, 'Not a scratchpad');
1864
+ return { name: path.basename(dir), parent: path.dirname(dir) };
1865
+ }
1866
+
1867
+ // API: Open a scratchpad in the external `scratch` viewer — the window outlives
1868
+ // this request and cck never waits on it.
1869
+ app.post('/api/scratchpad/open', (req, res) => {
1870
+ try {
1871
+ const bin = whichSync('scratch');
1872
+ if (!bin) return res.status(501).json({ error: 'scratch CLI not found on PATH' });
1873
+ const { name, parent } = resolveScratchpad(req.body?.path);
1874
+ // .cmd/.bat cannot be spawned without a shell since the CVE-2024-27980 fix;
1875
+ // scratch ships a real .exe, so resolve it rather than reintroducing one.
1876
+ const ext = path.extname(bin).toLowerCase();
1877
+ const file = ext === '.cmd' || ext === '.bat' ? exeBehindShim(bin) : bin;
1878
+ if (!file) return res.status(501).json({ error: 'scratch CLI cannot be launched without a shell' });
1879
+ // `scratch ui` is a foreground supervisor: it reloads the window on edits and
1880
+ // quits on stdin EOF. So stdin must be an open pipe we never write to —
1881
+ // 'ignore' is /dev/null, which reads as EOF and closes the viewer at once.
1882
+ //
1883
+ // And no `detached`: on Windows that means DETACHED_PROCESS, which overrides
1884
+ // the CREATE_NO_WINDOW `windowsHide` asks for, so the bun shim allocates its
1885
+ // own console and a blank terminal window pops up next to the viewer.
1886
+ const child = spawn(file, ['ui', name, '--dir', parent], {
1887
+ stdio: ['pipe', 'ignore', 'ignore'],
1888
+ windowsHide: true,
1889
+ });
1890
+ child.on('error', (e) => console.error('scratch ui failed to start:', e.message));
1891
+ // Nothing is ever written to it, but an unhandled EPIPE when the user closes
1892
+ // the viewer would take the server down with it.
1893
+ child.stdin.on('error', () => {});
1894
+ child.stdin.unref();
1895
+ child.unref();
1896
+ res.json({ success: true, pad: name });
1897
+ } catch (error) {
1898
+ if (error.status) return res.status(error.status).json({ error: error.message });
1899
+ console.error('Error opening scratchpad:', error);
1900
+ res.status(500).json({ error: 'Failed to open scratchpad' });
1901
+ }
1902
+ });
1903
+
1904
+ // #endregion
1905
+
1906
+ // #region AGENT_ROUTES
1824
1907
  // API: Get team config
1825
1908
  app.get('/api/teams/:name', (req, res) => {
1826
1909
  const config = loadTeamConfig(req.params.name);
@@ -1872,7 +1955,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1872
1955
  }
1873
1956
  }
1874
1957
  agents.push(agent);
1875
- } catch (e) { /* skip invalid */ }
1958
+ } catch { /* skip invalid */ }
1876
1959
  }
1877
1960
  const liveAgents = agents.filter(isAgentLive);
1878
1961
  if (liveAgents.length && meta.jsonlPath) {
@@ -2029,7 +2112,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2029
2112
  // Subagents (Explore, general-purpose, etc.) are not in teamMemberNames and bypass
2030
2113
  // dedup entirely, so parallel siblings of the same subagent type remain visible.
2031
2114
  let visibleAgents = agents;
2032
- if (teamMemberNames && teamMemberNames.size) {
2115
+ if (teamMemberNames?.size) {
2033
2116
  const groups = new Map();
2034
2117
  for (const a of agents) {
2035
2118
  const t = agentDisplayName(a);
@@ -2051,7 +2134,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2051
2134
 
2052
2135
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
2053
2136
  res.json({ agents: visibleAgents, waitingForUser, teamColors });
2054
- } catch (e) {
2137
+ } catch {
2055
2138
  res.json({ agents: [], waitingForUser: null });
2056
2139
  }
2057
2140
  });
@@ -2065,7 +2148,7 @@ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
2065
2148
  try {
2066
2149
  clearWaitingFile(resolveSessionId(req.params.sessionId));
2067
2150
  res.json({ ok: true });
2068
- } catch (e) {
2151
+ } catch {
2069
2152
  res.status(500).json({ error: 'Failed to discard waiting' });
2070
2153
  }
2071
2154
  });
@@ -2079,7 +2162,7 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2079
2162
  const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
2080
2163
  let marker = null;
2081
2164
  try { marker = JSON.parse(readFileSync(path.join(dir, '_waiting.json'), 'utf8')); }
2082
- catch (e) { /* missing or invalid → buildDecision reports 410 */ }
2165
+ catch { /* missing or invalid → buildDecision reports 410 */ }
2083
2166
  // The gate stopped polling after waitSeconds — an orphaned decision file would
2084
2167
  // sit unconsumed while the card pretends the click worked.
2085
2168
  if (marker && isWaitingLapsed(marker)) {
@@ -2090,7 +2173,7 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2090
2173
  try {
2091
2174
  writeJsonAtomic(path.join(dir, decisionFileName(marker.id)), result.decision);
2092
2175
  res.json({ ok: true });
2093
- } catch (e) {
2176
+ } catch {
2094
2177
  res.status(500).json({ error: 'Failed to write decision' });
2095
2178
  }
2096
2179
  });
@@ -2098,21 +2181,24 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2098
2181
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
2099
2182
  const sessionId = resolveSessionId(req.params.sessionId);
2100
2183
  const agentId = sanitizeAgentId(req.params.agentId);
2101
- const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.jsonl');
2184
+ const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, `${agentId}.jsonl`);
2102
2185
  if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
2103
2186
  try {
2104
2187
  const agent = readAgentJsonl(agentFile);
2105
2188
  agent.status = 'stopped';
2106
2189
  agent.stoppedAt = new Date().toISOString();
2107
2190
  const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
2108
- writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt) + '\n', 'utf8'); // sync — response depends on write
2191
+ writeFileSync(agentFile, `${readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt)}\n`, 'utf8'); // sync — response depends on write
2109
2192
  clearWaitingFile(sessionId);
2110
2193
  res.json({ ok: true });
2111
- } catch (e) {
2194
+ } catch {
2112
2195
  res.status(500).json({ error: 'Failed to stop agent' });
2113
2196
  }
2114
2197
  });
2115
2198
 
2199
+ // #endregion
2200
+
2201
+ // #region SUBAGENT_RESOLUTION
2116
2202
  function sanitizeAgentId(raw) {
2117
2203
  return path.basename(raw).replace(/[^a-zA-Z0-9_-]/g, '');
2118
2204
  }
@@ -2122,7 +2208,7 @@ function sessionDirFromMeta(meta) {
2122
2208
  }
2123
2209
 
2124
2210
  function subagentJsonlPath(meta, agentId) {
2125
- return path.join(sessionDirFromMeta(meta), 'subagents', 'agent-' + agentId + '.jsonl');
2211
+ return path.join(sessionDirFromMeta(meta), 'subagents', `agent-${agentId}.jsonl`);
2126
2212
  }
2127
2213
 
2128
2214
  // A subagent transcript is normally at <sessionDir>/subagents/agent-<id>.jsonl.
@@ -2130,14 +2216,14 @@ function subagentJsonlPath(meta, agentId) {
2130
2216
  // <sessionDir>/subagents/workflows/<wf_id>/agent-<id>.jsonl. Return whichever
2131
2217
  // exists (flat first), else null.
2132
2218
  function subagentJsonlInDir(sessionDir, agentId) {
2133
- const flat = path.join(sessionDir, 'subagents', 'agent-' + agentId + '.jsonl');
2219
+ const flat = path.join(sessionDir, 'subagents', `agent-${agentId}.jsonl`);
2134
2220
  if (existsSync(flat)) return flat;
2135
2221
  const wfRoot = path.join(sessionDir, 'subagents', 'workflows');
2136
2222
  let wfDirs;
2137
2223
  try { wfDirs = readdirSync(wfRoot, { withFileTypes: true }); } catch { return null; }
2138
2224
  for (const wf of wfDirs) {
2139
2225
  if (!wf.isDirectory()) continue;
2140
- const nested = path.join(wfRoot, wf.name, 'agent-' + agentId + '.jsonl');
2226
+ const nested = path.join(wfRoot, wf.name, `agent-${agentId}.jsonl`);
2141
2227
  if (existsSync(nested)) return nested;
2142
2228
  }
2143
2229
  return null;
@@ -2172,7 +2258,7 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
2172
2258
  // Same session dir: flat path or nested under subagents/workflows/ (both checked here).
2173
2259
  const local = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
2174
2260
  if (local) return local;
2175
- const key = sessionId + '/' + agentId;
2261
+ const key = `${sessionId}/${agentId}`;
2176
2262
  const cached = subagentPathCache.get(key);
2177
2263
  if (cached) return cached;
2178
2264
  let found = null;
@@ -2262,7 +2348,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2262
2348
  try { text = readFileSync(fp, 'utf8'); } catch { continue; }
2263
2349
  if (!text.includes(targetUuid)) continue;
2264
2350
  for (const l of text.split('\n')) {
2265
- if (!l || !l.includes(targetUuid)) continue;
2351
+ if (!l?.includes(targetUuid)) continue;
2266
2352
  try {
2267
2353
  const d = JSON.parse(l);
2268
2354
  if (d.uuid === targetUuid && d.sessionId) {
@@ -2314,6 +2400,9 @@ function lookupParentSession(sessionId) {
2314
2400
  parentSessionCache.set(sessionId, result);
2315
2401
  return result;
2316
2402
  }
2403
+ // #endregion
2404
+
2405
+ // #region MESSAGE_ROUTES
2317
2406
  app.get('/api/sessions/:sessionId/parent', (req, res) => {
2318
2407
  res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
2319
2408
  });
@@ -2413,7 +2502,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2413
2502
  if (entry.usage) msg.agentUsageStats = entry.usage;
2414
2503
  if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
2415
2504
  try {
2416
- const agentFile = path.join(agentDir, entry.agentId + '.jsonl');
2505
+ const agentFile = path.join(agentDir, `${entry.agentId}.jsonl`);
2417
2506
  const agent = readAgentJsonl(agentFile);
2418
2507
  if (agent.lastMessage) msg.agentLastMessage = agent.lastMessage;
2419
2508
  if (agent.prompt && !msg.agentPrompt) msg.agentPrompt = agent.prompt;
@@ -2436,7 +2525,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2436
2525
  if (unresolved.length && existsSync(agentDir)) {
2437
2526
  const activity = listAgentFiles(agentDir)
2438
2527
  .map((f) => { try { return readAgentJsonl(path.join(agentDir, f)); } catch (_) { return null; } })
2439
- .filter((a) => a && a.agentId && a.prompt);
2528
+ .filter((a) => a?.agentId && a.prompt);
2440
2529
  const usedIds = new Set(agentMessages.map(m => m.agentId).filter(Boolean));
2441
2530
  for (const msg of unresolved) {
2442
2531
  const key = msg.agentPrompt.slice(0, 200);
@@ -2636,17 +2725,28 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
2636
2725
  res.end(img.buffer);
2637
2726
  });
2638
2727
 
2639
- app.get('/api/version', (req, res) => {
2728
+ // #endregion
2729
+
2730
+ // #region META_ROUTES
2731
+ app.get('/api/version', (_req, res) => {
2640
2732
  const pkg = require('./package.json');
2641
2733
  res.json({ version: pkg.version });
2642
2734
  });
2643
2735
 
2644
- app.get('/api/config', (req, res) => {
2645
- res.json({ marketplaceUrl: MARKETPLACE_URL, costUrl: COST_URL, memoryUrl: MEMORY_URL });
2736
+ app.get('/api/config', (_req, res) => {
2737
+ res.json({
2738
+ marketplaceUrl: MARKETPLACE_URL,
2739
+ costUrl: COST_URL,
2740
+ memoryUrl: MEMORY_URL,
2741
+ scratchAvailable: !!whichSync('scratch'),
2742
+ });
2646
2743
  });
2647
2744
 
2745
+ // #endregion
2746
+
2747
+ // #region TASK_ROUTES
2648
2748
  // API: Get all tasks across all sessions
2649
- app.get('/api/tasks/all', async (req, res) => {
2749
+ app.get('/api/tasks/all', async (_req, res) => {
2650
2750
  try {
2651
2751
  if (!existsSync(TASKS_DIR)) {
2652
2752
  return res.json([]);
@@ -2686,7 +2786,7 @@ app.get('/api/tasks/all', async (req, res) => {
2686
2786
  sessionName: getSessionDisplayName(sessionDir.name, meta),
2687
2787
  project
2688
2788
  });
2689
- } catch (e) {
2789
+ } catch {
2690
2790
  // Skip invalid files
2691
2791
  }
2692
2792
  }
@@ -2791,16 +2891,16 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2791
2891
  return res.status(404).json({ error: 'Task not found' });
2792
2892
  }
2793
2893
 
2794
- // Check if this task blocks other tasks
2894
+ // A deleted task can't block anything, so drop the dangling reference instead of
2895
+ // refusing the delete -- a stale blockedBy id would pin the other task as BLOCKED forever.
2795
2896
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
2796
2897
 
2797
2898
  for (const file of taskFiles) {
2798
- const otherTask = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
2799
- if (otherTask.blockedBy && otherTask.blockedBy.includes(taskId)) {
2800
- return res.status(400).json({
2801
- error: 'Cannot delete task that blocks other tasks',
2802
- blockedTasks: [otherTask.id]
2803
- });
2899
+ const otherPath = path.join(sessionPath, file);
2900
+ const otherTask = JSON.parse(readFileSync(otherPath, 'utf8'));
2901
+ if (otherTask.blockedBy?.includes(taskId)) {
2902
+ otherTask.blockedBy = otherTask.blockedBy.filter(id => id !== taskId);
2903
+ await fs.writeFile(otherPath, JSON.stringify(otherTask, null, 2));
2804
2904
  }
2805
2905
  }
2806
2906
 
@@ -2814,6 +2914,9 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2814
2914
  }
2815
2915
  });
2816
2916
 
2917
+ // #endregion
2918
+
2919
+ // #region PREVIEW
2817
2920
  // API: File preview — read file and broadcast to clients
2818
2921
  const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
2819
2922
  // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
@@ -2884,8 +2987,11 @@ function dropUrlFragment(abs) {
2884
2987
  return existsSync(trimmed) ? trimmed : abs;
2885
2988
  }
2886
2989
 
2887
- function resolvePreviewPath(filePath, base) {
2888
- if (!filePath || typeof filePath !== 'string') return null;
2990
+ function resolvePreviewPath(rawPath, base) {
2991
+ if (!rawPath || typeof rawPath !== 'string') return null;
2992
+ // Every path entering the preview/link surface passes through here, so accepting
2993
+ // `file://` once covers /api/preview, /api/document/link and /api/file/resolve.
2994
+ const filePath = fileUrlToPath(rawPath);
2889
2995
  if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
2890
2996
  if (base && typeof base === 'string' && path.isAbsolute(base)) {
2891
2997
  let baseDir = base;
@@ -2990,7 +3096,7 @@ app.post('/api/session/pin', async (req, res) => {
2990
3096
  }
2991
3097
  });
2992
3098
 
2993
- app.get('/api/session/pins', (req, res) => {
3099
+ app.get('/api/session/pins', (_req, res) => {
2994
3100
  res.setHeader('Cache-Control', 'no-store');
2995
3101
  try {
2996
3102
  const pins = readPins();
@@ -3029,6 +3135,9 @@ app.get('/api/file/resolve', async (req, res) => {
3029
3135
  }
3030
3136
  });
3031
3137
 
3138
+ // #endregion
3139
+
3140
+ // #region SSE
3032
3141
  // SSE endpoint for live updates
3033
3142
  app.get('/api/events', (req, res) => {
3034
3143
  res.setHeader('Content-Type', 'text/event-stream');
@@ -3058,11 +3167,11 @@ function broadcast(data) {
3058
3167
  }
3059
3168
  }
3060
3169
 
3061
- app.get('/api/context-status', (req, res) => {
3170
+ app.get('/api/context-status', (_req, res) => {
3062
3171
  res.json(Object.fromEntries(contextStatusCache));
3063
3172
  });
3064
3173
 
3065
- app.use('/api', (req, res) => {
3174
+ app.use('/api', (_req, res) => {
3066
3175
  res.status(404).json({ error: 'Not found' });
3067
3176
  });
3068
3177
 
@@ -3088,6 +3197,9 @@ watcher.on('all', (event, filePath) => {
3088
3197
  // shared list or a team dir is one directory several sessions map onto -- so anything
3089
3198
  // addressing a session from a task path has to fan out the same way. One resolver for
3090
3199
  // both readers of that mapping: the SSE broadcast and the doorbell.
3200
+ // #endregion
3201
+
3202
+ // #region DOORBELL
3091
3203
  function resolveSessionsForTaskDir(name) {
3092
3204
  if (isUUID(name)) return [name];
3093
3205
  const { listToSessions } = loadAllTaskMaps();
@@ -3218,7 +3330,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
3218
3330
  fs.unlink(file).catch(() => {});
3219
3331
  }
3220
3332
  }
3221
- } catch (e) { /* ignore */ }
3333
+ } catch { /* ignore */ }
3222
3334
  }
3223
3335
  broadcast({ type: 'agent-update', sessionId });
3224
3336
  // For team sessions, also broadcast with team name so frontend picks it up
@@ -3232,7 +3344,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
3232
3344
  break;
3233
3345
  }
3234
3346
  }
3235
- } catch (e) { /* ignore */ }
3347
+ } catch { /* ignore */ }
3236
3348
  }
3237
3349
  }
3238
3350
  });
@@ -3253,7 +3365,7 @@ contextStatusWatcher.on('all', (event, filePath) => {
3253
3365
  try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
3254
3366
  contextStatusCache.set(sessionId, data);
3255
3367
  evictStaleCache(contextStatusCache);
3256
- } catch (e) { /* ignore malformed */ }
3368
+ } catch { /* ignore malformed */ }
3257
3369
  broadcast({ type: 'context-update', sessionId });
3258
3370
  } else if (event === 'unlink') {
3259
3371
  contextStatusCache.delete(sessionId);
@@ -3261,6 +3373,9 @@ contextStatusWatcher.on('all', (event, filePath) => {
3261
3373
  }
3262
3374
  });
3263
3375
 
3376
+ // #endregion
3377
+
3378
+ // #region CLEANUP
3264
3379
  async function cleanupContextStatus() {
3265
3380
  try {
3266
3381
  const entries = await fs.readdir(CONTEXT_STATUS_DIR);
@@ -3274,9 +3389,9 @@ async function cleanupContextStatus() {
3274
3389
  await fs.unlink(fp);
3275
3390
  contextStatusCache.delete(path.basename(f, '.json'));
3276
3391
  }
3277
- } catch (e) { /* ignore */ }
3392
+ } catch { /* ignore */ }
3278
3393
  }
3279
- } catch (e) { /* dir may not exist */ }
3394
+ } catch { /* dir may not exist */ }
3280
3395
  }
3281
3396
 
3282
3397
  async function cleanupAgentActivity() {
@@ -3303,11 +3418,11 @@ async function cleanupAgentActivity() {
3303
3418
  try {
3304
3419
  const fst = await fs.stat(fp);
3305
3420
  if (now - fst.mtimeMs > PERMISSION_TTL_MS) await fs.rm(fp, { force: true });
3306
- } catch (e) { /* ignore per-file errors */ }
3421
+ } catch { /* ignore per-file errors */ }
3307
3422
  }
3308
- } catch (e) { /* ignore per-folder errors */ }
3423
+ } catch { /* ignore per-folder errors */ }
3309
3424
  }
3310
- } catch (e) { /* agent-activity dir may not exist */ }
3425
+ } catch { /* agent-activity dir may not exist */ }
3311
3426
  }
3312
3427
 
3313
3428
  cleanupAgentActivity();
@@ -3364,3 +3479,5 @@ async function prewarmCaches() {
3364
3479
  }
3365
3480
  });
3366
3481
 
3482
+
3483
+ // #endregion