claude-memory-admin 1.8.0 → 1.9.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.mjs +15 -1
- package/public/state.mjs +1 -0
- package/public/views/header.mjs +4 -1
- package/public/views/sessions.mjs +42 -2
- package/public/views/stores.mjs +8 -0
- package/server.mjs +13 -0
- package/src/liveSessions.mjs +46 -0
package/package.json
CHANGED
package/public/app.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { isDialogOpen } from '/dialog.mjs';
|
|
|
2
2
|
import { el, node } from '/dom.mjs';
|
|
3
3
|
import * as ui from '/ui.mjs';
|
|
4
4
|
import { state } from '/state.mjs';
|
|
5
|
-
import { toast } from '/api.mjs';
|
|
5
|
+
import { api, toast } from '/api.mjs';
|
|
6
6
|
import { register, paint } from '/bus.mjs';
|
|
7
7
|
import { openStore, reloadStores } from '/store.mjs';
|
|
8
8
|
import { renderStores } from '/views/stores.mjs';
|
|
@@ -76,6 +76,14 @@ function applyStyles() {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
async function refreshActiveSessions() {
|
|
80
|
+
try {
|
|
81
|
+
const data = await api('/api/stores/active');
|
|
82
|
+
state.activeSessions = data.sessions;
|
|
83
|
+
paint('stores', 'tabs', 'tab');
|
|
84
|
+
} catch { /* a failed poll just leaves the last-known state on screen */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
function registerRegions() {
|
|
80
88
|
register('stores', renderStores);
|
|
81
89
|
register('header', renderStoreHeader);
|
|
@@ -144,6 +152,12 @@ async function init() {
|
|
|
144
152
|
} catch (err) {
|
|
145
153
|
toast(err.message, { error: true });
|
|
146
154
|
}
|
|
155
|
+
|
|
156
|
+
refreshActiveSessions();
|
|
157
|
+
setInterval(refreshActiveSessions, 10000);
|
|
158
|
+
document.addEventListener('visibilitychange', () => {
|
|
159
|
+
if (document.visibilityState === 'visible') refreshActiveSessions();
|
|
160
|
+
});
|
|
147
161
|
}
|
|
148
162
|
|
|
149
163
|
init();
|
package/public/state.mjs
CHANGED
|
@@ -43,6 +43,7 @@ export const state = {
|
|
|
43
43
|
indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
|
|
44
44
|
aux: { instructions: null, settings: null, sessions: null, sessionDay: null, sessionFocus: null, tools: null },
|
|
45
45
|
pathCheck: null,
|
|
46
|
+
activeSessions: [],
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const RANK = { ok: 0, warn: 1, bad: 2 };
|
package/public/views/header.mjs
CHANGED
|
@@ -56,7 +56,10 @@ function segmentBadge(id) {
|
|
|
56
56
|
}
|
|
57
57
|
if (id === 'sessions') {
|
|
58
58
|
const sessions = state.store.sessions;
|
|
59
|
-
|
|
59
|
+
if (!sessions) return {};
|
|
60
|
+
const live = state.activeSessions.some((s) => s.storeId === state.store.id);
|
|
61
|
+
const badge = live ? `● ${sessions.count}` : String(sessions.count);
|
|
62
|
+
return { badge, tone: live ? 'ok' : sessions.expiringCount ? 'warn' : 'neutral' };
|
|
60
63
|
}
|
|
61
64
|
return {};
|
|
62
65
|
}
|
|
@@ -20,11 +20,26 @@ export function focusSession(id) {
|
|
|
20
20
|
goTo('environment', 'sessions');
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function
|
|
23
|
+
function agoLabel(ms) {
|
|
24
|
+
const seconds = Math.max(0, Math.round((Date.now() - ms) / 1000));
|
|
25
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
26
|
+
const minutes = Math.round(seconds / 60);
|
|
27
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
28
|
+
return `${Math.round(minutes / 60)}h ago`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sessionRow(session, memoriesFrom, focused, live) {
|
|
24
32
|
const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
|
|
25
33
|
const caret = node('span', { class: ui.contextCaret, text: focused ? '\u25be' : '\u25b8' });
|
|
26
34
|
|
|
27
35
|
const tags = [];
|
|
36
|
+
if (live) {
|
|
37
|
+
tags.push(node('span', {
|
|
38
|
+
class: ui.badge('ok'),
|
|
39
|
+
text: live.status === 'waiting' ? 'waiting' : live.status || 'active',
|
|
40
|
+
title: live.waitingFor || `updated ${agoLabel(live.updatedAt)}`,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
28
43
|
if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
|
|
29
44
|
if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
|
|
30
45
|
if (session.expiresInDays !== null) {
|
|
@@ -196,8 +211,33 @@ export async function renderSessions(container) {
|
|
|
196
211
|
paint('tab');
|
|
197
212
|
};
|
|
198
213
|
|
|
214
|
+
const active = state.activeSessions.filter((s) => s.storeId === state.storeId);
|
|
215
|
+
const byId = new Map(active.map((s) => [s.sessionId, s]));
|
|
216
|
+
|
|
199
217
|
container.append(node('p', { class: ui.noteTight, text: 'The transcripts sitting beside this store\u2019s memory. Claude Code deletes them once they pass the retention period and never touches memory/, so this is the evidence that expires while the memories stay. This tab only reads, and only the head of each file - nothing here deletes a transcript.' }));
|
|
200
218
|
|
|
219
|
+
if (active.length) {
|
|
220
|
+
const list = node('div', { class: ui.card });
|
|
221
|
+
for (const session of active) {
|
|
222
|
+
list.append(node('button', {
|
|
223
|
+
class: ui.contextRowButton,
|
|
224
|
+
onclick: () => focusSession(session.sessionId),
|
|
225
|
+
}, [
|
|
226
|
+
node('div', { class: ui.contextMain }, [
|
|
227
|
+
node('div', { class: ui.contextTags }, [
|
|
228
|
+
node('span', {
|
|
229
|
+
class: ui.badge('ok'),
|
|
230
|
+
text: session.status === 'waiting' ? 'waiting' : session.status || 'active',
|
|
231
|
+
}),
|
|
232
|
+
]),
|
|
233
|
+
node('div', { class: ui.contextFile, text: session.name || session.sessionId }),
|
|
234
|
+
]),
|
|
235
|
+
node('div', { class: ui.contextSize, text: session.waitingFor || agoLabel(session.updatedAt) }),
|
|
236
|
+
]));
|
|
237
|
+
}
|
|
238
|
+
container.append(meterPanel('Active now', null, list));
|
|
239
|
+
}
|
|
240
|
+
|
|
201
241
|
container.append(node('div', { class: ui.meter }, [
|
|
202
242
|
node('div', { class: ui.meterTop }, [
|
|
203
243
|
node('span', { class: ui.meterValue, text: String(data.count) }),
|
|
@@ -241,7 +281,7 @@ export async function renderSessions(container) {
|
|
|
241
281
|
const card = node('div', { class: ui.card });
|
|
242
282
|
let focused = null;
|
|
243
283
|
for (const session of visible) {
|
|
244
|
-
const row = sessionRow(session, byOrigin.get(session.id) || [], session.id === focus);
|
|
284
|
+
const row = sessionRow(session, byOrigin.get(session.id) || [], session.id === focus, byId.get(session.id));
|
|
245
285
|
if (session.id === focus) focused = row;
|
|
246
286
|
card.append(row);
|
|
247
287
|
}
|
package/public/views/stores.mjs
CHANGED
|
@@ -22,10 +22,17 @@ function issueTitle(store) {
|
|
|
22
22
|
return parts.length ? `${store.dir}\n${parts.join(', ')}` : store.dir;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function activeTitle(active) {
|
|
26
|
+
const waiting = active.filter((s) => s.status === 'waiting').length;
|
|
27
|
+
const label = active.length === 1 ? '1 session' : `${active.length} sessions`;
|
|
28
|
+
return waiting ? `${label} - ${waiting} waiting for input` : `${label} ${active.length === 1 ? 'is' : 'are'} busy`;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
function storeButton(store) {
|
|
26
32
|
const global = store.kind === 'global';
|
|
27
33
|
const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
|
|
28
34
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
35
|
+
const active = state.activeSessions.filter((s) => s.storeId === store.id);
|
|
29
36
|
return node('button', {
|
|
30
37
|
class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
|
|
31
38
|
onclick: () => openStore(store.id),
|
|
@@ -34,6 +41,7 @@ function storeButton(store) {
|
|
|
34
41
|
node('span', { class: ui.storeRow }, [
|
|
35
42
|
node('span', { class: ui.dot(health) }),
|
|
36
43
|
node('span', { class: ui.storeName, text: store.label }),
|
|
44
|
+
active.length ? node('span', { class: ui.dot('ok'), title: activeTitle(active) }) : null,
|
|
37
45
|
off ? node('span', { class: ui.offMarker, text: 'off', title: 'Auto memory is disabled for this project' }) : null,
|
|
38
46
|
global ? null : node('span', { class: ui.storeCount, text: store.hasMemoryDir ? String(store.memoryCount) : '-' }),
|
|
39
47
|
]),
|
package/server.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { searchAll } from './src/search.mjs';
|
|
|
18
18
|
import { verifyPaths } from './src/pathcheck.mjs';
|
|
19
19
|
import { detectTools, toolReport } from './src/tools.mjs';
|
|
20
20
|
import { sessionsWithSummaries, transcriptDir } from './src/sessions.mjs';
|
|
21
|
+
import { readLiveSessions } from './src/liveSessions.mjs';
|
|
21
22
|
import {
|
|
22
23
|
addIndexEntry,
|
|
23
24
|
addIndexEntryPreview,
|
|
@@ -242,6 +243,18 @@ async function handleApi(req, res, url) {
|
|
|
242
243
|
return sendJson(res, 200, { stores: issueSweep() });
|
|
243
244
|
}
|
|
244
245
|
|
|
246
|
+
// Same "ahead of requireStore" reasoning as the issues sweep above: real
|
|
247
|
+
// store ids always carry a "kind:" prefix, so "active" can never collide.
|
|
248
|
+
if (segments.length === 3 && segments[1] === 'stores' && segments[2] === 'active' && req.method === 'GET') {
|
|
249
|
+
const live = readLiveSessions();
|
|
250
|
+
const projectStores = listStores(ROOT).filter((s) => s.kind === 'auto' && s.pathExists);
|
|
251
|
+
const sessions = live.map((session) => ({
|
|
252
|
+
...session,
|
|
253
|
+
storeId: projectStores.find((s) => s.path === session.cwd)?.id ?? null,
|
|
254
|
+
}));
|
|
255
|
+
return sendJson(res, 200, { sessions });
|
|
256
|
+
}
|
|
257
|
+
|
|
245
258
|
if (segments[1] !== 'stores' || segments.length < 3) {
|
|
246
259
|
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
247
260
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The live-session registry Claude Code keeps at ~/.claude/sessions/<pid>.json,
|
|
2
|
+
// one file per running `claude` process. This is distinct from the transcript
|
|
3
|
+
// history in sessions.mjs: that reads what happened, this reads what is
|
|
4
|
+
// happening right now.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
function isAlive(pid) {
|
|
11
|
+
try {
|
|
12
|
+
process.kill(pid, 0);
|
|
13
|
+
return true;
|
|
14
|
+
} catch (err) {
|
|
15
|
+
return err.code !== 'ESRCH';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Every session whose process is still running, newest-updated first. A file
|
|
21
|
+
* left behind by a process that has since exited is silently skipped rather
|
|
22
|
+
* than reported as stale - the registry itself is not this app's to clean up.
|
|
23
|
+
*/
|
|
24
|
+
export function readLiveSessions({ dir = path.join(os.homedir(), '.claude', 'sessions') } = {}) {
|
|
25
|
+
let entries;
|
|
26
|
+
try {
|
|
27
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sessions = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
35
|
+
let session;
|
|
36
|
+
try {
|
|
37
|
+
session = JSON.parse(fs.readFileSync(path.join(dir, entry.name), 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!session || typeof session.pid !== 'number' || !isAlive(session.pid)) continue;
|
|
42
|
+
sessions.push(session);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return sessions.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
46
|
+
}
|