claude-code-kanban 6.0.0 → 6.1.1

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/terminal.js CHANGED
@@ -7,6 +7,10 @@
7
7
  // (net-guard upgradeVerdict), refusal when the server is exposed, and a per-launch
8
8
  // token sent in the first message. The token is the only one that stops a
9
9
  // non-browser local process.
10
+ //
11
+ // The service runs in its own process (lib/terminal-host.js): cck checks the first two
12
+ // gates and hands the upgraded socket over, so no keystroke or output byte waits on
13
+ // cck's main thread.
10
14
 
11
15
  const crypto = require('node:crypto');
12
16
  const fs = require('node:fs');
@@ -249,15 +253,43 @@ function refuse(socket, status, reason) {
249
253
  socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${reason}\n`);
250
254
  }
251
255
 
256
+ function localUnavailableReason(config, net) {
257
+ if (!config.enabled) return 'disabled';
258
+ if (net.EXPOSED) return 'refused while listening on a non-loopback address';
259
+ return null;
260
+ }
261
+
262
+ function refuseUpgrade(req, socket, reasonFor) {
263
+ let pathname;
264
+ try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
265
+ if (pathname !== WS_PATH) {
266
+ refuse(socket, '404 Not Found', 'no such endpoint');
267
+ return true;
268
+ }
269
+ const reason = reasonFor();
270
+ if (reason) refuse(socket, '403 Forbidden', reason);
271
+ return !!reason;
272
+ }
273
+
274
+ function clientConfigFor(config, available) {
275
+ return {
276
+ available,
277
+ fontFamily: config.fontFamily,
278
+ fontSize: config.fontSize,
279
+ scrollback: config.scrollback,
280
+ maxSessions: config.maxSessions,
281
+ };
282
+ }
283
+
252
284
  /**
253
285
  * @param {object} o
254
286
  * @param {ReturnType<typeof readTerminalConfig>} o.config
255
287
  * @param {{EXPOSED: boolean, upgradeVerdict: (req: any) => string|null}} o.net
256
288
  * @param {string} o.claudeDir
257
289
  * @param {boolean} o.isDefaultDir
258
- * @param {(id: string) => string|null} o.resolveCwd null = unknown session
290
+ * @param {(id: string) => string|null|Promise<string|null>} o.resolveCwd null = unknown session
259
291
  * @param {(id: string, exceptPid?: number) => boolean} o.isLiveElsewhere
260
- * @param {(dir: string) => boolean} o.isAllowedFolder where a new session may start
292
+ * @param {(dir: string) => boolean|Promise<boolean>} o.isAllowedFolder where a new session may start
261
293
  * @param {() => {pid: number|null, sessionId: string, cwd: string|null, startedAt: number}[]} o.liveSessions
262
294
  * claude's live-session registry, read fresh
263
295
  * @param {(cmd: string) => string|null} o.which
@@ -310,10 +342,7 @@ function createTerminalService(o) {
310
342
  }
311
343
 
312
344
  function unavailableReason() {
313
- if (!config.enabled) return 'disabled';
314
- if (net.EXPOSED) return 'refused while listening on a non-loopback address';
315
- if (!load()) return loadError;
316
- return null;
345
+ return localUnavailableReason(config, net) || (load() ? null : loadError);
317
346
  }
318
347
 
319
348
  function changed() {
@@ -343,11 +372,11 @@ function createTerminalService(o) {
343
372
  function restore() {
344
373
  if (!restoreQueue.length || unavailableReason()) return;
345
374
  console.log(`Restoring ${restoreQueue.length} terminal(s)`);
346
- const next = () => {
375
+ const next = async () => {
347
376
  while (restoreQueue.length && !shuttingDown) {
348
377
  const id = restoreQueue.shift();
349
378
  if (sessions.has(id) || o.isLiveElsewhere(id)) continue;
350
- const r = start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
379
+ const r = await start(id, 'resume', {}, HEADLESS_COLS, HEADLESS_ROWS);
351
380
  if (!r.error) break;
352
381
  console.log(`Could not restore terminal ${id}: ${r.error}`);
353
382
  }
@@ -498,7 +527,8 @@ function createTerminalService(o) {
498
527
  // Claude's registry entry keeps its pid and swaps its sessionId to the picked one; the entry
499
528
  // is found by folder and start time, and the pick shows as an id with a transcript behind it.
500
529
  function watchPick(s) {
501
- s.pickTimer = setInterval(() => {
530
+ s.pickTimer = setInterval(async () => {
531
+ if (s.pickChecking) return;
502
532
  const live = o.liveSessions();
503
533
  const entry = s.claudePid && live.find((l) => l.pid === s.claudePid);
504
534
  if (!entry) {
@@ -509,7 +539,11 @@ function createTerminalService(o) {
509
539
  return;
510
540
  }
511
541
  const picked = entry.sessionId;
512
- if (picked === s.id || o.resolveCwd(picked) === null) return;
542
+ if (picked === s.id) return;
543
+ s.pickChecking = true;
544
+ const known = await o.resolveCwd(picked);
545
+ s.pickChecking = false;
546
+ if (known === null || s.exited || s.mode !== 'pick') return;
513
547
  clearInterval(s.pickTimer);
514
548
  // Two claude processes on one session write the same transcript. When cck already runs it,
515
549
  // the pick is ended and its viewers move to that terminal; the sockets leave first so no
@@ -562,7 +596,7 @@ function createTerminalService(o) {
562
596
  });
563
597
  }
564
598
 
565
- function onHello(ws, msg) {
599
+ async function onHello(ws, msg) {
566
600
  if (!msg || msg.t !== 'hello' || !tokenMatches(token, msg.token)) {
567
601
  send(ws, { t: 'error', msg: 'The terminal token is out of date. Reload the hub window.' });
568
602
  return ws.close(4001);
@@ -584,36 +618,43 @@ function createTerminalService(o) {
584
618
  send(ws, { t: 'live' });
585
619
  return ws.close(1000);
586
620
  }
587
- const r = start(id, mode, msg, cols, rows);
621
+ const r = await start(id, mode, msg, cols, rows);
622
+ if (r.running) return attach(ws, r.running, true);
588
623
  if (r.error) {
589
624
  send(ws, { t: 'error', msg: r.error });
590
625
  return ws.close(r.code);
591
626
  }
627
+ // The viewer left while the PTY started; it runs headless until the next attach.
628
+ if (ws.readyState !== 1) return;
592
629
  attach(ws, r.session, false);
593
630
  }
594
631
 
595
632
  // The admission rules shared by the socket hello and startNew. A refusal carries both a
596
633
  // WebSocket close code and an HTTP status, so each caller maps it its own way.
597
- function start(id, mode, msg, cols, rows, extraEnv) {
598
- if (sessions.size >= config.maxSessions) {
599
- return { code: 4003, status: 429, error: `${config.maxSessions} terminals are open; end one first` };
634
+ // resolveCwd and isAllowedFolder may answer later, so the limits are checked again after them.
635
+ async function start(id, mode, msg, cols, rows, extraEnv) {
636
+ const full = { code: 4003, status: 429, error: `${config.maxSessions} terminals are open; end one first` };
637
+ if (sessions.size >= config.maxSessions) return full;
638
+ let spec = null;
639
+ if (mode === 'new') {
640
+ spec = parseNewSpec(msg);
641
+ if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
600
642
  }
601
- const known = o.resolveCwd(id);
643
+ const known = await o.resolveCwd(id);
602
644
  let cwd = known;
603
- let spec = null;
604
645
  if (mode === 'new' || mode === 'pick') {
605
- if (mode === 'new') {
606
- spec = parseNewSpec(msg);
607
- if (typeof spec === 'string') return { code: 4002, status: 400, error: `invalid ${spec}` };
608
- }
609
646
  if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
610
- if (typeof msg.cwd !== 'string' || !o.isAllowedFolder(msg.cwd)) {
647
+ if (typeof msg.cwd !== 'string' || !(await o.isAllowedFolder(msg.cwd))) {
611
648
  return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
612
649
  }
613
650
  cwd = msg.cwd;
614
651
  } else if (mode !== 'shell' && known === null) {
615
652
  return { code: 4004, status: 404, error: 'unknown session' };
616
653
  }
654
+ if (shuttingDown) return { code: 1001, status: 503, error: 'the terminal is shutting down' };
655
+ const running = sessions.get(id);
656
+ if (running && !running.ended) return { running, code: 4006, status: 409, error: 'session id already in use' };
657
+ if (sessions.size >= config.maxSessions) return full;
617
658
  try {
618
659
  return { session: spawnSession(id, mode === 'auto' ? 'resume' : mode, cwd, cols, rows, spec, extraEnv) };
619
660
  } catch (e) {
@@ -622,31 +663,24 @@ function createTerminalService(o) {
622
663
  }
623
664
 
624
665
  function handleUpgrade(req, socket, head) {
625
- let pathname;
626
- try { pathname = new URL(req.url, 'http://x').pathname; } catch { pathname = ''; }
627
- if (pathname !== WS_PATH) return refuse(socket, '404 Not Found', 'no such endpoint');
628
- const reason = unavailableReason() || net.upgradeVerdict(req);
629
- if (reason) return refuse(socket, '403 Forbidden', reason);
666
+ if (refuseUpgrade(req, socket, () => unavailableReason() || net.upgradeVerdict(req))) return;
630
667
  wss.handleUpgrade(req, socket, head, (ws) => {
631
668
  const timer = setTimeout(() => ws.close(4001), HELLO_TIMEOUT_MS);
632
669
  ws.once('message', (raw, isBinary) => {
633
670
  clearTimeout(timer);
634
671
  let msg = null;
635
672
  if (!isBinary) { try { msg = JSON.parse(raw.toString()); } catch { /* treated as bad hello */ } }
636
- onHello(ws, msg);
673
+ onHello(ws, msg).catch((e) => {
674
+ send(ws, { t: 'error', msg: `failed to start the terminal: ${e.message}` });
675
+ ws.close(1011);
676
+ });
637
677
  });
638
678
  ws.on('error', () => {});
639
679
  });
640
680
  }
641
681
 
642
682
  function clientConfig() {
643
- return {
644
- available: !unavailableReason(),
645
- fontFamily: config.fontFamily,
646
- fontSize: config.fontSize,
647
- scrollback: config.scrollback,
648
- maxSessions: config.maxSessions,
649
- };
683
+ return clientConfigFor(config, !unavailableReason());
650
684
  }
651
685
 
652
686
  // kill() returns before the process is gone, so an ended session leaves list() at once.
@@ -693,10 +727,11 @@ function createTerminalService(o) {
693
727
 
694
728
  // A 'new' session for a caller with no socket. The PTY runs headless until a browser
695
729
  // attaches, and a later attach resizes it.
696
- function startNew(msg, extraEnv) {
730
+ async function startNew(msg, extraEnv) {
697
731
  const reason = unavailableReason();
698
732
  if (reason) return { status: 403, error: reason };
699
- const r = start(crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
733
+ if (msg.id != null && !(typeof msg.id === 'string' && UUID_RE.test(msg.id))) return { status: 400, error: 'invalid session id' };
734
+ const r = await start(msg.id || crypto.randomUUID(), 'new', msg, HEADLESS_COLS, HEADLESS_ROWS, extraEnv);
700
735
  return r.error ? r : { id: r.session.id, cwd: r.session.cwd };
701
736
  }
702
737
 
@@ -724,4 +759,4 @@ function createTerminalService(o) {
724
759
  return { token, handleUpgrade, clientConfig, list, claudePids, isRunning, end, authorized, startNew, paste, restore, shutdown, unavailableReason };
725
760
  }
726
761
 
727
- module.exports = { createTerminalService, readTerminalConfig, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
762
+ module.exports = { createTerminalService, readTerminalConfig, localUnavailableReason, refuseUpgrade, clientConfigFor, ptyEnv, shellArgs, resolveShell, claudeArgsFor, parseNewSpec, findPickProcess, tokenMatches };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "6.0.0",
3
+ "version": "6.1.1",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "claude-code-kanban dashboard integration: agent activity tracking, context and cost tracking, skills to drive the board from a session and to follow it",
5
5
  "experimental": {
6
6
  "monitors": "./monitors.json"
@@ -12,9 +12,10 @@ export function displayName(model: string) {
12
12
  const toEpochSeconds = (iso?: string) => (iso ? Math.floor(Date.parse(iso) / 1000) : undefined)
13
13
 
14
14
  // The board reads the statusLine's JSON shape, so the file keeps its field names.
15
- export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined) {
15
+ export function toStatus(model: string, m: Measure, usage: ModelUsage | undefined, requestAt?: number) {
16
16
  const { context } = m
17
17
  return {
18
+ ...(requestAt && { cache: { last_request_at: requestAt } }),
18
19
  model: { id: model, display_name: displayName(model) },
19
20
  cost: { total_cost_usd: m.cost?.usd ?? 0 },
20
21
  context_window: {
@@ -43,6 +44,7 @@ function cckDir($: EngineInterface) {
43
44
 
44
45
  let lastUsage: ModelUsage | undefined
45
46
  let lastModel: string | undefined
47
+ let lastRequestAt: number | undefined
46
48
  const lastWritten = new Map<string, string>()
47
49
 
48
50
  async function write($: EngineInterface, m: Measure) {
@@ -51,7 +53,7 @@ async function write($: EngineInterface, m: Measure) {
51
53
  lastModel ?? $.session.model(),
52
54
  cckDir($),
53
55
  ])
54
- const text = JSON.stringify(toStatus(model, m, lastUsage))
56
+ const text = JSON.stringify(toStatus(model, m, lastUsage, lastRequestAt))
55
57
  const file = `${dir}/context-status/${sessionId}.json`
56
58
  if (lastWritten.get(file) === text) return
57
59
  await $.fs.write(file, text)
@@ -65,6 +67,7 @@ export const register: Register = on => {
65
67
  const { model, ...usage } = result.usage
66
68
  lastUsage = usage
67
69
  lastModel = model
70
+ lastRequestAt = Date.now()
68
71
  await write($, await $.session.usage())
69
72
  }
70
73
  return result
@@ -1,6 +1,6 @@
1
1
  import type { On, SessionMeasureInput } from 'claude-code'
2
2
  import { expect, test } from 'claude-code/testing'
3
- import { displayName } from '../hooks/context'
3
+ import { displayName, toStatus } from '../hooks/context'
4
4
 
5
5
  const MEASURE: SessionMeasureInput = {
6
6
  context: { tokens: 129_174, window: 1_000_000, percent: 13 },
@@ -45,6 +45,19 @@ test('session.measure writes the statusLine shape the board reads', async ($, on
45
45
  expect(status.rate_limits.seven_day.used_percentage).toBe(7.5)
46
46
  })
47
47
 
48
+ test('session.measure writes no cache entry before the first request', async ($, on) => {
49
+ const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-4')
50
+
51
+ await $.session.measure(MEASURE)
52
+
53
+ expect(JSON.parse(writes[0]?.text ?? '{}').cache).toBeUndefined()
54
+ })
55
+
56
+ test('toStatus adds the last request time', async () => {
57
+ const status = toStatus('claude-opus-5-5', MEASURE, undefined, 1_790_000_000_000)
58
+ expect(status.cache).toEqual({ last_request_at: 1_790_000_000_000 })
59
+ })
60
+
48
61
  test('skips a write when the status has not changed', async ($, on) => {
49
62
  const writes = engine(on, { CLAUDE_CONFIG_DIR: 'C:/cfg' }, 'sid-3')
50
63
 
package/public/app.js CHANGED
@@ -8931,6 +8931,73 @@ function formatCost(usd) {
8931
8931
  return `$${usd.toFixed(2)}`;
8932
8932
  }
8933
8933
 
8934
+ const CACHE_TTL = { '5m': { ms: 5 * 60000, write: 1.25 }, '1h': { ms: 60 * 60000, write: 2 } };
8935
+ // List input price, $ per million tokens. Most specific prefix first.
8936
+ const INPUT_PRICE_PER_MTOK = [
8937
+ ['claude-fable-5', 10],
8938
+ ['claude-mythos-5', 10],
8939
+ ['claude-opus-5-5', 4],
8940
+ ['claude-opus-5', 5],
8941
+ ['claude-opus-4', 5],
8942
+ ['claude-sonnet-5', 2],
8943
+ ['claude-sonnet-4', 3],
8944
+ ['claude-haiku-4', 1],
8945
+ ];
8946
+
8947
+ // Same token count Claude Code prices in its own estimated_cache_write_usd.
8948
+ function cacheRewrite(usage, modelId, writeRate) {
8949
+ const price = INPUT_PRICE_PER_MTOK.find(([prefix]) => modelId?.startsWith(prefix))?.[1];
8950
+ const tokens =
8951
+ (usage.input_tokens || 0) +
8952
+ (usage.cache_read_input_tokens || 0) +
8953
+ (usage.cache_creation_input_tokens || 0) +
8954
+ (usage.output_tokens || 0);
8955
+ if (!price || !tokens) return null;
8956
+ return { tokens, usd: (tokens / 1e6) * price * writeRate };
8957
+ }
8958
+
8959
+ function formatCacheLeft(expiresAt, expiredText) {
8960
+ const left = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
8961
+ if (left === 0) return { text: expiredText, color: 'var(--text-muted)' };
8962
+ const text = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
8963
+ const color = left < 120 ? CONTEXT_COLORS.red : left < 300 ? CONTEXT_COLORS.orange : CONTEXT_COLORS.green;
8964
+ return { text, color };
8965
+ }
8966
+
8967
+ let cacheTimerInterval = null;
8968
+
8969
+ function tickCacheTimers() {
8970
+ const els = document.querySelectorAll('.cache-timer');
8971
+ if (!els.length) {
8972
+ clearInterval(cacheTimerInterval);
8973
+ cacheTimerInterval = null;
8974
+ return;
8975
+ }
8976
+ if (!isOnScreen()) return;
8977
+ for (const el of els) {
8978
+ const { text, color } = formatCacheLeft(Number(el.dataset.expires), el.dataset.expiredText);
8979
+ if (el.textContent === text) continue;
8980
+ el.textContent = text;
8981
+ el.style.color = color;
8982
+ }
8983
+ }
8984
+
8985
+ function renderCacheTimer(lastRequestAt, ttl, usage, modelId) {
8986
+ const cache = CACHE_TTL[ttl];
8987
+ if (!lastRequestAt || !cache) return '';
8988
+ const expiresAt = lastRequestAt + cache.ms;
8989
+ const rewrite = cacheRewrite(usage, modelId, cache.write);
8990
+ const expiredText = rewrite ? `expired · ~${formatCost(rewrite.usd)}` : 'expired';
8991
+ const { text, color } = formatCacheLeft(expiresAt, expiredText);
8992
+ cacheTimerInterval ??= setInterval(tickCacheTimers, 1000);
8993
+ const at = new Date(expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
8994
+ const cost = rewrite
8995
+ ? ` It writes ${formatTokens(rewrite.tokens / 1000)} tokens again, about ${formatCost(rewrite.usd)} at list price.`
8996
+ : '';
8997
+ const title = `Prompt cache, ${ttl} TTL. Expires at ${at}; the next request after that writes it again.${cost}`;
8998
+ return `<div class="stat-item" title="${escapeHtml(title)}"><span class="stat-label">Cache</span><span class="stat-value cache-timer" data-expires="${escapeHtml(expiresAt)}" data-expired-text="${escapeHtml(expiredText)}" style="color:${color}">${text}</span></div>`;
8999
+ }
9000
+
8934
9001
  function renderContextDetail(session, { tokens = true } = {}) {
8935
9002
  const ctx = getCtx(session);
8936
9003
  if (!ctx) return '';
@@ -8968,6 +9035,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
8968
9035
  <div class="detail-context-stats">
8969
9036
  ${tokenRows}
8970
9037
  <div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
9038
+ ${renderCacheTimer(raw.cache?.last_request_at, session.cacheTtl, usage, raw.model?.id)}
8971
9039
  ${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
8972
9040
  ${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
8973
9041
  ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
@@ -12455,14 +12523,16 @@ function formatTerminalProc(s) {
12455
12523
  async function pollTerminalProcStats() {
12456
12524
  if (!isOnScreen()) return;
12457
12525
  let cck;
12526
+ let host;
12458
12527
  try {
12459
12528
  const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
12460
12529
  if (!res.ok) return;
12461
- ({ cck, terminals: terminalProcStats } = await res.json());
12530
+ ({ cck, host, terminals: terminalProcStats } = await res.json());
12462
12531
  } catch (_) {
12463
12532
  return;
12464
12533
  }
12465
- document.getElementById('terminal-manager-stats').textContent = cck ? `cck ${formatTerminalProc(cck)}` : '';
12534
+ const parts = [cck && `cck ${formatTerminalProc(cck)}`, host && `terminal host ${formatTerminalProc(host)}`];
12535
+ document.getElementById('terminal-manager-stats').textContent = parts.filter(Boolean).join(' · ');
12466
12536
  for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
12467
12537
  el.textContent = formatTerminalProc(terminalProcStats[el.dataset.proc]);
12468
12538
  }
@@ -12568,7 +12638,8 @@ const newSpecs = new Map();
12568
12638
  // Sessions another session started (`dispatch start`), shown where they will stay until
12569
12639
  // their transcript appears. Kept apart from newSpecs: their prompt is already sent.
12570
12640
  const dispatchSpecs = new Map();
12571
- const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', browsing: false, resume: false };
12641
+ const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
12642
+ const NEW_SESSION_STAY_KEY = 'new-session-stay';
12572
12643
 
12573
12644
  const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
12574
12645
  const PLACEHOLDER_HINTS = {
@@ -12627,9 +12698,13 @@ async function loadDispatches() {
12627
12698
 
12628
12699
  async function onDispatchUpdate() {
12629
12700
  await loadDispatches();
12701
+ refreshPlaceholders();
12702
+ fetchSessions(false).catch(() => {});
12703
+ }
12704
+
12705
+ function refreshPlaceholders() {
12630
12706
  sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
12631
12707
  renderSessions();
12632
- fetchSessions(false).catch(() => {});
12633
12708
  }
12634
12709
 
12635
12710
  // With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
@@ -12708,6 +12783,7 @@ function openNewSession(folder, resume = false) {
12708
12783
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
12709
12784
  document.getElementById('ns-wt').checked = false;
12710
12785
  document.getElementById('ns-model').value = '';
12786
+ document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
12711
12787
  setNewSessionError('');
12712
12788
  const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
12713
12789
  setNewSessionFolder(folder || current || '');
@@ -12841,36 +12917,44 @@ function renderNewSessionForm() {
12841
12917
  const problem = newSessionProblem(v);
12842
12918
  setNewSessionError(problem);
12843
12919
  const start = document.getElementById('ns-start');
12844
- start.disabled = !v.cwd || !!problem || ns.browsing;
12920
+ start.disabled = !v.cwd || !!problem || ns.busy;
12845
12921
  const verb = ns.resume ? 'Resume' : 'Start';
12846
12922
  start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
12847
12923
  }
12848
12924
 
12925
+ // Resolves to the response body, or to { error } with the message for the dialog.
12926
+ async function newSessionPost(url, body, failure) {
12927
+ try {
12928
+ const res = await terminalFetch(url, 'POST', body);
12929
+ const out = await res.json().catch(() => ({}));
12930
+ if (res.ok) return out;
12931
+ return { error: res.status === 401 ? STALE_TOKEN_MSG : out.error || `${failure} (${res.status})` };
12932
+ } catch (e) {
12933
+ return { error: e.message };
12934
+ }
12935
+ }
12936
+
12937
+ function setNewSessionBusy(on) {
12938
+ ns.busy = on;
12939
+ renderNewSessionForm();
12940
+ }
12941
+
12849
12942
  async function browseNewSessionFolder() {
12850
- if (ns.browsing) return;
12851
- ns.browsing = true;
12943
+ if (ns.busy) return;
12852
12944
  closeFolderList();
12853
12945
  const btn = document.getElementById('ns-browse');
12854
12946
  btn.textContent = 'Opening…';
12855
- renderNewSessionForm();
12856
- try {
12857
- const start = ns.folder || ns.picked[0] || ns.projects[0];
12858
- const res = await terminalFetch('/api/terminal/pick-folder', 'POST', start ? { start } : null);
12859
- const body = await res.json().catch(() => ({}));
12860
- if (res.status === 401) setNewSessionError(STALE_TOKEN_MSG);
12861
- else if (!res.ok) setNewSessionError(body.error || `Folder dialog failed (${res.status})`);
12862
- else if (body.path) {
12863
- ns.picked = [body.path, ...ns.picked.filter((p) => p !== body.path)];
12864
- setNewSessionFolder(body.path);
12865
- }
12866
- } catch (e) {
12867
- setNewSessionError(e.message);
12868
- } finally {
12869
- ns.browsing = false;
12870
- btn.textContent = 'Browse…';
12871
- renderNewSessionForm();
12872
- document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
12947
+ setNewSessionBusy(true);
12948
+ const start = ns.folder || ns.picked[0] || ns.projects[0];
12949
+ const r = await newSessionPost('/api/terminal/pick-folder', start ? { start } : null, 'Folder dialog failed');
12950
+ if (r.path) {
12951
+ ns.picked = [r.path, ...ns.picked.filter((p) => p !== r.path)];
12952
+ setNewSessionFolder(r.path);
12873
12953
  }
12954
+ btn.textContent = 'Browse…';
12955
+ setNewSessionBusy(false);
12956
+ if (r.error) setNewSessionError(r.error);
12957
+ document.getElementById(ns.folder ? fieldAfterFolder() : 'ns-folder').focus();
12874
12958
  }
12875
12959
 
12876
12960
  function fieldAfterFolder() {
@@ -12879,15 +12963,36 @@ function fieldAfterFolder() {
12879
12963
 
12880
12964
  function startNewSession() {
12881
12965
  const v = newSessionValues();
12882
- if (!v.cwd || newSessionProblem(v) || ns.browsing) return;
12966
+ if (!v.cwd || newSessionProblem(v) || ns.busy) return;
12883
12967
  const id = crypto.randomUUID();
12884
12968
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
12885
- sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
12886
12969
  setTerminalMode(id, true);
12970
+ if (!ns.resume && document.getElementById('ns-stay').checked) return startNewSessionInBackground(id, v);
12971
+ sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
12887
12972
  closeNewSession();
12888
12973
  fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
12889
12974
  }
12890
12975
 
12976
+ async function startNewSessionInBackground(id, v) {
12977
+ refreshPlaceholders();
12978
+ setNewSessionBusy(true);
12979
+ const r = await newSessionPost('/api/terminal/start', { id, ...v }, 'Start failed');
12980
+ setNewSessionBusy(false);
12981
+ if (r.error) {
12982
+ dropPlaceholder(id);
12983
+ return setNewSessionError(r.error);
12984
+ }
12985
+ // A server started before the page's code changed ignores the id and picks its own.
12986
+ if (r.session !== id) {
12987
+ const spec = newSpecs.get(id);
12988
+ forgetPlaceholder(id);
12989
+ newSpecs.set(r.session, spec);
12990
+ setTerminalMode(r.session, true);
12991
+ refreshPlaceholders();
12992
+ }
12993
+ closeNewSession();
12994
+ }
12995
+
12891
12996
  const SpeechRecognitionApi = window.SpeechRecognition || window.webkitSpeechRecognition;
12892
12997
  const promptVoice = { rec: null };
12893
12998
 
@@ -12963,6 +13068,9 @@ function initNewSession() {
12963
13068
  renderNewSessionForm();
12964
13069
  if (e.target.checked) document.getElementById('ns-wt-name').focus();
12965
13070
  });
13071
+ document
13072
+ .getElementById('ns-stay')
13073
+ .addEventListener('change', (e) => store.setItem(NEW_SESSION_STAY_KEY, String(e.target.checked)));
12966
13074
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
12967
13075
  document.getElementById(id).addEventListener('input', renderNewSessionForm);
12968
13076
  }
package/public/index.html CHANGED
@@ -52,7 +52,7 @@
52
52
  </div>
53
53
  <h2>Server Not Running</h2>
54
54
  <p>The dashboard needs the Claude Code Kanban server to stream live task data. Start it with:</p>
55
- <code>npx claude-code-kanban</code>
55
+ <code>claude-code-kanban</code>
56
56
  <button class="btn btn-primary" onclick="location.reload()">Retry Connection</button>
57
57
  <div class="offline-status" id="offline-status" aria-live="polite"></div>
58
58
  </div>
@@ -818,6 +818,7 @@
818
818
  <div id="ns-error" class="ns-error" hidden></div>
819
819
  </div>
820
820
  <div class="modal-footer ns-footer">
821
+ <label class="ns-check ns-stay" title="Start in the background and keep the current view"><input type="checkbox" id="ns-stay"> Stay here</label>
821
822
  <button type="button" class="btn btn-secondary" onclick="closeNewSession()">Cancel</button>
822
823
  <button type="button" id="ns-start" class="btn btn-primary" onclick="startNewSession()" disabled>Start</button>
823
824
  </div>
package/public/style.css CHANGED
@@ -6861,6 +6861,17 @@ select.form-input option:checked {
6861
6861
 
6862
6862
  .ns-footer {
6863
6863
  align-items: center;
6864
+ margin-top: 16px;
6865
+ border-top: 1px solid var(--border);
6866
+ }
6867
+
6868
+ .ns-stay {
6869
+ margin-right: auto;
6870
+ color: var(--text-tertiary);
6871
+ }
6872
+
6873
+ .new-session-modal.ns-resume .ns-stay {
6874
+ visibility: hidden;
6864
6875
  }
6865
6876
 
6866
6877
  .ns-footer .btn-primary:disabled {