claude-code-kanban 5.2.0 → 5.4.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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/cli.js +10 -5
  3. package/install.js +43 -123
  4. package/lib/approvals.js +16 -17
  5. package/lib/claude-dir.js +7 -1
  6. package/lib/git-branch.js +47 -0
  7. package/lib/net-guard.js +41 -14
  8. package/lib/parsers.js +96 -59
  9. package/lib/plugin-status.js +1 -1
  10. package/lib/proc-stats.js +82 -0
  11. package/lib/retention.js +12 -9
  12. package/lib/session-cache.js +1 -1
  13. package/lib/terminal.js +19 -5
  14. package/lib/worktrees.js +105 -0
  15. package/package.json +1 -2
  16. package/plugin/.claude-plugin/marketplace.json +1 -1
  17. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
  18. package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
  20. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
  21. package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
  22. package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
  23. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
  24. package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +0 -1
  25. package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
  26. package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
  27. package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
  28. package/public/app.js +661 -380
  29. package/public/index.html +7 -3
  30. package/public/project-match.js +6 -4
  31. package/public/style.css +258 -79
  32. package/public/terminal-frame.js +341 -0
  33. package/public/terminal.html +27 -0
  34. package/public/vendor/claude-hub-sdk.js +13 -3
  35. package/server.js +163 -145
  36. package/skill-guides/dispatch.md +4 -4
  37. package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
  38. package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
  39. package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/server.js CHANGED
@@ -9,7 +9,7 @@ const _readline = require('node:readline');
9
9
  const chokidar = require('chokidar');
10
10
  const os = require('node:os');
11
11
  const crypto = require('node:crypto');
12
- const { spawnSync, spawn } = require('node:child_process');
12
+ const { 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');
@@ -35,26 +35,30 @@ const {
35
35
  readToolResultImage,
36
36
  readCachedImage,
37
37
  updateLoopInfo,
38
- buildLoopInfoFromState
38
+ buildLoopInfoFromState,
39
+ readLines
39
40
  } = require('./lib/parsers');
40
41
  const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
41
42
  const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
42
- const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
43
+ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
43
44
  const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
45
+ const { createProcStats } = require('./lib/proc-stats');
44
46
  const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
45
47
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
46
- const { createDispatchedStore, listTranscriptIds, pruneSessionDirs, retentionMs } = require('./lib/retention');
48
+ const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
49
+ const { createWorktreeStore } = require('./lib/worktrees');
50
+ const { readGitBranch } = require('./lib/git-branch');
47
51
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
48
52
  const { pickFolder } = require('./lib/folder-dialog');
49
53
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
50
54
  const { countTaskDir } = require('./lib/task-counts');
51
- const { projectMatcher } = require('./public/project-match');
55
+ const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
52
56
  const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
53
57
 
54
58
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
55
59
  const { runInstall, runUninstall } = require("./install");
56
- const pluginOnly = process.argv.includes("--plugin-only");
57
- (process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
60
+ const yes = process.argv.includes("--yes");
61
+ (process.argv.includes("--install") ? runInstall({ yes }) : runUninstall())
58
62
  .then(() => process.exit(0))
59
63
  .catch(e => { console.error(e.message); process.exit(1); });
60
64
  } else if (!require("./cli").runCli(process.argv)) {
@@ -69,7 +73,8 @@ const PORT = parseInt(getArgValue('port') || process.env.PORT || '3541', 10);
69
73
 
70
74
  // Mounted before express.json() so a rejected request never buffers a body, and
71
75
  // before the /api catch-all so no route escapes the check.
72
- const net = createNetGuard({ appName: 'Claude Task Kanban' });
76
+ // The board frames the terminal from its other loopback name, so Chrome runs it in its own process.
77
+ const net = createNetGuard({ appName: 'Claude Task Kanban', selfFramedPaths: ['/terminal.html'] });
73
78
  app.use(net.hostGuard);
74
79
  app.use(net.frameGuard);
75
80
  app.use(net.originGuard);
@@ -93,6 +98,7 @@ const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
93
98
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
94
99
  const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
95
100
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
101
+ const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
96
102
  const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
97
103
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
98
104
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
@@ -132,7 +138,7 @@ function writePins(pins) {
132
138
  writeJsonAtomic(PINS_FILE, pins);
133
139
  }
134
140
 
135
- // Port discovery for out-of-process helpers (the postman monitor, approval-gate.sh).
141
+ // Port discovery for out-of-process helpers (the postman monitor, the CLI).
136
142
  // The pid rides along so a reader can tell a live server from a file left behind by
137
143
  // a crashed one.
138
144
  function writeServerInfo(port) {
@@ -161,8 +167,8 @@ function ownerPid(file) {
161
167
  try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
162
168
  }
163
169
 
164
- // A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
165
- // probes the port, finds it closed and never waits. The hub spawns every sub-app on
170
+ // A beacon that outlives its server sends the postman monitor and the CLI to a closed
171
+ // port. The hub spawns every sub-app on
166
172
  // an ephemeral port, so a stale beacon never comes back on its own — drop ours on
167
173
  // the way out. Only when the file is still ours: a newer server on the same config
168
174
  // dir has already claimed it. The terminal token file goes the same way.
@@ -267,7 +273,10 @@ function checkWaitingForUser(agentDir, logMtime) {
267
273
  const age = Date.now() - waitTime;
268
274
  if (age >= PERMISSION_TTL_MS) return null;
269
275
  // After grace period, check if session resumed activity (user already responded)
270
- if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
276
+ if (movedOnSince(waitTime, logMtime)) return null;
277
+ // The mod sees the ask before auto mode's classifier decides it, and no dialog may follow.
278
+ // An ask from a settings rule always opens the dialog.
279
+ if (data.kind === 'permission' && !data.rule && loadSessionMetadata()[path.basename(agentDir)]?.permissionMode === 'auto') return null;
271
280
  // Opted out reads the same as lapsed to the board: the ask is only
272
281
  // answerable in the terminal, so no buttons.
273
282
  if (boardRefusal(data, approvalsConfig())) return { ...data, lapsed: true };
@@ -309,7 +318,7 @@ function isAgentLive(agent) {
309
318
  // Claude Code records gitBranch from the launch-time repo and never updates it
310
319
  // when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
311
320
  // the live cwd instead. Cached per-cwd with a short TTL so a list refresh
312
- // across N sessions sharing one cwd spawns git at most once per TTL window.
321
+ // across N sessions sharing one cwd reads HEAD at most once per TTL window.
313
322
  const gitBranchCache = new Map();
314
323
  const GIT_BRANCH_TTL_MS = 30000;
315
324
  const GIT_BRANCH_CACHE_MAX = 500;
@@ -318,18 +327,7 @@ function getGitBranch(cwd) {
318
327
  const now = Date.now();
319
328
  const cached = gitBranchCache.get(cwd);
320
329
  if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
321
- let branch = null;
322
- try {
323
- // cwd rather than `-C <cwd>`: a path beginning with a dash would otherwise be
324
- // read by git as an option.
325
- const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
326
- cwd, encoding: 'utf8', timeout: 500, windowsHide: true
327
- });
328
- if (r.status === 0) {
329
- const out = (r.stdout || '').trim();
330
- if (out && out !== 'HEAD') branch = out;
331
- }
332
- } catch (_) {}
330
+ const branch = readGitBranch(cwd);
333
331
  gitBranchCache.set(cwd, { branch, ts: now });
334
332
  if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
335
333
  const firstKey = gitBranchCache.keys().next().value;
@@ -338,36 +336,15 @@ function getGitBranch(cwd) {
338
336
  return branch;
339
337
  }
340
338
 
341
- // A linked worktree's `.git` is a file holding `gitdir: <main>/.git/worktrees/<name>`, so the
342
- // main checkout is readable without spawning git. Path shape alone would not do: only some
343
- // worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
344
- // Cached without a TTL, misses included: a directory cannot turn from an ordinary checkout into
345
- // a linked worktree without being recreated, and a recreated path is a new cache key.
346
- const worktreeCache = new Map();
347
- const WORKTREE_CACHE_MAX = 500;
348
- const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
349
-
350
- function resolveWorktree(dir) {
351
- if (!dir) return null;
352
- if (worktreeCache.has(dir)) return worktreeCache.get(dir);
353
-
354
- let worktree = null;
355
- try {
356
- // An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
357
- // answer, and it costs one syscall instead of a stat followed by a read.
358
- const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
359
- if (m) worktree = { repo: m[1], name: m[2] };
360
- } catch (_) {}
361
-
362
- worktreeCache.set(dir, worktree);
363
- if (worktreeCache.size > WORKTREE_CACHE_MAX) {
364
- worktreeCache.delete(worktreeCache.keys().next().value);
365
- }
366
- return worktree;
367
- }
339
+ const worktrees = createWorktreeStore({
340
+ load: () => {
341
+ try { return JSON.parse(readFileSync(WORKTREES_FILE, 'utf8')); } catch { return null; }
342
+ },
343
+ save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
344
+ });
368
345
 
369
- // Only spawn git when cwd has diverged from the launch project — that's the
370
- // only case the JSONL value is wrong. Saves N spawns on a typical list build.
346
+ // Only look up the branch when cwd has diverged from the launch project — that's
347
+ // the only case the JSONL value is wrong.
371
348
  function resolveSessionGitBranch(meta) {
372
349
  if (meta.cwd && meta.project && meta.cwd !== meta.project) {
373
350
  return getGitBranch(meta.cwd) || meta.gitBranch || null;
@@ -383,13 +360,25 @@ function getSessionLogStat(meta) {
383
360
  } catch { return { mtime: null, hasMessages: false }; }
384
361
  }
385
362
 
363
+ // A transcript write past the grace window after a marker means the session went on.
364
+ function movedOnSince(markerMs, logMtime) {
365
+ return !!logMtime && logMtime > markerMs + WAITING_RESOLVE_GRACE_MS;
366
+ }
367
+
368
+ // The mod cannot delete _stop.json when the session works again.
369
+ function resumedSince(markerPath, logMtime) {
370
+ if (!logMtime) return false;
371
+ try { return movedOnSince(statSync(markerPath).mtimeMs, logMtime); }
372
+ catch { return true; }
373
+ }
374
+
386
375
  function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
387
376
  const result = { hasActive: false, hasRunning: false, waitingForUser: null, unread: false };
388
377
  let names;
389
378
  try { names = readdirSync(agentDir); } catch { return result; }
390
379
  if (names.includes('_waiting.json')) result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
391
380
  if (result.waitingForUser) result.hasActive = true;
392
- const stopped = names.includes('_stop.json');
381
+ const stopped = names.includes('_stop.json') && !resumedSince(path.join(agentDir, '_stop.json'), logMtime);
393
382
  if (stale && !isTeam) {
394
383
  result.unread = stopped;
395
384
  return result;
@@ -791,33 +780,46 @@ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
791
780
  return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
792
781
  }
793
782
 
783
+ // A promise from loadFn is cached as is, so concurrent callers share one read, and a newer
784
+ // mtime starts no second read while it is pending: full-transcript scans stream for seconds.
794
785
  function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
795
786
  try {
796
787
  const cached = cache.get(cacheKey);
797
- if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.data;
788
+ if (cached && (cached.pending || Date.now() - cached.ts < MESSAGE_CACHE_TTL)) return cached.data;
798
789
  const st = statSync(filePath);
799
790
  if (cached && cached.mtime === st.mtimeMs) {
800
791
  cached.ts = Date.now();
801
792
  return cached.data;
802
793
  }
803
- const data = loadFn();
804
- cache.set(cacheKey, { data, mtime: st.mtimeMs, ts: Date.now() });
794
+ const entry = { data: loadFn(), mtime: st.mtimeMs, ts: Date.now() };
795
+ if (typeof entry.data?.then === 'function') {
796
+ entry.pending = true;
797
+ entry.data = entry.data.catch(() => {
798
+ if (cache.get(cacheKey) === entry) cache.delete(cacheKey);
799
+ return fallback;
800
+ }).finally(() => {
801
+ entry.pending = false;
802
+ entry.ts = Date.now();
803
+ });
804
+ }
805
+ cache.set(cacheKey, entry);
805
806
  evictStaleCache(cache);
806
- return data;
807
+ return entry.data;
807
808
  } catch (_) { return fallback; }
808
809
  }
809
810
 
811
+ // Express 4 does not pass a rejected handler promise to next(), and Node 24 exits on it.
812
+ function asyncRoute(fn) {
813
+ return (req, res, next) => fn(req, res, next).catch(next);
814
+ }
815
+
810
816
  const sessionDigestCache = new Map();
811
817
  function getSessionDigest(jsonlPath) {
812
818
  return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
813
819
  }
814
820
 
815
- function getProgressMap(jsonlPath) {
816
- return getSessionDigest(jsonlPath).progressMap;
817
- }
818
-
819
- function getTerminatedTeammates(jsonlPath) {
820
- return getSessionDigest(jsonlPath).terminated;
821
+ async function getProgressMap(jsonlPath) {
822
+ return (await getSessionDigest(jsonlPath)).progressMap;
821
823
  }
822
824
 
823
825
  function readRecentMessages(jsonlPath, limit = 10) {
@@ -853,6 +855,7 @@ function refreshSessionMetadataPath(jsonlPath) {
853
855
  if (info.customTitle) existing.customTitle = info.customTitle;
854
856
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
855
857
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
858
+ existing.permissionMode = info.permissionMode;
856
859
  return true;
857
860
  }
858
861
 
@@ -939,7 +942,8 @@ function loadSessionMetadata() {
939
942
  customTitle: sessionInfo.customTitle || null,
940
943
  jsonlPath: jsonlPath,
941
944
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
942
- compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
945
+ compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
946
+ permissionMode: sessionInfo.permissionMode
943
947
  };
944
948
  sessionIds.push(sessionId);
945
949
  }
@@ -1140,12 +1144,6 @@ function getSessionDisplayName(_sessionId, meta) {
1140
1144
  return null;
1141
1145
  }
1142
1146
 
1143
- // The harness spells a project path into a temp/log dir name by replacing every
1144
- // non-alphanumeric character with a dash, drive colon and separators included.
1145
- function encodeProjectDirName(p) {
1146
- return p.replace(/[^a-zA-Z0-9]/g, '-');
1147
- }
1148
-
1149
1147
  // Derived by convention, not looked up: the harness creates the dir lazily, so a
1150
1148
  // stat here would report "missing" for every session that has not written a temp
1151
1149
  // file yet — and it would put IO on the session-list hot path. Pure string join.
@@ -1160,7 +1158,7 @@ function getScratchpadDir(id, meta) {
1160
1158
  // harness actually created. Probed only for worktree sessions, and only until one
1161
1159
  // of them exists — before that there is nothing to disambiguate and `byProject`,
1162
1160
  // right for a session started inside the worktree, stands.
1163
- const wt = resolveWorktree(meta.project);
1161
+ const wt = worktrees.resolve(meta.project);
1164
1162
  if (!wt || existsSync(byProject)) return byProject;
1165
1163
  const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
1166
1164
  return existsSync(byRepo) ? byRepo : byProject;
@@ -1179,7 +1177,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1179
1177
  cwd: meta.cwd || null,
1180
1178
  description: meta.description || null,
1181
1179
  gitBranch: resolveSessionGitBranch(meta),
1182
- worktree: resolveWorktree(meta.project),
1180
+ worktree: worktrees.resolve(meta.project),
1183
1181
  customTitle: meta.customTitle || null,
1184
1182
  taskCount: 0,
1185
1183
  completed: 0,
@@ -1578,7 +1576,7 @@ app.get('/api/sessions', async (req, res) => {
1578
1576
  // if the row is missing — that one is not a preference.
1579
1577
  if (projectFilter) {
1580
1578
  const matches = projectMatcher(String(projectFilter));
1581
- sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
1579
+ sessions = sessions.filter(s => matches(s.project, s.worktree?.repo) || includeIds.has(s.id));
1582
1580
  }
1583
1581
  // The sidebar's 24h filter: projects with any transcript written in the window, so an older
1584
1582
  // session of such a project stays in.
@@ -1650,14 +1648,30 @@ function projectActivity() {
1650
1648
  return activity;
1651
1649
  }
1652
1650
 
1653
- // API: Get distinct project paths with last-modified timestamps
1651
+ // API: Get distinct project paths with last-modified timestamps. A linked worktree is folded
1652
+ // into its repo, which lists it under `worktrees`; the repo row exists even when only its
1653
+ // worktrees have transcripts.
1654
1654
  app.get('/api/projects', (_req, res) => {
1655
1655
  res.setHeader('Cache-Control', 'no-store');
1656
- const projects = [...projectActivity()]
1657
- .map(([path, mtime]) => ({
1658
- path,
1659
- modifiedAt: mtime ? new Date(mtime).toISOString() : null,
1660
- ...(isTempPath(path) && { temp: true }),
1656
+ const byRepo = new Map();
1657
+ for (const [project, mtime] of projectActivity()) {
1658
+ const repo = worktrees.resolve(project)?.repo || project;
1659
+ const key = normalizeProjectPath(repo);
1660
+ let row = byRepo.get(key);
1661
+ if (!row) {
1662
+ row = { path: repo, mtime: 0, worktrees: [] };
1663
+ byRepo.set(key, row);
1664
+ }
1665
+ if (repo === project) row.path = project;
1666
+ else row.worktrees.push(project);
1667
+ row.mtime = Math.max(row.mtime, mtime || 0);
1668
+ }
1669
+ const projects = [...byRepo.values()]
1670
+ .map((r) => ({
1671
+ path: r.path,
1672
+ modifiedAt: r.mtime ? new Date(r.mtime).toISOString() : null,
1673
+ ...(isTempPath(r.path) && { temp: true }),
1674
+ ...(r.worktrees.length && { worktrees: r.worktrees.sort() }),
1661
1675
  }))
1662
1676
  .sort((a, b) => a.path.localeCompare(b.path));
1663
1677
  res.json(projects);
@@ -1782,15 +1796,20 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1782
1796
 
1783
1797
  // Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
1784
1798
  // it already pays to notice the file grew. `load` may return a promise: the promise is
1785
- // what gets cached, so concurrent callers share a single read. Null when the file is gone.
1799
+ // what gets cached, so concurrent callers share a single read, and a file that grows while
1800
+ // it is pending starts no second one. Null when the file is gone.
1786
1801
  function cachedByFileStat(cache, filePath, load) {
1787
1802
  let stat;
1788
1803
  try { stat = statSync(filePath); } catch (_) { return null; }
1789
1804
  const hit = cache.get(filePath);
1790
- if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
1791
- const value = load();
1792
- cache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value });
1793
- return value;
1805
+ if (hit && (hit.pending || (hit.mtimeMs === stat.mtimeMs && hit.size === stat.size))) return hit.value;
1806
+ const entry = { mtimeMs: stat.mtimeMs, size: stat.size, value: load() };
1807
+ if (typeof entry.value?.then === 'function') {
1808
+ entry.pending = true;
1809
+ entry.value.finally(() => { entry.pending = false; }).catch(() => {});
1810
+ }
1811
+ cache.set(filePath, entry);
1812
+ return entry.value;
1794
1813
  }
1795
1814
 
1796
1815
  // Cold path — a full transcript scan, so the result is held until the file grows.
@@ -1801,12 +1820,12 @@ function getArtifactLinks(jsonlPath) {
1801
1820
  return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
1802
1821
  }
1803
1822
 
1804
- app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
1823
+ app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
1805
1824
  try {
1806
1825
  const metadata = loadSessionMetadata();
1807
1826
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1808
1827
  if (!meta?.jsonlPath) return res.json({ artifacts: [] });
1809
- res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
1828
+ res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
1810
1829
  } catch (error) {
1811
1830
  console.error('Error reading artifacts:', error);
1812
1831
  res.status(500).json({ error: 'Failed to read artifacts' });
@@ -1851,7 +1870,7 @@ async function findPads(root, depth = 0) {
1851
1870
  // stored, so re-reading a transcript cannot drift; the client links each pad once
1852
1871
  // and records that it did, which is what stops a re-read undoing an unlink.
1853
1872
  async function readCreatedPads(meta) {
1854
- const creations = readScratchpadCreations(meta.jsonlPath);
1873
+ const creations = await readScratchpadCreations(meta.jsonlPath);
1855
1874
  // No `scratch new` in the transcript means no disk work at all, so sessions that
1856
1875
  // never touch the CLI pay a substring pass over the transcript and nothing more.
1857
1876
  if (!creations.length) return [];
@@ -2129,7 +2148,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
2129
2148
  try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
2130
2149
  for (const f of files) {
2131
2150
  const agentId = f.slice('agent-'.length, -'.jsonl'.length);
2132
- const stats = extractTranscriptStats(path.join(runDir, f)) || {};
2151
+ const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
2133
2152
  let type = null;
2134
2153
  try {
2135
2154
  type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
@@ -2364,9 +2383,9 @@ app.get('/api/teams/:name', (req, res) => {
2364
2383
  // API: Get agents for a session
2365
2384
  // The resultUnavailable latch is persisted on the agent record. Bump this when
2366
2385
  // extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
2367
- const RESULT_SCAN = 2;
2386
+ const RESULT_SCAN = 3;
2368
2387
 
2369
- app.get('/api/sessions/:sessionId/agents', (req, res) => {
2388
+ app.get('/api/sessions/:sessionId/agents', async (req, res) => {
2370
2389
  const sessionId = resolveSessionId(req.params.sessionId);
2371
2390
  const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
2372
2391
  if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
@@ -2410,10 +2429,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2410
2429
  agents.push(agent);
2411
2430
  } catch { /* skip invalid */ }
2412
2431
  }
2432
+ const digest = meta.jsonlPath ? await getSessionDigest(meta.jsonlPath) : null;
2413
2433
  const liveAgents = agents.filter(isAgentLive);
2414
2434
  if (liveAgents.length && meta.jsonlPath) {
2415
2435
  try {
2416
- const terminated = getTerminatedTeammates(meta.jsonlPath);
2436
+ const { terminated } = digest;
2417
2437
  if (terminated.size) {
2418
2438
  for (const agent of liveAgents) {
2419
2439
  const agentName = agentDisplayName(agent);
@@ -2430,10 +2450,9 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2430
2450
  // Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
2431
2451
  // the parent will never read their output, so they're orphans. Match by agentId
2432
2452
  // when the digest already correlated tool_use→agent, else fall back to prompt text
2433
- // (the agent-spy hook doesn't record the spawning tool_use_id).
2453
+ // (the plugin's mod doesn't record the spawning tool_use_id).
2434
2454
  try {
2435
- const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
2436
- getSessionDigest(meta.jsonlPath);
2455
+ const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } = digest;
2437
2456
  if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
2438
2457
  for (const agent of liveAgents) {
2439
2458
  if (!isAgentLive(agent)) continue;
@@ -2464,8 +2483,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2464
2483
  const byAgentId = {};
2465
2484
  if (meta.jsonlPath) {
2466
2485
  try {
2467
- const progressMap = getProgressMap(meta.jsonlPath);
2468
- for (const entry of Object.values(progressMap)) {
2486
+ for (const entry of Object.values(digest.progressMap)) {
2469
2487
  byAgentId[entry.agentId] ||= {};
2470
2488
  const e = byAgentId[entry.agentId];
2471
2489
  for (const f of ['prompt', 'name', 'description', 'usage']) {
@@ -2622,10 +2640,10 @@ app.post('/api/sessions/:sessionId/read', (req, res) => {
2622
2640
  }
2623
2641
  });
2624
2642
 
2625
- // UI-driven approvals: answers the ask approval-gate.sh is blocking on by
2626
- // writing _decision-<id>.json next to the marker. The hook consumes and deletes
2627
- // both files; a write for a hook that already gave up is accepted anyway (D13)
2628
- // and left for cleanupAgentActivity's sweep.
2643
+ // UI-driven approvals: answers the ask the plugin's mod is polling for by writing
2644
+ // _decision-<id>.json next to the marker. The mod cannot delete files, so it clears
2645
+ // the marker and leaves the decision, and a write for an ask that is already over
2646
+ // is accepted anyway (D13); cleanupAgentActivity's sweep removes both kinds.
2629
2647
  app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2630
2648
  const sessionId = resolveSessionId(req.params.sessionId);
2631
2649
  const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
@@ -2942,7 +2960,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
2942
2960
  res.on('error', cleanup);
2943
2961
  });
2944
2962
 
2945
- app.get('/api/sessions/:sessionId/messages', (req, res) => {
2963
+ async function sendSessionMessages(req, res) {
2946
2964
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
2947
2965
  const before = req.query.before || null;
2948
2966
  const metadata = loadSessionMetadata();
@@ -2959,9 +2977,10 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2959
2977
  hasMore = messages.length > limit;
2960
2978
  if (hasMore) messages = messages.slice(-limit);
2961
2979
  }
2980
+ const compactPromise = cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), []);
2962
2981
  const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
2963
2982
  if (agentMessages.length) {
2964
- const progressMap = getProgressMap(jsonlPath);
2983
+ const progressMap = await getProgressMap(jsonlPath);
2965
2984
  const resolvedSid = resolveSessionId(req.params.sessionId);
2966
2985
  const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedSid);
2967
2986
  for (const msg of agentMessages) {
@@ -3012,15 +3031,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
3012
3031
  }
3013
3032
  }
3014
3033
  }
3015
- const cachedCompact = compactSummaryCache.get(jsonlPath);
3016
- let compactSummaries;
3017
- if (cachedCompact && Date.now() - cachedCompact.ts < MESSAGE_CACHE_TTL) {
3018
- compactSummaries = cachedCompact.data;
3019
- } else {
3020
- compactSummaries = readCompactSummaries(jsonlPath);
3021
- compactSummaryCache.set(jsonlPath, { data: compactSummaries, ts: Date.now() });
3022
- evictStaleCache(compactSummaryCache);
3023
- }
3034
+ const compactSummaries = await compactPromise;
3024
3035
  // Match compaction messages to summaries by chronological order
3025
3036
  const compactedMsgs = messages
3026
3037
  .filter(m => m.systemLabel === 'Compacted')
@@ -3038,29 +3049,30 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
3038
3049
  delete msg.promptId;
3039
3050
  }
3040
3051
  res.json({ messages, hasMore, sessionId: req.params.sessionId });
3041
- });
3052
+ }
3042
3053
 
3043
- app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
3054
+ app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
3055
+
3056
+ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
3044
3057
  const metadata = loadSessionMetadata();
3045
3058
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3046
3059
  const jsonlPath = meta?.jsonlPath;
3047
3060
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3048
- const content = readFullToolResult(jsonlPath, req.params.toolUseId);
3061
+ const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
3049
3062
  if (content == null) return res.status(404).json({ error: 'tool result not found' });
3050
3063
  res.json({ toolUseId: req.params.toolUseId, content });
3051
- });
3064
+ }));
3052
3065
 
3053
3066
  const toolStatsCache = new Map();
3054
3067
 
3055
- function buildToolStats(jsonlPath) {
3068
+ async function buildToolStats(jsonlPath) {
3056
3069
  const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
3057
3070
  const seenResults = new Set();
3058
3071
  const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
3059
3072
  const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
3060
3073
  const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
3061
3074
 
3062
- const content = readFileSync(jsonlPath, 'utf8');
3063
- for (const line of content.split('\n')) {
3075
+ for await (const line of readLines(jsonlPath)) {
3064
3076
  if (!line) continue;
3065
3077
  let obj;
3066
3078
  try { obj = JSON.parse(line); } catch (_) { continue; }
@@ -3147,13 +3159,13 @@ function buildToolStats(jsonlPath) {
3147
3159
  return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
3148
3160
  }
3149
3161
 
3150
- app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
3162
+ app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
3151
3163
  const metadata = loadSessionMetadata();
3152
3164
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3153
3165
  const jsonlPath = meta?.jsonlPath;
3154
3166
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3155
3167
  try {
3156
- const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
3168
+ const data = await cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
3157
3169
  if (!data) return res.status(404).json({ error: 'could not parse session' });
3158
3170
  res.json({ sessionId: req.params.sessionId, ...data });
3159
3171
  } catch (e) {
@@ -3161,31 +3173,24 @@ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
3161
3173
  }
3162
3174
  });
3163
3175
 
3164
- app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
3176
+ async function sendTranscriptImage(req, res, read) {
3165
3177
  const metadata = loadSessionMetadata();
3166
3178
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3167
3179
  const jsonlPath = meta?.jsonlPath;
3168
3180
  if (!jsonlPath) return res.status(404).end();
3169
- const img = readUserImage(jsonlPath, req.params.msgUuid, req.params.blockIndex);
3181
+ const img = await read(jsonlPath);
3170
3182
  if (!img) return res.status(404).end();
3171
3183
  const buf = Buffer.from(img.data, 'base64');
3172
3184
  res.setHeader('Content-Type', img.mediaType);
3173
3185
  res.setHeader('Cache-Control', 'no-store');
3174
3186
  res.end(buf);
3175
- });
3187
+ }
3176
3188
 
3177
- app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', (req, res) => {
3178
- const metadata = loadSessionMetadata();
3179
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3180
- const jsonlPath = meta?.jsonlPath;
3181
- if (!jsonlPath) return res.status(404).end();
3182
- const img = readToolResultImage(jsonlPath, req.params.toolUseId, req.params.n);
3183
- if (!img) return res.status(404).end();
3184
- const buf = Buffer.from(img.data, 'base64');
3185
- res.setHeader('Content-Type', img.mediaType);
3186
- res.setHeader('Cache-Control', 'no-store');
3187
- res.end(buf);
3188
- });
3189
+ app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', asyncRoute((req, res) =>
3190
+ sendTranscriptImage(req, res, (p) => readUserImage(p, req.params.msgUuid, req.params.blockIndex))));
3191
+
3192
+ app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', asyncRoute((req, res) =>
3193
+ sendTranscriptImage(req, res, (p) => readToolResultImage(p, req.params.toolUseId, req.params.n))));
3189
3194
 
3190
3195
  app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
3191
3196
  const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
@@ -3298,6 +3303,16 @@ app.get('/api/terminals', (_req, res) => {
3298
3303
  res.json({ sessions: terminal.list() });
3299
3304
  });
3300
3305
 
3306
+ const terminalProcStats = createProcStats();
3307
+ app.get('/api/terminals/stats', async (_req, res) => {
3308
+ res.setHeader('Cache-Control', 'no-store');
3309
+ const pids = terminal.claudePids();
3310
+ const byPid = await terminalProcStats([process.pid, ...Object.values(pids)]);
3311
+ const terminals = {};
3312
+ for (const [id, pid] of Object.entries(pids)) if (byPid[pid]) terminals[id] = byPid[pid];
3313
+ res.json({ cck: byPid[process.pid] || null, terminals });
3314
+ });
3315
+
3301
3316
  app.delete('/api/terminals/:id', (req, res) => {
3302
3317
  const err = terminal.end(req.params.id, req.get('x-terminal-token'));
3303
3318
  if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
@@ -3978,7 +3993,7 @@ function formatReviewMarkdown(src, items) {
3978
3993
  const parts = [
3979
3994
  `# Review of ${src.path || src.label}`,
3980
3995
  '',
3981
- "Each comment is the user's instruction about the quoted text. A quote missing from the source means the source changed after the review: say so.",
3996
+ `Each comment is the user's instruction about the quoted text.${src.path ? ' A quote missing from the source means the source changed after the review: say so.' : ''}`,
3982
3997
  ];
3983
3998
  items.forEach((c, i) => {
3984
3999
  const where = [c.line && `line ${c.line}`, c.heading && `under "${c.heading}"`].filter(Boolean).join(', ');
@@ -4245,7 +4260,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
4245
4260
  }
4246
4261
  });
4247
4262
 
4248
- // Watch context-status directory for statusline updates
4263
+ // Watch context-status directory for the plugin's context and cost updates
4249
4264
  const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
4250
4265
  persistent: true,
4251
4266
  ignoreInitial: false,
@@ -4305,9 +4320,8 @@ async function cleanupAgentActivity() {
4305
4320
  await fs.rm(dirPath, { recursive: true, force: true });
4306
4321
  continue;
4307
4322
  }
4308
- // Orphaned decision files: a terminal deny fires no hook event (D13), so a
4309
- // board answer for an already-gone gate is never consumed. The gate waits at
4310
- // most PERMISSION_TTL_MS, so anything older is unclaimable.
4323
+ // Mods cannot delete files, so every decision file is swept here. The mod
4324
+ // waits at most PERMISSION_TTL_MS, so anything older is unclaimable.
4311
4325
  for (const f of contents) {
4312
4326
  if (!isDecisionFile(f)) continue;
4313
4327
  const fp = path.join(dirPath, f);
@@ -4321,13 +4335,17 @@ async function cleanupAgentActivity() {
4321
4335
  } catch { /* agent-activity dir may not exist */ }
4322
4336
  }
4323
4337
 
4324
- // Dispatch markers and reviews: see docs/retention.md.
4338
+ // Dispatch markers, reviews and worktrees: see docs/retention.md.
4325
4339
  async function runRetention() {
4326
4340
  try {
4327
- const opts = { known: await listTranscriptIds(PROJECTS_DIR), maxAgeMs: retentionMs(CLAUDE_DIR) };
4341
+ const scan = await scanTranscripts(PROJECTS_DIR);
4342
+ const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
4328
4343
  const markers = dispatched.prune(opts);
4329
4344
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4330
- if (markers || reviews) console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews`);
4345
+ const wts = worktrees.prune(scan?.dirs);
4346
+ if (markers || reviews || wts) {
4347
+ console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${wts} worktrees`);
4348
+ }
4331
4349
  } catch (e) {
4332
4350
  console.warn('[retention] failed:', e.message);
4333
4351
  }