pi-inspect 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,6 +31,14 @@ Then use `/inspect start | stop | restart | status | open | list | snapshot` fro
31
31
 
32
32
  State is driven entirely through the `?session=` URL param — share or refresh URLs to pin views. The in-page picker also writes to the URL.
33
33
 
34
+ ## Sharing a snapshot
35
+
36
+ Click **Share** in the topbar to copy a self-contained link of the current snapshot. The snapshot is `deflate-raw` compressed and base64url-encoded into the URL hash (`#s=…`) — no server, no upload, no account.
37
+
38
+ Recipients open the link on the hosted static dashboard at **https://nikiforovall.blog/pi-inspect/** and see the exact same tools / commands / skills / system prompt. The page makes no network requests; everything is in the URL.
39
+
40
+ Heads up: the link includes the system prompt and `cwd`. Don't share secrets you wouldn't paste in chat.
41
+
34
42
  ## What it captures
35
43
 
36
44
  - **Tools** — name, description, parameter schema, source
@@ -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.5.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
@@ -10,6 +10,7 @@ const state = {
10
10
  expandAll: true,
11
11
  highlight: -1,
12
12
  visibleRows: [],
13
+ staticMode: false,
13
14
  };
14
15
  const els = {};
15
16
 
@@ -122,12 +123,23 @@ function inferSource(x) {
122
123
  }
123
124
 
124
125
  //#region MODEL
126
+ function normPath(p) {
127
+ if (!p || typeof p !== 'string') return null;
128
+ return p.replace(/\\/g, '/').toLowerCase();
129
+ }
130
+
125
131
  function buildItems() {
126
132
  const s = state.snapshot;
127
133
  if (!s) return [];
128
134
  const items = [];
129
135
  const activeSet = new Set(s.activeTools ?? []);
130
136
  const activeIds = new Set();
137
+ // pi.getCommands() is frozen at session boot. After a toggle, the SDK-resolved
138
+ // disabledItems is the source of truth — drop any active command whose path
139
+ // matches a disabled resource so the UI reflects the post-toggle state.
140
+ const disabledPaths = new Set(
141
+ (s.disabledItems ?? []).map((d) => normPath(d.path)).filter(Boolean),
142
+ );
131
143
  for (const t of s.tools ?? []) {
132
144
  const description = (t.description ?? '').replace(/\s+/g, ' ').trim();
133
145
  const id = `tool:${t.name}`;
@@ -145,13 +157,16 @@ function buildItems() {
145
157
  });
146
158
  }
147
159
  for (const c of s.commands ?? []) {
160
+ const cp = normPath(c?.sourceInfo?.path);
161
+ if (cp && disabledPaths.has(cp)) continue;
148
162
  const name = c.name ?? c.command ?? '';
149
163
  const isSkill = name.startsWith('skill:');
150
164
  const src = inferSource(c);
151
165
  const isPrompt = !isSkill && c.source === 'prompt';
152
166
  const kind = isSkill ? 'skill' : isPrompt ? 'prompt' : 'command';
153
167
  const description = (c.description ?? '').replace(/\s+/g, ' ').trim();
154
- const id = `${kind}:${name}`;
168
+ const idName = isSkill ? name.slice('skill:'.length) : name;
169
+ const id = `${kind}:${idName}`;
155
170
  activeIds.add(id);
156
171
  items.push({
157
172
  kind,
@@ -180,6 +195,22 @@ function buildItems() {
180
195
  raw: d,
181
196
  });
182
197
  }
198
+ for (const d of s.pendingItems ?? []) {
199
+ const id = `${d.kind}:${d.name}`;
200
+ if (activeIds.has(id)) continue;
201
+ const description = (d.description ?? '').replace(/\s+/g, ' ').trim();
202
+ items.push({
203
+ kind: d.kind,
204
+ id,
205
+ name: d.displayName ?? d.name,
206
+ source: d.source ?? '(package)',
207
+ description,
208
+ chars: (d.description ?? '').length,
209
+ pending: true,
210
+ path: d.path ?? null,
211
+ raw: d,
212
+ });
213
+ }
183
214
  if (s.systemPrompt) {
184
215
  for (const part of splitSystemPrompt(s.systemPrompt, s.cwd)) {
185
216
  items.push({
@@ -328,7 +359,9 @@ function renderTree() {
328
359
  const root = $('treeContainer');
329
360
  const items = filterItems(buildItems());
330
361
  if (!state.snapshot) {
331
- root.innerHTML = `<div class="loading">No snapshot for this session. Run <code>/inspect snapshot</code> in a pi session.</div>`;
362
+ root.innerHTML = state.staticMode
363
+ ? `<div class="loading">No snapshot in this URL. Open a shared link, or run pi /inspect locally.</div>`
364
+ : `<div class="loading">No snapshot for this session. Run <code>/inspect snapshot</code> in a pi session.</div>`;
332
365
  return;
333
366
  }
334
367
  if (!items.length) {
@@ -338,7 +371,10 @@ function renderTree() {
338
371
 
339
372
  const groups = new Map();
340
373
  for (const k of KIND_ORDER) groups.set(k, []);
341
- for (const it of items) groups.get(it.kind).push(it);
374
+ for (const it of items) {
375
+ const g = groups.get(it.kind);
376
+ if (g) g.push(it);
377
+ }
342
378
 
343
379
  const html = [];
344
380
  const rows = [];
@@ -398,11 +434,12 @@ function renderTree() {
398
434
  const descHtml = it.description
399
435
  ? `<span class="tree-desc">${esc(it.description)}</span><div class="spacer"></div>`
400
436
  : '<div class="spacer"></div>';
401
- const disabledCls = it.disabled ? ' disabled' : '';
437
+ const stateCls = it.disabled ? ' disabled' : (it.pending ? ' pending' : '');
438
+ const stateBadge = it.pending ? `<span class="tree-badge pending" title="Enabled in settings but not yet active — restart pi">restart pi</span>` : '';
402
439
  html.push(`
403
- <div class="tree-row ${selected}${disabledCls}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
440
+ <div class="tree-row ${selected}${stateCls}" data-item="${esc(it.id)}" style="padding-left:${pad}px">
404
441
  <div class="tree-icon">${iconFor(it.kind)}</div>
405
- <div class="tree-label">${esc(it.name)}</div>
442
+ <div class="tree-label">${esc(it.name)}${stateBadge}</div>
406
443
  ${descHtml}
407
444
  <div class="tree-meta">${esc(it.source)}</div>
408
445
  </div>
@@ -507,11 +544,14 @@ function renderDetail() {
507
544
  }
508
545
 
509
546
  const ghUrl = githubUrlFor(it);
547
+ const toggleInfo = toggleInfoFor(it);
548
+ const allowEditor = !state.staticMode && it.path;
510
549
  panel.innerHTML = `
511
550
  <div class="detail-header">
512
551
  <h3>${iconFor(it.kind)} ${esc(it.name)} <span class="version">${esc(it.kind)}</span></h3>
513
552
  <div class="detail-header-actions">
514
- ${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>` : ''}
553
+ ${toggleInfo ? `<button class="detail-action" id="toggleBtn" title="${toggleInfo.enable ? 'Enable' : 'Disable'} (${toggleInfo.scope})">${toggleInfo.enable ? 'Enable' : 'Disable'}</button>` : ''}
554
+ ${allowEditor ? `<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
555
  ${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
556
  ${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>` : ''}
517
557
  <button class="detail-close" id="detailCloseBtn" title="Close">&#10005;</button>
@@ -550,6 +590,42 @@ function renderDetail() {
550
590
  if (ghBtn && ghUrl) ghBtn.addEventListener('click', () => {
551
591
  window.open(ghUrl, '_blank', 'noopener');
552
592
  });
593
+ const toggleBtn = $('toggleBtn');
594
+ if (toggleBtn && toggleInfo) toggleBtn.addEventListener('click', async () => {
595
+ if (state.staticMode) { toast('shared view is read-only — run pi-inspect locally to toggle'); return; }
596
+ toggleBtn.disabled = true;
597
+ try {
598
+ const r = await fetch('/api/toggle', {
599
+ method: 'POST',
600
+ headers: { 'content-type': 'application/json' },
601
+ body: JSON.stringify({
602
+ action: toggleInfo.enable ? 'enable' : 'disable',
603
+ resourceKind: toggleInfo.resourceKind,
604
+ path: toggleInfo.path,
605
+ scope: toggleInfo.scope,
606
+ }),
607
+ });
608
+ const j = await r.json();
609
+ if (!r.ok || j.ok === false) throw new Error(j.error || `HTTP ${r.status}`);
610
+ toast(`${toggleInfo.enable ? 'Enabled' : 'Disabled'} ${it.name} — restart pi to apply fully`);
611
+ } catch (e) {
612
+ toast(`Toggle failed: ${e.message}`);
613
+ toggleBtn.disabled = false;
614
+ }
615
+ });
616
+ }
617
+
618
+ function toggleInfoFor(it) {
619
+ if (it.kind !== 'skill' && it.kind !== 'prompt') return null;
620
+ if (!it.path) return null;
621
+ const resourceKind = it.kind === 'skill' ? 'skills' : 'prompts';
622
+ if (it.disabled) {
623
+ const scope = it.raw?.scope === 'project' ? 'project' : 'user';
624
+ return { enable: true, resourceKind, path: it.path, scope };
625
+ }
626
+ const si = it.raw?.sourceInfo;
627
+ const scope = si?.scope === 'project' ? 'project' : 'user';
628
+ return { enable: false, resourceKind, path: it.path, scope };
553
629
  }
554
630
  //#endregion
555
631
 
@@ -606,6 +682,24 @@ function bindEvents() {
606
682
  renderTree();
607
683
  });
608
684
 
685
+ $('shareBtn').addEventListener('click', async () => {
686
+ if (!state.snapshot) { toast('no snapshot to share'); return; }
687
+ if (!window.piShare) { toast('share module not loaded'); return; }
688
+ const btn = $('shareBtn');
689
+ btn.classList.add('loading');
690
+ try {
691
+ const encoded = await window.piShare.encodeSnapshot(state.snapshot);
692
+ const url = window.piShare.buildShareUrl(encoded);
693
+ await navigator.clipboard.writeText(url);
694
+ const kb = Math.max(1, Math.round(url.length / 1024));
695
+ toast(`Share link copied — ${kb} KB. Paths redacted to <home>.`);
696
+ } catch (e) {
697
+ toast(`Share failed: ${e.message}`);
698
+ } finally {
699
+ btn.classList.remove('loading');
700
+ }
701
+ });
702
+
609
703
  $('refreshBtn').addEventListener('click', async () => {
610
704
  const btn = $('refreshBtn');
611
705
  btn.classList.add('loading');
@@ -816,6 +910,34 @@ function bindSse() {
816
910
  //#endregion
817
911
 
818
912
  //#region INIT
913
+ async function loadSharedSnapshot() {
914
+ const param = window.piShare?.getSharedSnapshotParam();
915
+ if (!param) return false;
916
+ try {
917
+ state.snapshot = await window.piShare.decodeSnapshot(param);
918
+ state.currentSessionId = state.snapshot?.sessionId ?? null;
919
+ state.sessions = state.currentSessionId
920
+ ? [{ id: state.currentSessionId, name: state.snapshot.sessionName, cwd: state.snapshot.cwd }]
921
+ : [];
922
+ state.staticMode = true;
923
+ return true;
924
+ } catch (e) {
925
+ console.warn('Failed to decode shared snapshot:', e);
926
+ toast(`shared link decode failed: ${e.message}`, 'error');
927
+ return false;
928
+ }
929
+ }
930
+
931
+ function applyStaticModeUi() {
932
+ document.body.classList.add('static-mode');
933
+ for (const id of ['sessionSelect', 'cleanupSessionsBtn', 'refreshBtn']) {
934
+ const el = $(id);
935
+ if (el) el.style.display = 'none';
936
+ }
937
+ const share = $('shareBtn');
938
+ if (share) share.title = 'Re-copy this shared snapshot link';
939
+ }
940
+
819
941
  (async function init() {
820
942
  try {
821
943
  if (localStorage.getItem('inspect.theme') === 'light') document.body.classList.add('light');
@@ -824,10 +946,20 @@ function bindSse() {
824
946
  bindResize();
825
947
  bindEvents();
826
948
 
827
- await loadSessions();
828
- const requested = getUrlSession();
829
- await loadSnapshot(requested);
830
- if (state.currentSessionId && !requested) setUrlSession(state.currentSessionId, true);
949
+ const sharedLoaded = await loadSharedSnapshot();
950
+ if (!sharedLoaded) {
951
+ try {
952
+ await loadSessions();
953
+ const requested = getUrlSession();
954
+ await loadSnapshot(requested);
955
+ if (state.currentSessionId && !requested) setUrlSession(state.currentSessionId, true);
956
+ } catch {
957
+ state.staticMode = true;
958
+ state.snapshot = null;
959
+ }
960
+ }
961
+
962
+ if (state.staticMode) applyStaticModeUi();
831
963
 
832
964
  if (state.snapshot?.systemPrompt) {
833
965
  const firstCtx = buildItems().find((x) => x.kind === 'context');
@@ -837,6 +969,6 @@ function bindSse() {
837
969
  renderTopbar();
838
970
  renderTree();
839
971
  renderDetail();
840
- bindSse();
972
+ if (!state.staticMode) bindSse();
841
973
  })();
842
974
  //#endregion
package/public/index.html CHANGED
@@ -42,6 +42,10 @@
42
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>
43
43
  <span id="projectPath">—</span>
44
44
  </div>
45
+ <button class="topbar-btn" id="shareBtn" title="Copy a shareable link for the current snapshot">
46
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
47
+ Share
48
+ </button>
45
49
  <button class="topbar-btn" id="refreshBtn" title="Refresh data">
46
50
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
47
51
  Refresh
@@ -111,11 +115,12 @@
111
115
  </div>
112
116
  </div>
113
117
 
118
+ <script src="share.js"></script>
114
119
  <script src="app.js"></script>
115
120
  <script>
116
121
  if ('serviceWorker' in navigator) {
117
122
  window.addEventListener('load', () => {
118
- navigator.serviceWorker.register('/sw.js').catch((e) => console.warn('sw register failed:', e));
123
+ navigator.serviceWorker.register('./sw.js').catch((e) => console.warn('sw register failed:', e));
119
124
  });
120
125
  }
121
126
  </script>
@@ -0,0 +1,119 @@
1
+ // Shareable snapshot encoding: JSON → deflate-raw → base64url.
2
+ // URL shape mirrors plannotator.ai: `#s=<base64url>`.
3
+
4
+ const HASH_PREFIX = '#s=';
5
+
6
+ function bytesToBase64Url(bytes) {
7
+ let bin = '';
8
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
9
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
10
+ }
11
+
12
+ function base64UrlToBytes(str) {
13
+ const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4));
14
+ const b64 = str.replace(/-/g, '+').replace(/_/g, '/') + pad;
15
+ const bin = atob(b64);
16
+ const out = new Uint8Array(bin.length);
17
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
18
+ return out;
19
+ }
20
+
21
+ async function streamThrough(transformer, bytes) {
22
+ const stream = new Blob([bytes]).stream().pipeThrough(transformer);
23
+ const buf = await new Response(stream).arrayBuffer();
24
+ return new Uint8Array(buf);
25
+ }
26
+
27
+ // Path-redaction: strip the sender's HOME prefix from every string and key,
28
+ // in both slash flavors. Preserves project basename + relative tail so the
29
+ // shared view still has useful structure ("<home>/dev/pi-inspect").
30
+ function escapeRegex(s) {
31
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
+ }
33
+
34
+ function deriveHome(cwd) {
35
+ if (!cwd || typeof cwd !== 'string') return null;
36
+ const patterns = [
37
+ /^([a-zA-Z]:[\\/]Users[\\/][^\\/]+)/, // Windows: C:\Users\<user>
38
+ /^(\/Users\/[^/]+)/, // macOS: /Users/<user>
39
+ /^(\/home\/[^/]+)/, // Linux: /home/<user>
40
+ /^(\/[a-zA-Z]\/Users\/[^/]+)/, // Git Bash: /c/Users/<user>
41
+ ];
42
+ for (const re of patterns) {
43
+ const m = cwd.match(re);
44
+ if (m) return m[1];
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function buildHomeReplacers(cwd) {
50
+ const home = deriveHome(cwd);
51
+ if (!home) return [];
52
+ const alt = home.includes('\\') ? home.replace(/\\/g, '/') : home.replace(/\//g, '\\');
53
+ const variants = new Set([home, alt]);
54
+ return [...variants].map((v) => [new RegExp(escapeRegex(v), 'g'), '<home>']);
55
+ }
56
+
57
+ function redactString(s, reps) {
58
+ if (typeof s !== 'string') return s;
59
+ let out = s;
60
+ for (const [re, rep] of reps) out = out.replace(re, rep);
61
+ return out;
62
+ }
63
+
64
+ // Keys whose values are home-path-keyed maps — redact keys here too.
65
+ const PATH_KEYED_MAPS = new Set(['githubSources']);
66
+
67
+ function redactDeep(value, reps, redactKeys) {
68
+ if (value == null) return value;
69
+ if (typeof value === 'string') return redactString(value, reps);
70
+ if (Array.isArray(value)) return value.map((v) => redactDeep(v, reps, false));
71
+ if (typeof value === 'object') {
72
+ const out = {};
73
+ for (const [k, v] of Object.entries(value)) {
74
+ const newKey = redactKeys ? redactString(k, reps) : k;
75
+ out[newKey] = redactDeep(v, reps, PATH_KEYED_MAPS.has(k));
76
+ }
77
+ return out;
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function redactSnapshot(snapshot) {
83
+ if (!snapshot) return snapshot;
84
+ const reps = buildHomeReplacers(snapshot.cwd || '');
85
+ if (!reps.length) return snapshot;
86
+ return redactDeep(snapshot, reps, false);
87
+ }
88
+
89
+ async function encodeSnapshot(snapshot) {
90
+ const json = JSON.stringify(redactSnapshot(snapshot));
91
+ const raw = new TextEncoder().encode(json);
92
+ const compressed = await streamThrough(new CompressionStream('deflate-raw'), raw);
93
+ return bytesToBase64Url(compressed);
94
+ }
95
+
96
+ async function decodeSnapshot(encoded) {
97
+ const compressed = base64UrlToBytes(encoded);
98
+ const raw = await streamThrough(new DecompressionStream('deflate-raw'), compressed);
99
+ return JSON.parse(new TextDecoder().decode(raw));
100
+ }
101
+
102
+ function getSharedSnapshotParam() {
103
+ const h = location.hash || '';
104
+ return h.startsWith(HASH_PREFIX) ? h.slice(HASH_PREFIX.length) : null;
105
+ }
106
+
107
+ const PUBLIC_BASE_URL = 'https://nikiforovall.blog/pi-inspect/';
108
+
109
+ function buildShareUrl(encoded) {
110
+ // Local pi-inspect runs on localhost — recipients can't open that. Always
111
+ // anchor share links on the hosted static dashboard. When the current page
112
+ // is already a non-localhost origin (e.g. the hosted site itself), reuse it.
113
+ const host = location.hostname;
114
+ const isLocal = !host || host === 'localhost' || host === '127.0.0.1' || host === '::1';
115
+ const base = isLocal ? PUBLIC_BASE_URL : `${location.origin}${location.pathname}`;
116
+ return `${base}${HASH_PREFIX}${encoded}`;
117
+ }
118
+
119
+ window.piShare = { encodeSnapshot, decodeSnapshot, getSharedSnapshotParam, buildShareUrl, redactSnapshot };
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/public/sw.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // pi-inspect service worker — network-first for dynamic data, cache-first for static shell.
2
- const VERSION = 'pi-inspect-v1';
3
- const SHELL = ['/', '/index.html', '/style.css', '/app.js', '/manifest.webmanifest', '/icon.svg'];
2
+ const VERSION = 'pi-inspect-v6';
3
+ // Resolve relative to the SW scope so this works under any subpath (e.g. GitHub Pages).
4
+ const BASE = new URL('./', self.registration?.scope || self.location.href).pathname;
5
+ const SHELL = ['', 'index.html', 'style.css', 'app.js', 'share.js', 'manifest.webmanifest', 'icon.svg'].map((p) => BASE + p);
4
6
 
5
7
  self.addEventListener('install', (event) => {
6
8
  event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).catch(() => {}));
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;