claude-code-kanban 4.20.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,
@@ -33,6 +33,7 @@ const {
33
33
  buildLoopInfoFromState
34
34
  } = require('./lib/parsers');
35
35
  const { inlineHtmlAssets } = require('./lib/inline-assets');
36
+ const { buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed } = require('./lib/approvals');
36
37
 
37
38
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
38
39
  const { runInstall, runUninstall } = require("./install");
@@ -98,6 +99,9 @@ const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
98
99
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
99
100
  const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
100
101
 
102
+ // #endregion
103
+
104
+ // #region SERVER_STATE
101
105
  // Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
102
106
  function readPins() {
103
107
  try {
@@ -128,6 +132,8 @@ function writeServerInfo(port) {
128
132
  writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
129
133
  }
130
134
 
135
+ // #endregion
136
+
131
137
  // #region TIMINGS
132
138
  const PERMISSION_TTL_MS = 30 * 60 * 1000;
133
139
  const AGENT_TTL_MS = 60 * 60 * 1000;
@@ -143,6 +149,8 @@ const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
143
149
  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
144
150
  // #endregion
145
151
 
152
+ // #region AGENT_ACTIVITY
153
+
146
154
  function readAgentJsonl(filePath) {
147
155
  const raw = readFileSync(filePath, 'utf8');
148
156
  const merged = {};
@@ -164,8 +172,27 @@ function listAgentFiles(agentDir) {
164
172
  }
165
173
 
166
174
  function persistAgent(dir, agent) {
167
- const file = path.join(dir, agent.agentId + '.jsonl');
168
- 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(() => {});
177
+ }
178
+
179
+ // Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
180
+ // this only reads the config, cached so the polled session list doesn't re-read
181
+ // it per hit (marker stays visible for the badge until TTL).
182
+ const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
183
+ let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
184
+ function approvalsWaitMs() {
185
+ if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
186
+ let cfg = null;
187
+ try {
188
+ cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
189
+ } catch { /* missing config — gate default */ }
190
+ approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
191
+ return approvalsWaitCache.ms;
192
+ }
193
+
194
+ function isWaitingLapsed(data) {
195
+ return isLapsed(data.timestamp, approvalsWaitMs());
169
196
  }
170
197
 
171
198
  function checkWaitingForUser(agentDir, logMtime) {
@@ -177,9 +204,10 @@ function checkWaitingForUser(agentDir, logMtime) {
177
204
  if (age >= PERMISSION_TTL_MS) return null;
178
205
  // After grace period, check if session resumed activity (user already responded)
179
206
  if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
207
+ if (isWaitingLapsed(data)) return { ...data, lapsed: true };
180
208
  return data;
181
209
  }
182
- } catch (e) { /* skip — missing or invalid */ }
210
+ } catch { /* skip — missing or invalid */ }
183
211
  return null;
184
212
  }
185
213
 
@@ -253,7 +281,7 @@ function getSessionLogStat(meta) {
253
281
  try {
254
282
  const st = statSync(meta.jsonlPath);
255
283
  return { mtime: st.mtimeMs, hasMessages: st.size > 1000 };
256
- } catch (e) { return { mtime: null, hasMessages: false }; }
284
+ } catch { return { mtime: null, hasMessages: false }; }
257
285
  }
258
286
 
259
287
  function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
@@ -273,12 +301,15 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
273
301
  result.hasRunning = true;
274
302
  }
275
303
  if (result.hasRunning) break;
276
- } catch (e) { /* skip invalid */ }
304
+ } catch { /* skip invalid */ }
277
305
  }
278
- } catch (e) { /* ignore */ }
306
+ } catch { /* ignore */ }
279
307
  return result;
280
308
  }
281
309
 
310
+ // #endregion
311
+
312
+ // #region TEAMS
282
313
  // isContained canonicalises both sides with realpath, which costs ~100x a plain
283
314
  // path.join. This is called once per session on the /api/sessions hot path, and
284
315
  // the verdict for a given name cannot change while TEAMS_DIR is fixed — so the
@@ -315,14 +346,14 @@ function loadTeamConfig(teamName) {
315
346
  const data = JSON.parse(readFileSync(configPath, 'utf8'));
316
347
  teamConfigCache.set(teamName, { data, ts: Date.now() });
317
348
  return data;
318
- } catch (e) {
349
+ } catch {
319
350
  return null;
320
351
  }
321
352
  }
322
353
 
323
354
  function resolveSessionId(sessionId) {
324
355
  const teamConfig = loadTeamConfig(sessionId);
325
- return (teamConfig && teamConfig.leadSessionId) ? teamConfig.leadSessionId : sessionId;
356
+ return (teamConfig?.leadSessionId) ? teamConfig.leadSessionId : sessionId;
326
357
  }
327
358
 
328
359
  // Recent Claude Code releases auto-create a single-member "self-team" for every
@@ -436,6 +467,9 @@ function attachTeamTasks(card, teamTaskDir, teamName, counts) {
436
467
  }
437
468
  }
438
469
 
470
+ // #endregion
471
+
472
+ // #region SHARED_STATE
439
473
  // SSE clients for live updates
440
474
  const clients = new Set();
441
475
 
@@ -449,39 +483,48 @@ const METADATA_CACHE_TTL = 10000; // 10 seconds
449
483
  const dirtyMetadataPaths = new Set();
450
484
  let metadataNeedsFullScan = true;
451
485
 
486
+ // #endregion
487
+
488
+ // #region REQUEST_GUARDS
452
489
  const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
453
490
  function isSafeId(id) {
454
491
  return typeof id === 'string' && id.length > 0 && id.length <= 128 && SAFE_ID_RE.test(id);
455
492
  }
456
493
 
457
- app.param('sessionId', (req, res, next, val) => {
494
+ app.param('sessionId', (_req, res, next, val) => {
458
495
  if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid session ID' });
459
496
  next();
460
497
  });
461
- app.param('taskId', (req, res, next, val) => {
498
+ app.param('taskId', (_req, res, next, val) => {
462
499
  if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid task ID' });
463
500
  next();
464
501
  });
465
502
  // Team names are directory names under TEAMS_DIR, so /api/teams/:name was a
466
503
  // straight traversal before this.
467
- app.param('name', (req, res, next, val) => {
504
+ app.param('name', (_req, res, next, val) => {
468
505
  if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid team name' });
469
506
  next();
470
507
  });
471
508
 
472
509
  // Parse JSON bodies
473
510
  app.use(express.json());
511
+ // #endregion
512
+
513
+ // #region STATIC
474
514
 
475
515
  app.get('/hub-config', (_req, res) => {
476
516
  res.json({ enabled: !!process.env.CLAUDE_HUB, url: process.env.HUB_URL || null });
477
517
  });
478
518
 
479
519
  // Serve static files
480
- app.get('/sw.js', (req, res) => {
520
+ app.get('/sw.js', (_req, res) => {
481
521
  res.setHeader('Cache-Control', 'no-cache');
482
522
  res.sendFile(path.join(__dirname, 'public', 'sw.js'));
483
523
  });
484
524
  app.use(express.static(path.join(__dirname, 'public')));
525
+ // #endregion
526
+
527
+ // #region SESSION_LIST
485
528
 
486
529
  const messageCache = new Map();
487
530
  const MESSAGE_CACHE_TTL = 5000;
@@ -524,9 +567,9 @@ function loadAllTaskMaps() {
524
567
  for (const sessionId of Object.keys(map)) {
525
568
  sessionToList[sessionId] = taskListName;
526
569
  }
527
- } catch (e) { /* skip invalid */ }
570
+ } catch { /* skip invalid */ }
528
571
  }
529
- } catch (e) { /* ignore */ }
572
+ } catch { /* ignore */ }
530
573
  sessionToTaskListCache = { sessionToList, listToSessions };
531
574
  lastTaskMapScan = now;
532
575
  return sessionToTaskListCache;
@@ -596,7 +639,7 @@ function getTaskCounts(sessionPath) {
596
639
  try {
597
640
  const taskPath = path.join(sessionPath, file);
598
641
  const task = JSON.parse(readFileSync(taskPath, 'utf8'));
599
- if (task.metadata && task.metadata._internal) continue;
642
+ if (task.metadata?._internal) continue;
600
643
  if (task.status === 'completed') completed++;
601
644
  else if (task.status === 'in_progress') inProgress++;
602
645
  else pending++;
@@ -604,7 +647,7 @@ function getTaskCounts(sessionPath) {
604
647
  if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
605
648
  newestTaskMtime = taskStat.mtime;
606
649
  }
607
- } catch (e) { /* skip invalid */ }
650
+ } catch { /* skip invalid */ }
608
651
  }
609
652
 
610
653
  const taskCount = completed + inProgress + pending;
@@ -743,7 +786,7 @@ function loadSessionMetadata() {
743
786
  for (const entry of indexEntries) {
744
787
  if (entry.projectPath) { indexProjectPath = entry.projectPath; break; }
745
788
  }
746
- } catch (e) {}
789
+ } catch {}
747
790
  }
748
791
 
749
792
  // First pass: read all JSONL files
@@ -763,7 +806,7 @@ function loadSessionMetadata() {
763
806
  // JSONLs that only hold custom-title/agent-name records when a session
764
807
  // is continued from a worktree). Don't let a weaker entry (no cwd, no
765
808
  // project) overwrite a previously resolved one — just merge scalars.
766
- if (existing && existing.project && !candidateProject) {
809
+ if (existing?.project && !candidateProject) {
767
810
  if (!existing.slug && sessionInfo.slug) existing.slug = sessionInfo.slug;
768
811
  if (!existing.customTitle && sessionInfo.customTitle) existing.customTitle = sessionInfo.customTitle;
769
812
  if (!existing.gitBranch && sessionInfo.gitBranch) existing.gitBranch = sessionInfo.gitBranch;
@@ -905,7 +948,7 @@ function getWorkflowIndex() {
905
948
  function getWorkflowScripts(sessionId) {
906
949
  const idx = getWorkflowIndex();
907
950
  let list = idx.get(sessionId);
908
- if (!list || !list.length) {
951
+ if (!list?.length) {
909
952
  const alt = resolveSessionId(sessionId);
910
953
  if (alt && alt !== sessionId) list = idx.get(alt);
911
954
  }
@@ -914,7 +957,7 @@ function getWorkflowScripts(sessionId) {
914
957
 
915
958
  function getWorkflowInfoSummary(sessionId) {
916
959
  const list = getWorkflowIndex().get(sessionId);
917
- return { hasWorkflow: !!(list && list.length), workflowCount: list ? list.length : 0 };
960
+ return { hasWorkflow: !!(list?.length), workflowCount: list ? list.length : 0 };
918
961
  }
919
962
 
920
963
  function getPlanInfo(slug) {
@@ -925,7 +968,7 @@ function getPlanInfo(slug) {
925
968
  const head = readFileSync(planPath, 'utf8').slice(0, 512);
926
969
  const match = head.match(/^#\s+(.+)$/m);
927
970
  return { hasPlan: true, planTitle: match ? match[1].trim() : null, planPath };
928
- } catch (e) {
971
+ } catch {
929
972
  return { hasPlan: true, planTitle: null, planPath };
930
973
  }
931
974
  }
@@ -935,7 +978,7 @@ function getPlanInfo(slug) {
935
978
  const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
936
979
 
937
980
  function isWakeupActive(w, now = Date.now()) {
938
- if (!w || !w.timestamp || w.delaySeconds == null) return true;
981
+ if (!w?.timestamp || w.delaySeconds == null) return true;
939
982
  const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
940
983
  return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
941
984
  }
@@ -975,7 +1018,7 @@ function getLoopInfoSummary(meta) {
975
1018
  } catch (_) { return empty; }
976
1019
  }
977
1020
 
978
- function getSessionDisplayName(sessionId, meta) {
1021
+ function getSessionDisplayName(_sessionId, meta) {
979
1022
  if (meta?.customTitle) return meta.customTitle;
980
1023
  if (meta?.slug) return meta.slug;
981
1024
  return null;
@@ -1031,6 +1074,9 @@ function buildSessionObject(id, meta, overrides = {}) {
1031
1074
  };
1032
1075
  }
1033
1076
 
1077
+ // #endregion
1078
+
1079
+ // #region SESSION_ROUTES
1034
1080
  // API: List all sessions
1035
1081
  app.get('/api/sessions', async (req, res) => {
1036
1082
  // Prevent browser caching
@@ -1214,7 +1260,7 @@ app.get('/api/sessions', async (req, res) => {
1214
1260
  hasWaitingForUser: true,
1215
1261
  }));
1216
1262
  }
1217
- } catch (e) { /* ignore */ }
1263
+ } catch { /* ignore */ }
1218
1264
  }
1219
1265
 
1220
1266
  // Enrich leader sessions with team info and remove team-named duplicates
@@ -1299,13 +1345,13 @@ app.get('/api/sessions', async (req, res) => {
1299
1345
 
1300
1346
  // Correlate plan sessions with their implementation sessions (same slug)
1301
1347
  const slugGroups = new Map();
1302
- for (const [sid, session] of sessionsMap) {
1348
+ for (const [_sid, session] of sessionsMap) {
1303
1349
  if (session.slug) {
1304
1350
  if (!slugGroups.has(session.slug)) slugGroups.set(session.slug, []);
1305
1351
  slugGroups.get(session.slug).push(session);
1306
1352
  }
1307
1353
  }
1308
- for (const [slug, group] of slugGroups) {
1354
+ for (const [_slug, group] of slugGroups) {
1309
1355
  if (group.length < 2) continue;
1310
1356
  group.sort((a, b) => new Date(a.modifiedAt) - new Date(b.modifiedAt));
1311
1357
  const planSession = group.find(s => s.hasPlan);
@@ -1407,7 +1453,7 @@ app.get('/api/sessions', async (req, res) => {
1407
1453
  });
1408
1454
 
1409
1455
  // API: Get distinct project paths with last-modified timestamps
1410
- app.get('/api/projects', (req, res) => {
1456
+ app.get('/api/projects', (_req, res) => {
1411
1457
  res.setHeader('Cache-Control', 'no-store');
1412
1458
  const metadata = loadSessionMetadata();
1413
1459
  const projectMap = {};
@@ -1446,7 +1492,7 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
1446
1492
  }
1447
1493
 
1448
1494
  // Sort by ID (numeric)
1449
- tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
1495
+ tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
1450
1496
 
1451
1497
  res.json(tasks);
1452
1498
  } catch (error) {
@@ -1493,7 +1539,7 @@ app.get('/api/projects/:encodedPath/tasks', (req, res) => {
1493
1539
  } catch (_) {}
1494
1540
  }
1495
1541
  }
1496
- tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
1542
+ tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
1497
1543
  res.json(tasks);
1498
1544
  } catch (error) {
1499
1545
  console.error('Error getting project tasks:', error);
@@ -1575,6 +1621,9 @@ app.get('/api/sessions/:sessionId/workflows/:wfId', async (req, res) => {
1575
1621
  }
1576
1622
  });
1577
1623
 
1624
+ // #endregion
1625
+
1626
+ // #region WORKFLOWS
1578
1627
  // meta.phases and meta.name/description are pure literals per the Workflow tool
1579
1628
  // contract, so pull them by regex rather than executing the untrusted script.
1580
1629
  // name/description take the first match (the meta block is at the top of the file).
@@ -1588,9 +1637,7 @@ function parseWorkflowMeta(source) {
1588
1637
  const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
1589
1638
  const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
1590
1639
  if (phasesM) {
1591
- const re = /\{[\s\S]*?\}/g;
1592
- let m;
1593
- while ((m = re.exec(phasesM[1]))) {
1640
+ for (const m of phasesM[1].matchAll(/\{[\s\S]*?\}/g)) {
1594
1641
  const title = matchStr(m[0], 'title');
1595
1642
  if (title) meta.phases.push({ title, detail: matchStr(m[0], 'detail') });
1596
1643
  }
@@ -1760,6 +1807,9 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
1760
1807
  }
1761
1808
  });
1762
1809
 
1810
+ // #endregion
1811
+
1812
+ // #region OPEN_TARGETS
1763
1813
  // API: Open folder (and optionally a file within it) in editor
1764
1814
  app.post('/api/open-folder', (req, res) => {
1765
1815
  try {
@@ -1789,7 +1839,7 @@ app.post('/api/open-in-editor', (req, res) => {
1789
1839
  const safeName = (title || 'message').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50);
1790
1840
  const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 8);
1791
1841
  const tmpFile = path.join(os.tmpdir(), `claude-kanban-${safeName}-${hash}.md`);
1792
- require('fs').writeFileSync(tmpFile, content, 'utf8');
1842
+ require('node:fs').writeFileSync(tmpFile, content, 'utf8');
1793
1843
 
1794
1844
  openInEditor([tmpFile]);
1795
1845
  res.json({ success: true, path: tmpFile });
@@ -1800,6 +1850,60 @@ app.post('/api/open-in-editor', (req, res) => {
1800
1850
  }
1801
1851
  });
1802
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
1803
1907
  // API: Get team config
1804
1908
  app.get('/api/teams/:name', (req, res) => {
1805
1909
  const config = loadTeamConfig(req.params.name);
@@ -1851,7 +1955,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1851
1955
  }
1852
1956
  }
1853
1957
  agents.push(agent);
1854
- } catch (e) { /* skip invalid */ }
1958
+ } catch { /* skip invalid */ }
1855
1959
  }
1856
1960
  const liveAgents = agents.filter(isAgentLive);
1857
1961
  if (liveAgents.length && meta.jsonlPath) {
@@ -2008,7 +2112,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2008
2112
  // Subagents (Explore, general-purpose, etc.) are not in teamMemberNames and bypass
2009
2113
  // dedup entirely, so parallel siblings of the same subagent type remain visible.
2010
2114
  let visibleAgents = agents;
2011
- if (teamMemberNames && teamMemberNames.size) {
2115
+ if (teamMemberNames?.size) {
2012
2116
  const groups = new Map();
2013
2117
  for (const a of agents) {
2014
2118
  const t = agentDisplayName(a);
@@ -2030,7 +2134,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2030
2134
 
2031
2135
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
2032
2136
  res.json({ agents: visibleAgents, waitingForUser, teamColors });
2033
- } catch (e) {
2137
+ } catch {
2034
2138
  res.json({ agents: [], waitingForUser: null });
2035
2139
  }
2036
2140
  });
@@ -2044,29 +2148,57 @@ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
2044
2148
  try {
2045
2149
  clearWaitingFile(resolveSessionId(req.params.sessionId));
2046
2150
  res.json({ ok: true });
2047
- } catch (e) {
2151
+ } catch {
2048
2152
  res.status(500).json({ error: 'Failed to discard waiting' });
2049
2153
  }
2050
2154
  });
2051
2155
 
2156
+ // UI-driven approvals: answers the ask approval-gate.sh is blocking on by
2157
+ // writing _decision-<id>.json next to the marker. The hook consumes and deletes
2158
+ // both files; a write for a hook that already gave up is accepted anyway (D13)
2159
+ // and left for cleanupAgentActivity's sweep.
2160
+ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2161
+ const sessionId = resolveSessionId(req.params.sessionId);
2162
+ const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
2163
+ let marker = null;
2164
+ try { marker = JSON.parse(readFileSync(path.join(dir, '_waiting.json'), 'utf8')); }
2165
+ catch { /* missing or invalid → buildDecision reports 410 */ }
2166
+ // The gate stopped polling after waitSeconds — an orphaned decision file would
2167
+ // sit unconsumed while the card pretends the click worked.
2168
+ if (marker && isWaitingLapsed(marker)) {
2169
+ return res.status(410).json({ error: 'Ask lapsed — answer it in the terminal' });
2170
+ }
2171
+ const result = buildDecision(marker, req.body || {});
2172
+ if (result.error) return res.status(result.status).json({ error: result.error });
2173
+ try {
2174
+ writeJsonAtomic(path.join(dir, decisionFileName(marker.id)), result.decision);
2175
+ res.json({ ok: true });
2176
+ } catch {
2177
+ res.status(500).json({ error: 'Failed to write decision' });
2178
+ }
2179
+ });
2180
+
2052
2181
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
2053
2182
  const sessionId = resolveSessionId(req.params.sessionId);
2054
2183
  const agentId = sanitizeAgentId(req.params.agentId);
2055
- const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.jsonl');
2184
+ const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, `${agentId}.jsonl`);
2056
2185
  if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
2057
2186
  try {
2058
2187
  const agent = readAgentJsonl(agentFile);
2059
2188
  agent.status = 'stopped';
2060
2189
  agent.stoppedAt = new Date().toISOString();
2061
2190
  const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
2062
- 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
2063
2192
  clearWaitingFile(sessionId);
2064
2193
  res.json({ ok: true });
2065
- } catch (e) {
2194
+ } catch {
2066
2195
  res.status(500).json({ error: 'Failed to stop agent' });
2067
2196
  }
2068
2197
  });
2069
2198
 
2199
+ // #endregion
2200
+
2201
+ // #region SUBAGENT_RESOLUTION
2070
2202
  function sanitizeAgentId(raw) {
2071
2203
  return path.basename(raw).replace(/[^a-zA-Z0-9_-]/g, '');
2072
2204
  }
@@ -2076,7 +2208,7 @@ function sessionDirFromMeta(meta) {
2076
2208
  }
2077
2209
 
2078
2210
  function subagentJsonlPath(meta, agentId) {
2079
- return path.join(sessionDirFromMeta(meta), 'subagents', 'agent-' + agentId + '.jsonl');
2211
+ return path.join(sessionDirFromMeta(meta), 'subagents', `agent-${agentId}.jsonl`);
2080
2212
  }
2081
2213
 
2082
2214
  // A subagent transcript is normally at <sessionDir>/subagents/agent-<id>.jsonl.
@@ -2084,14 +2216,14 @@ function subagentJsonlPath(meta, agentId) {
2084
2216
  // <sessionDir>/subagents/workflows/<wf_id>/agent-<id>.jsonl. Return whichever
2085
2217
  // exists (flat first), else null.
2086
2218
  function subagentJsonlInDir(sessionDir, agentId) {
2087
- const flat = path.join(sessionDir, 'subagents', 'agent-' + agentId + '.jsonl');
2219
+ const flat = path.join(sessionDir, 'subagents', `agent-${agentId}.jsonl`);
2088
2220
  if (existsSync(flat)) return flat;
2089
2221
  const wfRoot = path.join(sessionDir, 'subagents', 'workflows');
2090
2222
  let wfDirs;
2091
2223
  try { wfDirs = readdirSync(wfRoot, { withFileTypes: true }); } catch { return null; }
2092
2224
  for (const wf of wfDirs) {
2093
2225
  if (!wf.isDirectory()) continue;
2094
- const nested = path.join(wfRoot, wf.name, 'agent-' + agentId + '.jsonl');
2226
+ const nested = path.join(wfRoot, wf.name, `agent-${agentId}.jsonl`);
2095
2227
  if (existsSync(nested)) return nested;
2096
2228
  }
2097
2229
  return null;
@@ -2126,7 +2258,7 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
2126
2258
  // Same session dir: flat path or nested under subagents/workflows/ (both checked here).
2127
2259
  const local = meta.jsonlPath ? subagentJsonlInDir(sessionDirFromMeta(meta), agentId) : null;
2128
2260
  if (local) return local;
2129
- const key = sessionId + '/' + agentId;
2261
+ const key = `${sessionId}/${agentId}`;
2130
2262
  const cached = subagentPathCache.get(key);
2131
2263
  if (cached) return cached;
2132
2264
  let found = null;
@@ -2216,7 +2348,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2216
2348
  try { text = readFileSync(fp, 'utf8'); } catch { continue; }
2217
2349
  if (!text.includes(targetUuid)) continue;
2218
2350
  for (const l of text.split('\n')) {
2219
- if (!l || !l.includes(targetUuid)) continue;
2351
+ if (!l?.includes(targetUuid)) continue;
2220
2352
  try {
2221
2353
  const d = JSON.parse(l);
2222
2354
  if (d.uuid === targetUuid && d.sessionId) {
@@ -2268,6 +2400,9 @@ function lookupParentSession(sessionId) {
2268
2400
  parentSessionCache.set(sessionId, result);
2269
2401
  return result;
2270
2402
  }
2403
+ // #endregion
2404
+
2405
+ // #region MESSAGE_ROUTES
2271
2406
  app.get('/api/sessions/:sessionId/parent', (req, res) => {
2272
2407
  res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
2273
2408
  });
@@ -2367,7 +2502,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2367
2502
  if (entry.usage) msg.agentUsageStats = entry.usage;
2368
2503
  if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
2369
2504
  try {
2370
- const agentFile = path.join(agentDir, entry.agentId + '.jsonl');
2505
+ const agentFile = path.join(agentDir, `${entry.agentId}.jsonl`);
2371
2506
  const agent = readAgentJsonl(agentFile);
2372
2507
  if (agent.lastMessage) msg.agentLastMessage = agent.lastMessage;
2373
2508
  if (agent.prompt && !msg.agentPrompt) msg.agentPrompt = agent.prompt;
@@ -2390,7 +2525,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2390
2525
  if (unresolved.length && existsSync(agentDir)) {
2391
2526
  const activity = listAgentFiles(agentDir)
2392
2527
  .map((f) => { try { return readAgentJsonl(path.join(agentDir, f)); } catch (_) { return null; } })
2393
- .filter((a) => a && a.agentId && a.prompt);
2528
+ .filter((a) => a?.agentId && a.prompt);
2394
2529
  const usedIds = new Set(agentMessages.map(m => m.agentId).filter(Boolean));
2395
2530
  for (const msg of unresolved) {
2396
2531
  const key = msg.agentPrompt.slice(0, 200);
@@ -2590,17 +2725,28 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
2590
2725
  res.end(img.buffer);
2591
2726
  });
2592
2727
 
2593
- app.get('/api/version', (req, res) => {
2728
+ // #endregion
2729
+
2730
+ // #region META_ROUTES
2731
+ app.get('/api/version', (_req, res) => {
2594
2732
  const pkg = require('./package.json');
2595
2733
  res.json({ version: pkg.version });
2596
2734
  });
2597
2735
 
2598
- app.get('/api/config', (req, res) => {
2599
- 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
+ });
2600
2743
  });
2601
2744
 
2745
+ // #endregion
2746
+
2747
+ // #region TASK_ROUTES
2602
2748
  // API: Get all tasks across all sessions
2603
- app.get('/api/tasks/all', async (req, res) => {
2749
+ app.get('/api/tasks/all', async (_req, res) => {
2604
2750
  try {
2605
2751
  if (!existsSync(TASKS_DIR)) {
2606
2752
  return res.json([]);
@@ -2640,7 +2786,7 @@ app.get('/api/tasks/all', async (req, res) => {
2640
2786
  sessionName: getSessionDisplayName(sessionDir.name, meta),
2641
2787
  project
2642
2788
  });
2643
- } catch (e) {
2789
+ } catch {
2644
2790
  // Skip invalid files
2645
2791
  }
2646
2792
  }
@@ -2745,16 +2891,16 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2745
2891
  return res.status(404).json({ error: 'Task not found' });
2746
2892
  }
2747
2893
 
2748
- // 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.
2749
2896
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
2750
2897
 
2751
2898
  for (const file of taskFiles) {
2752
- const otherTask = JSON.parse(readFileSync(path.join(sessionPath, file), 'utf8'));
2753
- if (otherTask.blockedBy && otherTask.blockedBy.includes(taskId)) {
2754
- return res.status(400).json({
2755
- error: 'Cannot delete task that blocks other tasks',
2756
- blockedTasks: [otherTask.id]
2757
- });
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));
2758
2904
  }
2759
2905
  }
2760
2906
 
@@ -2768,6 +2914,9 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2768
2914
  }
2769
2915
  });
2770
2916
 
2917
+ // #endregion
2918
+
2919
+ // #region PREVIEW
2771
2920
  // API: File preview — read file and broadcast to clients
2772
2921
  const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
2773
2922
  // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
@@ -2838,8 +2987,11 @@ function dropUrlFragment(abs) {
2838
2987
  return existsSync(trimmed) ? trimmed : abs;
2839
2988
  }
2840
2989
 
2841
- function resolvePreviewPath(filePath, base) {
2842
- 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);
2843
2995
  if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
2844
2996
  if (base && typeof base === 'string' && path.isAbsolute(base)) {
2845
2997
  let baseDir = base;
@@ -2944,7 +3096,7 @@ app.post('/api/session/pin', async (req, res) => {
2944
3096
  }
2945
3097
  });
2946
3098
 
2947
- app.get('/api/session/pins', (req, res) => {
3099
+ app.get('/api/session/pins', (_req, res) => {
2948
3100
  res.setHeader('Cache-Control', 'no-store');
2949
3101
  try {
2950
3102
  const pins = readPins();
@@ -2983,6 +3135,9 @@ app.get('/api/file/resolve', async (req, res) => {
2983
3135
  }
2984
3136
  });
2985
3137
 
3138
+ // #endregion
3139
+
3140
+ // #region SSE
2986
3141
  // SSE endpoint for live updates
2987
3142
  app.get('/api/events', (req, res) => {
2988
3143
  res.setHeader('Content-Type', 'text/event-stream');
@@ -3012,11 +3167,11 @@ function broadcast(data) {
3012
3167
  }
3013
3168
  }
3014
3169
 
3015
- app.get('/api/context-status', (req, res) => {
3170
+ app.get('/api/context-status', (_req, res) => {
3016
3171
  res.json(Object.fromEntries(contextStatusCache));
3017
3172
  });
3018
3173
 
3019
- app.use('/api', (req, res) => {
3174
+ app.use('/api', (_req, res) => {
3020
3175
  res.status(404).json({ error: 'Not found' });
3021
3176
  });
3022
3177
 
@@ -3042,6 +3197,9 @@ watcher.on('all', (event, filePath) => {
3042
3197
  // shared list or a team dir is one directory several sessions map onto -- so anything
3043
3198
  // addressing a session from a task path has to fan out the same way. One resolver for
3044
3199
  // both readers of that mapping: the SSE broadcast and the doorbell.
3200
+ // #endregion
3201
+
3202
+ // #region DOORBELL
3045
3203
  function resolveSessionsForTaskDir(name) {
3046
3204
  if (isUUID(name)) return [name];
3047
3205
  const { listToSessions } = loadAllTaskMaps();
@@ -3172,7 +3330,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
3172
3330
  fs.unlink(file).catch(() => {});
3173
3331
  }
3174
3332
  }
3175
- } catch (e) { /* ignore */ }
3333
+ } catch { /* ignore */ }
3176
3334
  }
3177
3335
  broadcast({ type: 'agent-update', sessionId });
3178
3336
  // For team sessions, also broadcast with team name so frontend picks it up
@@ -3186,7 +3344,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
3186
3344
  break;
3187
3345
  }
3188
3346
  }
3189
- } catch (e) { /* ignore */ }
3347
+ } catch { /* ignore */ }
3190
3348
  }
3191
3349
  }
3192
3350
  });
@@ -3207,7 +3365,7 @@ contextStatusWatcher.on('all', (event, filePath) => {
3207
3365
  try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
3208
3366
  contextStatusCache.set(sessionId, data);
3209
3367
  evictStaleCache(contextStatusCache);
3210
- } catch (e) { /* ignore malformed */ }
3368
+ } catch { /* ignore malformed */ }
3211
3369
  broadcast({ type: 'context-update', sessionId });
3212
3370
  } else if (event === 'unlink') {
3213
3371
  contextStatusCache.delete(sessionId);
@@ -3215,6 +3373,9 @@ contextStatusWatcher.on('all', (event, filePath) => {
3215
3373
  }
3216
3374
  });
3217
3375
 
3376
+ // #endregion
3377
+
3378
+ // #region CLEANUP
3218
3379
  async function cleanupContextStatus() {
3219
3380
  try {
3220
3381
  const entries = await fs.readdir(CONTEXT_STATUS_DIR);
@@ -3228,9 +3389,9 @@ async function cleanupContextStatus() {
3228
3389
  await fs.unlink(fp);
3229
3390
  contextStatusCache.delete(path.basename(f, '.json'));
3230
3391
  }
3231
- } catch (e) { /* ignore */ }
3392
+ } catch { /* ignore */ }
3232
3393
  }
3233
- } catch (e) { /* dir may not exist */ }
3394
+ } catch { /* dir may not exist */ }
3234
3395
  }
3235
3396
 
3236
3397
  async function cleanupAgentActivity() {
@@ -3246,10 +3407,22 @@ async function cleanupAgentActivity() {
3246
3407
  const age = now - stat.mtimeMs;
3247
3408
  if ((contents.length === 0 && age > AGENT_STALE_MS) || age > CLEANUP_MAX_AGE_MS) {
3248
3409
  await fs.rm(dirPath, { recursive: true, force: true });
3410
+ continue;
3249
3411
  }
3250
- } catch (e) { /* ignore per-folder errors */ }
3412
+ // Orphaned decision files: a terminal deny fires no hook event (D13), so a
3413
+ // board answer for an already-gone gate is never consumed. The gate waits at
3414
+ // most PERMISSION_TTL_MS, so anything older is unclaimable.
3415
+ for (const f of contents) {
3416
+ if (!isDecisionFile(f)) continue;
3417
+ const fp = path.join(dirPath, f);
3418
+ try {
3419
+ const fst = await fs.stat(fp);
3420
+ if (now - fst.mtimeMs > PERMISSION_TTL_MS) await fs.rm(fp, { force: true });
3421
+ } catch { /* ignore per-file errors */ }
3422
+ }
3423
+ } catch { /* ignore per-folder errors */ }
3251
3424
  }
3252
- } catch (e) { /* agent-activity dir may not exist */ }
3425
+ } catch { /* agent-activity dir may not exist */ }
3253
3426
  }
3254
3427
 
3255
3428
  cleanupAgentActivity();
@@ -3306,3 +3479,5 @@ async function prewarmCaches() {
3306
3479
  }
3307
3480
  });
3308
3481
 
3482
+
3483
+ // #endregion