claude-code-kanban 5.3.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.
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,7 +35,8 @@ 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');
@@ -46,6 +47,7 @@ const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName,
46
47
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
47
48
  const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
48
49
  const { createWorktreeStore } = require('./lib/worktrees');
50
+ const { readGitBranch } = require('./lib/git-branch');
49
51
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
50
52
  const { pickFolder } = require('./lib/folder-dialog');
51
53
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
@@ -55,8 +57,8 @@ const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
55
57
 
56
58
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
57
59
  const { runInstall, runUninstall } = require("./install");
58
- const pluginOnly = process.argv.includes("--plugin-only");
59
- (process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
60
+ const yes = process.argv.includes("--yes");
61
+ (process.argv.includes("--install") ? runInstall({ yes }) : runUninstall())
60
62
  .then(() => process.exit(0))
61
63
  .catch(e => { console.error(e.message); process.exit(1); });
62
64
  } else if (!require("./cli").runCli(process.argv)) {
@@ -136,7 +138,7 @@ function writePins(pins) {
136
138
  writeJsonAtomic(PINS_FILE, pins);
137
139
  }
138
140
 
139
- // 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).
140
142
  // The pid rides along so a reader can tell a live server from a file left behind by
141
143
  // a crashed one.
142
144
  function writeServerInfo(port) {
@@ -165,8 +167,8 @@ function ownerPid(file) {
165
167
  try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
166
168
  }
167
169
 
168
- // A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
169
- // 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
170
172
  // an ephemeral port, so a stale beacon never comes back on its own — drop ours on
171
173
  // the way out. Only when the file is still ours: a newer server on the same config
172
174
  // dir has already claimed it. The terminal token file goes the same way.
@@ -271,7 +273,10 @@ function checkWaitingForUser(agentDir, logMtime) {
271
273
  const age = Date.now() - waitTime;
272
274
  if (age >= PERMISSION_TTL_MS) return null;
273
275
  // After grace period, check if session resumed activity (user already responded)
274
- 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;
275
280
  // Opted out reads the same as lapsed to the board: the ask is only
276
281
  // answerable in the terminal, so no buttons.
277
282
  if (boardRefusal(data, approvalsConfig())) return { ...data, lapsed: true };
@@ -313,7 +318,7 @@ function isAgentLive(agent) {
313
318
  // Claude Code records gitBranch from the launch-time repo and never updates it
314
319
  // when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
315
320
  // the live cwd instead. Cached per-cwd with a short TTL so a list refresh
316
- // 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.
317
322
  const gitBranchCache = new Map();
318
323
  const GIT_BRANCH_TTL_MS = 30000;
319
324
  const GIT_BRANCH_CACHE_MAX = 500;
@@ -322,18 +327,7 @@ function getGitBranch(cwd) {
322
327
  const now = Date.now();
323
328
  const cached = gitBranchCache.get(cwd);
324
329
  if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
325
- let branch = null;
326
- try {
327
- // cwd rather than `-C <cwd>`: a path beginning with a dash would otherwise be
328
- // read by git as an option.
329
- const r = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
330
- cwd, encoding: 'utf8', timeout: 500, windowsHide: true
331
- });
332
- if (r.status === 0) {
333
- const out = (r.stdout || '').trim();
334
- if (out && out !== 'HEAD') branch = out;
335
- }
336
- } catch (_) {}
330
+ const branch = readGitBranch(cwd);
337
331
  gitBranchCache.set(cwd, { branch, ts: now });
338
332
  if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
339
333
  const firstKey = gitBranchCache.keys().next().value;
@@ -349,8 +343,8 @@ const worktrees = createWorktreeStore({
349
343
  save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
350
344
  });
351
345
 
352
- // Only spawn git when cwd has diverged from the launch project — that's the
353
- // 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.
354
348
  function resolveSessionGitBranch(meta) {
355
349
  if (meta.cwd && meta.project && meta.cwd !== meta.project) {
356
350
  return getGitBranch(meta.cwd) || meta.gitBranch || null;
@@ -366,13 +360,25 @@ function getSessionLogStat(meta) {
366
360
  } catch { return { mtime: null, hasMessages: false }; }
367
361
  }
368
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
+
369
375
  function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
370
376
  const result = { hasActive: false, hasRunning: false, waitingForUser: null, unread: false };
371
377
  let names;
372
378
  try { names = readdirSync(agentDir); } catch { return result; }
373
379
  if (names.includes('_waiting.json')) result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
374
380
  if (result.waitingForUser) result.hasActive = true;
375
- const stopped = names.includes('_stop.json');
381
+ const stopped = names.includes('_stop.json') && !resumedSince(path.join(agentDir, '_stop.json'), logMtime);
376
382
  if (stale && !isTeam) {
377
383
  result.unread = stopped;
378
384
  return result;
@@ -774,33 +780,46 @@ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
774
780
  return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
775
781
  }
776
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.
777
785
  function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
778
786
  try {
779
787
  const cached = cache.get(cacheKey);
780
- 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;
781
789
  const st = statSync(filePath);
782
790
  if (cached && cached.mtime === st.mtimeMs) {
783
791
  cached.ts = Date.now();
784
792
  return cached.data;
785
793
  }
786
- const data = loadFn();
787
- 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);
788
806
  evictStaleCache(cache);
789
- return data;
807
+ return entry.data;
790
808
  } catch (_) { return fallback; }
791
809
  }
792
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
+
793
816
  const sessionDigestCache = new Map();
794
817
  function getSessionDigest(jsonlPath) {
795
818
  return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
796
819
  }
797
820
 
798
- function getProgressMap(jsonlPath) {
799
- return getSessionDigest(jsonlPath).progressMap;
800
- }
801
-
802
- function getTerminatedTeammates(jsonlPath) {
803
- return getSessionDigest(jsonlPath).terminated;
821
+ async function getProgressMap(jsonlPath) {
822
+ return (await getSessionDigest(jsonlPath)).progressMap;
804
823
  }
805
824
 
806
825
  function readRecentMessages(jsonlPath, limit = 10) {
@@ -836,6 +855,7 @@ function refreshSessionMetadataPath(jsonlPath) {
836
855
  if (info.customTitle) existing.customTitle = info.customTitle;
837
856
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
838
857
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
858
+ existing.permissionMode = info.permissionMode;
839
859
  return true;
840
860
  }
841
861
 
@@ -922,7 +942,8 @@ function loadSessionMetadata() {
922
942
  customTitle: sessionInfo.customTitle || null,
923
943
  jsonlPath: jsonlPath,
924
944
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
925
- compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
945
+ compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
946
+ permissionMode: sessionInfo.permissionMode
926
947
  };
927
948
  sessionIds.push(sessionId);
928
949
  }
@@ -1775,15 +1796,20 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1775
1796
 
1776
1797
  // Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
1777
1798
  // it already pays to notice the file grew. `load` may return a promise: the promise is
1778
- // 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.
1779
1801
  function cachedByFileStat(cache, filePath, load) {
1780
1802
  let stat;
1781
1803
  try { stat = statSync(filePath); } catch (_) { return null; }
1782
1804
  const hit = cache.get(filePath);
1783
- if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.value;
1784
- const value = load();
1785
- cache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value });
1786
- 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;
1787
1813
  }
1788
1814
 
1789
1815
  // Cold path — a full transcript scan, so the result is held until the file grows.
@@ -1794,12 +1820,12 @@ function getArtifactLinks(jsonlPath) {
1794
1820
  return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
1795
1821
  }
1796
1822
 
1797
- app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
1823
+ app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
1798
1824
  try {
1799
1825
  const metadata = loadSessionMetadata();
1800
1826
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1801
1827
  if (!meta?.jsonlPath) return res.json({ artifacts: [] });
1802
- res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
1828
+ res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
1803
1829
  } catch (error) {
1804
1830
  console.error('Error reading artifacts:', error);
1805
1831
  res.status(500).json({ error: 'Failed to read artifacts' });
@@ -1844,7 +1870,7 @@ async function findPads(root, depth = 0) {
1844
1870
  // stored, so re-reading a transcript cannot drift; the client links each pad once
1845
1871
  // and records that it did, which is what stops a re-read undoing an unlink.
1846
1872
  async function readCreatedPads(meta) {
1847
- const creations = readScratchpadCreations(meta.jsonlPath);
1873
+ const creations = await readScratchpadCreations(meta.jsonlPath);
1848
1874
  // No `scratch new` in the transcript means no disk work at all, so sessions that
1849
1875
  // never touch the CLI pay a substring pass over the transcript and nothing more.
1850
1876
  if (!creations.length) return [];
@@ -2122,7 +2148,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
2122
2148
  try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
2123
2149
  for (const f of files) {
2124
2150
  const agentId = f.slice('agent-'.length, -'.jsonl'.length);
2125
- const stats = extractTranscriptStats(path.join(runDir, f)) || {};
2151
+ const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
2126
2152
  let type = null;
2127
2153
  try {
2128
2154
  type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
@@ -2357,9 +2383,9 @@ app.get('/api/teams/:name', (req, res) => {
2357
2383
  // API: Get agents for a session
2358
2384
  // The resultUnavailable latch is persisted on the agent record. Bump this when
2359
2385
  // extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
2360
- const RESULT_SCAN = 2;
2386
+ const RESULT_SCAN = 3;
2361
2387
 
2362
- app.get('/api/sessions/:sessionId/agents', (req, res) => {
2388
+ app.get('/api/sessions/:sessionId/agents', async (req, res) => {
2363
2389
  const sessionId = resolveSessionId(req.params.sessionId);
2364
2390
  const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
2365
2391
  if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
@@ -2403,10 +2429,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2403
2429
  agents.push(agent);
2404
2430
  } catch { /* skip invalid */ }
2405
2431
  }
2432
+ const digest = meta.jsonlPath ? await getSessionDigest(meta.jsonlPath) : null;
2406
2433
  const liveAgents = agents.filter(isAgentLive);
2407
2434
  if (liveAgents.length && meta.jsonlPath) {
2408
2435
  try {
2409
- const terminated = getTerminatedTeammates(meta.jsonlPath);
2436
+ const { terminated } = digest;
2410
2437
  if (terminated.size) {
2411
2438
  for (const agent of liveAgents) {
2412
2439
  const agentName = agentDisplayName(agent);
@@ -2423,10 +2450,9 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2423
2450
  // Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
2424
2451
  // the parent will never read their output, so they're orphans. Match by agentId
2425
2452
  // when the digest already correlated tool_use→agent, else fall back to prompt text
2426
- // (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).
2427
2454
  try {
2428
- const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
2429
- getSessionDigest(meta.jsonlPath);
2455
+ const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } = digest;
2430
2456
  if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
2431
2457
  for (const agent of liveAgents) {
2432
2458
  if (!isAgentLive(agent)) continue;
@@ -2457,8 +2483,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2457
2483
  const byAgentId = {};
2458
2484
  if (meta.jsonlPath) {
2459
2485
  try {
2460
- const progressMap = getProgressMap(meta.jsonlPath);
2461
- for (const entry of Object.values(progressMap)) {
2486
+ for (const entry of Object.values(digest.progressMap)) {
2462
2487
  byAgentId[entry.agentId] ||= {};
2463
2488
  const e = byAgentId[entry.agentId];
2464
2489
  for (const f of ['prompt', 'name', 'description', 'usage']) {
@@ -2615,10 +2640,10 @@ app.post('/api/sessions/:sessionId/read', (req, res) => {
2615
2640
  }
2616
2641
  });
2617
2642
 
2618
- // UI-driven approvals: answers the ask approval-gate.sh is blocking on by
2619
- // writing _decision-<id>.json next to the marker. The hook consumes and deletes
2620
- // both files; a write for a hook that already gave up is accepted anyway (D13)
2621
- // 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.
2622
2647
  app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2623
2648
  const sessionId = resolveSessionId(req.params.sessionId);
2624
2649
  const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
@@ -2935,7 +2960,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
2935
2960
  res.on('error', cleanup);
2936
2961
  });
2937
2962
 
2938
- app.get('/api/sessions/:sessionId/messages', (req, res) => {
2963
+ async function sendSessionMessages(req, res) {
2939
2964
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
2940
2965
  const before = req.query.before || null;
2941
2966
  const metadata = loadSessionMetadata();
@@ -2952,9 +2977,10 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2952
2977
  hasMore = messages.length > limit;
2953
2978
  if (hasMore) messages = messages.slice(-limit);
2954
2979
  }
2980
+ const compactPromise = cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), []);
2955
2981
  const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
2956
2982
  if (agentMessages.length) {
2957
- const progressMap = getProgressMap(jsonlPath);
2983
+ const progressMap = await getProgressMap(jsonlPath);
2958
2984
  const resolvedSid = resolveSessionId(req.params.sessionId);
2959
2985
  const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedSid);
2960
2986
  for (const msg of agentMessages) {
@@ -3005,15 +3031,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
3005
3031
  }
3006
3032
  }
3007
3033
  }
3008
- const cachedCompact = compactSummaryCache.get(jsonlPath);
3009
- let compactSummaries;
3010
- if (cachedCompact && Date.now() - cachedCompact.ts < MESSAGE_CACHE_TTL) {
3011
- compactSummaries = cachedCompact.data;
3012
- } else {
3013
- compactSummaries = readCompactSummaries(jsonlPath);
3014
- compactSummaryCache.set(jsonlPath, { data: compactSummaries, ts: Date.now() });
3015
- evictStaleCache(compactSummaryCache);
3016
- }
3034
+ const compactSummaries = await compactPromise;
3017
3035
  // Match compaction messages to summaries by chronological order
3018
3036
  const compactedMsgs = messages
3019
3037
  .filter(m => m.systemLabel === 'Compacted')
@@ -3031,29 +3049,30 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
3031
3049
  delete msg.promptId;
3032
3050
  }
3033
3051
  res.json({ messages, hasMore, sessionId: req.params.sessionId });
3034
- });
3052
+ }
3053
+
3054
+ app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
3035
3055
 
3036
- app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
3056
+ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
3037
3057
  const metadata = loadSessionMetadata();
3038
3058
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3039
3059
  const jsonlPath = meta?.jsonlPath;
3040
3060
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3041
- const content = readFullToolResult(jsonlPath, req.params.toolUseId);
3061
+ const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
3042
3062
  if (content == null) return res.status(404).json({ error: 'tool result not found' });
3043
3063
  res.json({ toolUseId: req.params.toolUseId, content });
3044
- });
3064
+ }));
3045
3065
 
3046
3066
  const toolStatsCache = new Map();
3047
3067
 
3048
- function buildToolStats(jsonlPath) {
3068
+ async function buildToolStats(jsonlPath) {
3049
3069
  const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
3050
3070
  const seenResults = new Set();
3051
3071
  const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
3052
3072
  const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
3053
3073
  const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
3054
3074
 
3055
- const content = readFileSync(jsonlPath, 'utf8');
3056
- for (const line of content.split('\n')) {
3075
+ for await (const line of readLines(jsonlPath)) {
3057
3076
  if (!line) continue;
3058
3077
  let obj;
3059
3078
  try { obj = JSON.parse(line); } catch (_) { continue; }
@@ -3140,13 +3159,13 @@ function buildToolStats(jsonlPath) {
3140
3159
  return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
3141
3160
  }
3142
3161
 
3143
- app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
3162
+ app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
3144
3163
  const metadata = loadSessionMetadata();
3145
3164
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3146
3165
  const jsonlPath = meta?.jsonlPath;
3147
3166
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3148
3167
  try {
3149
- const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
3168
+ const data = await cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
3150
3169
  if (!data) return res.status(404).json({ error: 'could not parse session' });
3151
3170
  res.json({ sessionId: req.params.sessionId, ...data });
3152
3171
  } catch (e) {
@@ -3154,31 +3173,24 @@ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
3154
3173
  }
3155
3174
  });
3156
3175
 
3157
- app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
3176
+ async function sendTranscriptImage(req, res, read) {
3158
3177
  const metadata = loadSessionMetadata();
3159
3178
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3160
3179
  const jsonlPath = meta?.jsonlPath;
3161
3180
  if (!jsonlPath) return res.status(404).end();
3162
- const img = readUserImage(jsonlPath, req.params.msgUuid, req.params.blockIndex);
3181
+ const img = await read(jsonlPath);
3163
3182
  if (!img) return res.status(404).end();
3164
3183
  const buf = Buffer.from(img.data, 'base64');
3165
3184
  res.setHeader('Content-Type', img.mediaType);
3166
3185
  res.setHeader('Cache-Control', 'no-store');
3167
3186
  res.end(buf);
3168
- });
3187
+ }
3169
3188
 
3170
- app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', (req, res) => {
3171
- const metadata = loadSessionMetadata();
3172
- const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3173
- const jsonlPath = meta?.jsonlPath;
3174
- if (!jsonlPath) return res.status(404).end();
3175
- const img = readToolResultImage(jsonlPath, req.params.toolUseId, req.params.n);
3176
- if (!img) return res.status(404).end();
3177
- const buf = Buffer.from(img.data, 'base64');
3178
- res.setHeader('Content-Type', img.mediaType);
3179
- res.setHeader('Cache-Control', 'no-store');
3180
- res.end(buf);
3181
- });
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))));
3182
3194
 
3183
3195
  app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
3184
3196
  const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
@@ -4248,7 +4260,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
4248
4260
  }
4249
4261
  });
4250
4262
 
4251
- // Watch context-status directory for statusline updates
4263
+ // Watch context-status directory for the plugin's context and cost updates
4252
4264
  const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
4253
4265
  persistent: true,
4254
4266
  ignoreInitial: false,
@@ -4308,9 +4320,8 @@ async function cleanupAgentActivity() {
4308
4320
  await fs.rm(dirPath, { recursive: true, force: true });
4309
4321
  continue;
4310
4322
  }
4311
- // Orphaned decision files: a terminal deny fires no hook event (D13), so a
4312
- // board answer for an already-gone gate is never consumed. The gate waits at
4313
- // 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.
4314
4325
  for (const f of contents) {
4315
4326
  if (!isDecisionFile(f)) continue;
4316
4327
  const fp = path.join(dirPath, f);
@@ -1,135 +0,0 @@
1
- #!/bin/bash
2
- # Tracks subagent lifecycle: one append-only JSONL file per agent, grouped by session
3
- # Layout: <CLAUDE_CONFIG_DIR or ~/.claude>/.cck/agent-activity/{sessionId}/{agentId}.jsonl
4
- # Each line is a lifecycle event (start | idle | stop). Server folds last-line-wins.
5
-
6
- INPUT=$(cat)
7
-
8
- # Single jq call to extract all routing fields
9
- eval "$(echo "$INPUT" | jq -r '
10
- @sh "SESSION_ID=\(.session_id // "")",
11
- @sh "AGENT_ID=\(.agent_id // "")",
12
- @sh "EVENT=\(.hook_event_name // "")",
13
- @sh "TOOL_NAME=\(.tool_name // "")",
14
- @sh "AGENT_TYPE_RAW=\(.agent_type // "")",
15
- @sh "TEAMMATE_NAME=\(.teammate_name // "")"
16
- ')"
17
-
18
- [ -z "$SESSION_ID" ] && exit 0
19
-
20
- CCK_ACTIVITY="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.cck/agent-activity"
21
-
22
- # Map session to custom task list on session start
23
- if [ "$EVENT" = "SessionStart" ]; then
24
- TASK_LIST_ID="${CLAUDE_CODE_TASK_LIST_ID:-}"
25
- if [ -n "$TASK_LIST_ID" ]; then
26
- CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
27
- MAPS_DIR="$CCK_ACTIVITY/_task-maps"
28
- mkdir -p "$MAPS_DIR"
29
- MAP_FILE="$MAPS_DIR/$TASK_LIST_ID.json"
30
- TMP_FILE="$MAP_FILE.$$"
31
- TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
32
- EXISTING="{}"
33
- [ -f "$MAP_FILE" ] && EXISTING=$(cat "$MAP_FILE")
34
- echo "$EXISTING" | jq -c --arg sid "$SESSION_ID" --arg cwd "$CWD" --arg ts "$TS" \
35
- '.[$sid] = {project: $cwd, updatedAt: $ts}' > "$TMP_FILE" && mv "$TMP_FILE" "$MAP_FILE"
36
- fi
37
- exit 0
38
- fi
39
-
40
- # Stop: the session finished its turn. The marker means unread; the board deletes it on open.
41
- if [ "$EVENT" = "Stop" ]; then
42
- DIR="$CCK_ACTIVITY/$SESSION_ID"
43
- [ -d "$DIR" ] || mkdir -p "$DIR"
44
- : > "$DIR/_stop.json"
45
- exit 0
46
- fi
47
-
48
- # PostToolUse / non-waiting PreToolUse: clear waiting state, and unread since the session is working again
49
- if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ] && [ "$TOOL_NAME" != "ExitPlanMode" ]; }; then
50
- rm -f "$CCK_ACTIVITY/$SESSION_ID/_waiting.json" "$CCK_ACTIVITY/$SESSION_ID/_stop.json"
51
- [ "$EVENT" = "PostToolUse" ] && exit 0
52
- fi
53
-
54
- # EnterPlanMode has no waiting semantics — skip
55
- [ "$TOOL_NAME" = "EnterPlanMode" ] && exit 0
56
-
57
- # Legacy-config shim: shipped hooks.json routes PermissionRequest to
58
- # approval-gate.sh (which owns the id-bearing marker) and registers no
59
- # PreToolUse, so the suppression and writer below only run for configs that
60
- # still route those events here. Suppressing question/plan PermissionRequest
61
- # keeps such a config from overwriting the gate's marker with an id-less one.
62
- if [ "$EVENT" = "PermissionRequest" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; then
63
- exit 0
64
- fi
65
-
66
- # Waiting-for-user events → write _waiting.json marker
67
- if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; }; then
68
- DIR="$CCK_ACTIVITY/$SESSION_ID"
69
- mkdir -p "$DIR"
70
- KIND="permission"
71
- [ "$EVENT" = "PreToolUse" ] && KIND="question"
72
- TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
73
- echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" '{
74
- status: "waiting",
75
- kind: $kind,
76
- toolName: (.tool_name // "unknown"),
77
- toolInput: ((.tool_input | tostring) // ""),
78
- timestamp: $ts
79
- }' > "$DIR/_waiting.json"
80
- exit 0
81
- fi
82
-
83
- # TeammateIdle has no agent_id — resolve via name→id mapping file
84
- if [ "$EVENT" = "TeammateIdle" ] && [ -z "$AGENT_ID" ] && [ -n "$TEAMMATE_NAME" ]; then
85
- DIR="$CCK_ACTIVITY/$SESSION_ID"
86
- MAP_FILE="$DIR/_name-${TEAMMATE_NAME}.id"
87
- [ ! -f "$MAP_FILE" ] && exit 0
88
- AGENT_ID=$(cat "$MAP_FILE")
89
- [ -z "$AGENT_ID" ] && exit 0
90
- FILE="$DIR/$AGENT_ID.jsonl"
91
- TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
92
- echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$TEAMMATE_NAME\",\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
93
- exit 0
94
- fi
95
-
96
- [ -z "$AGENT_ID" ] && exit 0
97
-
98
- DIR="$CCK_ACTIVITY/$SESSION_ID"
99
- FILE="$DIR/$AGENT_ID.jsonl"
100
-
101
- # On Start: skip if no type (internal agents like AskUserQuestion)
102
- # On Stop/Idle: only skip if no existing file (never tracked)
103
- if [ -z "$AGENT_TYPE_RAW" ]; then
104
- if [ "$EVENT" = "SubagentStart" ]; then
105
- exit 0
106
- elif [ ! -f "$FILE" ]; then
107
- exit 0
108
- fi
109
- fi
110
-
111
- mkdir -p "$DIR"
112
- TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
113
-
114
- if [ "$EVENT" = "SubagentStart" ]; then
115
- echo "{\"agentId\":\"$AGENT_ID\",\"type\":\"$AGENT_TYPE_RAW\",\"event\":\"start\",\"status\":\"active\",\"startedAt\":\"$TS\",\"updatedAt\":\"$TS\"}" >> "$FILE"
116
- # Mapping always points at latest agent of this type (used by TeammateIdle resolution).
117
- if [ -n "$AGENT_TYPE_RAW" ]; then
118
- echo -n "$AGENT_ID" > "$DIR/_name-${AGENT_TYPE_RAW}.id"
119
- fi
120
-
121
- elif [ "$EVENT" = "SubagentStop" ]; then
122
- # Omit empty type: the server folds lines last-key-wins, so an empty type
123
- # here would clobber the type recorded by the start line
124
- echo "$INPUT" | jq -c \
125
- --arg id "$AGENT_ID" --arg type "$AGENT_TYPE_RAW" --arg ts "$TS" \
126
- '{agentId: $id, event: "stop", status: "stopped",
127
- lastMessage: (.last_assistant_message // ""), stoppedAt: $ts, updatedAt: $ts}
128
- + (if $type == "" then {} else {type: $type} end)' \
129
- >> "$FILE"
130
-
131
- elif [ "$EVENT" = "TeammateIdle" ]; then
132
- TYPE_FIELD=""
133
- [ -n "$AGENT_TYPE_RAW" ] && TYPE_FIELD="\"type\":\"$AGENT_TYPE_RAW\","
134
- echo "{\"agentId\":\"$AGENT_ID\",${TYPE_FIELD}\"event\":\"idle\",\"status\":\"idle\",\"updatedAt\":\"$TS\"}" >> "$FILE"
135
- fi