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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-admin",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Browse, audit and prune the auto memory Claude Code keeps under ~/.claude/projects",
5
5
  "keywords": [
6
6
  "claude",
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 };
@@ -56,7 +56,10 @@ function segmentBadge(id) {
56
56
  }
57
57
  if (id === 'sessions') {
58
58
  const sessions = state.store.sessions;
59
- return sessions ? { badge: String(sessions.count), tone: sessions.expiringCount ? 'warn' : 'neutral' } : {};
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 sessionRow(session, memoriesFrom, focused) {
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
  }
@@ -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
+ }