claude-code-kanban 4.29.0 → 4.30.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
@@ -41,7 +41,12 @@ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = requ
41
41
  const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
42
42
  const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName } = require('./lib/dispatch');
43
43
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
44
+ const { createLinkedDocStore } = require('./lib/linked-docs');
44
45
  const { pickFolder } = require('./lib/folder-dialog');
46
+ const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
47
+ const { countTaskDir } = require('./lib/task-counts');
48
+ const { projectMatcher } = require('./public/project-match');
49
+ const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
45
50
 
46
51
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
47
52
  const { runInstall, runUninstall } = require("./install");
@@ -49,10 +54,12 @@ if (process.argv.includes("--install") || process.argv.includes("--uninstall"))
49
54
  (process.argv.includes("--install") ? runInstall({ pluginOnly }) : runUninstall())
50
55
  .then(() => process.exit(0))
51
56
  .catch(e => { console.error(e.message); process.exit(1); });
52
- return;
57
+ } else if (!require("./cli").runCli(process.argv)) {
58
+ startServer();
53
59
  }
54
- if (require("./cli").runCli(process.argv)) return;
55
60
 
61
+ // Not indented: the formatter is off for this file, and a reindent would rewrite every line.
62
+ function startServer() {
56
63
 
57
64
  const app = express();
58
65
  const PORT = process.env.PORT || 3541;
@@ -82,8 +89,10 @@ const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
82
89
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
83
90
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
84
91
  const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
92
+ const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
85
93
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
86
- const TERMINAL_TOKEN_FILE = path.join(CCK_DIR, 'terminal-token.json');
94
+ const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
95
+ const SESSION_CACHE_FILE = path.join(CCK_DIR, 'session-cache.json');
87
96
  // Harness-owned scratchpad root; the per-session dir under it is created lazily.
88
97
  const SCRATCHPAD_ROOT = path.join(os.tmpdir(), 'claude');
89
98
 
@@ -101,7 +110,7 @@ function readPins() {
101
110
 
102
111
  function writeJsonAtomic(file, obj, mode) {
103
112
  try {
104
- mkdirSync(CCK_DIR, { recursive: true });
113
+ mkdirSync(path.dirname(file), { recursive: true });
105
114
  const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
106
115
  writeFileSync(tmp, JSON.stringify(obj, null, 2), { encoding: 'utf8', mode });
107
116
  renameSync(tmp, file);
@@ -119,37 +128,46 @@ function writePins(pins) {
119
128
  // a crashed one.
120
129
  function writeServerInfo(port) {
121
130
  writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
122
- writeTerminalToken();
123
131
  }
124
132
 
125
133
  // For `dispatch start`: a local process as the same user can already run claude itself,
126
134
  // so handing it the token adds little. A browser still cannot read the file.
127
- function writeTerminalToken() {
135
+ // One file per port: two boards on one config dir each keep a token, so the CLI can
136
+ // reach the board CCK_URL names even when the other one owns server.json.
137
+ // The sweep also drops terminal-token.json, the single file that older versions wrote.
138
+ let terminalTokenFile = null;
139
+ function writeTerminalToken(port) {
128
140
  if (terminal.unavailableReason()) return;
129
- writeJsonAtomic(TERMINAL_TOKEN_FILE, { pid: process.pid, token: terminal.token }, 0o600);
141
+ let stale = [path.join(CCK_DIR, 'terminal-token.json')];
142
+ try { stale = stale.concat(readdirSync(TERMINAL_TOKENS_DIR).map(name => path.join(TERMINAL_TOKENS_DIR, name))); } catch (_) { /* no dir yet */ }
143
+ for (const file of stale) {
144
+ const pid = ownerPid(file);
145
+ if (pid !== null && !isPidAlive(pid)) try { unlinkSync(file); } catch (_) { /* already gone */ }
146
+ }
147
+ terminalTokenFile = path.join(TERMINAL_TOKENS_DIR, `${port}.json`);
148
+ writeJsonAtomic(terminalTokenFile, { pid: process.pid, token: terminal.token }, 0o600);
149
+ }
150
+
151
+ function ownerPid(file) {
152
+ try { return JSON.parse(readFileSync(file, 'utf8')).pid ?? null; } catch (_) { return null; }
130
153
  }
131
154
 
132
155
  // A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
133
156
  // probes the port, finds it closed and never waits. The hub spawns every sub-app on
134
157
  // an ephemeral port, so a stale beacon never comes back on its own — drop ours on
135
158
  // the way out. Only when the file is still ours: a newer server on the same config
136
- // dir has already claimed it.
159
+ // dir has already claimed it. The terminal token file goes the same way.
137
160
  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 */ }
161
+ for (const file of [SERVER_INFO_FILE, terminalTokenFile]) {
162
+ if (file && ownerPid(file) === process.pid) try { unlinkSync(file); } catch (_) { /* already gone */ }
143
163
  }
144
164
  }
145
165
 
146
166
  // A second server on the same config dir (a test hub, say) takes the beacon and removes it on
147
167
  // exit, which leaves this live server undiscoverable. A newer live owner keeps it.
148
168
  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 */ }
169
+ const pid = ownerPid(SERVER_INFO_FILE);
170
+ if (pid !== null && isPidAlive(pid)) return;
153
171
  writeServerInfo(port);
154
172
  }
155
173
 
@@ -718,29 +736,13 @@ function getTaskCounts(sessionPath) {
718
736
  const cached = taskCountsCache.get(sessionPath);
719
737
  if (cached) return cached;
720
738
 
721
- const taskFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
722
- let completed = 0, inProgress = 0, pending = 0, newestTaskMtime = null;
739
+ const { completed, inProgress, pending, newestTaskMtime } = countTaskDir(sessionPath);
723
740
  // Directory mtime bumps when task files are added/removed, so it stays fresh even for an
724
741
  // emptied dir (task list closed) that has no files left to date. Task-file mtime alone would
725
742
  // report 0 for such a dir and lose "latest wins" to a stale prior-boot dir.
726
743
  let dirMtime = 0;
727
744
  try { dirMtime = statSync(sessionPath).mtimeMs; } catch (_) {}
728
745
 
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
746
  const taskCount = completed + inProgress + pending;
745
747
  const result = { taskCount, completed, inProgress, pending, newestTaskMtime, dirMtime };
746
748
  taskCountsCache.set(sessionPath, result);
@@ -823,8 +825,6 @@ function refreshSessionMetadataPath(jsonlPath) {
823
825
  if (info.cwd) existing.cwd = info.cwd;
824
826
  if (info.gitBranch) existing.gitBranch = info.gitBranch;
825
827
  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
828
  if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
829
829
  if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
830
830
  return true;
@@ -911,7 +911,6 @@ function loadSessionMetadata() {
911
911
  cwd: sessionInfo.cwd || null,
912
912
  gitBranch: sessionInfo.gitBranch || null,
913
913
  customTitle: sessionInfo.customTitle || null,
914
- goal: sessionInfo.goal || null,
915
914
  jsonlPath: jsonlPath,
916
915
  logicalParentUuid: sessionInfo.logicalParentUuid || null,
917
916
  compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null
@@ -1155,7 +1154,6 @@ function buildSessionObject(id, meta, overrides = {}) {
1155
1154
  gitBranch: resolveSessionGitBranch(meta),
1156
1155
  worktree: resolveWorktree(meta.project),
1157
1156
  customTitle: meta.customTitle || null,
1158
- goal: meta.goal || null,
1159
1157
  taskCount: 0,
1160
1158
  completed: 0,
1161
1159
  inProgress: 0,
@@ -1177,7 +1175,6 @@ function buildSessionObject(id, meta, overrides = {}) {
1177
1175
  contextStatus: getContextStatus(id, meta),
1178
1176
  ...getPlanInfo(meta.slug),
1179
1177
  ...getWorkflowInfoSummary(id),
1180
- loopInfo: getLoopInfoSummary(meta),
1181
1178
  ...overrides,
1182
1179
  // Remove internal-only field
1183
1180
  _logStat: undefined,
@@ -1551,24 +1548,62 @@ app.get('/api/sessions', async (req, res) => {
1551
1548
  // The client sends the session it currently has open, which it cannot render at all
1552
1549
  // if the row is missing — that one is not a preference.
1553
1550
  if (projectFilter) {
1554
- sessions = sessions.filter(s => s.project === projectFilter || includeIds.has(s.id));
1551
+ const matches = projectMatcher(String(projectFilter));
1552
+ sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
1553
+ }
1554
+ // The sidebar's 24h filter: projects with any transcript written in the window, so an older
1555
+ // session of such a project stays in.
1556
+ const recentHours = Number(req.query.recentHours);
1557
+ if (recentHours > 0) {
1558
+ const cutoff = Date.now() - recentHours * 3600 * 1000;
1559
+ const activity = projectActivity();
1560
+ sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff || includeIds.has(s.id));
1555
1561
  }
1556
1562
 
1563
+ const paged = limit !== null && limit > 0;
1564
+ // The sidebar loads the next page on scroll while this is true. A header, so the body stays a plain array.
1565
+ res.setHeader('X-Has-More', String(paged && sessions.length > limit));
1557
1566
  // Apply limit if specified, but always include pinned sessions
1558
- if (limit !== null && limit > 0) {
1567
+ if (paged) {
1559
1568
  const top = sessions.slice(0, limit);
1560
1569
  const topIds = new Set(top.map(s => s.id));
1561
1570
  const missingPinned = sessions.filter(s => pinnedIds.has(s.id) && !topIds.has(s.id));
1562
1571
  sessions = [...top, ...missingPinned];
1563
1572
  }
1564
1573
 
1574
+ // Loop info can mean a full read of the transcript, so only the rows sent pay for it.
1575
+ for (const s of sessions) s.loopInfo = getLoopInfoSummary(s);
1576
+
1565
1577
  res.json(withDispatchPlacement(sessions));
1578
+ startPrewarm();
1566
1579
  } catch (error) {
1567
1580
  console.error('Error listing sessions:', error);
1568
1581
  res.status(500).json({ error: 'Failed to list sessions' });
1569
1582
  }
1570
1583
  });
1571
1584
 
1585
+ // Ids of sessions whose id or name contains q, from any transcript, newest first. The client
1586
+ // gets the rows through `/api/sessions?include=`. A linear scan is enough: ~4 ms for 10k ids,
1587
+ // plus one stat per hit for the sort.
1588
+ const SESSION_SEARCH_MIN = 3;
1589
+ const SESSION_SEARCH_MAX = 20;
1590
+ app.get('/api/sessions/search', (req, res) => {
1591
+ res.setHeader('Cache-Control', 'no-store');
1592
+ const text = String(req.query.q || '').trim().toLowerCase();
1593
+ if (text.length < SESSION_SEARCH_MIN) return res.json([]);
1594
+ const idQ = text.replace(/-/g, '');
1595
+ const searchIds = idQ.length >= SESSION_SEARCH_MIN && /^[0-9a-f]+$/.test(idQ);
1596
+ const hits = [];
1597
+ for (const [id, meta] of Object.entries(loadSessionMetadata())) {
1598
+ const name = getSessionDisplayName(id, meta);
1599
+ if ((searchIds && id.replace(/-/g, '').includes(idQ)) || name?.toLowerCase().includes(text)) {
1600
+ hits.push({ id, mtime: getSessionLogStat(meta).mtime || 0 });
1601
+ }
1602
+ }
1603
+ hits.sort((a, b) => b.mtime - a.mtime);
1604
+ res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
1605
+ });
1606
+
1572
1607
  // os.tmpdir() can be an 8.3 short path on Windows; transcripts record the long form.
1573
1608
  const TEMP_ROOT = (() => {
1574
1609
  try { return realpathSync.native(os.tmpdir()); } catch { return os.tmpdir(); }
@@ -1578,19 +1613,22 @@ function isTempPath(p) {
1578
1613
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
1579
1614
  }
1580
1615
 
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)) {
1616
+ // Project path → newest transcript mtime among its sessions (undefined when none has one).
1617
+ function projectActivity() {
1618
+ const activity = new Map();
1619
+ for (const meta of Object.values(loadSessionMetadata())) {
1587
1620
  if (!meta.project) continue;
1588
1621
  const mtime = getSessionLogStat(meta).mtime;
1589
- if (!projectMap[meta.project] || (mtime && mtime > projectMap[meta.project])) {
1590
- projectMap[meta.project] = mtime;
1591
- }
1622
+ const prev = activity.get(meta.project);
1623
+ if (!prev || (mtime && mtime > prev)) activity.set(meta.project, mtime);
1592
1624
  }
1593
- const projects = Object.entries(projectMap)
1625
+ return activity;
1626
+ }
1627
+
1628
+ // API: Get distinct project paths with last-modified timestamps
1629
+ app.get('/api/projects', (_req, res) => {
1630
+ res.setHeader('Cache-Control', 'no-store');
1631
+ const projects = [...projectActivity()]
1594
1632
  .map(([path, mtime]) => ({
1595
1633
  path,
1596
1634
  modifiedAt: mtime ? new Date(mtime).toISOString() : null,
@@ -2398,7 +2436,8 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2398
2436
  try {
2399
2437
  const progressMap = getProgressMap(meta.jsonlPath);
2400
2438
  for (const entry of Object.values(progressMap)) {
2401
- const e = (byAgentId[entry.agentId] ||= {});
2439
+ byAgentId[entry.agentId] ||= {};
2440
+ const e = byAgentId[entry.agentId];
2402
2441
  for (const f of ['prompt', 'name', 'description', 'usage']) {
2403
2442
  if (entry[f] && !e[f]) e[f] = entry[f];
2404
2443
  }
@@ -2674,7 +2713,6 @@ function resolveSubagentJsonl(meta, sessionId, agentId) {
2674
2713
  // Fork: copies the parent's early messages verbatim (same UUIDs). Anchor = first UUID.
2675
2714
  // Compact: writes a compact_boundary record with logicalParentUuid in the preamble.
2676
2715
  // Birthtime (not mtime) identifies the parent — mtime changes on resume, birthtime is immutable.
2677
- const parentSessionCache = new Map();
2678
2716
  const FORK_ANCHOR_SCAN_LINES = 10;
2679
2717
  function findForkAnchorUuid(jsonlPath) {
2680
2718
  let text;
@@ -2688,8 +2726,8 @@ function findForkAnchorUuid(jsonlPath) {
2688
2726
  return firstUuid;
2689
2727
  }
2690
2728
  // 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.
2729
+ // Reached only from lookupParentSession, whose result is cached (lib/parent-cache.js) —
2730
+ // one read per session until its verdict goes stale, never per request.
2693
2731
  // Bounded read (~1 MB) mirrors readSessionInfoFromJsonl's HEAD_MAX — compact_boundary
2694
2732
  // always sits in the preamble before the first user/assistant record.
2695
2733
  const COMPACT_ANCHOR_READ_MAX = 1048576;
@@ -2729,8 +2767,9 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2729
2767
  if (!f.endsWith('.jsonl')) continue;
2730
2768
  const fp = path.join(projectDir, f);
2731
2769
  if (fp === excludeJsonlPath) continue;
2732
- let birthtime = 0;
2733
- try { birthtime = statSync(fp).birthtimeMs; } catch { continue; }
2770
+ let st;
2771
+ try { st = statSync(fp); } catch { continue; }
2772
+ const birthtime = st.birthtimeMs;
2734
2773
  if (maxBirthtimeMs != null && birthtime >= maxBirthtimeMs) continue;
2735
2774
  if (birthtime >= bestBirthtime) continue;
2736
2775
  let text;
@@ -2741,7 +2780,7 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2741
2780
  try {
2742
2781
  const d = JSON.parse(l);
2743
2782
  if (d.uuid === targetUuid && d.sessionId) {
2744
- best = { parentSessionId: d.sessionId, parentJsonlPath: fp, isFork: false };
2783
+ best = { parentSessionId: d.sessionId, parentJsonlPath: fp, parentIno: st.ino, isFork: false };
2745
2784
  bestBirthtime = birthtime;
2746
2785
  bestText = text;
2747
2786
  break;
@@ -2754,8 +2793,10 @@ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath, max
2754
2793
  return best;
2755
2794
  }
2756
2795
  function lookupParentSession(sessionId) {
2757
- if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
2758
2796
  const meta = loadSessionMetadata()[sessionId];
2797
+ const known = getParentVerdict(sessionId, meta);
2798
+ if (known) return known;
2799
+ let self = null, parentIno = null;
2759
2800
  // relation is the single verdict: 'none' (no parent found), 'compact' (this session
2760
2801
  // continues a lineage the parent could not hold) or 'fork' (an intentional branch off
2761
2802
  // a parent that keeps its own life). isCompact/isFork are derived views of it.
@@ -2770,23 +2811,24 @@ function lookupParentSession(sessionId) {
2770
2811
  // A fork copies the parent's early records verbatim, so its first uuid is the anchor.
2771
2812
  const anchorUuid = compactAnchor ?? findForkAnchorUuid(meta.jsonlPath);
2772
2813
  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);
2814
+ try { self = statSync(meta.jsonlPath); } catch { /* ignore */ }
2815
+ if (self) {
2816
+ const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath, self.birthtimeMs, boundary?.boundaryUuid);
2777
2817
  if (hit) {
2778
2818
  // No compact boundary at all → fork by construction; with one, the copied
2779
2819
  // boundary uuid (hit.isFork) is what separates a fork from a continuation.
2780
2820
  result.parentSessionId = hit.parentSessionId;
2781
2821
  result.parentJsonlPath = hit.parentJsonlPath;
2782
2822
  result.relation = (!compactAnchor || hit.isFork) ? 'fork' : 'compact';
2823
+ parentIno = hit.parentIno;
2783
2824
  }
2784
2825
  }
2785
2826
  }
2786
2827
  }
2787
2828
  result.isCompact = result.relation === 'compact';
2788
2829
  result.isFork = result.relation === 'fork';
2789
- parentSessionCache.set(sessionId, result);
2830
+ // No anchor yet means the head is not written; that "no parent" is not a verdict.
2831
+ if (self) setParentVerdict(sessionId, meta, self, parentIno, result);
2790
2832
  return result;
2791
2833
  }
2792
2834
  // #endregion
@@ -3143,8 +3185,10 @@ function isAllowedFolder(dir) {
3143
3185
  try { return known && statSync(dir).isDirectory(); } catch { return false; }
3144
3186
  }
3145
3187
 
3188
+ let listenPort = null;
3146
3189
  const terminal = createTerminalService({
3147
3190
  config: readTerminalConfig({ getArgValue }),
3191
+ serverUrl: () => (listenPort ? `http://127.0.0.1:${listenPort}` : null),
3148
3192
  net,
3149
3193
  claudeDir: CLAUDE_DIR,
3150
3194
  isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
@@ -3229,6 +3273,13 @@ const dispatchGroups = createGroupStore({
3229
3273
  pinnedIds: () => new Set(Object.keys(readPins())),
3230
3274
  });
3231
3275
 
3276
+ const linkedDocs = createLinkedDocStore({
3277
+ load: () => {
3278
+ try { return JSON.parse(readFileSync(LINKED_DOCS_FILE, 'utf8')); } catch { return null; }
3279
+ },
3280
+ save: (data) => writeJsonAtomic(LINKED_DOCS_FILE, data),
3281
+ });
3282
+
3232
3283
  // The board places a session from these alone, so it never has to move it later.
3233
3284
  // `startedBy` lets it follow a starter the user put in a named group, which only the
3234
3285
  // browser knows; it goes once the dispatch settles.
@@ -3256,8 +3307,7 @@ app.post('/api/dispatch', (req, res) => {
3256
3307
  if (group != null && !isGroupName(group)) {
3257
3308
  return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3258
3309
  }
3259
- const starterGroup = parent ? dispatchGroups.groupOf(parent) : null;
3260
- const target = group || starterGroup;
3310
+ const target = group || (parent && dispatchGroups.groupOf(parent)) || null;
3261
3311
  const r = dispatches.create({ parent, name, spec: spec.trim(), report: report === true, peer, group: target, worktree });
3262
3312
  const env = { CCK_DISPATCH_ID: r.id, ...(parent && { PARENT_SESSION_ID: parent }) };
3263
3313
  const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
@@ -3266,8 +3316,8 @@ app.post('/api/dispatch', (req, res) => {
3266
3316
  return res.status(started.status).json({ error: started.error });
3267
3317
  }
3268
3318
  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]);
3319
+ // The starter stays where it is: moving it would jump it under the user.
3320
+ if (target) dispatchGroups.join(target, [started.id], parent);
3271
3321
  broadcast({ type: 'dispatch-update' });
3272
3322
  res.status(201).json({ dispatch: r.id, session: started.id, cwd: started.cwd, group: target });
3273
3323
  });
@@ -3653,14 +3703,29 @@ app.post('/api/document/link', async (req, res) => {
3653
3703
  const abs = resolvePreviewPath(filePath);
3654
3704
  if (!abs) return res.status(400).json({ error: 'path is required' });
3655
3705
  if (!unlink) await statFileTarget(abs);
3706
+ if (unlink) linkedDocs.unlink(sessionId, abs);
3707
+ else linkedDocs.link(sessionId, abs);
3656
3708
  broadcast({ type: 'document:link', path: abs, sessionId, unlink: !!unlink });
3657
- res.json({ success: true });
3709
+ res.json({ success: true, path: abs, tabs: clients.size });
3658
3710
  } catch (error) {
3659
3711
  console.error('Error in /api/document/link:', error);
3660
3712
  res.status(error.status || 500).json({ error: error.message || 'Link failed' });
3661
3713
  }
3662
3714
  });
3663
3715
 
3716
+ app.get('/api/document/links', (req, res) => {
3717
+ res.setHeader('Cache-Control', 'no-store');
3718
+ const sessionId = req.query.session ? String(req.query.session) : null;
3719
+ res.json(sessionId ? { [sessionId]: linkedDocs.get(sessionId) } : linkedDocs.all());
3720
+ });
3721
+
3722
+ // The browser's own unlink: it already updated its list, so this only drops the server
3723
+ // copy that would bring the doc back on the next merge. No path clears the session.
3724
+ app.delete('/api/document/links/:sessionId', (req, res) => {
3725
+ const filePath = req.query.path ? resolvePreviewPath(String(req.query.path)) : null;
3726
+ res.json({ removed: linkedDocs.unlink(req.params.sessionId, filePath) });
3727
+ });
3728
+
3664
3729
  app.get('/api/session/resolve', (req, res) => {
3665
3730
  try {
3666
3731
  const idArg = (req.query.id || '').toString();
@@ -4080,30 +4145,51 @@ setInterval(cleanupContextStatus, 30 * 60 * 1000);
4080
4145
  // enrichment for inactive sessions, so we no longer drive a full self-request
4081
4146
  // here — that was 690× wasted work for an active-filter first hit.
4082
4147
  // Yields to the event loop periodically so any inbound request isn't starved.
4148
+ // A yield every N sessions does not bound the wait: one large transcript is a long sync read.
4149
+ const PREWARM_SLICE_MS = 10;
4150
+ // Started on the ready line, the full read of every transcript competes for disk and CPU with
4151
+ // the hub's other apps while they render, and slows their first render. It starts after the
4152
+ // first list is sent, or after the fallback when no client asks.
4153
+ const PREWARM_FALLBACK_MS = 5000;
4154
+ const SESSION_CACHE_SAVE_MS = 30000;
4155
+ const persistSessionCache = () => saveSessionCache((data) => writeJsonAtomic(SESSION_CACHE_FILE, data));
4156
+ let prewarmStarted = false;
4157
+ function startPrewarm() {
4158
+ if (prewarmStarted) return;
4159
+ prewarmStarted = true;
4160
+ setImmediate(prewarmCaches);
4161
+ }
4162
+
4083
4163
  async function prewarmCaches() {
4084
4164
  const t0 = Date.now();
4085
4165
  try {
4086
4166
  const metadata = loadSessionMetadata();
4087
4167
 
4088
- let i = 0;
4168
+ let sliceStart = Date.now();
4089
4169
  for (const meta of Object.values(metadata)) {
4090
4170
  if (meta?.jsonlPath) {
4091
4171
  try { refreshLoopInfoState(meta.jsonlPath); } catch {}
4092
4172
  }
4093
- if (++i % 50 === 0) await new Promise(r => setImmediate(r));
4173
+ if (Date.now() - sliceStart >= PREWARM_SLICE_MS) {
4174
+ await new Promise(r => setImmediate(r));
4175
+ sliceStart = Date.now();
4176
+ }
4094
4177
  }
4095
4178
 
4096
4179
  console.log(`[prewarm] done in ${Date.now() - t0}ms (${Object.keys(metadata).length} sessions)`);
4180
+ persistSessionCache();
4097
4181
  } catch (e) {
4098
4182
  console.warn('[prewarm] failed:', e.message);
4099
4183
  }
4100
4184
  }
4101
4185
 
4102
4186
  const onReady = (actualPort) => {
4103
- console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
4187
+ listenPort = actualPort;
4104
4188
  // The port is configurable and falls back to a random one when taken, so the postman
4105
4189
  // monitor cannot assume it -- publish the live one where it can read it.
4106
4190
  writeServerInfo(actualPort);
4191
+ writeTerminalToken(actualPort);
4192
+ console.log(`Claude Task Kanban running at http://localhost:${actualPort}`);
4107
4193
  setInterval(() => reclaimServerInfo(actualPort), 30000).unref();
4108
4194
  migrateLegacyApprovalsConfig();
4109
4195
  const warning = net.exposureWarning();
@@ -4118,9 +4204,15 @@ async function prewarmCaches() {
4118
4204
  if (process.argv.includes('--open')) {
4119
4205
  import('open').then(open => open.default(`http://localhost:${actualPort}`));
4120
4206
  }
4121
- setImmediate(prewarmCaches);
4207
+ setTimeout(startPrewarm, PREWARM_FALLBACK_MS).unref();
4122
4208
  };
4123
4209
 
4210
+ loadSessionCache(SESSION_CACHE_FILE);
4211
+ // Under the hub a child can end by TerminateProcess on Windows, where no exit handler runs,
4212
+ // so the cache is also saved on a timer.
4213
+ setInterval(persistSessionCache, SESSION_CACHE_SAVE_MS).unref();
4214
+ process.on('exit', persistSessionCache);
4215
+
4124
4216
  const listenOpts = { onUpgrade: terminal.handleUpgrade };
4125
4217
  const server = net.listenLoopback(app, PORT, onReady, listenOpts);
4126
4218
  process.on('exit', terminal.shutdown);
@@ -4135,4 +4227,5 @@ async function prewarmCaches() {
4135
4227
  });
4136
4228
 
4137
4229
 
4138
- // #endregion
4230
+ // #endregion
4231
+ }
@@ -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