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/README.md +1 -1
- package/install.js +16 -1
- package/lib/approvals.js +34 -3
- package/lib/inline-assets.js +1 -1
- package/lib/parsers.js +177 -0
- package/lib/scratch-files.js +47 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +53 -18
- package/public/app.js +667 -79
- package/public/index.html +19 -0
- package/public/style.css +355 -18
- package/server.js +527 -89
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,
|
|
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).
|
|
109
|
-
// so a reader can tell a live server from a file left behind by
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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',
|
|
1831
|
+
app.get('/api/sessions/:sessionId/workflows', (req, res) => {
|
|
1580
1832
|
try {
|
|
1581
|
-
const
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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
|
|
1640
|
-
// per workflow agent. Map agentId →
|
|
1641
|
-
// "N/M agents" ratio the
|
|
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(
|
|
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.
|
|
1667
|
-
|
|
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.
|
|
1697
|
-
//
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
2165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '
|
|
2947
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
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
|
|