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