claude-code-kanban 7.15.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/package.json +1 -1
- package/public/app.js +129 -23
- package/public/index.html +3 -1
- package/public/style.css +75 -0
- package/server.js +29 -3
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
|
|
|
@@ -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();
|
|
@@ -7591,9 +7593,8 @@ function dismissSessionFromList(sid) {
|
|
|
7591
7593
|
return;
|
|
7592
7594
|
}
|
|
7593
7595
|
// Ctrl+Alt+S would reveal the dismissed session, and revealing restores it.
|
|
7594
|
-
const
|
|
7595
|
-
|
|
7596
|
-
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
7596
|
+
const fallback = currentSessionId === sid && swapHistory[0] === sid ? swapHistory[1] : null;
|
|
7597
|
+
forgetSwapHistory(sid);
|
|
7597
7598
|
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
7598
7599
|
// If the dismissed session is currently open, navigate to the previous one
|
|
7599
7600
|
if (currentSessionId === sid || selectedSessionId === sid) {
|
|
@@ -15131,20 +15132,22 @@ function terminalShortcut(e, probe = false) {
|
|
|
15131
15132
|
return null;
|
|
15132
15133
|
}
|
|
15133
15134
|
|
|
15134
|
-
function
|
|
15135
|
-
|
|
15136
|
-
|
|
15135
|
+
function setSwapHistory(ids) {
|
|
15136
|
+
swapHistory = [...new Set(ids.filter(Boolean))].slice(0, SWAP_HISTORY_MAX);
|
|
15137
|
+
store.writeJson(SWAP_KEY, swapHistory);
|
|
15137
15138
|
}
|
|
15138
15139
|
|
|
15139
|
-
function
|
|
15140
|
-
|
|
15141
|
-
|
|
15142
|
-
|
|
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));
|
|
15143
15146
|
}
|
|
15144
15147
|
|
|
15145
15148
|
async function swapToPreviousSession() {
|
|
15146
|
-
const target =
|
|
15147
|
-
if (!target
|
|
15149
|
+
const target = swapHistory.find((id) => id !== currentSessionId);
|
|
15150
|
+
if (!target) return;
|
|
15148
15151
|
if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
|
|
15149
15152
|
await revealSession(target);
|
|
15150
15153
|
}
|
|
@@ -15805,8 +15808,7 @@ function terminalOpenMode(sessionId) {
|
|
|
15805
15808
|
// replays the same screen, and the placeholder gives way to the real card.
|
|
15806
15809
|
function adoptPickedSession(oldId, id) {
|
|
15807
15810
|
const focused = terminalPaneFocused();
|
|
15808
|
-
|
|
15809
|
-
setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
|
|
15811
|
+
setSwapHistory(swapHistory.map((x) => (x === oldId ? id : x)));
|
|
15810
15812
|
setTerminalMode(id, true);
|
|
15811
15813
|
forgetPlaceholder(oldId);
|
|
15812
15814
|
if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
|
|
@@ -17025,7 +17027,10 @@ const newSpecs = new Map();
|
|
|
17025
17027
|
// Sessions another session started (`dispatch start`), shown where they will stay until
|
|
17026
17028
|
// their transcript appears. Kept apart from newSpecs: their prompt is already sent.
|
|
17027
17029
|
const dispatchSpecs = new Map();
|
|
17028
|
-
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;
|
|
17029
17034
|
const NEW_SESSION_STAY_KEY = 'new-session-stay';
|
|
17030
17035
|
const NEW_SESSION_EDIT_KEY = 'new-session-edit';
|
|
17031
17036
|
|
|
@@ -17127,8 +17132,7 @@ function forgetPlaceholder(id) {
|
|
|
17127
17132
|
// Before the socket's close handler runs, or it would reconnect and start the session again.
|
|
17128
17133
|
if (termState.sessionId === id) detachTerminal();
|
|
17129
17134
|
sessions = sessions.filter((s) => s.id !== id);
|
|
17130
|
-
|
|
17131
|
-
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
17135
|
+
forgetSwapHistory(id);
|
|
17132
17136
|
return true;
|
|
17133
17137
|
}
|
|
17134
17138
|
|
|
@@ -17229,6 +17233,7 @@ function openNewSession(folder, resume = false) {
|
|
|
17229
17233
|
document.getElementById('ns-wt').checked = false;
|
|
17230
17234
|
document.getElementById('ns-model').value = '';
|
|
17231
17235
|
document.getElementById('ns-effort').value = '';
|
|
17236
|
+
clearNewSessionImages();
|
|
17232
17237
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
17233
17238
|
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
17234
17239
|
setNewSessionError('');
|
|
@@ -17252,9 +17257,77 @@ function openNewSession(folder, resume = false) {
|
|
|
17252
17257
|
function closeNewSession() {
|
|
17253
17258
|
hideModalOverlay('new-session-modal');
|
|
17254
17259
|
closeFolderList();
|
|
17260
|
+
closeImagePreview();
|
|
17261
|
+
clearNewSessionImages();
|
|
17255
17262
|
promptVoice.rec?.abort();
|
|
17256
17263
|
}
|
|
17257
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
|
+
|
|
17258
17331
|
function knownFolders() {
|
|
17259
17332
|
return [...new Set([...ns.picked, ...ns.projects])];
|
|
17260
17333
|
}
|
|
@@ -17414,10 +17487,17 @@ function fieldAfterFolder() {
|
|
|
17414
17487
|
return ns.resume ? 'ns-start' : 'ns-name';
|
|
17415
17488
|
}
|
|
17416
17489
|
|
|
17417
|
-
function startNewSession() {
|
|
17490
|
+
async function startNewSession() {
|
|
17418
17491
|
const v = newSessionValues();
|
|
17419
17492
|
if (!v.cwd || newSessionProblem(v) || ns.busy) return;
|
|
17420
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
|
+
}
|
|
17421
17501
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
17422
17502
|
setTerminalMode(id, true);
|
|
17423
17503
|
if (v.stay) return startNewSessionInBackground(id, v);
|
|
@@ -17556,11 +17636,35 @@ function initNewSession() {
|
|
|
17556
17636
|
mic.addEventListener('mousedown', (e) => e.preventDefault());
|
|
17557
17637
|
mic.addEventListener('click', togglePromptVoice);
|
|
17558
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);
|
|
17559
17662
|
// Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
|
|
17560
17663
|
modal.addEventListener('keydown', (e) => {
|
|
17561
17664
|
const inFolder = e.target === input;
|
|
17562
17665
|
if (e.key === 'Escape') {
|
|
17563
|
-
if (
|
|
17666
|
+
if (closeImagePreview()) prompt.focus();
|
|
17667
|
+
else if (folderListOpen()) closeFolderList();
|
|
17564
17668
|
else closeNewSession();
|
|
17565
17669
|
} else if (inFolder && matchKey(e, 'ArrowDown')) {
|
|
17566
17670
|
if (!folderListOpen()) renderFolderList();
|
|
@@ -17569,6 +17673,8 @@ function initNewSession() {
|
|
|
17569
17673
|
moveFolderHighlight(-1);
|
|
17570
17674
|
} else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
|
|
17571
17675
|
pickFolderOption(ns.idx);
|
|
17676
|
+
} else if (e.target === prompt && e.key === 'Backspace' && !prompt.value && ns.images.length) {
|
|
17677
|
+
removeNewSessionImage(ns.images.length - 1);
|
|
17572
17678
|
} else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
|
|
17573
17679
|
if (e.target.tagName === 'BUTTON') return;
|
|
17574
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
|
|
package/public/style.css
CHANGED
|
@@ -9276,6 +9276,81 @@ select.form-input option:checked {
|
|
|
9276
9276
|
}
|
|
9277
9277
|
}
|
|
9278
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
|
+
|
|
9279
9354
|
.ns-error {
|
|
9280
9355
|
font-size: 12px;
|
|
9281
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);
|