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/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 { spawn, spawnSync } = require('child_process');
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
- const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
201
- encoding: 'utf8', timeout: 500, windowsHide: true
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
- return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
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 = path.join(TEAMS_DIR, teamName, 'config.json');
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
- const compactAnchor = metadata[sid]?.logicalParentUuid;
1255
- if (!compactAnchor) continue;
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.parentSessionId && sessionsMap.has(parent.parentSessionId)) {
1258
- compactSuppressed.add(parent.parentSessionId);
1259
- sessionsMap.get(sid).continuedFromSessionId = parent.parentSessionId;
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 target = folder || CLAUDE_DIR;
1697
- openInEditor(target, ...(file ? [file] : []));
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
- openInEditor(file);
1711
- return res.json({ success: true, path: file });
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 logicalParentUuid (older entries, cold cache).
2095
- // Hot path reads from metadata directly; this never runs from the suppression loop.
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
- function findCompactAnchorUuid(jsonlPath) {
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) return 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
- function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, maxBirthtimeMs) {
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 (best && birthtime >= best.birthtime) continue;
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, birthtime };
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
- return { parentSessionId: best.parentSessionId, parentJsonlPath: best.parentJsonlPath };
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
- const result = { parentSessionId: null, parentJsonlPath: null, isCompact: false };
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
- const compactAnchor = meta.logicalParentUuid || findCompactAnchorUuid(meta.jsonlPath);
2155
- result.isCompact = !!compactAnchor;
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) Object.assign(result, 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
- return { content: await fs.readFile(absPath, 'utf8'), kind };
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 server = app.listen(PORT, () => {
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
- const fallback = app.listen(0, () => {
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
  }