pi-inspect 0.2.0 → 0.3.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.
@@ -1,10 +1,10 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
3
  import { createConnection } from "node:net";
4
4
  import { homedir } from "node:os";
5
5
  import { join as joinPath, resolve as resolvePath } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { type ExtensionAPI, parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
 
9
9
  const extDir = fileURLToPath(new URL(".", import.meta.url));
10
10
  const port = 5462;
@@ -100,6 +100,161 @@ function writeSnapshot(id: string, snapshot: unknown): void {
100
100
  writeFileSync(joinPath(SNAP_DIR, `${sanitize(id)}.json`), JSON.stringify(snapshot, null, 2), "utf8");
101
101
  }
102
102
 
103
+ type DisabledKind = "command" | "skill" | "extension" | "theme";
104
+ type DisabledScope = "user" | "project";
105
+ type DisabledItem = {
106
+ kind: DisabledKind;
107
+ name: string;
108
+ displayName: string;
109
+ description: string;
110
+ source: string;
111
+ scope: DisabledScope;
112
+ settingsPath: string;
113
+ path: string;
114
+ reason: string;
115
+ };
116
+
117
+ const AGENT_DIR = joinPath(homedir(), ".pi", "agent");
118
+ const SETTINGS_PATH = joinPath(AGENT_DIR, "settings.json");
119
+ const NPM_ROOT = joinPath(AGENT_DIR, "npm", "node_modules");
120
+
121
+ type FilterKind = "prompts" | "skills" | "extensions" | "themes";
122
+ const FILTER_KINDS: readonly FilterKind[] = ["prompts", "skills", "extensions", "themes"];
123
+
124
+ const KIND_CFG: Record<FilterKind, { kind: DisabledKind; stripExt: RegExp; display: (n: string) => string }> = {
125
+ prompts: { kind: "command", stripExt: /\.md$/i, display: (n) => `/${n}` },
126
+ skills: { kind: "skill", stripExt: /\.md$/i, display: (n) => `/skill:${n}` },
127
+ extensions: { kind: "extension", stripExt: /\.(ts|js|mjs|cjs)$/i, display: (n) => n },
128
+ themes: { kind: "theme", stripExt: /\.(json|toml|ya?ml)$/i, display: (n) => n },
129
+ };
130
+
131
+ function resolvePackageRoot(source: string): string | null {
132
+ if (source.startsWith("npm:")) return joinPath(NPM_ROOT, source.slice(4));
133
+ let s = source.replace(/\\/g, "/");
134
+ if (s.startsWith("~/")) s = joinPath(homedir(), s.slice(2));
135
+ try { return statSync(s).isDirectory() ? s : null; } catch { return null; }
136
+ }
137
+
138
+ function sourceLabel(source: string): string {
139
+ if (source.startsWith("npm:")) return source.slice(4);
140
+ const norm = source.replace(/\\/g, "/").replace(/\/$/, "");
141
+ return norm.split("/").pop() ?? norm;
142
+ }
143
+
144
+ const describeCache = new Map<string, { mtimeMs: number; desc: string }>();
145
+
146
+ function describeFrom(filePath: string, isDir: boolean): string {
147
+ const path = isDir ? joinPath(filePath, "SKILL.md") : filePath;
148
+ let mtimeMs: number;
149
+ try { mtimeMs = statSync(path).mtimeMs; } catch { return ""; }
150
+ const cached = describeCache.get(path);
151
+ if (cached && cached.mtimeMs === mtimeMs) return cached.desc;
152
+ let desc = "";
153
+ try {
154
+ const { frontmatter, body } = parseFrontmatter<{ description?: string }>(readFileSync(path, "utf8"));
155
+ desc = typeof frontmatter.description === "string" && frontmatter.description
156
+ ? frontmatter.description
157
+ : body.split(/\r?\n/).find((l) => l.trim() && !l.trim().startsWith("#"))?.trim().slice(0, 240) ?? "";
158
+ } catch {}
159
+ describeCache.set(path, { mtimeMs, desc });
160
+ return desc;
161
+ }
162
+
163
+ function nameFromRel(filterKind: FilterKind, rel: string): string {
164
+ const base = rel.split("/").pop() ?? rel;
165
+ if (filterKind === "skills" && /SKILL\.md$/i.test(base)) {
166
+ const parent = rel.replace(/\/SKILL\.md$/i, "").split("/").pop();
167
+ return parent ?? base;
168
+ }
169
+ return base.replace(KIND_CFG[filterKind].stripExt, "");
170
+ }
171
+
172
+ type GroupCtx = {
173
+ root: string;
174
+ label: string;
175
+ scope: DisabledScope;
176
+ settingsPath: string;
177
+ };
178
+
179
+ function pushDisabled(
180
+ items: DisabledItem[],
181
+ filterKind: FilterKind,
182
+ raw: unknown,
183
+ ctx: GroupCtx,
184
+ ): void {
185
+ if (typeof raw !== "string" || !raw.startsWith("-")) return;
186
+ const rel = raw.slice(1).replace(/\\/g, "/");
187
+ const filePath = joinPath(ctx.root, rel);
188
+ let isDir = false;
189
+ try { isDir = statSync(filePath).isDirectory(); } catch { return; }
190
+ const cfg = KIND_CFG[filterKind];
191
+ const name = nameFromRel(filterKind, rel);
192
+ items.push({
193
+ kind: cfg.kind,
194
+ name,
195
+ displayName: cfg.display(name),
196
+ description: describeFrom(filePath, isDir),
197
+ source: ctx.label,
198
+ scope: ctx.scope,
199
+ settingsPath: ctx.settingsPath,
200
+ path: filePath,
201
+ reason: raw,
202
+ });
203
+ }
204
+
205
+ function collectGroup(items: DisabledItem[], group: any, ctx: GroupCtx): void {
206
+ for (const filterKind of FILTER_KINDS) {
207
+ const arr = group?.[filterKind];
208
+ if (!Array.isArray(arr)) continue;
209
+ for (const raw of arr) pushDisabled(items, filterKind, raw, ctx);
210
+ }
211
+ }
212
+
213
+ const settingsCache = new Map<string, { mtimeMs: number; items: DisabledItem[] }>();
214
+
215
+ function readDisabledFrom(
216
+ settingsPath: string,
217
+ baseDir: string,
218
+ scope: DisabledScope,
219
+ ): DisabledItem[] {
220
+ let mtimeMs: number;
221
+ try { mtimeMs = statSync(settingsPath).mtimeMs; } catch { return []; }
222
+ const cached = settingsCache.get(settingsPath);
223
+ if (cached && cached.mtimeMs === mtimeMs) return cached.items;
224
+
225
+ let settings: any;
226
+ try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch { return []; }
227
+ const items: DisabledItem[] = [];
228
+
229
+ collectGroup(items, settings, { root: baseDir, label: scope, scope, settingsPath });
230
+
231
+ const packages = Array.isArray(settings?.packages) ? settings.packages : [];
232
+ for (const entry of packages) {
233
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
234
+ const source = String(entry.source ?? "");
235
+ if (!source) continue;
236
+ const root = resolvePackageRoot(source);
237
+ if (!root) continue;
238
+ collectGroup(items, entry, { root, label: sourceLabel(source), scope, settingsPath });
239
+ }
240
+ settingsCache.set(settingsPath, { mtimeMs, items });
241
+ return items;
242
+ }
243
+
244
+ function discoverDisabledFromPackages(cwd: string | null): DisabledItem[] {
245
+ const scopes: { settings: string; base: string; scope: DisabledScope }[] = [
246
+ { settings: SETTINGS_PATH, base: AGENT_DIR, scope: "user" },
247
+ ];
248
+ if (cwd) scopes.push({ settings: joinPath(cwd, ".pi", "settings.json"), base: joinPath(cwd, ".pi"), scope: "project" });
249
+
250
+ // Project overrides user when both disable the same path.
251
+ const byPath = new Map<string, DisabledItem>();
252
+ for (const { settings, base, scope } of scopes) {
253
+ for (const it of readDisabledFrom(settings, base, scope)) byPath.set(it.path, it);
254
+ }
255
+ return [...byPath.values()];
256
+ }
257
+
103
258
  function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
104
259
  const sm = ctx.sessionManager;
105
260
  const id = sm?.getSessionId?.();
@@ -112,8 +267,9 @@ function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
112
267
  const commands = typeof pi?.getCommands === "function" ? pi.getCommands() : [];
113
268
  const tools = typeof pi?.getAllTools === "function" ? pi.getAllTools() : [];
114
269
  const activeTools = typeof pi?.getActiveTools === "function" ? pi.getActiveTools() : [];
270
+ const disabledItems = discoverDisabledFromPackages(cwd);
115
271
  const capturedAt = Date.now();
116
- const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, capturedAt };
272
+ const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, disabledItems, capturedAt };
117
273
  try {
118
274
  writeSnapshot(id, snap);
119
275
  upsertIndex({ id, cwd, name, model, capturedAt });
package/lib/snapshot.js CHANGED
@@ -40,6 +40,49 @@ async function readSnapshot(sessionId) {
40
40
  }
41
41
  }
42
42
 
43
+ async function writeIndex(sessions) {
44
+ await fsp.mkdir(SNAPSHOT_DIR, { recursive: true });
45
+ await fsp.writeFile(INDEX_PATH, JSON.stringify({ sessions }, null, 2), 'utf8');
46
+ }
47
+
48
+ async function cleanupIndex() {
49
+ const sessions = await readIndex();
50
+ const kept = [];
51
+ const removed = [];
52
+ for (const s of sessions) {
53
+ try {
54
+ await fsp.access(snapshotPath(s.id));
55
+ kept.push(s);
56
+ } catch {
57
+ removed.push(s.id);
58
+ }
59
+ }
60
+ if (removed.length) await writeIndex(kept);
61
+ return { kept: kept.length, removed };
62
+ }
63
+
64
+ async function keepOnly(sessionId) {
65
+ if (!sessionId) throw new Error('sessionId required');
66
+ const sessions = await readIndex();
67
+ const kept = sessions.filter((s) => s.id === sessionId);
68
+ const removed = [];
69
+ let files;
70
+ try { files = await fsp.readdir(SNAPSHOT_DIR); }
71
+ catch { files = []; }
72
+ const keepPath = path.basename(snapshotPath(sessionId));
73
+ for (const f of files) {
74
+ if (!f.endsWith('.json') || f === 'index.json' || f === keepPath) continue;
75
+ try {
76
+ await fsp.unlink(path.join(SNAPSHOT_DIR, f));
77
+ removed.push(f.replace(/\.json$/, ''));
78
+ } catch (e) {
79
+ console.warn(`pi-inspect: unlink ${f}: ${e.message}`);
80
+ }
81
+ }
82
+ await writeIndex(kept);
83
+ return { kept: kept.length, removed };
84
+ }
85
+
43
86
  async function readLatestSnapshot() {
44
87
  const sessions = await readIndex();
45
88
  if (!sessions.length) return null;
@@ -51,6 +94,9 @@ module.exports = {
51
94
  snapshotDir,
52
95
  snapshotPath,
53
96
  readIndex,
97
+ writeIndex,
98
+ cleanupIndex,
99
+ keepOnly,
54
100
  readSnapshot,
55
101
  readLatestSnapshot,
56
102
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inspect",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Introspection dashboard for the pi coding agent — tools, slash commands, skills, and the system prompt injected on init.",
5
5
  "keywords": [
6
6
  "pi-package",
package/public/app.js CHANGED
@@ -5,7 +5,7 @@ const state = {
5
5
  snapshot: null,
6
6
  search: '',
7
7
  kind: 'all',
8
- expanded: { context: true, tool: true, command: true, skill: true },
8
+ expanded: { context: true, tool: true, command: true, prompt: true, skill: true },
9
9
  selected: null,
10
10
  expandAll: true,
11
11
  highlight: -1,
@@ -110,7 +110,11 @@ function inferSource(x) {
110
110
  const si = x?.sourceInfo;
111
111
  if (si) {
112
112
  if (si.label) return si.label;
113
- if (si.source) return si.source.startsWith('npm:') ? si.source.slice(4) : si.source;
113
+ if (si.source) {
114
+ const src = si.source.startsWith('npm:') ? si.source.slice(4) : si.source;
115
+ if (src === 'auto' && si.scope) return si.scope;
116
+ return src;
117
+ }
114
118
  if (si.origin) return si.origin;
115
119
  if (si.kind) return si.kind;
116
120
  }
@@ -122,16 +126,20 @@ function buildItems() {
122
126
  const s = state.snapshot;
123
127
  if (!s) return [];
124
128
  const items = [];
129
+ const activeSet = new Set(s.activeTools ?? []);
130
+ const activeIds = new Set();
125
131
  for (const t of s.tools ?? []) {
126
132
  const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
133
+ const id = `tool:${t.name}`;
134
+ activeIds.add(id);
127
135
  items.push({
128
136
  kind: 'tool',
129
- id: `tool:${t.name}`,
137
+ id,
130
138
  name: t.name ?? '(tool)',
131
139
  source: inferSource(t),
132
140
  description,
133
141
  chars: (t.description ?? '').length,
134
- active: (s.activeTools ?? []).includes(t.name),
142
+ active: activeSet.has(t.name),
135
143
  path: inferPath(t),
136
144
  raw: t,
137
145
  });
@@ -139,18 +147,39 @@ function buildItems() {
139
147
  for (const c of s.commands ?? []) {
140
148
  const name = c.name ?? c.command ?? '';
141
149
  const isSkill = name.startsWith('skill:');
150
+ const src = inferSource(c);
151
+ const isPrompt = !isSkill && c.source === 'prompt';
152
+ const kind = isSkill ? 'skill' : isPrompt ? 'prompt' : 'command';
142
153
  const description = (c.description ?? '').replace(/\s+/g, ' ').trim();
154
+ const id = `${kind}:${name}`;
155
+ activeIds.add(id);
143
156
  items.push({
144
- kind: isSkill ? 'skill' : 'command',
145
- id: `${isSkill ? 'skill' : 'command'}:${name}`,
157
+ kind,
158
+ id,
146
159
  name: `/${name}`,
147
- source: inferSource(c),
160
+ source: src,
148
161
  description,
149
162
  chars: (c.description ?? '').length,
150
163
  path: inferPath(c),
151
164
  raw: c,
152
165
  });
153
166
  }
167
+ for (const d of s.disabledItems ?? []) {
168
+ const id = `${d.kind}:${d.name}`;
169
+ if (activeIds.has(id)) continue;
170
+ const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
171
+ items.push({
172
+ kind: d.kind,
173
+ id,
174
+ name: d.displayName ?? d.name,
175
+ source: d.source ?? '(package)',
176
+ description,
177
+ chars: (d.description ?? '').length,
178
+ disabled: true,
179
+ path: d.path ?? null,
180
+ raw: d,
181
+ });
182
+ }
154
183
  if (s.systemPrompt) {
155
184
  for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
156
185
  items.push({
@@ -189,8 +218,9 @@ function filterItems(items) {
189
218
  });
190
219
  }
191
220
 
192
- const KIND_ORDER = ['context', 'tool', 'command', 'skill'];
193
- const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', skill: 'Skills' };
221
+ const KIND_ORDER = ['context', 'tool', 'command', 'prompt', 'skill'];
222
+ const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', prompt: 'Prompts', skill: 'Skills' };
223
+ const SOURCE_RANK = { user: 0, project: 1, auto: 2, builtin: 3 };
194
224
  //#endregion
195
225
 
196
226
  //#region ICONS
@@ -201,6 +231,9 @@ function iconFor(kind) {
201
231
  if (kind === 'command') {
202
232
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`;
203
233
  }
234
+ if (kind === 'prompt') {
235
+ return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/><line x1="7" y1="9" x2="17" y2="9"/><line x1="7" y1="13" x2="13" y2="13"/></svg>`;
236
+ }
204
237
  if (kind === 'skill') {
205
238
  return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`;
206
239
  }
@@ -334,11 +367,9 @@ function renderTree() {
334
367
  const useSubgroups = bySource.size > 1 && kind !== 'context';
335
368
  const sources = useSubgroups
336
369
  ? [...bySource.keys()].sort((a, b) => {
337
- const rank = (s) => {
338
- const i = ['auto', 'builtin'].indexOf(s);
339
- return i === -1 ? Infinity : i;
340
- };
341
- return (rank(a) - rank(b)) || a.localeCompare(b);
370
+ const ra = SOURCE_RANK[a] ?? Infinity;
371
+ const rb = SOURCE_RANK[b] ?? Infinity;
372
+ return (ra - rb) || a.localeCompare(b);
342
373
  })
343
374
  : ['__all__'];
344
375
  if (!useSubgroups) bySource.set('__all__', list);
@@ -367,8 +398,9 @@ function renderTree() {
367
398
  const descHtml = it.description
368
399
  ? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
369
400
  : '<div class="spacer"></div>';
401
+ const disabledCls = it.disabled ? ' disabled' : '';
370
402
  html.push(`
371
- <div class="tree-row ${selected}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
403
+ <div class="tree-row ${selected}${disabledCls}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
372
404
  <div class="tree-icon">${iconFor(it.kind)}</div>
373
405
  <div class="tree-label">${esc(it.name)}</div>
374
406
  ${descHtml}
@@ -443,6 +475,8 @@ function renderDetail() {
443
475
  `);
444
476
  }
445
477
 
478
+ const scope = it.raw?.scope;
479
+ const settingsPath = it.raw?.settingsPath;
446
480
  bodySections.push(`
447
481
  <div class="detail-section">
448
482
  <h4>Metadata</h4>
@@ -450,6 +484,8 @@ function renderDetail() {
450
484
  <span class="detail-meta-item">Kind: ${esc(it.kind)}</span>
451
485
  <span class="detail-meta-item">Source: ${esc(it.source)}</span>
452
486
  ${it.active != null ? `<span class="detail-meta-item">Active: ${it.active ? 'yes' : 'no'}</span>` : ''}
487
+ ${it.disabled ? `<span class="detail-meta-item">Disabled${scope ? ` (${esc(scope)})` : ''}</span>` : ''}
488
+ ${settingsPath ? `<span class="detail-meta-item">Settings: ${esc(settingsPath)}</span>` : ''}
453
489
  </div>
454
490
  </div>
455
491
  `);
@@ -582,6 +618,33 @@ function bindEvents() {
582
618
  toast('refreshed');
583
619
  });
584
620
 
621
+ $('cleanupSessionsBtn').addEventListener('click', async () => {
622
+ const keep = state.currentSessionId;
623
+ if (!keep) { toast('select a session first'); return; }
624
+ const others = state.sessions.filter((s) => s.id !== keep).length;
625
+ if (!others) { toast('only one session — nothing to remove'); return; }
626
+ if (!confirm(`Delete ${others} other snapshot${others === 1 ? '' : 's'} and keep only the selected session?`)) return;
627
+ const btn = $('cleanupSessionsBtn');
628
+ btn.classList.add('loading');
629
+ try {
630
+ const r = await fetch('/api/sessions/cleanup', {
631
+ method: 'POST',
632
+ headers: { 'Content-Type': 'application/json' },
633
+ body: JSON.stringify({ keep }),
634
+ });
635
+ const data = await r.json();
636
+ if (!r.ok || data.ok === false) throw new Error(data.error || `HTTP ${r.status}`);
637
+ const n = data.removed?.length ?? 0;
638
+ await loadSessions();
639
+ renderTopbar();
640
+ toast(n ? `removed ${n} session${n === 1 ? '' : 's'}` : 'nothing to remove');
641
+ } catch (e) {
642
+ toast(`cleanup failed: ${e.message}`);
643
+ } finally {
644
+ btn.classList.remove('loading');
645
+ }
646
+ });
647
+
585
648
  $('themeBtn').addEventListener('click', () => {
586
649
  document.body.classList.toggle('light');
587
650
  try {
package/public/index.html CHANGED
@@ -31,9 +31,13 @@
31
31
  <option value="context">Context</option>
32
32
  <option value="tool">Tools</option>
33
33
  <option value="command">Commands</option>
34
+ <option value="prompt">Prompts</option>
34
35
  <option value="skill">Skills</option>
35
36
  </select>
36
37
  <select class="topbar-select" id="sessionSelect" title="Switch session"></select>
38
+ <button class="topbar-btn" id="cleanupSessionsBtn" title="Delete all snapshots except the selected session">
39
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 01-2 2H9a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a2 2 0 012-2h2a2 2 0 012 2v2"/></svg>
40
+ </button>
37
41
  <div class="topbar-project" id="projectBtn" title="Current cwd">
38
42
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
39
43
  <span id="projectPath">—</span>
package/public/style.css CHANGED
@@ -359,15 +359,30 @@ body {
359
359
  border-bottom: 1px solid var(--border);
360
360
  position: sticky;
361
361
  top: 0;
362
- z-index: 2;
362
+ z-index: 3;
363
363
  font-size: 12px;
364
364
  text-transform: uppercase;
365
365
  letter-spacing: 0.04em;
366
366
  padding: 8px 12px;
367
367
  }
368
+ /* Stacks under the group row (top:0); 38px matches .tree-row min-height. */
369
+ .tree-row.marketplace-row.tree-subgroup {
370
+ top: 38px;
371
+ z-index: 2;
372
+ }
368
373
  .tree-row.marketplace-row.virtual .tree-icon {
369
374
  color: var(--plan);
370
375
  }
376
+ .tree-row.disabled .tree-label,
377
+ .tree-row.disabled .tree-desc,
378
+ .tree-row.disabled .tree-icon {
379
+ opacity: 0.45;
380
+ }
381
+ .tree-row.disabled .tree-meta {
382
+ color: var(--text-muted, #888);
383
+ font-style: italic;
384
+ opacity: 0.8;
385
+ }
371
386
 
372
387
  .tree-indent {
373
388
  flex-shrink: 0;
@@ -433,10 +448,11 @@ body {
433
448
  .tree-subgroup .tree-icon { width: 14px; height: 14px; }
434
449
  .tree-subgroup .tree-icon svg { width: 14px; height: 14px; }
435
450
  .tree-meta {
436
- font-size: 10px;
437
- color: var(--text-muted);
451
+ font-size: 11px;
452
+ color: var(--text, inherit);
438
453
  flex-shrink: 0;
439
454
  white-space: nowrap;
455
+ opacity: 0.65;
440
456
  }
441
457
  .tree-desc {
442
458
  font-size: 11px;
package/server.js CHANGED
@@ -40,6 +40,18 @@ app.get('/api/sessions', async (_req, res) => {
40
40
  }
41
41
  });
42
42
 
43
+ app.post('/api/sessions/cleanup', async (req, res) => {
44
+ try {
45
+ const keep = (req.body && req.body.keep) || req.query.keep || null;
46
+ const result = keep
47
+ ? await snapshots.keepOnly(String(keep))
48
+ : await snapshots.cleanupIndex();
49
+ res.json({ ok: true, ...result });
50
+ } catch (e) {
51
+ res.status(500).json({ ok: false, error: e.message });
52
+ }
53
+ });
54
+
43
55
  const githubMemo = new Map(); // sessionId -> Record<root, {url, source}>
44
56
 
45
57
  function collectSourceRoots(snap) {