pi-inspect 0.2.0 → 0.4.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,31 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import {
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ unlinkSync,
9
+ watch as fsWatch,
10
+ writeFileSync,
11
+ type FSWatcher,
12
+ } from "node:fs";
3
13
  import { createConnection } from "node:net";
4
14
  import { homedir } from "node:os";
5
- import { join as joinPath, resolve as resolvePath } from "node:path";
15
+ import {
16
+ dirname,
17
+ join as joinPath,
18
+ relative as relativePath,
19
+ resolve as resolvePath,
20
+ } from "node:path";
6
21
  import { fileURLToPath } from "node:url";
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import {
23
+ DefaultPackageManager,
24
+ type ExtensionAPI,
25
+ parseFrontmatter,
26
+ type ResolvedResource,
27
+ SettingsManager,
28
+ } from "@earendil-works/pi-coding-agent";
8
29
 
9
30
  const extDir = fileURLToPath(new URL(".", import.meta.url));
10
31
  const port = 5462;
@@ -13,6 +34,8 @@ const url = `http://localhost:${port}`;
13
34
  const INSPECT_DIR = joinPath(homedir(), ".pi", "agent", "inspect");
14
35
  const SNAP_DIR = joinPath(INSPECT_DIR, "snapshots");
15
36
  const INDEX_PATH = joinPath(SNAP_DIR, "index.json");
37
+ const REQ_DIR = joinPath(INSPECT_DIR, "requests");
38
+ const REQ_STALE_MS = 60 * 60 * 1000; // 1 hour
16
39
 
17
40
  let child: ChildProcess | null = null;
18
41
  let lastStderr = "";
@@ -100,7 +123,288 @@ function writeSnapshot(id: string, snapshot: unknown): void {
100
123
  writeFileSync(joinPath(SNAP_DIR, `${sanitize(id)}.json`), JSON.stringify(snapshot, null, 2), "utf8");
101
124
  }
102
125
 
103
- function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
126
+ type DisabledKind = "command" | "prompt" | "skill" | "extension" | "theme";
127
+ type DisabledScope = "user" | "project";
128
+ type DisabledItem = {
129
+ kind: DisabledKind;
130
+ name: string;
131
+ displayName: string;
132
+ description: string;
133
+ source: string;
134
+ scope: DisabledScope;
135
+ settingsPath: string;
136
+ path: string;
137
+ reason: string;
138
+ resourceKind: ResourceKind;
139
+ baseDir: string;
140
+ };
141
+
142
+ const AGENT_DIR = joinPath(homedir(), ".pi", "agent");
143
+ const SETTINGS_PATH = joinPath(AGENT_DIR, "settings.json");
144
+
145
+ type ResourceKind = "skills" | "prompts" | "extensions" | "themes";
146
+ const RESOURCE_KINDS: readonly ResourceKind[] = ["skills", "prompts", "extensions", "themes"];
147
+
148
+ const KIND_MAP: Record<ResourceKind, { kind: DisabledKind; display: (n: string) => string }> = {
149
+ prompts: { kind: "prompt", display: (n) => `/${n}` },
150
+ skills: { kind: "skill", display: (n) => `/skill:${n}` },
151
+ extensions: { kind: "extension", display: (n) => n },
152
+ themes: { kind: "theme", display: (n) => n },
153
+ };
154
+
155
+ function sourceLabel(source: string): string {
156
+ if (source.startsWith("npm:")) return source.slice(4);
157
+ const norm = source.replace(/\\/g, "/").replace(/\/$/, "");
158
+ return norm.split("/").pop() ?? norm;
159
+ }
160
+
161
+ const describeCache = new Map<string, { mtimeMs: number; desc: string }>();
162
+
163
+ function describeFromPath(path: string): string {
164
+ let mtimeMs: number;
165
+ try { mtimeMs = statSync(path).mtimeMs; } catch { return ""; }
166
+ const cached = describeCache.get(path);
167
+ if (cached && cached.mtimeMs === mtimeMs) return cached.desc;
168
+ let desc = "";
169
+ try {
170
+ const { frontmatter, body } = parseFrontmatter<{ description?: string }>(readFileSync(path, "utf8"));
171
+ desc = typeof frontmatter.description === "string" && frontmatter.description
172
+ ? frontmatter.description
173
+ : body.split(/\r?\n/).find((l) => l.trim() && !l.trim().startsWith("#"))?.trim().slice(0, 240) ?? "";
174
+ } catch {}
175
+ describeCache.set(path, { mtimeMs, desc });
176
+ return desc;
177
+ }
178
+
179
+ function nameFromPath(kind: ResourceKind, path: string): string {
180
+ const norm = path.replace(/\\/g, "/");
181
+ const base = norm.split("/").pop() ?? norm;
182
+ if (kind === "skills" && /^SKILL\.md$/i.test(base)) {
183
+ const parts = norm.split("/");
184
+ return parts[parts.length - 2] ?? base;
185
+ }
186
+ return base.replace(/\.(md|ts|js|mjs|cjs|json|toml|ya?ml)$/i, "");
187
+ }
188
+
189
+ function buildItemFromResource(
190
+ kind: ResourceKind,
191
+ r: ResolvedResource,
192
+ sessionCwd: string,
193
+ ): DisabledItem {
194
+ const md = r.metadata;
195
+ const scope: DisabledScope = md.scope === "project" ? "project" : "user";
196
+ const label = md.origin === "package" ? sourceLabel(md.source) : scope;
197
+ const name = nameFromPath(kind, r.path);
198
+ const cfg = KIND_MAP[kind];
199
+ return {
200
+ kind: cfg.kind,
201
+ name,
202
+ displayName: cfg.display(name),
203
+ description: describeFromPath(r.path),
204
+ source: label,
205
+ scope,
206
+ settingsPath: scope === "project" ? joinPath(sessionCwd, ".pi", "settings.json") : SETTINGS_PATH,
207
+ path: r.path,
208
+ reason: md.source,
209
+ resourceKind: kind,
210
+ baseDir: md.baseDir ?? AGENT_DIR,
211
+ };
212
+ }
213
+
214
+ type ResourceListing = { disabled: DisabledItem[]; pending: DisabledItem[] };
215
+
216
+ async function discoverFromPackages(
217
+ cwd: string | null,
218
+ activePaths: Set<string>,
219
+ ): Promise<ResourceListing> {
220
+ const sessionCwd = cwd ?? process.cwd();
221
+ const sm = SettingsManager.create(sessionCwd, AGENT_DIR);
222
+ const pm = new DefaultPackageManager({ cwd: sessionCwd, agentDir: AGENT_DIR, settingsManager: sm });
223
+ let resolved;
224
+ try {
225
+ resolved = await pm.resolve(async () => "skip");
226
+ } catch (e: any) {
227
+ console.warn(`pi-inspect: package resolve failed: ${e?.message ?? e}`);
228
+ return { disabled: [], pending: [] };
229
+ }
230
+
231
+ const disabledByPath = new Map<string, DisabledItem>();
232
+ const pendingByPath = new Map<string, DisabledItem>();
233
+ // Pending detection only makes sense for resources with a 1:1 path→command mapping.
234
+ // Extensions register tools at different paths, themes don't appear as commands at all.
235
+ const PENDABLE: ReadonlySet<ResourceKind> = new Set(["skills", "prompts"]);
236
+ for (const kind of RESOURCE_KINDS) {
237
+ const list = resolved[kind] as ResolvedResource[];
238
+ for (const r of list) {
239
+ const item = buildItemFromResource(kind, r, sessionCwd);
240
+ if (!r.enabled) {
241
+ disabledByPath.set(r.path, item);
242
+ } else if (
243
+ PENDABLE.has(kind) &&
244
+ !activePaths.has(r.path.replace(/\\/g, "/").toLowerCase())
245
+ ) {
246
+ // Resolved-enabled but pi's boot-frozen command map doesn't have it yet.
247
+ pendingByPath.set(r.path, item);
248
+ }
249
+ }
250
+ }
251
+ return { disabled: [...disabledByPath.values()], pending: [...pendingByPath.values()] };
252
+ }
253
+
254
+ type ToggleRequest = {
255
+ id: string;
256
+ ts: number;
257
+ action: "enable" | "disable";
258
+ resourceKind: ResourceKind;
259
+ path: string;
260
+ scope: DisabledScope;
261
+ };
262
+
263
+ function normPath(p: string): string {
264
+ return resolvePath(p).replace(/\\/g, "/").toLowerCase();
265
+ }
266
+
267
+ function ensureReqDir(): void {
268
+ mkdirSync(REQ_DIR, { recursive: true });
269
+ }
270
+
271
+ function sweepStaleRequests(): void {
272
+ let entries: string[];
273
+ try { entries = readdirSync(REQ_DIR); } catch { return; }
274
+ const now = Date.now();
275
+ for (const f of entries) {
276
+ const full = joinPath(REQ_DIR, f);
277
+ try {
278
+ const st = statSync(full);
279
+ if (now - st.mtimeMs > REQ_STALE_MS) unlinkSync(full);
280
+ } catch {}
281
+ }
282
+ }
283
+
284
+ // Mirrors the toggle logic in pi-coding-agent's ConfigSelectorComponent
285
+ // (modes/interactive/components/config-selector.js). Strips any existing
286
+ // `+`/`-`/`!` entries for the pattern, then appends the new one.
287
+ function applyPattern(arr: string[], pattern: string, enabled: boolean): string[] {
288
+ const filtered = arr.filter((p) => {
289
+ const stripped = p.startsWith("!") || p.startsWith("+") || p.startsWith("-") ? p.slice(1) : p;
290
+ return stripped !== pattern;
291
+ });
292
+ filtered.push(enabled ? `+${pattern}` : `-${pattern}`);
293
+ return filtered;
294
+ }
295
+
296
+ async function processToggleRequest(req: ToggleRequest, sessionCwd: string): Promise<void> {
297
+ const sm = SettingsManager.create(sessionCwd, AGENT_DIR);
298
+ const pm = new DefaultPackageManager({ cwd: sessionCwd, agentDir: AGENT_DIR, settingsManager: sm });
299
+ const resolved = await pm.resolve(async () => "skip");
300
+ const list = resolved[req.resourceKind] as ResolvedResource[];
301
+ const target = list.find((r) => normPath(r.path) === normPath(req.path));
302
+ if (!target) throw new Error(`resource not in resolution: ${req.path}`);
303
+ const md = target.metadata;
304
+ const enabled = req.action === "enable";
305
+ const scope: DisabledScope = md.scope === "project" ? "project" : "user";
306
+
307
+ if (md.origin === "package") {
308
+ const baseDir = md.baseDir ?? dirname(target.path);
309
+ const pattern = relativePath(baseDir, target.path);
310
+ const settings = scope === "project" ? sm.getProjectSettings() : sm.getGlobalSettings();
311
+ const packages = [...(settings.packages ?? [])];
312
+ const idx = packages.findIndex((p) => (typeof p === "string" ? p : p.source) === md.source);
313
+ if (idx < 0) throw new Error(`package not found in settings: ${md.source}`);
314
+ let pkg = packages[idx];
315
+ if (typeof pkg === "string") {
316
+ pkg = { source: pkg };
317
+ packages[idx] = pkg;
318
+ }
319
+ const arrayKey = req.resourceKind as keyof typeof pkg & ("extensions" | "skills" | "prompts" | "themes");
320
+ const current = ((pkg as any)[arrayKey] as string[] | undefined) ?? [];
321
+ const updated = applyPattern(current, pattern, enabled);
322
+ (pkg as any)[arrayKey] = updated.length > 0 ? updated : undefined;
323
+ const hasFilters = (["extensions", "skills", "prompts", "themes"] as const).some(
324
+ (k) => (pkg as any)[k] !== undefined,
325
+ );
326
+ if (!hasFilters) packages[idx] = (pkg as any).source;
327
+ if (scope === "project") sm.setProjectPackages(packages);
328
+ else sm.setPackages(packages);
329
+ await sm.flush();
330
+ return;
331
+ }
332
+
333
+ // top-level
334
+ const baseDir = scope === "project" ? joinPath(sessionCwd, ".pi") : AGENT_DIR;
335
+ const pattern = relativePath(baseDir, target.path);
336
+ const settings = scope === "project" ? sm.getProjectSettings() : sm.getGlobalSettings();
337
+ const current = ((settings as any)[req.resourceKind] as string[] | undefined) ?? [];
338
+ const updated = applyPattern(current, pattern, enabled);
339
+ const setters: Record<ResourceKind, { user: string; project: string }> = {
340
+ skills: { user: "setSkillPaths", project: "setProjectSkillPaths" },
341
+ prompts: { user: "setPromptTemplatePaths", project: "setProjectPromptTemplatePaths" },
342
+ extensions: { user: "setExtensionPaths", project: "setProjectExtensionPaths" },
343
+ themes: { user: "setThemePaths", project: "setProjectThemePaths" },
344
+ };
345
+ (sm as any)[setters[req.resourceKind][scope]](updated);
346
+ await sm.flush();
347
+ }
348
+
349
+ let lastCtx: any = null;
350
+ let reqWatcher: FSWatcher | null = null;
351
+ const recentlyHandled = new Set<string>();
352
+
353
+ async function handleRequestFile(full: string): Promise<void> {
354
+ if (recentlyHandled.has(full)) return;
355
+ recentlyHandled.add(full);
356
+ setTimeout(() => recentlyHandled.delete(full), 5000);
357
+
358
+ let raw: string;
359
+ try { raw = readFileSync(full, "utf8"); } catch { return; }
360
+ if (!raw.trim()) return;
361
+ let req: ToggleRequest;
362
+ try { req = JSON.parse(raw) as ToggleRequest; } catch (e: any) {
363
+ console.warn(`pi-inspect: bad request file ${full}: ${e?.message ?? e}`);
364
+ try { renameSync(full, `${full}.err.json`); } catch {}
365
+ return;
366
+ }
367
+
368
+ const cwd = lastCtx?.sessionManager?.getCwd?.() ?? process.cwd();
369
+ try {
370
+ await processToggleRequest(req, cwd);
371
+ try { unlinkSync(full); } catch {}
372
+ if (lastCtx) await captureSnapshot(lastCtx);
373
+ } catch (e: any) {
374
+ console.warn(`pi-inspect: toggle failed: ${e?.message ?? e}`);
375
+ try {
376
+ writeFileSync(`${full}.err.json`, JSON.stringify({ error: e?.message ?? String(e), req }, null, 2));
377
+ unlinkSync(full);
378
+ } catch {}
379
+ }
380
+ }
381
+
382
+ function setupRequestWatcher(): void {
383
+ ensureReqDir();
384
+ sweepStaleRequests();
385
+ // Drain orphans at startup
386
+ try {
387
+ for (const f of readdirSync(REQ_DIR)) {
388
+ if (f.endsWith(".err.json") || !f.endsWith(".json")) continue;
389
+ void handleRequestFile(joinPath(REQ_DIR, f));
390
+ }
391
+ } catch {}
392
+ if (reqWatcher) return;
393
+ try {
394
+ reqWatcher = fsWatch(REQ_DIR, (_event, filename) => {
395
+ if (!filename) return;
396
+ const name = String(filename);
397
+ if (!name.endsWith(".json") || name.endsWith(".err.json")) return;
398
+ const full = joinPath(REQ_DIR, name);
399
+ try { statSync(full); } catch { return; }
400
+ void handleRequestFile(full);
401
+ });
402
+ } catch (e: any) {
403
+ console.warn(`pi-inspect: request watcher failed: ${e?.message ?? e}`);
404
+ }
405
+ }
406
+
407
+ async function captureSnapshot(ctx: any): Promise<{ id: string; entry: IndexEntry } | null> {
104
408
  const sm = ctx.sessionManager;
105
409
  const id = sm?.getSessionId?.();
106
410
  if (!id) return null;
@@ -112,8 +416,18 @@ function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
112
416
  const commands = typeof pi?.getCommands === "function" ? pi.getCommands() : [];
113
417
  const tools = typeof pi?.getAllTools === "function" ? pi.getAllTools() : [];
114
418
  const activeTools = typeof pi?.getActiveTools === "function" ? pi.getActiveTools() : [];
419
+ const activePaths = new Set<string>();
420
+ for (const c of commands) {
421
+ const p = c?.sourceInfo?.path;
422
+ if (typeof p === "string") activePaths.add(p.replace(/\\/g, "/").toLowerCase());
423
+ }
424
+ for (const t of tools) {
425
+ const p = t?.sourceInfo?.path;
426
+ if (typeof p === "string") activePaths.add(p.replace(/\\/g, "/").toLowerCase());
427
+ }
428
+ const { disabled: disabledItems, pending: pendingItems } = await discoverFromPackages(cwd, activePaths);
115
429
  const capturedAt = Date.now();
116
- const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, capturedAt };
430
+ const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, disabledItems, pendingItems, capturedAt };
117
431
  try {
118
432
  writeSnapshot(id, snap);
119
433
  upsertIndex({ id, cwd, name, model, capturedAt });
@@ -183,13 +497,16 @@ function showHelp(notify: (m: string, l?: "info" | "error") => void) {
183
497
 
184
498
  export default function inspectExtension(pi: ExtensionAPI) {
185
499
  piRef = pi;
500
+ setupRequestWatcher();
186
501
  pi.on("session_start", async (_event, ctx) => {
187
- captureSnapshot(ctx);
502
+ lastCtx = ctx;
503
+ await captureSnapshot(ctx);
188
504
  });
189
505
  // session_start fires before all extensions register their tools/commands.
190
506
  // before_agent_start fires after the user's first prompt with the fully assembled state — re-capture then.
191
507
  pi.on("before_agent_start", async (_event, ctx) => {
192
- captureSnapshot(ctx);
508
+ lastCtx = ctx;
509
+ await captureSnapshot(ctx);
193
510
  });
194
511
 
195
512
  pi.registerCommand("inspect", {
@@ -207,6 +524,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
207
524
  return SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
208
525
  },
209
526
  handler: async (args, ctx) => {
527
+ lastCtx = ctx;
210
528
  const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
211
529
  const tokens = args.trim().split(/\s+/).filter(Boolean);
212
530
  const first = tokens[0] as Sub | string | undefined;
@@ -226,7 +544,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
226
544
 
227
545
  if (first === "start") {
228
546
  if (!(await startServer(notify))) return;
229
- captureSnapshot(ctx);
547
+ await captureSnapshot(ctx);
230
548
  notify(`pi-inspect started → ${url}`);
231
549
  return;
232
550
  }
@@ -251,7 +569,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
251
569
  }
252
570
 
253
571
  if (first === "snapshot") {
254
- const r = captureSnapshot(ctx);
572
+ const r = await captureSnapshot(ctx);
255
573
  notify(r ? `snapshot captured: ${r.id}` : "no active session to snapshot", r ? "info" : "error");
256
574
  return;
257
575
  }
@@ -261,7 +579,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
261
579
  const openTarget = (isExplicitOpen ? (tokens[1] ?? "web") : "web") as "web" | "app";
262
580
 
263
581
  if (!(await startServer(notify))) return;
264
- captureSnapshot(ctx);
582
+ await captureSnapshot(ctx);
265
583
 
266
584
  let openId: string | null = null;
267
585
  if (!isExplicitOpen && first && !(SUBCOMMANDS as readonly string[]).includes(first)) {
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.4.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
  }
@@ -118,39 +122,94 @@ function inferSource(x) {
118
122
  }
119
123
 
120
124
  //#region MODEL
125
+ function normPath(p) {
126
+ if (!p || typeof p !== 'string') return null;
127
+ return p.replace(/\\/g, '/').toLowerCase();
128
+ }
129
+
121
130
  function buildItems() {
122
131
  const s = state.snapshot;
123
132
  if (!s) return [];
124
133
  const items = [];
134
+ const activeSet = new Set(s.activeTools ?? []);
135
+ const activeIds = new Set();
136
+ // pi.getCommands() is frozen at session boot. After a toggle, the SDK-resolved
137
+ // disabledItems is the source of truth — drop any active command whose path
138
+ // matches a disabled resource so the UI reflects the post-toggle state.
139
+ const disabledPaths = new Set(
140
+ (s.disabledItems ?? []).map((d) => normPath(d.path)).filter(Boolean),
141
+ );
125
142
  for (const t of s.tools ?? []) {
126
143
  const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
144
+ const id = `tool:${t.name}`;
145
+ activeIds.add(id);
127
146
  items.push({
128
147
  kind: 'tool',
129
- id: `tool:${t.name}`,
148
+ id,
130
149
  name: t.name ?? '(tool)',
131
150
  source: inferSource(t),
132
151
  description,
133
152
  chars: (t.description ?? '').length,
134
- active: (s.activeTools ?? []).includes(t.name),
153
+ active: activeSet.has(t.name),
135
154
  path: inferPath(t),
136
155
  raw: t,
137
156
  });
138
157
  }
139
158
  for (const c of s.commands ?? []) {
159
+ const cp = normPath(c?.sourceInfo?.path);
160
+ if (cp && disabledPaths.has(cp)) continue;
140
161
  const name = c.name ?? c.command ?? '';
141
162
  const isSkill = name.startsWith('skill:');
163
+ const src = inferSource(c);
164
+ const isPrompt = !isSkill && c.source === 'prompt';
165
+ const kind = isSkill ? 'skill' : isPrompt ? 'prompt' : 'command';
142
166
  const description = (c.description ?? '').replace(/\s+/g, ' ').trim();
167
+ const idName = isSkill ? name.slice('skill:'.length) : name;
168
+ const id = `${kind}:${idName}`;
169
+ activeIds.add(id);
143
170
  items.push({
144
- kind: isSkill ? 'skill' : 'command',
145
- id: `${isSkill ? 'skill' : 'command'}:${name}`,
171
+ kind,
172
+ id,
146
173
  name: `/${name}`,
147
- source: inferSource(c),
174
+ source: src,
148
175
  description,
149
176
  chars: (c.description ?? '').length,
150
177
  path: inferPath(c),
151
178
  raw: c,
152
179
  });
153
180
  }
181
+ for (const d of s.disabledItems ?? []) {
182
+ const id = `${d.kind}:${d.name}`;
183
+ if (activeIds.has(id)) continue;
184
+ const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
185
+ items.push({
186
+ kind: d.kind,
187
+ id,
188
+ name: d.displayName ?? d.name,
189
+ source: d.source ?? '(package)',
190
+ description,
191
+ chars: (d.description ?? '').length,
192
+ disabled: true,
193
+ path: d.path ?? null,
194
+ raw: d,
195
+ });
196
+ }
197
+ for (const d of s.pendingItems ?? []) {
198
+ const id = `${d.kind}:${d.name}`;
199
+ if (activeIds.has(id)) continue;
200
+ const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
201
+ items.push({
202
+ kind: d.kind,
203
+ id,
204
+ name: d.displayName ?? d.name,
205
+ source: d.source ?? '(package)',
206
+ description,
207
+ chars: (d.description ?? '').length,
208
+ pending: true,
209
+ path: d.path ?? null,
210
+ raw: d,
211
+ });
212
+ }
154
213
  if (s.systemPrompt) {
155
214
  for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
156
215
  items.push({
@@ -189,8 +248,9 @@ function filterItems(items) {
189
248
  });
190
249
  }
191
250
 
192
- const KIND_ORDER = ['context', 'tool', 'command', 'skill'];
193
- const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', skill: 'Skills' };
251
+ const KIND_ORDER = ['context', 'tool', 'command', 'prompt', 'skill'];
252
+ const KIND_LABEL = { context: 'Context', tool: 'Tools', command: 'Commands', prompt: 'Prompts', skill: 'Skills' };
253
+ const SOURCE_RANK = { user: 0, project: 1, auto: 2, builtin: 3 };
194
254
  //#endregion
195
255
 
196
256
  //#region ICONS
@@ -201,6 +261,9 @@ function iconFor(kind) {
201
261
  if (kind === 'command') {
202
262
  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
263
  }
264
+ if (kind === 'prompt') {
265
+ 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>`;
266
+ }
204
267
  if (kind === 'skill') {
205
268
  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
269
  }
@@ -305,7 +368,10 @@ function renderTree() {
305
368
 
306
369
  const groups = new Map();
307
370
  for (const k of KIND_ORDER) groups.set(k, []);
308
- for (const it of items) groups.get(it.kind).push(it);
371
+ for (const it of items) {
372
+ const g = groups.get(it.kind);
373
+ if (g) g.push(it);
374
+ }
309
375
 
310
376
  const html = [];
311
377
  const rows = [];
@@ -334,11 +400,9 @@ function renderTree() {
334
400
  const useSubgroups = bySource.size > 1 && kind !== 'context';
335
401
  const sources = useSubgroups
336
402
  ? [...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);
403
+ const ra = SOURCE_RANK[a] ?? Infinity;
404
+ const rb = SOURCE_RANK[b] ?? Infinity;
405
+ return (ra - rb) || a.localeCompare(b);
342
406
  })
343
407
  : ['__all__'];
344
408
  if (!useSubgroups) bySource.set('__all__', list);
@@ -367,10 +431,12 @@ function renderTree() {
367
431
  const descHtml = it.description
368
432
  ? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
369
433
  : '<div class="spacer"></div>';
434
+ const stateCls = it.disabled ? ' disabled' : (it.pending ? ' pending' : '');
435
+ const stateBadge = it.pending ? `<span class="tree-badge pending" title="Enabled in settings but not yet active — restart pi">restart pi</span>` : '';
370
436
  html.push(`
371
- <div class="tree-row ${selected}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
437
+ <div class="tree-row ${selected}${stateCls}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
372
438
  <div class="tree-icon">${iconFor(it.kind)}</div>
373
- <div class="tree-label">${esc(it.name)}</div>
439
+ <div class="tree-label">${esc(it.name)}${stateBadge}</div>
374
440
  ${descHtml}
375
441
  <div class="tree-meta">${esc(it.source)}</div>
376
442
  </div>
@@ -443,6 +509,8 @@ function renderDetail() {
443
509
  `);
444
510
  }
445
511
 
512
+ const scope = it.raw?.scope;
513
+ const settingsPath = it.raw?.settingsPath;
446
514
  bodySections.push(`
447
515
  <div class="detail-section">
448
516
  <h4>Metadata</h4>
@@ -450,6 +518,8 @@ function renderDetail() {
450
518
  <span class="detail-meta-item">Kind: ${esc(it.kind)}</span>
451
519
  <span class="detail-meta-item">Source: ${esc(it.source)}</span>
452
520
  ${it.active != null ? `<span class="detail-meta-item">Active: ${it.active ? 'yes' : 'no'}</span>` : ''}
521
+ ${it.disabled ? `<span class="detail-meta-item">Disabled${scope ? ` (${esc(scope)})` : ''}</span>` : ''}
522
+ ${settingsPath ? `<span class="detail-meta-item">Settings: ${esc(settingsPath)}</span>` : ''}
453
523
  </div>
454
524
  </div>
455
525
  `);
@@ -471,10 +541,12 @@ function renderDetail() {
471
541
  }
472
542
 
473
543
  const ghUrl = githubUrlFor(it);
544
+ const toggleInfo = toggleInfoFor(it);
474
545
  panel.innerHTML = `
475
546
  <div class="detail-header">
476
547
  <h3>${iconFor(it.kind)} ${esc(it.name)} <span class="version">${esc(it.kind)}</span></h3>
477
548
  <div class="detail-header-actions">
549
+ ${toggleInfo ? `<button class="detail-action" id="toggleBtn" title="${toggleInfo.enable ? 'Enable' : 'Disable'} (${toggleInfo.scope})">${toggleInfo.enable ? 'Enable' : 'Disable'}</button>` : ''}
478
550
  ${it.path ? `<button class="detail-action" id="openEditorBtn" title="Open in $EDITOR"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button>` : ''}
479
551
  ${it.path ? `<button class="detail-action" id="copyPathBtn" title="Copy path"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>` : ''}
480
552
  ${ghUrl ? `<button class="detail-action" id="openGithubBtn" title="Open on GitHub"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2c-3.2.7-3.88-1.37-3.88-1.37-.52-1.33-1.27-1.68-1.27-1.68-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.03 1.76 2.7 1.25 3.36.96.1-.75.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.05 0 0 .96-.31 3.15 1.18a10.96 10.96 0 015.74 0c2.18-1.49 3.14-1.18 3.14-1.18.63 1.59.23 2.76.11 3.05.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.25 5.68.41.36.78 1.06.78 2.14v3.17c0 .31.21.68.8.56C20.21 21.38 23.5 17.07 23.5 12 23.5 5.65 18.35.5 12 .5z"/></svg></button>` : ''}
@@ -514,6 +586,41 @@ function renderDetail() {
514
586
  if (ghBtn && ghUrl) ghBtn.addEventListener('click', () => {
515
587
  window.open(ghUrl, '_blank', 'noopener');
516
588
  });
589
+ const toggleBtn = $('toggleBtn');
590
+ if (toggleBtn && toggleInfo) toggleBtn.addEventListener('click', async () => {
591
+ toggleBtn.disabled = true;
592
+ try {
593
+ const r = await fetch('/api/toggle', {
594
+ method: 'POST',
595
+ headers: { 'content-type': 'application/json' },
596
+ body: JSON.stringify({
597
+ action: toggleInfo.enable ? 'enable' : 'disable',
598
+ resourceKind: toggleInfo.resourceKind,
599
+ path: toggleInfo.path,
600
+ scope: toggleInfo.scope,
601
+ }),
602
+ });
603
+ const j = await r.json();
604
+ if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`);
605
+ toast(`${toggleInfo.enable ? 'Enabled' : 'Disabled'} ${it.name} — restart pi to apply fully`);
606
+ } catch (e) {
607
+ toast(`Toggle failed: ${e.message}`);
608
+ toggleBtn.disabled = false;
609
+ }
610
+ });
611
+ }
612
+
613
+ function toggleInfoFor(it) {
614
+ if (it.kind !== 'skill' && it.kind !== 'prompt') return null;
615
+ if (!it.path) return null;
616
+ const resourceKind = it.kind === 'skill' ? 'skills' : 'prompts';
617
+ if (it.disabled) {
618
+ const scope = it.raw?.scope === 'project' ? 'project' : 'user';
619
+ return { enable: true, resourceKind, path: it.path, scope };
620
+ }
621
+ const si = it.raw?.sourceInfo;
622
+ const scope = si?.scope === 'project' ? 'project' : 'user';
623
+ return { enable: false, resourceKind, path: it.path, scope };
517
624
  }
518
625
  //#endregion
519
626
 
@@ -582,6 +689,33 @@ function bindEvents() {
582
689
  toast('refreshed');
583
690
  });
584
691
 
692
+ $('cleanupSessionsBtn').addEventListener('click', async () => {
693
+ const keep = state.currentSessionId;
694
+ if (!keep) { toast('select a session first'); return; }
695
+ const others = state.sessions.filter((s) => s.id !== keep).length;
696
+ if (!others) { toast('only one session — nothing to remove'); return; }
697
+ if (!confirm(`Delete ${others} other snapshot${others === 1 ? '' : 's'} and keep only the selected session?`)) return;
698
+ const btn = $('cleanupSessionsBtn');
699
+ btn.classList.add('loading');
700
+ try {
701
+ const r = await fetch('/api/sessions/cleanup', {
702
+ method: 'POST',
703
+ headers: { 'Content-Type': 'application/json' },
704
+ body: JSON.stringify({ keep }),
705
+ });
706
+ const data = await r.json();
707
+ if (!r.ok || data.ok === false) throw new Error(data.error || `HTTP ${r.status}`);
708
+ const n = data.removed?.length ?? 0;
709
+ await loadSessions();
710
+ renderTopbar();
711
+ toast(n ? `removed ${n} session${n === 1 ? '' : 's'}` : 'nothing to remove');
712
+ } catch (e) {
713
+ toast(`cleanup failed: ${e.message}`);
714
+ } finally {
715
+ btn.classList.remove('loading');
716
+ }
717
+ });
718
+
585
719
  $('themeBtn').addEventListener('click', () => {
586
720
  document.body.classList.toggle('light');
587
721
  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,47 @@ 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
+ }
386
+ .tree-badge.pending {
387
+ display: inline-block;
388
+ margin-left: 8px;
389
+ padding: 1px 6px;
390
+ border-radius: 4px;
391
+ font-size: 10px;
392
+ font-weight: 600;
393
+ text-transform: uppercase;
394
+ letter-spacing: 0.4px;
395
+ background: rgba(255, 170, 60, 0.18);
396
+ color: #c97a00;
397
+ border: 1px solid rgba(201, 122, 0, 0.35);
398
+ vertical-align: middle;
399
+ }
400
+ .tree-row.pending .tree-icon {
401
+ opacity: 0.7;
402
+ }
371
403
 
372
404
  .tree-indent {
373
405
  flex-shrink: 0;
@@ -433,10 +465,11 @@ body {
433
465
  .tree-subgroup .tree-icon { width: 14px; height: 14px; }
434
466
  .tree-subgroup .tree-icon svg { width: 14px; height: 14px; }
435
467
  .tree-meta {
436
- font-size: 10px;
437
- color: var(--text-muted);
468
+ font-size: 11px;
469
+ color: var(--text, inherit);
438
470
  flex-shrink: 0;
439
471
  white-space: nowrap;
472
+ opacity: 0.65;
440
473
  }
441
474
  .tree-desc {
442
475
  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) {
@@ -123,6 +135,28 @@ app.post('/api/open', (req, res) => {
123
135
  }
124
136
  });
125
137
 
138
+ const REQ_DIR = path.join(os.homedir(), '.pi', 'agent', 'inspect', 'requests');
139
+
140
+ app.post('/api/toggle', async (req, res) => {
141
+ try {
142
+ const { action, resourceKind, path: target, scope } = req.body || {};
143
+ if (action !== 'enable' && action !== 'disable') return res.status(400).json({ ok: false, error: 'action must be enable|disable' });
144
+ if (!['skills', 'prompts', 'extensions', 'themes'].includes(resourceKind)) return res.status(400).json({ ok: false, error: 'invalid resourceKind' });
145
+ if (!target || typeof target !== 'string') return res.status(400).json({ ok: false, error: 'missing path' });
146
+ const useScope = scope === 'project' ? 'project' : 'user';
147
+ await fsp.mkdir(REQ_DIR, { recursive: true });
148
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
149
+ const file = path.join(REQ_DIR, `${id}.json`);
150
+ const tmp = `${file}.tmp`;
151
+ const body = JSON.stringify({ id, ts: Date.now(), action, resourceKind, path: target, scope: useScope });
152
+ await fsp.writeFile(tmp, body, 'utf8');
153
+ await fsp.rename(tmp, file);
154
+ res.json({ ok: true, id });
155
+ } catch (e) {
156
+ res.status(500).json({ ok: false, error: e.message });
157
+ }
158
+ });
159
+
126
160
  app.post('/api/focus', (req, res) => {
127
161
  const sid = (req.body && req.body.session) || req.query.session || null;
128
162
  const count = sseClients.size;