claude-code-kanban 7.15.0 → 7.17.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 +2 -1
- package/public/app.js +210 -45
- package/public/index.html +3 -1
- package/public/style.css +86 -2
- package/public/terminal-frame.js +52 -1
- package/public/terminal.html +1 -0
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +30 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.17.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",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"@xterm/addon-fit": "0.11.0",
|
|
40
40
|
"@xterm/addon-serialize": "0.14.0",
|
|
41
41
|
"@xterm/addon-unicode11": "0.9.0",
|
|
42
|
+
"@xterm/addon-web-links": "0.12.0",
|
|
42
43
|
"@xterm/addon-webgl": "0.19.0",
|
|
43
44
|
"@xterm/headless": "6.0.0",
|
|
44
45
|
"@xterm/xterm": "6.0.0",
|
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();
|
|
@@ -7501,7 +7503,13 @@ function _findOrphanedKeys(known) {
|
|
|
7501
7503
|
const orphaned = [];
|
|
7502
7504
|
for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
|
|
7503
7505
|
for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
|
|
7504
|
-
const sessionKeyPrefixes = [
|
|
7506
|
+
const sessionKeyPrefixes = [
|
|
7507
|
+
PINNED_MESSAGES_PREFIX,
|
|
7508
|
+
PAD_EMOJI_PREFIX,
|
|
7509
|
+
PREVIEW_STORAGE_PREFIX,
|
|
7510
|
+
PAD_LINKED_PREFIX,
|
|
7511
|
+
DROPPED_ARTIFACTS_PREFIX,
|
|
7512
|
+
];
|
|
7505
7513
|
for (const key of store.keys()) {
|
|
7506
7514
|
const pad = _parsePadKey(key);
|
|
7507
7515
|
if (pad?.kind === 'group') {
|
|
@@ -7591,9 +7599,8 @@ function dismissSessionFromList(sid) {
|
|
|
7591
7599
|
return;
|
|
7592
7600
|
}
|
|
7593
7601
|
// Ctrl+Alt+S would reveal the dismissed session, and revealing restores it.
|
|
7594
|
-
const
|
|
7595
|
-
|
|
7596
|
-
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
7602
|
+
const fallback = currentSessionId === sid && swapHistory[0] === sid ? swapHistory[1] : null;
|
|
7603
|
+
forgetSwapHistory(sid);
|
|
7597
7604
|
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
7598
7605
|
// If the dismissed session is currently open, navigate to the previous one
|
|
7599
7606
|
if (currentSessionId === sid || selectedSessionId === sid) {
|
|
@@ -9172,7 +9179,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
|
|
|
9172
9179
|
if (isUrlRef(href)) return;
|
|
9173
9180
|
const cleanHref = href.replace(/#.*$/, '');
|
|
9174
9181
|
e.preventDefault();
|
|
9175
|
-
openPreviewByPath(cleanHref,
|
|
9182
|
+
openPreviewByPath(cleanHref, baseOf(a), openFileInEditor);
|
|
9176
9183
|
});
|
|
9177
9184
|
bodyEl.dataset.relLinkBound = '1';
|
|
9178
9185
|
}
|
|
@@ -9674,8 +9681,34 @@ function artifactLabel(a) {
|
|
|
9674
9681
|
return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
|
|
9675
9682
|
}
|
|
9676
9683
|
|
|
9684
|
+
// The list comes from the transcript on every read, so a dropped artifact is kept out by URL.
|
|
9685
|
+
const DROPPED_ARTIFACTS_PREFIX = 'artifacts-dropped-';
|
|
9686
|
+
|
|
9687
|
+
function droppedArtifacts(sessionId) {
|
|
9688
|
+
return new Set(readStoredList(DROPPED_ARTIFACTS_PREFIX + sessionId));
|
|
9689
|
+
}
|
|
9690
|
+
|
|
9691
|
+
function setArtifactDropped(sessionId, url, dropped) {
|
|
9692
|
+
const urls = droppedArtifacts(sessionId);
|
|
9693
|
+
if (dropped) urls.add(url);
|
|
9694
|
+
else urls.delete(url);
|
|
9695
|
+
if (urls.size) store.writeJson(DROPPED_ARTIFACTS_PREFIX + sessionId, [...urls]);
|
|
9696
|
+
else store.removeItem(DROPPED_ARTIFACTS_PREFIX + sessionId);
|
|
9697
|
+
artifactsSection.repaint(sessionId);
|
|
9698
|
+
}
|
|
9699
|
+
|
|
9700
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9701
|
+
function dropArtifact(sessionId, url) {
|
|
9702
|
+
setArtifactDropped(sessionId, url, true);
|
|
9703
|
+
showToast('Artifact dropped from the list', 'info', {
|
|
9704
|
+
label: 'Undo',
|
|
9705
|
+
onClick: () => setArtifactDropped(sessionId, url, false),
|
|
9706
|
+
});
|
|
9707
|
+
}
|
|
9708
|
+
|
|
9677
9709
|
function artifactsInnerHtml(sessionId) {
|
|
9678
|
-
const
|
|
9710
|
+
const dropped = droppedArtifacts(sessionId);
|
|
9711
|
+
const list = artifactsSection.get(sessionId).filter((a) => !dropped.has(a.url));
|
|
9679
9712
|
if (!list.length) return '';
|
|
9680
9713
|
const items = list
|
|
9681
9714
|
.map(
|
|
@@ -9683,6 +9716,7 @@ function artifactsInnerHtml(sessionId) {
|
|
|
9683
9716
|
<a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
|
|
9684
9717
|
<span class="row-actions artifact-actions">
|
|
9685
9718
|
<button type="button" onclick="copyWithFeedback('${escAttrJs(a.url)}', this)" title="Copy link" aria-label="Copy artifact link">${ICON_COPY}</button>
|
|
9719
|
+
<button type="button" class="artifact-remove" onclick="dropArtifact('${escAttrJs(sessionId)}','${escAttrJs(a.url)}')" title="Drop from the list" aria-label="Drop ${escapeHtml(artifactLabel(a))} from the list">×</button>
|
|
9686
9720
|
</span>
|
|
9687
9721
|
</li>`,
|
|
9688
9722
|
)
|
|
@@ -11054,9 +11088,14 @@ function toRelativeIfUnder(filePath, baseDir) {
|
|
|
11054
11088
|
return fp.slice(bd.length + 1);
|
|
11055
11089
|
}
|
|
11056
11090
|
|
|
11091
|
+
// DOMPurify's default list plus `file:` and drive paths (`C:/x` reads as a scheme), so a link to a
|
|
11092
|
+
// local file survives and opens in the preview.
|
|
11093
|
+
const MARKDOWN_URI =
|
|
11094
|
+
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix|file):|[a-z]:[\\/]|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i;
|
|
11095
|
+
|
|
11057
11096
|
function renderMarkdown(text) {
|
|
11058
11097
|
if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
|
|
11059
|
-
return DOMPurify.sanitize(marked.parse(text));
|
|
11098
|
+
return DOMPurify.sanitize(marked.parse(text), { ALLOWED_URI_REGEXP: MARKDOWN_URI });
|
|
11060
11099
|
}
|
|
11061
11100
|
return `<pre style="white-space:pre-wrap;margin:0;">${escapeHtml(text)}</pre>`;
|
|
11062
11101
|
}
|
|
@@ -15131,20 +15170,22 @@ function terminalShortcut(e, probe = false) {
|
|
|
15131
15170
|
return null;
|
|
15132
15171
|
}
|
|
15133
15172
|
|
|
15134
|
-
function
|
|
15135
|
-
|
|
15136
|
-
|
|
15173
|
+
function setSwapHistory(ids) {
|
|
15174
|
+
swapHistory = [...new Set(ids.filter(Boolean))].slice(0, SWAP_HISTORY_MAX);
|
|
15175
|
+
store.writeJson(SWAP_KEY, swapHistory);
|
|
15137
15176
|
}
|
|
15138
15177
|
|
|
15139
|
-
function
|
|
15140
|
-
|
|
15141
|
-
|
|
15142
|
-
|
|
15178
|
+
function pushSwapHistory(id) {
|
|
15179
|
+
if (swapHistory[0] !== id) setSwapHistory([id, ...swapHistory]);
|
|
15180
|
+
}
|
|
15181
|
+
|
|
15182
|
+
function forgetSwapHistory(id) {
|
|
15183
|
+
if (swapHistory.includes(id)) setSwapHistory(swapHistory.filter((x) => x !== id));
|
|
15143
15184
|
}
|
|
15144
15185
|
|
|
15145
15186
|
async function swapToPreviousSession() {
|
|
15146
|
-
const target =
|
|
15147
|
-
if (!target
|
|
15187
|
+
const target = swapHistory.find((id) => id !== currentSessionId);
|
|
15188
|
+
if (!target) return;
|
|
15148
15189
|
if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
|
|
15149
15190
|
await revealSession(target);
|
|
15150
15191
|
}
|
|
@@ -15374,6 +15415,12 @@ function terminalInitOptions(themeOptions) {
|
|
|
15374
15415
|
};
|
|
15375
15416
|
}
|
|
15376
15417
|
|
|
15418
|
+
function openTerminalLink(href) {
|
|
15419
|
+
if (typeof href === 'string' && isLocalHref(href)) return openLocalHref(href);
|
|
15420
|
+
const url = linkUrl(href);
|
|
15421
|
+
if (url) hub.openExternal(url);
|
|
15422
|
+
}
|
|
15423
|
+
|
|
15377
15424
|
function onTerminalFrameMessage(t, m) {
|
|
15378
15425
|
const socket = termState.socket;
|
|
15379
15426
|
const current = socket && m.socketId === socket.id;
|
|
@@ -15393,6 +15440,7 @@ function onTerminalFrameMessage(t, m) {
|
|
|
15393
15440
|
reconnectTerminal();
|
|
15394
15441
|
}
|
|
15395
15442
|
} else if (t === 'key') replayKey(m, document.getElementById('terminal-key-proxy'));
|
|
15443
|
+
else if (t === 'link') openTerminalLink(m.href);
|
|
15396
15444
|
// Chrome does not always fire focusin on the iframe element when its content takes focus.
|
|
15397
15445
|
else if (t === 'focused') terminalHadFocus = terminalPaneFocused();
|
|
15398
15446
|
else if (t === 'opening') {
|
|
@@ -15805,8 +15853,7 @@ function terminalOpenMode(sessionId) {
|
|
|
15805
15853
|
// replays the same screen, and the placeholder gives way to the real card.
|
|
15806
15854
|
function adoptPickedSession(oldId, id) {
|
|
15807
15855
|
const focused = terminalPaneFocused();
|
|
15808
|
-
|
|
15809
|
-
setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
|
|
15856
|
+
setSwapHistory(swapHistory.map((x) => (x === oldId ? id : x)));
|
|
15810
15857
|
setTerminalMode(id, true);
|
|
15811
15858
|
forgetPlaceholder(oldId);
|
|
15812
15859
|
if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
|
|
@@ -17025,7 +17072,10 @@ const newSpecs = new Map();
|
|
|
17025
17072
|
// Sessions another session started (`dispatch start`), shown where they will stay until
|
|
17026
17073
|
// their transcript appears. Kept apart from newSpecs: their prompt is already sent.
|
|
17027
17074
|
const dispatchSpecs = new Map();
|
|
17028
|
-
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
|
|
17075
|
+
const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false, images: [] };
|
|
17076
|
+
// Must match ATTACHMENT_TYPES and the upload limit in server.js.
|
|
17077
|
+
const NS_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
17078
|
+
const NS_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
|
|
17029
17079
|
const NEW_SESSION_STAY_KEY = 'new-session-stay';
|
|
17030
17080
|
const NEW_SESSION_EDIT_KEY = 'new-session-edit';
|
|
17031
17081
|
|
|
@@ -17127,8 +17177,7 @@ function forgetPlaceholder(id) {
|
|
|
17127
17177
|
// Before the socket's close handler runs, or it would reconnect and start the session again.
|
|
17128
17178
|
if (termState.sessionId === id) detachTerminal();
|
|
17129
17179
|
sessions = sessions.filter((s) => s.id !== id);
|
|
17130
|
-
|
|
17131
|
-
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
17180
|
+
forgetSwapHistory(id);
|
|
17132
17181
|
return true;
|
|
17133
17182
|
}
|
|
17134
17183
|
|
|
@@ -17229,6 +17278,7 @@ function openNewSession(folder, resume = false) {
|
|
|
17229
17278
|
document.getElementById('ns-wt').checked = false;
|
|
17230
17279
|
document.getElementById('ns-model').value = '';
|
|
17231
17280
|
document.getElementById('ns-effort').value = '';
|
|
17281
|
+
clearNewSessionImages();
|
|
17232
17282
|
document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
|
|
17233
17283
|
document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
|
|
17234
17284
|
setNewSessionError('');
|
|
@@ -17252,9 +17302,77 @@ function openNewSession(folder, resume = false) {
|
|
|
17252
17302
|
function closeNewSession() {
|
|
17253
17303
|
hideModalOverlay('new-session-modal');
|
|
17254
17304
|
closeFolderList();
|
|
17305
|
+
closeImagePreview();
|
|
17306
|
+
clearNewSessionImages();
|
|
17255
17307
|
promptVoice.rec?.abort();
|
|
17256
17308
|
}
|
|
17257
17309
|
|
|
17310
|
+
function addNewSessionImages(files) {
|
|
17311
|
+
for (const file of files) {
|
|
17312
|
+
if (!NS_IMAGE_TYPES.includes(file.type)) {
|
|
17313
|
+
setNewSessionError(`${file.name || 'File'}: use a PNG, JPEG, GIF or WebP image.`);
|
|
17314
|
+
continue;
|
|
17315
|
+
}
|
|
17316
|
+
if (file.size > NS_IMAGE_MAX_BYTES) {
|
|
17317
|
+
setNewSessionError(`${file.name || 'Image'}: larger than ${NS_IMAGE_MAX_BYTES / 1024 / 1024} MB.`);
|
|
17318
|
+
continue;
|
|
17319
|
+
}
|
|
17320
|
+
ns.images.push({ file, url: URL.createObjectURL(file) });
|
|
17321
|
+
}
|
|
17322
|
+
renderNewSessionImages();
|
|
17323
|
+
}
|
|
17324
|
+
|
|
17325
|
+
function removeNewSessionImage(i) {
|
|
17326
|
+
const [img] = ns.images.splice(i, 1);
|
|
17327
|
+
if (img) URL.revokeObjectURL(img.url);
|
|
17328
|
+
renderNewSessionImages();
|
|
17329
|
+
}
|
|
17330
|
+
|
|
17331
|
+
function clearNewSessionImages() {
|
|
17332
|
+
for (const img of ns.images) URL.revokeObjectURL(img.url);
|
|
17333
|
+
ns.images = [];
|
|
17334
|
+
renderNewSessionImages();
|
|
17335
|
+
}
|
|
17336
|
+
|
|
17337
|
+
function renderNewSessionImages() {
|
|
17338
|
+
const strip = document.getElementById('ns-images');
|
|
17339
|
+
strip.hidden = !ns.images.length;
|
|
17340
|
+
strip.innerHTML = ns.images
|
|
17341
|
+
.map(
|
|
17342
|
+
(img, i) =>
|
|
17343
|
+
`<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>`,
|
|
17344
|
+
)
|
|
17345
|
+
.join('');
|
|
17346
|
+
}
|
|
17347
|
+
|
|
17348
|
+
function openImagePreview(i) {
|
|
17349
|
+
const img = ns.images[i];
|
|
17350
|
+
if (!img) return;
|
|
17351
|
+
const box = document.getElementById('ns-lightbox');
|
|
17352
|
+
box.querySelector('img').src = img.url;
|
|
17353
|
+
box.hidden = false;
|
|
17354
|
+
}
|
|
17355
|
+
|
|
17356
|
+
function closeImagePreview() {
|
|
17357
|
+
const box = document.getElementById('ns-lightbox');
|
|
17358
|
+
if (box.hidden) return false;
|
|
17359
|
+
box.hidden = true;
|
|
17360
|
+
box.querySelector('img').removeAttribute('src');
|
|
17361
|
+
return true;
|
|
17362
|
+
}
|
|
17363
|
+
|
|
17364
|
+
async function uploadNewSessionImage(sessionId, file) {
|
|
17365
|
+
const r = await newSessionPost(`/api/terminal/attachments/${encodeURIComponent(sessionId)}`, file, 'Upload failed');
|
|
17366
|
+
if (r.error) throw new Error(r.error);
|
|
17367
|
+
return r.path;
|
|
17368
|
+
}
|
|
17369
|
+
|
|
17370
|
+
async function withImagePaths(id, prompt) {
|
|
17371
|
+
const paths = await Promise.all(ns.images.map((img) => uploadNewSessionImage(id, img.file)));
|
|
17372
|
+
const list = `Attached images:\n${paths.map((p) => `- ${p}`).join('\n')}`;
|
|
17373
|
+
return prompt ? `${prompt}\n\n${list}` : list;
|
|
17374
|
+
}
|
|
17375
|
+
|
|
17258
17376
|
function knownFolders() {
|
|
17259
17377
|
return [...new Set([...ns.picked, ...ns.projects])];
|
|
17260
17378
|
}
|
|
@@ -17414,10 +17532,17 @@ function fieldAfterFolder() {
|
|
|
17414
17532
|
return ns.resume ? 'ns-start' : 'ns-name';
|
|
17415
17533
|
}
|
|
17416
17534
|
|
|
17417
|
-
function startNewSession() {
|
|
17535
|
+
async function startNewSession() {
|
|
17418
17536
|
const v = newSessionValues();
|
|
17419
17537
|
if (!v.cwd || newSessionProblem(v) || ns.busy) return;
|
|
17420
17538
|
const id = crypto.randomUUID();
|
|
17539
|
+
if (!ns.resume && ns.images.length) {
|
|
17540
|
+
setNewSessionBusy(true);
|
|
17541
|
+
const prompt = await withImagePaths(id, v.prompt).catch((e) => e);
|
|
17542
|
+
setNewSessionBusy(false);
|
|
17543
|
+
if (prompt instanceof Error) return setNewSessionError(prompt.message);
|
|
17544
|
+
v.prompt = prompt;
|
|
17545
|
+
}
|
|
17421
17546
|
newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
|
|
17422
17547
|
setTerminalMode(id, true);
|
|
17423
17548
|
if (v.stay) return startNewSessionInBackground(id, v);
|
|
@@ -17556,11 +17681,35 @@ function initNewSession() {
|
|
|
17556
17681
|
mic.addEventListener('mousedown', (e) => e.preventDefault());
|
|
17557
17682
|
mic.addEventListener('click', togglePromptVoice);
|
|
17558
17683
|
}
|
|
17684
|
+
const prompt = document.getElementById('ns-prompt');
|
|
17685
|
+
const promptField = prompt.closest('.ns-prompt-field');
|
|
17686
|
+
prompt.addEventListener('paste', (e) => {
|
|
17687
|
+
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'));
|
|
17688
|
+
if (!files.length) return;
|
|
17689
|
+
e.preventDefault();
|
|
17690
|
+
addNewSessionImages(files);
|
|
17691
|
+
});
|
|
17692
|
+
promptField.addEventListener('dragover', (e) => {
|
|
17693
|
+
if (e.dataTransfer?.types.includes('Files')) e.preventDefault();
|
|
17694
|
+
});
|
|
17695
|
+
promptField.addEventListener('drop', (e) => {
|
|
17696
|
+
if (!e.dataTransfer?.files.length) return;
|
|
17697
|
+
e.preventDefault();
|
|
17698
|
+
addNewSessionImages(e.dataTransfer.files);
|
|
17699
|
+
});
|
|
17700
|
+
document.getElementById('ns-images').addEventListener('click', (e) => {
|
|
17701
|
+
const remove = e.target.closest('[data-remove]');
|
|
17702
|
+
if (remove) return removeNewSessionImage(Number(remove.dataset.remove));
|
|
17703
|
+
const thumb = e.target.closest('[data-preview]');
|
|
17704
|
+
if (thumb) openImagePreview(Number(thumb.dataset.preview));
|
|
17705
|
+
});
|
|
17706
|
+
document.getElementById('ns-lightbox').addEventListener('click', closeImagePreview);
|
|
17559
17707
|
// Bound on the dialog: the global handler returns early on INPUT and TEXTAREA targets.
|
|
17560
17708
|
modal.addEventListener('keydown', (e) => {
|
|
17561
17709
|
const inFolder = e.target === input;
|
|
17562
17710
|
if (e.key === 'Escape') {
|
|
17563
|
-
if (
|
|
17711
|
+
if (closeImagePreview()) prompt.focus();
|
|
17712
|
+
else if (folderListOpen()) closeFolderList();
|
|
17564
17713
|
else closeNewSession();
|
|
17565
17714
|
} else if (inFolder && matchKey(e, 'ArrowDown')) {
|
|
17566
17715
|
if (!folderListOpen()) renderFolderList();
|
|
@@ -17569,6 +17718,8 @@ function initNewSession() {
|
|
|
17569
17718
|
moveFolderHighlight(-1);
|
|
17570
17719
|
} else if (inFolder && e.key === 'Enter' && folderListOpen() && ns.idx >= 0 && ns.matches[ns.idx] !== ns.folder) {
|
|
17571
17720
|
pickFolderOption(ns.idx);
|
|
17721
|
+
} else if (e.target === prompt && e.key === 'Backspace' && !prompt.value && ns.images.length) {
|
|
17722
|
+
removeNewSessionImage(ns.images.length - 1);
|
|
17572
17723
|
} else if (e.key === 'Enter' && (e.target.tagName !== 'TEXTAREA' || e.ctrlKey || e.metaKey)) {
|
|
17573
17724
|
if (e.target.tagName === 'BUTTON') return;
|
|
17574
17725
|
startNewSession();
|
|
@@ -17986,13 +18137,6 @@ function publishSession() {
|
|
|
17986
18137
|
);
|
|
17987
18138
|
}
|
|
17988
18139
|
|
|
17989
|
-
// The help modal stops click propagation, so the document handler below never sees this link.
|
|
17990
|
-
// The service worker can pair a cached page that lacks the link with this script.
|
|
17991
|
-
document.getElementById('help-docs')?.addEventListener('click', (e) => {
|
|
17992
|
-
e.preventDefault();
|
|
17993
|
-
hub.openExternal(e.currentTarget.href);
|
|
17994
|
-
});
|
|
17995
|
-
|
|
17996
18140
|
function webUrl(href) {
|
|
17997
18141
|
let url;
|
|
17998
18142
|
try {
|
|
@@ -18003,17 +18147,38 @@ function webUrl(href) {
|
|
|
18003
18147
|
return url.protocol === 'http:' || url.protocol === 'https:' ? url : null;
|
|
18004
18148
|
}
|
|
18005
18149
|
|
|
18006
|
-
|
|
18007
|
-
|
|
18008
|
-
|
|
18009
|
-
|
|
18010
|
-
|
|
18011
|
-
|
|
18012
|
-
|
|
18013
|
-
|
|
18014
|
-
|
|
18015
|
-
|
|
18016
|
-
|
|
18150
|
+
function isLocalHref(href) {
|
|
18151
|
+
return /^file:/i.test(href) || (isAbsolutePath(href) && !href.startsWith('//'));
|
|
18152
|
+
}
|
|
18153
|
+
|
|
18154
|
+
function openLocalHref(href) {
|
|
18155
|
+
openPreviewByPath(href.replace(/#.*$/, ''), undefined, openFileInEditor);
|
|
18156
|
+
}
|
|
18157
|
+
|
|
18158
|
+
// Capture, because the modals stop a click's propagation, and stopped here so a link in a message
|
|
18159
|
+
// preview does not also open the row. Under the hub the SDK has already opened a web link
|
|
18160
|
+
// outside (defaultPrevented), and leaves a link to a hub app alone.
|
|
18161
|
+
document.addEventListener(
|
|
18162
|
+
'click',
|
|
18163
|
+
(e) => {
|
|
18164
|
+
if (e.button !== 0) return;
|
|
18165
|
+
const href = e.target.closest?.('a[href]')?.getAttribute('href');
|
|
18166
|
+
if (!href) return;
|
|
18167
|
+
const url = webUrl(href);
|
|
18168
|
+
const external = url && url.origin !== window.location.origin;
|
|
18169
|
+
if (e.defaultPrevented) {
|
|
18170
|
+
if (external) e.stopPropagation();
|
|
18171
|
+
return;
|
|
18172
|
+
}
|
|
18173
|
+
const local = isLocalHref(href);
|
|
18174
|
+
if (!local && (!external || hub.inHub)) return;
|
|
18175
|
+
e.preventDefault();
|
|
18176
|
+
e.stopPropagation();
|
|
18177
|
+
if (local) openLocalHref(href);
|
|
18178
|
+
else hub.openExternal(url.href);
|
|
18179
|
+
},
|
|
18180
|
+
true,
|
|
18181
|
+
);
|
|
18017
18182
|
|
|
18018
18183
|
(function initHubTheme() {
|
|
18019
18184
|
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|
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
|
@@ -7030,13 +7030,15 @@ body.show-resizing iframe {
|
|
|
7030
7030
|
}
|
|
7031
7031
|
|
|
7032
7032
|
/* The × is a glyph, not an svg, so .row-actions button svg does not size it. */
|
|
7033
|
-
.linked-doc-remove
|
|
7033
|
+
.linked-doc-remove,
|
|
7034
|
+
.artifact-remove {
|
|
7034
7035
|
font-family: var(--mono);
|
|
7035
7036
|
font-size: 15px;
|
|
7036
7037
|
line-height: 1;
|
|
7037
7038
|
}
|
|
7038
7039
|
|
|
7039
|
-
.linked-doc-actions .linked-doc-remove:hover
|
|
7040
|
+
.linked-doc-actions .linked-doc-remove:hover,
|
|
7041
|
+
.artifact-actions .artifact-remove:hover {
|
|
7040
7042
|
background: var(--bg-elevated);
|
|
7041
7043
|
border-color: var(--border);
|
|
7042
7044
|
color: var(--text-primary);
|
|
@@ -8134,6 +8136,13 @@ body.light .msg-assistant .msg-text {
|
|
|
8134
8136
|
padding: 8px 16px;
|
|
8135
8137
|
white-space: nowrap;
|
|
8136
8138
|
}
|
|
8139
|
+
|
|
8140
|
+
/* The modal focuses a button itself, and after a mouse click Chrome does not match
|
|
8141
|
+
:focus-visible for that, so Enter would act on a button with no ring. */
|
|
8142
|
+
.confirm-buttons .btn:focus {
|
|
8143
|
+
outline: 2px solid var(--accent);
|
|
8144
|
+
outline-offset: 2px;
|
|
8145
|
+
}
|
|
8137
8146
|
/* #endregion */
|
|
8138
8147
|
|
|
8139
8148
|
/* Reference card, not reading material: it stays out of the modal zoom, uses the app's
|
|
@@ -9276,6 +9285,81 @@ select.form-input option:checked {
|
|
|
9276
9285
|
}
|
|
9277
9286
|
}
|
|
9278
9287
|
|
|
9288
|
+
.ns-images {
|
|
9289
|
+
display: flex;
|
|
9290
|
+
flex-wrap: wrap;
|
|
9291
|
+
gap: 8px;
|
|
9292
|
+
margin-top: 8px;
|
|
9293
|
+
}
|
|
9294
|
+
|
|
9295
|
+
.ns-images[hidden] {
|
|
9296
|
+
display: none;
|
|
9297
|
+
}
|
|
9298
|
+
|
|
9299
|
+
.ns-thumb {
|
|
9300
|
+
position: relative;
|
|
9301
|
+
width: 64px;
|
|
9302
|
+
height: 64px;
|
|
9303
|
+
padding: 0;
|
|
9304
|
+
overflow: hidden;
|
|
9305
|
+
background: var(--bg-hover);
|
|
9306
|
+
border: 1px solid var(--border);
|
|
9307
|
+
border-radius: 6px;
|
|
9308
|
+
cursor: zoom-in;
|
|
9309
|
+
}
|
|
9310
|
+
|
|
9311
|
+
.ns-thumb img {
|
|
9312
|
+
width: 100%;
|
|
9313
|
+
height: 100%;
|
|
9314
|
+
object-fit: cover;
|
|
9315
|
+
display: block;
|
|
9316
|
+
}
|
|
9317
|
+
|
|
9318
|
+
.ns-thumb-remove {
|
|
9319
|
+
position: absolute;
|
|
9320
|
+
top: 2px;
|
|
9321
|
+
right: 2px;
|
|
9322
|
+
display: flex;
|
|
9323
|
+
align-items: center;
|
|
9324
|
+
justify-content: center;
|
|
9325
|
+
width: 18px;
|
|
9326
|
+
height: 18px;
|
|
9327
|
+
font-size: 13px;
|
|
9328
|
+
line-height: 1;
|
|
9329
|
+
color: #fff;
|
|
9330
|
+
background: rgba(0, 0, 0, 0.6);
|
|
9331
|
+
border-radius: 50%;
|
|
9332
|
+
cursor: pointer;
|
|
9333
|
+
}
|
|
9334
|
+
|
|
9335
|
+
.ns-thumb-remove:hover {
|
|
9336
|
+
background: var(--danger);
|
|
9337
|
+
}
|
|
9338
|
+
|
|
9339
|
+
.ns-lightbox {
|
|
9340
|
+
position: fixed;
|
|
9341
|
+
inset: 0;
|
|
9342
|
+
z-index: 10000;
|
|
9343
|
+
display: flex;
|
|
9344
|
+
align-items: center;
|
|
9345
|
+
justify-content: center;
|
|
9346
|
+
padding: 32px;
|
|
9347
|
+
background: rgba(0, 0, 0, 0.75);
|
|
9348
|
+
cursor: zoom-out;
|
|
9349
|
+
}
|
|
9350
|
+
|
|
9351
|
+
.ns-lightbox[hidden] {
|
|
9352
|
+
display: none;
|
|
9353
|
+
}
|
|
9354
|
+
|
|
9355
|
+
.ns-lightbox img {
|
|
9356
|
+
max-width: 100%;
|
|
9357
|
+
max-height: 100%;
|
|
9358
|
+
object-fit: contain;
|
|
9359
|
+
border-radius: 4px;
|
|
9360
|
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
|
9361
|
+
}
|
|
9362
|
+
|
|
9279
9363
|
.ns-error {
|
|
9280
9364
|
font-size: 12px;
|
|
9281
9365
|
color: var(--danger);
|
package/public/terminal-frame.js
CHANGED
|
@@ -48,6 +48,39 @@
|
|
|
48
48
|
return lines.join('\n');
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const PATH_ROOT = String.raw`(?:[A-Za-z]:[\\/]|\/(?=[\w.@+-]+\/)|\\\\(?=[\w.$-]+\\))`;
|
|
52
|
+
// A quoted path may hold spaces; a bare one ends at the first.
|
|
53
|
+
const PATH_RE = new RegExp(
|
|
54
|
+
[
|
|
55
|
+
String.raw`"(${PATH_ROOT}[^"<>|*?]*[^\s"<>|*?])"`,
|
|
56
|
+
String.raw`'(${PATH_ROOT}[^'<>|*?]*[^\s'<>|*?])'`,
|
|
57
|
+
String.raw`(?<![\w/\\:.~-])${PATH_ROOT}[^\s"'<>|*?${'`'}]*[^\s"'<>|*?${'`'}.,;:!)\]}]`,
|
|
58
|
+
].join('|'),
|
|
59
|
+
'g',
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
function pathsIn(text) {
|
|
63
|
+
return Array.from(text.matchAll(PATH_RE), (m) => {
|
|
64
|
+
const quoted = m[1] ?? m[2];
|
|
65
|
+
const found = quoted ?? m[0];
|
|
66
|
+
return { index: m.index + (quoted ? 1 : 0), text: found, path: found.replace(/(?::\d+){1,2}$/, '') };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Cell x of each character, because a wide character spans two cells and translateToString counts it once.
|
|
71
|
+
function lineCells(line, cols, reuse) {
|
|
72
|
+
let text = '';
|
|
73
|
+
const xs = [];
|
|
74
|
+
for (let x = 0; x < cols; x++) {
|
|
75
|
+
const cell = line.getCell(x, reuse);
|
|
76
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
77
|
+
const chars = cell.getChars() || ' ';
|
|
78
|
+
for (let i = 0; i < chars.length; i++) xs.push(x);
|
|
79
|
+
text += chars;
|
|
80
|
+
}
|
|
81
|
+
return { text, xs };
|
|
82
|
+
}
|
|
83
|
+
|
|
51
84
|
function oscColor(hex) {
|
|
52
85
|
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
|
53
86
|
if (!m) return null;
|
|
@@ -147,6 +180,8 @@
|
|
|
147
180
|
scrollback: o.scrollback,
|
|
148
181
|
cursorBlink: true,
|
|
149
182
|
allowProposedApi: true,
|
|
183
|
+
// xterm's default asks with confirm() and opens a window, which the installed hub window blocks.
|
|
184
|
+
linkHandler: { activate: (_e, uri) => up('link', { href: uri }), allowNonHttpProtocols: true },
|
|
150
185
|
...o.themeOptions,
|
|
151
186
|
});
|
|
152
187
|
state.term = term;
|
|
@@ -154,6 +189,22 @@
|
|
|
154
189
|
term.loadAddon(state.fit);
|
|
155
190
|
term.loadAddon(new win.Unicode11Addon.Unicode11Addon());
|
|
156
191
|
term.unicode.activeVersion = '11';
|
|
192
|
+
// Link providers scan a line only when the pointer moves onto it, never on output.
|
|
193
|
+
term.loadAddon(new win.WebLinksAddon.WebLinksAddon((_e, uri) => up('link', { href: uri })));
|
|
194
|
+
const cellReuse = term.buffer.active.getNullCell();
|
|
195
|
+
term.registerLinkProvider({
|
|
196
|
+
provideLinks(y, done) {
|
|
197
|
+
const line = term.buffer.active.getLine(y - 1);
|
|
198
|
+
if (!line || !/[\\/]/.test(line.translateToString(true))) return done(undefined);
|
|
199
|
+
const { text, xs } = lineCells(line, term.cols, cellReuse);
|
|
200
|
+
const links = pathsIn(text).map((p) => ({
|
|
201
|
+
text: p.text,
|
|
202
|
+
range: { start: { x: xs[p.index] + 1, y }, end: { x: xs[p.index + p.text.length - 1] + 1, y } },
|
|
203
|
+
activate: () => up('link', { href: p.path }),
|
|
204
|
+
}));
|
|
205
|
+
done(links.length ? links : undefined);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
157
208
|
term.open(host);
|
|
158
209
|
try {
|
|
159
210
|
const gl = new win.WebglAddon.WebglAddon();
|
|
@@ -346,7 +397,7 @@
|
|
|
346
397
|
}
|
|
347
398
|
|
|
348
399
|
if (typeof module === 'object' && module.exports) {
|
|
349
|
-
module.exports = { createTerminalFrame, claimSig, isClaimed, handsBack, parentOriginOf, tailOf };
|
|
400
|
+
module.exports = { createTerminalFrame, claimSig, isClaimed, handsBack, parentOriginOf, tailOf, pathsIn };
|
|
350
401
|
} else {
|
|
351
402
|
createTerminalFrame(root);
|
|
352
403
|
}
|
package/public/terminal.html
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
<script src="/vendor/xterm/addon-fit.js"></script>
|
|
23
23
|
<script src="/vendor/xterm/addon-webgl.js"></script>
|
|
24
24
|
<script src="/vendor/xterm/addon-unicode11.js"></script>
|
|
25
|
+
<script src="/vendor/xterm/addon-web-links.js"></script>
|
|
25
26
|
<script src="/terminal-frame.js"></script>
|
|
26
27
|
</body>
|
|
27
28
|
</html>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// claude-hub-sdk 1.
|
|
1
|
+
// claude-hub-sdk 1.6.0 (sha256 71a58022516d). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
|
|
2
2
|
// Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
|
|
3
3
|
// stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
|
|
4
4
|
// client.js and stub.js, so an app still loads one file.
|
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');
|
|
@@ -3443,6 +3468,7 @@ const XTERM_FILES = {
|
|
|
3443
3468
|
'addon-fit.js': '@xterm/addon-fit/lib/addon-fit.js',
|
|
3444
3469
|
'addon-webgl.js': '@xterm/addon-webgl/lib/addon-webgl.js',
|
|
3445
3470
|
'addon-unicode11.js': '@xterm/addon-unicode11/lib/addon-unicode11.js',
|
|
3471
|
+
'addon-web-links.js': '@xterm/addon-web-links/lib/addon-web-links.js',
|
|
3446
3472
|
};
|
|
3447
3473
|
app.get('/vendor/xterm/:file', (req, res) => {
|
|
3448
3474
|
const rel = Object.hasOwn(XTERM_FILES, req.params.file) ? XTERM_FILES[req.params.file] : null;
|
|
@@ -4783,7 +4809,7 @@ async function cleanupAgentActivity() {
|
|
|
4783
4809
|
} catch { /* agent-activity dir may not exist */ }
|
|
4784
4810
|
}
|
|
4785
4811
|
|
|
4786
|
-
// Dispatch markers, pane layouts, reviews, context status and worktrees: see docs/retention.md.
|
|
4812
|
+
// Dispatch markers, pane layouts, reviews, attachments, context status and worktrees: see docs/retention.md.
|
|
4787
4813
|
async function runRetention() {
|
|
4788
4814
|
try {
|
|
4789
4815
|
const scan = await scanTranscripts(PROJECTS_DIR);
|
|
@@ -4792,12 +4818,13 @@ async function runRetention() {
|
|
|
4792
4818
|
const layouts = panes.prune(opts);
|
|
4793
4819
|
if (layouts) queuePaneFileWatch();
|
|
4794
4820
|
const reviews = await pruneSessionDirs(REVIEW_DIR, opts);
|
|
4821
|
+
const attachments = await pruneSessionDirs(ATTACHMENTS_DIR, opts);
|
|
4795
4822
|
const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
|
|
4796
4823
|
const wts = worktrees.prune(scan?.dirs);
|
|
4797
4824
|
const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
|
|
4798
4825
|
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`);
|
|
4826
|
+
if (markers || layouts || reviews || attachments || contexts || wts || taskMaps || grouped.members) {
|
|
4827
|
+
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
4828
|
}
|
|
4802
4829
|
} catch (e) {
|
|
4803
4830
|
console.warn('[retention] failed:', e.message);
|