claude-code-kanban 4.27.0 → 4.28.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/README.md +72 -44
- package/cli.js +235 -7
- package/lib/claude-dir.js +1 -1
- package/lib/dispatch-groups.js +93 -0
- package/lib/dispatch.js +171 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/net-guard.js +20 -3
- package/lib/session-events.js +22 -2
- package/lib/terminal.js +555 -0
- package/package.json +14 -3
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/monitors.json +6 -0
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +34 -2
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +7 -2
- package/plugin/plugins/claude-code-kanban/skills/kanban-dispatch/SKILL.md +23 -0
- package/public/app.js +1761 -138
- package/public/index.html +150 -14
- package/public/style.css +772 -53
- package/public/sw.js +2 -1
- package/public/themes.css +84 -80
- package/server.js +240 -18
- package/skill-guides/dispatch.md +72 -0
package/lib/terminal.js
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Embedded terminal: one PTY per session id, reached over a WebSocket.
|
|
4
|
+
//
|
|
5
|
+
// A PTY endpoint turns any gap in the network boundary into code execution as the
|
|
6
|
+
// user, so the upgrade passes three independent gates: Host + strict Origin
|
|
7
|
+
// (net-guard upgradeVerdict), refusal when the server is exposed, and a per-launch
|
|
8
|
+
// token sent in the first message. The token is the only one that stops a
|
|
9
|
+
// non-browser local process.
|
|
10
|
+
|
|
11
|
+
const crypto = require('node:crypto');
|
|
12
|
+
const fs = require('node:fs');
|
|
13
|
+
const os = require('node:os');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
const { isContained } = require('./contain');
|
|
16
|
+
|
|
17
|
+
const WS_PATH = '/api/terminal/ws';
|
|
18
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
const MODES = new Set(['auto', 'resume', 'fork', 'shell', 'new', 'pick']);
|
|
20
|
+
const PICK_POLL_MS = 1000;
|
|
21
|
+
// How long before the PTY a registry entry may claim to have started (clock skew between the two).
|
|
22
|
+
const PICK_START_SLACK_MS = 2000;
|
|
23
|
+
// A new session's name and worktree reach a shell command line, so they are held to a
|
|
24
|
+
// charset that needs no escaping beyond plain quotes. The first character is never '-'.
|
|
25
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$/;
|
|
26
|
+
const WORKTREE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
27
|
+
const MODELS = new Set(['fable', 'opus', 'sonnet', 'haiku']);
|
|
28
|
+
const MAX_PROMPT = 32 * 1024;
|
|
29
|
+
const PROMPT_QUIET_MS = 400;
|
|
30
|
+
// Watermarks from https://xtermjs.org/docs/guides/flowcontrol/ — pause the PTY while a
|
|
31
|
+
// client lags, so a runaway process cannot grow the socket buffer without bound.
|
|
32
|
+
const HIGH_WATER = 128 * 1024;
|
|
33
|
+
const LOW_WATER = 16 * 1024;
|
|
34
|
+
const HELLO_TIMEOUT_MS = 5000;
|
|
35
|
+
const MAX_PAYLOAD = 1024 * 1024;
|
|
36
|
+
// The hub hands its children PORT=0 and friends; a dev server started in the terminal
|
|
37
|
+
// would otherwise bind a random port, and a nested `claude` would think it runs inside one.
|
|
38
|
+
const STRIP_ENV = [
|
|
39
|
+
'PORT', 'HOST', 'ALLOWED_HOSTS', 'CLAUDE_HUB', 'HUB_URL',
|
|
40
|
+
'CCK_TERMINAL', 'CCK_TERMINAL_TOKEN', 'CCK_TERMINAL_SHELL', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT',
|
|
41
|
+
// A cck started from inside Claude Code carries these; a claude that inherits them runs as that
|
|
42
|
+
// session's child and writes no live-session registry entry, which the resume picker relies on.
|
|
43
|
+
'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID', 'CLAUDE_CODE_SESSION_ATTENDED',
|
|
44
|
+
'CLAUDE_CODE_MESSAGING_SOCKET', 'CLAUDE_CODE_MESSAGING_TOKEN', 'CLAUDE_CODE_EXECPATH',
|
|
45
|
+
// A cck started inside tmux passes these on, and claude then lists its session under a tmux pane it is not in.
|
|
46
|
+
'TMUX', 'TMUX_PANE',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function posInt(v, fallback) {
|
|
50
|
+
const n = Number(v);
|
|
51
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// CCK_TERMINAL is the hub's `terminal` config block as JSON; flags win over it.
|
|
55
|
+
function readTerminalConfig({ argv = process.argv, env = process.env, getArgValue } = {}) {
|
|
56
|
+
let cfg = {};
|
|
57
|
+
try { cfg = JSON.parse(env.CCK_TERMINAL || '{}') || {}; } catch { cfg = {}; }
|
|
58
|
+
return {
|
|
59
|
+
enabled: argv.includes('--enable-terminal') || cfg.enabled === true,
|
|
60
|
+
shell:
|
|
61
|
+
(getArgValue && getArgValue('terminal-shell')) ||
|
|
62
|
+
env.CCK_TERMINAL_SHELL ||
|
|
63
|
+
(typeof cfg.shell === 'string' && cfg.shell) ||
|
|
64
|
+
null,
|
|
65
|
+
maxSessions: posInt(cfg.maxSessions, 20),
|
|
66
|
+
fontFamily: typeof cfg.fontFamily === 'string' && cfg.fontFamily ? cfg.fontFamily : null,
|
|
67
|
+
fontSize: posInt(cfg.fontSize, 13),
|
|
68
|
+
scrollback: posInt(cfg.scrollback, 5000),
|
|
69
|
+
noFlicker: cfg.noFlicker !== false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Git for Windows ships two bash.exe files; bin\ is the launcher that sets up the MSYS environment.
|
|
74
|
+
const GIT_BASH_RE = /[\\/]Git[\\/](usr[\\/])?bin[\\/]bash\.exe$/i;
|
|
75
|
+
|
|
76
|
+
function shellFamily(shell) {
|
|
77
|
+
if (GIT_BASH_RE.test(shell)) return 'gitbash';
|
|
78
|
+
const base = path.basename(shell).toLowerCase().replace(/\.exe$/, '');
|
|
79
|
+
if (base === 'pwsh' || base === 'powershell') return 'pwsh';
|
|
80
|
+
if (base === 'cmd') return 'cmd';
|
|
81
|
+
return 'posix';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findGitBash(which, env) {
|
|
85
|
+
const git = which('git');
|
|
86
|
+
const candidates = [
|
|
87
|
+
git && path.join(path.dirname(path.dirname(git)), 'bin', 'bash.exe'),
|
|
88
|
+
env.ProgramFiles && path.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'),
|
|
89
|
+
];
|
|
90
|
+
return candidates.find((p) => p && fs.existsSync(p)) || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// `value` is `gitbash`, a program name looked up on PATH (pwsh, cmd, zsh), or a path.
|
|
94
|
+
// Unset means the platform default. Throws when the named shell cannot be found.
|
|
95
|
+
function resolveShell(value, which, platform = process.platform, env = process.env) {
|
|
96
|
+
if (!value) {
|
|
97
|
+
if (platform === 'win32') return which('pwsh') || which('powershell') || 'powershell.exe';
|
|
98
|
+
return env.SHELL || '/bin/sh';
|
|
99
|
+
}
|
|
100
|
+
// which() appends PATHEXT itself, so a bare `cmd.exe` would never match.
|
|
101
|
+
const name = /[\\/]/.test(value) ? value : value.replace(/\.exe$/i, '');
|
|
102
|
+
const found = name.toLowerCase() === 'gitbash' ? findGitBash(which, env) : which(name);
|
|
103
|
+
if (!found) throw new Error(`terminal shell "${value}" not found`);
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// claudeArgs is null for a plain shell. Its items are fixed flags, a validated UUID, or
|
|
108
|
+
// values that passed NAME_RE / WORKTREE_RE, so plain quoting is enough to keep a space
|
|
109
|
+
// from splitting an argument and nothing can break out of the quotes.
|
|
110
|
+
function quoteArg(arg, family) {
|
|
111
|
+
if (/^[A-Za-z0-9._-]+$/.test(arg)) return arg;
|
|
112
|
+
return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function shellArgs(shell, claudeArgs) {
|
|
116
|
+
const family = shellFamily(shell);
|
|
117
|
+
if (!claudeArgs) return family === 'pwsh' ? ['-NoLogo'] : family === 'gitbash' ? ['--login', '-i'] : [];
|
|
118
|
+
const cmd = ['claude', ...claudeArgs].map((a) => quoteArg(a, family)).join(' ');
|
|
119
|
+
if (family === 'pwsh') return ['-NoLogo', '-NoExit', '-Command', cmd];
|
|
120
|
+
if (family === 'cmd') return ['/k', cmd];
|
|
121
|
+
if (family === 'gitbash') return ['--login', '-i', '-c', `${cmd}; exec bash --login -i`];
|
|
122
|
+
// $0 is the shell itself (the argument after the script), so exiting claude
|
|
123
|
+
// lands in an interactive shell of the same kind.
|
|
124
|
+
return ['-c', `${cmd}; exec "$0"`, shell];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function claudeArgsFor(mode, id, spec) {
|
|
128
|
+
if (mode === 'shell') return null;
|
|
129
|
+
if (mode === 'fork') return ['--resume', id, '--fork-session'];
|
|
130
|
+
if (mode === 'pick') return ['--resume'];
|
|
131
|
+
if (mode === 'new') {
|
|
132
|
+
const args = ['--session-id', id];
|
|
133
|
+
if (spec.name) args.push('--name', spec.name);
|
|
134
|
+
if (spec.model) args.push('--model', spec.model);
|
|
135
|
+
// Last, because its value is optional and a following flag must not be taken for it.
|
|
136
|
+
if (spec.worktree === true) args.push('-w');
|
|
137
|
+
else if (spec.worktree) args.push('-w', spec.worktree);
|
|
138
|
+
return args;
|
|
139
|
+
}
|
|
140
|
+
return ['--resume', id];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Returns the new-session options from a hello, or a string naming the bad field.
|
|
144
|
+
function parseNewSpec(msg) {
|
|
145
|
+
if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
|
|
146
|
+
const name = typeof msg.name === 'string' ? msg.name.trim() : '';
|
|
147
|
+
if (name && !NAME_RE.test(name)) return 'name';
|
|
148
|
+
const worktree = msg.worktree ?? false;
|
|
149
|
+
if (typeof worktree !== 'boolean' && !(typeof worktree === 'string' && WORKTREE_RE.test(worktree))) return 'worktree name';
|
|
150
|
+
const model = msg.model || null;
|
|
151
|
+
if (model && !MODELS.has(model)) return 'model';
|
|
152
|
+
const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
|
|
153
|
+
if (prompt.length > MAX_PROMPT) return 'prompt';
|
|
154
|
+
return { cwd: msg.cwd, name: name || null, worktree, model, prompt: prompt || null };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function samePath(a, b) {
|
|
158
|
+
return isContained(a, b) && isContained(b, a);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The registry entry of the claude a pick PTY started: same folder, started no earlier than the PTY.
|
|
162
|
+
function findPickProcess(live, pty, claimed) {
|
|
163
|
+
return live.find(
|
|
164
|
+
(l) => l.pid && !claimed.has(l.pid) && l.startedAt >= pty.startedAt - PICK_START_SLACK_MS && samePath(l.cwd, pty.cwd),
|
|
165
|
+
)?.pid;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function screenText(term) {
|
|
169
|
+
const buf = term.buffer.active;
|
|
170
|
+
const lines = [];
|
|
171
|
+
for (let i = 0; i < term.rows; i++) {
|
|
172
|
+
const line = buf.getLine(buf.viewportY + i);
|
|
173
|
+
if (line) lines.push(line.translateToString(true));
|
|
174
|
+
}
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Claude Code keeps .claude.json beside ~/.claude when CLAUDE_CONFIG_DIR is unset but
|
|
179
|
+
// inside the dir when set, so the default dir must stay unset, not be spelled out.
|
|
180
|
+
function ptyEnv({ claudeDir, isDefaultDir, noFlicker }) {
|
|
181
|
+
const env = { ...process.env };
|
|
182
|
+
for (const k of STRIP_ENV) delete env[k];
|
|
183
|
+
if (isDefaultDir) delete env.CLAUDE_CONFIG_DIR;
|
|
184
|
+
else env.CLAUDE_CONFIG_DIR = claudeDir;
|
|
185
|
+
// xterm.js #5801: a clear inside a synchronized-output block jumps the viewport.
|
|
186
|
+
if (noFlicker) env.CLAUDE_CODE_NO_FLICKER = '1';
|
|
187
|
+
env.TERM = 'xterm-256color';
|
|
188
|
+
env.COLORTERM = 'truecolor';
|
|
189
|
+
return env;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function tokenMatches(expected, given) {
|
|
193
|
+
if (typeof given !== 'string') return false;
|
|
194
|
+
const a = crypto.createHash('sha256').update(expected).digest();
|
|
195
|
+
const b = crypto.createHash('sha256').update(given).digest();
|
|
196
|
+
return crypto.timingSafeEqual(a, b);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function clampDim(v, fallback) {
|
|
200
|
+
const n = Number(v);
|
|
201
|
+
return Number.isInteger(n) ? Math.min(500, Math.max(2, n)) : fallback;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Destroying right after write can reset the connection before the response is sent (seen on Windows).
|
|
205
|
+
function refuse(socket, status, reason) {
|
|
206
|
+
socket.once('finish', () => socket.destroy());
|
|
207
|
+
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${reason}\n`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @param {object} o
|
|
212
|
+
* @param {ReturnType<typeof readTerminalConfig>} o.config
|
|
213
|
+
* @param {{EXPOSED: boolean, upgradeVerdict: (req: any) => string|null}} o.net
|
|
214
|
+
* @param {string} o.claudeDir
|
|
215
|
+
* @param {boolean} o.isDefaultDir
|
|
216
|
+
* @param {(id: string) => string|null} o.resolveCwd null = unknown session
|
|
217
|
+
* @param {(id: string, exceptPid?: number) => boolean} o.isLiveElsewhere
|
|
218
|
+
* @param {(dir: string) => boolean} o.isAllowedFolder where a new session may start
|
|
219
|
+
* @param {() => {pid: number|null, sessionId: string, cwd: string|null, startedAt: number}[]} o.liveSessions
|
|
220
|
+
* claude's live-session registry, read fresh
|
|
221
|
+
* @param {(cmd: string) => string|null} o.which
|
|
222
|
+
* @param {string} [o.token]
|
|
223
|
+
* @param {() => void} [o.onChange] a terminal started, ended or moved to another session id
|
|
224
|
+
*/
|
|
225
|
+
function createTerminalService(o) {
|
|
226
|
+
const { config, net } = o;
|
|
227
|
+
const token = o.token || crypto.randomBytes(32).toString('hex');
|
|
228
|
+
const sessions = new Map();
|
|
229
|
+
let pty = null;
|
|
230
|
+
let loadError = null;
|
|
231
|
+
let wss = null;
|
|
232
|
+
let Headless = null;
|
|
233
|
+
let Serialize = null;
|
|
234
|
+
let shell = null;
|
|
235
|
+
|
|
236
|
+
// Lazy and optional: a missing prebuilt or shell must hide the feature, not crash cck.
|
|
237
|
+
function load() {
|
|
238
|
+
if (pty || loadError) return !!pty;
|
|
239
|
+
try {
|
|
240
|
+
shell = resolveShell(config.shell, o.which);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
loadError = e.message;
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
pty = require('@lydell/node-pty');
|
|
247
|
+
({ Terminal: Headless } = require('@xterm/headless'));
|
|
248
|
+
({ SerializeAddon: Serialize } = require('@xterm/addon-serialize'));
|
|
249
|
+
const { WebSocketServer } = require('ws');
|
|
250
|
+
wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD });
|
|
251
|
+
} catch (e) {
|
|
252
|
+
pty = null;
|
|
253
|
+
loadError = `terminal backend failed to load: ${e.message}`;
|
|
254
|
+
}
|
|
255
|
+
return !!pty;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function unavailableReason() {
|
|
259
|
+
if (!config.enabled) return 'disabled';
|
|
260
|
+
if (net.EXPOSED) return 'refused while listening on a non-loopback address';
|
|
261
|
+
if (!load()) return loadError;
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function send(ws, msg) {
|
|
266
|
+
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sendOutput(ws, buf) {
|
|
270
|
+
ws.unacked += buf.length;
|
|
271
|
+
ws.send(buf);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function updateFlow(s) {
|
|
275
|
+
let lagging = false;
|
|
276
|
+
let drained = true;
|
|
277
|
+
for (const ws of s.sockets) {
|
|
278
|
+
if (ws.unacked > HIGH_WATER) lagging = true;
|
|
279
|
+
if (ws.unacked > LOW_WATER) drained = false;
|
|
280
|
+
}
|
|
281
|
+
if (lagging && !s.paused) { s.paused = true; s.pty.pause(); }
|
|
282
|
+
else if (drained && s.paused) { s.paused = false; s.pty.resume(); }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Waits for claude to turn on bracketed paste (its input box is live), then for the
|
|
286
|
+
// screen to settle. A folder-trust question also takes input, and Enter there would
|
|
287
|
+
// answer it, so the prompt waits for as long as that question is on screen.
|
|
288
|
+
function queuePrompt(s, prompt) {
|
|
289
|
+
let armed = false;
|
|
290
|
+
let timer = null;
|
|
291
|
+
const text = `\x1b[200~${prompt.replace(/\r?\n/g, '\r')}\x1b[201~`;
|
|
292
|
+
s.onOutput = (data) => {
|
|
293
|
+
if (!armed && data.includes('\x1b[?2004h')) armed = true;
|
|
294
|
+
if (!armed) return;
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
timer = setTimeout(() => {
|
|
297
|
+
if (s.exited || /\btrust\b/i.test(screenText(s.term))) return;
|
|
298
|
+
s.onOutput = null;
|
|
299
|
+
s.pty.write(text);
|
|
300
|
+
setTimeout(() => { if (!s.exited) s.pty.write('\r'); }, 100);
|
|
301
|
+
}, PROMPT_QUIET_MS);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function spawnSession(id, mode, cwdCandidate, cols, rows, spec, extraEnv) {
|
|
306
|
+
const cwd = cwdCandidate && fs.existsSync(cwdCandidate) ? cwdCandidate : os.homedir();
|
|
307
|
+
const env = { ...ptyEnv({ claudeDir: o.claudeDir, isDefaultDir: o.isDefaultDir, noFlicker: config.noFlicker }), ...extraEnv };
|
|
308
|
+
// Git Bash's login profile cds to $HOME unless this is set.
|
|
309
|
+
if (shellFamily(shell) === 'gitbash') env.CHERE_INVOKING = '1';
|
|
310
|
+
const proc = pty.spawn(shell, shellArgs(shell, claudeArgsFor(mode, id, spec)), {
|
|
311
|
+
name: 'xterm-256color',
|
|
312
|
+
cols,
|
|
313
|
+
rows,
|
|
314
|
+
cwd,
|
|
315
|
+
env,
|
|
316
|
+
});
|
|
317
|
+
const term = new Headless({ cols, rows, scrollback: config.scrollback, allowProposedApi: true });
|
|
318
|
+
const serializer = new Serialize();
|
|
319
|
+
term.loadAddon(serializer);
|
|
320
|
+
const s = {
|
|
321
|
+
id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
322
|
+
name: spec?.name || null, worktree: spec?.worktree || false, onOutput: null,
|
|
323
|
+
};
|
|
324
|
+
if (spec?.prompt) queuePrompt(s, spec.prompt);
|
|
325
|
+
if (mode === 'pick') watchPick(s);
|
|
326
|
+
|
|
327
|
+
proc.onData((data) => {
|
|
328
|
+
term.write(data);
|
|
329
|
+
if (s.onOutput) s.onOutput(data);
|
|
330
|
+
if (!s.sockets.size) return;
|
|
331
|
+
const buf = Buffer.from(data, 'utf8');
|
|
332
|
+
for (const ws of s.sockets) if (ws.readyState === 1) sendOutput(ws, buf);
|
|
333
|
+
updateFlow(s);
|
|
334
|
+
});
|
|
335
|
+
proc.onExit(({ exitCode }) => {
|
|
336
|
+
s.exited = true;
|
|
337
|
+
clearInterval(s.pickTimer);
|
|
338
|
+
if (sessions.get(s.id) === s) sessions.delete(s.id);
|
|
339
|
+
o.onExit?.(s.id);
|
|
340
|
+
if (!s.ended) o.onChange?.();
|
|
341
|
+
for (const ws of s.sockets) {
|
|
342
|
+
send(ws, { t: 'exit', code: exitCode, ended: s.ended });
|
|
343
|
+
ws.close(1000);
|
|
344
|
+
}
|
|
345
|
+
term.dispose();
|
|
346
|
+
});
|
|
347
|
+
sessions.set(id, s);
|
|
348
|
+
o.onChange?.();
|
|
349
|
+
return s;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// `claude --resume` opens its own picker, so the session is unknown until the user picks.
|
|
353
|
+
// Claude's registry entry keeps its pid and swaps its sessionId to the picked one; the entry
|
|
354
|
+
// is found by folder and start time, and the pick shows as an id with a transcript behind it.
|
|
355
|
+
function watchPick(s) {
|
|
356
|
+
s.pickTimer = setInterval(() => {
|
|
357
|
+
const live = o.liveSessions();
|
|
358
|
+
const entry = s.claudePid && live.find((l) => l.pid === s.claudePid);
|
|
359
|
+
if (!entry) {
|
|
360
|
+
// Claude left the picker without a pick and the PTY fell back to its shell.
|
|
361
|
+
if (s.claudePid) return clearInterval(s.pickTimer);
|
|
362
|
+
const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
|
|
363
|
+
s.claudePid = findPickProcess(live, s, claimed);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const picked = entry.sessionId;
|
|
367
|
+
if (picked === s.id || o.resolveCwd(picked) === null) return;
|
|
368
|
+
clearInterval(s.pickTimer);
|
|
369
|
+
// Two claude processes on one session write the same transcript. When cck already runs it,
|
|
370
|
+
// the pick is ended and its viewers move to that terminal; the sockets leave first so no
|
|
371
|
+
// exit reaches them.
|
|
372
|
+
if (sessions.has(picked)) {
|
|
373
|
+
const sockets = [...s.sockets];
|
|
374
|
+
s.sockets.clear();
|
|
375
|
+
kill(s);
|
|
376
|
+
for (const ws of sockets) send(ws, { t: 'rekey', id: picked, duplicate: true });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const elsewhere = o.isLiveElsewhere(picked, s.claudePid);
|
|
380
|
+
sessions.delete(s.id);
|
|
381
|
+
s.id = picked;
|
|
382
|
+
s.mode = 'resume';
|
|
383
|
+
sessions.set(picked, s);
|
|
384
|
+
o.onChange?.();
|
|
385
|
+
for (const ws of s.sockets) send(ws, { t: 'rekey', id: picked, elsewhere });
|
|
386
|
+
}, PICK_POLL_MS);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function attach(ws, s, attached) {
|
|
390
|
+
ws.unacked = 0;
|
|
391
|
+
s.sockets.add(ws);
|
|
392
|
+
send(ws, { t: 'ready', attached });
|
|
393
|
+
const snapshot = s.serializer.serialize();
|
|
394
|
+
if (snapshot) sendOutput(ws, Buffer.from(snapshot, 'utf8'));
|
|
395
|
+
|
|
396
|
+
ws.on('message', (raw, isBinary) => {
|
|
397
|
+
if (isBinary) return;
|
|
398
|
+
let msg;
|
|
399
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
400
|
+
if (s.exited) return;
|
|
401
|
+
if (msg.t === 'in' && typeof msg.d === 'string') s.pty.write(msg.d);
|
|
402
|
+
else if (msg.t === 'resize') {
|
|
403
|
+
const cols = clampDim(msg.cols, s.term.cols);
|
|
404
|
+
const rows = clampDim(msg.rows, s.term.rows);
|
|
405
|
+
s.pty.resize(cols, rows);
|
|
406
|
+
s.term.resize(cols, rows);
|
|
407
|
+
} else if (msg.t === 'ack' && Number.isFinite(msg.n)) {
|
|
408
|
+
ws.unacked = Math.max(0, ws.unacked - msg.n);
|
|
409
|
+
updateFlow(s);
|
|
410
|
+
} else if (msg.t === 'kill') kill(s);
|
|
411
|
+
});
|
|
412
|
+
ws.on('close', () => {
|
|
413
|
+
s.sockets.delete(ws);
|
|
414
|
+
if (!s.exited) updateFlow(s);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function onHello(ws, msg) {
|
|
419
|
+
if (!msg || msg.t !== 'hello' || !tokenMatches(token, msg.token)) {
|
|
420
|
+
send(ws, { t: 'error', msg: 'The terminal token is out of date. Reload the hub window.' });
|
|
421
|
+
return ws.close(4001);
|
|
422
|
+
}
|
|
423
|
+
const id = msg.id;
|
|
424
|
+
const mode = MODES.has(msg.mode) ? msg.mode : null;
|
|
425
|
+
if (typeof id !== 'string' || !UUID_RE.test(id) || !mode) {
|
|
426
|
+
send(ws, { t: 'error', msg: 'invalid session id or mode' });
|
|
427
|
+
return ws.close(4002);
|
|
428
|
+
}
|
|
429
|
+
const cols = clampDim(msg.cols, 80);
|
|
430
|
+
const rows = clampDim(msg.rows, 24);
|
|
431
|
+
|
|
432
|
+
const existing = sessions.get(id);
|
|
433
|
+
if (existing) return attach(ws, existing, true);
|
|
434
|
+
|
|
435
|
+
if (mode === 'auto' && o.isLiveElsewhere(id)) {
|
|
436
|
+
send(ws, { t: 'live' });
|
|
437
|
+
return ws.close(1000);
|
|
438
|
+
}
|
|
439
|
+
const r = start(id, mode, msg, cols, rows);
|
|
440
|
+
if (r.error) {
|
|
441
|
+
send(ws, { t: 'error', msg: r.error });
|
|
442
|
+
return ws.close(r.code);
|
|
443
|
+
}
|
|
444
|
+
attach(ws, r.session, false);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// The admission rules shared by the socket hello and startNew. A refusal carries both a
|
|
448
|
+
// WebSocket close code and an HTTP status, so each caller maps it its own way.
|
|
449
|
+
function start(id, mode, msg, cols, rows, extraEnv) {
|
|
450
|
+
if (sessions.size >= config.maxSessions) {
|
|
451
|
+
return { code: 4003, status: 429, error: `${config.maxSessions} terminals are open; end one first` };
|
|
452
|
+
}
|
|
453
|
+
const known = o.resolveCwd(id);
|
|
454
|
+
let cwd = known;
|
|
455
|
+
let spec = null;
|
|
456
|
+
if (mode === 'new' || mode === 'pick') {
|
|
457
|
+
if (mode === 'new') {
|
|
458
|
+
spec = parseNewSpec(msg);
|
|
459
|
+
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
460
|
+
}
|
|
461
|
+
if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
|
|
462
|
+
if (typeof msg.cwd !== 'string' || !o.isAllowedFolder(msg.cwd)) {
|
|
463
|
+
return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
|
|
464
|
+
}
|
|
465
|
+
cwd = msg.cwd;
|
|
466
|
+
} else if (mode !== 'shell' && known === null) {
|
|
467
|
+
return { code: 4004, status: 404, error: 'unknown session' };
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
return { session: spawnSession(id, mode === 'auto' ? 'resume' : mode, cwd, cols, rows, spec, extraEnv) };
|
|
471
|
+
} catch (e) {
|
|
472
|
+
return { code: 4005, status: 500, error: `failed to start the shell: ${e.message}` };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function handleUpgrade(req, socket, head) {
|
|
477
|
+
let pathname;
|
|
478
|
+
try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
|
|
479
|
+
if (pathname !== WS_PATH) return refuse(socket, '404 Not Found', 'no such endpoint');
|
|
480
|
+
const reason = unavailableReason() || net.upgradeVerdict(req);
|
|
481
|
+
if (reason) return refuse(socket, '403 Forbidden', reason);
|
|
482
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
483
|
+
const timer = setTimeout(() => ws.close(4001), HELLO_TIMEOUT_MS);
|
|
484
|
+
ws.once('message', (raw, isBinary) => {
|
|
485
|
+
clearTimeout(timer);
|
|
486
|
+
let msg = null;
|
|
487
|
+
if (!isBinary) { try { msg = JSON.parse(raw.toString()); } catch { /* treated as bad hello */ } }
|
|
488
|
+
onHello(ws, msg);
|
|
489
|
+
});
|
|
490
|
+
ws.on('error', () => {});
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function clientConfig() {
|
|
495
|
+
return {
|
|
496
|
+
available: !unavailableReason(),
|
|
497
|
+
fontFamily: config.fontFamily,
|
|
498
|
+
fontSize: config.fontSize,
|
|
499
|
+
scrollback: config.scrollback,
|
|
500
|
+
maxSessions: config.maxSessions,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// kill() returns before the process is gone, so an ended session leaves list() at once.
|
|
505
|
+
function kill(s) {
|
|
506
|
+
s.ended = true;
|
|
507
|
+
s.pty.kill();
|
|
508
|
+
o.onChange?.();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function list() {
|
|
512
|
+
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
513
|
+
id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
514
|
+
name: s.name, worktree: s.worktree,
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function isRunning(id) {
|
|
519
|
+
const s = sessions.get(id);
|
|
520
|
+
return !!s && !s.ended;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function authorized(providedToken) {
|
|
524
|
+
return tokenMatches(token, providedToken);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Same token as the WebSocket hello: ending a terminal kills a running claude process.
|
|
528
|
+
function end(id, providedToken) {
|
|
529
|
+
if (!authorized(providedToken)) return 'auth';
|
|
530
|
+
const s = sessions.get(id);
|
|
531
|
+
if (!s) return 'not-found';
|
|
532
|
+
kill(s);
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// A 'new' session for a caller with no socket. The PTY runs headless until a browser
|
|
537
|
+
// attaches, and a later attach resizes it.
|
|
538
|
+
function startNew(msg, extraEnv) {
|
|
539
|
+
const reason = unavailableReason();
|
|
540
|
+
if (reason) return { status: 403, error: reason };
|
|
541
|
+
const r = start(crypto.randomUUID(), 'new', msg, 120, 32, extraEnv);
|
|
542
|
+
return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function shutdown() {
|
|
546
|
+
for (const s of sessions.values()) {
|
|
547
|
+
try { s.pty.kill(); } catch { /* already gone */ }
|
|
548
|
+
}
|
|
549
|
+
sessions.clear();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { token, handleUpgrade, clientConfig, list, isRunning, end, authorized, startNew, shutdown, unavailableReason };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
module.exports = { createTerminalService, readTerminalConfig, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.28.0",
|
|
4
4
|
"description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -35,9 +35,16 @@
|
|
|
35
35
|
},
|
|
36
36
|
"homepage": "https://github.com/NikiforovAll/claude-code-kanban#readme",
|
|
37
37
|
"dependencies": {
|
|
38
|
+
"@xterm/addon-fit": "0.11.0",
|
|
39
|
+
"@xterm/addon-serialize": "0.14.0",
|
|
40
|
+
"@xterm/addon-unicode11": "0.9.0",
|
|
41
|
+
"@xterm/addon-webgl": "0.19.0",
|
|
42
|
+
"@xterm/headless": "6.0.0",
|
|
43
|
+
"@xterm/xterm": "6.0.0",
|
|
38
44
|
"chokidar": "^3.5.3",
|
|
39
45
|
"express": "^4.18.2",
|
|
40
|
-
"open": "^11.0.0"
|
|
46
|
+
"open": "^11.0.0",
|
|
47
|
+
"ws": "8.21.3"
|
|
41
48
|
},
|
|
42
49
|
"engines": {
|
|
43
50
|
"node": ">=20.0.0"
|
|
@@ -48,12 +55,16 @@
|
|
|
48
55
|
"install.js",
|
|
49
56
|
"plugin/**/*",
|
|
50
57
|
"lib/**/*",
|
|
51
|
-
"public/**/*"
|
|
58
|
+
"public/**/*",
|
|
59
|
+
"skill-guides/**/*"
|
|
52
60
|
],
|
|
53
61
|
"devDependencies": {
|
|
54
62
|
"@biomejs/biome": "2.4.14",
|
|
55
63
|
"ajv": "^8.18.0",
|
|
56
64
|
"ajv-formats": "^3.0.1",
|
|
57
65
|
"husky": "^9.1.7"
|
|
66
|
+
},
|
|
67
|
+
"optionalDependencies": {
|
|
68
|
+
"@lydell/node-pty": "1.2.0-beta.15"
|
|
58
69
|
}
|
|
59
70
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -4,5 +4,11 @@
|
|
|
4
4
|
"description": "Notifies this session when its tasks are moved on the kanban board.",
|
|
5
5
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\"",
|
|
6
6
|
"when": "on-skill-invoke:claude-code-kanban:kanban-follow"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"name": "kanban-dispatch-inbox",
|
|
10
|
+
"description": "Notifies this session when a session it dispatched through cck reports or exits.",
|
|
11
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/postman.js\" --topic dispatch --keep-backlog",
|
|
12
|
+
"when": "on-skill-invoke:claude-code-kanban:kanban-dispatch"
|
|
7
13
|
}
|
|
8
14
|
]
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
# config config.json {approvals: {enabled, mode, waitSeconds}} (on by default; absent = defaults)
|
|
11
11
|
# liveness server.json {port, pid} (D1: a dead board costs nothing)
|
|
12
12
|
#
|
|
13
|
-
# First writer wins (D5): a terminal answer deletes the marker via
|
|
14
|
-
# and this gate exits silently; a decision arriving after the tool already ran
|
|
13
|
+
# First writer wins (D5): a terminal answer deletes the marker (this gate, via
|
|
14
|
+
# the session registry, or PostToolUse) and this gate exits silently; a decision arriving after the tool already ran
|
|
15
15
|
# is discarded by Claude Code, so a losing write on either side is harmless.
|
|
16
16
|
|
|
17
17
|
INPUT=$(cat)
|
|
@@ -114,6 +114,24 @@ BOARD_GRACE_SECONDS=15
|
|
|
114
114
|
NEXT_PROBE=0
|
|
115
115
|
UNREACHABLE_SINCE=0
|
|
116
116
|
|
|
117
|
+
# Claude Code's live-session registry flips from "waiting" the moment the
|
|
118
|
+
# terminal prompt is answered, while PostToolUse waits for the tool to finish
|
|
119
|
+
# and never fires on a deny. Undocumented, so a missing file or field leaves
|
|
120
|
+
# PostToolUse in charge, as before.
|
|
121
|
+
SESSIONS_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/sessions"
|
|
122
|
+
REGISTRY=""
|
|
123
|
+
SEEN_WAITING=0
|
|
124
|
+
find_registry() {
|
|
125
|
+
local f raw
|
|
126
|
+
for f in "$SESSIONS_DIR"/*.json; do
|
|
127
|
+
[ -f "$f" ] || continue
|
|
128
|
+
raw=""
|
|
129
|
+
IFS= read -r raw 2>/dev/null < "$f"
|
|
130
|
+
case "$raw" in *"\"sessionId\":\"$SESSION_ID\""*) REGISTRY=$f; return 0 ;; esac
|
|
131
|
+
done
|
|
132
|
+
return 1
|
|
133
|
+
}
|
|
134
|
+
|
|
117
135
|
while :; do
|
|
118
136
|
if [ -f "$DECISION" ]; then
|
|
119
137
|
PAYLOAD=$(cat "$DECISION" 2>/dev/null)
|
|
@@ -157,8 +175,22 @@ while :; do
|
|
|
157
175
|
IFS= read -r CUR_MARKER < "$MARKER" 2>/dev/null || exit 0
|
|
158
176
|
case "$CUR_MARKER" in *"\"id\":\"$REQ_ID\""*) ;; *) exit 0 ;; esac
|
|
159
177
|
|
|
178
|
+
if [ -n "$REGISTRY" ]; then
|
|
179
|
+
REG_RAW=""
|
|
180
|
+
IFS= read -r REG_RAW 2>/dev/null < "$REGISTRY"
|
|
181
|
+
if [[ $REG_RAW =~ \"status\":\"([a-z]+)\" ]]; then
|
|
182
|
+
if [ "${BASH_REMATCH[1]}" = "waiting" ]; then
|
|
183
|
+
SEEN_WAITING=1
|
|
184
|
+
elif [ "$SEEN_WAITING" -eq 1 ]; then
|
|
185
|
+
rm -f "$MARKER"
|
|
186
|
+
exit 0
|
|
187
|
+
fi
|
|
188
|
+
fi
|
|
189
|
+
fi
|
|
190
|
+
|
|
160
191
|
if [ "$EPOCHSECONDS" -ge "$NEXT_PROBE" ]; then
|
|
161
192
|
NEXT_PROBE=$((EPOCHSECONDS + BOARD_PROBE_SECONDS))
|
|
193
|
+
[ -n "$REGISTRY" ] || find_registry
|
|
162
194
|
# No beacon at all = no board on this config dir, now or a moment ago: give
|
|
163
195
|
# up at once, exactly as before. A beacon whose port is closed is the
|
|
164
196
|
# restart window instead, so that one gets the grace.
|