claude-code-kanban 5.3.0 → 6.0.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 (36) hide show
  1. package/README.md +4 -4
  2. package/cli.js +30 -108
  3. package/install.js +43 -123
  4. package/lib/approvals.js +16 -17
  5. package/lib/dispatch-groups.js +9 -31
  6. package/lib/dispatch.js +19 -166
  7. package/lib/git-branch.js +62 -0
  8. package/lib/parsers.js +96 -59
  9. package/lib/plugin-status.js +1 -1
  10. package/lib/retention.js +5 -17
  11. package/lib/session-cache.js +1 -1
  12. package/lib/session-events.js +3 -2
  13. package/lib/terminal.js +30 -5
  14. package/package.json +1 -2
  15. package/plugin/.claude-plugin/marketplace.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
  17. package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
  20. package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
  21. package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
  22. package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
  23. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
  24. package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
  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 +257 -74
  29. package/public/index.html +12 -2
  30. package/public/project-match.js +6 -1
  31. package/public/style.css +99 -0
  32. package/server.js +146 -147
  33. package/skill-guides/dispatch.md +16 -54
  34. package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
  35. package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
  36. 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,17 +35,19 @@ 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
43
  const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
43
44
  const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
44
45
  const { createProcStats } = require('./lib/proc-stats');
45
- const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
46
+ const { createDispatchRegistry } = require('./lib/dispatch');
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, sessionGitBranch } = 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,15 +343,6 @@ 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.
354
- function resolveSessionGitBranch(meta) {
355
- if (meta.cwd && meta.project && meta.cwd !== meta.project) {
356
- return getGitBranch(meta.cwd) || meta.gitBranch || null;
357
- }
358
- return meta.gitBranch || null;
359
- }
360
-
361
346
  function getSessionLogStat(meta) {
362
347
  if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
363
348
  try {
@@ -366,13 +351,25 @@ function getSessionLogStat(meta) {
366
351
  } catch { return { mtime: null, hasMessages: false }; }
367
352
  }
368
353
 
354
+ // A transcript write past the grace window after a marker means the session went on.
355
+ function movedOnSince(markerMs, logMtime) {
356
+ return !!logMtime && logMtime > markerMs + WAITING_RESOLVE_GRACE_MS;
357
+ }
358
+
359
+ // The mod cannot delete _stop.json when the session works again.
360
+ function resumedSince(markerPath, logMtime) {
361
+ if (!logMtime) return false;
362
+ try { return movedOnSince(statSync(markerPath).mtimeMs, logMtime); }
363
+ catch { return true; }
364
+ }
365
+
369
366
  function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
370
367
  const result = { hasActive: false, hasRunning: false, waitingForUser: null, unread: false };
371
368
  let names;
372
369
  try { names = readdirSync(agentDir); } catch { return result; }
373
370
  if (names.includes('_waiting.json')) result.waitingForUser = checkWaitingForUser(agentDir, logMtime);
374
371
  if (result.waitingForUser) result.hasActive = true;
375
- const stopped = names.includes('_stop.json');
372
+ const stopped = names.includes('_stop.json') && !resumedSince(path.join(agentDir, '_stop.json'), logMtime);
376
373
  if (stale && !isTeam) {
377
374
  result.unread = stopped;
378
375
  return result;
@@ -774,33 +771,46 @@ function taskDirBeats(candMtime, candCount, curMtime, curCount) {
774
771
  return candMtime > curMtime || (candMtime === curMtime && candCount > curCount);
775
772
  }
776
773
 
774
+ // A promise from loadFn is cached as is, so concurrent callers share one read, and a newer
775
+ // mtime starts no second read while it is pending: full-transcript scans stream for seconds.
777
776
  function cachedByMtime(cache, cacheKey, filePath, loadFn, fallback) {
778
777
  try {
779
778
  const cached = cache.get(cacheKey);
780
- if (cached && Date.now() - cached.ts < MESSAGE_CACHE_TTL) return cached.data;
779
+ if (cached && (cached.pending || Date.now() - cached.ts < MESSAGE_CACHE_TTL)) return cached.data;
781
780
  const st = statSync(filePath);
782
781
  if (cached && cached.mtime === st.mtimeMs) {
783
782
  cached.ts = Date.now();
784
783
  return cached.data;
785
784
  }
786
- const data = loadFn();
787
- cache.set(cacheKey, { data, mtime: st.mtimeMs, ts: Date.now() });
785
+ const entry = { data: loadFn(), mtime: st.mtimeMs, ts: Date.now() };
786
+ if (typeof entry.data?.then === 'function') {
787
+ entry.pending = true;
788
+ entry.data = entry.data.catch(() => {
789
+ if (cache.get(cacheKey) === entry) cache.delete(cacheKey);
790
+ return fallback;
791
+ }).finally(() => {
792
+ entry.pending = false;
793
+ entry.ts = Date.now();
794
+ });
795
+ }
796
+ cache.set(cacheKey, entry);
788
797
  evictStaleCache(cache);
789
- return data;
798
+ return entry.data;
790
799
  } catch (_) { return fallback; }
791
800
  }
792
801
 
802
+ // Express 4 does not pass a rejected handler promise to next(), and Node 24 exits on it.
803
+ function asyncRoute(fn) {
804
+ return (req, res, next) => fn(req, res, next).catch(next);
805
+ }
806
+
793
807
  const sessionDigestCache = new Map();
794
808
  function getSessionDigest(jsonlPath) {
795
809
  return cachedByMtime(sessionDigestCache, jsonlPath, jsonlPath, () => buildSessionDigest(jsonlPath), { progressMap: {}, terminated: new Map() });
796
810
  }
797
811
 
798
- function getProgressMap(jsonlPath) {
799
- return getSessionDigest(jsonlPath).progressMap;
800
- }
801
-
802
- function getTerminatedTeammates(jsonlPath) {
803
- return getSessionDigest(jsonlPath).terminated;
812
+ async function getProgressMap(jsonlPath) {
813
+ return (await getSessionDigest(jsonlPath)).progressMap;
804
814
  }
805
815
 
806
816
  function readRecentMessages(jsonlPath, limit = 10) {
@@ -836,6 +846,7 @@ function refreshSessionMetadataPath(jsonlPath) {
836
846
  if (info.customTitle) existing.customTitle = info.customTitle;
837
847
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
838
848
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
849
+ existing.permissionMode = info.permissionMode;
839
850
  return true;
840
851
  }
841
852
 
@@ -922,7 +933,8 @@ function loadSessionMetadata() {
922
933
  customTitle: sessionInfo.customTitle || null,
923
934
  jsonlPath: jsonlPath,
924
935
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
925
- compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
936
+ compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
937
+ permissionMode: sessionInfo.permissionMode
926
938
  };
927
939
  sessionIds.push(sessionId);
928
940
  }
@@ -1147,6 +1159,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1147
1159
  const logStat = overrides._logStat || getSessionLogStat(meta);
1148
1160
  const logMtime = logStat.mtime;
1149
1161
  const logAge = logMtime ? Date.now() - logMtime : Infinity;
1162
+ const worktree = worktrees.resolve(meta.project);
1150
1163
  return {
1151
1164
  id,
1152
1165
  name: getSessionDisplayName(id, meta),
@@ -1155,8 +1168,8 @@ function buildSessionObject(id, meta, overrides = {}) {
1155
1168
  project: meta.project || null,
1156
1169
  cwd: meta.cwd || null,
1157
1170
  description: meta.description || null,
1158
- gitBranch: resolveSessionGitBranch(meta),
1159
- worktree: worktrees.resolve(meta.project),
1171
+ gitBranch: sessionGitBranch(meta, !!worktree, getGitBranch),
1172
+ worktree,
1160
1173
  customTitle: meta.customTitle || null,
1161
1174
  taskCount: 0,
1162
1175
  completed: 0,
@@ -1610,6 +1623,30 @@ app.get('/api/sessions/search', (req, res) => {
1610
1623
  res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
1611
1624
  });
1612
1625
 
1626
+ app.get('/api/sessions/known', (_req, res) => {
1627
+ res.setHeader('Cache-Control', 'no-store');
1628
+ try {
1629
+ const known = new Map();
1630
+ for (const [id, meta] of Object.entries(loadSessionMetadata())) {
1631
+ known.set(id, { id, project: meta.project || null, name: getSessionDisplayName(id, meta) });
1632
+ }
1633
+ const add = (id, project = null) => {
1634
+ if (!known.has(id)) known.set(id, { id, project, name: null });
1635
+ };
1636
+ for (const dir of [TASKS_DIR, AGENT_ACTIVITY_DIR]) {
1637
+ if (!existsSync(dir)) continue;
1638
+ for (const d of readdirSync(dir, { withFileTypes: true })) if (d.isDirectory()) add(d.name);
1639
+ }
1640
+ for (const map of Object.values(loadAllTaskMaps().listToSessions)) {
1641
+ for (const [id, info] of Object.entries(map)) add(id, info.project || null);
1642
+ }
1643
+ res.json([...known.values()]);
1644
+ } catch (error) {
1645
+ console.error('Error listing known sessions:', error);
1646
+ res.status(500).json({ error: 'Failed to list known sessions' });
1647
+ }
1648
+ });
1649
+
1613
1650
  function isTempPath(p) {
1614
1651
  const rel = path.relative(TEMP_ROOT, p);
1615
1652
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
@@ -1775,15 +1812,20 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
1775
1812
 
1776
1813
  // Memo for a cold read keyed on the file's identity, so a repeat ask costs the one stat
1777
1814
  // 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.
1815
+ // what gets cached, so concurrent callers share a single read, and a file that grows while
1816
+ // it is pending starts no second one. Null when the file is gone.
1779
1817
  function cachedByFileStat(cache, filePath, load) {
1780
1818
  let stat;
1781
1819
  try { stat = statSync(filePath); } catch (_) { return null; }
1782
1820
  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;
1821
+ if (hit && (hit.pending || (hit.mtimeMs === stat.mtimeMs && hit.size === stat.size))) return hit.value;
1822
+ const entry = { mtimeMs: stat.mtimeMs, size: stat.size, value: load() };
1823
+ if (typeof entry.value?.then === 'function') {
1824
+ entry.pending = true;
1825
+ entry.value.finally(() => { entry.pending = false; }).catch(() => {});
1826
+ }
1827
+ cache.set(filePath, entry);
1828
+ return entry.value;
1787
1829
  }
1788
1830
 
1789
1831
  // Cold path — a full transcript scan, so the result is held until the file grows.
@@ -1794,12 +1836,12 @@ function getArtifactLinks(jsonlPath) {
1794
1836
  return cachedByFileStat(artifactsByPath, jsonlPath, () => readArtifactLinks(jsonlPath)) || [];
1795
1837
  }
1796
1838
 
1797
- app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
1839
+ app.get('/api/sessions/:sessionId/artifacts', async (req, res) => {
1798
1840
  try {
1799
1841
  const metadata = loadSessionMetadata();
1800
1842
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
1801
1843
  if (!meta?.jsonlPath) return res.json({ artifacts: [] });
1802
- res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
1844
+ res.json({ artifacts: await getArtifactLinks(meta.jsonlPath) });
1803
1845
  } catch (error) {
1804
1846
  console.error('Error reading artifacts:', error);
1805
1847
  res.status(500).json({ error: 'Failed to read artifacts' });
@@ -1844,7 +1886,7 @@ async function findPads(root, depth = 0) {
1844
1886
  // stored, so re-reading a transcript cannot drift; the client links each pad once
1845
1887
  // and records that it did, which is what stops a re-read undoing an unlink.
1846
1888
  async function readCreatedPads(meta) {
1847
- const creations = readScratchpadCreations(meta.jsonlPath);
1889
+ const creations = await readScratchpadCreations(meta.jsonlPath);
1848
1890
  // No `scratch new` in the transcript means no disk work at all, so sessions that
1849
1891
  // never touch the CLI pay a substring pass over the transcript and nothing more.
1850
1892
  if (!creations.length) return [];
@@ -2122,7 +2164,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
2122
2164
  try { files = readdirSync(runDir).filter((f) => /^agent-.+\.jsonl$/.test(f)); } catch (_) {}
2123
2165
  for (const f of files) {
2124
2166
  const agentId = f.slice('agent-'.length, -'.jsonl'.length);
2125
- const stats = extractTranscriptStats(path.join(runDir, f)) || {};
2167
+ const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
2126
2168
  let type = null;
2127
2169
  try {
2128
2170
  type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
@@ -2357,9 +2399,9 @@ app.get('/api/teams/:name', (req, res) => {
2357
2399
  // API: Get agents for a session
2358
2400
  // The resultUnavailable latch is persisted on the agent record. Bump this when
2359
2401
  // extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
2360
- const RESULT_SCAN = 2;
2402
+ const RESULT_SCAN = 3;
2361
2403
 
2362
- app.get('/api/sessions/:sessionId/agents', (req, res) => {
2404
+ app.get('/api/sessions/:sessionId/agents', async (req, res) => {
2363
2405
  const sessionId = resolveSessionId(req.params.sessionId);
2364
2406
  const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
2365
2407
  if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
@@ -2403,10 +2445,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2403
2445
  agents.push(agent);
2404
2446
  } catch { /* skip invalid */ }
2405
2447
  }
2448
+ const digest = meta.jsonlPath ? await getSessionDigest(meta.jsonlPath) : null;
2406
2449
  const liveAgents = agents.filter(isAgentLive);
2407
2450
  if (liveAgents.length && meta.jsonlPath) {
2408
2451
  try {
2409
- const terminated = getTerminatedTeammates(meta.jsonlPath);
2452
+ const { terminated } = digest;
2410
2453
  if (terminated.size) {
2411
2454
  for (const agent of liveAgents) {
2412
2455
  const agentName = agentDisplayName(agent);
@@ -2423,10 +2466,9 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2423
2466
  // Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
2424
2467
  // the parent will never read their output, so they're orphans. Match by agentId
2425
2468
  // 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).
2469
+ // (the plugin's mod doesn't record the spawning tool_use_id).
2427
2470
  try {
2428
- const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
2429
- getSessionDigest(meta.jsonlPath);
2471
+ const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } = digest;
2430
2472
  if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
2431
2473
  for (const agent of liveAgents) {
2432
2474
  if (!isAgentLive(agent)) continue;
@@ -2457,8 +2499,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2457
2499
  const byAgentId = {};
2458
2500
  if (meta.jsonlPath) {
2459
2501
  try {
2460
- const progressMap = getProgressMap(meta.jsonlPath);
2461
- for (const entry of Object.values(progressMap)) {
2502
+ for (const entry of Object.values(digest.progressMap)) {
2462
2503
  byAgentId[entry.agentId] ||= {};
2463
2504
  const e = byAgentId[entry.agentId];
2464
2505
  for (const f of ['prompt', 'name', 'description', 'usage']) {
@@ -2615,10 +2656,10 @@ app.post('/api/sessions/:sessionId/read', (req, res) => {
2615
2656
  }
2616
2657
  });
2617
2658
 
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.
2659
+ // UI-driven approvals: answers the ask the plugin's mod is polling for by writing
2660
+ // _decision-<id>.json next to the marker. The mod cannot delete files, so it clears
2661
+ // the marker and leaves the decision, and a write for an ask that is already over
2662
+ // is accepted anyway (D13); cleanupAgentActivity's sweep removes both kinds.
2622
2663
  app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
2623
2664
  const sessionId = resolveSessionId(req.params.sessionId);
2624
2665
  const dir = path.join(AGENT_ACTIVITY_DIR, sessionId);
@@ -2935,7 +2976,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
2935
2976
  res.on('error', cleanup);
2936
2977
  });
2937
2978
 
2938
- app.get('/api/sessions/:sessionId/messages', (req, res) => {
2979
+ async function sendSessionMessages(req, res) {
2939
2980
  const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
2940
2981
  const before = req.query.before || null;
2941
2982
  const metadata = loadSessionMetadata();
@@ -2952,9 +2993,10 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2952
2993
  hasMore = messages.length > limit;
2953
2994
  if (hasMore) messages = messages.slice(-limit);
2954
2995
  }
2996
+ const compactPromise = cachedByMtime(compactSummaryCache, jsonlPath, jsonlPath, () => readCompactSummaries(jsonlPath), []);
2955
2997
  const agentMessages = messages.filter(m => m.tool === 'Agent' && m.toolUseId);
2956
2998
  if (agentMessages.length) {
2957
- const progressMap = getProgressMap(jsonlPath);
2999
+ const progressMap = await getProgressMap(jsonlPath);
2958
3000
  const resolvedSid = resolveSessionId(req.params.sessionId);
2959
3001
  const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedSid);
2960
3002
  for (const msg of agentMessages) {
@@ -3005,15 +3047,7 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
3005
3047
  }
3006
3048
  }
3007
3049
  }
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
- }
3050
+ const compactSummaries = await compactPromise;
3017
3051
  // Match compaction messages to summaries by chronological order
3018
3052
  const compactedMsgs = messages
3019
3053
  .filter(m => m.systemLabel === 'Compacted')
@@ -3031,29 +3065,30 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
3031
3065
  delete msg.promptId;
3032
3066
  }
3033
3067
  res.json({ messages, hasMore, sessionId: req.params.sessionId });
3034
- });
3068
+ }
3035
3069
 
3036
- app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
3070
+ app.get('/api/sessions/:sessionId/messages', asyncRoute(sendSessionMessages));
3071
+
3072
+ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', asyncRoute(async (req, res) => {
3037
3073
  const metadata = loadSessionMetadata();
3038
3074
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3039
3075
  const jsonlPath = meta?.jsonlPath;
3040
3076
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3041
- const content = readFullToolResult(jsonlPath, req.params.toolUseId);
3077
+ const content = await readFullToolResult(jsonlPath, req.params.toolUseId);
3042
3078
  if (content == null) return res.status(404).json({ error: 'tool result not found' });
3043
3079
  res.json({ toolUseId: req.params.toolUseId, content });
3044
- });
3080
+ }));
3045
3081
 
3046
3082
  const toolStatsCache = new Map();
3047
3083
 
3048
- function buildToolStats(jsonlPath) {
3084
+ async function buildToolStats(jsonlPath) {
3049
3085
  const toolUseById = {}; // tool_use_id -> { displayName, isSkill }
3050
3086
  const seenResults = new Set();
3051
3087
  const toolMap = {}; // displayName -> { count, success, failed, outputBytes }
3052
3088
  const skillPromptIds = {}; // promptId -> [skillDisplayName, ...]
3053
3089
  const promptOutputBytes = {}; // promptId -> total outputBytes in that turn
3054
3090
 
3055
- const content = readFileSync(jsonlPath, 'utf8');
3056
- for (const line of content.split('\n')) {
3091
+ for await (const line of readLines(jsonlPath)) {
3057
3092
  if (!line) continue;
3058
3093
  let obj;
3059
3094
  try { obj = JSON.parse(line); } catch (_) { continue; }
@@ -3140,13 +3175,13 @@ function buildToolStats(jsonlPath) {
3140
3175
  return { totalCalls, uniqueTools, totalFailed, totalRejected, tools };
3141
3176
  }
3142
3177
 
3143
- app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
3178
+ app.get('/api/sessions/:sessionId/tool-stats', async (req, res) => {
3144
3179
  const metadata = loadSessionMetadata();
3145
3180
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3146
3181
  const jsonlPath = meta?.jsonlPath;
3147
3182
  if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
3148
3183
  try {
3149
- const data = cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
3184
+ const data = await cachedByMtime(toolStatsCache, jsonlPath, jsonlPath, () => buildToolStats(jsonlPath), null);
3150
3185
  if (!data) return res.status(404).json({ error: 'could not parse session' });
3151
3186
  res.json({ sessionId: req.params.sessionId, ...data });
3152
3187
  } catch (e) {
@@ -3154,31 +3189,24 @@ app.get('/api/sessions/:sessionId/tool-stats', (req, res) => {
3154
3189
  }
3155
3190
  });
3156
3191
 
3157
- app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
3192
+ async function sendTranscriptImage(req, res, read) {
3158
3193
  const metadata = loadSessionMetadata();
3159
3194
  const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
3160
3195
  const jsonlPath = meta?.jsonlPath;
3161
3196
  if (!jsonlPath) return res.status(404).end();
3162
- const img = readUserImage(jsonlPath, req.params.msgUuid, req.params.blockIndex);
3197
+ const img = await read(jsonlPath);
3163
3198
  if (!img) return res.status(404).end();
3164
3199
  const buf = Buffer.from(img.data, 'base64');
3165
3200
  res.setHeader('Content-Type', img.mediaType);
3166
3201
  res.setHeader('Cache-Control', 'no-store');
3167
3202
  res.end(buf);
3168
- });
3203
+ }
3169
3204
 
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
- });
3205
+ app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', asyncRoute((req, res) =>
3206
+ sendTranscriptImage(req, res, (p) => readUserImage(p, req.params.msgUuid, req.params.blockIndex))));
3207
+
3208
+ app.get('/api/sessions/:sessionId/tool-result-image/:toolUseId/:n', asyncRoute((req, res) =>
3209
+ sendTranscriptImage(req, res, (p) => readToolResultImage(p, req.params.toolUseId, req.params.n))));
3182
3210
 
3183
3211
  app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
3184
3212
  const img = readCachedImage(req.params.sessionId, req.params.n, CLAUDE_DIR);
@@ -3264,7 +3292,9 @@ const terminal = createTerminalService({
3264
3292
  isAllowedFolder,
3265
3293
  liveSessions: () => loadLiveSessions(true),
3266
3294
  onChange: () => broadcast({ type: 'terminals-update', ids: terminal.list().map((t) => t.id) }),
3267
- onExit: (id) => dispatches.sessionExited(id),
3295
+ onExit: (id) => {
3296
+ if (dispatches.remove(id)) broadcast({ type: 'dispatch-update' });
3297
+ },
3268
3298
  });
3269
3299
 
3270
3300
  let folderDialogOpen = false;
@@ -3334,13 +3364,7 @@ const dispatched = createDispatchedStore({
3334
3364
  save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
3335
3365
  });
3336
3366
 
3337
- const dispatches = createDispatchRegistry({
3338
- onSettle: (r) => {
3339
- if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
3340
- if (r.session) dispatched.settle(r.session, r.status);
3341
- broadcast({ type: 'dispatch-update' });
3342
- },
3343
- });
3367
+ const dispatches = createDispatchRegistry();
3344
3368
 
3345
3369
  const dispatchGroups = createGroupStore({
3346
3370
  load: () => {
@@ -3359,8 +3383,6 @@ const linkedDocs = createLinkedDocStore({
3359
3383
  });
3360
3384
 
3361
3385
  // The board places a session from these alone, so it never has to move it later.
3362
- // `startedBy` lets it follow a starter the user put in a named group, which only the
3363
- // browser knows; it goes once the dispatch settles. `dispatched` stays for the card's marker.
3364
3386
  function withDispatchPlacement(sessions) {
3365
3387
  const groups = dispatchGroups.snapshot();
3366
3388
  return sessions.map((s) => {
@@ -3370,56 +3392,35 @@ function withDispatchPlacement(sessions) {
3370
3392
  return {
3371
3393
  ...s,
3372
3394
  dispatchGroup,
3373
- startedBy: marker?.status === 'running' ? marker.parent || undefined : undefined,
3374
- dispatched: marker ? { parent: marker.parent, outcome: DISPATCH_OUTCOME[marker.status] } : undefined,
3395
+ dispatched: marker ? { parent: marker.parent } : undefined,
3375
3396
  };
3376
3397
  });
3377
3398
  }
3378
3399
 
3379
3400
  // Starting a session needs the terminal token, as the browser does; the CLI reads it from
3380
- // TERMINAL_TOKEN_FILE. The child gets only its dispatch capability through the preamble.
3401
+ // TERMINAL_TOKEN_FILE. The started session never holds it.
3381
3402
  app.post('/api/dispatch', (req, res) => {
3382
3403
  if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3383
- const { cwd, spec, name, model, worktree, parent, group, report, peer } = req.body || {};
3404
+ const { cwd, spec, name, model, worktree, parent, group, claudeArgs } = req.body || {};
3384
3405
  if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3385
- if (peer != null && !isPeerName(peer)) return res.status(400).json({ error: 'peer must be a peer name as ListAgents prints it' });
3386
3406
  if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
3387
3407
  if (group != null && !isGroupName(group)) {
3388
3408
  return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3389
3409
  }
3390
- const target = group || (parent && dispatchGroups.groupOf(parent)) || null;
3391
- const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
3392
- const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
3393
- const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
3394
- if (started.error) {
3395
- dispatches.discard(r.id);
3396
- return res.status(started.status).json({ error: started.error });
3397
- }
3398
- dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
3410
+ const started = terminal.startNew({ cwd, name, model, worktree, prompt: spec.trim(), extraArgs: claudeArgs });
3411
+ if (started.error) return res.status(started.status).json({ error: started.error });
3412
+ dispatches.add({ session: started.id, parent, cwd: started.cwd, name, group: group || null, worktree });
3399
3413
  dispatched.record(started.id, parent);
3400
3414
  // The starter stays where it is: moving it would jump it under the user.
3401
- if (target) dispatchGroups.join(target, [started.id], parent);
3415
+ if (group) dispatchGroups.join(group, [started.id]);
3402
3416
  broadcast({ type: 'dispatch-update' });
3403
- res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
3417
+ res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
3404
3418
  });
3405
3419
 
3406
- app.post('/api/dispatch/:id/done', (req, res) => {
3407
- const { cap, outcome, summary } = req.body || {};
3408
- const err = dispatches.settle(req.params.id, cap, outcome, summary);
3409
- if (err) return res.status(err.status).json({ error: err.error });
3410
- res.status(204).end();
3411
- });
3412
-
3413
- app.get('/api/dispatch', async (req, res) => {
3420
+ app.get('/api/dispatch', (req, res) => {
3414
3421
  res.setHeader('Cache-Control', 'no-store');
3415
- const ids = typeof req.query.ids === 'string' && req.query.ids ? req.query.ids.split(',') : null;
3416
3422
  const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
3417
- // res, not req: a GET's request stream can close before the response is sent.
3418
- const out = await dispatches.wait({ ids, parent }, req.query.wait, (stop) => {
3419
- res.on('close', stop);
3420
- return () => res.off('close', stop);
3421
- });
3422
- if (!res.writableEnded) res.json(out);
3423
+ res.json({ running: dispatches.list({ parent }) });
3423
3424
  });
3424
3425
  // #endregion
3425
3426
 
@@ -3484,7 +3485,6 @@ const {
3484
3485
  formatTaskMoved,
3485
3486
  handleSessionEvents,
3486
3487
  hasSessionListener,
3487
- topicKey,
3488
3488
  } = require('./lib/session-events');
3489
3489
  app.get('/api/sessions/:sessionId/events', handleSessionEvents);
3490
3490
 
@@ -4248,7 +4248,7 @@ agentActivityWatcher.on('all', (event, filePath) => {
4248
4248
  }
4249
4249
  });
4250
4250
 
4251
- // Watch context-status directory for statusline updates
4251
+ // Watch context-status directory for the plugin's context and cost updates
4252
4252
  const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
4253
4253
  persistent: true,
4254
4254
  ignoreInitial: false,
@@ -4308,9 +4308,8 @@ async function cleanupAgentActivity() {
4308
4308
  await fs.rm(dirPath, { recursive: true, force: true });
4309
4309
  continue;
4310
4310
  }
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.
4311
+ // Mods cannot delete files, so every decision file is swept here. The mod
4312
+ // waits at most PERMISSION_TTL_MS, so anything older is unclaimable.
4314
4313
  for (const f of contents) {
4315
4314
  if (!isDecisionFile(f)) continue;
4316
4315
  const fp = path.join(dirPath, f);