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 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', 'fork', 'shell', 'new', 'pick']);
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 = ['claude', ...claudeArgs].map((a) => quoteArg(a, family)).join(' ');
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
- const args = ['--session-id', id, ...(spec.extraArgs || [])];
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
- if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
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
- // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ESC so a prompt cannot carry terminal sequences
200
- const prompt = typeof msg.prompt === 'string' ? msg.prompt.replace(/\x1b/g, '').trim() : '';
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
- return { cwd: msg.cwd, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs };
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
- function queuePrompt(s, prompt) {
446
- let armed = false;
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(prompt);
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
- const own = ownEntry(live, s, s.startedAt);
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 proc = pty.spawn(shell, shellArgs(shell, claudeArgsFor(mode, id, spec)), {
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 (spec?.prompt) queuePrompt(s, spec.prompt);
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
- const known = await o.resolveCwd(id);
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
- if (typeof msg.cwd !== 'string' || !(await o.isAllowedFolder(msg.cwd))) {
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 fork runs under
799
- // a new session id, so its terminal id does not name the session the text is for. A
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' || s.mode === 'fork') return false;
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.2.0",
3
+ "version": "7.4.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
  "type": "commonjs",