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 +28 -3
- package/package.json +1 -1
- package/public/app.js +224 -46
- package/public/index.html +11 -3
- package/public/style.css +127 -0
- package/server.js +29 -3
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
|
-
|
|
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
|
|
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
|
|
710
|
+
return mtimeMs;
|
|
686
711
|
}
|
|
687
712
|
|
|
688
713
|
const sessionCachesDirty = () => sessionCacheDirty;
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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 } =
|
|
423
|
-
if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX)
|
|
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
|
-
|
|
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
|
|
7594
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
12407
|
-
const
|
|
12408
|
-
|
|
12409
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12415
|
-
|
|
12416
|
-
|
|
12417
|
-
? '
|
|
12418
|
-
:
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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')
|
|
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
|
|
15064
|
-
|
|
15065
|
-
|
|
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
|
|
15069
|
-
|
|
15070
|
-
|
|
15071
|
-
|
|
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 =
|
|
15076
|
-
if (!target
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
<
|
|
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>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Ctrl</kbd><kbd>Space</kbd> peek · <kbd>Esc</kbd> close</div>
|
|
901
|
+
<div class="sp-hint"><kbd>↑</kbd><kbd>↓</kbd> move · <kbd>Enter</kbd> open · <kbd>Tab</kbd> board / all · <kbd>Ctrl</kbd><kbd>Space</kbd> peek · <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);
|