pi-inspect 0.3.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, statSync, 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, parseFrontmatter } 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,7 @@ 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
- type DisabledKind = "command" | "skill" | "extension" | "theme";
126
+ type DisabledKind = "command" | "prompt" | "skill" | "extension" | "theme";
104
127
  type DisabledScope = "user" | "project";
105
128
  type DisabledItem = {
106
129
  kind: DisabledKind;
@@ -112,29 +135,23 @@ type DisabledItem = {
112
135
  settingsPath: string;
113
136
  path: string;
114
137
  reason: string;
138
+ resourceKind: ResourceKind;
139
+ baseDir: string;
115
140
  };
116
141
 
117
142
  const AGENT_DIR = joinPath(homedir(), ".pi", "agent");
118
143
  const SETTINGS_PATH = joinPath(AGENT_DIR, "settings.json");
119
- const NPM_ROOT = joinPath(AGENT_DIR, "npm", "node_modules");
120
144
 
121
- type FilterKind = "prompts" | "skills" | "extensions" | "themes";
122
- const FILTER_KINDS: readonly FilterKind[] = ["prompts", "skills", "extensions", "themes"];
145
+ type ResourceKind = "skills" | "prompts" | "extensions" | "themes";
146
+ const RESOURCE_KINDS: readonly ResourceKind[] = ["skills", "prompts", "extensions", "themes"];
123
147
 
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 },
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 },
129
153
  };
130
154
 
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
155
  function sourceLabel(source: string): string {
139
156
  if (source.startsWith("npm:")) return source.slice(4);
140
157
  const norm = source.replace(/\\/g, "/").replace(/\/$/, "");
@@ -143,8 +160,7 @@ function sourceLabel(source: string): string {
143
160
 
144
161
  const describeCache = new Map<string, { mtimeMs: number; desc: string }>();
145
162
 
146
- function describeFrom(filePath: string, isDir: boolean): string {
147
- const path = isDir ? joinPath(filePath, "SKILL.md") : filePath;
163
+ function describeFromPath(path: string): string {
148
164
  let mtimeMs: number;
149
165
  try { mtimeMs = statSync(path).mtimeMs; } catch { return ""; }
150
166
  const cached = describeCache.get(path);
@@ -160,102 +176,235 @@ function describeFrom(filePath: string, isDir: boolean): string {
160
176
  return desc;
161
177
  }
162
178
 
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;
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;
168
185
  }
169
- return base.replace(KIND_CFG[filterKind].stripExt, "");
186
+ return base.replace(/\.(md|ts|js|mjs|cjs|json|toml|ya?ml)$/i, "");
170
187
  }
171
188
 
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({
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 {
193
200
  kind: cfg.kind,
194
201
  name,
195
202
  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
+ 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
+ };
203
212
  }
204
213
 
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);
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
+ }
210
250
  }
251
+ return { disabled: [...disabledByPath.values()], pending: [...pendingByPath.values()] };
211
252
  }
212
253
 
213
- const settingsCache = new Map<string, { mtimeMs: number; items: DisabledItem[] }>();
254
+ type ToggleRequest = {
255
+ id: string;
256
+ ts: number;
257
+ action: "enable" | "disable";
258
+ resourceKind: ResourceKind;
259
+ path: string;
260
+ scope: DisabledScope;
261
+ };
214
262
 
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 });
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 {}
239
281
  }
240
- settingsCache.set(settingsPath, { mtimeMs, items });
241
- return items;
242
282
  }
243
283
 
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" });
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
+ }
249
381
 
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);
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}`);
254
404
  }
255
- return [...byPath.values()];
256
405
  }
257
406
 
258
- function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
407
+ async function captureSnapshot(ctx: any): Promise<{ id: string; entry: IndexEntry } | null> {
259
408
  const sm = ctx.sessionManager;
260
409
  const id = sm?.getSessionId?.();
261
410
  if (!id) return null;
@@ -267,9 +416,18 @@ function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
267
416
  const commands = typeof pi?.getCommands === "function" ? pi.getCommands() : [];
268
417
  const tools = typeof pi?.getAllTools === "function" ? pi.getAllTools() : [];
269
418
  const activeTools = typeof pi?.getActiveTools === "function" ? pi.getActiveTools() : [];
270
- const disabledItems = discoverDisabledFromPackages(cwd);
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);
271
429
  const capturedAt = Date.now();
272
- const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, disabledItems, capturedAt };
430
+ const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, disabledItems, pendingItems, capturedAt };
273
431
  try {
274
432
  writeSnapshot(id, snap);
275
433
  upsertIndex({ id, cwd, name, model, capturedAt });
@@ -339,13 +497,16 @@ function showHelp(notify: (m: string, l?: "info" | "error") => void) {
339
497
 
340
498
  export default function inspectExtension(pi: ExtensionAPI) {
341
499
  piRef = pi;
500
+ setupRequestWatcher();
342
501
  pi.on("session_start", async (_event, ctx) => {
343
- captureSnapshot(ctx);
502
+ lastCtx = ctx;
503
+ await captureSnapshot(ctx);
344
504
  });
345
505
  // session_start fires before all extensions register their tools/commands.
346
506
  // before_agent_start fires after the user's first prompt with the fully assembled state — re-capture then.
347
507
  pi.on("before_agent_start", async (_event, ctx) => {
348
- captureSnapshot(ctx);
508
+ lastCtx = ctx;
509
+ await captureSnapshot(ctx);
349
510
  });
350
511
 
351
512
  pi.registerCommand("inspect", {
@@ -363,6 +524,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
363
524
  return SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
364
525
  },
365
526
  handler: async (args, ctx) => {
527
+ lastCtx = ctx;
366
528
  const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
367
529
  const tokens = args.trim().split(/\s+/).filter(Boolean);
368
530
  const first = tokens[0] as Sub | string | undefined;
@@ -382,7 +544,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
382
544
 
383
545
  if (first === "start") {
384
546
  if (!(await startServer(notify))) return;
385
- captureSnapshot(ctx);
547
+ await captureSnapshot(ctx);
386
548
  notify(`pi-inspect started → ${url}`);
387
549
  return;
388
550
  }
@@ -407,7 +569,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
407
569
  }
408
570
 
409
571
  if (first === "snapshot") {
410
- const r = captureSnapshot(ctx);
572
+ const r = await captureSnapshot(ctx);
411
573
  notify(r ? `snapshot captured: ${r.id}` : "no active session to snapshot", r ? "info" : "error");
412
574
  return;
413
575
  }
@@ -417,7 +579,7 @@ export default function inspectExtension(pi: ExtensionAPI) {
417
579
  const openTarget = (isExplicitOpen ? (tokens[1] ?? "web") : "web") as "web" | "app";
418
580
 
419
581
  if (!(await startServer(notify))) return;
420
- captureSnapshot(ctx);
582
+ await captureSnapshot(ctx);
421
583
 
422
584
  let openId: string | null = null;
423
585
  if (!isExplicitOpen && first && !(SUBCOMMANDS as readonly string[]).includes(first)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-inspect",
3
- "version": "0.3.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
@@ -122,12 +122,23 @@ function inferSource(x) {
122
122
  }
123
123
 
124
124
  //#region MODEL
125
+ function normPath(p) {
126
+ if (!p || typeof p !== 'string') return null;
127
+ return p.replace(/\\/g, '/').toLowerCase();
128
+ }
129
+
125
130
  function buildItems() {
126
131
  const s = state.snapshot;
127
132
  if (!s) return [];
128
133
  const items = [];
129
134
  const activeSet = new Set(s.activeTools ?? []);
130
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
+ );
131
142
  for (const t of s.tools ?? []) {
132
143
  const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
133
144
  const id = `tool:${t.name}`;
@@ -145,13 +156,16 @@ function buildItems() {
145
156
  });
146
157
  }
147
158
  for (const c of s.commands ?? []) {
159
+ const cp = normPath(c?.sourceInfo?.path);
160
+ if (cp && disabledPaths.has(cp)) continue;
148
161
  const name = c.name ?? c.command ?? '';
149
162
  const isSkill = name.startsWith('skill:');
150
163
  const src = inferSource(c);
151
164
  const isPrompt = !isSkill && c.source === 'prompt';
152
165
  const kind = isSkill ? 'skill' : isPrompt ? 'prompt' : 'command';
153
166
  const description = (c.description ?? '').replace(/\s+/g, ' ').trim();
154
- const id = `${kind}:${name}`;
167
+ const idName = isSkill ? name.slice('skill:'.length) : name;
168
+ const id = `${kind}:${idName}`;
155
169
  activeIds.add(id);
156
170
  items.push({
157
171
  kind,
@@ -180,6 +194,22 @@ function buildItems() {
180
194
  raw: d,
181
195
  });
182
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
+ }
183
213
  if (s.systemPrompt) {
184
214
  for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
185
215
  items.push({
@@ -338,7 +368,10 @@ function renderTree() {
338
368
 
339
369
  const groups = new Map();
340
370
  for (const k of KIND_ORDER) groups.set(k, []);
341
- 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
+ }
342
375
 
343
376
  const html = [];
344
377
  const rows = [];
@@ -398,11 +431,12 @@ function renderTree() {
398
431
  const descHtml = it.description
399
432
  ? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
400
433
  : '<div class="spacer"></div>';
401
- const disabledCls = it.disabled ? ' disabled' : '';
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>` : '';
402
436
  html.push(`
403
- <div class="tree-row ${selected}${disabledCls}" 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">
404
438
  <div class="tree-icon">${iconFor(it.kind)}</div>
405
- <div class="tree-label">${esc(it.name)}</div>
439
+ <div class="tree-label">${esc(it.name)}${stateBadge}</div>
406
440
  ${descHtml}
407
441
  <div class="tree-meta">${esc(it.source)}</div>
408
442
  </div>
@@ -507,10 +541,12 @@ function renderDetail() {
507
541
  }
508
542
 
509
543
  const ghUrl = githubUrlFor(it);
544
+ const toggleInfo = toggleInfoFor(it);
510
545
  panel.innerHTML = `
511
546
  <div class="detail-header">
512
547
  <h3>${iconFor(it.kind)} ${esc(it.name)} <span class="version">${esc(it.kind)}</span></h3>
513
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>` : ''}
514
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>` : ''}
515
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>` : ''}
516
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>` : ''}
@@ -550,6 +586,41 @@ function renderDetail() {
550
586
  if (ghBtn && ghUrl) ghBtn.addEventListener('click', () => {
551
587
  window.open(ghUrl, '_blank', 'noopener');
552
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 };
553
624
  }
554
625
  //#endregion
555
626
 
package/public/style.css CHANGED
@@ -383,6 +383,23 @@ body {
383
383
  font-style: italic;
384
384
  opacity: 0.8;
385
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
+ }
386
403
 
387
404
  .tree-indent {
388
405
  flex-shrink: 0;
package/server.js CHANGED
@@ -135,6 +135,28 @@ app.post('/api/open', (req, res) => {
135
135
  }
136
136
  });
137
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
+
138
160
  app.post('/api/focus', (req, res) => {
139
161
  const sid = (req.body && req.body.session) || req.query.session || null;
140
162
  const count = sseClients.size;