claude-code-kanban 4.0.0 → 4.2.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,7 @@ 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 } = require('child_process');
11
+ const { spawn, spawnSync } = require('child_process');
12
12
 
13
13
  const {
14
14
  readRecentMessages: _readRecentMessagesUncached,
@@ -20,7 +20,10 @@ const {
20
20
  findTerminatedTeammates,
21
21
  extractPromptFromTranscript,
22
22
  extractModelFromTranscript,
23
- readFullToolResult
23
+ readFullToolResult,
24
+ readUserImage,
25
+ updateLoopInfo,
26
+ buildLoopInfoFromState
24
27
  } = require('./lib/parsers');
25
28
 
26
29
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -95,12 +98,16 @@ function writePins(pins) {
95
98
  }
96
99
  }
97
100
 
98
- const PERMISSION_TTL_MS = 1800000;
99
- const AGENT_TTL_MS = 3600000;
100
- const AGENT_STALE_MS = 900000;
101
- const SESSION_STALE_MS = 300000;
102
-
103
- const WAITING_RESOLVE_GRACE_MS = 15000;
101
+ // #region TIMINGS
102
+ const PERMISSION_TTL_MS = 30 * 60 * 1000;
103
+ const AGENT_TTL_MS = 60 * 60 * 1000;
104
+ const AGENT_STALE_MS = 30 * 60 * 1000; // safety net for crashed sessions
105
+ const SESSION_STALE_MS = 5 * 60 * 1000;
106
+ const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
107
+ const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
108
+ const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
109
+ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
110
+ // #endregion
104
111
 
105
112
  function readAgentJsonl(filePath) {
106
113
  const raw = readFileSync(filePath, 'utf8');
@@ -152,6 +159,45 @@ function isAgentFresh(agent) {
152
159
  return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
153
160
  }
154
161
 
162
+ // Claude Code records gitBranch from the launch-time repo and never updates it
163
+ // when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
164
+ // the live cwd instead. Cached per-cwd with a short TTL so a list refresh
165
+ // across N sessions sharing one cwd spawns git at most once per TTL window.
166
+ const gitBranchCache = new Map();
167
+ const GIT_BRANCH_TTL_MS = 30000;
168
+ const GIT_BRANCH_CACHE_MAX = 500;
169
+ function getGitBranch(cwd) {
170
+ if (!cwd) return null;
171
+ const now = Date.now();
172
+ const cached = gitBranchCache.get(cwd);
173
+ if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
174
+ let branch = null;
175
+ try {
176
+ const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
177
+ encoding: 'utf8', timeout: 500, windowsHide: true
178
+ });
179
+ if (r.status === 0) {
180
+ const out = (r.stdout || '').trim();
181
+ if (out && out !== 'HEAD') branch = out;
182
+ }
183
+ } catch (_) {}
184
+ gitBranchCache.set(cwd, { branch, ts: now });
185
+ if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
186
+ const firstKey = gitBranchCache.keys().next().value;
187
+ gitBranchCache.delete(firstKey);
188
+ }
189
+ return branch;
190
+ }
191
+
192
+ // Only spawn git when cwd has diverged from the launch project — that's the
193
+ // only case the JSONL value is wrong. Saves N spawns on a typical list build.
194
+ function resolveSessionGitBranch(meta) {
195
+ if (meta.cwd && meta.project && meta.cwd !== meta.project) {
196
+ return getGitBranch(meta.cwd) || meta.gitBranch || null;
197
+ }
198
+ return meta.gitBranch || null;
199
+ }
200
+
155
201
  function getSessionLogStat(meta) {
156
202
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
157
203
  try {
@@ -216,6 +262,11 @@ const clients = new Set();
216
262
  let sessionMetadataCache = {};
217
263
  let lastMetadataRefresh = 0;
218
264
  const METADATA_CACHE_TTL = 10000; // 10 seconds
265
+ // Watcher-driven invalidation. `change` events (append to existing jsonl) only
266
+ // dirty the one path so we can do a targeted refresh; `add` / `unlink` events
267
+ // are structural and force a full rescan.
268
+ const dirtyMetadataPaths = new Set();
269
+ let metadataNeedsFullScan = true;
219
270
 
220
271
  const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
221
272
  function isSafeId(id) {
@@ -381,10 +432,50 @@ function readRecentMessages(jsonlPath, limit = 10) {
381
432
  /**
382
433
  * Scan all project directories to find session JSONL files and extract slugs
383
434
  */
435
+ // Returns false when sessionId is unknown — caller must promote to full scan.
436
+ function refreshSessionMetadataPath(jsonlPath) {
437
+ const sessionId = path.basename(jsonlPath, '.jsonl');
438
+ if (!isSafeId(sessionId)) return false;
439
+ const existing = sessionMetadataCache[sessionId];
440
+ if (!existing) return false;
441
+ let info;
442
+ try {
443
+ info = readSessionInfoFromJsonl(jsonlPath);
444
+ } catch (_) {
445
+ return false;
446
+ }
447
+ // Shadow JSONLs (continued from a worktree) hold only custom-title / agent-
448
+ // name records — no projectPath. Don't let a shadow clobber the real entry.
449
+ const shadow = existing.project && !info.projectPath;
450
+ if (shadow) {
451
+ if (!existing.slug && info.slug) existing.slug = info.slug;
452
+ if (!existing.customTitle && info.customTitle) existing.customTitle = info.customTitle;
453
+ return true;
454
+ }
455
+ if (info.slug) existing.slug = info.slug;
456
+ if (info.cwd) existing.cwd = info.cwd;
457
+ if (info.gitBranch) existing.gitBranch = info.gitBranch;
458
+ if (info.customTitle) existing.customTitle = info.customTitle;
459
+ return true;
460
+ }
461
+
384
462
  function loadSessionMetadata() {
385
463
  const now = Date.now();
386
- if (now - lastMetadataRefresh < METADATA_CACHE_TTL) {
387
- return sessionMetadataCache;
464
+
465
+ if (!metadataNeedsFullScan && now - lastMetadataRefresh < METADATA_CACHE_TTL) {
466
+ if (dirtyMetadataPaths.size > 0) {
467
+ for (const p of dirtyMetadataPaths) {
468
+ if (!refreshSessionMetadataPath(p)) {
469
+ // Unknown sessionId — structural change snuck in. Promote to full.
470
+ metadataNeedsFullScan = true;
471
+ break;
472
+ }
473
+ }
474
+ dirtyMetadataPaths.clear();
475
+ if (!metadataNeedsFullScan) return sessionMetadataCache;
476
+ } else {
477
+ return sessionMetadataCache;
478
+ }
388
479
  }
389
480
 
390
481
  const metadata = {};
@@ -515,6 +606,8 @@ function loadSessionMetadata() {
515
606
 
516
607
  sessionMetadataCache = metadata;
517
608
  lastMetadataRefresh = now;
609
+ metadataNeedsFullScan = false;
610
+ dirtyMetadataPaths.clear();
518
611
  return metadata;
519
612
  }
520
613
 
@@ -531,6 +624,51 @@ function getPlanInfo(slug) {
531
624
  }
532
625
  }
533
626
 
627
+ // Hide wakeups whose fire time is more than this far in the past — long /loop
628
+ // sessions otherwise produce dozens of stale entries that drown the badge.
629
+ const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
630
+
631
+ function isWakeupActive(w, now = Date.now()) {
632
+ if (!w || !w.timestamp || w.delaySeconds == null) return true;
633
+ const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
634
+ return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
635
+ }
636
+
637
+ function filterActiveLoopInfo(info) {
638
+ const now = Date.now();
639
+ return {
640
+ wakeups: info.wakeups.filter(w => isWakeupActive(w, now)),
641
+ crons: info.crons
642
+ };
643
+ }
644
+
645
+ // Per-path incremental scan state. Populated lazily on first access and
646
+ // updated in place; the projectsWatcher event handler keeps entries warm so
647
+ // the request path does O(1) work in steady state.
648
+ const loopInfoStateByPath = new Map();
649
+
650
+ function refreshLoopInfoState(jsonlPath) {
651
+ if (!jsonlPath) return null;
652
+ const prev = loopInfoStateByPath.get(jsonlPath);
653
+ const next = updateLoopInfo(jsonlPath, prev);
654
+ if (next) loopInfoStateByPath.set(jsonlPath, next);
655
+ return next;
656
+ }
657
+
658
+ function getLoopInfoSummary(meta) {
659
+ const empty = { wakeupCount: 0, cronCount: 0, latest: null };
660
+ if (!meta?.jsonlPath) return empty;
661
+ try {
662
+ const state = refreshLoopInfoState(meta.jsonlPath);
663
+ const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
664
+ return {
665
+ wakeupCount: filtered.wakeups.length,
666
+ cronCount: filtered.crons.length,
667
+ latest: filtered.wakeups[filtered.wakeups.length - 1] || filtered.crons[filtered.crons.length - 1] || null
668
+ };
669
+ } catch (_) { return empty; }
670
+ }
671
+
534
672
  function getSessionDisplayName(sessionId, meta) {
535
673
  if (meta?.customTitle) return meta.customTitle;
536
674
  if (meta?.slug) return meta.slug;
@@ -548,7 +686,7 @@ function buildSessionObject(id, meta, overrides = {}) {
548
686
  project: meta.project || null,
549
687
  cwd: meta.cwd || null,
550
688
  description: meta.description || null,
551
- gitBranch: meta.gitBranch || null,
689
+ gitBranch: resolveSessionGitBranch(meta),
552
690
  customTitle: meta.customTitle || null,
553
691
  taskCount: 0,
554
692
  completed: 0,
@@ -568,6 +706,7 @@ function buildSessionObject(id, meta, overrides = {}) {
568
706
  projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
569
707
  contextStatus: getContextStatus(id, meta),
570
708
  ...getPlanInfo(meta.slug),
709
+ loopInfo: getLoopInfoSummary(meta),
571
710
  ...overrides,
572
711
  // Remove internal-only field
573
712
  _logStat: undefined,
@@ -969,6 +1108,23 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
969
1108
  }
970
1109
  });
971
1110
 
1111
+ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1112
+ try {
1113
+ const metadata = loadSessionMetadata();
1114
+ const meta = metadata[req.params.sessionId];
1115
+ if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
1116
+ const state = refreshLoopInfoState(meta.jsonlPath);
1117
+ const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
1118
+ res.json({
1119
+ wakeups: [...filtered.wakeups].reverse(),
1120
+ crons: [...filtered.crons].reverse()
1121
+ });
1122
+ } catch (error) {
1123
+ console.error('Error reading loop info:', error);
1124
+ res.status(500).json({ error: 'Failed to read loop info' });
1125
+ }
1126
+ });
1127
+
972
1128
  function openInEditor(...targets) {
973
1129
  const editor = process.env.EDITOR || 'code';
974
1130
  spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
@@ -1218,6 +1374,20 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1218
1374
  }
1219
1375
  });
1220
1376
 
1377
+ function clearWaitingFile(sessionId) {
1378
+ try { unlinkSync(path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json')); }
1379
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
1380
+ }
1381
+
1382
+ app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
1383
+ try {
1384
+ clearWaitingFile(resolveSessionId(req.params.sessionId));
1385
+ res.json({ ok: true });
1386
+ } catch (e) {
1387
+ res.status(500).json({ error: 'Failed to discard waiting' });
1388
+ }
1389
+ });
1390
+
1221
1391
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
1222
1392
  const sessionId = resolveSessionId(req.params.sessionId);
1223
1393
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -1229,9 +1399,7 @@ app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
1229
1399
  agent.stoppedAt = new Date().toISOString();
1230
1400
  const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
1231
1401
  writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt) + '\n', 'utf8'); // sync — response depends on write
1232
- // Also remove waiting state if present
1233
- const waitingFile = path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json');
1234
- if (existsSync(waitingFile)) unlinkSync(waitingFile);
1402
+ clearWaitingFile(sessionId);
1235
1403
  res.json({ ok: true });
1236
1404
  } catch (e) {
1237
1405
  res.status(500).json({ error: 'Failed to stop agent' });
@@ -1252,30 +1420,122 @@ function subagentJsonlPath(meta, agentId) {
1252
1420
  }
1253
1421
 
1254
1422
  // Claude Code can scatter a session's records across multiple project dirs
1255
- // (e.g. main repo + worktree), so the subagent JSONL may live under a
1256
- // different project dir than meta.jsonlPath. Fall back to scanning when the
1257
- // derived path is missing.
1423
+ // (e.g. main repo + worktree) and across sibling sessionId dirs when a
1424
+ // session is forked/resumed the subagent JSONL stays under the original
1425
+ // parent sessionId. Fall back to scanning when the derived path is missing.
1258
1426
  const subagentPathCache = new Map();
1427
+ function findSubagentJsonlInProject(projPath, sessionId, agentId) {
1428
+ const sameSid = path.join(projPath, sessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1429
+ if (existsSync(sameSid)) return sameSid;
1430
+ let sessions;
1431
+ try { sessions = readdirSync(projPath, { withFileTypes: true }); } catch { return null; }
1432
+ for (const sess of sessions) {
1433
+ if (!sess.isDirectory() || sess.name === sessionId) continue;
1434
+ const candidate = path.join(projPath, sess.name, 'subagents', 'agent-' + agentId + '.jsonl');
1435
+ if (existsSync(candidate)) return candidate;
1436
+ }
1437
+ return null;
1438
+ }
1259
1439
  function resolveSubagentJsonl(meta, sessionId, agentId) {
1260
1440
  const primary = subagentJsonlPath(meta, agentId);
1261
1441
  if (existsSync(primary)) return primary;
1262
1442
  const key = sessionId + '/' + agentId;
1263
- if (subagentPathCache.has(key)) return subagentPathCache.get(key) || primary;
1443
+ const cached = subagentPathCache.get(key);
1444
+ if (cached) return cached;
1264
1445
  let found = null;
1265
- try {
1266
- for (const entry of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1267
- if (!entry.isDirectory()) continue;
1268
- const candidate = path.join(
1269
- PROJECTS_DIR, entry.name, sessionId,
1270
- 'subagents', 'agent-' + agentId + '.jsonl'
1271
- );
1272
- if (existsSync(candidate)) { found = candidate; break; }
1273
- }
1274
- } catch (_) { /* projects dir missing */ }
1275
- subagentPathCache.set(key, found);
1446
+ const parent = lookupParentSession(sessionId);
1447
+ if (parent.parentSessionId && parent.parentJsonlPath) {
1448
+ const projDir = path.dirname(parent.parentJsonlPath);
1449
+ const candidate = path.join(projDir, parent.parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1450
+ if (existsSync(candidate)) found = candidate;
1451
+ }
1452
+ if (!found) {
1453
+ try {
1454
+ for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1455
+ if (!proj.isDirectory()) continue;
1456
+ found = findSubagentJsonlInProject(path.join(PROJECTS_DIR, proj.name), sessionId, agentId);
1457
+ if (found) break;
1458
+ }
1459
+ } catch (_) { /* projects dir missing */ }
1460
+ }
1461
+ if (found) subagentPathCache.set(key, found);
1276
1462
  return found || primary;
1277
1463
  }
1278
1464
 
1465
+ // Claude Code marks fork lineage in two ways:
1466
+ // 1. `logicalParentUuid` on a system record (when present) points to a uuid
1467
+ // in the parent session's JSONL.
1468
+ // 2. When absent, the fork copies the parent's early records verbatim, so
1469
+ // the earliest `uuid` in this session also exists (same uuid+timestamp)
1470
+ // in the parent's JSONL.
1471
+ // We try (1) first, then fall back to (2).
1472
+ const parentSessionCache = new Map();
1473
+ // Both anchor signals live in the first few records (system marker on top,
1474
+ // fork-copy starts at line 0), so cap the scan instead of reading the whole file.
1475
+ const FORK_ANCHOR_SCAN_LINES = 10;
1476
+ function findForkAnchorUuid(jsonlPath) {
1477
+ let text;
1478
+ try { text = readFileSync(jsonlPath, 'utf8'); } catch { return null; }
1479
+ let firstUuid = null;
1480
+ let scanned = 0;
1481
+ for (const l of text.split('\n')) {
1482
+ if (!l) continue;
1483
+ if (scanned++ >= FORK_ANCHOR_SCAN_LINES) break;
1484
+ try {
1485
+ const d = JSON.parse(l);
1486
+ if (d.logicalParentUuid) return d.logicalParentUuid;
1487
+ if (!firstUuid && d.uuid) firstUuid = d.uuid;
1488
+ } catch { /* skip malformed */ }
1489
+ }
1490
+ return firstUuid;
1491
+ }
1492
+ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
1493
+ let files;
1494
+ try { files = readdirSync(projectDir); } catch { return null; }
1495
+ const candidates = [];
1496
+ for (const f of files) {
1497
+ if (!f.endsWith('.jsonl')) continue;
1498
+ const fp = path.join(projectDir, f);
1499
+ if (fp === excludeJsonlPath) continue;
1500
+ let text;
1501
+ try { text = readFileSync(fp, 'utf8'); } catch { continue; }
1502
+ if (!text.includes(targetUuid)) continue;
1503
+ for (const l of text.split('\n')) {
1504
+ if (!l || !l.includes(targetUuid)) continue;
1505
+ try {
1506
+ const d = JSON.parse(l);
1507
+ if (d.uuid === targetUuid && d.sessionId) {
1508
+ let mtime = 0;
1509
+ try { mtime = statSync(fp).mtimeMs; } catch { /* ignore */ }
1510
+ candidates.push({ parentSessionId: d.sessionId, parentJsonlPath: fp, mtime });
1511
+ break;
1512
+ }
1513
+ } catch { /* skip */ }
1514
+ }
1515
+ }
1516
+ if (!candidates.length) return null;
1517
+ candidates.sort((a, b) => a.mtime - b.mtime);
1518
+ const { parentSessionId, parentJsonlPath } = candidates[0];
1519
+ return { parentSessionId, parentJsonlPath };
1520
+ }
1521
+ function lookupParentSession(sessionId) {
1522
+ if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
1523
+ const meta = loadSessionMetadata()[sessionId];
1524
+ const result = { parentSessionId: null, parentJsonlPath: null };
1525
+ if (meta?.jsonlPath) {
1526
+ const anchorUuid = findForkAnchorUuid(meta.jsonlPath);
1527
+ if (anchorUuid) {
1528
+ const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath);
1529
+ if (hit) Object.assign(result, hit);
1530
+ }
1531
+ }
1532
+ if (result.parentSessionId) parentSessionCache.set(sessionId, result);
1533
+ return result;
1534
+ }
1535
+ app.get('/api/sessions/:sessionId/parent', (req, res) => {
1536
+ res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
1537
+ });
1538
+
1279
1539
  app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
1280
1540
  const sessionId = resolveSessionId(req.params.sessionId);
1281
1541
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -1418,6 +1678,19 @@ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
1418
1678
  res.json({ toolUseId: req.params.toolUseId, content });
1419
1679
  });
1420
1680
 
1681
+ app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
1682
+ const metadata = loadSessionMetadata();
1683
+ const meta = metadata[req.params.sessionId];
1684
+ const jsonlPath = meta?.jsonlPath;
1685
+ if (!jsonlPath) return res.status(404).end();
1686
+ const img = readUserImage(jsonlPath, req.params.msgUuid, req.params.blockIndex);
1687
+ if (!img) return res.status(404).end();
1688
+ const buf = Buffer.from(img.data, 'base64');
1689
+ res.setHeader('Content-Type', img.mediaType);
1690
+ res.setHeader('Cache-Control', 'no-store');
1691
+ res.end(buf);
1692
+ });
1693
+
1421
1694
  app.get('/api/version', (req, res) => {
1422
1695
  const pkg = require('./package.json');
1423
1696
  res.json({ version: pkg.version });
@@ -1818,13 +2091,27 @@ console.log(`Watching for team changes in: ${TEAMS_DIR}`);
1818
2091
  const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
1819
2092
  persistent: true,
1820
2093
  ignoreInitial: true,
1821
- depth: 2
2094
+ depth: 2,
2095
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
1822
2096
  });
1823
2097
 
1824
2098
  projectsWatcher.on('all', (event, filePath) => {
1825
- if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.jsonl')) {
1826
- // Invalidate cache on any change
1827
- lastMetadataRefresh = 0;
2099
+ if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
2100
+ if (filePath.endsWith('.jsonl')) {
2101
+ if (event === 'unlink') {
2102
+ loopInfoStateByPath.delete(filePath);
2103
+ } else {
2104
+ // Warm the incremental scan state so the next request does no IO.
2105
+ try { refreshLoopInfoState(filePath); } catch (_) {}
2106
+ }
2107
+ // add/unlink reshape the session set — promote to full rescan.
2108
+ if (event === 'change') dirtyMetadataPaths.add(filePath);
2109
+ else metadataNeedsFullScan = true;
2110
+ broadcast({ type: 'metadata-update' });
2111
+ } else if (path.basename(filePath) === 'sessions-index.json') {
2112
+ // Index holds description / created / customTitle that the targeted
2113
+ // refresh doesn't touch — promote to full rescan.
2114
+ metadataNeedsFullScan = true;
1828
2115
  broadcast({ type: 'metadata-update' });
1829
2116
  }
1830
2117
  });
@@ -1837,7 +2124,9 @@ const plansWatcher = chokidar.watch(PLANS_DIR, {
1837
2124
 
1838
2125
  plansWatcher.on('all', (event, filePath) => {
1839
2126
  if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.md')) {
1840
- lastMetadataRefresh = 0;
2127
+ // Plan files don't affect cached session metadata — getPlanInfo is called
2128
+ // fresh from buildSessionObject on every list build. The broadcast alone
2129
+ // is enough to trigger a client refetch.
1841
2130
  broadcast({ type: 'metadata-update' });
1842
2131
  if (event === 'change') {
1843
2132
  const slug = path.basename(filePath, '.md');
@@ -1920,7 +2209,6 @@ contextStatusWatcher.on('all', (event, filePath) => {
1920
2209
  }
1921
2210
  });
1922
2211
 
1923
- const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
1924
2212
  async function cleanupContextStatus() {
1925
2213
  try {
1926
2214
  const entries = await fs.readdir(CONTEXT_STATUS_DIR);
@@ -1939,10 +2227,6 @@ async function cleanupContextStatus() {
1939
2227
  } catch (e) { /* dir may not exist */ }
1940
2228
  }
1941
2229
 
1942
- // Cleanup agent-activity folders older than 2 days
1943
- const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
1944
- const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
1945
-
1946
2230
  async function cleanupAgentActivity() {
1947
2231
  try {
1948
2232
  const entries = await fs.readdir(AGENT_ACTIVITY_DIR, { withFileTypes: true });