claude-memory-admin 1.7.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.
@@ -5,6 +5,8 @@ import { state } from '/state.mjs';
5
5
  import { sizeLabel, sessionDate, isAt } from '/parts.mjs';
6
6
  import { selectMemory } from '/store.mjs';
7
7
  import { paint } from '/bus.mjs';
8
+ import { goTo } from '/parts.mjs';
9
+ import { toast } from '/api.mjs';
8
10
 
9
11
  const TITLE_SOURCE = {
10
12
  'ai-title': 'title Claude Code generated for this session',
@@ -12,11 +14,32 @@ const TITLE_SOURCE = {
12
14
  prompt: 'first prompt, no generated title found in the part read',
13
15
  };
14
16
 
15
- function sessionRow(session, memoriesFrom) {
17
+ export function focusSession(id) {
18
+ state.aux.sessionFocus = id;
19
+ state.aux.sessionDay = null;
20
+ goTo('environment', 'sessions');
21
+ }
22
+
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) {
16
32
  const soon = session.expiresInDays !== null && session.expiresInDays <= 7;
17
- const caret = node('span', { class: ui.contextCaret, text: '\u25b8' });
33
+ const caret = node('span', { class: ui.contextCaret, text: focused ? '\u25be' : '\u25b8' });
18
34
 
19
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
+ }
20
43
  if (session.gitBranch) tags.push(node('span', { class: ui.badge('neutral'), text: session.gitBranch }));
21
44
  if (session.model) tags.push(node('span', { class: ui.badge('neutral'), text: session.model }));
22
45
  if (session.expiresInDays !== null) {
@@ -29,7 +52,7 @@ function sessionRow(session, memoriesFrom) {
29
52
  tags.push(node('span', { class: ui.badge('project'), text: memory.name }));
30
53
  }
31
54
 
32
- const body = node('div', { class: ui.contextBody, hidden: true });
55
+ const body = node('div', { class: ui.contextBody, hidden: !focused });
33
56
  const facts = node('dl', { class: ui.metaList });
34
57
  const rows = [
35
58
  ['session', session.id],
@@ -62,7 +85,7 @@ function sessionRow(session, memoriesFrom) {
62
85
 
63
86
  const button = node('button', {
64
87
  class: ui.contextRowButton,
65
- 'aria-expanded': 'false',
88
+ 'aria-expanded': String(Boolean(focused)),
66
89
  onclick: (event) => {
67
90
  const open = body.hidden;
68
91
  body.hidden = !open;
@@ -188,8 +211,33 @@ export async function renderSessions(container) {
188
211
  paint('tab');
189
212
  };
190
213
 
214
+ const active = state.activeSessions.filter((s) => s.storeId === state.storeId);
215
+ const byId = new Map(active.map((s) => [s.sessionId, s]));
216
+
191
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.' }));
192
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
+
193
241
  container.append(node('div', { class: ui.meter }, [
194
242
  node('div', { class: ui.meterTop }, [
195
243
  node('span', { class: ui.meterValue, text: String(data.count) }),
@@ -227,9 +275,21 @@ export async function renderSessions(container) {
227
275
  }));
228
276
  }
229
277
  container.append(listHead);
278
+ const focus = state.aux.sessionFocus;
279
+ state.aux.sessionFocus = null;
280
+
230
281
  const card = node('div', { class: ui.card });
282
+ let focused = null;
231
283
  for (const session of visible) {
232
- card.append(sessionRow(session, byOrigin.get(session.id) || []));
284
+ const row = sessionRow(session, byOrigin.get(session.id) || [], session.id === focus, byId.get(session.id));
285
+ if (session.id === focus) focused = row;
286
+ card.append(row);
233
287
  }
234
288
  container.append(card);
289
+
290
+ if (focus && focused) {
291
+ requestAnimationFrame(() => focused.scrollIntoView({ block: 'center' }));
292
+ } else if (focus) {
293
+ toast('That transcript is no longer beside this store - Claude Code has swept it.');
294
+ }
235
295
  }
@@ -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
  ]),
@@ -4,73 +4,141 @@ import { api } from '/api.mjs';
4
4
  import { state } from '/state.mjs';
5
5
  import { isAt } from '/parts.mjs';
6
6
  import { paint } from '/bus.mjs';
7
- import { renderRtk } from '/views/rtk.mjs';
7
+ import { renderRtk, rtkSummary } from '/views/rtk.mjs';
8
+ import { renderCcusage, ccusageSummary } from '/views/ccusage.mjs';
8
9
 
9
10
  const OPT_IN = 'toolsOptIn';
10
11
 
11
- const installedRtk = () => state.tools.find((tool) => tool.id === 'rtk' && tool.found) || null;
12
+ const PANELS = {
13
+ rtk: { render: renderRtk, summary: rtkSummary },
14
+ ccusage: { render: renderCcusage, summary: ccusageSummary },
15
+ };
12
16
 
13
- async function fetchRtk() {
14
- const id = state.storeId;
17
+ const installed = () => state.tools.filter((tool) => tool.found && PANELS[tool.id]);
18
+
19
+ const openKey = (id) => `toolOpen:${id}`;
20
+ const isOpen = (id) => localStorage.getItem(openKey(id)) === '1';
21
+
22
+ function setOpen(id, open) {
23
+ if (open) localStorage.setItem(openKey(id), '1');
24
+ else localStorage.removeItem(openKey(id));
25
+ }
26
+
27
+ async function fetchTool(id) {
28
+ const storeId = state.storeId;
15
29
  let next;
16
30
  try {
17
- next = { data: await api(`/api/stores/${encodeURIComponent(id)}/tools/rtk`) };
31
+ next = { data: await api(`/api/stores/${encodeURIComponent(storeId)}/tools/${encodeURIComponent(id)}`) };
18
32
  } catch (err) {
19
33
  next = { error: err.message };
20
34
  }
21
- if (state.storeId !== id) return;
22
- state.aux.rtk = next;
35
+ if (state.storeId !== storeId) return;
36
+ state.aux.tools = { ...state.aux.tools, [id]: next };
23
37
  if (isAt('environment', 'tools')) paint('tab');
24
38
  }
25
39
 
26
- function startRtk({ remember = true } = {}) {
40
+ function start({ remember = true } = {}) {
27
41
  if (remember) localStorage.setItem(OPT_IN, '1');
28
- state.aux.rtk = { running: true };
29
- fetchRtk();
42
+ const running = {};
43
+ for (const tool of installed()) running[tool.id] = { running: true };
44
+ state.aux.tools = running;
45
+ for (const tool of installed()) fetchTool(tool.id);
30
46
  }
31
47
 
32
- function readRtk() {
33
- startRtk();
48
+ function readAll() {
49
+ start();
34
50
  paint('tab');
35
51
  }
36
52
 
37
53
  function turnOff() {
38
54
  localStorage.removeItem(OPT_IN);
39
- state.aux.rtk = null;
55
+ state.aux.tools = null;
40
56
  paint('tab');
41
57
  }
42
58
 
43
- function gate(container, tool) {
59
+ function gate(container, tools) {
60
+ const names = tools.map((tool) => tool.label).join(' and ');
61
+ const verb = tools.length > 1 ? 'are installed' : 'is installed';
44
62
  container.append(node('div', { class: ui.issue(false) }, [
45
63
  node('div', { class: ui.issueBody }, [
46
- node('div', { class: ui.issueTitle, text: 'rtk is installed, and is not being read' }),
47
- node('div', { class: ui.issueDetail, text: `${tool.path} keeps a ledger of what its filters stripped before it reached the model. Reading it runs rtk on this machine, so it stays off until you ask.` }),
64
+ node('div', { class: ui.issueTitle, text: `${names} ${verb}, and not being read` }),
65
+ node('div', { class: ui.issueDetail, text: `Each keeps a ledger of what it saved or spent on your behalf. Reading them runs ${names} on this machine, so it stays off until you ask.` }),
48
66
  ]),
49
- node('button', { class: ui.buttonSmall, text: 'Read rtk stats', onclick: readRtk }),
67
+ node('button', { class: ui.buttonSmall, text: 'Read tool stats', onclick: readAll }),
50
68
  ]));
51
69
  }
52
70
 
53
- function failure(container, message) {
54
- container.append(node('div', { class: ui.issue(true) }, [
55
- node('div', { class: ui.issueBody }, [
56
- node('div', { class: ui.issueTitle, text: 'rtk could not be read' }),
57
- node('div', { class: ui.issueDetail, text: message }),
71
+ function headline(tool, slot) {
72
+ if (!slot || slot.running) return 'reading…';
73
+ if (slot.error) return 'could not be read';
74
+ return PANELS[tool.id].summary(slot.data);
75
+ }
76
+
77
+ function subtitle(tool, slot) {
78
+ const report = slot && slot.data;
79
+ if (report && report.version) return `${tool.label} ${report.version}`;
80
+ return tool.label;
81
+ }
82
+
83
+ function body(tool, slot) {
84
+ const host = node('div');
85
+ if (!slot || slot.running) {
86
+ host.append(node('p', { class: ui.note, text: `Reading the ${tool.label} ledger…` }));
87
+ return host;
88
+ }
89
+ if (slot.error) {
90
+ host.append(node('div', { class: ui.issue(true) }, [
91
+ node('div', { class: ui.issueBody }, [
92
+ node('div', { class: ui.issueTitle, text: `${tool.label} could not be read` }),
93
+ node('div', { class: ui.issueDetail, text: slot.error }),
94
+ ]),
95
+ node('button', { class: ui.buttonSmall, text: 'Try again', onclick: readAll }),
96
+ ]));
97
+ return host;
98
+ }
99
+ PANELS[tool.id].render(host, slot.data);
100
+ return host;
101
+ }
102
+
103
+ function accordion(container, tool, slot) {
104
+ const open = isOpen(tool.id);
105
+ const panel = body(tool, slot);
106
+ panel.hidden = !open;
107
+
108
+ const caret = node('span', { class: ui.contextCaret, text: open ? '▾' : '▸' });
109
+
110
+ container.append(node('button', {
111
+ class: ui.contextRowButton,
112
+ 'aria-expanded': String(open),
113
+ onclick: (event) => {
114
+ const next = panel.hidden;
115
+ panel.hidden = !next;
116
+ caret.textContent = next ? '▾' : '▸';
117
+ event.currentTarget.setAttribute('aria-expanded', String(next));
118
+ setOpen(tool.id, next);
119
+ },
120
+ }, [
121
+ node('div', { class: ui.contextMain }, [
122
+ node('div', { class: ui.contextFile }, [caret, document.createTextNode(subtitle(tool, slot))]),
58
123
  ]),
59
- node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: turnOff }),
60
- node('button', { class: ui.buttonSmall, text: 'Try again', onclick: readRtk }),
124
+ node('div', { class: ui.contextSize, text: headline(tool, slot) }),
61
125
  ]));
126
+ container.append(panel);
62
127
  }
63
128
 
64
129
  export function renderTools(container) {
65
- const tool = installedRtk();
66
- if (!tool) return;
130
+ const tools = installed();
131
+ if (!tools.length) return;
67
132
 
68
- if (!state.aux.rtk && localStorage.getItem(OPT_IN) === '1') startRtk({ remember: false });
133
+ if (!state.aux.tools && localStorage.getItem(OPT_IN) === '1') start({ remember: false });
134
+ if (!state.aux.tools) return gate(container, tools);
69
135
 
70
- const report = state.aux.rtk;
71
- if (!report) return gate(container, tool);
72
- if (report.running) return container.append(node('p', { class: ui.note, text: 'Reading the rtk ledger…' }));
73
- if (report.error) return failure(container, report.error);
136
+ container.append(node('div', { class: ui.listBar }, [
137
+ node('span', { class: ui.sectionLabelInline, text: 'Companion tools' }),
138
+ node('span', { class: ui.listSpacer }),
139
+ node('button', { class: ui.buttonSmall, text: 'Re-read', onclick: readAll }),
140
+ node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: turnOff }),
141
+ ]));
74
142
 
75
- renderRtk(container, report.data, { reread: readRtk, turnOff });
143
+ for (const tool of tools) accordion(container, tool, state.aux.tools[tool.id]);
76
144
  }
package/server.mjs CHANGED
@@ -16,8 +16,9 @@ import { listStores } from './src/stores.mjs';
16
16
  import { forgetPath, rememberPath } from './src/pathcache.mjs';
17
17
  import { searchAll } from './src/search.mjs';
18
18
  import { verifyPaths } from './src/pathcheck.mjs';
19
- import { findRtk, rtkReport } from './src/rtk.mjs';
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,
@@ -129,6 +130,14 @@ function requireStore(id) {
129
130
  return store;
130
131
  }
131
132
 
133
+ const VERSION = (() => {
134
+ try {
135
+ return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version || null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ })();
140
+
132
141
  function storeProjectDir(store) {
133
142
  // The global store is the user scope itself, which no project owns.
134
143
  if (store.kind === 'global') return null;
@@ -218,7 +227,8 @@ async function handleApi(req, res, url) {
218
227
  rootSource: origin.path === ROOT ? origin.source : 'flag',
219
228
  rootFile: origin.path === ROOT ? origin.file : null,
220
229
  rootWarning: origin.invalid,
221
- tools: [findRtk()],
230
+ version: VERSION,
231
+ tools: detectTools(),
222
232
  stores: listStores(ROOT),
223
233
  });
224
234
  }
@@ -233,6 +243,18 @@ async function handleApi(req, res, url) {
233
243
  return sendJson(res, 200, { stores: issueSweep() });
234
244
  }
235
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
+
236
258
  if (segments[1] !== 'stores' || segments.length < 3) {
237
259
  return sendJson(res, 404, { error: 'Unknown endpoint' });
238
260
  }
@@ -278,11 +300,12 @@ async function handleApi(req, res, url) {
278
300
  return sendJson(res, 200, sessionsWithSummaries(slugDir, { projectDir: storeProjectDir(store) }));
279
301
  }
280
302
 
281
- if (action === 'tools/rtk' && req.method === 'GET') {
282
- const rtk = findRtk();
283
- if (!rtk.found) throw new Error('rtk is not installed, or not on the PATH this server was started with.');
284
- const report = await rtkReport(storeProjectDir(store));
285
- return sendJson(res, 200, { ...report, path: rtk.path });
303
+ if (action.startsWith('tools/') && req.method === 'GET') {
304
+ const target = {
305
+ projectDir: store.kind === 'global' ? null : storeProjectDir(store),
306
+ slug: store.kind === 'auto' ? store.slug : null,
307
+ };
308
+ return sendJson(res, 200, await toolReport(action.slice('tools/'.length), target));
286
309
  }
287
310
 
288
311
  if (action === 'settings' && req.method === 'GET') {
@@ -0,0 +1,122 @@
1
+ import os from 'node:os';
2
+
3
+ import { collector, findBinary, num, parseJson, pct, runBinary, text } from './toolrun.mjs';
4
+
5
+ const BINARY = 'ccusage';
6
+ const WINDOW_DAYS = 30;
7
+ const SESSION_LIMIT = 8;
8
+ const MODEL_LIMIT = 6;
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+
11
+ export const findCcusage = (env = process.env) => ({ id: BINARY, ...findBinary(BINARY, env) });
12
+
13
+ const runCcusage = (args, options) => runBinary(BINARY, args, options);
14
+
15
+ const ZERO = { sessions: 0, cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
16
+
17
+ function rowOf(session) {
18
+ const models = Array.isArray(session.modelBreakdowns) ? session.modelBreakdowns : [];
19
+ return {
20
+ id: text(session.sessionId),
21
+ slug: text(session.projectPath),
22
+ cost: num(session.totalCost),
23
+ tokens: num(session.totalTokens),
24
+ input: num(session.inputTokens),
25
+ output: num(session.outputTokens),
26
+ cacheRead: num(session.cacheReadTokens),
27
+ cacheWrite: num(session.cacheCreationTokens),
28
+ last: text(session.lastActivity),
29
+ models: models
30
+ .filter((model) => model && typeof model === 'object')
31
+ .map((model) => ({ name: text(model.modelName), cost: num(model.cost), tokens: num(model.outputTokens) }))
32
+ .filter((model) => model.name),
33
+ };
34
+ }
35
+
36
+ const totalsOf = (rows) => rows.reduce((sum, row) => ({
37
+ sessions: sum.sessions + 1,
38
+ cost: sum.cost + row.cost,
39
+ tokens: sum.tokens + row.tokens,
40
+ input: sum.input + row.input,
41
+ output: sum.output + row.output,
42
+ cacheRead: sum.cacheRead + row.cacheRead,
43
+ cacheWrite: sum.cacheWrite + row.cacheWrite,
44
+ }), ZERO);
45
+
46
+ function modelMix(rows) {
47
+ const byName = new Map();
48
+ for (const row of rows) {
49
+ for (const model of row.models) {
50
+ const seen = byName.get(model.name) || { name: model.name, cost: 0, tokens: 0 };
51
+ seen.cost += model.cost;
52
+ seen.tokens += model.tokens;
53
+ byName.set(model.name, seen);
54
+ }
55
+ }
56
+ return [...byName.values()].sort((a, b) => b.cost - a.cost).slice(0, MODEL_LIMIT);
57
+ }
58
+
59
+ function windowOf(rows, now) {
60
+ const cutoff = now - (WINDOW_DAYS * DAY_MS);
61
+ const recent = rows.filter((row) => {
62
+ const stamp = Date.parse(row.last);
63
+ return Number.isFinite(stamp) && stamp >= cutoff;
64
+ });
65
+ return { days: WINDOW_DAYS, ...totalsOf(recent) };
66
+ }
67
+
68
+ function machineOf(raw, rows) {
69
+ const totals = raw && typeof raw.totals === 'object' && raw.totals ? raw.totals : {};
70
+ return {
71
+ sessions: rows.length,
72
+ cost: num(totals.totalCost),
73
+ tokens: num(totals.totalTokens),
74
+ input: num(totals.inputTokens),
75
+ output: num(totals.outputTokens),
76
+ cacheRead: num(totals.cacheReadTokens),
77
+ cacheWrite: num(totals.cacheCreationTokens),
78
+ };
79
+ }
80
+
81
+ export async function ccusageReport(target = {}, { run = runCcusage, home = os.homedir(), now = Date.now() } = {}) {
82
+ const { errors, step } = collector();
83
+
84
+ const [version, raw] = await Promise.all([
85
+ step('version', async () => text(await run(['--version'], { cwd: home })).trim().replace(/^ccusage\s+/, '')),
86
+ step('claude session', async () => parseJson(
87
+ await run(['claude', 'session', '--json', '--offline'], { cwd: home }),
88
+ 'ccusage claude session',
89
+ )),
90
+ ]);
91
+
92
+ if (!raw) {
93
+ return { tool: BINARY, version: version || null, slug: target.slug || null, machine: null, project: null, errors };
94
+ }
95
+
96
+ const rows = (Array.isArray(raw.sessions) ? raw.sessions : [])
97
+ .filter((session) => session && typeof session === 'object')
98
+ .map(rowOf)
99
+ .filter((row) => row.id);
100
+
101
+ const machine = machineOf(raw, rows);
102
+ const mine = target.slug ? rows.filter((row) => row.slug === target.slug) : null;
103
+ const mineTotals = mine ? totalsOf(mine) : null;
104
+
105
+ return {
106
+ tool: BINARY,
107
+ version: version || null,
108
+ slug: target.slug || null,
109
+ machine: { ...machine, window: windowOf(rows, now) },
110
+ project: mine ? {
111
+ ...mineTotals,
112
+ share: pct(mineTotals.cost, machine.cost),
113
+ window: windowOf(mine, now),
114
+ models: modelMix(mine),
115
+ top: [...mine]
116
+ .sort((a, b) => b.cost - a.cost)
117
+ .slice(0, SESSION_LIMIT)
118
+ .map(({ id, cost, tokens, last }) => ({ id, cost, tokens, last })),
119
+ } : null,
120
+ errors,
121
+ };
122
+ }
@@ -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
+ }
package/src/rtk.mjs CHANGED
@@ -1,66 +1,16 @@
1
- import { execFile } from 'node:child_process';
2
- import fs from 'node:fs';
3
1
  import os from 'node:os';
4
- import path from 'node:path';
2
+
3
+ import { collector, findBinary, num, parseJson, pct, runBinary, text, usableDir } from './toolrun.mjs';
5
4
 
6
5
  const BINARY = 'rtk';
7
- const TIMEOUT_MS = 15000;
8
- const MAX_BUFFER = 4 * 1024 * 1024;
9
6
  const DISCOVER_DAYS = 30;
10
7
  const DISCOVER_LIMIT = 10;
11
8
  const MISSED_LIMIT = 12;
12
9
  const TREND_DAYS = 30;
13
10
 
14
- const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
15
- const text = (value) => (typeof value === 'string' ? value : '');
16
- const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0);
17
-
18
- function candidateNames(env) {
19
- if (process.platform !== 'win32') return [BINARY];
20
- const exts = text(env.PATHEXT || '.EXE').split(';').filter(Boolean);
21
- return exts.map((ext) => BINARY + ext.toLowerCase());
22
- }
23
-
24
- export function findRtk(env = process.env) {
25
- const dirs = text(env.PATH).split(path.delimiter).filter(Boolean);
26
- const names = candidateNames(env);
27
- for (const dir of dirs) {
28
- for (const name of names) {
29
- const candidate = path.join(dir, name);
30
- try {
31
- if (!fs.statSync(candidate).isFile()) continue;
32
- fs.accessSync(candidate, fs.constants.X_OK);
33
- return { id: BINARY, found: true, path: candidate };
34
- } catch {
35
- continue;
36
- }
37
- }
38
- }
39
- return { id: BINARY, found: false, path: null };
40
- }
41
-
42
- function runRtk(args, { cwd, timeout = TIMEOUT_MS } = {}) {
43
- return new Promise((resolve, reject) => {
44
- const options = { cwd, timeout, maxBuffer: MAX_BUFFER, windowsHide: true, encoding: 'utf8' };
45
- execFile(BINARY, args, options, (err, stdout, stderr) => {
46
- if (!err) return resolve(stdout);
47
- if (err.code === 'ENOENT') return reject(new Error('rtk is not on this process PATH'));
48
- if (err.killed) return reject(new Error(`rtk ${args[0]} was still running after ${timeout}ms and was stopped`));
49
- const detail = text(stderr || stdout).trim().split('\n')[0];
50
- return reject(new Error(detail ? `rtk ${args[0]} failed: ${detail}` : `rtk ${args[0]} exited ${err.code}`));
51
- });
52
- });
53
- }
11
+ export const findRtk = (env = process.env) => ({ id: BINARY, ...findBinary(BINARY, env) });
54
12
 
55
- function parseJson(raw, label) {
56
- try {
57
- const value = JSON.parse(raw);
58
- if (!value || typeof value !== 'object') throw new Error('not an object');
59
- return value;
60
- } catch {
61
- throw new Error(`rtk ${label} did not answer with JSON`);
62
- }
63
- }
13
+ const runRtk = (args, options) => runBinary(BINARY, args, options);
64
14
 
65
15
  function summaryOf(raw) {
66
16
  const summary = raw && typeof raw.summary === 'object' && raw.summary ? raw.summary : {};
@@ -133,35 +83,17 @@ function discoverOf(raw) {
133
83
  };
134
84
  }
135
85
 
136
- function usableDir(dir) {
137
- if (!dir) return null;
138
- try {
139
- return fs.statSync(dir).isDirectory() ? dir : null;
140
- } catch {
141
- return null;
142
- }
143
- }
144
-
145
- export async function rtkReport(projectDir, { run = runRtk, home = os.homedir() } = {}) {
146
- const errors = [];
147
- const step = async (label, work) => {
148
- try {
149
- return await work();
150
- } catch (err) {
151
- errors.push({ step: label, message: err.message });
152
- return null;
153
- }
154
- };
155
-
156
- const dir = usableDir(projectDir);
86
+ export async function rtkReport(target = {}, { run = runRtk, home = os.homedir() } = {}) {
87
+ const { errors, step } = collector();
88
+ const dir = usableDir(target.projectDir);
157
89
 
158
90
  const [version, machine, project, discover] = await Promise.all([
159
91
  step('version', async () => text(await run(['--version'], { cwd: home })).trim().replace(/^rtk\s+/, '')),
160
- step('gain', async () => parseJson(await run(['gain', '-a', '--format', 'json'], { cwd: home }), 'gain')),
161
- dir ? step('gain --project', async () => parseJson(await run(['gain', '-p', '--format', 'json'], { cwd: dir }), 'gain --project')) : null,
92
+ step('gain', async () => parseJson(await run(['gain', '-a', '--format', 'json'], { cwd: home }), 'rtk gain')),
93
+ dir ? step('gain --project', async () => parseJson(await run(['gain', '-p', '--format', 'json'], { cwd: dir }), 'rtk gain --project')) : null,
162
94
  dir ? step('discover', async () => parseJson(
163
95
  await run(['discover', '--format', 'json', '-s', String(DISCOVER_DAYS), '-l', String(DISCOVER_LIMIT)], { cwd: dir }),
164
- 'discover',
96
+ 'rtk discover',
165
97
  )) : null,
166
98
  ]);
167
99