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.
- package/lib/claude-dir.js +7 -1
- package/lib/net-guard.js +41 -14
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +12 -9
- package/lib/terminal.js +17 -4
- package/lib/worktrees.js +105 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +0 -1
- package/public/app.js +653 -372
- package/public/index.html +7 -3
- package/public/project-match.js +6 -4
- package/public/style.css +258 -79
- package/public/terminal-frame.js +341 -0
- package/public/terminal.html +27 -0
- package/public/vendor/claude-hub-sdk.js +13 -3
- package/server.js +57 -50
- package/skill-guides/dispatch.md +4 -4
|
@@ -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
|
+
// 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,
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
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
|
-
|
|
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
|
|
4327
|
+
// Dispatch markers, reviews and worktrees: see docs/retention.md.
|
|
4325
4328
|
async function runRetention() {
|
|
4326
4329
|
try {
|
|
4327
|
-
const
|
|
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
|
-
|
|
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
|
}
|
package/skill-guides/dispatch.md
CHANGED
|
@@ -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
|
|
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>
|
|
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
|
|
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
|
|
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
|
|