claude-code-kanban 7.14.0 → 7.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/parsers.js CHANGED
@@ -673,16 +673,41 @@ function countTranscriptsBornBefore(dir, birthtimeMs) {
673
673
  // Cache: jsonlPath -> { ino, size, mtimeMs }, the transcript as it last grew.
674
674
  // Claude Code, `claude --resume` and backup tools set a transcript's mtime without writing
675
675
  // to it (#50), so only a file that grew, shrank or was replaced counts as activity.
676
+ // When a session exits, Claude Code appends bookkeeping lines (cost-state, last-prompt,
677
+ // mode, ai-title ...) with no `timestamp`; a write of only such lines is not activity either.
676
678
  const logActivityCache = new Map();
679
+ const BOOKKEEPING_APPEND_MAX = 64 * 1024;
680
+
681
+ function isBookkeepingAppend(jsonlPath, from, to) {
682
+ if (to - from > BOOKKEEPING_APPEND_MAX || from < 1) return false;
683
+ // Reads from one byte early, so the append is known to start on a line boundary.
684
+ const buf = Buffer.allocUnsafe(to - from + 1);
685
+ try {
686
+ const fd = fs.openSync(jsonlPath, 'r');
687
+ try { fs.readSync(fd, buf, 0, buf.length, from - 1); } finally { fs.closeSync(fd); }
688
+ } catch { return false; }
689
+ const text = buf.toString('utf8');
690
+ if (text[0] !== '\n' || !text.endsWith('\n') || text.includes('"timestamp":')) return false;
691
+ return text.slice(1, -1).split('\n').every((line) => {
692
+ if (!line) return true;
693
+ try {
694
+ const obj = JSON.parse(line);
695
+ return obj !== null && typeof obj === 'object';
696
+ } catch { return false; }
697
+ });
698
+ }
677
699
 
678
700
  function transcriptActivityMs(jsonlPath, stat) {
679
701
  const prev = logActivityCache.get(jsonlPath);
680
- if (prev && prev.ino === stat.ino && prev.size === stat.size) return prev.mtimeMs;
702
+ const sameFile = prev && prev.ino === stat.ino;
703
+ if (sameFile && prev.size === stat.size) return prev.mtimeMs;
704
+ const quiet = sameFile && stat.size > prev.size && isBookkeepingAppend(jsonlPath, prev.size, stat.size);
705
+ const mtimeMs = quiet ? prev.mtimeMs : stat.mtimeMs;
681
706
  logActivityCache.delete(jsonlPath);
682
- logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs });
707
+ logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs });
683
708
  if (logActivityCache.size > SESSION_INFO_CACHE_MAX) logActivityCache.delete(logActivityCache.keys().next().value);
684
709
  sessionCacheDirty = true;
685
- return stat.mtimeMs;
710
+ return mtimeMs;
686
711
  }
687
712
 
688
713
  const sessionCachesDirty = () => sessionCacheDirty;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.14.0",
3
+ "version": "7.16.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",
package/public/app.js CHANGED
@@ -77,7 +77,8 @@ let sessions = [];
77
77
  let currentSessionId = null;
78
78
  // Kept in the store because a config-dir switch in the hub reloads this page.
79
79
  const SWAP_KEY = 'swap-sessions';
80
- let [lastSessionId, previousSessionId] = readSwapPair();
80
+ const SWAP_HISTORY_MAX = 20;
81
+ let swapHistory = readStoredList(SWAP_KEY).filter(Boolean);
81
82
  let currentTasks = [];
82
83
  let viewMode = 'session';
83
84
  let sessionFilter = FILTER_DEFAULTS.session;
@@ -309,12 +310,13 @@ function apiPath(strings, ...values) {
309
310
 
310
311
  function api(path, { method, body, query, headers, cache } = {}) {
311
312
  const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v != null)).toString() : '';
312
- const json = body != null;
313
+ // A Blob goes as-is, and fetch takes its Content-Type from the Blob's type.
314
+ const json = body != null && !(body instanceof Blob);
313
315
  return fetch(qs ? `${path}?${qs}` : path, {
314
316
  method,
315
317
  cache,
316
318
  headers: json ? { 'Content-Type': 'application/json', ...headers } : headers,
317
- body: json ? JSON.stringify(body) : undefined,
319
+ body: json ? JSON.stringify(body) : (body ?? undefined),
318
320
  });
319
321
  }
320
322
 
@@ -369,7 +371,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
369
371
  schedulePublishSession();
370
372
  }
371
373
  // A list too short to scroll gives no scroll event, so fill it until it can scroll.
372
- if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
374
+ if (sessionsHasMore) setTimeout(() => loadMoreIfNearEnd());
373
375
 
374
376
  if (includeTasks && searchQuery) await loadSearchTasks();
375
377
  } catch (error) {
@@ -418,11 +420,11 @@ function resetSessionPage() {
418
420
  }
419
421
 
420
422
  const LOAD_MORE_MARGIN_PX = 240;
421
- function loadMoreIfNearEnd() {
422
- const { scrollTop, clientHeight, scrollHeight } = sessionsList;
423
- if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) loadMoreSessions();
423
+ function loadMoreIfNearEnd(el = sessionsList, load = loadMoreSessions) {
424
+ const { scrollTop, clientHeight, scrollHeight } = el;
425
+ if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) load();
424
426
  }
425
- sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
427
+ sessionsList.addEventListener('scroll', () => loadMoreIfNearEnd(), { passive: true });
426
428
 
427
429
  // Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
428
430
  async function fetchSessionsByIds(ids) {
@@ -782,7 +784,7 @@ async function fetchTasks(sessionId) {
782
784
  revealedStorageSessionId = null;
783
785
  }
784
786
  if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
785
- if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
787
+ pushSwapHistory(sessionId);
786
788
  const switched = sessionId !== currentSessionId;
787
789
  currentSessionId = sessionId;
788
790
  markOpenSessionRead();
@@ -6446,6 +6448,7 @@ const SHORTCUT_TABS = [
6446
6448
  { keys: ['I'], label: 'Session info' },
6447
6449
  { keys: ['.'], label: 'Pin / unpin' },
6448
6450
  { keys: ['>'], label: 'Toggle sticky' },
6451
+ { keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
6449
6452
  { keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
6450
6453
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
6451
6454
  { keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
@@ -7590,9 +7593,8 @@ function dismissSessionFromList(sid) {
7590
7593
  return;
7591
7594
  }
7592
7595
  // Ctrl+Alt+S would reveal the dismissed session, and revealing restores it.
7593
- const forget = (x) => (x === sid ? null : x);
7594
- const fallback = currentSessionId === sid && lastSessionId === sid ? previousSessionId : null;
7595
- setSwapPair(forget(lastSessionId), forget(previousSessionId));
7596
+ const fallback = currentSessionId === sid && swapHistory[0] === sid ? swapHistory[1] : null;
7597
+ forgetSwapHistory(sid);
7596
7598
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
7597
7599
  // If the dismissed session is currently open, navigate to the previous one
7598
7600
  if (currentSessionId === sid || selectedSessionId === sid) {
@@ -12273,6 +12275,52 @@ let spFromTerminal = false;
12273
12275
  let spRenderedQuery = null;
12274
12276
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
12275
12277
  const spPeekCache = new Map();
12278
+ // 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
12279
+ // every session, newest first, paged like the sidebar's All filter. Each open starts on 'board'.
12280
+ let spScope = 'board';
12281
+ let spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
12282
+ let spRowEls = [];
12283
+ const SP_RECENT_PAGE = 50;
12284
+ const SP_RECENT_TAIL = 5;
12285
+
12286
+ // The first load asks for the board's count plus the tail, so the tail is there even when
12287
+ // the newest sessions are all on the board.
12288
+ async function spLoadRecent() {
12289
+ const state = spRecent;
12290
+ if (state.loading || !state.hasMore) return;
12291
+ state.loading = true;
12292
+ state.limit = state.limit ? state.limit + SP_RECENT_PAGE : spSource.length + SP_RECENT_TAIL;
12293
+ try {
12294
+ const res = await api('/api/sessions', { query: { limit: state.limit, pins: 'off' } });
12295
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
12296
+ const rows = await res.json();
12297
+ if (state !== spRecent) return;
12298
+ state.rows = rows;
12299
+ state.hasMore = res.headers.get('X-Has-More') === 'true';
12300
+ } catch (error) {
12301
+ console.error('Failed to load recent sessions:', error);
12302
+ state.hasMore = false;
12303
+ } finally {
12304
+ state.loading = false;
12305
+ }
12306
+ if (!isSessionPickerOpen()) return;
12307
+ renderSessionPicker();
12308
+ spLoadMoreNearEnd();
12309
+ }
12310
+
12311
+ function spSetScope(scope) {
12312
+ spScope = scope;
12313
+ for (const el of document.querySelectorAll('#session-picker-scope [data-scope]')) {
12314
+ el.setAttribute('aria-checked', String(el.dataset.scope === scope));
12315
+ }
12316
+ renderSessionPicker();
12317
+ spLoadMoreNearEnd();
12318
+ }
12319
+
12320
+ // Also run after each load: a page too short to scroll gives no scroll event.
12321
+ function spLoadMoreNearEnd() {
12322
+ if (spScope === 'all') loadMoreIfNearEnd(document.getElementById('session-picker-list'), spLoadRecent);
12323
+ }
12276
12324
 
12277
12325
  const spSearch = sessionSearch(() => {
12278
12326
  if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
@@ -12285,9 +12333,11 @@ function openSessionPicker() {
12285
12333
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
12286
12334
  spPeekCache.clear();
12287
12335
  spRenderedQuery = null;
12336
+ spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
12288
12337
  document.getElementById('session-picker-modal').classList.add('visible');
12289
- renderSessionPicker();
12338
+ spSetScope('board');
12290
12339
  input.focus();
12340
+ spLoadRecent();
12291
12341
  }
12292
12342
 
12293
12343
  // refocus: false when a session is being opened, which moves the terminal focus itself.
@@ -12403,29 +12453,39 @@ function spPinHtml(session) {
12403
12453
  function renderSessionPicker() {
12404
12454
  const list = document.getElementById('session-picker-list');
12405
12455
  const query = document.getElementById('session-picker-input').value.trim();
12406
- // Search rows that arrive for the same query append below, so the cursor stays on its row.
12407
- const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
12408
- spRenderedQuery = query;
12409
- const local = spSource.filter((s) => spMatches(s, query));
12456
+ // Rows that arrive for the same query and scope append below, so the cursor stays on its row.
12457
+ const renderKey = `${spScope}\n${query}`;
12458
+ const selectedId = renderKey === spRenderedQuery ? spRows[spIdx]?.id : null;
12459
+ spRenderedQuery = renderKey;
12460
+ const all = spScope === 'all';
12461
+ const board = new Set(spSource.map((s) => s.id));
12462
+ const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
12410
12463
  const found = spSearch.rows(query, local);
12411
- spRows = [...local, ...found.rows];
12464
+ const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
12465
+ const extra = [...found.rows, ...tail];
12466
+ spRows = [...local, ...extra];
12412
12467
  if (!spRows.length) {
12413
12468
  spIdx = -1;
12414
- const empty = found.pending
12415
- ? 'Searching all sessions…'
12416
- : spSource.length || query.length >= SESSION_SEARCH_MIN
12417
- ? 'No session matches'
12418
- : 'No sessions in the current sidebar filter';
12469
+ spRowEls = [];
12470
+ const empty =
12471
+ found.pending || (all && spRecent.loading)
12472
+ ? 'Searching all sessions…'
12473
+ : all || spSource.length || query.length >= SESSION_SEARCH_MIN
12474
+ ? 'No session matches'
12475
+ : 'No sessions in the current sidebar filter';
12419
12476
  list.innerHTML = `<div class="sp-empty">${empty}</div>`;
12420
12477
  return;
12421
12478
  }
12422
12479
 
12423
- // Rows past the sidebar list come from the server search and show their full project path.
12480
+ // Rows not on the board show their full project path; in the board scope they also sit below
12481
+ // the board's rows, under a label.
12482
+ const section = !all && extra.length ? '<div class="sp-section">Not on board</div>' : '';
12424
12483
  list.innerHTML = spRows
12425
12484
  .map((s, i) => {
12426
- const outside = i >= local.length;
12485
+ const outside = !board.has(s.id);
12486
+ const below = !all && i >= local.length;
12427
12487
  const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
12428
- return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
12488
+ return `${below && i === local.length ? section : ''}<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${below ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
12429
12489
  ${spDotHtml(s)}
12430
12490
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
12431
12491
  <span class="sp-project">${escapeHtml(project)}</span>
@@ -12436,18 +12496,21 @@ function renderSessionPicker() {
12436
12496
  </button>`;
12437
12497
  })
12438
12498
  .join('');
12499
+ // The section label sits between rows, so row index and child index differ.
12500
+ spRowEls = list.querySelectorAll('.sp-row');
12501
+ spIdx = -1;
12439
12502
  const keep = spRows.findIndex((s) => s.id === selectedId);
12440
12503
  spSelect(Math.max(0, keep));
12441
12504
  }
12442
12505
 
12443
12506
  function spSelect(idx) {
12444
- const list = document.getElementById('session-picker-list');
12445
- list.children[spIdx]?.classList.remove('selected');
12507
+ spRowEls[spIdx]?.classList.remove('selected');
12446
12508
  spIdx = Math.min(Math.max(idx, 0), spRows.length - 1);
12447
- const row = list.children[spIdx];
12509
+ const row = spRowEls[spIdx];
12448
12510
  row?.classList.add('selected');
12449
12511
  row?.scrollIntoView({ block: 'nearest' });
12450
12512
  spSchedulePeek();
12513
+ if (spScope === 'all' && spIdx >= spRows.length - 5) spLoadRecent();
12451
12514
  }
12452
12515
 
12453
12516
  async function spOpen(idx) {
@@ -12468,14 +12531,22 @@ function initSessionPicker() {
12468
12531
  else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
12469
12532
  else if (e.key === 'Enter') spOpen(spIdx);
12470
12533
  else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
12534
+ else if (e.key === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey) spSetScope(spScope === 'all' ? 'board' : 'all');
12471
12535
  else return;
12472
12536
  e.preventDefault();
12473
12537
  e.stopPropagation();
12474
12538
  });
12475
- document.getElementById('session-picker-list').addEventListener('click', (e) => {
12539
+ const list = document.getElementById('session-picker-list');
12540
+ list.addEventListener('click', (e) => {
12476
12541
  const row = e.target.closest('.sp-row');
12477
12542
  if (row) spOpen(Number(row.dataset.idx));
12478
12543
  });
12544
+ list.addEventListener('scroll', spLoadMoreNearEnd, { passive: true });
12545
+ document.getElementById('session-picker-scope').addEventListener('click', (e) => {
12546
+ const scope = e.target.closest('[data-scope]')?.dataset.scope;
12547
+ if (scope) spSetScope(scope);
12548
+ input.focus();
12549
+ });
12479
12550
  // Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
12480
12551
  // A blur that no click in the picker caused, while the window keeps focus, is that Escape.
12481
12552
  const modal = document.getElementById('session-picker-modal');
@@ -15045,6 +15116,7 @@ function terminalShortcut(e, probe = false) {
15045
15116
  if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
15046
15117
  if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
15047
15118
  if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
15119
+ if (ctrlShift && e.code === 'Period') return currentSessionId ? () => toggleSessionSticky(currentSessionId) : null;
15048
15120
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
15049
15121
  return () => openNewSession(null, e.code === 'KeyR');
15050
15122
  }
@@ -15060,20 +15132,22 @@ function terminalShortcut(e, probe = false) {
15060
15132
  return null;
15061
15133
  }
15062
15134
 
15063
- function readSwapPair() {
15064
- const pair = readStoredList(SWAP_KEY);
15065
- return pair.length === 2 ? pair : [null, null];
15135
+ function setSwapHistory(ids) {
15136
+ swapHistory = [...new Set(ids.filter(Boolean))].slice(0, SWAP_HISTORY_MAX);
15137
+ store.writeJson(SWAP_KEY, swapHistory);
15066
15138
  }
15067
15139
 
15068
- function setSwapPair(last, previous) {
15069
- lastSessionId = last;
15070
- previousSessionId = previous;
15071
- store.writeJson(SWAP_KEY, [last, previous]);
15140
+ function pushSwapHistory(id) {
15141
+ if (swapHistory[0] !== id) setSwapHistory([id, ...swapHistory]);
15142
+ }
15143
+
15144
+ function forgetSwapHistory(id) {
15145
+ if (swapHistory.includes(id)) setSwapHistory(swapHistory.filter((x) => x !== id));
15072
15146
  }
15073
15147
 
15074
15148
  async function swapToPreviousSession() {
15075
- const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
15076
- if (!target || target === currentSessionId) return;
15149
+ const target = swapHistory.find((id) => id !== currentSessionId);
15150
+ if (!target) return;
15077
15151
  if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
15078
15152
  await revealSession(target);
15079
15153
  }
@@ -15365,6 +15439,7 @@ const TERMINAL_PROBE_KEYS = [
15365
15439
  ['Backquote', '`', '~'],
15366
15440
  ['Equal', '=', '+'],
15367
15441
  ['Minus', '-', '_'],
15442
+ ['Period', '.', '>'],
15368
15443
  ['NumpadAdd', '+', '+'],
15369
15444
  ['NumpadSubtract', '-', '-'],
15370
15445
  ['Numpad0', '0', '0'],
@@ -15733,8 +15808,7 @@ function terminalOpenMode(sessionId) {
15733
15808
  // replays the same screen, and the placeholder gives way to the real card.
15734
15809
  function adoptPickedSession(oldId, id) {
15735
15810
  const focused = terminalPaneFocused();
15736
- const adopt = (x) => (x === oldId ? id : x);
15737
- setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
15811
+ setSwapHistory(swapHistory.map((x) => (x === oldId ? id : x)));
15738
15812
  setTerminalMode(id, true);
15739
15813
  forgetPlaceholder(oldId);
15740
15814
  if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
@@ -16953,7 +17027,10 @@ const newSpecs = new Map();
16953
17027
  // Sessions another session started (`dispatch start`), shown where they will stay until
16954
17028
  // their transcript appears. Kept apart from newSpecs: their prompt is already sent.
16955
17029
  const dispatchSpecs = new Map();
16956
- const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
17030
+ const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false, images: [] };
17031
+ // Must match ATTACHMENT_TYPES and the upload limit in server.js.
17032
+ const NS_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
17033
+ const NS_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
16957
17034
  const NEW_SESSION_STAY_KEY = 'new-session-stay';
16958
17035
  const NEW_SESSION_EDIT_KEY = 'new-session-edit';
16959
17036
 
@@ -17055,8 +17132,7 @@ function forgetPlaceholder(id) {
17055
17132
  // Before the socket's close handler runs, or it would reconnect and start the session again.
17056
17133
  if (termState.sessionId === id) detachTerminal();
17057
17134
  sessions = sessions.filter((s) => s.id !== id);
17058
- const forget = (x) => (x === id ? null : x);
17059
- setSwapPair(forget(lastSessionId), forget(previousSessionId));
17135
+ forgetSwapHistory(id);
17060
17136
  return true;
17061
17137
  }
17062
17138
 
@@ -17157,6 +17233,7 @@ function openNewSession(folder, resume = false) {
17157
17233
  document.getElementById('ns-wt').checked = false;
17158
17234
  document.getElementById('ns-model').value = '';
17159
17235
  document.getElementById('ns-effort').value = '';
17236
+ clearNewSessionImages();
17160
17237
  document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
17161
17238
  document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
17162
17239
  setNewSessionError('');
@@ -17180,9 +17257,77 @@ function openNewSession(folder, resume = false) {
17180
17257
  function closeNewSession() {
17181
17258
  hideModalOverlay('new-session-modal');
17182
17259
  closeFolderList();
17260
+ closeImagePreview();
17261
+ clearNewSessionImages();
17183
17262
  promptVoice.rec?.abort();
17184
17263
  }
17185
17264
 
17265
+ function addNewSessionImages(files) {
17266
+ for (const file of files) {
17267
+ if (!NS_IMAGE_TYPES.includes(file.type)) {
17268
+ setNewSessionError(`${file.name || 'File'}: use a PNG, JPEG, GIF or WebP image.`);
17269
+ continue;
17270
+ }
17271
+ if (file.size > NS_IMAGE_MAX_BYTES) {
17272
+ setNewSessionError(`${file.name || 'Image'}: larger than ${NS_IMAGE_MAX_BYTES / 1024 / 1024} MB.`);
17273
+ continue;
17274
+ }
17275
+ ns.images.push({ file, url: URL.createObjectURL(file) });
17276
+ }
17277
+ renderNewSessionImages();
17278
+ }
17279
+
17280
+ function removeNewSessionImage(i) {
17281
+ const [img] = ns.images.splice(i, 1);
17282
+ if (img) URL.revokeObjectURL(img.url);
17283
+ renderNewSessionImages();
17284
+ }
17285
+
17286
+ function clearNewSessionImages() {
17287
+ for (const img of ns.images) URL.revokeObjectURL(img.url);
17288
+ ns.images = [];
17289
+ renderNewSessionImages();
17290
+ }
17291
+
17292
+ function renderNewSessionImages() {
17293
+ const strip = document.getElementById('ns-images');
17294
+ strip.hidden = !ns.images.length;
17295
+ strip.innerHTML = ns.images
17296
+ .map(
17297
+ (img, i) =>
17298
+ `<button type="button" class="ns-thumb" data-preview="${i}" title="${escapeHtml(img.file.name || 'Pasted image')}"><img src="${escapeHtml(img.url)}" alt=""><span class="ns-thumb-remove" data-remove="${i}" title="Remove" aria-label="Remove image">×</span></button>`,
17299
+ )
17300
+ .join('');
17301
+ }
17302
+
17303
+ function openImagePreview(i) {
17304
+ const img = ns.images[i];
17305
+ if (!img) return;
17306
+ const box = document.getElementById('ns-lightbox');
17307
+ box.querySelector('img').src = img.url;
17308
+ box.hidden = false;
17309
+ }
17310
+
17311
+ function closeImagePreview() {
17312
+ const box = document.getElementById('ns-lightbox');
17313
+ if (box.hidden) return false;
17314
+ box.hidden = true;
17315
+ box.querySelector('img').removeAttribute('src');
17316
+ return true;
17317
+ }
17318
+
17319
+ async function uploadNewSessionImage(sessionId, file) {
17320
+ const r = await newSessionPost(`/api/terminal/attachments/${encodeURIComponent(sessionId)}`, file, 'Upload failed');
17321
+ if (r.error) throw new Error(r.error);
17322
+ return r.path;
17323
+ }
17324
+
17325
+ async function withImagePaths(id, prompt) {
17326
+ const paths = await Promise.all(ns.images.map((img) => uploadNewSessionImage(id, img.file)));
17327
+ const list = `Attached images:\n${paths.map((p) => `- ${p}`).join('\n')}`;
17328
+ return prompt ? `${prompt}\n\n${list}` : list;
17329
+ }
17330
+
17186
17331
  function knownFolders() {
17187
17332
  return [...new Set([...ns.picked, ...ns.projects])];
17188
17333
  }
@@ -17342,10 +17487,17 @@ function fieldAfterFolder() {
17342
17487
  return ns.resume ? 'ns-start' : 'ns-name';
17343
17488
  }
17344
17489
 
17345
- function startNewSession() {
17490
+ async function startNewSession() {
17346
17491
  const v = newSessionValues();
17347
17492
  if (!v.cwd || newSessionProblem(v) || ns.busy) return;
17348
17493
  const id = crypto.randomUUID();
17494
+ if (!ns.resume && ns.images.length) {
17495
+ setNewSessionBusy(true);
17496
+ const prompt = await withImagePaths(id, v.prompt).catch((e) => e);
17497
+ setNewSessionBusy(false);
17498
+ if (prompt instanceof Error) return setNewSessionError(prompt.message);
17499
+ v.prompt = prompt;
17500
+ }
17349
17501
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
17350
17502
  setTerminalMode(id, true);
17351
17503
  if (v.stay) return startNewSessionInBackground(id, v);
@@ -17484,11 +17636,35 @@ function initNewSession() {
17484
17636
  mic.addEventListener('mousedown', (e) => e.preventDefault());
17485
17637
  mic.addEventListener('click', togglePromptVoice);
17486
17638
  }
17639
+ const prompt = document.getElementById('ns-prompt');
17640
+ const promptField = prompt.closest('.ns-prompt-field');
17641
+ prompt.addEventListener('paste', (e) => {
17642
+ const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'));
17643
+ if (!files.length) return;
17644
+ e.preventDefault();
17645
+ addNewSessionImages(files);
17646
+ });
17647
+ promptField.addEventListener('dragover', (e) => {
17648
+ if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
17649
+ });
17650
+ promptField.addEventListener('drop', (e) => {
17651
+ if (!e.dataTransfer?.files.length) return;
17652
+ e.preventDefault();
17653
+ addNewSessionImages(e.dataTransfer.files);
17654
+ });
17655
+ document.getElementById('ns-images').addEventListener('click', (e) => {
17656
+ const remove = e.target.closest('[data-remove]');
17657
+ if (remove) return removeNewSessionImage(Number(remove.dataset.remove));
17658
+ const thumb = e.target.closest('[data-preview]');
17659
+ if (thumb) openImagePreview(Number(thumb.dataset.preview));
17660
+ });
17661
+ document.getElementById('ns-lightbox').addEventListener('click', closeImagePreview);
17487
17662
  // Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
17488
17663
  modal.addEventListener('keydown', (e) => {
17489
17664
  const inFolder = e.target === input;
17490
17665
  if (e.key === 'Escape') {
17491
- if (folderListOpen()) closeFolderList();
17666
+ if (closeImagePreview()) prompt.focus();
17667
+ else if (folderListOpen()) closeFolderList();
17492
17668
  else closeNewSession();
17493
17669
  } else if (inFolder && matchKey(e, 'ArrowDown')) {
17494
17670
  if (!folderListOpen()) renderFolderList();
@@ -17497,6 +17673,8 @@ function initNewSession() {
17497
17673
  moveFolderHighlight(-1);
17498
17674
  } else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
17499
17675
  pickFolderOption(ns.idx);
17676
+ } else if (e.target === prompt && e.key === 'Backspace' && !prompt.value && ns.images.length) {
17677
+ removeNewSessionImage(ns.images.length - 1);
17500
17678
  } else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
17501
17679
  if (e.target.tagName === 'BUTTON') return;
17502
17680
  startNewSession();
package/public/index.html CHANGED
@@ -812,10 +812,11 @@
812
812
  <div class="ns-row ns-extra">
813
813
  <label class="ns-label" for="ns-prompt">Prompt</label>
814
814
  <div class="ns-field ns-prompt-field">
815
- <textarea id="ns-prompt" class="form-input" rows="4" placeholder="Optional. Sent as the first message."></textarea>
815
+ <textarea id="ns-prompt" class="form-input" rows="4" placeholder="Optional. Sent as the first message. Paste or drop images to attach."></textarea>
816
816
  <button type="button" id="ns-mic" class="ns-mic" title="Dictate" aria-label="Dictate" aria-pressed="false" hidden>
817
817
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>
818
818
  </button>
819
+ <div id="ns-images" class="ns-images" hidden></div>
819
820
  </div>
820
821
  </div>
821
822
  <div class="ns-row ns-extra">
@@ -864,6 +865,7 @@
864
865
  <button type="button" class="btn btn-secondary" onclick="closeNewSession()">Cancel</button>
865
866
  <button type="button" id="ns-start" class="btn btn-primary" onclick="startNewSession()" disabled>Start</button>
866
867
  </div>
868
+ <div id="ns-lightbox" class="ns-lightbox" hidden><img alt=""></div>
867
869
  </div>
868
870
  </div>
869
871
 
@@ -885,12 +887,18 @@
885
887
 
886
888
  <div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
887
889
  <div class="modal session-picker" onclick="event.stopPropagation()">
888
- <input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
890
+ <div class="sp-head">
891
+ <input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
892
+ <div id="session-picker-scope" class="sp-scope" role="radiogroup" aria-label="Sessions" title="Tab switches scope">
893
+ <button type="button" role="radio" tabindex="-1" data-scope="board">Board</button>
894
+ <button type="button" role="radio" tabindex="-1" data-scope="all">All</button>
895
+ </div>
896
+ </div>
889
897
  <div class="sp-body">
890
898
  <div id="session-picker-list" class="sp-list"></div>
891
899
  <div id="session-picker-peek" class="sp-peek"></div>
892
900
  </div>
893
- <div class="sp-hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> move &middot; <kbd>Enter</kbd> open &middot; <kbd>Ctrl</kbd><kbd>Space</kbd> peek &middot; <kbd>Esc</kbd> close</div>
901
+ <div class="sp-hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> move &middot; <kbd>Enter</kbd> open &middot; <kbd>Tab</kbd> board / all &middot; <kbd>Ctrl</kbd><kbd>Space</kbd> peek &middot; <kbd>Esc</kbd> close</div>
894
902
  </div>
895
903
  </div>
896
904
 
package/public/style.css CHANGED
@@ -8746,6 +8746,54 @@ select.form-input option:checked {
8746
8746
  overflow: hidden;
8747
8747
  }
8748
8748
 
8749
+ .sp-head {
8750
+ display: flex;
8751
+ align-items: center;
8752
+ gap: 8px;
8753
+ }
8754
+
8755
+ .sp-head .sp-input {
8756
+ flex: 1;
8757
+ }
8758
+
8759
+ .sp-scope {
8760
+ flex: none;
8761
+ display: flex;
8762
+ gap: 2px;
8763
+ padding: 2px;
8764
+ border: 1px solid var(--border);
8765
+ border-radius: 6px;
8766
+ }
8767
+
8768
+ .sp-scope button {
8769
+ padding: 4px 10px;
8770
+ background: none;
8771
+ border: none;
8772
+ border-radius: 4px;
8773
+ font-family: var(--mono);
8774
+ font-size: 11.5px;
8775
+ color: var(--text-muted);
8776
+ cursor: pointer;
8777
+ }
8778
+
8779
+ .sp-scope button:hover {
8780
+ color: var(--text-secondary);
8781
+ }
8782
+
8783
+ .sp-scope button[aria-checked='true'] {
8784
+ background: var(--bg-deep);
8785
+ color: var(--text-primary);
8786
+ }
8787
+
8788
+ .sp-section {
8789
+ margin-top: 8px;
8790
+ padding: 2px 8px;
8791
+ color: var(--text-muted);
8792
+ font-size: 10.5px;
8793
+ letter-spacing: 0.04em;
8794
+ text-transform: uppercase;
8795
+ }
8796
+
8749
8797
  .sp-input {
8750
8798
  padding: 10px 12px;
8751
8799
  background: var(--bg-deep);
@@ -8909,6 +8957,10 @@ select.form-input option:checked {
8909
8957
  margin-top: 8px;
8910
8958
  }
8911
8959
 
8960
+ .sp-section + .sp-row.outside {
8961
+ margin-top: 0;
8962
+ }
8963
+
8912
8964
  .sp-row.current .sp-name {
8913
8965
  color: var(--accent-text);
8914
8966
  }
@@ -9224,6 +9276,81 @@ select.form-input option:checked {
9224
9276
  }
9225
9277
  }
9226
9278
 
9279
+ .ns-images {
9280
+ display: flex;
9281
+ flex-wrap: wrap;
9282
+ gap: 8px;
9283
+ margin-top: 8px;
9284
+ }
9285
+
9286
+ .ns-images[hidden] {
9287
+ display: none;
9288
+ }
9289
+
9290
+ .ns-thumb {
9291
+ position: relative;
9292
+ width: 64px;
9293
+ height: 64px;
9294
+ padding: 0;
9295
+ overflow: hidden;
9296
+ background: var(--bg-hover);
9297
+ border: 1px solid var(--border);
9298
+ border-radius: 6px;
9299
+ cursor: zoom-in;
9300
+ }
9301
+
9302
+ .ns-thumb img {
9303
+ width: 100%;
9304
+ height: 100%;
9305
+ object-fit: cover;
9306
+ display: block;
9307
+ }
9308
+
9309
+ .ns-thumb-remove {
9310
+ position: absolute;
9311
+ top: 2px;
9312
+ right: 2px;
9313
+ display: flex;
9314
+ align-items: center;
9315
+ justify-content: center;
9316
+ width: 18px;
9317
+ height: 18px;
9318
+ font-size: 13px;
9319
+ line-height: 1;
9320
+ color: #fff;
9321
+ background: rgba(0, 0, 0, 0.6);
9322
+ border-radius: 50%;
9323
+ cursor: pointer;
9324
+ }
9325
+
9326
+ .ns-thumb-remove:hover {
9327
+ background: var(--danger);
9328
+ }
9329
+
9330
+ .ns-lightbox {
9331
+ position: fixed;
9332
+ inset: 0;
9333
+ z-index: 10000;
9334
+ display: flex;
9335
+ align-items: center;
9336
+ justify-content: center;
9337
+ padding: 32px;
9338
+ background: rgba(0, 0, 0, 0.75);
9339
+ cursor: zoom-out;
9340
+ }
9341
+
9342
+ .ns-lightbox[hidden] {
9343
+ display: none;
9344
+ }
9345
+
9346
+ .ns-lightbox img {
9347
+ max-width: 100%;
9348
+ max-height: 100%;
9349
+ object-fit: contain;
9350
+ border-radius: 4px;
9351
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
9352
+ }
9353
+
9227
9354
  .ns-error {
9228
9355
  font-size: 12px;
9229
9356
  color: var(--danger);
package/server.js CHANGED
@@ -115,6 +115,7 @@ const SESSIONS_DIR = path.join(CLAUDE_DIR, 'sessions');
115
115
  const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
116
116
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
117
117
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
118
+ const ATTACHMENTS_DIR = path.join(CCK_DIR, 'attachments');
118
119
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
119
120
  const USER_GROUPS_FILE = path.join(CCK_DIR, 'groups.json');
120
121
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
@@ -3386,6 +3387,30 @@ app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3386
3387
  res.status(201).json({ session: started.id, cwd: started.cwd });
3387
3388
  }));
3388
3389
 
3390
+ // The New session dialog's images. The prompt names the saved path, and claude reads the file.
3391
+ // The token check runs before the body parser, so a rejected upload is never buffered.
3392
+ const ATTACHMENT_TYPES = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp' };
3393
+ app.post('/api/terminal/attachments/:session', (req, res, next) => {
3394
+ const reason = terminal.unavailableReason();
3395
+ if (reason) return res.status(403).json({ error: reason });
3396
+ if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3397
+ if (!isUUID(req.params.session)) return res.status(400).json({ error: 'invalid session id' });
3398
+ res.locals.ext = ATTACHMENT_TYPES[req.get('content-type')];
3399
+ if (!res.locals.ext) return res.status(415).json({ error: 'use a PNG, JPEG, GIF or WebP image' });
3400
+ next();
3401
+ }, express.raw({ type: () => true, limit: '10mb' }), async (req, res) => {
3402
+ if (!req.body?.length) return res.status(400).json({ error: 'empty image' });
3403
+ const dir = path.join(ATTACHMENTS_DIR, req.params.session);
3404
+ const file = path.join(dir, `${Date.now()}-${crypto.randomBytes(3).toString('hex')}.${res.locals.ext}`);
3405
+ try {
3406
+ await fs.mkdir(dir, { recursive: true });
3407
+ await fs.writeFile(file, req.body);
3408
+ res.status(201).json({ path: file });
3409
+ } catch (e) {
3410
+ res.status(500).json({ error: e.message });
3411
+ }
3412
+ });
3413
+
3389
3414
  // The hub's eviction check reads this to keep a pool with live PTYs alive.
3390
3415
  app.get('/api/terminals', terminalRoute(async (_req, res) => {
3391
3416
  res.setHeader('Cache-Control', 'no-store');
@@ -4783,7 +4808,7 @@ async function cleanupAgentActivity() {
4783
4808
  } catch { /* agent-activity dir may not exist */ }
4784
4809
  }
4785
4810
 
4786
- // Dispatch markers, pane layouts, reviews, context status and worktrees: see docs/retention.md.
4811
+ // Dispatch markers, pane layouts, reviews, attachments, context status and worktrees: see docs/retention.md.
4787
4812
  async function runRetention() {
4788
4813
  try {
4789
4814
  const scan = await scanTranscripts(PROJECTS_DIR);
@@ -4792,12 +4817,13 @@ async function runRetention() {
4792
4817
  const layouts = panes.prune(opts);
4793
4818
  if (layouts) queuePaneFileWatch();
4794
4819
  const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
4820
+ const attachments = await pruneSessionDirs(ATTACHMENTS_DIR, opts);
4795
4821
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4796
4822
  const wts = worktrees.prune(scan?.dirs);
4797
4823
  const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4798
4824
  const grouped = expireGroupMembers(scan?.ids);
4799
- if (markers || layouts || reviews || contexts || wts || taskMaps || grouped.members) {
4800
- console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4825
+ if (markers || layouts || reviews || attachments || contexts || wts || taskMaps || grouped.members) {
4826
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${attachments} attachments, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4801
4827
  }
4802
4828
  } catch (e) {
4803
4829
  console.warn('[retention] failed:', e.message);