claude-code-kanban 4.16.0 → 4.18.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/lib/contain.js +78 -0
- package/lib/inline-assets.js +238 -0
- package/lib/net-guard.js +267 -0
- package/lib/open-editor.js +267 -0
- package/lib/parsers.js +8 -1
- package/package.json +3 -3
- package/public/app.js +306 -102
- package/public/index.html +71 -38
- package/public/style.css +131 -10
- package/server.js +148 -55
package/server.js
CHANGED
|
@@ -8,7 +8,10 @@ const readline = require('readline');
|
|
|
8
8
|
const chokidar = require('chokidar');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const {
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
|
|
13
|
+
const { createNetGuard } = require('./lib/net-guard');
|
|
14
|
+
const { isContained } = require('./lib/contain');
|
|
12
15
|
|
|
13
16
|
const {
|
|
14
17
|
readRecentMessages: _readRecentMessagesUncached,
|
|
@@ -29,6 +32,7 @@ const {
|
|
|
29
32
|
updateLoopInfo,
|
|
30
33
|
buildLoopInfoFromState
|
|
31
34
|
} = require('./lib/parsers');
|
|
35
|
+
const { inlineHtmlAssets } = require('./lib/inline-assets');
|
|
32
36
|
|
|
33
37
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
34
38
|
const { runInstall, runUninstall } = require("./install");
|
|
@@ -44,6 +48,13 @@ if (require("./cli").runCli(process.argv)) return;
|
|
|
44
48
|
const app = express();
|
|
45
49
|
const PORT = process.env.PORT || 3541;
|
|
46
50
|
|
|
51
|
+
// Mounted before express.json() so a rejected request never buffers a body, and
|
|
52
|
+
// before the /api catch-all so no route escapes the check.
|
|
53
|
+
const net = createNetGuard({ appName: 'Claude Task Kanban' });
|
|
54
|
+
app.use(net.hostGuard);
|
|
55
|
+
app.use(net.frameGuard);
|
|
56
|
+
app.use(net.originGuard);
|
|
57
|
+
|
|
47
58
|
// Parse --dir flag for custom Claude directory
|
|
48
59
|
function getClaudeDir() {
|
|
49
60
|
const dirIndex = process.argv.findIndex(arg => arg.startsWith('--dir'));
|
|
@@ -197,8 +208,10 @@ function getGitBranch(cwd) {
|
|
|
197
208
|
if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
|
|
198
209
|
let branch = null;
|
|
199
210
|
try {
|
|
200
|
-
|
|
201
|
-
|
|
211
|
+
// cwd rather than `-C <cwd>`: a path beginning with a dash would otherwise be
|
|
212
|
+
// read by git as an option.
|
|
213
|
+
const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
214
|
+
cwd, encoding: 'utf8', timeout: 500, windowsHide: true
|
|
202
215
|
});
|
|
203
216
|
if (r.status === 0) {
|
|
204
217
|
const out = (r.stdout || '').trim();
|
|
@@ -253,8 +266,28 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
|
|
|
253
266
|
return result;
|
|
254
267
|
}
|
|
255
268
|
|
|
269
|
+
// isContained canonicalises both sides with realpath, which costs ~100x a plain
|
|
270
|
+
// path.join. This is called once per session on the /api/sessions hot path, and
|
|
271
|
+
// the verdict for a given name cannot change while TEAMS_DIR is fixed — so the
|
|
272
|
+
// resolved path (or the null rejection) is memoized.
|
|
273
|
+
const teamPathCache = new Map();
|
|
274
|
+
const TEAM_PATH_CACHE_MAX = 1000;
|
|
275
|
+
|
|
276
|
+
function teamConfigPath(teamName) {
|
|
277
|
+
if (typeof teamName !== 'string' || !teamName) return null;
|
|
278
|
+
if (teamPathCache.has(teamName)) return teamPathCache.get(teamName);
|
|
279
|
+
const candidate = path.join(TEAMS_DIR, teamName, 'config.json');
|
|
280
|
+
// TEAMS_DIR follows --dir / CLAUDE_CONFIG_DIR, so containment is checked against
|
|
281
|
+
// the module-level constant rather than a hardcoded ~/.claude/teams.
|
|
282
|
+
const configPath = isContained(candidate, TEAMS_DIR) ? candidate : null;
|
|
283
|
+
if (teamPathCache.size >= TEAM_PATH_CACHE_MAX) teamPathCache.clear();
|
|
284
|
+
teamPathCache.set(teamName, configPath);
|
|
285
|
+
return configPath;
|
|
286
|
+
}
|
|
287
|
+
|
|
256
288
|
function isTeamSession(sessionId) {
|
|
257
|
-
|
|
289
|
+
const configPath = teamConfigPath(sessionId);
|
|
290
|
+
return !!configPath && existsSync(configPath);
|
|
258
291
|
}
|
|
259
292
|
|
|
260
293
|
const teamConfigCache = new Map();
|
|
@@ -264,8 +297,8 @@ function loadTeamConfig(teamName) {
|
|
|
264
297
|
const cached = teamConfigCache.get(teamName);
|
|
265
298
|
if (cached && Date.now() - cached.ts < TEAM_CACHE_TTL) return cached.data;
|
|
266
299
|
try {
|
|
267
|
-
const configPath =
|
|
268
|
-
if (!existsSync(configPath)) return null;
|
|
300
|
+
const configPath = teamConfigPath(teamName);
|
|
301
|
+
if (!configPath || !existsSync(configPath)) return null;
|
|
269
302
|
const data = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
270
303
|
teamConfigCache.set(teamName, { data, ts: Date.now() });
|
|
271
304
|
return data;
|
|
@@ -416,6 +449,12 @@ app.param('taskId', (req, res, next, val) => {
|
|
|
416
449
|
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid task ID' });
|
|
417
450
|
next();
|
|
418
451
|
});
|
|
452
|
+
// Team names are directory names under TEAMS_DIR, so /api/teams/:name was a
|
|
453
|
+
// straight traversal before this.
|
|
454
|
+
app.param('name', (req, res, next, val) => {
|
|
455
|
+
if (!isSafeId(val)) return res.status(400).json({ error: 'Invalid team name' });
|
|
456
|
+
next();
|
|
457
|
+
});
|
|
419
458
|
|
|
420
459
|
// Parse JSON bodies
|
|
421
460
|
app.use(express.json());
|
|
@@ -633,6 +672,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
633
672
|
// Direct assign (not guarded) so a /goal clear propagates as null.
|
|
634
673
|
existing.goal = info.goal || null;
|
|
635
674
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
675
|
+
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
636
676
|
return true;
|
|
637
677
|
}
|
|
638
678
|
|
|
@@ -719,7 +759,8 @@ function loadSessionMetadata() {
|
|
|
719
759
|
customTitle: sessionInfo.customTitle || null,
|
|
720
760
|
goal: sessionInfo.goal || null,
|
|
721
761
|
jsonlPath: jsonlPath,
|
|
722
|
-
logicalParentUuid: sessionInfo.logicalParentUuid || null
|
|
762
|
+
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
763
|
+
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
|
|
723
764
|
};
|
|
724
765
|
sessionIds.push(sessionId);
|
|
725
766
|
}
|
|
@@ -1249,15 +1290,22 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1249
1290
|
|
|
1250
1291
|
// Suppress parent sessions that have a compact continuation — compaction is involuntary
|
|
1251
1292
|
// (context limit hit), not an intentional fork. Only the continuation is shown.
|
|
1293
|
+
// A fork off an already-compacted transcript looks identical from the metadata alone
|
|
1294
|
+
// (its copied compact_boundary gives the child a logicalParentUuid), so the verdict
|
|
1295
|
+
// comes from lookupParentSession, not from the anchor's presence.
|
|
1252
1296
|
const compactSuppressed = new Set();
|
|
1253
1297
|
for (const [sid] of sessionsMap) {
|
|
1254
|
-
|
|
1255
|
-
|
|
1298
|
+
// Cheap pre-gate: only a session with a boundary anchor can be a continuation,
|
|
1299
|
+
// and lookupParentSession reads JSONLs on a cold cache.
|
|
1300
|
+
if (!metadata[sid]?.logicalParentUuid) continue;
|
|
1256
1301
|
const parent = lookupParentSession(sid);
|
|
1257
|
-
if (parent.
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1302
|
+
if (parent.relation !== 'compact' || !sessionsMap.has(parent.parentSessionId)) continue;
|
|
1303
|
+
compactSuppressed.add(parent.parentSessionId);
|
|
1304
|
+
sessionsMap.get(sid).continuedFromSessionId = parent.parentSessionId;
|
|
1305
|
+
}
|
|
1306
|
+
// Invariant: a running process is never a superseded lineage, whatever the heuristics say.
|
|
1307
|
+
if (compactSuppressed.size) {
|
|
1308
|
+
for (const s of loadLiveSessions()) compactSuppressed.delete(s.sessionId);
|
|
1261
1309
|
}
|
|
1262
1310
|
for (const sid of compactSuppressed) sessionsMap.delete(sid);
|
|
1263
1311
|
|
|
@@ -1652,17 +1700,12 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
|
|
|
1652
1700
|
}
|
|
1653
1701
|
});
|
|
1654
1702
|
|
|
1655
|
-
function openInEditor(...targets) {
|
|
1656
|
-
const editor = process.env.EDITOR || 'code';
|
|
1657
|
-
spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
|
|
1658
|
-
}
|
|
1659
|
-
|
|
1660
1703
|
// API: Open a workflow script in VS Code
|
|
1661
1704
|
app.post('/api/sessions/:sessionId/workflows/:wfId/open', (req, res) => {
|
|
1662
1705
|
try {
|
|
1663
1706
|
const wf = getWorkflowScripts(req.params.sessionId).find((w) => w.id === req.params.wfId);
|
|
1664
1707
|
if (!wf) return res.status(404).json({ error: 'Workflow script not found' });
|
|
1665
|
-
openInEditor(wf.path);
|
|
1708
|
+
openInEditor([wf.path]);
|
|
1666
1709
|
res.json({ success: true });
|
|
1667
1710
|
} catch (error) {
|
|
1668
1711
|
console.error('Error opening workflow script in editor:', error);
|
|
@@ -1681,7 +1724,7 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
|
1681
1724
|
const planPath = path.join(PLANS_DIR, `${slug}.md`);
|
|
1682
1725
|
if (!existsSync(planPath)) return res.status(404).json({ error: 'No plan found' });
|
|
1683
1726
|
|
|
1684
|
-
openInEditor(planPath);
|
|
1727
|
+
openInEditor([planPath]);
|
|
1685
1728
|
res.json({ success: true });
|
|
1686
1729
|
} catch (error) {
|
|
1687
1730
|
console.error('Error opening plan in editor:', error);
|
|
@@ -1693,10 +1736,12 @@ app.post('/api/sessions/:sessionId/plan/open', (req, res) => {
|
|
|
1693
1736
|
app.post('/api/open-folder', (req, res) => {
|
|
1694
1737
|
try {
|
|
1695
1738
|
const { folder, file } = req.body;
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1739
|
+
const targets = [folder ? assertOpenTarget(folder, 'folder') : CLAUDE_DIR];
|
|
1740
|
+
if (file) targets.push(assertOpenTarget(file, 'file'));
|
|
1741
|
+
openInEditor(targets);
|
|
1698
1742
|
res.json({ success: true });
|
|
1699
1743
|
} catch (error) {
|
|
1744
|
+
if (error.status) return res.status(error.status).json({ error: error.message });
|
|
1700
1745
|
console.error('Error opening folder:', error);
|
|
1701
1746
|
res.status(500).json({ error: 'Failed to open folder' });
|
|
1702
1747
|
}
|
|
@@ -1707,8 +1752,9 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1707
1752
|
try {
|
|
1708
1753
|
const { content, title, file } = req.body;
|
|
1709
1754
|
if (file) {
|
|
1710
|
-
|
|
1711
|
-
|
|
1755
|
+
const resolved = assertOpenTarget(file, 'file');
|
|
1756
|
+
openInEditor([resolved]);
|
|
1757
|
+
return res.json({ success: true, path: resolved });
|
|
1712
1758
|
}
|
|
1713
1759
|
if (!content) return res.status(400).json({ error: 'No content provided' });
|
|
1714
1760
|
|
|
@@ -1717,9 +1763,10 @@ app.post('/api/open-in-editor', (req, res) => {
|
|
|
1717
1763
|
const tmpFile = path.join(os.tmpdir(), `claude-kanban-${safeName}-${hash}.md`);
|
|
1718
1764
|
require('fs').writeFileSync(tmpFile, content, 'utf8');
|
|
1719
1765
|
|
|
1720
|
-
openInEditor(tmpFile);
|
|
1766
|
+
openInEditor([tmpFile]);
|
|
1721
1767
|
res.json({ success: true, path: tmpFile });
|
|
1722
1768
|
} catch (error) {
|
|
1769
|
+
if (error.status) return res.status(error.status).json({ error: error.message });
|
|
1723
1770
|
console.error('Error opening in editor:', error);
|
|
1724
1771
|
res.status(500).json({ error: 'Failed to open in editor' });
|
|
1725
1772
|
}
|
|
@@ -2091,12 +2138,14 @@ function findForkAnchorUuid(jsonlPath) {
|
|
|
2091
2138
|
}
|
|
2092
2139
|
return firstUuid;
|
|
2093
2140
|
}
|
|
2094
|
-
// Fallback when metadata cache lacks
|
|
2095
|
-
//
|
|
2141
|
+
// Fallback when the metadata cache lacks the boundary pair (older entries, cold cache).
|
|
2142
|
+
// Reached only from lookupParentSession, whose result is memoized for the process life —
|
|
2143
|
+
// at most one read per session, never per request.
|
|
2096
2144
|
// Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
|
|
2097
2145
|
// always sits in the preamble before the first user/assistant record.
|
|
2098
2146
|
const COMPACT_ANCHOR_READ_MAX = 1048576;
|
|
2099
|
-
|
|
2147
|
+
// Returns { anchor, boundaryUuid } for the preamble compact_boundary, or null.
|
|
2148
|
+
function findCompactBoundary(jsonlPath) {
|
|
2100
2149
|
let fd;
|
|
2101
2150
|
try {
|
|
2102
2151
|
fd = openSync(jsonlPath, 'r');
|
|
@@ -2110,17 +2159,23 @@ function findCompactAnchorUuid(jsonlPath) {
|
|
|
2110
2159
|
try {
|
|
2111
2160
|
const d = JSON.parse(l);
|
|
2112
2161
|
if (d.type === 'user' || d.type === 'assistant') return null;
|
|
2113
|
-
if (d.subtype === 'compact_boundary' && d.logicalParentUuid)
|
|
2162
|
+
if (d.subtype === 'compact_boundary' && d.logicalParentUuid) {
|
|
2163
|
+
return { anchor: d.logicalParentUuid, boundaryUuid: d.uuid || null };
|
|
2164
|
+
}
|
|
2114
2165
|
} catch { /* skip malformed */ }
|
|
2115
2166
|
}
|
|
2116
2167
|
return null;
|
|
2117
2168
|
} catch { return null; }
|
|
2118
2169
|
finally { if (fd !== undefined) { try { closeSync(fd); } catch {} } }
|
|
2119
2170
|
}
|
|
2120
|
-
|
|
2171
|
+
// forkProbeUuid (optional): the child's own compact_boundary uuid. It only shows up
|
|
2172
|
+
// in the parent when the child copied the record — i.e. a fork off an already-compacted
|
|
2173
|
+
// transcript, not a compact continuation. Probed once, on the winner's text, which the
|
|
2174
|
+
// scan already read.
|
|
2175
|
+
function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, maxBirthtimeMs, forkProbeUuid) {
|
|
2121
2176
|
let files;
|
|
2122
2177
|
try { files = readdirSync(projectDir); } catch { return null; }
|
|
2123
|
-
let best = null;
|
|
2178
|
+
let best = null, bestBirthtime = Infinity, bestText = null;
|
|
2124
2179
|
for (const f of files) {
|
|
2125
2180
|
if (!f.endsWith('.jsonl')) continue;
|
|
2126
2181
|
const fp = path.join(projectDir, f);
|
|
@@ -2128,7 +2183,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2128
2183
|
let birthtime = 0;
|
|
2129
2184
|
try { birthtime = statSync(fp).birthtimeMs; } catch { continue; }
|
|
2130
2185
|
if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
|
|
2131
|
-
if (
|
|
2186
|
+
if (birthtime >= bestBirthtime) continue;
|
|
2132
2187
|
let text;
|
|
2133
2188
|
try { text = readFileSync(fp, 'utf8'); } catch { continue; }
|
|
2134
2189
|
if (!text.includes(targetUuid)) continue;
|
|
@@ -2137,32 +2192,51 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
|
|
|
2137
2192
|
try {
|
|
2138
2193
|
const d = JSON.parse(l);
|
|
2139
2194
|
if (d.uuid === targetUuid && d.sessionId) {
|
|
2140
|
-
best = { parentSessionId: d.sessionId, parentJsonlPath: fp,
|
|
2195
|
+
best = { parentSessionId: d.sessionId, parentJsonlPath: fp, isFork: false };
|
|
2196
|
+
bestBirthtime = birthtime;
|
|
2197
|
+
bestText = text;
|
|
2141
2198
|
break;
|
|
2142
2199
|
}
|
|
2143
2200
|
} catch { /* skip */ }
|
|
2144
2201
|
}
|
|
2145
2202
|
}
|
|
2146
2203
|
if (!best) return null;
|
|
2147
|
-
|
|
2204
|
+
if (forkProbeUuid) best.isFork = bestText.includes(forkProbeUuid);
|
|
2205
|
+
return best;
|
|
2148
2206
|
}
|
|
2149
2207
|
function lookupParentSession(sessionId) {
|
|
2150
2208
|
if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
|
|
2151
2209
|
const meta = loadSessionMetadata()[sessionId];
|
|
2152
|
-
|
|
2210
|
+
// relation is the single verdict: 'none' (no parent found), 'compact' (this session
|
|
2211
|
+
// continues a lineage the parent could not hold) or 'fork' (an intentional branch off
|
|
2212
|
+
// a parent that keeps its own life). isCompact/isFork are derived views of it.
|
|
2213
|
+
const result = { parentSessionId: null, parentJsonlPath: null, relation: 'none', isCompact: false, isFork: false };
|
|
2153
2214
|
if (meta?.jsonlPath) {
|
|
2154
|
-
|
|
2155
|
-
|
|
2215
|
+
// Metadata entries cached before compactBoundaryUuid existed carry the anchor
|
|
2216
|
+
// without the boundary uuid — re-read rather than lose fork detection.
|
|
2217
|
+
const boundary = (meta.logicalParentUuid && meta.compactBoundaryUuid)
|
|
2218
|
+
? { anchor: meta.logicalParentUuid, boundaryUuid: meta.compactBoundaryUuid }
|
|
2219
|
+
: findCompactBoundary(meta.jsonlPath);
|
|
2220
|
+
const compactAnchor = boundary?.anchor ?? meta.logicalParentUuid ?? null;
|
|
2221
|
+
// A fork copies the parent's early records verbatim, so its first uuid is the anchor.
|
|
2156
2222
|
const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
|
|
2157
2223
|
if (anchorUuid) {
|
|
2158
2224
|
let selfBirthtime;
|
|
2159
2225
|
try { selfBirthtime = statSync(meta.jsonlPath).birthtimeMs; } catch { /* ignore */ }
|
|
2160
2226
|
if (selfBirthtime != null) {
|
|
2161
|
-
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime);
|
|
2162
|
-
if (hit)
|
|
2227
|
+
const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime, boundary?.boundaryUuid);
|
|
2228
|
+
if (hit) {
|
|
2229
|
+
// No compact boundary at all → fork by construction; with one, the copied
|
|
2230
|
+
// boundary uuid (hit.isFork) is what separates a fork from a continuation.
|
|
2231
|
+
result.parentSessionId = hit.parentSessionId;
|
|
2232
|
+
result.parentJsonlPath = hit.parentJsonlPath;
|
|
2233
|
+
result.relation = (!compactAnchor || hit.isFork) ? 'fork' : 'compact';
|
|
2234
|
+
}
|
|
2163
2235
|
}
|
|
2164
2236
|
}
|
|
2165
2237
|
}
|
|
2238
|
+
result.isCompact = result.relation === 'compact';
|
|
2239
|
+
result.isFork = result.relation === 'fork';
|
|
2166
2240
|
parentSessionCache.set(sessionId, result);
|
|
2167
2241
|
return result;
|
|
2168
2242
|
}
|
|
@@ -2656,12 +2730,36 @@ async function validatePreviewFile(absPath) {
|
|
|
2656
2730
|
|
|
2657
2731
|
async function readPreviewFile(absPath) {
|
|
2658
2732
|
const { kind } = await validatePreviewFile(absPath);
|
|
2659
|
-
|
|
2733
|
+
const raw = await fs.readFile(absPath, 'utf8');
|
|
2734
|
+
if (kind !== 'html') return { content: raw, kind };
|
|
2735
|
+
// The client renders HTML into a `srcdoc` iframe, which has no base URL — sibling
|
|
2736
|
+
// assets have to travel inside the document or they never load.
|
|
2737
|
+
try {
|
|
2738
|
+
const { html, skipped } = await inlineHtmlAssets(raw, absPath);
|
|
2739
|
+
for (const s of skipped) {
|
|
2740
|
+
console.warn(`Preview: skipped inlining ${s.path} (${s.reason}, ${s.size} bytes)`);
|
|
2741
|
+
}
|
|
2742
|
+
return { content: html, kind };
|
|
2743
|
+
} catch (e) {
|
|
2744
|
+
// Inlining is an enhancement: a failure here must not cost the user the preview.
|
|
2745
|
+
console.error('Preview: asset inlining failed, serving as authored:', e.message);
|
|
2746
|
+
return { content: raw, kind };
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
// Paths copied out of a browser or a slide deck carry a URL fragment
|
|
2751
|
+
// ("deck.html#10"). Dropped only when the literal path is missing and the trimmed one
|
|
2752
|
+
// exists, so a filename that genuinely contains '#' still resolves to itself.
|
|
2753
|
+
function dropUrlFragment(abs) {
|
|
2754
|
+
const i = abs.lastIndexOf('#');
|
|
2755
|
+
if (i <= 0 || existsSync(abs)) return abs;
|
|
2756
|
+
const trimmed = abs.slice(0, i);
|
|
2757
|
+
return existsSync(trimmed) ? trimmed : abs;
|
|
2660
2758
|
}
|
|
2661
2759
|
|
|
2662
2760
|
function resolvePreviewPath(filePath, base) {
|
|
2663
2761
|
if (!filePath || typeof filePath !== 'string') return null;
|
|
2664
|
-
if (path.isAbsolute(filePath)) return filePath;
|
|
2762
|
+
if (path.isAbsolute(filePath)) return dropUrlFragment(filePath);
|
|
2665
2763
|
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
2666
2764
|
let baseDir = base;
|
|
2667
2765
|
try {
|
|
@@ -2670,9 +2768,9 @@ function resolvePreviewPath(filePath, base) {
|
|
|
2670
2768
|
// base doesn't exist — fall back to dirname if it looks like a file
|
|
2671
2769
|
if (path.extname(base)) baseDir = path.dirname(base);
|
|
2672
2770
|
}
|
|
2673
|
-
return path.resolve(baseDir, filePath);
|
|
2771
|
+
return dropUrlFragment(path.resolve(baseDir, filePath));
|
|
2674
2772
|
}
|
|
2675
|
-
return path.resolve(filePath);
|
|
2773
|
+
return dropUrlFragment(path.resolve(filePath));
|
|
2676
2774
|
}
|
|
2677
2775
|
|
|
2678
2776
|
app.post('/api/preview', async (req, res) => {
|
|
@@ -3102,28 +3200,23 @@ async function prewarmCaches() {
|
|
|
3102
3200
|
}
|
|
3103
3201
|
}
|
|
3104
3202
|
|
|
3105
|
-
const
|
|
3106
|
-
const actualPort = server.address().port;
|
|
3203
|
+
const onReady = (actualPort) => {
|
|
3107
3204
|
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
3205
|
+
const warning = net.exposureWarning();
|
|
3206
|
+
if (warning) console.log(warning);
|
|
3108
3207
|
|
|
3109
3208
|
if (process.argv.includes('--open')) {
|
|
3110
3209
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
3111
3210
|
}
|
|
3112
3211
|
setImmediate(prewarmCaches);
|
|
3113
|
-
}
|
|
3212
|
+
};
|
|
3213
|
+
|
|
3214
|
+
const server = net.listenLoopback(app, PORT, onReady);
|
|
3114
3215
|
|
|
3115
3216
|
server.on('error', (err) => {
|
|
3116
3217
|
if (err.code === 'EADDRINUSE') {
|
|
3117
3218
|
console.log(`Port ${PORT} in use, trying random port...`);
|
|
3118
|
-
|
|
3119
|
-
const actualPort = fallback.address().port;
|
|
3120
|
-
console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
|
|
3121
|
-
|
|
3122
|
-
if (process.argv.includes('--open')) {
|
|
3123
|
-
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|
|
3124
|
-
}
|
|
3125
|
-
setImmediate(prewarmCaches);
|
|
3126
|
-
});
|
|
3219
|
+
net.listenLoopback(app, 0, onReady);
|
|
3127
3220
|
} else {
|
|
3128
3221
|
throw err;
|
|
3129
3222
|
}
|