claude-code-kanban 5.2.0 → 5.3.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.
@@ -0,0 +1,341 @@
1
+ // The terminal half of the board's TERMINAL region. The board frames this page from the other
2
+ // loopback name (see terminalFrameOrigin in app.js), a different site, so Chrome runs it in its own
3
+ // renderer process: keys, output and acks never wait behind board work. The board keeps every decision
4
+ // (when to open, retries, prompts, focus); this page keeps xterm and the socket.
5
+ ((root) => {
6
+ 'use strict';
7
+
8
+ const ACK_BATCH_BYTES = 32 * 1024;
9
+ const MSG = 'cck-term:';
10
+
11
+ const modsOf = (e) =>
12
+ [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'].filter(Boolean).join('+');
13
+
14
+ // A claim names a key by its physical code, or by its character when the layout moves it.
15
+ function keySigs(e) {
16
+ const mods = modsOf(e);
17
+ return [e.code && `${mods}|c:${e.code}`, typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`].filter(
18
+ Boolean,
19
+ );
20
+ }
21
+
22
+ const claimSig = (c) => keySigs(c)[0];
23
+
24
+ const isClaimed = (claims, e) => keySigs(e).some((s) => claims.has(s));
25
+
26
+ // Hub keys are matched the way the SDK matches them, so a layout that moves a key agrees on both sides.
27
+ const handsBack = (claims, forward, comboOf, e) => (!!comboOf && forward.has(comboOf(e))) || isClaimed(claims, e);
28
+
29
+ function parentOriginOf(hash) {
30
+ const m = /[#&]p=([^&]*)/.exec(hash || '');
31
+ if (!m) return null;
32
+ try {
33
+ return new URL(decodeURIComponent(m[1])).origin;
34
+ } catch (_) {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function tailOf(buf, count) {
40
+ if (!buf) return '';
41
+ const lines = [];
42
+ for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
43
+ const text = buf.getLine(i)?.translateToString(true) ?? '';
44
+ if (text.trim() || lines.length) lines.unshift(text);
45
+ }
46
+ return lines.join('\n');
47
+ }
48
+
49
+ function oscColor(hex) {
50
+ const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
51
+ if (!m) return null;
52
+ return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
53
+ }
54
+
55
+ function createTerminalFrame(win) {
56
+ const doc = win.document;
57
+ const parentOrigin = parentOriginOf(win.location.hash);
58
+ const host = doc.getElementById('host');
59
+ const state = {
60
+ term: null,
61
+ fit: null,
62
+ webgl: null,
63
+ ws: null,
64
+ attached: false,
65
+ ackPending: 0,
66
+ ackTimer: null,
67
+ claims: new Set(),
68
+ forward: new Set(),
69
+ };
70
+ const comboOf = win.ClaudeHub?.comboOf;
71
+
72
+ function up(t, data) {
73
+ if (parentOrigin) win.parent.postMessage({ ...data, type: MSG + t }, parentOrigin);
74
+ }
75
+
76
+ function send(msg) {
77
+ if (state.ws?.readyState === win.WebSocket.OPEN) state.ws.send(JSON.stringify(msg));
78
+ }
79
+
80
+ // The WebGL renderer keeps its last frame and glyph atlas while the frame is 0×0, and comes back
81
+ // showing stale cells or backgrounds with no text. xterm does not repaint on its own.
82
+ function repaint() {
83
+ const term = state.term;
84
+ if (!term) return;
85
+ try {
86
+ state.webgl?.clearTextureAtlas();
87
+ } catch (_) {}
88
+ term.refresh(0, term.rows - 1);
89
+ }
90
+
91
+ // The fit addon measures 0×0 while the board's pane (or the hub's iframe) is display:none.
92
+ function fit() {
93
+ if (!state.fit || !host.offsetWidth || !host.offsetHeight) return;
94
+ try {
95
+ state.fit.fit();
96
+ } catch (_) {}
97
+ }
98
+
99
+ function setBg(options) {
100
+ doc.documentElement.style.setProperty('--bg', options?.theme?.background || '');
101
+ }
102
+
103
+ function applyTheme(options) {
104
+ Object.assign(state.term.options, options);
105
+ setBg(options);
106
+ repaint();
107
+ }
108
+
109
+ function applyFont(size) {
110
+ const term = state.term;
111
+ term.options.fontSize = size;
112
+ fit();
113
+ doc.fonts
114
+ ?.load(`${size}px ${term.options.fontFamily}`)
115
+ .then(repaint)
116
+ .catch(() => {});
117
+ }
118
+
119
+ function forwardClaimed(e) {
120
+ if (!handsBack(state.claims, state.forward, comboOf, e)) return false;
121
+ e.preventDefault();
122
+ up('key', { key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
123
+ return true;
124
+ }
125
+
126
+ function keyFilter(e) {
127
+ if (e.type !== 'keydown') return true;
128
+ if (forwardClaimed(e)) return false;
129
+ const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
130
+ if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || state.term.hasSelection())) {
131
+ e.preventDefault();
132
+ win.navigator.clipboard?.writeText(state.term.getSelection()).catch(() => {});
133
+ state.term.clearSelection();
134
+ return false;
135
+ }
136
+ // The browser's own paste event reaches xterm's textarea.
137
+ return !(ctrlOnly && e.code === 'KeyV');
138
+ }
139
+
140
+ function init(o) {
141
+ if (state.term) return;
142
+ const term = new win.Terminal({
143
+ fontFamily: o.fontFamily,
144
+ fontSize: o.fontSize,
145
+ scrollback: o.scrollback,
146
+ cursorBlink: true,
147
+ allowProposedApi: true,
148
+ ...o.themeOptions,
149
+ });
150
+ state.term = term;
151
+ state.fit = new win.FitAddon.FitAddon();
152
+ term.loadAddon(state.fit);
153
+ term.loadAddon(new win.Unicode11Addon.Unicode11Addon());
154
+ term.unicode.activeVersion = '11';
155
+ term.open(host);
156
+ try {
157
+ const gl = new win.WebglAddon.WebglAddon();
158
+ gl.onContextLoss(() => {
159
+ gl.dispose();
160
+ state.webgl = null;
161
+ repaint();
162
+ });
163
+ term.loadAddon(gl);
164
+ state.webgl = gl;
165
+ } catch (_) {
166
+ // No WebGL: xterm falls back to its DOM renderer.
167
+ }
168
+ setBg(o.themeOptions);
169
+ applyFont(o.fontSize);
170
+ term.attachCustomKeyEventHandler(keyFilter);
171
+ // After a click on the scrollbar, or before the board's focus message lands, the frame has focus
172
+ // but xterm's textarea does not, and the board's keys must still get out.
173
+ doc.addEventListener('keydown', (e) => {
174
+ if (e.target !== term.textarea) forwardClaimed(e);
175
+ });
176
+ term.textarea.addEventListener('focus', () => up('focused'));
177
+ // xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
178
+ // background (Claude Code's Auto theme) assume dark even on a light theme.
179
+ for (const [code, key] of [
180
+ [10, 'foreground'],
181
+ [11, 'background'],
182
+ ]) {
183
+ term.parser.registerOscHandler(code, (data) => {
184
+ if (data !== '?') return false;
185
+ const rgb = oscColor(term.options.theme[key]);
186
+ if (rgb) send({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
187
+ return true;
188
+ });
189
+ }
190
+ term.onData((d) => send({ t: 'in', d }));
191
+ term.onResize(({ cols, rows }) => send({ t: 'resize', cols, rows }));
192
+ // One fit per frame while a drag resizes the pane. The atlas is rebuilt only when the frame comes
193
+ // back from 0×0, which is also how a hidden hub iframe or pane shows again.
194
+ let frame = 0;
195
+ let visible = false;
196
+ new win.ResizeObserver(() => {
197
+ if (frame) return;
198
+ frame = win.requestAnimationFrame(() => {
199
+ frame = 0;
200
+ const nowVisible = host.offsetWidth > 0;
201
+ fit();
202
+ if (nowVisible && !visible) repaint();
203
+ visible = nowVisible;
204
+ });
205
+ }).observe(host);
206
+ // Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
207
+ // A blur no click caused, while this frame keeps focus, is that Escape. When the board takes
208
+ // focus back, this document loses it, so that blur is not one.
209
+ let pointerDown = false;
210
+ doc.addEventListener('mousedown', () => (pointerDown = true), true);
211
+ doc.addEventListener('mouseup', () => (pointerDown = false), true);
212
+ term.textarea.addEventListener('blur', () => {
213
+ if (pointerDown) return;
214
+ win.requestAnimationFrame(() => {
215
+ if (!state.attached || !host.offsetWidth) return;
216
+ if (!doc.hasFocus() || doc.activeElement !== doc.body) return;
217
+ send({ t: 'in', d: '\x1b' });
218
+ term.focus();
219
+ });
220
+ });
221
+ up('term');
222
+ }
223
+
224
+ function ack(ws, n) {
225
+ if (state.ws !== ws) return;
226
+ state.ackPending += n;
227
+ const flush = () => {
228
+ win.clearTimeout(state.ackTimer);
229
+ state.ackTimer = null;
230
+ if (state.ws === ws && state.ackPending) send({ t: 'ack', n: state.ackPending });
231
+ state.ackPending = 0;
232
+ };
233
+ if (state.ackPending >= ACK_BATCH_BYTES) flush();
234
+ else if (!state.ackTimer) state.ackTimer = win.setTimeout(flush, 50);
235
+ }
236
+
237
+ // RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
238
+ // session had already queued would still be drawn after it.
239
+ function clearScreen() {
240
+ state.term.write('\x1bc', repaint);
241
+ }
242
+
243
+ function detach() {
244
+ const ws = state.ws;
245
+ state.ws = null;
246
+ state.attached = false;
247
+ win.clearTimeout(state.ackTimer);
248
+ state.ackTimer = null;
249
+ state.ackPending = 0;
250
+ if (ws) {
251
+ ws.onclose = null;
252
+ ws.close();
253
+ }
254
+ }
255
+
256
+ function open({ socketId, hello, reset }) {
257
+ detach();
258
+ up('opening', { socketId });
259
+ const term = state.term;
260
+ let outputSent = false;
261
+ if (reset) clearScreen();
262
+ fit();
263
+ const proto = win.location.protocol === 'https:' ? 'wss:' : 'ws:';
264
+ const ws = new win.WebSocket(`${proto}//${win.location.host}/api/terminal/ws`);
265
+ ws.binaryType = 'arraybuffer';
266
+ state.ws = ws;
267
+ ws.onopen = () => ws.send(JSON.stringify({ ...hello, t: 'hello', cols: term.cols, rows: term.rows }));
268
+ ws.onmessage = (ev) => {
269
+ if (state.ws !== ws) return;
270
+ if (typeof ev.data !== 'string') {
271
+ const bytes = new Uint8Array(ev.data);
272
+ term.write(bytes, () => {
273
+ ack(ws, bytes.length);
274
+ if (!outputSent && state.ws === ws && tailOf(term.buffer.active, 1)) {
275
+ outputSent = true;
276
+ up('output', { socketId });
277
+ }
278
+ });
279
+ return;
280
+ }
281
+ let msg;
282
+ try {
283
+ msg = JSON.parse(ev.data);
284
+ } catch (_) {
285
+ return;
286
+ }
287
+ if (msg.t === 'ready') {
288
+ state.attached = true;
289
+ // A retry keeps the last screen until the replay arrives.
290
+ if (!reset) clearScreen();
291
+ send({ t: 'resize', cols: term.cols, rows: term.rows });
292
+ } else if (msg.t === 'exit') state.attached = false;
293
+ up('ws', { socketId, msg, ...(msg.t === 'exit' && { tail: tailOf(term.buffer.active, 6) }) });
294
+ };
295
+ ws.onclose = (ev) => {
296
+ if (state.ws !== ws) return;
297
+ state.ws = null;
298
+ state.attached = false;
299
+ up('close', { socketId, code: ev.code });
300
+ };
301
+ }
302
+
303
+ function onMessage(e) {
304
+ if (e.source !== win.parent || e.origin !== parentOrigin) return;
305
+ const m = e.data;
306
+ if (typeof m?.type !== 'string' || !m.type.startsWith(MSG)) return;
307
+ if (m.type === `${MSG}init`) return init(m);
308
+ if (!state.term) return;
309
+ switch (m.type.slice(MSG.length)) {
310
+ case 'open':
311
+ return open(m);
312
+ case 'detach':
313
+ return detach();
314
+ case 'send':
315
+ return send(m.msg);
316
+ case 'theme':
317
+ return applyTheme(m.options);
318
+ case 'font':
319
+ return applyFont(m.size);
320
+ case 'claims':
321
+ state.claims = new Set((m.keys || []).map(claimSig));
322
+ state.forward = new Set(m.forward || []);
323
+ return;
324
+ case 'focus':
325
+ return state.term.focus();
326
+ case 'refresh':
327
+ fit();
328
+ return repaint();
329
+ }
330
+ }
331
+
332
+ win.addEventListener('message', onMessage);
333
+ up('loaded');
334
+ }
335
+
336
+ if (typeof module === 'object' && module.exports) {
337
+ module.exports = { createTerminalFrame, claimSig, isClaimed, handsBack, parentOriginOf, tailOf };
338
+ } else {
339
+ createTerminalFrame(root);
340
+ }
341
+ })(typeof window === 'undefined' ? globalThis : window);
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Terminal</title>
6
+ <link rel="stylesheet" href="/fonts/fonts.css">
7
+ <link rel="stylesheet" href="/vendor/xterm/xterm.css">
8
+ <style>
9
+ html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg, transparent); }
10
+ #host { height: 100%; }
11
+ #host .xterm { height: 100%; }
12
+ /* xterm.css paints the viewport #000; it shows below the last row when the height is not a whole number of rows. */
13
+ /* xterm draws its own scrollbar; the viewport's native one would show at the bottom-right corner. */
14
+ #host .xterm .xterm-viewport { background-color: var(--bg, transparent); scrollbar-width: none; }
15
+ </style>
16
+ <!-- For ClaudeHub.comboOf only. In <head>, where there is no body yet for the SDK to paint theme vars on. -->
17
+ <script src="/vendor/claude-hub-sdk.js"></script>
18
+ </head>
19
+ <body>
20
+ <div id="host"></div>
21
+ <script src="/vendor/xterm/xterm.js"></script>
22
+ <script src="/vendor/xterm/addon-fit.js"></script>
23
+ <script src="/vendor/xterm/addon-webgl.js"></script>
24
+ <script src="/vendor/xterm/addon-unicode11.js"></script>
25
+ <script src="/terminal-frame.js"></script>
26
+ </body>
27
+ </html>
@@ -1,9 +1,18 @@
1
- // claude-hub-sdk 1.1.0 (sha256 0fed14faa670). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
1
+ // claude-hub-sdk 1.2.0 (sha256 62c7d9f477b2). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
2
2
  // Claude Code Hub SDK stub: what an app serves when no hub runs it. Under a hub, the hub hands the
3
3
  // app its real SDK (client.js) in place of this file. The API must match client.js; a test checks it.
4
4
  ((root) => {
5
5
  'use strict';
6
6
 
7
+ // A copy of client.js comboOf(); a test checks they match.
8
+ function comboOf(e) {
9
+ const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
10
+ const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
11
+ const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
12
+ const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
13
+ return [...mods, key].filter(Boolean).join('+');
14
+ }
15
+
7
16
  function createClaudeHub(win) {
8
17
  let hub = null;
9
18
  const off = () => {};
@@ -33,6 +42,7 @@
33
42
  },
34
43
  can: (action) => !!standaloneFn(action),
35
44
  forwards: () => false,
45
+ forwardCombos: () => [],
36
46
  closeGuard() {},
37
47
  openExternal(url) {
38
48
  win.open(url, '_blank', 'noopener');
@@ -42,9 +52,9 @@
42
52
  return hub;
43
53
  }
44
54
 
45
- return { connect };
55
+ return { connect, comboOf };
46
56
  }
47
57
 
48
- if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub };
58
+ if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
49
59
  else root.ClaudeHub = createClaudeHub(root);
50
60
  })(typeof window !== 'undefined' ? window : globalThis);
package/server.js CHANGED
@@ -39,16 +39,18 @@ const {
39
39
  } = require('./lib/parsers');
40
40
  const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
41
41
  const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
42
- const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir } = require('./lib/claude-dir');
42
+ const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
43
43
  const { createTerminalService, readTerminalConfig } = require('./lib/terminal');
44
+ const { createProcStats } = require('./lib/proc-stats');
44
45
  const { createDispatchRegistry, formatPreamble, formatDispatchLine, isPeerName, DISPATCH_OUTCOME } = require('./lib/dispatch');
45
46
  const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
46
- const { createDispatchedStore, listTranscriptIds, pruneSessionDirs, retentionMs } = require('./lib/retention');
47
+ const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
48
+ const { createWorktreeStore } = require('./lib/worktrees');
47
49
  const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
48
50
  const { pickFolder } = require('./lib/folder-dialog');
49
51
  const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
50
52
  const { countTaskDir } = require('./lib/task-counts');
51
- const { projectMatcher } = require('./public/project-match');
53
+ const { projectMatcher, normalizeProjectPath } = require('./public/project-match');
52
54
  const { getParentVerdict, setParentVerdict } = require('./lib/parent-cache');
53
55
 
54
56
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -69,7 +71,8 @@ const PORT = parseInt(getArgValue('port') || process.env.PORT || '3541', 10);
69
71
 
70
72
  // Mounted before express.json() so a rejected request never buffers a body, and
71
73
  // before the /api catch-all so no route escapes the check.
72
- const net = createNetGuard({ appName: 'Claude Task Kanban' });
74
+ // The board frames the terminal from its other loopback name, so Chrome runs it in its own process.
75
+ const net = createNetGuard({ appName: 'Claude Task Kanban', selfFramedPaths: ['/terminal.html'] });
73
76
  app.use(net.hostGuard);
74
77
  app.use(net.frameGuard);
75
78
  app.use(net.originGuard);
@@ -93,6 +96,7 @@ const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
93
96
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
94
97
  const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
95
98
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
99
+ const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
96
100
  const LINKED_DOCS_FILE = path.join(CCK_DIR, 'linked-docs.json');
97
101
  const SERVER_INFO_FILE = path.join(CCK_DIR, 'server.json');
98
102
  const TERMINAL_TOKENS_DIR = path.join(CCK_DIR, 'terminal-tokens');
@@ -338,33 +342,12 @@ function getGitBranch(cwd) {
338
342
  return branch;
339
343
  }
340
344
 
341
- // A linked worktree's `.git` is a file holding `gitdir: <main>/.git/worktrees/<name>`, so the
342
- // main checkout is readable without spawning git. Path shape alone would not do: only some
343
- // worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
344
- // Cached without a TTL, misses included: a directory cannot turn from an ordinary checkout into
345
- // a linked worktree without being recreated, and a recreated path is a new cache key.
346
- const worktreeCache = new Map();
347
- const WORKTREE_CACHE_MAX = 500;
348
- const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
349
-
350
- function resolveWorktree(dir) {
351
- if (!dir) return null;
352
- if (worktreeCache.has(dir)) return worktreeCache.get(dir);
353
-
354
- let worktree = null;
355
- try {
356
- // An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
357
- // answer, and it costs one syscall instead of a stat followed by a read.
358
- const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
359
- if (m) worktree = { repo: m[1], name: m[2] };
360
- } catch (_) {}
361
-
362
- worktreeCache.set(dir, worktree);
363
- if (worktreeCache.size > WORKTREE_CACHE_MAX) {
364
- worktreeCache.delete(worktreeCache.keys().next().value);
365
- }
366
- return worktree;
367
- }
345
+ const worktrees = createWorktreeStore({
346
+ load: () => {
347
+ try { return JSON.parse(readFileSync(WORKTREES_FILE, 'utf8')); } catch { return null; }
348
+ },
349
+ save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
350
+ });
368
351
 
369
352
  // Only spawn git when cwd has diverged from the launch project — that's the
370
353
  // only case the JSONL value is wrong. Saves N spawns on a typical list build.
@@ -1140,12 +1123,6 @@ function getSessionDisplayName(_sessionId, meta) {
1140
1123
  return null;
1141
1124
  }
1142
1125
 
1143
- // The harness spells a project path into a temp/log dir name by replacing every
1144
- // non-alphanumeric character with a dash, drive colon and separators included.
1145
- function encodeProjectDirName(p) {
1146
- return p.replace(/[^a-zA-Z0-9]/g, '-');
1147
- }
1148
-
1149
1126
  // Derived by convention, not looked up: the harness creates the dir lazily, so a
1150
1127
  // stat here would report "missing" for every session that has not written a temp
1151
1128
  // file yet — and it would put IO on the session-list hot path. Pure string join.
@@ -1160,7 +1137,7 @@ function getScratchpadDir(id, meta) {
1160
1137
  // harness actually created. Probed only for worktree sessions, and only until one
1161
1138
  // of them exists — before that there is nothing to disambiguate and `byProject`,
1162
1139
  // right for a session started inside the worktree, stands.
1163
- const wt = resolveWorktree(meta.project);
1140
+ const wt = worktrees.resolve(meta.project);
1164
1141
  if (!wt || existsSync(byProject)) return byProject;
1165
1142
  const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
1166
1143
  return existsSync(byRepo) ? byRepo : byProject;
@@ -1179,7 +1156,7 @@ function buildSessionObject(id, meta, overrides = {}) {
1179
1156
  cwd: meta.cwd || null,
1180
1157
  description: meta.description || null,
1181
1158
  gitBranch: resolveSessionGitBranch(meta),
1182
- worktree: resolveWorktree(meta.project),
1159
+ worktree: worktrees.resolve(meta.project),
1183
1160
  customTitle: meta.customTitle || null,
1184
1161
  taskCount: 0,
1185
1162
  completed: 0,
@@ -1578,7 +1555,7 @@ app.get('/api/sessions', async (req, res) => {
1578
1555
  // if the row is missing — that one is not a preference.
1579
1556
  if (projectFilter) {
1580
1557
  const matches = projectMatcher(String(projectFilter));
1581
- sessions = sessions.filter(s => matches(s.project) || includeIds.has(s.id));
1558
+ sessions = sessions.filter(s => matches(s.project, s.worktree?.repo) || includeIds.has(s.id));
1582
1559
  }
1583
1560
  // The sidebar's 24h filter: projects with any transcript written in the window, so an older
1584
1561
  // session of such a project stays in.
@@ -1650,14 +1627,30 @@ function projectActivity() {
1650
1627
  return activity;
1651
1628
  }
1652
1629
 
1653
- // API: Get distinct project paths with last-modified timestamps
1630
+ // API: Get distinct project paths with last-modified timestamps. A linked worktree is folded
1631
+ // into its repo, which lists it under `worktrees`; the repo row exists even when only its
1632
+ // worktrees have transcripts.
1654
1633
  app.get('/api/projects', (_req, res) => {
1655
1634
  res.setHeader('Cache-Control', 'no-store');
1656
- const projects = [...projectActivity()]
1657
- .map(([path, mtime]) => ({
1658
- path,
1659
- modifiedAt: mtime ? new Date(mtime).toISOString() : null,
1660
- ...(isTempPath(path) && { temp: true }),
1635
+ const byRepo = new Map();
1636
+ for (const [project, mtime] of projectActivity()) {
1637
+ const repo = worktrees.resolve(project)?.repo || project;
1638
+ const key = normalizeProjectPath(repo);
1639
+ let row = byRepo.get(key);
1640
+ if (!row) {
1641
+ row = { path: repo, mtime: 0, worktrees: [] };
1642
+ byRepo.set(key, row);
1643
+ }
1644
+ if (repo === project) row.path = project;
1645
+ else row.worktrees.push(project);
1646
+ row.mtime = Math.max(row.mtime, mtime || 0);
1647
+ }
1648
+ const projects = [...byRepo.values()]
1649
+ .map((r) => ({
1650
+ path: r.path,
1651
+ modifiedAt: r.mtime ? new Date(r.mtime).toISOString() : null,
1652
+ ...(isTempPath(r.path) && { temp: true }),
1653
+ ...(r.worktrees.length && { worktrees: r.worktrees.sort() }),
1661
1654
  }))
1662
1655
  .sort((a, b) => a.path.localeCompare(b.path));
1663
1656
  res.json(projects);
@@ -3298,6 +3291,16 @@ app.get('/api/terminals', (_req, res) => {
3298
3291
  res.json({ sessions: terminal.list() });
3299
3292
  });
3300
3293
 
3294
+ const terminalProcStats = createProcStats();
3295
+ app.get('/api/terminals/stats', async (_req, res) => {
3296
+ res.setHeader('Cache-Control', 'no-store');
3297
+ const pids = terminal.claudePids();
3298
+ const byPid = await terminalProcStats([process.pid, ...Object.values(pids)]);
3299
+ const terminals = {};
3300
+ for (const [id, pid] of Object.entries(pids)) if (byPid[pid]) terminals[id] = byPid[pid];
3301
+ res.json({ cck: byPid[process.pid] || null, terminals });
3302
+ });
3303
+
3301
3304
  app.delete('/api/terminals/:id', (req, res) => {
3302
3305
  const err = terminal.end(req.params.id, req.get('x-terminal-token'));
3303
3306
  if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
@@ -3978,7 +3981,7 @@ function formatReviewMarkdown(src, items) {
3978
3981
  const parts = [
3979
3982
  `# Review of ${src.path || src.label}`,
3980
3983
  '',
3981
- "Each comment is the user's instruction about the quoted text. A quote missing from the source means the source changed after the review: say so.",
3984
+ `Each comment is the user's instruction about the quoted text.${src.path ? ' A quote missing from the source means the source changed after the review: say so.' : ''}`,
3982
3985
  ];
3983
3986
  items.forEach((c, i) => {
3984
3987
  const where = [c.line && `line ${c.line}`, c.heading && `under "${c.heading}"`].filter(Boolean).join(', ');
@@ -4321,13 +4324,17 @@ async function cleanupAgentActivity() {
4321
4324
  } catch { /* agent-activity dir may not exist */ }
4322
4325
  }
4323
4326
 
4324
- // Dispatch markers and reviews: see docs/retention.md.
4327
+ // Dispatch markers, reviews and worktrees: see docs/retention.md.
4325
4328
  async function runRetention() {
4326
4329
  try {
4327
- const opts = { known: await listTranscriptIds(PROJECTS_DIR), maxAgeMs: retentionMs(CLAUDE_DIR) };
4330
+ const scan = await scanTranscripts(PROJECTS_DIR);
4331
+ const opts = { known: scan?.ids, maxAgeMs: retentionMs(CLAUDE_DIR) };
4328
4332
  const markers = dispatched.prune(opts);
4329
4333
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4330
- if (markers || reviews) console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews`);
4334
+ const wts = worktrees.prune(scan?.dirs);
4335
+ if (markers || reviews || wts) {
4336
+ console.log(`[retention] removed ${markers} dispatch markers, ${reviews} reviews, ${wts} worktrees`);
4337
+ }
4331
4338
  } catch (e) {
4332
4339
  console.warn('[retention] failed:', e.message);
4333
4340
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  A dispatch is one Claude Code session that cck starts for a task, in its embedded terminal. It is an ordinary session, not a child: it shows in the sidebar like any other, and the user can open its terminal at any time.
4
4
 
5
- A dispatch is **fire-and-forget** by default: you hand the task off, and the user watches it in the sidebar. Add `--report` only when you need the outcome back: the user asked you to collect it, or your next step depends on it.
5
+ A dispatch **reports back** by default: start it with `--report` and `--peer`, then collect and verify the outcome (With `--report`, below). When the user's request says `--no-report`, it is **fire-and-forget**: start it with neither flag (Fire-and-forget, below). `--no-report` lives only in the user's request; `dispatch start` has no such flag.
6
6
 
7
7
  ## Write the spec
8
8
 
@@ -19,12 +19,12 @@ 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] --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
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
26
 
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).
27
+ - `--peer` is your own peer name: the first line of `ListAgents` ("This session is `<name>`"). Pass it when 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).
28
28
  - `--spec-file` over `--spec` for anything longer than a line: no shell quoting.
29
29
  - `--name` is what the user sees in the sidebar. Kebab-case, saying what the session does: `fix-login-redirect`, not `task-1`.
30
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.
@@ -32,7 +32,7 @@ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name
32
32
 
33
33
  ## Fire-and-forget
34
34
 
35
- Tell the user the session name, its group, and the dispatch id, then carry on with your own work or end your turn. The user follows the dispatch in the sidebar. With `--peer`, its questions still reach you as new turns.
35
+ The started session gets the task alone and does not know your session exists. Tell the user the session name, its group, and the dispatch id. Your part ends with that message: the user follows the dispatch in the sidebar, and asks you when they want it checked.
36
36
 
37
37
  ## With `--report`
38
38