claude-code-kanban 4.26.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 {
@@ -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,7 +35,7 @@ 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
40
  const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
39
41
 
@@ -1159,7 +1161,9 @@ app.get('/api/sessions', async (req, res) => {
1159
1161
  const limit = limitParam === 'all' ? null : parseInt(limitParam, 10);
1160
1162
 
1161
1163
  const pinnedParam = req.query.pinned;
1164
+ const includeIds = req.query.include ? new Set(req.query.include.split(',').filter(Boolean)) : new Set();
1162
1165
  const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
1166
+ for (const id of includeIds) pinnedIds.add(id);
1163
1167
  const activeFilter = req.query.filter === 'active';
1164
1168
 
1165
1169
  const metadata = loadSessionMetadata();
@@ -1502,8 +1506,12 @@ app.get('/api/sessions', async (req, res) => {
1502
1506
 
1503
1507
  // Apply project filter before limit so the limit is per-project
1504
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.
1505
1513
  if (projectFilter) {
1506
- sessions = sessions.filter(s => s.project === projectFilter);
1514
+ sessions = sessions.filter(s => s.project === projectFilter || includeIds.has(s.id));
1507
1515
  }
1508
1516
 
1509
1517
  // Apply limit if specified, but always include pinned sessions
@@ -1544,8 +1552,10 @@ app.get('/api/sessions/:sessionId', async (req, res) => {
1544
1552
  try {
1545
1553
  const sessionPath = taskDirFor(req.params.sessionId);
1546
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.
1547
1557
  if (!existsSync(sessionPath)) {
1548
- return res.status(404).json({ error: 'Session not found' });
1558
+ return res.json([]);
1549
1559
  }
1550
1560
 
1551
1561
  const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
@@ -1621,11 +1631,13 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
1621
1631
  try {
1622
1632
  const metadata = loadSessionMetadata();
1623
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.
1624
1636
  const slug = meta?.slug;
1625
- if (!slug) return res.status(404).json({ error: 'No plan found' });
1637
+ if (!slug) return res.json({ content: null });
1626
1638
 
1627
1639
  const planPath = path.join(PLANS_DIR, `${slug}.md`);
1628
- if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
1640
+ if (!existsSync(planPath)) return res.json({ content: null });
1629
1641
 
1630
1642
  const content = await fs.readFile(planPath, 'utf8');
1631
1643
  res.json({ content, slug });
@@ -1652,18 +1664,25 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1652
1664
  }
1653
1665
  });
1654
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
+
1655
1680
  // Cold path — a full transcript scan, so the result is held until the file grows.
1656
1681
  // The zen panel asks again on every re-render, which then costs one stat.
1657
1682
  const artifactsByPath = new Map();
1658
1683
 
1659
1684
  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;
1685
+ return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
1667
1686
  }
1668
1687
 
1669
1688
  app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
@@ -1678,23 +1697,148 @@ app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
1678
1697
  }
1679
1698
  });
1680
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
+
1681
1829
  // API: List workflow scripts for a session. Parses each script's meta for the
1682
1830
  // canonical name + description (cold path — only when the workflow modal opens).
1683
- app.get('/api/sessions/:sessionId/workflows', async (req, res) => {
1831
+ app.get('/api/sessions/:sessionId/workflows', (req, res) => {
1684
1832
  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
- );
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
+ });
1698
1842
  res.json({ workflows });
1699
1843
  } catch (error) {
1700
1844
  console.error('Error listing workflows:', error);
@@ -1728,6 +1872,20 @@ function matchStr(src, key) {
1728
1872
  return m ? m[2] : null;
1729
1873
  }
1730
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
+
1731
1889
  function parseWorkflowMeta(source) {
1732
1890
  const meta = { name: matchStr(source, 'name'), description: matchStr(source, 'description'), phases: [] };
1733
1891
  const phasesM = source.match(/phases\s*:\s*\[([\s\S]*?)\]/);
@@ -1740,26 +1898,61 @@ function parseWorkflowMeta(source) {
1740
1898
  return meta;
1741
1899
  }
1742
1900
 
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.
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
+
1746
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) {
1747
1916
  const status = new Map();
1748
1917
  let content;
1749
- try { content = readFileSync(path.join(runDir, 'journal.jsonl'), 'utf8'); } catch { return status; }
1918
+ try { content = readFileSync(journalPath, 'utf8'); } catch { return status; }
1750
1919
  for (const line of content.split('\n')) {
1751
1920
  if (!line.trim()) continue;
1752
1921
  let o;
1753
1922
  try { o = JSON.parse(line); } catch { continue; }
1754
1923
  if (!o.agentId) continue;
1755
- 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 };
1756
1925
  if (o.type === 'started') e.started = true;
1757
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;
1758
1929
  status.set(o.agentId, e);
1759
1930
  }
1760
1931
  return status;
1761
1932
  }
1762
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
+
1763
1956
  // A workflow's run artifacts (journal + agent transcripts) live at
1764
1957
  // <sessionDir>/subagents/workflows/<wfId>/. The script itself sits under
1765
1958
  // <sessionDir>/workflows/scripts/, so the run dir is derivable from the script
@@ -1767,8 +1960,9 @@ function readWorkflowJournal(runDir) {
1767
1960
  // then to scanning every project/session (the session dir can sit under a
1768
1961
  // different projEnc than the script when a workflow runs from another cwd). wfId
1769
1962
  // comes from the trusted script index (validated filename), so it can't
1770
- // traverse. Cold path only.
1771
- 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) {
1772
1966
  const rel = path.join('subagents', 'workflows', wfId);
1773
1967
  if (scriptPath) {
1774
1968
  const sessionDir = path.dirname(path.dirname(path.dirname(scriptPath)));
@@ -1779,6 +1973,7 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1779
1973
  const local = path.join(sessionDirFromMeta(meta), rel);
1780
1974
  if (existsSync(local)) return local;
1781
1975
  }
1976
+ if (skipScan) return null;
1782
1977
  try {
1783
1978
  for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1784
1979
  if (!proj.isDirectory()) continue;
@@ -1797,15 +1992,13 @@ function resolveWorkflowRunDir(meta, wfId, scriptPath) {
1797
1992
 
1798
1993
  // API: Workflow run state — declared phases (from the script) plus the agent
1799
1994
  // 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.
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.
1802
1997
  app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1803
1998
  try {
1804
1999
  const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
1805
2000
  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);
2001
+ const parsed = getWorkflowMeta(wf.path);
1809
2002
 
1810
2003
  const sessionId = resolveSessionId(req.params.sessionId);
1811
2004
  const meta = loadSessionMetadata()[sessionId] || {};
@@ -1824,17 +2017,19 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1824
2017
  try {
1825
2018
  type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
1826
2019
  } catch (_) {}
1827
- const done = journal.get(agentId)?.done;
2020
+ const entry = journal.get(agentId);
1828
2021
  const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
1829
2022
  if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
1830
2023
  agents.push({
1831
2024
  agentId,
1832
2025
  type,
2026
+ phase: entry?.phase || null,
2027
+ label: entry?.label || null,
1833
2028
  model: stats.model || null,
1834
2029
  outputTokens: stats.outputTokens || 0,
1835
2030
  durationMs,
1836
2031
  startedAt: stats.firstTs || null,
1837
- status: done ? 'done' : 'running',
2032
+ status: entry?.done ? 'done' : 'running',
1838
2033
  });
1839
2034
  }
1840
2035
  agents.sort((a, b) => (a.startedAt || '').localeCompare(b.startedAt || ''));
@@ -1842,12 +2037,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1842
2037
  // Roster is start-ordered, so the earliest start is simply the first agent.
1843
2038
  const startedAt = agents[0]?.startedAt || null;
1844
2039
 
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
- }
2040
+ let { startedCount, doneCount } = summarizeWorkflowJournal(journal);
1851
2041
  if (!startedCount) {
1852
2042
  startedCount = agents.length;
1853
2043
  doneCount = agents.filter((a) => a.status === 'done').length;
@@ -1870,6 +2060,58 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
1870
2060
  }
1871
2061
  });
1872
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
+
1873
2115
  // API: Open a workflow script in VS Code
1874
2116
  app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
1875
2117
  try {
@@ -1945,11 +2187,6 @@ app.post('/api/open-in-editor', (req, res) => {
1945
2187
  }
1946
2188
  });
1947
2189
 
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
2190
  // Resolve a linked path to the pad the viewer wants: `<parent>/<name>` where
1954
2191
  // `<name>/scratchpad.json` exists. Accepts either the manifest or its directory.
1955
2192
  function resolveScratchpad(value) {
@@ -3016,9 +3253,69 @@ app.delete('/api/tasks/:sessionId/:taskId', async (req, res) => {
3016
3253
 
3017
3254
  // #region PREVIEW
3018
3255
  // API: File preview — read file and broadcast to clients
3019
- 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
+
3020
3315
  // Whole files are pushed into a modal, so anything huge freezes the tab regardless of kind.
3021
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;
3022
3319
 
3023
3320
  function previewError(status, message) {
3024
3321
  const err = new Error(message);
@@ -3033,7 +3330,8 @@ async function statFileTarget(absPath) {
3033
3330
  try {
3034
3331
  const stats = await fs.stat(absPath);
3035
3332
  if (!stats.isFile()) throw previewError(400, 'Not a file');
3036
- 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) };
3037
3335
  } catch (e) {
3038
3336
  if (e.status) throw e;
3039
3337
  if (e.code === 'ENOENT') throw previewError(404, 'File not found');
@@ -3042,22 +3340,31 @@ async function statFileTarget(absPath) {
3042
3340
  }
3043
3341
  }
3044
3342
 
3045
- // 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
3046
3351
  // validation (and its status codes) but never the content.
3047
3352
  async function validatePreviewFile(absPath) {
3048
3353
  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
- }
3354
+ if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file');
3355
+ enforcePreviewSize(kind, size);
3056
3356
  return { kind, size };
3057
3357
  }
3058
3358
 
3059
3359
  async function readPreviewFile(absPath) {
3060
- 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 };
3061
3368
  const raw = await fs.readFile(absPath, 'utf8');
3062
3369
  if (kind !== 'html') return { content: raw, kind };
3063
3370
  // The client renders HTML into a `srcdoc` iframe, which has no base URL — sibling
@@ -3090,7 +3397,10 @@ function resolvePreviewPath(rawPath, base) {
3090
3397
  // Every path entering the preview/link surface passes through here, so accepting
3091
3398
  // `file://` once covers /api/preview, /api/document/link and /api/file/resolve.
3092
3399
  const filePath = fileUrlToPath(rawPath);
3093
- 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));
3094
3404
  if (base && typeof base === 'string' && path.isAbsolute(base)) {
3095
3405
  let baseDir = base;
3096
3406
  try {
@@ -3207,27 +3517,51 @@ app.get('/api/session/pins', (_req, res) => {
3207
3517
  });
3208
3518
 
3209
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' });
3210
3522
  try {
3211
- const abs = resolvePreviewPath(req.query.path, req.query.base);
3212
- if (!abs) return res.status(400).json({ error: 'path is required' });
3213
3523
  const { content, kind } = await readPreviewFile(abs);
3214
- res.json({ path: abs, content, kind });
3524
+ res.json({ path: abs, exists: true, content, kind });
3215
3525
  } catch (error) {
3216
- 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);
3217
3533
  res.status(error.status || 500).json({ error: error.message || 'Preview failed' });
3218
3534
  }
3219
3535
  });
3220
3536
 
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.
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.
3224
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' });
3225
3557
  try {
3226
- const abs = resolvePreviewPath(req.query.path, req.query.base);
3227
- if (!abs) return res.status(400).json({ error: 'path is required' });
3228
3558
  // No size cap here — an unpreviewable file of any size is still linkable.
3229
- res.json({ path: abs, ...(await statFileTarget(abs)) });
3559
+ res.json({ path: abs, exists: true, ...(await statFileTarget(abs)) });
3230
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 });
3231
3565
  console.error('Error in GET /api/file/resolve:', error);
3232
3566
  res.status(error.status || 500).json({ error: error.message || 'Failed to resolve file' });
3233
3567
  }