claude-code-kanban 4.25.0 → 4.27.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
@@ -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 {
@@ -21,6 +22,8 @@ const {
21
22
  readSessionInfoFromJsonl,
22
23
  buildSessionDigest,
23
24
  readCompactSummaries,
25
+ readArtifactLinks,
26
+ readScratchpadCreations,
24
27
  extractPromptFromTranscript,
25
28
  extractModelFromTranscript,
26
29
  extractStructuredResultFromTranscript,
@@ -32,8 +35,8 @@ const {
32
35
  updateLoopInfo,
33
36
  buildLoopInfoFromState
34
37
  } = require('./lib/parsers');
35
- const { inlineHtmlAssets } = require('./lib/inline-assets');
36
- const { buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed } = require('./lib/approvals');
38
+ const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
39
+ const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
37
40
  const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
38
41
 
39
42
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -105,12 +108,33 @@ function writePins(pins) {
105
108
  writeJsonAtomic(PINS_FILE, pins);
106
109
  }
107
110
 
108
- // Port discovery for out-of-process helpers (the postman monitor). The pid rides along
109
- // so a reader can tell a live server from a file left behind by a crashed one.
111
+ // Port discovery for out-of-process helpers (the postman monitor, approval-gate.sh).
112
+ // The pid rides along so a reader can tell a live server from a file left behind by
113
+ // a crashed one.
110
114
  function writeServerInfo(port) {
111
115
  writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
112
116
  }
113
117
 
118
+ // A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
119
+ // probes the port, finds it closed and never waits. The hub spawns every sub-app on
120
+ // an ephemeral port, so a stale beacon never comes back on its own — drop ours on
121
+ // the way out. Only when the file is still ours: a newer server on the same config
122
+ // dir has already claimed it.
123
+ function removeServerInfo() {
124
+ try {
125
+ const info = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
126
+ if (info.pid === process.pid) unlinkSync(SERVER_INFO_FILE);
127
+ } catch (_) { /* gone, unreadable, or not ours */ }
128
+ }
129
+
130
+ process.on('exit', removeServerInfo);
131
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
132
+ process.on(sig, () => {
133
+ removeServerInfo();
134
+ process.exit(0);
135
+ });
136
+ }
137
+
114
138
  // #endregion
115
139
 
116
140
  // #region TIMINGS
@@ -155,23 +179,30 @@ function persistAgent(dir, agent) {
155
179
  fs.appendFile(file, `${JSON.stringify({ ...agent, event: 'server-update' })}\n`, 'utf8').catch(() => {});
156
180
  }
157
181
 
158
- // Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
159
- // this only reads the config, cached so the polled session list doesn't re-read
160
- // it per hit (marker stays visible for the badge until TTL).
161
- const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
162
- let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
163
- function approvalsWaitMs() {
164
- if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
165
- let cfg = null;
166
- try {
167
- cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
168
- } catch { /* missing config — gate default */ }
169
- approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
170
- return approvalsWaitCache.ms;
182
+ // <CCK_DIR>/config.json — cck's own settings, per Claude config dir.
183
+ // Normalization lives in lib/approvals (kept in sync with the gate's own parse).
184
+ const CCK_CONFIG_FILE = path.join(CCK_DIR, 'config.json');
185
+ const LEGACY_APPROVALS_FILE = path.join(CCK_DIR, 'approvals.json');
186
+ const cckConfigCache = new Map();
187
+ function approvalsConfig() {
188
+ return cachedByMtime(cckConfigCache, 'approvals', CCK_CONFIG_FILE,
189
+ () => approvalsFrom(JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'))), approvalsFrom(null));
171
190
  }
172
191
 
173
- function isWaitingLapsed(data) {
174
- return isLapsed(data.timestamp, approvalsWaitMs());
192
+ // approvals.json predates config.json (and was opt-in). Fold it into
193
+ // config.json once so an existing opt-in keeps its tuning, then drop it.
194
+ function migrateLegacyApprovalsConfig() {
195
+ if (!existsSync(LEGACY_APPROVALS_FILE)) return;
196
+ try {
197
+ const legacy = JSON.parse(readFileSync(LEGACY_APPROVALS_FILE, 'utf8'));
198
+ let cfg = {};
199
+ try { cfg = JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8')) || {}; } catch { /* absent */ }
200
+ if (!cfg.approvals) writeJsonAtomic(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
201
+ unlinkSync(LEGACY_APPROVALS_FILE);
202
+ cckConfigCache.clear();
203
+ } catch (e) {
204
+ console.error(`[cck] could not migrate ${LEGACY_APPROVALS_FILE}: ${e.message}`);
205
+ }
175
206
  }
176
207
 
177
208
  function checkWaitingForUser(agentDir, logMtime) {
@@ -183,7 +214,9 @@ function checkWaitingForUser(agentDir, logMtime) {
183
214
  if (age >= PERMISSION_TTL_MS) return null;
184
215
  // After grace period, check if session resumed activity (user already responded)
185
216
  if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
186
- if (isWaitingLapsed(data)) return { ...data, lapsed: true };
217
+ // Opted out reads the same as lapsed to the board: the ask is only
218
+ // answerable in the terminal, so no buttons.
219
+ if (boardRefusal(data, approvalsConfig())) return { ...data, lapsed: true };
187
220
  return data;
188
221
  }
189
222
  } catch { /* skip — missing or invalid */ }
@@ -246,6 +279,34 @@ function getGitBranch(cwd) {
246
279
  return branch;
247
280
  }
248
281
 
282
+ // A linked worktree's `.git` is a file holding `gitdir: <main>/.git/worktrees/<name>`, so the
283
+ // main checkout is readable without spawning git. Path shape alone would not do: only some
284
+ // worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
285
+ // Cached without a TTL, misses included: a directory cannot turn from an ordinary checkout into
286
+ // a linked worktree without being recreated, and a recreated path is a new cache key.
287
+ const worktreeCache = new Map();
288
+ const WORKTREE_CACHE_MAX = 500;
289
+ const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
290
+
291
+ function resolveWorktree(dir) {
292
+ if (!dir) return null;
293
+ if (worktreeCache.has(dir)) return worktreeCache.get(dir);
294
+
295
+ let worktree = null;
296
+ try {
297
+ // An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
298
+ // answer, and it costs one syscall instead of a stat followed by a read.
299
+ const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
300
+ if (m) worktree = { repo: m[1], name: m[2] };
301
+ } catch (_) {}
302
+
303
+ worktreeCache.set(dir, worktree);
304
+ if (worktreeCache.size > WORKTREE_CACHE_MAX) {
305
+ worktreeCache.delete(worktreeCache.keys().next().value);
306
+ }
307
+ return worktree;
308
+ }
309
+
249
310
  // Only spawn git when cwd has diverged from the launch project — that's the
250
311
  // only case the JSONL value is wrong. Saves N spawns on a typical list build.
251
312
  function resolveSessionGitBranch(meta) {
@@ -1015,12 +1076,30 @@ function getSessionDisplayName(_sessionId, meta) {
1015
1076
  return null;
1016
1077
  }
1017
1078
 
1079
+ // The harness spells a project path into a temp/log dir name by replacing every
1080
+ // non-alphanumeric character with a dash, drive colon and separators included.
1081
+ function encodeProjectDirName(p) {
1082
+ return p.replace(/[^a-zA-Z0-9]/g, '-');
1083
+ }
1084
+
1018
1085
  // Derived by convention, not looked up: the harness creates the dir lazily, so a
1019
1086
  // stat here would report "missing" for every session that has not written a temp
1020
1087
  // file yet — and it would put IO on the session-list hot path. Pure string join.
1021
1088
  function getScratchpadDir(id, meta) {
1022
1089
  if (!meta.jsonlPath) return null;
1023
- return path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
1090
+ const byProject = path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
1091
+
1092
+ // A session launched with `claude -w` starts in the main checkout and enters the
1093
+ // worktree afterwards, so the harness keys its scratchpad on the repo while the
1094
+ // transcript ends up filed under the worktree. Neither the project nor any `cwd`
1095
+ // in the log records that, so the two candidates are told apart by which one the
1096
+ // harness actually created. Probed only for worktree sessions, and only until one
1097
+ // of them exists — before that there is nothing to disambiguate and `byProject`,
1098
+ // right for a session started inside the worktree, stands.
1099
+ const wt = resolveWorktree(meta.project);
1100
+ if (!wt || existsSync(byProject)) return byProject;
1101
+ const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
1102
+ return existsSync(byRepo) ? byRepo : byProject;
1024
1103
  }
1025
1104
 
1026
1105
  function buildSessionObject(id, meta, overrides = {}) {
@@ -1035,6 +1114,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1035
1114
  cwd: meta.cwd || null,
1036
1115
  description: meta.description || null,
1037
1116
  gitBranch: resolveSessionGitBranch(meta),
1117
+ worktree: resolveWorktree(meta.project),
1038
1118
  customTitle: meta.customTitle || null,
1039
1119
  goal: meta.goal || null,
1040
1120
  taskCount: 0,
@@ -1081,7 +1161,9 @@ app.get('/api/sessions', async (req, res) => {
1081
1161
  const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
1082
1162
 
1083
1163
  const pinnedParam = req.query.pinned;
1164
+ const includeIds = req.query.include ? new Set(req.query.include.split(',').filter(Boolean)) : new Set();
1084
1165
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
1166
+ for (const id of includeIds) pinnedIds.add(id);
1085
1167
  const activeFilter = req.query.filter === 'active';
1086
1168
 
1087
1169
  const metadata = loadSessionMetadata();
@@ -1424,8 +1506,12 @@ app.get('/api/sessions', async (req, res) => {
1424
1506
 
1425
1507
  // Apply project filter before limit so the limit is per-project
1426
1508
  const projectFilter = req.query.project;
1509
+ // `include` is narrower than `pinned`: pinned rows survive the limit but still obey
1510
+ // the project filter, because pinning is a preference and the filter is an intent.
1511
+ // The client sends the session it currently has open, which it cannot render at all
1512
+ // if the row is missing — that one is not a preference.
1427
1513
  if (projectFilter) {
1428
- sessions = sessions.filter(s => s.project === projectFilter);
1514
+ sessions = sessions.filter(s => s.project === projectFilter || includeIds.has(s.id));
1429
1515
  }
1430
1516
 
1431
1517
  // Apply limit if specified, but always include pinned sessions
@@ -1466,8 +1552,10 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
1466
1552
  try {
1467
1553
  const sessionPath = taskDirFor(req.params.sessionId);
1468
1554
 
1555
+ // A session that never used the board has no task dir. That is the common case and
1556
+ // an answer, not a failure; a 404 only reached the browser console.
1469
1557
  if (!existsSync(sessionPath)) {
1470
- return res.status(404).json({ error: 'Session not found' });
1558
+ return res.json([]);
1471
1559
  }
1472
1560
 
1473
1561
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
@@ -1543,11 +1631,13 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1543
1631
  try {
1544
1632
  const metadata = loadSessionMetadata();
1545
1633
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1634
+ // Most sessions have no saved plan, and the info modal asks for one every time it
1635
+ // opens, so "no plan" is a normal answer rather than a 404 in the console.
1546
1636
  const slug = meta?.slug;
1547
- if (!slug) return res.status(404).json({ error: 'No plan found' });
1637
+ if (!slug) return res.json({ content: null });
1548
1638
 
1549
1639
  const planPath = path.join(PLANS_DIR, `${slug}.md`);
1550
- if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
1640
+ if (!existsSync(planPath)) return res.json({ content: null });
1551
1641
 
1552
1642
  const content = await fs.readFile(planPath, 'utf8');
1553
1643
  res.json({ content, slug });
@@ -1574,23 +1664,181 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1574
1664
  }
1575
1665
  });
1576
1666
 
1667
+ // Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
1668
+ // it already pays to notice the file grew. `load` may return a promise: the promise is
1669
+ // what gets cached, so concurrent callers share a single read. Null when the file is gone.
1670
+ function cachedByFileStat(cache, filePath, load) {
1671
+ let stat;
1672
+ try { stat = statSync(filePath); } catch (_) { return null; }
1673
+ const hit = cache.get(filePath);
1674
+ if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
1675
+ const value = load();
1676
+ cache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value });
1677
+ return value;
1678
+ }
1679
+
1680
+ // Cold path — a full transcript scan, so the result is held until the file grows.
1681
+ // The zen panel asks again on every re-render, which then costs one stat.
1682
+ const artifactsByPath = new Map();
1683
+
1684
+ function getArtifactLinks(jsonlPath) {
1685
+ return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
1686
+ }
1687
+
1688
+ app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
1689
+ try {
1690
+ const metadata = loadSessionMetadata();
1691
+ const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1692
+ if (!meta?.jsonlPath) return res.json({ artifacts: [] });
1693
+ res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
1694
+ } catch (error) {
1695
+ console.error('Error reading artifacts:', error);
1696
+ res.status(500).json({ error: 'Failed to read artifacts' });
1697
+ }
1698
+ });
1699
+
1700
+ // A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
1701
+ // external `scratch` CLI. cck only recognizes the shape and launches the viewer —
1702
+ // it reads nothing from the manifest but `name`, `created` and `id`, so the two stay
1703
+ // independently versioned.
1704
+ const SCRATCHPAD_MANIFEST = 'scratchpad.json';
1705
+
1706
+ // Fallback only, for a `scratch new` whose output the transcript never captured. A pad
1707
+ // has no central registry — the CLI treats the folder path as the pad's identity and
1708
+ // finds pads by scanning — and `_scratchpads/` is one user's habit, not a convention it
1709
+ // knows, so there is no layout to guess. Depth 3 rather than a deeper walk for the
1710
+ // reason docs/session-scanning.md records: pruned, it measures 0-3 ms on the worst-case
1711
+ // project dirs, while an unpruned walk of the same dirs costs ~800 ms.
1712
+ const PAD_SCAN_IGNORE = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build']);
1713
+ const PAD_SCAN_MAX_DEPTH = 3;
1714
+ // A pad manifest's `created` is written after the tool call starts, so the match is
1715
+ // a window, not an instant. Measured lag on a real `scratch new` was 4.1 s; the
1716
+ // window is wide enough to survive a slow disk and far too narrow to reach a pad
1717
+ // made in an earlier session.
1718
+ const PAD_CREATE_WINDOW_MS = 120000;
1719
+
1720
+ async function findPads(root, depth = 0) {
1721
+ let entries;
1722
+ try { entries = await fs.readdir(root, { withFileTypes: true }); } catch (_) { return []; }
1723
+ // A pad is never nested inside another pad, so a manifest ends the descent.
1724
+ if (entries.some((e) => e.isFile() && e.name === SCRATCHPAD_MANIFEST)) return [root];
1725
+ if (depth >= PAD_SCAN_MAX_DEPTH) return [];
1726
+ const nested = await Promise.all(
1727
+ entries
1728
+ .filter((e) => e.isDirectory() && !PAD_SCAN_IGNORE.has(e.name))
1729
+ .map((e) => findPads(path.join(root, e.name), depth + 1)),
1730
+ );
1731
+ return nested.flat();
1732
+ }
1733
+
1734
+ // The pads a session made, as linked-doc rows. Derived on every read rather than
1735
+ // stored, so re-reading a transcript cannot drift; the client links each pad once
1736
+ // and records that it did, which is what stops a re-read undoing an unlink.
1737
+ async function readCreatedPads(meta) {
1738
+ const creations = readScratchpadCreations(meta.jsonlPath);
1739
+ // No `scratch new` in the transcript means no disk work at all, so sessions that
1740
+ // never touch the CLI pay a substring pass over the transcript and nothing more.
1741
+ if (!creations.length) return [];
1742
+ const reported = new Set(creations.filter((c) => c.path).map((c) => c.path));
1743
+ const unresolved = creations.filter((c) => !c.path);
1744
+ const askedFor = new Set(unresolved.map((c) => c.name).filter(Boolean));
1745
+ // Only a call whose name could not be read falls back to the clock, and it compares
1746
+ // against the call's second, not the call: the manifest records `created` to the
1747
+ // second while the transcript timestamps to the millisecond, so a pad written during
1748
+ // the same second as its own command otherwise reads as older than it.
1749
+ const byClock = unresolved.filter((c) => !c.name).map((c) => Math.floor(c.ts / 1000) * 1000);
1750
+ const sessionId = path.basename(meta.jsonlPath, '.jsonl');
1751
+ // The project, not `meta.cwd`: cwd is wherever the session last stood, which drifts
1752
+ // into subdirectories — a session that made a pad and then worked inside it reports
1753
+ // a cwd below the pad, and a scan from there finds nothing above it.
1754
+ const root = meta.project || meta.cwd;
1755
+ const scanned = unresolved.length && root ? await findPads(root) : [];
1756
+ const candidates = [...new Set([...reported, ...scanned.map((dir) => path.join(dir, SCRATCHPAD_MANIFEST))])];
1757
+ const rows = await Promise.all(
1758
+ candidates.map(async (file) => {
1759
+ let manifest;
1760
+ try { manifest = JSON.parse(await fs.readFile(file, 'utf8')); } catch (_) { return null; }
1761
+ const created = Date.parse(manifest?.created);
1762
+ if (!Number.isFinite(created)) return null;
1763
+ const name = manifest.name || path.basename(path.dirname(file));
1764
+ // A path the CLI printed names its pad outright. Otherwise `scratch new --id`
1765
+ // settles it: the flag stamps the session that asked for the pad into the
1766
+ // manifest, so an id decides ownership both ways — one naming another session
1767
+ // disowns the pad whatever the name or the clock say. Only a pad with no id is
1768
+ // guessed at, by the name the command asked for and then by the clock.
1769
+ const claimed =
1770
+ reported.has(file) ||
1771
+ (manifest.id
1772
+ ? manifest.id === sessionId
1773
+ : askedFor.has(name) || byClock.some((from) => created >= from && created - from <= PAD_CREATE_WINDOW_MS));
1774
+ if (!claimed) return null;
1775
+ return { path: file, name, created: manifest.created, ts: created };
1776
+ }),
1777
+ );
1778
+ return rows
1779
+ .filter(Boolean)
1780
+ .sort((a, b) => a.ts - b.ts)
1781
+ .map(({ ts: _ts, ...row }) => row);
1782
+ }
1783
+
1784
+ // Cold path — a full transcript scan, and a directory walk when the transcript did not
1785
+ // carry the pad paths, so the result is held until the transcript grows.
1786
+ const padsByPath = new Map();
1787
+
1788
+ function getCreatedPads(meta) {
1789
+ return cachedByFileStat(padsByPath, meta.jsonlPath, () => readCreatedPads(meta)) || [];
1790
+ }
1791
+
1792
+ app.get('/api/sessions/:sessionId/pads', async (req, res) => {
1793
+ try {
1794
+ const metadata = loadSessionMetadata();
1795
+ const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1796
+ if (!meta?.jsonlPath) return res.json({ pads: [] });
1797
+ res.json({ pads: await getCreatedPads(meta) });
1798
+ } catch (error) {
1799
+ console.error('Error reading created pads:', error);
1800
+ res.status(500).json({ error: 'Failed to read created pads' });
1801
+ }
1802
+ });
1803
+
1804
+ // API: List one level of a session's scratchpad dir — the root, or the folder named by
1805
+ // `?path=`, which is the absolute path a previous listing handed out and must still sit
1806
+ // under the root. One readdir per request, never a walk: sessions drop clones and
1807
+ // build output in there, and an unpruned walk costs three orders of magnitude more than
1808
+ // the readdir. The client asks for a folder only when the user opens it. Measurements
1809
+ // in docs/session-scanning.md.
1810
+ app.get('/api/sessions/:sessionId/scratchpad-files', async (req, res) => {
1811
+ try {
1812
+ const metadata = loadSessionMetadata();
1813
+ const id = metadata[req.params.sessionId] ? req.params.sessionId : resolveSessionId(req.params.sessionId);
1814
+ const meta = metadata[id];
1815
+ const root = meta ? getScratchpadDir(id, meta) : null;
1816
+ if (!root) return res.json({ files: [] });
1817
+
1818
+ const dir = resolveScratchSubdir(root, req.query.path);
1819
+ if (!dir) return res.status(400).json({ error: 'Path is outside the scratchpad dir' });
1820
+
1821
+ // The harness creates the dir lazily, so "missing" is the common case and lists as empty.
1822
+ res.json({ files: await listScratchDir(dir) });
1823
+ } catch (error) {
1824
+ console.error('Error listing scratchpad files:', error);
1825
+ res.status(500).json({ error: 'Failed to list scratchpad files' });
1826
+ }
1827
+ });
1828
+
1577
1829
  // API: List workflow scripts for a session. Parses each script's meta for the
1578
1830
  // canonical name + description (cold path — only when the workflow modal opens).
1579
- app.get('/api/sessions/:sessionId/workflows', async (req, res) => {
1831
+ app.get('/api/sessions/:sessionId/workflows', (req, res) => {
1580
1832
  try {
1581
- const scripts = getWorkflowScripts(req.params.sessionId);
1582
- const workflows = await Promise.all(
1583
- scripts.map(async (w) => {
1584
- let meta = {};
1585
- try { meta = parseWorkflowMeta(await fs.readFile(w.path, 'utf8')); } catch (_) {}
1586
- return {
1587
- id: w.id,
1588
- name: meta.name || w.name,
1589
- description: meta.description || null,
1590
- modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
1591
- };
1592
- }),
1593
- );
1833
+ const workflows = getWorkflowScripts(req.params.sessionId).map((w) => {
1834
+ const meta = getWorkflowMeta(w.path);
1835
+ return {
1836
+ id: w.id,
1837
+ name: meta.name || w.name,
1838
+ description: meta.description || null,
1839
+ modifiedAt: w.mtimeMs ? new Date(w.mtimeMs).toISOString() : null,
1840
+ };
1841
+ });
1594
1842
  res.json({ workflows });
1595
1843
  } catch (error) {
1596
1844
  console.error('Error listing workflows:', error);
@@ -1624,6 +1872,20 @@ function matchStr(src, key) {
1624
1872
  return m ? m[2] : null;
1625
1873
  }
1626
1874
 
1875
+ const workflowMetaCache = new Map();
1876
+ const EMPTY_WORKFLOW_META = { name: null, description: null, phases: [] };
1877
+ // A script never changes mid-run, and three endpoints parse the same file — one of them
1878
+ // on a poll — so key the parse by the script's mtime.
1879
+ function getWorkflowMeta(scriptPath) {
1880
+ return cachedByMtime(
1881
+ workflowMetaCache,
1882
+ scriptPath,
1883
+ scriptPath,
1884
+ () => parseWorkflowMeta(readFileSync(scriptPath, 'utf8')),
1885
+ EMPTY_WORKFLOW_META,
1886
+ );
1887
+ }
1888
+
1627
1889
  function parseWorkflowMeta(source) {
1628
1890
  const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
1629
1891
  const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
@@ -1636,26 +1898,61 @@ function parseWorkflowMeta(source) {
1636
1898
  return meta;
1637
1899
  }
1638
1900
 
1639
- // journal.jsonl records {type:'started',agentId} then {type:'result',agentId,...}
1640
- // per workflow agent. Map agentId → {started, done}; done count matches the
1641
- // "N/M agents" ratio the /workflows viewer shows.
1901
+ // journal.jsonl records {type:'started',agentId,phase,label} then
1902
+ // {type:'result',agentId,...} per workflow agent. Map agentId →
1903
+ // {started, done, phase, label}; done count matches the "N/M agents" ratio the
1904
+ // /workflows viewer shows.
1905
+ const workflowJournalCache = new Map();
1906
+ function workflowJournalPath(runDir) {
1907
+ return path.join(runDir, 'journal.jsonl');
1908
+ }
1909
+
1642
1910
  function readWorkflowJournal(runDir) {
1911
+ const journalPath = workflowJournalPath(runDir);
1912
+ return cachedByMtime(workflowJournalCache, journalPath, journalPath, () => parseWorkflowJournal(journalPath), new Map());
1913
+ }
1914
+
1915
+ function parseWorkflowJournal(journalPath) {
1643
1916
  const status = new Map();
1644
1917
  let content;
1645
- try { content = readFileSync(path.join(runDir, 'journal.jsonl'), 'utf8'); } catch { return status; }
1918
+ try { content = readFileSync(journalPath, 'utf8'); } catch { return status; }
1646
1919
  for (const line of content.split('\n')) {
1647
1920
  if (!line.trim()) continue;
1648
1921
  let o;
1649
1922
  try { o = JSON.parse(line); } catch { continue; }
1650
1923
  if (!o.agentId) continue;
1651
- const e = status.get(o.agentId) || { started: false, done: false };
1924
+ const e = status.get(o.agentId) || { started: false, done: false, phase: null, label: null };
1652
1925
  if (o.type === 'started') e.started = true;
1653
1926
  else if (o.type === 'result') e.done = true;
1927
+ if (o.phase) e.phase = o.phase;
1928
+ if (o.label) e.label = o.label;
1654
1929
  status.set(o.agentId, e);
1655
1930
  }
1656
1931
  return status;
1657
1932
  }
1658
1933
 
1934
+ // One definition of "how far along is this run", shared by the modal's /run view and
1935
+ // the sidebar widget, so the two can never disagree about what counts as done.
1936
+ function summarizeWorkflowJournal(journal) {
1937
+ const byPhase = new Map();
1938
+ const running = [];
1939
+ let startedCount = 0;
1940
+ let doneCount = 0;
1941
+ for (const e of journal.values()) {
1942
+ if (!e.started) continue;
1943
+ const phase = e.phase || '';
1944
+ const c = byPhase.get(phase) || { started: 0, done: 0 };
1945
+ startedCount++;
1946
+ c.started++;
1947
+ if (e.done) {
1948
+ doneCount++;
1949
+ c.done++;
1950
+ } else running.push(e.label || phase || 'agent');
1951
+ byPhase.set(phase, c);
1952
+ }
1953
+ return { startedCount, doneCount, byPhase, running };
1954
+ }
1955
+
1659
1956
  // A workflow's run artifacts (journal + agent transcripts) live at
1660
1957
  // <sessionDir>/subagents/workflows/<wfId>/. The script itself sits under
1661
1958
  // <sessionDir>/workflows/scripts/, so the run dir is derivable from the script
@@ -1663,8 +1960,9 @@ function readWorkflowJournal(runDir) {
1663
1960
  // then to scanning every project/session (the session dir can sit under a
1664
1961
  // different projEnc than the script when a workflow runs from another cwd). wfId
1665
1962
  // comes from the trusted script index (validated filename), so it can't
1666
- // traverse. Cold path only.
1667
- function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1963
+ // traverse. That last scan is a cold path only — `skipScan` is what keeps it out
1964
+ // of the polled live view, where a script with no run dir would pay it every tick.
1965
+ function resolveWorkflowRunDir(meta, wfId, scriptPath, skipScan = false) {
1668
1966
  const rel = path.join('subagents', 'workflows', wfId);
1669
1967
  if (scriptPath) {
1670
1968
  const sessionDir = path.dirname(path.dirname(path.dirname(scriptPath)));
@@ -1675,6 +1973,7 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1675
1973
  const local = path.join(sessionDirFromMeta(meta), rel);
1676
1974
  if (existsSync(local)) return local;
1677
1975
  }
1976
+ if (skipScan) return null;
1678
1977
  try {
1679
1978
  for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1680
1979
  if (!proj.isDirectory()) continue;
@@ -1693,15 +1992,13 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1693
1992
 
1694
1993
  // API: Workflow run state — declared phases (from the script) plus the agent
1695
1994
  // roster (type/model/output-tokens/duration/status) reconstructed from the run
1696
- // dir's journal + transcripts. Phase↔agent mapping and per-agent labels are
1697
- // runtime-only and never persisted, so the roster is flat by design.
1995
+ // dir's journal + transcripts. The roster is flat: the journal carries each
1996
+ // agent's phase and label, but the modal lists agents in start order.
1698
1997
  app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1699
1998
  try {
1700
1999
  const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1701
2000
  if (!wf) return res.status(404).json({ error: 'Workflow not found' });
1702
- let source = '';
1703
- try { source = await fs.readFile(wf.path, 'utf8'); } catch (_) {}
1704
- const parsed = parseWorkflowMeta(source);
2001
+ const parsed = getWorkflowMeta(wf.path);
1705
2002
 
1706
2003
  const sessionId = resolveSessionId(req.params.sessionId);
1707
2004
  const meta = loadSessionMetadata()[sessionId] || {};
@@ -1720,17 +2017,19 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1720
2017
  try {
1721
2018
  type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
1722
2019
  } catch (_) {}
1723
- const done = journal.get(agentId)?.done;
2020
+ const entry = journal.get(agentId);
1724
2021
  const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
1725
2022
  if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
1726
2023
  agents.push({
1727
2024
  agentId,
1728
2025
  type,
2026
+ phase: entry?.phase || null,
2027
+ label: entry?.label || null,
1729
2028
  model: stats.model || null,
1730
2029
  outputTokens: stats.outputTokens || 0,
1731
2030
  durationMs,
1732
2031
  startedAt: stats.firstTs || null,
1733
- status: done ? 'done' : 'running',
2032
+ status: entry?.done ? 'done' : 'running',
1734
2033
  });
1735
2034
  }
1736
2035
  agents.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
@@ -1738,12 +2037,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1738
2037
  // Roster is start-ordered, so the earliest start is simply the first agent.
1739
2038
  const startedAt = agents[0]?.startedAt || null;
1740
2039
 
1741
- let startedCount = 0;
1742
- let doneCount = 0;
1743
- for (const e of journal.values()) {
1744
- if (e.started) startedCount++;
1745
- if (e.done) doneCount++;
1746
- }
2040
+ let { startedCount, doneCount } = summarizeWorkflowJournal(journal);
1747
2041
  if (!startedCount) {
1748
2042
  startedCount = agents.length;
1749
2043
  doneCount = agents.filter((a) => a.status === 'done').length;
@@ -1766,6 +2060,58 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1766
2060
  }
1767
2061
  });
1768
2062
 
2063
+ // A workflow has no terminal journal entry, so "still running" is every started
2064
+ // agent without a result plus a journal that moved recently — a run killed
2065
+ // mid-flight would otherwise stay live forever.
2066
+ const WORKFLOW_LIVE_MAX_IDLE_MS = 10 * 60 * 1000;
2067
+ // Only the newest few scripts can hold the live run, and each miss costs two
2068
+ // existsSync probes — a session that has accumulated scripts must not turn the
2069
+ // poll into a scan of all of them.
2070
+ const WORKFLOW_LIVE_MAX_SCRIPTS = 3;
2071
+
2072
+ // API: The session's running workflow, or null. The zen panel polls this, so it
2073
+ // reads the journal only — never the agent transcripts the /run view parses.
2074
+ app.get('/api/sessions/:sessionId/workflow-live', (req, res) => {
2075
+ try {
2076
+ const sessionId = resolveSessionId(req.params.sessionId);
2077
+ const meta = loadSessionMetadata()[sessionId] || {};
2078
+ for (const wf of getWorkflowScripts(req.params.sessionId).slice(0, WORKFLOW_LIVE_MAX_SCRIPTS)) {
2079
+ const runDir = resolveWorkflowRunDir(meta, wf.id, wf.path, true);
2080
+ if (!runDir) continue;
2081
+ let mtimeMs = 0;
2082
+ try { mtimeMs = statSync(path.join(runDir, 'journal.jsonl')).mtimeMs; } catch { continue; }
2083
+ if (Date.now() - mtimeMs > WORKFLOW_LIVE_MAX_IDLE_MS) continue;
2084
+
2085
+ const { startedCount, doneCount, byPhase, running } = summarizeWorkflowJournal(readWorkflowJournal(runDir));
2086
+ if (!startedCount || doneCount >= startedCount) continue;
2087
+
2088
+ const parsed = getWorkflowMeta(wf.path);
2089
+ const declared = parsed.phases.map((p) => p.title);
2090
+ const extra = [...byPhase.keys()].filter((t) => t && !declared.includes(t));
2091
+ const phases = [...declared, ...extra].map((title) => ({
2092
+ title,
2093
+ started: byPhase.get(title)?.started || 0,
2094
+ done: byPhase.get(title)?.done || 0,
2095
+ }));
2096
+
2097
+ return res.json({
2098
+ workflow: {
2099
+ id: wf.id,
2100
+ name: parsed.name || wf.name,
2101
+ startedCount,
2102
+ doneCount,
2103
+ phases,
2104
+ running: running.slice(0, 4),
2105
+ },
2106
+ });
2107
+ }
2108
+ res.json({ workflow: null });
2109
+ } catch (error) {
2110
+ console.error('Error building live workflow view:', error);
2111
+ res.status(500).json({ error: 'Failed to build live workflow view' });
2112
+ }
2113
+ });
2114
+
1769
2115
  // API: Open a workflow script in VS Code
1770
2116
  app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
1771
2117
  try {
@@ -1841,11 +2187,6 @@ app.post('/api/open-in-editor', (req, res) => {
1841
2187
  }
1842
2188
  });
1843
2189
 
1844
- // A scratchpad is a folder plus a `scratchpad.json` manifest, rendered by the
1845
- // external `scratch` CLI. cck only recognizes the shape and launches the viewer —
1846
- // it never parses the manifest, so the two stay independently versioned.
1847
- const SCRATCHPAD_MANIFEST = 'scratchpad.json';
1848
-
1849
2190
  // Resolve a linked path to the pad the viewer wants: `<parent>/<name>` where
1850
2191
  // `<name>/scratchpad.json` exists. Accepts either the manifest or its directory.
1851
2192
  function resolveScratchpad(value) {
@@ -2161,9 +2502,8 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2161
2502
  catch { /* missing or invalid → buildDecision reports 410 */ }
2162
2503
  // The gate stopped polling after waitSeconds — an orphaned decision file would
2163
2504
  // sit unconsumed while the card pretends the click worked.
2164
- if (marker && isWaitingLapsed(marker)) {
2165
- return res.status(410).json({ error: 'Ask lapsed — answer it in the terminal' });
2166
- }
2505
+ const refusal = marker && boardRefusal(marker, approvalsConfig());
2506
+ if (refusal) return res.status(refusal.status).json({ error: refusal.error });
2167
2507
  const result = buildDecision(marker, req.body || {});
2168
2508
  if (result.error) return res.status(result.status).json({ error: result.error });
2169
2509
  try {
@@ -2913,9 +3253,69 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
2913
3253
 
2914
3254
  // #region PREVIEW
2915
3255
  // API: File preview — read file and broadcast to clients
2916
- const PREVIEW_KINDS = { '.md': 'markdown', '.markdown': 'markdown', '.html': 'html', '.htm': 'html' };
3256
+ // `text` files are shown as source, highlighted by extension, so the value doubles as
3257
+ // the hljs language name where the two differ. An allowlist rather than byte sniffing:
3258
+ // the client puts the whole response in the DOM, and a mislabelled binary would land
3259
+ // there as megabytes of mojibake.
3260
+ const PREVIEW_TEXT_EXTS =
3261
+ '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';
3262
+ // Served as bytes by GET /api/preview/image, so the NUL sniff below does not apply.
3263
+ const PREVIEW_IMAGE_EXTS = 'png jpg jpeg gif webp avif bmp';
3264
+ const PREVIEW_KINDS = {
3265
+ '.md': 'markdown',
3266
+ '.markdown': 'markdown',
3267
+ '.html': 'html',
3268
+ '.htm': 'html',
3269
+ ...Object.fromEntries(PREVIEW_TEXT_EXTS.split(' ').map((ext) => [`.${ext}`, 'text'])),
3270
+ ...Object.fromEntries(PREVIEW_IMAGE_EXTS.split(' ').map((ext) => [`.${ext}`, 'image'])),
3271
+ };
3272
+ // A scratchpad collects files whose real extension is buried under a trailing one —
3273
+ // report.html.before, app.js.map, config.env.local, page.html~. One hop past an
3274
+ // extension the allowlist does not know asks the question that actually decides the
3275
+ // kind, "is the extension under it previewable", instead of enumerating the suffix
3276
+ // conventions a session might invent. Compressed suffixes stop the hop: the bytes
3277
+ // beneath them are binary, which is what the allowlist exists to keep out of the DOM.
3278
+ const PREVIEW_OPAQUE_EXTS = new Set(['.gz', '.br', '.zst', '.xz', '.bz2', '.zip', '.7z']);
3279
+
3280
+ function previewExtFor(absPath) {
3281
+ const trimmed = absPath.replace(/~+$/, '');
3282
+ const ext = path.extname(trimmed).toLowerCase();
3283
+ if (PREVIEW_KINDS[ext] || !ext || PREVIEW_OPAQUE_EXTS.has(ext)) return ext;
3284
+ return path.extname(trimmed.slice(0, -ext.length)).toLowerCase();
3285
+ }
3286
+
3287
+ function previewKindFor(absPath) {
3288
+ return PREVIEW_KINDS[previewExtFor(absPath)] || null;
3289
+ }
3290
+
3291
+ // The hop makes a claim about bytes from a name, and PREVIEW_OPAQUE_EXTS can only stop
3292
+ // it for suffixes it already knows: report.json.gpg, notes.md.enc and secrets.yml.age
3293
+ // all hop to the extension underneath and would render their ciphertext. Enumerating
3294
+ // the encryption conventions fails open the same way, so the claim is confirmed against
3295
+ // the bytes instead — a NUL in the head is git's test for "not text", and encrypted or
3296
+ // compressed output trips it immediately.
3297
+ const TEXT_SNIFF_BYTES = 8192;
3298
+
3299
+ async function readHead(absPath) {
3300
+ const fh = await fs.open(absPath, 'r');
3301
+ try {
3302
+ const buf = Buffer.alloc(TEXT_SNIFF_BYTES);
3303
+ const { bytesRead } = await fh.read(buf, 0, TEXT_SNIFF_BYTES, 0);
3304
+ return buf.subarray(0, bytesRead);
3305
+ } finally {
3306
+ await fh.close();
3307
+ }
3308
+ }
3309
+
3310
+ async function confirmKind(absPath, kind) {
3311
+ if (!kind) return null;
3312
+ return (await readHead(absPath)).includes(0) ? null : kind;
3313
+ }
3314
+
2917
3315
  // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
2918
3316
  const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
3317
+ // Source in a modal is for reading, not for scrolling a generated bundle.
3318
+ const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
2919
3319
 
2920
3320
  function previewError(status, message) {
2921
3321
  const err = new Error(message);
@@ -2930,7 +3330,8 @@ async function statFileTarget(absPath) {
2930
3330
  try {
2931
3331
  const stats = await fs.stat(absPath);
2932
3332
  if (!stats.isFile()) throw previewError(400, 'Not a file');
2933
- return { size: stats.size, kind: PREVIEW_KINDS[path.extname(absPath).toLowerCase()] || null };
3333
+ const kind = previewKindFor(absPath);
3334
+ return { size: stats.size, kind: kind === 'image' ? kind : await confirmKind(absPath, kind) };
2934
3335
  } catch (e) {
2935
3336
  if (e.status) throw e;
2936
3337
  if (e.code === 'ENOENT') throw previewError(404, 'File not found');
@@ -2939,22 +3340,31 @@ async function statFileTarget(absPath) {
2939
3340
  }
2940
3341
  }
2941
3342
 
2942
- // Checks the file is previewable without reading it — the broadcast path needs the
3343
+ function enforcePreviewSize(kind, size) {
3344
+ const max = kind === 'text' ? PREVIEW_TEXT_MAX_BYTES : PREVIEW_MAX_BYTES;
3345
+ if (size > max) {
3346
+ throw previewError(400, `Preview too large (${Math.round(size / 1048576)}MB, max ${max / 1048576}MB)`);
3347
+ }
3348
+ }
3349
+
3350
+ // Checks the file is previewable without reading it whole — the broadcast path needs the
2943
3351
  // validation (and its status codes) but never the content.
2944
3352
  async function validatePreviewFile(absPath) {
2945
3353
  const { kind, size } = await statFileTarget(absPath);
2946
- if (!kind) throw previewError(400, 'Only .md/.markdown/.html/.htm files are allowed');
2947
- if (size > PREVIEW_MAX_BYTES) {
2948
- throw previewError(
2949
- 400,
2950
- `Preview too large (${Math.round(size / 1048576)}MB, max ${PREVIEW_MAX_BYTES / 1048576}MB)`
2951
- );
2952
- }
3354
+ if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file');
3355
+ enforcePreviewSize(kind, size);
2953
3356
  return { kind, size };
2954
3357
  }
2955
3358
 
2956
3359
  async function readPreviewFile(absPath) {
2957
- const { kind } = await validatePreviewFile(absPath);
3360
+ const { kind, size } = await statFileTarget(absPath);
3361
+ // A kind the previewer can't render is an answer, not a failure, reported the way
3362
+ // /api/file/resolve reports it: the caller opens the file in the editor instead. A 400
3363
+ // would only reach the browser console. A size refusal stays a 400 — that one is real.
3364
+ if (!kind) return { content: null, kind: null };
3365
+ enforcePreviewSize(kind, size);
3366
+ // Images travel as bytes through GET /api/preview/image; the client builds the URL from `path`.
3367
+ if (kind === 'image') return { content: null, kind };
2958
3368
  const raw = await fs.readFile(absPath, 'utf8');
2959
3369
  if (kind !== 'html') return { content: raw, kind };
2960
3370
  // The client renders HTML into a `srcdoc` iframe, which has no base URL — sibling
@@ -2987,7 +3397,10 @@ function resolvePreviewPath(rawPath, base) {
2987
3397
  // Every path entering the preview/link surface passes through here, so accepting
2988
3398
  // `file://` once covers /api/preview, /api/document/link and /api/file/resolve.
2989
3399
  const filePath = fileUrlToPath(rawPath);
2990
- if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
3400
+ // Normalized, not passed through: a linked document is identified by its path string,
3401
+ // and `C:/a/b` typed into the link editor has to reach the same string as the `C:\a\b`
3402
+ // a server-side `path.join` produces, or the same file is linked twice.
3403
+ if (path.isAbsolute(filePath)) return dropUrlFragment(path.normalize(filePath));
2991
3404
  if (base && typeof base === 'string' && path.isAbsolute(base)) {
2992
3405
  let baseDir = base;
2993
3406
  try {
@@ -3104,27 +3517,51 @@ app.get('/api/session/pins', (_req, res) => {
3104
3517
  });
3105
3518
 
3106
3519
  app.get('/api/preview', async (req, res) => {
3520
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
3521
+ if (!abs) return res.status(400).json({ error: 'path is required' });
3107
3522
  try {
3108
- const abs = resolvePreviewPath(req.query.path, req.query.base);
3109
- if (!abs) return res.status(400).json({ error: 'path is required' });
3110
3523
  const { content, kind } = await readPreviewFile(abs);
3111
- res.json({ path: abs, content, kind });
3524
+ res.json({ path: abs, exists: true, content, kind });
3112
3525
  } catch (error) {
3113
- console.error('Error in GET /api/preview:', error);
3526
+ // Reported the way /api/file/resolve reports it: a relative link inside a rendered
3527
+ // document is followed without knowing the target is there, so a link to a deleted
3528
+ // file would put a 404 in the browser console. The caller toasts it from `exists`.
3529
+ if (error.status === 404) return res.json({ path: abs, exists: false, content: null, kind: null });
3530
+ // A previewError carries the status it wants reported and is an answer, not a
3531
+ // failure — a missing file or one too large to render is the user's doing.
3532
+ if (!error.status) console.error('Error in GET /api/preview:', error);
3114
3533
  res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
3115
3534
  }
3116
3535
  });
3117
3536
 
3118
- // API: Resolve a hand-typed path to an absolute one that exists. Deliberately not
3119
- // extension-restricted — a linked file the previewer can't render (kind: null) is
3120
- // still linkable, the client just opens it in the editor instead.
3537
+ app.get('/api/preview/image', async (req, res) => {
3538
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
3539
+ if (!abs) return res.status(400).json({ error: 'path is required' });
3540
+ try {
3541
+ const { kind } = await statFileTarget(abs);
3542
+ if (kind !== 'image') throw previewError(400, 'Not an image');
3543
+ res.type(MIME_BY_EXT[previewExtFor(abs)]);
3544
+ res.sendFile(abs);
3545
+ } catch (error) {
3546
+ if (!error.status) console.error('Error in GET /api/preview/image:', error);
3547
+ res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
3548
+ }
3549
+ });
3550
+
3551
+ // API: Resolve a hand-typed path to an absolute one, reporting whether it exists and
3552
+ // what it is. Deliberately not extension-restricted — a linked file the previewer can't
3553
+ // render (kind: null) is still linkable, the client just opens it in the editor instead.
3121
3554
  app.get('/api/file/resolve', async (req, res) => {
3555
+ const abs = resolvePreviewPath(req.query.path, req.query.base);
3556
+ if (!abs) return res.status(400).json({ error: 'path is required' });
3122
3557
  try {
3123
- const abs = resolvePreviewPath(req.query.path, req.query.base);
3124
- if (!abs) return res.status(400).json({ error: 'path is required' });
3125
3558
  // No size cap here — an unpreviewable file of any size is still linkable.
3126
- res.json({ path: abs, ...(await statFileTarget(abs)) });
3559
+ res.json({ path: abs, exists: true, ...(await statFileTarget(abs)) });
3127
3560
  } catch (error) {
3561
+ // A path that is not there is an answer, not a transport failure: a linked-doc row
3562
+ // classifies its file through this endpoint, so a file since deleted would put a 404
3563
+ // in the browser console on every render. The link editor reports it from `exists`.
3564
+ if (error.status === 404) return res.json({ path: abs, exists: false, kind: null });
3128
3565
  console.error('Error in GET /api/file/resolve:', error);
3129
3566
  res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
3130
3567
  }
@@ -3454,6 +3891,7 @@ async function prewarmCaches() {
3454
3891
  // The port is configurable and falls back to a random one when taken, so the postman
3455
3892
  // monitor cannot assume it -- publish the live one where it can read it.
3456
3893
  writeServerInfo(actualPort);
3894
+ migrateLegacyApprovalsConfig();
3457
3895
  const warning = net.exposureWarning();
3458
3896
  if (warning) console.log(warning);
3459
3897