claude-code-kanban 4.29.0 → 4.31.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,7 @@ 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 { pluginStatus } = require('./lib/plugin-status');
18
19
 
19
20
  const {
20
21
  readRecentMessages: _readRecentMessagesUncached,
@@ -41,7 +42,12 @@ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = requ
41
42
  const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
42
43
  const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
43
44
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
45
+ const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
44
46
  const { pickFolder } = require('./lib/folder-dialog');
47
+ const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
48
+ const { countTaskDir } = require('./lib/task-counts');
49
+ const { projectMatcher } = require('./public/project-match');
50
+ const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
45
51
 
46
52
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
47
53
  const { runInstall, runUninstall } = require("./install");
@@ -49,10 +55,12 @@ if (process.argv.includes("--install") || process.argv.includes("--uninstall"))
49
55
  (process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
50
56
  .then(() => process.exit(0))
51
57
  .catch(e => { console.error(e.message); process.exit(1); });
52
- return;
58
+ } else if (!require("./cli").runCli(process.argv)) {
59
+ startServer();
53
60
  }
54
- if (require("./cli").runCli(process.argv)) return;
55
61
 
62
+ // Not indented: the formatter is off for this file, and a reindent would rewrite every line.
63
+ function startServer() {
56
64
 
57
65
  const app = express();
58
66
  const PORT = process.env.PORT || 3541;
@@ -82,10 +90,16 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
82
90
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
83
91
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
84
92
  const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
93
+ const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
85
94
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
86
- const TERMINAL_TOKEN_FILE = path.join(CCK_DIR, 'terminal-token.json');
95
+ const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
96
+ const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
97
+ // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
98
+ const TEMP_ROOT = (() => {
99
+ try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
100
+ })();
87
101
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
88
- const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
102
+ const SCRATCHPAD_ROOT = path.join(TEMP_ROOT, 'claude');
89
103
 
90
104
  // #endregion
91
105
 
@@ -101,7 +115,7 @@ function readPins() {
101
115
 
102
116
  function writeJsonAtomic(file, obj, mode) {
103
117
  try {
104
- mkdirSync(CCK_DIR, { recursive: true });
118
+ mkdirSync(path.dirname(file), { recursive: true });
105
119
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
106
120
  writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
107
121
  renameSync(tmp, file);
@@ -119,37 +133,46 @@ function writePins(pins) {
119
133
  // a crashed one.
120
134
  function writeServerInfo(port) {
121
135
  writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
122
- writeTerminalToken();
123
136
  }
124
137
 
125
138
  // For `dispatch start`: a local process as the same user can already run claude itself,
126
139
  // so handing it the token adds little. A browser still cannot read the file.
127
- function writeTerminalToken() {
140
+ // One file per port: two boards on one config dir each keep a token, so the CLI can
141
+ // reach the board CCK_URL names even when the other one owns server.json.
142
+ // The sweep also drops terminal-token.json, the single file that older versions wrote.
143
+ let terminalTokenFile = null;
144
+ function writeTerminalToken(port) {
128
145
  if (terminal.unavailableReason()) return;
129
- writeJsonAtomic(TERMINAL_TOKEN_FILE, { pid: process.pid, token: terminal.token }, 0o600);
146
+ let stale = [path.join(CCK_DIR, 'terminal-token.json')];
147
+ try { stale = stale.concat(readdirSync(TERMINAL_TOKENS_DIR).map(name => path.join(TERMINAL_TOKENS_DIR, name))); } catch (_) { /* no dir yet */ }
148
+ for (const file of stale) {
149
+ const pid = ownerPid(file);
150
+ if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
151
+ }
152
+ terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
153
+ writeJsonAtomic(terminalTokenFile, { pid: process.pid, token: terminal.token }, 0o600);
154
+ }
155
+
156
+ function ownerPid(file) {
157
+ try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
130
158
  }
131
159
 
132
160
  // A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
133
161
  // probes the port, finds it closed and never waits. The hub spawns every sub-app on
134
162
  // an ephemeral port, so a stale beacon never comes back on its own — drop ours on
135
163
  // the way out. Only when the file is still ours: a newer server on the same config
136
- // dir has already claimed it.
164
+ // dir has already claimed it. The terminal token file goes the same way.
137
165
  function removeServerInfo() {
138
- for (const file of [SERVER_INFO_FILE, TERMINAL_TOKEN_FILE]) {
139
- try {
140
- const info = JSON.parse(readFileSync(file, 'utf8'));
141
- if (info.pid === process.pid) unlinkSync(file);
142
- } catch (_) { /* gone, unreadable, or not ours */ }
166
+ for (const file of [SERVER_INFO_FILE, terminalTokenFile]) {
167
+ if (file && ownerPid(file) === process.pid) try { unlinkSync(file); } catch (_) { /* already gone */ }
143
168
  }
144
169
  }
145
170
 
146
171
  // A second server on the same config dir (a test hub, say) takes the beacon and removes it on
147
172
  // exit, which leaves this live server undiscoverable. A newer live owner keeps it.
148
173
  function reclaimServerInfo(port) {
149
- try {
150
- const { pid } = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
151
- if (pid === process.pid || isPidAlive(pid)) return;
152
- } catch (_) { /* missing or unreadable beacon */ }
174
+ const pid = ownerPid(SERVER_INFO_FILE);
175
+ if (pid !== null && isPidAlive(pid)) return;
153
176
  writeServerInfo(port);
154
177
  }
155
178
 
@@ -718,29 +741,13 @@ function getTaskCounts(sessionPath) {
718
741
  const cached = taskCountsCache.get(sessionPath);
719
742
  if (cached) return cached;
720
743
 
721
- const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
722
- let completed = 0, inProgress = 0, pending = 0, newestTaskMtime = null;
744
+ const { completed, inProgress, pending, newestTaskMtime } = countTaskDir(sessionPath);
723
745
  // Directory mtime bumps when task files are added/removed, so it stays fresh even for an
724
746
  // emptied dir (task list closed) that has no files left to date. Task-file mtime alone would
725
747
  // report 0 for such a dir and lose "latest wins" to a stale prior-boot dir.
726
748
  let dirMtime = 0;
727
749
  try { dirMtime = statSync(sessionPath).mtimeMs; } catch (_) {}
728
750
 
729
- for (const file of taskFiles) {
730
- try {
731
- const taskPath = path.join(sessionPath, file);
732
- const task = JSON.parse(readFileSync(taskPath, 'utf8'));
733
- if (task.metadata?._internal) continue;
734
- if (task.status === 'completed') completed++;
735
- else if (task.status === 'in_progress') inProgress++;
736
- else pending++;
737
- const taskStat = statSync(taskPath);
738
- if (!newestTaskMtime || taskStat.mtime > newestTaskMtime) {
739
- newestTaskMtime = taskStat.mtime;
740
- }
741
- } catch { /* skip invalid */ }
742
- }
743
-
744
751
  const taskCount = completed + inProgress + pending;
745
752
  const result = { taskCount, completed, inProgress, pending, newestTaskMtime, dirMtime };
746
753
  taskCountsCache.set(sessionPath, result);
@@ -823,8 +830,6 @@ function refreshSessionMetadataPath(jsonlPath) {
823
830
  if (info.cwd) existing.cwd = info.cwd;
824
831
  if (info.gitBranch) existing.gitBranch = info.gitBranch;
825
832
  if (info.customTitle) existing.customTitle = info.customTitle;
826
- // Direct assign (not guarded) so a /goal clear propagates as null.
827
- existing.goal = info.goal || null;
828
833
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
829
834
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
830
835
  return true;
@@ -911,7 +916,6 @@ function loadSessionMetadata() {
911
916
  cwd: sessionInfo.cwd || null,
912
917
  gitBranch: sessionInfo.gitBranch || null,
913
918
  customTitle: sessionInfo.customTitle || null,
914
- goal: sessionInfo.goal || null,
915
919
  jsonlPath: jsonlPath,
916
920
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
917
921
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
@@ -1155,7 +1159,6 @@ function buildSessionObject(id, meta, overrides = {}) {
1155
1159
  gitBranch: resolveSessionGitBranch(meta),
1156
1160
  worktree: resolveWorktree(meta.project),
1157
1161
  customTitle: meta.customTitle || null,
1158
- goal: meta.goal || null,
1159
1162
  taskCount: 0,
1160
1163
  completed: 0,
1161
1164
  inProgress: 0,
@@ -1177,7 +1180,6 @@ function buildSessionObject(id, meta, overrides = {}) {
1177
1180
  contextStatus: getContextStatus(id, meta),
1178
1181
  ...getPlanInfo(meta.slug),
1179
1182
  ...getWorkflowInfoSummary(id),
1180
- loopInfo: getLoopInfoSummary(meta),
1181
1183
  ...overrides,
1182
1184
  // Remove internal-only field
1183
1185
  _logStat: undefined,
@@ -1551,46 +1553,83 @@ app.get('/api/sessions', async (req, res) => {
1551
1553
  // The client sends the session it currently has open, which it cannot render at all
1552
1554
  // if the row is missing — that one is not a preference.
1553
1555
  if (projectFilter) {
1554
- sessions = sessions.filter(s => s.project === projectFilter || includeIds.has(s.id));
1556
+ const matches = projectMatcher(String(projectFilter));
1557
+ sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
1558
+ }
1559
+ // The sidebar's 24h filter: projects with any transcript written in the window, so an older
1560
+ // session of such a project stays in.
1561
+ const recentHours = Number(req.query.recentHours);
1562
+ if (recentHours > 0) {
1563
+ const cutoff = Date.now() - recentHours * 3600 * 1000;
1564
+ const activity = projectActivity();
1565
+ sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff || includeIds.has(s.id));
1555
1566
  }
1556
1567
 
1568
+ const paged = limit !== null && limit > 0;
1569
+ // The sidebar loads the next page on scroll while this is true. A header, so the body stays a plain array.
1570
+ res.setHeader('X-Has-More', String(paged && sessions.length > limit));
1557
1571
  // Apply limit if specified, but always include pinned sessions
1558
- if (limit !== null && limit > 0) {
1572
+ if (paged) {
1559
1573
  const top = sessions.slice(0, limit);
1560
1574
  const topIds = new Set(top.map(s => s.id));
1561
1575
  const missingPinned = sessions.filter(s => pinnedIds.has(s.id) && !topIds.has(s.id));
1562
1576
  sessions = [...top, ...missingPinned];
1563
1577
  }
1564
1578
 
1579
+ // Loop info can mean a full read of the transcript, so only the rows sent pay for it.
1580
+ for (const s of sessions) s.loopInfo = getLoopInfoSummary(s);
1581
+
1565
1582
  res.json(withDispatchPlacement(sessions));
1583
+ startPrewarm();
1566
1584
  } catch (error) {
1567
1585
  console.error('Error listing sessions:', error);
1568
1586
  res.status(500).json({ error: 'Failed to list sessions' });
1569
1587
  }
1570
1588
  });
1571
1589
 
1572
- // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
1573
- const TEMP_ROOT = (() => {
1574
- try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
1575
- })();
1590
+ // Ids of sessions whose id or name contains q, from any transcript, newest first. The client
1591
+ // gets the rows through `/api/sessions?include=`. A linear scan is enough: ~4 ms for 10k ids,
1592
+ // plus one stat per hit for the sort.
1593
+ const SESSION_SEARCH_MIN = 3;
1594
+ const SESSION_SEARCH_MAX = 20;
1595
+ app.get('/api/sessions/search', (req, res) => {
1596
+ res.setHeader('Cache-Control', 'no-store');
1597
+ const text = String(req.query.q || '').trim().toLowerCase();
1598
+ if (text.length < SESSION_SEARCH_MIN) return res.json([]);
1599
+ const idQ = text.replace(/-/g, '');
1600
+ const searchIds = idQ.length >= SESSION_SEARCH_MIN && /^[0-9a-f]+$/.test(idQ);
1601
+ const hits = [];
1602
+ for (const [id, meta] of Object.entries(loadSessionMetadata())) {
1603
+ const name = getSessionDisplayName(id, meta);
1604
+ if ((searchIds && id.replace(/-/g, '').includes(idQ)) || name?.toLowerCase().includes(text)) {
1605
+ hits.push({ id, mtime: getSessionLogStat(meta).mtime || 0 });
1606
+ }
1607
+ }
1608
+ hits.sort((a, b) => b.mtime - a.mtime);
1609
+ res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
1610
+ });
1611
+
1576
1612
  function isTempPath(p) {
1577
1613
  const rel = path.relative(TEMP_ROOT, p);
1578
1614
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
1579
1615
  }
1580
1616
 
1581
- // API: Get distinct project paths with last-modified timestamps
1582
- app.get('/api/projects', (_req, res) => {
1583
- res.setHeader('Cache-Control', 'no-store');
1584
- const metadata = loadSessionMetadata();
1585
- const projectMap = {};
1586
- for (const meta of Object.values(metadata)) {
1617
+ // Project path → newest transcript mtime among its sessions (undefined when none has one).
1618
+ function projectActivity() {
1619
+ const activity = new Map();
1620
+ for (const meta of Object.values(loadSessionMetadata())) {
1587
1621
  if (!meta.project) continue;
1588
1622
  const mtime = getSessionLogStat(meta).mtime;
1589
- if (!projectMap[meta.project] || (mtime && mtime > projectMap[meta.project])) {
1590
- projectMap[meta.project] = mtime;
1591
- }
1623
+ const prev = activity.get(meta.project);
1624
+ if (!prev || (mtime && mtime > prev)) activity.set(meta.project, mtime);
1592
1625
  }
1593
- const projects = Object.entries(projectMap)
1626
+ return activity;
1627
+ }
1628
+
1629
+ // API: Get distinct project paths with last-modified timestamps
1630
+ app.get('/api/projects', (_req, res) => {
1631
+ res.setHeader('Cache-Control', 'no-store');
1632
+ const projects = [...projectActivity()]
1594
1633
  .map(([path, mtime]) => ({
1595
1634
  path,
1596
1635
  modifiedAt: mtime ? new Date(mtime).toISOString() : null,
@@ -1804,8 +1843,9 @@ async function readCreatedPads(meta) {
1804
1843
  // The project, not `meta.cwd`: cwd is wherever the session last stood, which drifts
1805
1844
  // into subdirectories — a session that made a pad and then worked inside it reports
1806
1845
  // a cwd below the pad, and a scan from there finds nothing above it.
1807
- const root = meta.project || meta.cwd;
1808
- const scanned = unresolved.length && root ? await findPads(root) : [];
1846
+ // The session's own scratchpad dir too: a pad made there sits outside the project.
1847
+ const roots = unresolved.length ? [meta.project || meta.cwd, getScratchpadDir(sessionId, meta)] : [];
1848
+ const scanned = (await Promise.all(roots.filter(Boolean).map((r) => findPads(r)))).flat();
1809
1849
  const candidates = [...new Set([...reported, ...scanned.map((dir) => path.join(dir, SCRATCHPAD_MANIFEST))])];
1810
1850
  const rows = await Promise.all(
1811
1851
  candidates.map(async (file) => {
@@ -2398,7 +2438,8 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2398
2438
  try {
2399
2439
  const progressMap = getProgressMap(meta.jsonlPath);
2400
2440
  for (const entry of Object.values(progressMap)) {
2401
- const e = (byAgentId[entry.agentId] ||= {});
2441
+ byAgentId[entry.agentId] ||= {};
2442
+ const e = byAgentId[entry.agentId];
2402
2443
  for (const f of ['prompt', 'name', 'description', 'usage']) {
2403
2444
  if (entry[f] && !e[f]) e[f] = entry[f];
2404
2445
  }
@@ -2674,7 +2715,6 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
2674
2715
  // Fork: copies the parent's early messages verbatim (same UUIDs). Anchor = first UUID.
2675
2716
  // Compact: writes a compact_boundary record with logicalParentUuid in the preamble.
2676
2717
  // Birthtime (not mtime) identifies the parent — mtime changes on resume, birthtime is immutable.
2677
- const parentSessionCache = new Map();
2678
2718
  const FORK_ANCHOR_SCAN_LINES = 10;
2679
2719
  function findForkAnchorUuid(jsonlPath) {
2680
2720
  let text;
@@ -2688,8 +2728,8 @@ function findForkAnchorUuid(jsonlPath) {
2688
2728
  return firstUuid;
2689
2729
  }
2690
2730
  // Fallback when the metadata cache lacks the boundary pair (older entries, cold cache).
2691
- // Reached only from lookupParentSession, whose result is memoized for the process life —
2692
- // at most one read per session, never per request.
2731
+ // Reached only from lookupParentSession, whose result is cached (lib/parent-cache.js) —
2732
+ // one read per session until its verdict goes stale, never per request.
2693
2733
  // Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
2694
2734
  // always sits in the preamble before the first user/assistant record.
2695
2735
  const COMPACT_ANCHOR_READ_MAX = 1048576;
@@ -2729,8 +2769,9 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2729
2769
  if (!f.endsWith('.jsonl')) continue;
2730
2770
  const fp = path.join(projectDir, f);
2731
2771
  if (fp === excludeJsonlPath) continue;
2732
- let birthtime = 0;
2733
- try { birthtime = statSync(fp).birthtimeMs; } catch { continue; }
2772
+ let st;
2773
+ try { st = statSync(fp); } catch { continue; }
2774
+ const birthtime = st.birthtimeMs;
2734
2775
  if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
2735
2776
  if (birthtime >= bestBirthtime) continue;
2736
2777
  let text;
@@ -2741,7 +2782,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2741
2782
  try {
2742
2783
  const d = JSON.parse(l);
2743
2784
  if (d.uuid === targetUuid && d.sessionId) {
2744
- best = { parentSessionId: d.sessionId, parentJsonlPath: fp, isFork: false };
2785
+ best = { parentSessionId: d.sessionId, parentJsonlPath: fp, parentIno: st.ino, isFork: false };
2745
2786
  bestBirthtime = birthtime;
2746
2787
  bestText = text;
2747
2788
  break;
@@ -2754,8 +2795,10 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2754
2795
  return best;
2755
2796
  }
2756
2797
  function lookupParentSession(sessionId) {
2757
- if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
2758
2798
  const meta = loadSessionMetadata()[sessionId];
2799
+ const known = getParentVerdict(sessionId, meta);
2800
+ if (known) return known;
2801
+ let self = null, parentIno = null;
2759
2802
  // relation is the single verdict: 'none' (no parent found), 'compact' (this session
2760
2803
  // continues a lineage the parent could not hold) or 'fork' (an intentional branch off
2761
2804
  // a parent that keeps its own life). isCompact/isFork are derived views of it.
@@ -2770,23 +2813,24 @@ function lookupParentSession(sessionId) {
2770
2813
  // A fork copies the parent's early records verbatim, so its first uuid is the anchor.
2771
2814
  const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
2772
2815
  if (anchorUuid) {
2773
- let selfBirthtime;
2774
- try { selfBirthtime = statSync(meta.jsonlPath).birthtimeMs; } catch { /* ignore */ }
2775
- if (selfBirthtime != null) {
2776
- const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, selfBirthtime, boundary?.boundaryUuid);
2816
+ try { self = statSync(meta.jsonlPath); } catch { /* ignore */ }
2817
+ if (self) {
2818
+ const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, self.birthtimeMs, boundary?.boundaryUuid);
2777
2819
  if (hit) {
2778
2820
  // No compact boundary at all → fork by construction; with one, the copied
2779
2821
  // boundary uuid (hit.isFork) is what separates a fork from a continuation.
2780
2822
  result.parentSessionId = hit.parentSessionId;
2781
2823
  result.parentJsonlPath = hit.parentJsonlPath;
2782
2824
  result.relation = (!compactAnchor || hit.isFork) ? 'fork' : 'compact';
2825
+ parentIno = hit.parentIno;
2783
2826
  }
2784
2827
  }
2785
2828
  }
2786
2829
  }
2787
2830
  result.isCompact = result.relation === 'compact';
2788
2831
  result.isFork = result.relation === 'fork';
2789
- parentSessionCache.set(sessionId, result);
2832
+ // No anchor yet means the head is not written; that "no parent" is not a verdict.
2833
+ if (self) setParentVerdict(sessionId, meta, self, parentIno, result);
2790
2834
  return result;
2791
2835
  }
2792
2836
  // #endregion
@@ -3118,7 +3162,7 @@ app.get('/api/sessions/:sessionId/cached-image/:n', (req, res) => {
3118
3162
  // #region META_ROUTES
3119
3163
  app.get('/api/version', (_req, res) => {
3120
3164
  const pkg = require('./package.json');
3121
- res.json({ version: pkg.version });
3165
+ res.json({ version: pkg.version, plugin: pluginStatus(CLAUDE_DIR) });
3122
3166
  });
3123
3167
 
3124
3168
  app.get('/api/config', (_req, res) => {
@@ -3143,8 +3187,10 @@ function isAllowedFolder(dir) {
3143
3187
  try { return known && statSync(dir).isDirectory(); } catch { return false; }
3144
3188
  }
3145
3189
 
3190
+ let listenPort = null;
3146
3191
  const terminal = createTerminalService({
3147
3192
  config: readTerminalConfig({ getArgValue }),
3193
+ serverUrl: () => (listenPort ? `http://127.0.0.1:${listenPort}` : null),
3148
3194
  net,
3149
3195
  claudeDir: CLAUDE_DIR,
3150
3196
  isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
@@ -3229,6 +3275,13 @@ const dispatchGroups = createGroupStore({
3229
3275
  pinnedIds: () => new Set(Object.keys(readPins())),
3230
3276
  });
3231
3277
 
3278
+ const linkedDocs = createLinkedDocStore({
3279
+ load: () => {
3280
+ try { return JSON.parse(readFileSync(LINKED_DOCS_FILE, 'utf8')); } catch { return null; }
3281
+ },
3282
+ save: (data) => writeJsonAtomic(LINKED_DOCS_FILE, data),
3283
+ });
3284
+
3232
3285
  // The board places a session from these alone, so it never has to move it later.
3233
3286
  // `startedBy` lets it follow a starter the user put in a named group, which only the
3234
3287
  // browser knows; it goes once the dispatch settles.
@@ -3256,8 +3309,7 @@ app.post('/api/dispatch', (req, res) => {
3256
3309
  if (group != null && !isGroupName(group)) {
3257
3310
  return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3258
3311
  }
3259
- const starterGroup = parent ? dispatchGroups.groupOf(parent) : null;
3260
- const target = group || starterGroup;
3312
+ const target = group || (parent && dispatchGroups.groupOf(parent)) || null;
3261
3313
  const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
3262
3314
  const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
3263
3315
  const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
@@ -3266,8 +3318,8 @@ app.post('/api/dispatch', (req, res) => {
3266
3318
  return res.status(started.status).json({ error: started.error });
3267
3319
  }
3268
3320
  dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
3269
- // A starter already in a group stays where it is: moving it would jump it under the user.
3270
- if (target) dispatchGroups.join(target, [starterGroup ? null : parent, started.id]);
3321
+ // The starter stays where it is: moving it would jump it under the user.
3322
+ if (target) dispatchGroups.join(target, [started.id], parent);
3271
3323
  broadcast({ type: 'dispatch-update' });
3272
3324
  res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
3273
3325
  });
@@ -3646,21 +3698,40 @@ app.post('/api/preview', async (req, res) => {
3646
3698
  // API: Link a file to a session's sidebar docs without opening the preview modal.
3647
3699
  // Not extension-restricted, matching /api/file/resolve — an unpreviewable link just
3648
3700
  // opens in the editor. Unlinking skips the stat so a deleted file can still be removed.
3701
+ // An http(s) URL is linked as it is. `open` comes from `preview-doc <url>`: a tab cannot
3702
+ // open a URL without a click, so the tab on screen offers an Open button.
3649
3703
  app.post('/api/document/link', async (req, res) => {
3650
3704
  try {
3651
- const { path: filePath, sessionId, unlink } = req.body || {};
3705
+ const { path: filePath, sessionId, unlink, open } = req.body || {};
3652
3706
  if (typeof sessionId !== 'string' || !sessionId) return res.status(400).json({ error: 'sessionId is required' });
3653
- const abs = resolvePreviewPath(filePath);
3707
+ const url = linkUrl(filePath);
3708
+ const abs = url || resolvePreviewPath(filePath);
3654
3709
  if (!abs) return res.status(400).json({ error: 'path is required' });
3655
- if (!unlink) await statFileTarget(abs);
3656
- broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
3657
- res.json({ success: true });
3710
+ if (!unlink && !url) await statFileTarget(abs);
3711
+ if (unlink) linkedDocs.unlink(sessionId, abs);
3712
+ else linkedDocs.link(sessionId, abs);
3713
+ broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink, open: !!(url && open && !unlink) });
3714
+ res.json({ success: true, path: abs, tabs: clients.size });
3658
3715
  } catch (error) {
3659
3716
  console.error('Error in /api/document/link:', error);
3660
3717
  res.status(error.status || 500).json({ error: error.message || 'Link failed' });
3661
3718
  }
3662
3719
  });
3663
3720
 
3721
+ app.get('/api/document/links', (req, res) => {
3722
+ res.setHeader('Cache-Control', 'no-store');
3723
+ const sessionId = req.query.session ? String(req.query.session) : null;
3724
+ res.json(sessionId ? { [sessionId]: linkedDocs.get(sessionId) } : linkedDocs.all());
3725
+ });
3726
+
3727
+ // The browser's own unlink: it already updated its list, so this only drops the server
3728
+ // copy that would bring the doc back on the next merge. No path clears the session.
3729
+ app.delete('/api/document/links/:sessionId', (req, res) => {
3730
+ const raw = String(req.query.path || '');
3731
+ const filePath = linkUrl(raw) || resolvePreviewPath(raw);
3732
+ res.json({ removed: linkedDocs.unlink(req.params.sessionId, filePath) });
3733
+ });
3734
+
3664
3735
  app.get('/api/session/resolve', (req, res) => {
3665
3736
  try {
3666
3737
  const idArg = (req.query.id || '').toString();
@@ -4080,30 +4151,51 @@ setInterval(cleanupContextStatus, 30 * 60 * 1000);
4080
4151
  // enrichment for inactive sessions, so we no longer drive a full self-request
4081
4152
  // here — that was 690× wasted work for an active-filter first hit.
4082
4153
  // Yields to the event loop periodically so any inbound request isn't starved.
4154
+ // A yield every N sessions does not bound the wait: one large transcript is a long sync read.
4155
+ const PREWARM_SLICE_MS = 10;
4156
+ // Started on the ready line, the full read of every transcript competes for disk and CPU with
4157
+ // the hub's other apps while they render, and slows their first render. It starts after the
4158
+ // first list is sent, or after the fallback when no client asks.
4159
+ const PREWARM_FALLBACK_MS = 5000;
4160
+ const SESSION_CACHE_SAVE_MS = 30000;
4161
+ const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomic(SESSION_CACHE_FILE, data));
4162
+ let prewarmStarted = false;
4163
+ function startPrewarm() {
4164
+ if (prewarmStarted) return;
4165
+ prewarmStarted = true;
4166
+ setImmediate(prewarmCaches);
4167
+ }
4168
+
4083
4169
  async function prewarmCaches() {
4084
4170
  const t0 = Date.now();
4085
4171
  try {
4086
4172
  const metadata = loadSessionMetadata();
4087
4173
 
4088
- let i = 0;
4174
+ let sliceStart = Date.now();
4089
4175
  for (const meta of Object.values(metadata)) {
4090
4176
  if (meta?.jsonlPath) {
4091
4177
  try { refreshLoopInfoState(meta.jsonlPath); } catch {}
4092
4178
  }
4093
- if (++i % 50 === 0) await new Promise(r => setImmediate(r));
4179
+ if (Date.now() - sliceStart >= PREWARM_SLICE_MS) {
4180
+ await new Promise(r => setImmediate(r));
4181
+ sliceStart = Date.now();
4182
+ }
4094
4183
  }
4095
4184
 
4096
4185
  console.log(`[prewarm] done in ${Date.now() - t0}ms (${Object.keys(metadata).length} sessions)`);
4186
+ persistSessionCache();
4097
4187
  } catch (e) {
4098
4188
  console.warn('[prewarm] failed:', e.message);
4099
4189
  }
4100
4190
  }
4101
4191
 
4102
4192
  const onReady = (actualPort) => {
4103
- console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
4193
+ listenPort = actualPort;
4104
4194
  // The port is configurable and falls back to a random one when taken, so the postman
4105
4195
  // monitor cannot assume it -- publish the live one where it can read it.
4106
4196
  writeServerInfo(actualPort);
4197
+ writeTerminalToken(actualPort);
4198
+ console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
4107
4199
  setInterval(() => reclaimServerInfo(actualPort), 30000).unref();
4108
4200
  migrateLegacyApprovalsConfig();
4109
4201
  const warning = net.exposureWarning();
@@ -4118,9 +4210,15 @@ async function prewarmCaches() {
4118
4210
  if (process.argv.includes('--open')) {
4119
4211
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
4120
4212
  }
4121
- setImmediate(prewarmCaches);
4213
+ setTimeout(startPrewarm, PREWARM_FALLBACK_MS).unref();
4122
4214
  };
4123
4215
 
4216
+ loadSessionCache(SESSION_CACHE_FILE);
4217
+ // Under the hub a child can end by TerminateProcess on Windows, where no exit handler runs,
4218
+ // so the cache is also saved on a timer.
4219
+ setInterval(persistSessionCache, SESSION_CACHE_SAVE_MS).unref();
4220
+ process.on('exit', persistSessionCache);
4221
+
4124
4222
  const listenOpts = { onUpgrade: terminal.handleUpgrade };
4125
4223
  const server = net.listenLoopback(app, PORT, onReady, listenOpts);
4126
4224
  process.on('exit', terminal.shutdown);
@@ -4135,4 +4233,5 @@ async function prewarmCaches() {
4135
4233
  });
4136
4234
 
4137
4235
 
4138
- // #endregion
4236
+ // #endregion
4237
+ }
@@ -19,14 +19,15 @@ Dispatch when the task can run on its own. Do the work yourself when it is small
19
19
  ## Start
20
20
 
21
21
  ```bash
22
- claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> [--report] [--model haiku|sonnet|opus|fable] [--worktree [name]] --json
22
+ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name> --group <group> --peer <your-peer> [--report] --json
23
23
  ```
24
24
 
25
+ `claude-code-kanban help dispatch start` lists every flag (model, worktree, and the rest). `project list` shows the folders `--cwd` accepts. How to choose the values:
26
+
25
27
  - `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it whenever you have the `ListAgents` tool. cck then tells the started session to ask you with `SendMessage` instead of failing on a question. See [Peer](#peer).
26
- - `--cwd` must be a project cck already knows (default: the current dir).
27
28
  - `--spec-file` over `--spec` for anything longer than a line: no shell quoting.
28
29
  - `--name` is what the user sees in the sidebar. Kebab-case, saying what the session does: `fix-login-redirect`, not `task-1`.
29
- - `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session and this session together under one sidebar group. Pass it on your first dispatch; later dispatches join the same group without it. A group goes away when its sessions end, unless the user pins a member or keeps the group.
30
+ - `--group` names the effort, in kebab-case (`auth-refactor`), and shows the new session under that sidebar group. This session stays where it is. Pass it on your first dispatch; later dispatches join the same group without it. A group goes away when its sessions end, unless the user pins a member or keeps the group.
30
31
  - The result holds the `dispatch` id and the `session` id.
31
32
 
32
33
  ## Fire-and-forget
@@ -44,7 +45,7 @@ The started session settles with one report, `succeeded` or `failed`, or as `exi
44
45
  claude-code-kanban dispatch wait [<id>...] --timeout 15m --json
45
46
  ```
46
47
 
47
- It returns `settled`, `running`, and `timeout`, as soon as any watched dispatch settles; call it again with the ids still running. A timeout is a checkpoint: the session may still be working. Look before you act:
48
+ Call it again with the ids still running (`help dispatch wait` has the output fields). A timeout is a checkpoint: the session may still be working. Look before you act:
48
49
 
49
50
  ```bash
50
51
  claude-code-kanban dispatch list --json