claude-code-kanban 6.1.1 → 7.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.
package/server.js CHANGED
@@ -15,6 +15,8 @@ const { createNetGuard } = require('./lib/net-guard');
15
15
  const { isContained } = require('./lib/contain');
16
16
  const { resolveScratchSubdir, listScratchDir } = require('./lib/scratch-files');
17
17
  const { fileUrlToPath } = require('./lib/file-url');
18
+ const { httpError: previewError } = require('./lib/http-error');
19
+ const { oneLine } = require('./lib/one-line');
18
20
  const { pluginStatus } = require('./lib/plugin-status');
19
21
  const { getAutoCompact } = require('./lib/auto-compact');
20
22
 
@@ -22,12 +24,14 @@ const {
22
24
  readRecentMessages: _readRecentMessagesUncached,
23
25
  readMessagesPage: _readMessagesPageUncached,
24
26
  readSessionInfoFromJsonl,
27
+ modelDisplayName,
25
28
  buildSessionDigest,
26
29
  readCompactSummaries,
27
30
  readArtifactLinks,
28
31
  readScratchpadCreations,
29
32
  extractPromptFromTranscript,
30
33
  extractModelFromTranscript,
34
+ readSubagentMeta,
31
35
  extractAgentResultFromTranscript,
32
36
  extractTranscriptStats,
33
37
  readFullToolResult,
@@ -40,17 +44,21 @@ const {
40
44
  } = require('./lib/parsers');
41
45
  const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
42
46
  const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
43
- const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
47
+ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
44
48
  const { readTerminalConfig } = require('./lib/terminal');
45
49
  const { createTerminalClient } = require('./lib/terminal-client');
46
50
  const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
47
51
  const { createProcStats } = require('./lib/proc-stats');
48
52
  const { createDispatchRegistry } = require('./lib/dispatch');
49
53
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
50
- const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
54
+ const { createDispatchedStore, scanTranscripts, pruneSessionDirs, pruneContextStatus, retentionMs } = require('./lib/retention');
55
+ const { freshRateLimits } = require('./lib/rate-limits');
51
56
  const { createWorktreeStore } = require('./lib/worktrees');
57
+ const { createScratchpadDirResolver } = require('./lib/scratchpad-dir');
52
58
  const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
53
59
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
60
+ const { createPaneStore, isOwnOrigin } = require('./lib/panes');
61
+ const { probeFraming, parseOrigin } = require('./lib/frame-policy');
54
62
  const { pickFolder } = require('./lib/folder-dialog');
55
63
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
56
64
  const { countTaskDir } = require('./lib/task-counts');
@@ -102,6 +110,7 @@ const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
102
110
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
103
111
  const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
104
112
  const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
113
+ const PANES_FILE = path.join(CCK_DIR, 'panes.json');
105
114
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
106
115
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
107
116
  const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
@@ -208,7 +217,6 @@ const SESSION_STALE_MS = 5 * 60 * 1000;
208
217
  // never the "active" status (which stays accurate via hasRecentLog).
209
218
  const SESSION_GRACE_MS = 2 * 60 * 1000;
210
219
  const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
211
- const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
212
220
  const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
213
221
  const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
214
222
  const RETENTION_FIRST_RUN_MS = 5 * 60 * 1000;
@@ -302,11 +310,12 @@ function getContextStatus(sessionId, meta) {
302
310
  }
303
311
 
304
312
  function getContextFields(sessionId, meta) {
305
- const contextStatus = getContextStatus(sessionId, meta);
313
+ // A team dir has no transcript of its own; its lead's transcript has the requests.
314
+ const m = meta?.teamLeaderId ? sessionMetadataCache[meta.teamLeaderId] : meta;
315
+ const reply = m?.lastReply;
306
316
  return {
307
- contextStatus,
308
- autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null,
309
- cacheTtl: meta?.cacheTtl || null,
317
+ contextStatus: getContextStatus(sessionId, meta),
318
+ cache: reply ? { ttl: m.cacheTtl || null, ...reply, modelName: modelDisplayName(reply.model) } : null,
310
319
  };
311
320
  }
312
321
 
@@ -828,6 +837,7 @@ function refreshSessionMetadataPath(jsonlPath) {
828
837
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
829
838
  existing.permissionMode = info.permissionMode;
830
839
  existing.cacheTtl = info.cacheTtl;
840
+ existing.lastReply = info.lastReply;
831
841
  return true;
832
842
  }
833
843
 
@@ -916,7 +926,8 @@ function loadSessionMetadata() {
916
926
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
917
927
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
918
928
  permissionMode: sessionInfo.permissionMode,
919
- cacheTtl: sessionInfo.cacheTtl || null
929
+ cacheTtl: sessionInfo.cacheTtl || null,
930
+ lastReply: sessionInfo.lastReply || null
920
931
  };
921
932
  sessionIds.push(sessionId);
922
933
  }
@@ -1117,25 +1128,10 @@ function getSessionDisplayName(_sessionId, meta) {
1117
1128
  return null;
1118
1129
  }
1119
1130
 
1120
- // Derived by convention, not looked up: the harness creates the dir lazily, so a
1121
- // stat here would report "missing" for every session that has not written a temp
1122
- // file yet — and it would put IO on the session-list hot path. Pure string join.
1123
- function getScratchpadDir(id, meta) {
1124
- if (!meta.jsonlPath) return null;
1125
- const byProject = path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
1126
-
1127
- // A session launched with `claude -w` starts in the main checkout and enters the
1128
- // worktree afterwards, so the harness keys its scratchpad on the repo while the
1129
- // transcript ends up filed under the worktree. Neither the project nor any `cwd`
1130
- // in the log records that, so the two candidates are told apart by which one the
1131
- // harness actually created. Probed only for worktree sessions, and only until one
1132
- // of them exists — before that there is nothing to disambiguate and `byProject`,
1133
- // right for a session started inside the worktree, stands.
1134
- const wt = worktrees.resolve(meta.project);
1135
- if (!wt || existsSync(byProject)) return byProject;
1136
- const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
1137
- return existsSync(byRepo) ? byRepo : byProject;
1138
- }
1131
+ const getScratchpadDir = createScratchpadDirResolver({
1132
+ root: SCRATCHPAD_ROOT,
1133
+ resolveWorktree: worktrees.resolve,
1134
+ });
1139
1135
 
1140
1136
  function buildSessionObject(id, meta, overrides = {}) {
1141
1137
  const logStat = overrides._logStat || getSessionLogStat(meta);
@@ -1573,7 +1569,14 @@ app.get('/api/sessions', async (req, res) => {
1573
1569
  }
1574
1570
 
1575
1571
  // Loop info can mean a full read of the transcript, so only the rows sent pay for it.
1576
- for (const s of sessions) s.loopInfo = getLoopInfoSummary(s);
1572
+ // Same for autoCompact: up to 3 settings stats per call.
1573
+ const autoCompactByProject = new Map();
1574
+ for (const s of sessions) {
1575
+ s.loopInfo = getLoopInfoSummary(s);
1576
+ if (!s.contextStatus) continue;
1577
+ if (!autoCompactByProject.has(s.project)) autoCompactByProject.set(s.project, getAutoCompact(CLAUDE_DIR, s.project));
1578
+ s.autoCompact = autoCompactByProject.get(s.project);
1579
+ }
1577
1580
 
1578
1581
  res.json(withDispatchPlacement(sessions));
1579
1582
  startPrewarm();
@@ -2147,10 +2150,7 @@ app.get('/api/sessions/:sessionId/workflows/:wfId/run', async (req, res) => {
2147
2150
  for (const f of files) {
2148
2151
  const agentId = f.slice('agent-'.length, -'.jsonl'.length);
2149
2152
  const stats = (await extractTranscriptStats(path.join(runDir, f))) || {};
2150
- let type = null;
2151
- try {
2152
- type = JSON.parse(readFileSync(path.join(runDir, `agent-${agentId}.meta.json`), 'utf8')).agentType || null;
2153
- } catch (_) {}
2153
+ const type = readSubagentMeta(path.join(runDir, f))?.agentType || null;
2154
2154
  const entry = journal.get(agentId);
2155
2155
  const durationMs = stats.firstTs && stats.lastTs ? new Date(stats.lastTs) - new Date(stats.firstTs) : null;
2156
2156
  if (stats.lastTs && (!stoppedAt || stats.lastTs > stoppedAt)) stoppedAt = stats.lastTs;
@@ -2382,6 +2382,8 @@ app.get('/api/teams/:name', (req, res) => {
2382
2382
  // The resultUnavailable latch is persisted on the agent record. Bump this when
2383
2383
  // extractAgentResultFromTranscript finds more, so agents latched earlier rescan once.
2384
2384
  const RESULT_SCAN = 3;
2385
+ // Same for the modelUnavailable latch and extractModelFromTranscript.
2386
+ const MODEL_SCAN = 2;
2385
2387
 
2386
2388
  app.get('/api/sessions/:sessionId/agents', async (req, res) => {
2387
2389
  const sessionId = resolveSessionId(req.params.sessionId);
@@ -2523,19 +2525,34 @@ app.get('/api/sessions/:sessionId/agents', async (req, res) => {
2523
2525
  }
2524
2526
  }
2525
2527
 
2526
- // Retry stopped agents even if modelUnavailable was set — it may have been marked
2527
- // unavailable while the agent was still active and its JSONL wasn't ready yet.
2528
- const agentsNeedingModel = agents.filter(a => !a.model && (!a.modelUnavailable || a.status === 'stopped'));
2528
+ // The plugin's start record carries the model; this covers sessions without it.
2529
+ const agentsNeedingModel = agents.filter(a => !a.model && a.modelUnavailable !== MODEL_SCAN);
2529
2530
  if (agentsNeedingModel.length && meta.jsonlPath) {
2530
2531
  for (const agent of agentsNeedingModel) {
2532
+ const jsonl = subagentJsonlForExtraction(meta, agent.agentId);
2531
2533
  let model = null;
2532
- try { model = extractModelFromTranscript(subagentJsonlForExtraction(meta, agent.agentId)); } catch (_) {}
2534
+ try { model = extractModelFromTranscript(jsonl); } catch (_) {}
2533
2535
  if (model) {
2534
2536
  agent.model = model;
2535
2537
  delete agent.modelUnavailable;
2538
+ delete agent.modelAlias;
2536
2539
  dirty.add(agent);
2537
- } else if (agent.status === 'stopped' && !agent.modelUnavailable) {
2538
- agent.modelUnavailable = true;
2540
+ continue;
2541
+ }
2542
+ // Until the transcript has an assistant line, show the requested alias.
2543
+ // agent.model stays empty so the exact id is still looked up.
2544
+ if (!agent.modelAlias) {
2545
+ let alias = readSubagentMeta(jsonl)?.model || null;
2546
+ if (alias === 'inherit') {
2547
+ try { alias = extractModelFromTranscript(meta.jsonlPath); } catch (_) { alias = null; }
2548
+ }
2549
+ if (alias) {
2550
+ agent.modelAlias = alias;
2551
+ dirty.add(agent);
2552
+ }
2553
+ }
2554
+ if (agent.status === 'stopped') {
2555
+ agent.modelUnavailable = MODEL_SCAN;
2539
2556
  dirty.add(agent);
2540
2557
  }
2541
2558
  }
@@ -3662,25 +3679,19 @@ const PREVIEW_MAX_BYTES = 8 * 1024 * 1024;
3662
3679
  // Source in a modal is for reading, not for scrolling a generated bundle.
3663
3680
  const PREVIEW_TEXT_MAX_BYTES = 2 * 1024 * 1024;
3664
3681
 
3665
- function previewError(status, message) {
3666
- const err = new Error(message);
3667
- err.status = status;
3668
- return err;
3669
- }
3670
-
3671
3682
  // Existence + kind of a file target, with the HTTP status codes both the preview and
3672
3683
  // the link-a-file endpoints report. `kind` is null for anything the previewer can't
3673
3684
  // render — the caller decides whether that disqualifies the path.
3674
3685
  async function statFileTarget(absPath) {
3675
3686
  try {
3676
3687
  const stats = await fs.stat(absPath);
3677
- if (!stats.isFile()) throw previewError(400, 'Not a file');
3688
+ if (!stats.isFile()) throw previewError(400, 'Not a file', 'not_a_file');
3678
3689
  const kind = previewKindFor(absPath);
3679
3690
  return { size: stats.size, kind: kind === 'image' ? kind : await confirmKind(absPath, kind) };
3680
3691
  } catch (e) {
3681
3692
  if (e.status) throw e;
3682
- if (e.code === 'ENOENT') throw previewError(404, 'File not found');
3683
- if (e.code === 'EISDIR') throw previewError(400, 'Not a file');
3693
+ if (e.code === 'ENOENT') throw previewError(404, 'File not found', 'file_not_found');
3694
+ if (e.code === 'EISDIR') throw previewError(400, 'Not a file', 'not_a_file');
3684
3695
  throw e;
3685
3696
  }
3686
3697
  }
@@ -3688,7 +3699,7 @@ async function statFileTarget(absPath) {
3688
3699
  function enforcePreviewSize(kind, size) {
3689
3700
  const max = kind === 'text' ? PREVIEW_TEXT_MAX_BYTES : PREVIEW_MAX_BYTES;
3690
3701
  if (size > max) {
3691
- throw previewError(400, `Preview too large (${Math.round(size / 1048576)}MB, max ${max / 1048576}MB)`);
3702
+ throw previewError(400, `Preview too large (${Math.round(size / 1048576)}MB, max ${max / 1048576}MB)`, 'too_large');
3692
3703
  }
3693
3704
  }
3694
3705
 
@@ -3696,7 +3707,7 @@ function enforcePreviewSize(kind, size) {
3696
3707
  // validation (and its status codes) but never the content.
3697
3708
  async function validatePreviewFile(absPath) {
3698
3709
  const { kind, size } = await statFileTarget(absPath);
3699
- if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file');
3710
+ if (!kind) throw previewError(400, 'Not a previewable text, markdown, HTML or image file', 'not_previewable');
3700
3711
  enforcePreviewSize(kind, size);
3701
3712
  return { kind, size };
3702
3713
  }
@@ -3778,7 +3789,7 @@ app.post('/api/preview', async (req, res) => {
3778
3789
  // API: Link a file to a session's sidebar docs without opening the preview modal.
3779
3790
  // Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
3780
3791
  // opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
3781
- // An http(s) URL is linked as it is. `open` comes from `preview-doc <url>`: a tab cannot
3792
+ // An http(s) URL is linked as it is. `open` comes from `doc preview <url>`: a tab cannot
3782
3793
  // open a URL without a click, so the tab on screen offers an Open button.
3783
3794
  app.post('/api/document/link', async (req, res) => {
3784
3795
  try {
@@ -3933,6 +3944,116 @@ app.get('/api/file/resolve', async (req, res) => {
3933
3944
 
3934
3945
  // #endregion
3935
3946
 
3947
+ // #region PANES
3948
+ // Tabs next to Board in a session view. Each change broadcasts the whole layout with its rev,
3949
+ // so a tab replaces its copy when the rev is newer instead of applying a diff. Another board
3950
+ // on the same config dir writes the same file; the watcher broadcasts its changes here.
3951
+ const panesFileStamp = () => {
3952
+ try {
3953
+ const s = statSync(PANES_FILE);
3954
+ return `${s.mtimeMs}:${s.size}`;
3955
+ } catch {
3956
+ return null;
3957
+ }
3958
+ };
3959
+ let panesStamp;
3960
+ const panes = createPaneStore({
3961
+ load: () => {
3962
+ const stamp = panesFileStamp();
3963
+ if (stamp === panesStamp) return undefined;
3964
+ panesStamp = stamp;
3965
+ try { return JSON.parse(readFileSync(PANES_FILE, 'utf8')); } catch { return null; }
3966
+ },
3967
+ save: (data) => {
3968
+ writeJsonAtomic(PANES_FILE, data);
3969
+ panesStamp = panesFileStamp();
3970
+ },
3971
+ onChange: (sessionId, layout) => broadcast({ type: 'pane:changed', sessionId, layout }),
3972
+ });
3973
+ chokidar.watch(PANES_FILE, { ignoreInitial: true }).on('all', (event) => {
3974
+ if (event === 'add' || event === 'change') panes.reload();
3975
+ });
3976
+
3977
+ let boardPort = null;
3978
+ const boardHosts = [...net.ALLOWED_HOSTS.split(','), net.EXPOSED ? net.BIND_HOST : '']
3979
+ .map((h) => h.trim().toLowerCase())
3980
+ .filter(Boolean);
3981
+
3982
+ function paneRouteError(res, error, what) {
3983
+ if (!error.status) console.error(`Error in ${what}:`, error);
3984
+ const code = error.status ? error.code : 'internal';
3985
+ res.status(error.status || 500).json({ error: error.message || `${what} failed`, code });
3986
+ }
3987
+
3988
+ async function resolvePaneTarget(target, base) {
3989
+ const url = linkUrl(target);
3990
+ if (url) {
3991
+ if (isOwnOrigin(url, { boardPort, boardHosts, hubUrl: process.env.HUB_URL })) {
3992
+ throw previewError(400, 'A pane cannot show the board or the hub itself', 'own_origin');
3993
+ }
3994
+ return { kind: 'url', target: url };
3995
+ }
3996
+ const hasBase = typeof base === 'string' && path.isAbsolute(base);
3997
+ if (!hasBase && !path.isAbsolute(fileUrlToPath(target))) {
3998
+ throw previewError(400, 'target must be an http(s) URL, an absolute file path, or a relative path with an absolute base', 'bad_target');
3999
+ }
4000
+ const abs = resolvePreviewPath(target, base);
4001
+ const { kind } = await validatePreviewFile(abs);
4002
+ return { kind, target: await fs.realpath(abs) };
4003
+ }
4004
+
4005
+ app.get('/api/panes/:sessionId', (req, res) => {
4006
+ res.setHeader('Cache-Control', 'no-store');
4007
+ res.json(panes.get(req.params.sessionId));
4008
+ });
4009
+
4010
+ app.post('/api/panes/:sessionId', async (req, res) => {
4011
+ try {
4012
+ const { target, base, title } = req.body || {};
4013
+ if (typeof target !== 'string' || !target) throw previewError(400, 'target is required', 'bad_target');
4014
+ res.json(panes.add(req.params.sessionId, { ...(await resolvePaneTarget(target, base)), title }));
4015
+ } catch (error) {
4016
+ paneRouteError(res, error, 'POST /api/panes');
4017
+ }
4018
+ });
4019
+
4020
+ app.patch('/api/panes/:sessionId', (req, res) => {
4021
+ try {
4022
+ res.json({ layout: panes.reorder(req.params.sessionId, req.body?.order) });
4023
+ } catch (error) {
4024
+ paneRouteError(res, error, 'PATCH /api/panes');
4025
+ }
4026
+ });
4027
+
4028
+ // Probes only a pane's own target, so the route cannot fetch an arbitrary URL. `ancestors` is
4029
+ // the comma-separated origins above the frame: the board's page origin, then the hub's. The CLI
4030
+ // has no page, so without it the board's own origin and HUB_URL stand in; under the hub the page
4031
+ // is on the proxy port, which agrees for 'self', 'none' and policies that name neither.
4032
+ app.get('/api/panes/:sessionId/:paneId/framing', async (req, res) => {
4033
+ try {
4034
+ const { sessionId, paneId } = req.params;
4035
+ const pane = panes.get(sessionId).panes.find((p) => p.id === paneId && p.kind === 'url');
4036
+ if (!pane) throw previewError(404, `No URL pane ${paneId} in session ${sessionId}`, 'no_pane');
4037
+ const fallback = [`http://localhost:${boardPort}`, process.env.HUB_URL].filter(Boolean).join(',');
4038
+ const ancestors = String(req.query.ancestors || fallback)
4039
+ .split(',')
4040
+ .slice(0, 5)
4041
+ .filter((o) => parseOrigin(o));
4042
+ if (!ancestors.length) throw previewError(400, 'ancestors must name at least one http(s) origin', 'bad_ancestors');
4043
+ res.json({ frameable: await probeFraming(pane.target, ancestors) });
4044
+ } catch (error) {
4045
+ paneRouteError(res, error, 'GET /api/panes/framing');
4046
+ }
4047
+ });
4048
+
4049
+ app.delete('/api/panes/:sessionId/:paneId', (req, res) => {
4050
+ const { sessionId, paneId } = req.params;
4051
+ const layout = panes.remove(sessionId, paneId);
4052
+ if (!layout) return res.status(404).json({ error: `No pane ${paneId} in session ${sessionId}`, code: 'no_pane' });
4053
+ res.json({ layout });
4054
+ });
4055
+ // #endregion
4056
+
3936
4057
  // #region REVIEW
3937
4058
  // API: Send review comments on something the board shows (a file preview today) to a
3938
4059
  // session. The source is described, not interpreted, so a new kind of preview needs a
@@ -3947,11 +4068,7 @@ function parseReviewBody(body) {
3947
4068
  const { source, comments } = body || {};
3948
4069
  if (!source || !REVIEW_KIND_RE.test(source.kind)) throw previewError(400, 'source.kind is required');
3949
4070
  // The label is pasted into the terminal; an ESC could end bracketed paste and submit text.
3950
- const label = String(source.label || '')
3951
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
3952
- .replace(/[\x00-\x1f\x7f]/g, ' ')
3953
- .trim()
3954
- .slice(0, 200);
4071
+ const label = oneLine(source.label, 200);
3955
4072
  if (!label) throw previewError(400, 'source.label is required');
3956
4073
  if (!Array.isArray(comments) || !comments.length || comments.length > REVIEW_MAX_COMMENTS) {
3957
4074
  throw previewError(400, `comments must hold 1 to ${REVIEW_MAX_COMMENTS} items`);
@@ -4063,6 +4180,10 @@ app.get('/api/context-status', (_req, res) => {
4063
4180
  res.json(Object.fromEntries(contextStatusCache));
4064
4181
  });
4065
4182
 
4183
+ app.get('/api/rate-limits', (_req, res) => {
4184
+ res.json(freshRateLimits(contextStatusCache.values(), Date.now() / 1000));
4185
+ });
4186
+
4066
4187
  app.use('/api', (_req, res) => {
4067
4188
  res.status(404).json({ error: 'Not found' });
4068
4189
  });
@@ -4246,47 +4367,35 @@ agentActivityWatcher.on('all', (event, filePath) => {
4246
4367
  const contextStatusWatcher = chokidar.watch(CONTEXT_STATUS_DIR, {
4247
4368
  persistent: true,
4248
4369
  ignoreInitial: false,
4370
+ alwaysStat: true,
4249
4371
  depth: 0
4250
4372
  });
4251
4373
 
4252
- contextStatusWatcher.on('all', (event, filePath) => {
4374
+ // The initial scan adds one event per kept file (up to MAX_CONTEXT_STATUS), so it broadcasts once at the end.
4375
+ let contextStatusReady = false;
4376
+ contextStatusWatcher.on('ready', () => {
4377
+ contextStatusReady = true;
4378
+ broadcast({ type: 'context-update', sessionId: null });
4379
+ });
4380
+
4381
+ contextStatusWatcher.on('all', (event, filePath, stats) => {
4253
4382
  if (!filePath.endsWith('.json')) return;
4254
4383
  const sessionId = path.basename(filePath, '.json');
4255
4384
  if (event === 'add' || event === 'change') {
4256
4385
  try {
4257
4386
  const data = JSON.parse(readFileSync(filePath, 'utf8'));
4258
- try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
4387
+ data._updatedAt = stats?.mtimeMs ?? Date.now();
4259
4388
  contextStatusCache.set(sessionId, data);
4260
- evictStaleCache(contextStatusCache);
4261
4389
  } catch { /* ignore malformed */ }
4262
- broadcast({ type: 'context-update', sessionId });
4263
4390
  } else if (event === 'unlink') {
4264
4391
  contextStatusCache.delete(sessionId);
4265
- broadcast({ type: 'context-update', sessionId });
4266
- }
4392
+ } else return;
4393
+ if (contextStatusReady) broadcast({ type: 'context-update', sessionId });
4267
4394
  });
4268
4395
 
4269
4396
  // #endregion
4270
4397
 
4271
4398
  // #region CLEANUP
4272
- async function cleanupContextStatus() {
4273
- try {
4274
- const entries = await fs.readdir(CONTEXT_STATUS_DIR);
4275
- const now = Date.now();
4276
- for (const f of entries) {
4277
- if (!f.endsWith('.json')) continue;
4278
- try {
4279
- const fp = path.join(CONTEXT_STATUS_DIR, f);
4280
- const st = statSync(fp);
4281
- if (now - st.mtimeMs > CTX_CLEANUP_MAX_AGE_MS) {
4282
- await fs.unlink(fp);
4283
- contextStatusCache.delete(path.basename(f, '.json'));
4284
- }
4285
- } catch { /* ignore */ }
4286
- }
4287
- } catch { /* dir may not exist */ }
4288
- }
4289
-
4290
4399
  async function cleanupAgentActivity() {
4291
4400
  try {
4292
4401
  const entries = await fs.readdir(AGENT_ACTIVITY_DIR, { withFileTypes: true });
@@ -4317,16 +4426,18 @@ async function cleanupAgentActivity() {
4317
4426
  } catch { /* agent-activity dir may not exist */ }
4318
4427
  }
4319
4428
 
4320
- // Dispatch markers, reviews and worktrees: see docs/retention.md.
4429
+ // Dispatch markers, pane layouts, reviews, context status and worktrees: see docs/retention.md.
4321
4430
  async function runRetention() {
4322
4431
  try {
4323
4432
  const scan = await scanTranscripts(PROJECTS_DIR);
4324
4433
  const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
4325
4434
  const markers = dispatched.prune(opts);
4435
+ const layouts = panes.prune(opts);
4326
4436
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4437
+ const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4327
4438
  const wts = worktrees.prune(scan?.dirs);
4328
- if (markers || reviews || wts) {
4329
- console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${wts} worktrees`);
4439
+ if (markers || layouts || reviews || contexts || wts) {
4440
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees`);
4330
4441
  }
4331
4442
  } catch (e) {
4332
4443
  console.warn('[retention] failed:', e.message);
@@ -4334,9 +4445,7 @@ async function runRetention() {
4334
4445
  }
4335
4446
 
4336
4447
  cleanupAgentActivity();
4337
- cleanupContextStatus();
4338
4448
  setInterval(cleanupAgentActivity, CLEANUP_INTERVAL_MS);
4339
- setInterval(cleanupContextStatus, 30 * 60 * 1000);
4340
4449
  // Later than the startup scan and prewarm, so the first sweep never competes with them.
4341
4450
  setTimeout(() => {
4342
4451
  runRetention();
@@ -4387,6 +4496,7 @@ async function prewarmCaches() {
4387
4496
  }
4388
4497
 
4389
4498
  const onReady = (actualPort) => {
4499
+ boardPort = Number(actualPort);
4390
4500
  terminal.setServerUrl(`http://127.0.0.1:${actualPort}`);
4391
4501
  // The port is configurable and falls back to a random one when taken, so the postman
4392
4502
  // monitor cannot assume it -- publish the live one where it can read it.
@@ -31,5 +31,5 @@ A crashed session sends nothing. To learn when one ends, `SendMessage` it with `
31
31
 
32
32
  ```bash
33
33
  claude-code-kanban dispatch list --json # still running in cck's terminal
34
- claude-code-kanban session peek <session-id> --limit 20
34
+ claude-code-kanban session view <session-id> # its transcript path; read the last lines
35
35
  ```