claude-code-kanban 4.26.0 → 4.28.0-rc.1

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 } = require('node:fs');
7
+ const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, unlinkSync, 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');
@@ -13,6 +13,7 @@ const { spawnSync, 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
15
  const { isContained } = require('./lib/contain');
16
+ const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
16
17
  const { fileUrlToPath } = require('./lib/file-url');
17
18
 
18
19
  const {
@@ -22,6 +23,7 @@ const {
22
23
  buildSessionDigest,
23
24
  readCompactSummaries,
24
25
  readArtifactLinks,
26
+ readScratchpadCreations,
25
27
  extractPromptFromTranscript,
26
28
  extractModelFromTranscript,
27
29
  extractStructuredResultFromTranscript,
@@ -33,9 +35,13 @@ const {
33
35
  updateLoopInfo,
34
36
  buildLoopInfoFromState
35
37
  } = require('./lib/parsers');
36
- const { inlineHtmlAssets } = require('./lib/inline-assets');
38
+ const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
37
39
  const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
38
- const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
40
+ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
41
+ const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
42
+ const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
43
+ const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
44
+ const { pickFolder } = require('./lib/folder-dialog');
39
45
 
40
46
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
41
47
  const { runInstall, runUninstall } = require("./install");
@@ -75,7 +81,9 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
75
81
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
76
82
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
77
83
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
84
+ const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
78
85
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
86
+ const TERMINAL_TOKEN_FILE = path.join(CCK_DIR, 'terminal-token.json');
79
87
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
80
88
  const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
81
89
 
@@ -91,11 +99,11 @@ function readPins() {
91
99
  return {};
92
100
  }
93
101
 
94
- function writeJsonAtomic(file, obj) {
102
+ function writeJsonAtomic(file, obj, mode) {
95
103
  try {
96
104
  mkdirSync(CCK_DIR, { recursive: true });
97
105
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
98
- writeFileSync(tmp, JSON.stringify(obj, null, 2), 'utf8');
106
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
99
107
  renameSync(tmp, file);
100
108
  } catch (e) {
101
109
  console.error(`Failed to write ${path.basename(file)}:`, e.message);
@@ -111,6 +119,14 @@ function writePins(pins) {
111
119
  // a crashed one.
112
120
  function writeServerInfo(port) {
113
121
  writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
122
+ writeTerminalToken();
123
+ }
124
+
125
+ // For `dispatch start`: a local process as the same user can already run claude itself,
126
+ // so handing it the token adds little. A browser still cannot read the file.
127
+ function writeTerminalToken() {
128
+ if (terminal.unavailableReason()) return;
129
+ writeJsonAtomic(TERMINAL_TOKEN_FILE, { pid: process.pid, token: terminal.token }, 0o600);
114
130
  }
115
131
 
116
132
  // A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
@@ -119,10 +135,22 @@ function writeServerInfo(port) {
119
135
  // the way out. Only when the file is still ours: a newer server on the same config
120
136
  // dir has already claimed it.
121
137
  function removeServerInfo() {
138
+ for (const file of [SERVER_INFO_FILE, TERMINAL_TOKEN_FILE]) {
139
+ try {
140
+ const info = JSON.parse(readFileSync(file, 'utf8'));
141
+ if (info.pid === process.pid) unlinkSync(file);
142
+ } catch (_) { /* gone, unreadable, or not ours */ }
143
+ }
144
+ }
145
+
146
+ // A second server on the same config dir (a test hub, say) takes the beacon and removes it on
147
+ // exit, which leaves this live server undiscoverable. A newer live owner keeps it.
148
+ function reclaimServerInfo(port) {
122
149
  try {
123
- const info = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
124
- if (info.pid === process.pid) unlinkSync(SERVER_INFO_FILE);
125
- } catch (_) { /* gone, unreadable, or not ours */ }
150
+ const { pid } = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
151
+ if (pid === process.pid || isPidAlive(pid)) return;
152
+ } catch (_) { /* missing or unreadable beacon */ }
153
+ writeServerInfo(port);
126
154
  }
127
155
 
128
156
  process.on('exit', removeServerInfo);
@@ -420,9 +448,9 @@ let liveSessionsCache = null;
420
448
  let lastLiveSessionsScan = 0;
421
449
  const LIVE_SESSIONS_TTL = 5000;
422
450
 
423
- function loadLiveSessions() {
451
+ function loadLiveSessions(fresh = false) {
424
452
  const now = Date.now();
425
- if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
453
+ if (!fresh && liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
426
454
  const sessions = [];
427
455
  if (existsSync(SESSIONS_DIR)) {
428
456
  try {
@@ -430,7 +458,7 @@ function loadLiveSessions() {
430
458
  try {
431
459
  const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
432
460
  if (s?.sessionId && s.kind === 'interactive') {
433
- sessions.push({ sessionId: s.sessionId, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
461
+ sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null });
434
462
  }
435
463
  } catch (_) { /* skip invalid */ }
436
464
  }
@@ -451,6 +479,19 @@ function isRegistryIdle(sessionId) {
451
479
  return live?.status === 'idle';
452
480
  }
453
481
 
482
+ // A registry file outlives a crashed claude, so the pid is probed rather than trusted.
483
+ function isSessionProcessAlive(sessionId, exceptPid = null) {
484
+ return loadLiveSessions().some((s) => {
485
+ if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
486
+ return isPidAlive(s.pid);
487
+ });
488
+ }
489
+
490
+ // EPERM means the process exists but belongs to someone else.
491
+ function isPidAlive(pid) {
492
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
493
+ }
494
+
454
495
  function hasRecentLogActivity(sessionId, logAge) {
455
496
  return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
456
497
  }
@@ -1159,8 +1200,11 @@ app.get('/api/sessions', async (req, res) => {
1159
1200
  const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
1160
1201
 
1161
1202
  const pinnedParam = req.query.pinned;
1203
+ const includeIds = req.query.include ? new Set(req.query.include.split(',').filter(Boolean)) : new Set();
1162
1204
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
1205
+ for (const id of includeIds) pinnedIds.add(id);
1163
1206
  const activeFilter = req.query.filter === 'active';
1207
+ const terminalIds = activeFilter ? new Set(terminal.list().map((t) => t.id)) : new Set();
1164
1208
 
1165
1209
  const metadata = loadSessionMetadata();
1166
1210
  const sessionsMap = new Map();
@@ -1190,7 +1234,7 @@ app.get('/api/sessions', async (req, res) => {
1190
1234
 
1191
1235
  // Cheap-probe: when filter=active, skip expensive enrichment for inactive non-pinned sessions.
1192
1236
  // Mirrors the post-filter predicate using only signals already computed above.
1193
- if (activeFilter && !pinnedIds.has(entry.name)) {
1237
+ if (activeFilter && !pinnedIds.has(entry.name) && !terminalIds.has(entry.name)) {
1194
1238
  const cheaplyActive = logStat.hasMessages && (
1195
1239
  hasVisibleLogActivity(entry.name, logAge)
1196
1240
  || agentStatus.hasActive
@@ -1290,7 +1334,7 @@ app.get('/api/sessions', async (req, res) => {
1290
1334
  const metaAgentStatus = checkAgentStatus(metaAgentDir, stale, logMtime, metaIsTeam);
1291
1335
 
1292
1336
  // Cheap-probe: no tasks here (metadata-only), so active = recent log OR live agent.
1293
- if (activeFilter && !pinnedIds.has(sessionId)) {
1337
+ if (activeFilter && !pinnedIds.has(sessionId) && !terminalIds.has(sessionId)) {
1294
1338
  const cheaplyActive = logStat.hasMessages && (
1295
1339
  hasVisibleLogActivity(sessionId, logAge) || metaAgentStatus.hasActive || !!metaAgentStatus.waitingForUser
1296
1340
  );
@@ -1491,7 +1535,7 @@ app.get('/api/sessions', async (req, res) => {
1491
1535
  || s.hasRecentActivity
1492
1536
  );
1493
1537
  for (const [id, s] of sessionsMap) {
1494
- if (pinnedIds.has(id)) continue;
1538
+ if (pinnedIds.has(id) || terminalIds.has(id)) continue;
1495
1539
  if (!isActive(s)) sessionsMap.delete(id);
1496
1540
  }
1497
1541
  }
@@ -1502,8 +1546,12 @@ app.get('/api/sessions', async (req, res) => {
1502
1546
 
1503
1547
  // Apply project filter before limit so the limit is per-project
1504
1548
  const projectFilter = req.query.project;
1549
+ // `include` is narrower than `pinned`: pinned rows survive the limit but still obey
1550
+ // the project filter, because pinning is a preference and the filter is an intent.
1551
+ // The client sends the session it currently has open, which it cannot render at all
1552
+ // if the row is missing — that one is not a preference.
1505
1553
  if (projectFilter) {
1506
- sessions = sessions.filter(s => s.project === projectFilter);
1554
+ sessions = sessions.filter(s => s.project === projectFilter || includeIds.has(s.id));
1507
1555
  }
1508
1556
 
1509
1557
  // Apply limit if specified, but always include pinned sessions
@@ -1514,13 +1562,22 @@ app.get('/api/sessions', async (req, res) => {
1514
1562
  sessions = [...top, ...missingPinned];
1515
1563
  }
1516
1564
 
1517
- res.json(sessions);
1565
+ res.json(withDispatchPlacement(sessions));
1518
1566
  } catch (error) {
1519
1567
  console.error('Error listing sessions:', error);
1520
1568
  res.status(500).json({ error: 'Failed to list sessions' });
1521
1569
  }
1522
1570
  });
1523
1571
 
1572
+ // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
1573
+ const TEMP_ROOT = (() => {
1574
+ try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
1575
+ })();
1576
+ function isTempPath(p) {
1577
+ const rel = path.relative(TEMP_ROOT, p);
1578
+ return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
1579
+ }
1580
+
1524
1581
  // API: Get distinct project paths with last-modified timestamps
1525
1582
  app.get('/api/projects', (_req, res) => {
1526
1583
  res.setHeader('Cache-Control', 'no-store');
@@ -1534,7 +1591,11 @@ app.get('/api/projects', (_req, res) => {
1534
1591
  }
1535
1592
  }
1536
1593
  const projects = Object.entries(projectMap)
1537
- .map(([path, mtime]) => ({ path, modifiedAt: mtime ? new Date(mtime).toISOString() : null }))
1594
+ .map(([path, mtime]) => ({
1595
+ path,
1596
+ modifiedAt: mtime ? new Date(mtime).toISOString() : null,
1597
+ ...(isTempPath(path) && { temp: true }),
1598
+ }))
1538
1599
  .sort((a, b) => a.path.localeCompare(b.path));
1539
1600
  res.json(projects);
1540
1601
  });
@@ -1544,8 +1605,10 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
1544
1605
  try {
1545
1606
  const sessionPath = taskDirFor(req.params.sessionId);
1546
1607
 
1608
+ // A session that never used the board has no task dir. That is the common case and
1609
+ // an answer, not a failure; a 404 only reached the browser console.
1547
1610
  if (!existsSync(sessionPath)) {
1548
- return res.status(404).json({ error: 'Session not found' });
1611
+ return res.json([]);
1549
1612
  }
1550
1613
 
1551
1614
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
@@ -1621,11 +1684,13 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1621
1684
  try {
1622
1685
  const metadata = loadSessionMetadata();
1623
1686
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1687
+ // Most sessions have no saved plan, and the info modal asks for one every time it
1688
+ // opens, so "no plan" is a normal answer rather than a 404 in the console.
1624
1689
  const slug = meta?.slug;
1625
- if (!slug) return res.status(404).json({ error: 'No plan found' });
1690
+ if (!slug) return res.json({ content: null });
1626
1691
 
1627
1692
  const planPath = path.join(PLANS_DIR, `${slug}.md`);
1628
- if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
1693
+ if (!existsSync(planPath)) return res.json({ content: null });
1629
1694
 
1630
1695
  const content = await fs.readFile(planPath, 'utf8');
1631
1696
  res.json({ content, slug });
@@ -1652,18 +1717,25 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1652
1717
  }
1653
1718
  });
1654
1719
 
1720
+ // Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
1721
+ // it already pays to notice the file grew. `load` may return a promise: the promise is
1722
+ // what gets cached, so concurrent callers share a single read. Null when the file is gone.
1723
+ function cachedByFileStat(cache, filePath, load) {
1724
+ let stat;
1725
+ try { stat = statSync(filePath); } catch (_) { return null; }
1726
+ const hit = cache.get(filePath);
1727
+ if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
1728
+ const value = load();
1729
+ cache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value });
1730
+ return value;
1731
+ }
1732
+
1655
1733
  // Cold path — a full transcript scan, so the result is held until the file grows.
1656
1734
  // The zen panel asks again on every re-render, which then costs one stat.
1657
1735
  const artifactsByPath = new Map();
1658
1736
 
1659
1737
  function getArtifactLinks(jsonlPath) {
1660
- let stat;
1661
- try { stat = statSync(jsonlPath); } catch (_) { return []; }
1662
- const hit = artifactsByPath.get(jsonlPath);
1663
- if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.artifacts;
1664
- const artifacts = readArtifactLinks(jsonlPath);
1665
- artifactsByPath.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, artifacts });
1666
- return artifacts;
1738
+ return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
1667
1739
  }
1668
1740
 
1669
1741
  app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
@@ -1678,23 +1750,148 @@ app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
1678
1750
  }
1679
1751
  });
1680
1752
 
1753
+ // A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
1754
+ // external `scratch` CLI. cck only recognizes the shape and launches the viewer —
1755
+ // it reads nothing from the manifest but `name`, `created` and `id`, so the two stay
1756
+ // independently versioned.
1757
+ const SCRATCHPAD_MANIFEST = 'scratchpad.json';
1758
+
1759
+ // Fallback only, for a `scratch new` whose output the transcript never captured. A pad
1760
+ // has no central registry — the CLI treats the folder path as the pad's identity and
1761
+ // finds pads by scanning — and `_scratchpads/` is one user's habit, not a convention it
1762
+ // knows, so there is no layout to guess. Depth 3 rather than a deeper walk for the
1763
+ // reason docs/session-scanning.md records: pruned, it measures 0-3 ms on the worst-case
1764
+ // project dirs, while an unpruned walk of the same dirs costs ~800 ms.
1765
+ const PAD_SCAN_IGNORE = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build']);
1766
+ const PAD_SCAN_MAX_DEPTH = 3;
1767
+ // A pad manifest's `created` is written after the tool call starts, so the match is
1768
+ // a window, not an instant. Measured lag on a real `scratch new` was 4.1 s; the
1769
+ // window is wide enough to survive a slow disk and far too narrow to reach a pad
1770
+ // made in an earlier session.
1771
+ const PAD_CREATE_WINDOW_MS = 120000;
1772
+
1773
+ async function findPads(root, depth = 0) {
1774
+ let entries;
1775
+ try { entries = await fs.readdir(root, { withFileTypes: true }); } catch (_) { return []; }
1776
+ // A pad is never nested inside another pad, so a manifest ends the descent.
1777
+ if (entries.some((e) => e.isFile() && e.name === SCRATCHPAD_MANIFEST)) return [root];
1778
+ if (depth >= PAD_SCAN_MAX_DEPTH) return [];
1779
+ const nested = await Promise.all(
1780
+ entries
1781
+ .filter((e) => e.isDirectory() && !PAD_SCAN_IGNORE.has(e.name))
1782
+ .map((e) => findPads(path.join(root, e.name), depth + 1)),
1783
+ );
1784
+ return nested.flat();
1785
+ }
1786
+
1787
+ // The pads a session made, as linked-doc rows. Derived on every read rather than
1788
+ // stored, so re-reading a transcript cannot drift; the client links each pad once
1789
+ // and records that it did, which is what stops a re-read undoing an unlink.
1790
+ async function readCreatedPads(meta) {
1791
+ const creations = readScratchpadCreations(meta.jsonlPath);
1792
+ // No `scratch new` in the transcript means no disk work at all, so sessions that
1793
+ // never touch the CLI pay a substring pass over the transcript and nothing more.
1794
+ if (!creations.length) return [];
1795
+ const reported = new Set(creations.filter((c) => c.path).map((c) => c.path));
1796
+ const unresolved = creations.filter((c) => !c.path);
1797
+ const askedFor = new Set(unresolved.map((c) => c.name).filter(Boolean));
1798
+ // Only a call whose name could not be read falls back to the clock, and it compares
1799
+ // against the call's second, not the call: the manifest records `created` to the
1800
+ // second while the transcript timestamps to the millisecond, so a pad written during
1801
+ // the same second as its own command otherwise reads as older than it.
1802
+ const byClock = unresolved.filter((c) => !c.name).map((c) => Math.floor(c.ts / 1000) * 1000);
1803
+ const sessionId = path.basename(meta.jsonlPath, '.jsonl');
1804
+ // The project, not `meta.cwd`: cwd is wherever the session last stood, which drifts
1805
+ // into subdirectories — a session that made a pad and then worked inside it reports
1806
+ // a cwd below the pad, and a scan from there finds nothing above it.
1807
+ const root = meta.project || meta.cwd;
1808
+ const scanned = unresolved.length && root ? await findPads(root) : [];
1809
+ const candidates = [...new Set([...reported, ...scanned.map((dir) => path.join(dir, SCRATCHPAD_MANIFEST))])];
1810
+ const rows = await Promise.all(
1811
+ candidates.map(async (file) => {
1812
+ let manifest;
1813
+ try { manifest = JSON.parse(await fs.readFile(file, 'utf8')); } catch (_) { return null; }
1814
+ const created = Date.parse(manifest?.created);
1815
+ if (!Number.isFinite(created)) return null;
1816
+ const name = manifest.name || path.basename(path.dirname(file));
1817
+ // A path the CLI printed names its pad outright. Otherwise `scratch new --id`
1818
+ // settles it: the flag stamps the session that asked for the pad into the
1819
+ // manifest, so an id decides ownership both ways — one naming another session
1820
+ // disowns the pad whatever the name or the clock say. Only a pad with no id is
1821
+ // guessed at, by the name the command asked for and then by the clock.
1822
+ const claimed =
1823
+ reported.has(file) ||
1824
+ (manifest.id
1825
+ ? manifest.id === sessionId
1826
+ : askedFor.has(name) || byClock.some((from) => created >= from && created - from <= PAD_CREATE_WINDOW_MS));
1827
+ if (!claimed) return null;
1828
+ return { path: file, name, created: manifest.created, ts: created };
1829
+ }),
1830
+ );
1831
+ return rows
1832
+ .filter(Boolean)
1833
+ .sort((a, b) => a.ts - b.ts)
1834
+ .map(({ ts: _ts, ...row }) => row);
1835
+ }
1836
+
1837
+ // Cold path — a full transcript scan, and a directory walk when the transcript did not
1838
+ // carry the pad paths, so the result is held until the transcript grows.
1839
+ const padsByPath = new Map();
1840
+
1841
+ function getCreatedPads(meta) {
1842
+ return cachedByFileStat(padsByPath, meta.jsonlPath, () => readCreatedPads(meta)) || [];
1843
+ }
1844
+
1845
+ app.get('/api/sessions/:sessionId/pads', async (req, res) => {
1846
+ try {
1847
+ const metadata = loadSessionMetadata();
1848
+ const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1849
+ if (!meta?.jsonlPath) return res.json({ pads: [] });
1850
+ res.json({ pads: await getCreatedPads(meta) });
1851
+ } catch (error) {
1852
+ console.error('Error reading created pads:', error);
1853
+ res.status(500).json({ error: 'Failed to read created pads' });
1854
+ }
1855
+ });
1856
+
1857
+ // API: List one level of a session's scratchpad dir — the root, or the folder named by
1858
+ // `?path=`, which is the absolute path a previous listing handed out and must still sit
1859
+ // under the root. One readdir per request, never a walk: sessions drop clones and
1860
+ // build output in there, and an unpruned walk costs three orders of magnitude more than
1861
+ // the readdir. The client asks for a folder only when the user opens it. Measurements
1862
+ // in docs/session-scanning.md.
1863
+ app.get('/api/sessions/:sessionId/scratchpad-files', async (req, res) => {
1864
+ try {
1865
+ const metadata = loadSessionMetadata();
1866
+ const id = metadata[req.params.sessionId] ? req.params.sessionId : resolveSessionId(req.params.sessionId);
1867
+ const meta = metadata[id];
1868
+ const root = meta ? getScratchpadDir(id, meta) : null;
1869
+ if (!root) return res.json({ files: [] });
1870
+
1871
+ const dir = resolveScratchSubdir(root, req.query.path);
1872
+ if (!dir) return res.status(400).json({ error: 'Path is outside the scratchpad dir' });
1873
+
1874
+ // The harness creates the dir lazily, so "missing" is the common case and lists as empty.
1875
+ res.json({ files: await listScratchDir(dir) });
1876
+ } catch (error) {
1877
+ console.error('Error listing scratchpad files:', error);
1878
+ res.status(500).json({ error: 'Failed to list scratchpad files' });
1879
+ }
1880
+ });
1881
+
1681
1882
  // API: List workflow scripts for a session. Parses each script's meta for the
1682
1883
  // canonical name + description (cold path — only when the workflow modal opens).
1683
- app.get('/api/sessions/:sessionId/workflows', async (req, res) => {
1884
+ app.get('/api/sessions/:sessionId/workflows', (req, res) => {
1684
1885
  try {
1685
- const scripts = getWorkflowScripts(req.params.sessionId);
1686
- const workflows = await Promise.all(
1687
- scripts.map(async (w) => {
1688
- let meta = {};
1689
- try { meta = parseWorkflowMeta(await fs.readFile(w.path, 'utf8')); } catch (_) {}
1690
- return {
1691
- id: w.id,
1692
- name: meta.name || w.name,
1693
- description: meta.description || null,
1694
- modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
1695
- };
1696
- }),
1697
- );
1886
+ const workflows = getWorkflowScripts(req.params.sessionId).map((w) => {
1887
+ const meta = getWorkflowMeta(w.path);
1888
+ return {
1889
+ id: w.id,
1890
+ name: meta.name || w.name,
1891
+ description: meta.description || null,
1892
+ modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
1893
+ };
1894
+ });
1698
1895
  res.json({ workflows });
1699
1896
  } catch (error) {
1700
1897
  console.error('Error listing workflows:', error);
@@ -1728,6 +1925,20 @@ function matchStr(src, key) {
1728
1925
  return m ? m[2] : null;
1729
1926
  }
1730
1927
 
1928
+ const workflowMetaCache = new Map();
1929
+ const EMPTY_WORKFLOW_META = { name: null, description: null, phases: [] };
1930
+ // A script never changes mid-run, and three endpoints parse the same file — one of them
1931
+ // on a poll — so key the parse by the script's mtime.
1932
+ function getWorkflowMeta(scriptPath) {
1933
+ return cachedByMtime(
1934
+ workflowMetaCache,
1935
+ scriptPath,
1936
+ scriptPath,
1937
+ () => parseWorkflowMeta(readFileSync(scriptPath, 'utf8')),
1938
+ EMPTY_WORKFLOW_META,
1939
+ );
1940
+ }
1941
+
1731
1942
  function parseWorkflowMeta(source) {
1732
1943
  const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
1733
1944
  const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
@@ -1740,26 +1951,61 @@ function parseWorkflowMeta(source) {
1740
1951
  return meta;
1741
1952
  }
1742
1953
 
1743
- // journal.jsonl records {type:'started',agentId} then {type:'result',agentId,...}
1744
- // per workflow agent. Map agentId → {started, done}; done count matches the
1745
- // "N/M agents" ratio the /workflows viewer shows.
1954
+ // journal.jsonl records {type:'started',agentId,phase,label} then
1955
+ // {type:'result',agentId,...} per workflow agent. Map agentId →
1956
+ // {started, done, phase, label}; done count matches the "N/M agents" ratio the
1957
+ // /workflows viewer shows.
1958
+ const workflowJournalCache = new Map();
1959
+ function workflowJournalPath(runDir) {
1960
+ return path.join(runDir, 'journal.jsonl');
1961
+ }
1962
+
1746
1963
  function readWorkflowJournal(runDir) {
1964
+ const journalPath = workflowJournalPath(runDir);
1965
+ return cachedByMtime(workflowJournalCache, journalPath, journalPath, () => parseWorkflowJournal(journalPath), new Map());
1966
+ }
1967
+
1968
+ function parseWorkflowJournal(journalPath) {
1747
1969
  const status = new Map();
1748
1970
  let content;
1749
- try { content = readFileSync(path.join(runDir, 'journal.jsonl'), 'utf8'); } catch { return status; }
1971
+ try { content = readFileSync(journalPath, 'utf8'); } catch { return status; }
1750
1972
  for (const line of content.split('\n')) {
1751
1973
  if (!line.trim()) continue;
1752
1974
  let o;
1753
1975
  try { o = JSON.parse(line); } catch { continue; }
1754
1976
  if (!o.agentId) continue;
1755
- const e = status.get(o.agentId) || { started: false, done: false };
1977
+ const e = status.get(o.agentId) || { started: false, done: false, phase: null, label: null };
1756
1978
  if (o.type === 'started') e.started = true;
1757
1979
  else if (o.type === 'result') e.done = true;
1980
+ if (o.phase) e.phase = o.phase;
1981
+ if (o.label) e.label = o.label;
1758
1982
  status.set(o.agentId, e);
1759
1983
  }
1760
1984
  return status;
1761
1985
  }
1762
1986
 
1987
+ // One definition of "how far along is this run", shared by the modal's /run view and
1988
+ // the sidebar widget, so the two can never disagree about what counts as done.
1989
+ function summarizeWorkflowJournal(journal) {
1990
+ const byPhase = new Map();
1991
+ const running = [];
1992
+ let startedCount = 0;
1993
+ let doneCount = 0;
1994
+ for (const e of journal.values()) {
1995
+ if (!e.started) continue;
1996
+ const phase = e.phase || '';
1997
+ const c = byPhase.get(phase) || { started: 0, done: 0 };
1998
+ startedCount++;
1999
+ c.started++;
2000
+ if (e.done) {
2001
+ doneCount++;
2002
+ c.done++;
2003
+ } else running.push(e.label || phase || 'agent');
2004
+ byPhase.set(phase, c);
2005
+ }
2006
+ return { startedCount, doneCount, byPhase, running };
2007
+ }
2008
+
1763
2009
  // A workflow's run artifacts (journal + agent transcripts) live at
1764
2010
  // <sessionDir>/subagents/workflows/<wfId>/. The script itself sits under
1765
2011
  // <sessionDir>/workflows/scripts/, so the run dir is derivable from the script
@@ -1767,8 +2013,9 @@ function readWorkflowJournal(runDir) {
1767
2013
  // then to scanning every project/session (the session dir can sit under a
1768
2014
  // different projEnc than the script when a workflow runs from another cwd). wfId
1769
2015
  // comes from the trusted script index (validated filename), so it can't
1770
- // traverse. Cold path only.
1771
- function resolveWorkflowRunDir(meta, wfId, scriptPath) {
2016
+ // traverse. That last scan is a cold path only — `skipScan` is what keeps it out
2017
+ // of the polled live view, where a script with no run dir would pay it every tick.
2018
+ function resolveWorkflowRunDir(meta, wfId, scriptPath, skipScan = false) {
1772
2019
  const rel = path.join('subagents', 'workflows', wfId);
1773
2020
  if (scriptPath) {
1774
2021
  const sessionDir = path.dirname(path.dirname(path.dirname(scriptPath)));
@@ -1779,6 +2026,7 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1779
2026
  const local = path.join(sessionDirFromMeta(meta), rel);
1780
2027
  if (existsSync(local)) return local;
1781
2028
  }
2029
+ if (skipScan) return null;
1782
2030
  try {
1783
2031
  for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1784
2032
  if (!proj.isDirectory()) continue;
@@ -1797,15 +2045,13 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1797
2045
 
1798
2046
  // API: Workflow run state — declared phases (from the script) plus the agent
1799
2047
  // roster (type/model/output-tokens/duration/status) reconstructed from the run
1800
- // dir's journal + transcripts. Phase↔agent mapping and per-agent labels are
1801
- // runtime-only and never persisted, so the roster is flat by design.
2048
+ // dir's journal + transcripts. The roster is flat: the journal carries each
2049
+ // agent's phase and label, but the modal lists agents in start order.
1802
2050
  app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1803
2051
  try {
1804
2052
  const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1805
2053
  if (!wf) return res.status(404).json({ error: 'Workflow not found' });
1806
- let source = '';
1807
- try { source = await fs.readFile(wf.path, 'utf8'); } catch (_) {}
1808
- const parsed = parseWorkflowMeta(source);
2054
+ const parsed = getWorkflowMeta(wf.path);
1809
2055
 
1810
2056
  const sessionId = resolveSessionId(req.params.sessionId);
1811
2057
  const meta = loadSessionMetadata()[sessionId] || {};
@@ -1824,17 +2070,19 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1824
2070
  try {
1825
2071
  type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
1826
2072
  } catch (_) {}
1827
- const done = journal.get(agentId)?.done;
2073
+ const entry = journal.get(agentId);
1828
2074
  const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
1829
2075
  if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
1830
2076
  agents.push({
1831
2077
  agentId,
1832
2078
  type,
2079
+ phase: entry?.phase || null,
2080
+ label: entry?.label || null,
1833
2081
  model: stats.model || null,
1834
2082
  outputTokens: stats.outputTokens || 0,
1835
2083
  durationMs,
1836
2084
  startedAt: stats.firstTs || null,
1837
- status: done ? 'done' : 'running',
2085
+ status: entry?.done ? 'done' : 'running',
1838
2086
  });
1839
2087
  }
1840
2088
  agents.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
@@ -1842,12 +2090,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1842
2090
  // Roster is start-ordered, so the earliest start is simply the first agent.
1843
2091
  const startedAt = agents[0]?.startedAt || null;
1844
2092
 
1845
- let startedCount = 0;
1846
- let doneCount = 0;
1847
- for (const e of journal.values()) {
1848
- if (e.started) startedCount++;
1849
- if (e.done) doneCount++;
1850
- }
2093
+ let { startedCount, doneCount } = summarizeWorkflowJournal(journal);
1851
2094
  if (!startedCount) {
1852
2095
  startedCount = agents.length;
1853
2096
  doneCount = agents.filter((a) => a.status === 'done').length;
@@ -1870,6 +2113,58 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1870
2113
  }
1871
2114
  });
1872
2115
 
2116
+ // A workflow has no terminal journal entry, so "still running" is every started
2117
+ // agent without a result plus a journal that moved recently — a run killed
2118
+ // mid-flight would otherwise stay live forever.
2119
+ const WORKFLOW_LIVE_MAX_IDLE_MS = 10 * 60 * 1000;
2120
+ // Only the newest few scripts can hold the live run, and each miss costs two
2121
+ // existsSync probes — a session that has accumulated scripts must not turn the
2122
+ // poll into a scan of all of them.
2123
+ const WORKFLOW_LIVE_MAX_SCRIPTS = 3;
2124
+
2125
+ // API: The session's running workflow, or null. The zen panel polls this, so it
2126
+ // reads the journal only — never the agent transcripts the /run view parses.
2127
+ app.get('/api/sessions/:sessionId/workflow-live', (req, res) => {
2128
+ try {
2129
+ const sessionId = resolveSessionId(req.params.sessionId);
2130
+ const meta = loadSessionMetadata()[sessionId] || {};
2131
+ for (const wf of getWorkflowScripts(req.params.sessionId).slice(0, WORKFLOW_LIVE_MAX_SCRIPTS)) {
2132
+ const runDir = resolveWorkflowRunDir(meta, wf.id, wf.path, true);
2133
+ if (!runDir) continue;
2134
+ let mtimeMs = 0;
2135
+ try { mtimeMs = statSync(path.join(runDir, 'journal.jsonl')).mtimeMs; } catch { continue; }
2136
+ if (Date.now() - mtimeMs > WORKFLOW_LIVE_MAX_IDLE_MS) continue;
2137
+
2138
+ const { startedCount, doneCount, byPhase, running } = summarizeWorkflowJournal(readWorkflowJournal(runDir));
2139
+ if (!startedCount || doneCount >= startedCount) continue;
2140
+
2141
+ const parsed = getWorkflowMeta(wf.path);
2142
+ const declared = parsed.phases.map((p) => p.title);
2143
+ const extra = [...byPhase.keys()].filter((t) => t && !declared.includes(t));
2144
+ const phases = [...declared, ...extra].map((title) => ({
2145
+ title,
2146
+ started: byPhase.get(title)?.started || 0,
2147
+ done: byPhase.get(title)?.done || 0,
2148
+ }));
2149
+
2150
+ return res.json({
2151
+ workflow: {
2152
+ id: wf.id,
2153
+ name: parsed.name || wf.name,
2154
+ startedCount,
2155
+ doneCount,
2156
+ phases,
2157
+ running: running.slice(0, 4),
2158
+ },
2159
+ });
2160
+ }
2161
+ res.json({ workflow: null });
2162
+ } catch (error) {
2163
+ console.error('Error building live workflow view:', error);
2164
+ res.status(500).json({ error: 'Failed to build live workflow view' });
2165
+ }
2166
+ });
2167
+
1873
2168
  // API: Open a workflow script in VS Code
1874
2169
  app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
1875
2170
  try {
@@ -1945,11 +2240,6 @@ app.post('/api/open-in-editor', (req, res) => {
1945
2240
  }
1946
2241
  });
1947
2242
 
1948
- // A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
1949
- // external `scratch` CLI. cck only recognizes the shape and launches the viewer —
1950
- // it never parses the manifest, so the two stay independently versioned.
1951
- const SCRATCHPAD_MANIFEST = 'scratchpad.json';
1952
-
1953
2243
  // Resolve a linked path to the pad the viewer wants: `<parent>/<name>` where
1954
2244
  // `<name>/scratchpad.json` exists. Accepts either the manifest or its directory.
1955
2245
  function resolveScratchpad(value) {
@@ -2837,9 +3127,169 @@ app.get('/api/config', (_req, res) => {
2837
3127
  costUrl: COST_URL,
2838
3128
  memoryUrl: MEMORY_URL,
2839
3129
  scratchAvailable: !!whichSync('scratch'),
3130
+ terminal: terminal.clientConfig(),
3131
+ });
3132
+ });
3133
+
3134
+ // #endregion
3135
+
3136
+ // #region TERMINAL
3137
+ // A new session may start only in a folder the user has already worked in, or one they
3138
+ // chose in the native dialog during this run. Anything else would let a page script pick
3139
+ // the directory claude runs in.
3140
+ const pickedFolders = new Set();
3141
+ function isAllowedFolder(dir) {
3142
+ const known = pickedFolders.has(dir) || Object.values(loadSessionMetadata()).some((m) => m.project === dir);
3143
+ try { return known && statSync(dir).isDirectory(); } catch { return false; }
3144
+ }
3145
+
3146
+ const terminal = createTerminalService({
3147
+ config: readTerminalConfig({ getArgValue }),
3148
+ net,
3149
+ claudeDir: CLAUDE_DIR,
3150
+ isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
3151
+ token: process.env.CCK_TERMINAL_TOKEN,
3152
+ which: whichSync,
3153
+ isLiveElsewhere: isSessionProcessAlive,
3154
+ // The project, not the last cwd: `claude --resume` finds a session under the
3155
+ // project dir it started in, and cwd drifts into subdirectories.
3156
+ resolveCwd: (id) => {
3157
+ const meta = loadSessionMetadata()[id];
3158
+ return meta ? meta.project || meta.cwd || null : null;
3159
+ },
3160
+ isAllowedFolder,
3161
+ liveSessions: () => loadLiveSessions(true),
3162
+ onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
3163
+ onExit: (id) => dispatches.sessionExited(id),
3164
+ });
3165
+
3166
+ let folderDialogOpen = false;
3167
+ app.post('/api/terminal/pick-folder', async (req, res) => {
3168
+ const reason = terminal.unavailableReason();
3169
+ if (reason) return res.status(403).json({ error: reason });
3170
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3171
+ if (folderDialogOpen) return res.status(409).json({ error: 'a folder dialog is already open' });
3172
+ folderDialogOpen = true;
3173
+ try {
3174
+ const dir = await pickFolder(whichSync, { start: req.body?.start });
3175
+ if (dir) pickedFolders.add(dir);
3176
+ res.json({ path: dir });
3177
+ } catch (e) {
3178
+ res.status(501).json({ error: e.message });
3179
+ } finally {
3180
+ folderDialogOpen = false;
3181
+ }
3182
+ });
3183
+
3184
+ // The hub's eviction check reads this to keep a pool with live PTYs alive.
3185
+ app.get('/api/terminals', (_req, res) => {
3186
+ res.setHeader('Cache-Control', 'no-store');
3187
+ res.json({ sessions: terminal.list() });
3188
+ });
3189
+
3190
+ app.delete('/api/terminals/:id', (req, res) => {
3191
+ const err = terminal.end(req.params.id, req.get('x-terminal-token'));
3192
+ if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
3193
+ if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
3194
+ res.status(204).end();
3195
+ });
3196
+
3197
+ // Served from node_modules, never a CDN: any script on this page can use the token.
3198
+ const XTERM_FILES = {
3199
+ 'xterm.js': '@xterm/xterm/lib/xterm.js',
3200
+ 'xterm.css': '@xterm/xterm/css/xterm.css',
3201
+ 'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
3202
+ 'addon-webgl.js': '@xterm/addon-webgl/lib/addon-webgl.js',
3203
+ 'addon-unicode11.js': '@xterm/addon-unicode11/lib/addon-unicode11.js',
3204
+ };
3205
+ app.get('/vendor/xterm/:file', (req, res) => {
3206
+ const rel = Object.hasOwn(XTERM_FILES, req.params.file) ? XTERM_FILES[req.params.file] : null;
3207
+ if (!rel) return res.status(404).end();
3208
+ let file;
3209
+ try { file = require.resolve(rel); } catch { return res.status(404).end(); }
3210
+ res.setHeader('Cache-Control', 'public, max-age=86400');
3211
+ res.sendFile(file);
3212
+ });
3213
+ // #endregion
3214
+
3215
+ // #region DISPATCH
3216
+ const dispatches = createDispatchRegistry({
3217
+ onSettle: (r) => {
3218
+ if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
3219
+ broadcast({ type: 'dispatch-update' });
3220
+ },
3221
+ });
3222
+
3223
+ const dispatchGroups = createGroupStore({
3224
+ load: () => {
3225
+ try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
3226
+ },
3227
+ save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
3228
+ isAlive: (id) => terminal.isRunning(id) || isSessionProcessAlive(id),
3229
+ pinnedIds: () => new Set(Object.keys(readPins())),
3230
+ });
3231
+
3232
+ // The board places a session from these alone, so it never has to move it later.
3233
+ // `startedBy` lets it follow a starter the user put in a named group, which only the
3234
+ // browser knows; it goes once the dispatch settles.
3235
+ function withDispatchPlacement(sessions) {
3236
+ const groups = dispatchGroups.snapshot();
3237
+ const starters = new Map(
3238
+ dispatches.list().filter((r) => r.status === 'running' && r.parent && r.session).map((r) => [r.session, r.parent]),
3239
+ );
3240
+ if (!groups.size && !starters.size) return sessions;
3241
+ return sessions.map((s) => {
3242
+ const dispatchGroup = groups.get(s.id);
3243
+ const startedBy = starters.get(s.id);
3244
+ return dispatchGroup || startedBy ? { ...s, dispatchGroup, startedBy } : s;
2840
3245
  });
3246
+ }
3247
+
3248
+ // Starting a session needs the terminal token, as the browser does; the CLI reads it from
3249
+ // TERMINAL_TOKEN_FILE. The child gets only its dispatch capability through the preamble.
3250
+ app.post('/api/dispatch', (req, res) => {
3251
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3252
+ const { cwd, spec, name, model, worktree, parent, group, report, peer } = req.body || {};
3253
+ if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3254
+ if (peer != null && !isPeerName(peer)) return res.status(400).json({ error: 'peer must be a peer name as ListAgents prints it' });
3255
+ if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
3256
+ if (group != null && !isGroupName(group)) {
3257
+ return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3258
+ }
3259
+ const starterGroup = parent ? dispatchGroups.groupOf(parent) : null;
3260
+ const target = group || starterGroup;
3261
+ const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
3262
+ const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
3263
+ const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
3264
+ if (started.error) {
3265
+ dispatches.discard(r.id);
3266
+ return res.status(started.status).json({ error: started.error });
3267
+ }
3268
+ dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
3269
+ // A starter already in a group stays where it is: moving it would jump it under the user.
3270
+ if (target) dispatchGroups.join(target, [starterGroup ? null : parent, started.id]);
3271
+ broadcast({ type: 'dispatch-update' });
3272
+ res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
3273
+ });
3274
+
3275
+ app.post('/api/dispatch/:id/done', (req, res) => {
3276
+ const { cap, outcome, summary } = req.body || {};
3277
+ const err = dispatches.settle(req.params.id, cap, outcome, summary);
3278
+ if (err) return res.status(err.status).json({ error: err.error });
3279
+ res.status(204).end();
2841
3280
  });
2842
3281
 
3282
+ app.get('/api/dispatch', async (req, res) => {
3283
+ res.setHeader('Cache-Control', 'no-store');
3284
+ const ids = typeof req.query.ids === 'string' && req.query.ids ? req.query.ids.split(',') : null;
3285
+ const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
3286
+ // res, not req: a GET's request stream can close before the response is sent.
3287
+ const out = await dispatches.wait({ ids, parent }, req.query.wait, (stop) => {
3288
+ res.on('close', stop);
3289
+ return () => res.off('close', stop);
3290
+ });
3291
+ if (!res.writableEnded) res.json(out);
3292
+ });
2843
3293
  // #endregion
2844
3294
 
2845
3295
  // #region TASK_ROUTES
@@ -2897,7 +3347,7 @@ app.get('/api/tasks/all', async (_req, res) => {
2897
3347
  }
2898
3348
  });
2899
3349
 
2900
- const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents } = require('./lib/session-events');
3350
+ const { enqueueSessionEvent, formatTaskMoved, handleSessionEvents, topicKey } = require('./lib/session-events');
2901
3351
  app.get('/api/sessions/:sessionId/events', handleSessionEvents);
2902
3352
 
2903
3353
  // API: Create a task
@@ -3016,9 +3466,69 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
3016
3466
 
3017
3467
  // #region PREVIEW
3018
3468
  // API: File preview — read file and broadcast to clients
3019
- const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
3469
+ // `text` files are shown as source, highlighted by extension, so the value doubles as
3470
+ // the hljs language name where the two differ. An allowlist rather than byte sniffing:
3471
+ // the client puts the whole response in the DOM, and a mislabelled binary would land
3472
+ // there as megabytes of mojibake.
3473
+ const PREVIEW_TEXT_EXTS =
3474
+ 'txt log csv tsv json jsonl ndjson yml yaml toml ini cfg conf env js mjs cjs ts tsx jsx py rb go rs java kt cs c h cpp hpp php pl lua sh bash zsh ps1 psm1 bat cmd sql graphql css scss less xml svg patch diff';
3475
+ // Served as bytes by GET /api/preview/image, so the NUL sniff below does not apply.
3476
+ const PREVIEW_IMAGE_EXTS = 'png jpg jpeg gif webp avif bmp';
3477
+ const PREVIEW_KINDS = {
3478
+ '.md': 'markdown',
3479
+ '.markdown': 'markdown',
3480
+ '.html': 'html',
3481
+ '.htm': 'html',
3482
+ ...Object.fromEntries(PREVIEW_TEXT_EXTS.split(' ').map((ext) => [`.${ext}`, 'text'])),
3483
+ ...Object.fromEntries(PREVIEW_IMAGE_EXTS.split(' ').map((ext) => [`.${ext}`, 'image'])),
3484
+ };
3485
+ // A scratchpad collects files whose real extension is buried under a trailing one —
3486
+ // report.html.before, app.js.map, config.env.local, page.html~. One hop past an
3487
+ // extension the allowlist does not know asks the question that actually decides the
3488
+ // kind, "is the extension under it previewable", instead of enumerating the suffix
3489
+ // conventions a session might invent. Compressed suffixes stop the hop: the bytes
3490
+ // beneath them are binary, which is what the allowlist exists to keep out of the DOM.
3491
+ const PREVIEW_OPAQUE_EXTS = new Set(['.gz', '.br', '.zst', '.xz', '.bz2', '.zip', '.7z']);
3492
+
3493
+ function previewExtFor(absPath) {
3494
+ const trimmed = absPath.replace(/~+$/, '');
3495
+ const ext = path.extname(trimmed).toLowerCase();
3496
+ if (PREVIEW_KINDS[ext] || !ext || PREVIEW_OPAQUE_EXTS.has(ext)) return ext;
3497
+ return path.extname(trimmed.slice(0, -ext.length)).toLowerCase();
3498
+ }
3499
+
3500
+ function previewKindFor(absPath) {
3501
+ return PREVIEW_KINDS[previewExtFor(absPath)] || null;
3502
+ }
3503
+
3504
+ // The hop makes a claim about bytes from a name, and PREVIEW_OPAQUE_EXTS can only stop
3505
+ // it for suffixes it already knows: report.json.gpg, notes.md.enc and secrets.yml.age
3506
+ // all hop to the extension underneath and would render their ciphertext. Enumerating
3507
+ // the encryption conventions fails open the same way, so the claim is confirmed against
3508
+ // the bytes instead — a NUL in the head is git's test for "not text", and encrypted or
3509
+ // compressed output trips it immediately.
3510
+ const TEXT_SNIFF_BYTES = 8192;
3511
+
3512
+ async function readHead(absPath) {
3513
+ const fh = await fs.open(absPath, 'r');
3514
+ try {
3515
+ const buf = Buffer.alloc(TEXT_SNIFF_BYTES);
3516
+ const { bytesRead } = await fh.read(buf, 0, TEXT_SNIFF_BYTES, 0);
3517
+ return buf.subarray(0, bytesRead);
3518
+ } finally {
3519
+ await fh.close();
3520
+ }
3521
+ }
3522
+
3523
+ async function confirmKind(absPath, kind) {
3524
+ if (!kind) return null;
3525
+ return (await readHead(absPath)).includes(0) ? null : kind;
3526
+ }
3527
+
3020
3528
  // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
3021
3529
  const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
3530
+ // Source in a modal is for reading, not for scrolling a generated bundle.
3531
+ const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
3022
3532
 
3023
3533
  function previewError(status, message) {
3024
3534
  const err = new Error(message);
@@ -3033,7 +3543,8 @@ async function statFileTarget(absPath) {
3033
3543
  try {
3034
3544
  const stats = await fs.stat(absPath);
3035
3545
  if (!stats.isFile()) throw previewError(400, 'Not a file');
3036
- return { size: stats.size, kind: PREVIEW_KINDS[path.extname(absPath).toLowerCase()] || null };
3546
+ const kind = previewKindFor(absPath);
3547
+ return { size: stats.size, kind: kind === 'image' ? kind : await confirmKind(absPath, kind) };
3037
3548
  } catch (e) {
3038
3549
  if (e.status) throw e;
3039
3550
  if (e.code === 'ENOENT') throw previewError(404, 'File not found');
@@ -3042,22 +3553,31 @@ async function statFileTarget(absPath) {
3042
3553
  }
3043
3554
  }
3044
3555
 
3045
- // Checks the file is previewable without reading it — the broadcast path needs the
3556
+ function enforcePreviewSize(kind, size) {
3557
+ const max = kind === 'text' ? PREVIEW_TEXT_MAX_BYTES : PREVIEW_MAX_BYTES;
3558
+ if (size > max) {
3559
+ throw previewError(400, `Preview too large (${Math.round(size / 1048576)}MB, max ${max / 1048576}MB)`);
3560
+ }
3561
+ }
3562
+
3563
+ // Checks the file is previewable without reading it whole — the broadcast path needs the
3046
3564
  // validation (and its status codes) but never the content.
3047
3565
  async function validatePreviewFile(absPath) {
3048
3566
  const { kind, size } = await statFileTarget(absPath);
3049
- if (!kind) throw previewError(400, 'Only .md/.markdown/.html/.htm files are allowed');
3050
- if (size > PREVIEW_MAX_BYTES) {
3051
- throw previewError(
3052
- 400,
3053
- `Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
3054
- );
3055
- }
3567
+ if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file');
3568
+ enforcePreviewSize(kind, size);
3056
3569
  return { kind, size };
3057
3570
  }
3058
3571
 
3059
3572
  async function readPreviewFile(absPath) {
3060
- const { kind } = await validatePreviewFile(absPath);
3573
+ const { kind, size } = await statFileTarget(absPath);
3574
+ // A kind the previewer can't render is an answer, not a failure, reported the way
3575
+ // /api/file/resolve reports it: the caller opens the file in the editor instead. A 400
3576
+ // would only reach the browser console. A size refusal stays a 400 — that one is real.
3577
+ if (!kind) return { content: null, kind: null };
3578
+ enforcePreviewSize(kind, size);
3579
+ // Images travel as bytes through GET /api/preview/image; the client builds the URL from `path`.
3580
+ if (kind === 'image') return { content: null, kind };
3061
3581
  const raw = await fs.readFile(absPath, 'utf8');
3062
3582
  if (kind !== 'html') return { content: raw, kind };
3063
3583
  // The client renders HTML into a `srcdoc` iframe, which has no base URL — sibling
@@ -3090,7 +3610,10 @@ function resolvePreviewPath(rawPath, base) {
3090
3610
  // Every path entering the preview/link surface passes through here, so accepting
3091
3611
  // `file://` once covers /api/preview, /api/document/link and /api/file/resolve.
3092
3612
  const filePath = fileUrlToPath(rawPath);
3093
- if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
3613
+ // Normalized, not passed through: a linked document is identified by its path string,
3614
+ // and `C:/a/b` typed into the link editor has to reach the same string as the `C:\a\b`
3615
+ // a server-side `path.join` produces, or the same file is linked twice.
3616
+ if (path.isAbsolute(filePath)) return dropUrlFragment(path.normalize(filePath));
3094
3617
  if (base && typeof base === 'string' && path.isAbsolute(base)) {
3095
3618
  let baseDir = base;
3096
3619
  try {
@@ -3207,27 +3730,51 @@ app.get('/api/session/pins', (_req, res) => {
3207
3730
  });
3208
3731
 
3209
3732
  app.get('/api/preview', async (req, res) => {
3733
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
3734
+ if (!abs) return res.status(400).json({ error: 'path is required' });
3210
3735
  try {
3211
- const abs = resolvePreviewPath(req.query.path, req.query.base);
3212
- if (!abs) return res.status(400).json({ error: 'path is required' });
3213
3736
  const { content, kind } = await readPreviewFile(abs);
3214
- res.json({ path: abs, content, kind });
3737
+ res.json({ path: abs, exists: true, content, kind });
3215
3738
  } catch (error) {
3216
- console.error('Error in GET /api/preview:', error);
3739
+ // Reported the way /api/file/resolve reports it: a relative link inside a rendered
3740
+ // document is followed without knowing the target is there, so a link to a deleted
3741
+ // file would put a 404 in the browser console. The caller toasts it from `exists`.
3742
+ if (error.status === 404) return res.json({ path: abs, exists: false, content: null, kind: null });
3743
+ // A previewError carries the status it wants reported and is an answer, not a
3744
+ // failure — a missing file or one too large to render is the user's doing.
3745
+ if (!error.status) console.error('Error in GET /api/preview:', error);
3217
3746
  res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
3218
3747
  }
3219
3748
  });
3220
3749
 
3221
- // API: Resolve a hand-typed path to an absolute one that exists. Deliberately not
3222
- // extension-restricted — a linked file the previewer can't render (kind: null) is
3223
- // still linkable, the client just opens it in the editor instead.
3750
+ app.get('/api/preview/image', async (req, res) => {
3751
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
3752
+ if (!abs) return res.status(400).json({ error: 'path is required' });
3753
+ try {
3754
+ const { kind } = await statFileTarget(abs);
3755
+ if (kind !== 'image') throw previewError(400, 'Not an image');
3756
+ res.type(MIME_BY_EXT[previewExtFor(abs)]);
3757
+ res.sendFile(abs);
3758
+ } catch (error) {
3759
+ if (!error.status) console.error('Error in GET /api/preview/image:', error);
3760
+ res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
3761
+ }
3762
+ });
3763
+
3764
+ // API: Resolve a hand-typed path to an absolute one, reporting whether it exists and
3765
+ // what it is. Deliberately not extension-restricted — a linked file the previewer can't
3766
+ // render (kind: null) is still linkable, the client just opens it in the editor instead.
3224
3767
  app.get('/api/file/resolve', async (req, res) => {
3768
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
3769
+ if (!abs) return res.status(400).json({ error: 'path is required' });
3225
3770
  try {
3226
- const abs = resolvePreviewPath(req.query.path, req.query.base);
3227
- if (!abs) return res.status(400).json({ error: 'path is required' });
3228
3771
  // No size cap here — an unpreviewable file of any size is still linkable.
3229
- res.json({ path: abs, ...(await statFileTarget(abs)) });
3772
+ res.json({ path: abs, exists: true, ...(await statFileTarget(abs)) });
3230
3773
  } catch (error) {
3774
+ // A path that is not there is an answer, not a transport failure: a linked-doc row
3775
+ // classifies its file through this endpoint, so a file since deleted would put a 404
3776
+ // in the browser console on every render. The link editor reports it from `exists`.
3777
+ if (error.status === 404) return res.json({ path: abs, exists: false, kind: null });
3231
3778
  console.error('Error in GET /api/file/resolve:', error);
3232
3779
  res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
3233
3780
  }
@@ -3557,9 +4104,16 @@ async function prewarmCaches() {
3557
4104
  // The port is configurable and falls back to a random one when taken, so the postman
3558
4105
  // monitor cannot assume it -- publish the live one where it can read it.
3559
4106
  writeServerInfo(actualPort);
4107
+ setInterval(() => reclaimServerInfo(actualPort), 30000).unref();
3560
4108
  migrateLegacyApprovalsConfig();
3561
4109
  const warning = net.exposureWarning();
3562
4110
  if (warning) console.log(warning);
4111
+ const terminalReason = terminal.unavailableReason();
4112
+ if (terminalReason && terminalReason !== 'disabled') console.log(`Terminal unavailable: ${terminalReason}`);
4113
+ // Under the hub the hub owns the token and hands it to the iframe itself.
4114
+ if (!terminalReason && !process.env.CLAUDE_HUB) {
4115
+ console.log(`Terminal enabled - open http://localhost:${actualPort}/#t=${terminal.token}`);
4116
+ }
3563
4117
 
3564
4118
  if (process.argv.includes('--open')) {
3565
4119
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
@@ -3567,12 +4121,14 @@ async function prewarmCaches() {
3567
4121
  setImmediate(prewarmCaches);
3568
4122
  };
3569
4123
 
3570
- const server = net.listenLoopback(app, PORT, onReady);
4124
+ const listenOpts = { onUpgrade: terminal.handleUpgrade };
4125
+ const server = net.listenLoopback(app, PORT, onReady, listenOpts);
4126
+ process.on('exit', terminal.shutdown);
3571
4127
 
3572
4128
  server.on('error', (err) => {
3573
4129
  if (err.code === 'EADDRINUSE') {
3574
4130
  console.log(`Port ${PORT} in use, trying random port...`);
3575
- net.listenLoopback(app, 0, onReady);
4131
+ net.listenLoopback(app, 0, onReady, listenOpts);
3576
4132
  } else {
3577
4133
  throw err;
3578
4134
  }