claude-code-kanban 7.2.0 → 7.4.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/hub-app.json +1 -0
- package/lib/terminal.js +72 -33
- package/package.json +1 -1
- package/public/app.js +321 -126
- package/public/index.html +11 -7
- package/public/style.css +70 -13
- package/public/terminal-frame.js +6 -4
- package/public/vendor/claude-hub-sdk.js +40 -6
package/hub-app.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
"icon": "columns",
|
|
6
6
|
"run": { "entry": "server.js", "defaultPort": 3541 },
|
|
7
7
|
"publishes": ["session.changed"],
|
|
8
|
+
"keys": { "keeps": ["ctrl+alt+n", "ctrl+alt+r", "ctrl+alt+s"] },
|
|
8
9
|
"provides": {
|
|
9
10
|
"projects": { "path": "/api/projects" },
|
|
10
11
|
"terminal": { "liveWork": "/api/terminals" }
|
package/lib/terminal.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// gates and hands the upgraded socket over, so no keystroke or output byte waits on
|
|
13
13
|
// cck's main thread.
|
|
14
14
|
|
|
15
|
+
const { spawnSync } = require('node:child_process');
|
|
15
16
|
const crypto = require('node:crypto');
|
|
16
17
|
const fs = require('node:fs');
|
|
17
18
|
const os = require('node:os');
|
|
@@ -22,7 +23,7 @@ const priority = require('./priority');
|
|
|
22
23
|
|
|
23
24
|
const WS_PATH = '/api/terminal/ws';
|
|
24
25
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
25
|
-
const MODES = new Set(['auto', 'resume', '
|
|
26
|
+
const MODES = new Set(['auto', 'resume', 'shell', 'new', 'pick']);
|
|
26
27
|
const PICK_POLL_MS = 1000;
|
|
27
28
|
// How long before the PTY a registry entry may claim to have started (clock skew between the two).
|
|
28
29
|
const PICK_START_SLACK_MS = 2000;
|
|
@@ -58,7 +59,6 @@ const RESTORE_GAP_MS = 2000;
|
|
|
58
59
|
// The hub stops cck with `taskkill /f /t`, which can deliver the PTY exits to cck a moment
|
|
59
60
|
// before cck itself dies. The delay keeps that teardown out of the saved list.
|
|
60
61
|
const SAVE_DELAY_MS = 1000;
|
|
61
|
-
// A fork runs under its parent's id until claude names the new one, so it has no id to resume.
|
|
62
62
|
const RESTORABLE_MODES = new Set(['resume', 'new']);
|
|
63
63
|
// Quitting claude drops the PTY to its shell, so the PTY's exit cannot tell cck that claude is gone.
|
|
64
64
|
const CLAUDE_EXIT_POLL_MS = 5000;
|
|
@@ -147,10 +147,25 @@ function quoteArg(arg, family) {
|
|
|
147
147
|
return family === 'cmd' ? `"${arg}"` : `'${arg}'`;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// Free text, unlike quoteArg's input. Inside single quotes pwsh only reads a quote mark (it
|
|
151
|
+
// counts the typographic ones too) and POSIX shells read nothing, so doubling or closing
|
|
152
|
+
// around the quote is enough. cmd has no quoting that holds % and ", so start() refuses it.
|
|
153
|
+
function quotePrompt(text, family) {
|
|
154
|
+
if (family === 'pwsh') return `'${text.replace(/['‘’‚‛]/g, '$&$&')}'`;
|
|
155
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The prompt goes first: -w takes an optional value, so a prompt after it would become the worktree name.
|
|
159
|
+
function claudeCommand(shell, claudeArgs, prompt) {
|
|
160
|
+
const family = shellFamily(shell);
|
|
161
|
+
const args = claudeArgs.map((a) => quoteArg(a, family));
|
|
162
|
+
return ['claude', ...(prompt ? [quotePrompt(prompt, family)] : []), ...args].join(' ');
|
|
163
|
+
}
|
|
164
|
+
|
|
150
165
|
function shellArgs(shell, claudeArgs) {
|
|
151
166
|
const family = shellFamily(shell);
|
|
152
167
|
if (!claudeArgs) return family === 'pwsh' ? ['-NoLogo'] : family === 'gitbash' ? ['--login', '-i'] : [];
|
|
153
|
-
const cmd =
|
|
168
|
+
const cmd = claudeCommand(shell, claudeArgs);
|
|
154
169
|
if (family === 'pwsh') return ['-NoLogo', '-NoExit', '-Command', cmd];
|
|
155
170
|
if (family === 'cmd') return ['/k', cmd];
|
|
156
171
|
if (family === 'gitbash') return ['--login', '-i', '-c', `${cmd}; exec bash --login -i`];
|
|
@@ -161,10 +176,11 @@ function shellArgs(shell, claudeArgs) {
|
|
|
161
176
|
|
|
162
177
|
function claudeArgsFor(mode, id, spec) {
|
|
163
178
|
if (mode === 'shell') return null;
|
|
164
|
-
if (mode === 'fork') return ['--resume', id, '--fork-session'];
|
|
165
179
|
if (mode === 'pick') return ['--resume'];
|
|
166
180
|
if (mode === 'new') {
|
|
167
|
-
|
|
181
|
+
// claude takes --session-id with --resume only together with --fork-session.
|
|
182
|
+
const fork = spec.forkOf ? ['--resume', spec.forkOf, '--fork-session'] : [];
|
|
183
|
+
const args = [...fork, '--session-id', id, ...(spec.extraArgs || [])];
|
|
168
184
|
if (spec.name) args.push('--name', spec.name);
|
|
169
185
|
if (spec.model) args.push('--model', spec.model);
|
|
170
186
|
// Last, because its value is optional and a following flag must not be taken for it.
|
|
@@ -187,7 +203,9 @@ function extraArgsError(args) {
|
|
|
187
203
|
|
|
188
204
|
// Returns the new-session options from a hello, or a string naming the bad field.
|
|
189
205
|
function parseNewSpec(msg) {
|
|
190
|
-
|
|
206
|
+
const forkOf = msg.forkOf ?? null;
|
|
207
|
+
if (forkOf !== null && (typeof forkOf !== 'string' || !UUID_RE.test(forkOf))) return 'session to fork';
|
|
208
|
+
if (!forkOf && (typeof msg.cwd !== 'string' || !msg.cwd)) return 'folder';
|
|
191
209
|
const name = typeof msg.name === 'string' ? msg.name.trim() : '';
|
|
192
210
|
if (name && !NAME_RE.test(name)) return 'name';
|
|
193
211
|
const worktree = msg.worktree ?? false;
|
|
@@ -196,13 +214,18 @@ function parseNewSpec(msg) {
|
|
|
196
214
|
if (model && !MODELS.has(model)) return 'model';
|
|
197
215
|
const taskList = msg.taskList || null;
|
|
198
216
|
if (taskList && !(typeof taskList === 'string' && TASK_LIST_RE.test(taskList))) return 'task list id';
|
|
199
|
-
|
|
200
|
-
|
|
217
|
+
const edit = msg.edit === true;
|
|
218
|
+
// ESC so a prompt cannot carry terminal sequences; on a shell line (edit) every control
|
|
219
|
+
// character but tab and line breaks, since it could act as a key (Ctrl+C, Ctrl+U) instead of text.
|
|
220
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
221
|
+
const strip = edit ? /[\x00-\x08\x0b-\x1f\x7f]/g : /\x1b/g;
|
|
222
|
+
const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(strip, '').trim() : '';
|
|
201
223
|
if (prompt.length > MAX_PROMPT) return 'prompt';
|
|
202
224
|
const extraArgs = msg.extraArgs ?? [];
|
|
203
225
|
const bad = extraArgsError(extraArgs);
|
|
204
226
|
if (bad) return bad;
|
|
205
|
-
|
|
227
|
+
if (forkOf && worktree) return 'worktree: a fork runs in its parent session folder';
|
|
228
|
+
return { cwd: forkOf ? null : msg.cwd, forkOf, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
|
|
206
229
|
}
|
|
207
230
|
|
|
208
231
|
function samePath(a, b) {
|
|
@@ -442,19 +465,23 @@ function createTerminalService(o) {
|
|
|
442
465
|
return /\btrust\b/i.test(screenText(s.term));
|
|
443
466
|
}
|
|
444
467
|
|
|
445
|
-
|
|
446
|
-
|
|
468
|
+
// `submit` is claude's prompt: wait for its input box, then press Enter. Without it the text
|
|
469
|
+
// is the claude command typed at the shell prompt once the shell's startup output settles,
|
|
470
|
+
// and Enter is left to the user; bracketed paste keeps a prompt's line breaks from running
|
|
471
|
+
// the command early (checked in Git Bash and pwsh).
|
|
472
|
+
function queueText(s, raw, submit) {
|
|
473
|
+
let armed = !submit;
|
|
447
474
|
let timer = null;
|
|
448
|
-
const text = bracketed(
|
|
475
|
+
const text = bracketed(raw);
|
|
449
476
|
s.onOutput = (data) => {
|
|
450
477
|
if (!armed && data.includes('\x1b[?2004h')) armed = true;
|
|
451
478
|
if (!armed) return;
|
|
452
479
|
clearTimeout(timer);
|
|
453
480
|
timer = setTimeout(() => {
|
|
454
|
-
if (s.exited || onTrustScreen(s)) return;
|
|
481
|
+
if (s.exited || (submit && onTrustScreen(s))) return;
|
|
455
482
|
s.onOutput = null;
|
|
456
483
|
s.pty.write(text);
|
|
457
|
-
setTimeout(() => { if (!s.exited) s.pty.write('\r'); }, 100);
|
|
484
|
+
if (submit) setTimeout(() => { if (!s.exited) s.pty.write('\r'); }, 100);
|
|
458
485
|
}, PROMPT_QUIET_MS);
|
|
459
486
|
};
|
|
460
487
|
}
|
|
@@ -465,13 +492,7 @@ function createTerminalService(o) {
|
|
|
465
492
|
if (s.claudeExitedAt) return null;
|
|
466
493
|
if (s.claudePid || s.mode === 'pick') return s.claudePid;
|
|
467
494
|
const live = registry || o.liveSessions();
|
|
468
|
-
|
|
469
|
-
if (own || s.mode !== 'fork') {
|
|
470
|
-
s.claudePid = own?.pid;
|
|
471
|
-
return s.claudePid;
|
|
472
|
-
}
|
|
473
|
-
const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
|
|
474
|
-
s.claudePid = findPickProcess(live, s, claimed);
|
|
495
|
+
s.claudePid = ownEntry(live, s, s.startedAt)?.pid;
|
|
475
496
|
return s.claudePid;
|
|
476
497
|
}
|
|
477
498
|
|
|
@@ -492,7 +513,10 @@ function createTerminalService(o) {
|
|
|
492
513
|
moved = true;
|
|
493
514
|
continue;
|
|
494
515
|
}
|
|
516
|
+
const had = s.claudePid;
|
|
495
517
|
const pid = inputPid(s, live);
|
|
518
|
+
// An edit terminal starts claude whenever the user presses Enter, after syncBoost stopped looking.
|
|
519
|
+
if (pid && pid !== had) syncBoost(s);
|
|
496
520
|
if (!pid || (live.some((l) => l.pid === pid) && pidAlive(pid))) continue;
|
|
497
521
|
s.claudeExitedAt = Date.now();
|
|
498
522
|
s.claudePid = null;
|
|
@@ -537,7 +561,8 @@ function createTerminalService(o) {
|
|
|
537
561
|
if (taskList) env.CLAUDE_CODE_TASK_LIST_ID = taskList;
|
|
538
562
|
// Git Bash's login profile cds to $HOME unless this is set.
|
|
539
563
|
if (shellFamily(shell) === 'gitbash') env.CHERE_INVOKING = '1';
|
|
540
|
-
const
|
|
564
|
+
const claudeArgs = claudeArgsFor(mode, id, spec);
|
|
565
|
+
const proc = pty.spawn(shell, shellArgs(shell, spec?.edit ? null : claudeArgs), {
|
|
541
566
|
name: 'xterm-256color',
|
|
542
567
|
cols,
|
|
543
568
|
rows,
|
|
@@ -549,9 +574,11 @@ function createTerminalService(o) {
|
|
|
549
574
|
term.loadAddon(serializer);
|
|
550
575
|
const s = {
|
|
551
576
|
id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
|
|
552
|
-
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null,
|
|
577
|
+
name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
|
|
578
|
+
forkOf: spec?.forkOf || null,
|
|
553
579
|
};
|
|
554
|
-
if (
|
|
580
|
+
if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
|
|
581
|
+
else if (spec?.prompt) queueText(s, spec.prompt, true);
|
|
555
582
|
if (mode === 'pick') watchPick(s);
|
|
556
583
|
|
|
557
584
|
proc.onData((data) => {
|
|
@@ -697,15 +724,20 @@ function createTerminalService(o) {
|
|
|
697
724
|
if (mode === 'new') {
|
|
698
725
|
spec = parseNewSpec(msg);
|
|
699
726
|
if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
|
|
727
|
+
if (spec.edit && spec.prompt && shellFamily(shell) === 'cmd') {
|
|
728
|
+
return { code: 4002, status: 400, error: 'cmd cannot take a typed prompt; clear the prompt or set the terminal shell to pwsh or gitbash' };
|
|
729
|
+
}
|
|
700
730
|
}
|
|
701
|
-
|
|
731
|
+
// `claude --resume` finds a fork's parent only from the parent's own project folder.
|
|
732
|
+
const [known, forkCwd] = await Promise.all([o.resolveCwd(id), spec?.forkOf ? o.resolveCwd(spec.forkOf) : null]);
|
|
702
733
|
let cwd = known;
|
|
703
734
|
if (mode === 'new' || mode === 'pick') {
|
|
704
735
|
if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
|
|
705
|
-
|
|
736
|
+
cwd = spec?.forkOf ? forkCwd : msg.cwd;
|
|
737
|
+
if (spec?.forkOf && cwd === null) return { code: 4004, status: 404, error: 'unknown session to fork' };
|
|
738
|
+
if (!spec?.forkOf && (typeof cwd !== 'string' || !(await o.isAllowedFolder(cwd)))) {
|
|
706
739
|
return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
|
|
707
740
|
}
|
|
708
|
-
cwd = msg.cwd;
|
|
709
741
|
} else if (mode !== 'shell' && known === null) {
|
|
710
742
|
return { code: 4004, status: 404, error: 'unknown session' };
|
|
711
743
|
}
|
|
@@ -751,7 +783,7 @@ function createTerminalService(o) {
|
|
|
751
783
|
function list() {
|
|
752
784
|
return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
|
|
753
785
|
id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
|
|
754
|
-
name: s.name, worktree: s.worktree,
|
|
786
|
+
name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
|
|
755
787
|
}));
|
|
756
788
|
}
|
|
757
789
|
|
|
@@ -795,13 +827,14 @@ function createTerminalService(o) {
|
|
|
795
827
|
}
|
|
796
828
|
|
|
797
829
|
// Pastes into claude's input box and stops there: Enter would also submit whatever the
|
|
798
|
-
// user had half-typed, and over a permission dialog it would answer it. A
|
|
799
|
-
//
|
|
800
|
-
// pending queued prompt owns the input box until it is sent.
|
|
830
|
+
// user had half-typed, and over a permission dialog it would answer it. A pending queued
|
|
831
|
+
// prompt owns the input box until it is sent.
|
|
801
832
|
function paste(id, text) {
|
|
802
833
|
const s = sessions.get(id);
|
|
803
|
-
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell'
|
|
834
|
+
if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell') return false;
|
|
804
835
|
if (onTrustScreen(s)) return false;
|
|
836
|
+
// Until the user runs the typed command, the input box is the shell's command line.
|
|
837
|
+
if (s.edit && !s.claudePid) return false;
|
|
805
838
|
s.pty.write(bracketed(text));
|
|
806
839
|
return true;
|
|
807
840
|
}
|
|
@@ -810,6 +843,12 @@ function createTerminalService(o) {
|
|
|
810
843
|
shuttingDown = true;
|
|
811
844
|
clearTimeout(saveTimer);
|
|
812
845
|
clearInterval(exitTimer);
|
|
846
|
+
// On Windows pty.kill() returns before the tree is gone, and the host exits right after
|
|
847
|
+
// shutdown, so claude outlived it. One synchronous tree kill ends every session first.
|
|
848
|
+
const pids = [...sessions.values()].map((s) => s.pty.pid).filter(Boolean);
|
|
849
|
+
if (process.platform === 'win32' && pids.length) {
|
|
850
|
+
spawnSync('taskkill', ['/f', '/t', ...pids.flatMap((pid) => ['/pid', String(pid)])], { windowsHide: true, stdio: 'ignore' });
|
|
851
|
+
}
|
|
813
852
|
for (const s of sessions.values()) {
|
|
814
853
|
try { s.pty.kill(); } catch { /* already gone */ }
|
|
815
854
|
}
|
|
@@ -819,4 +858,4 @@ function createTerminalService(o) {
|
|
|
819
858
|
return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
|
|
820
859
|
}
|
|
821
860
|
|
|
822
|
-
module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|
|
861
|
+
module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, claudeCommand, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
|