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 +8 -0
- package/extensions/inspect.ts +267 -105
- package/package.json +1 -1
- package/public/app.js +144 -12
- package/public/index.html +6 -1
- package/public/share.js +119 -0
- package/public/style.css +17 -0
- package/public/sw.js +4 -2
- package/server.js +22 -0
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
|
package/extensions/inspect.ts
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
1
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
2
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
|
122
|
-
const
|
|
145
|
+
type ResourceKind = "skills" | "prompts" | "extensions" | "themes";
|
|
146
|
+
const RESOURCE_KINDS: readonly ResourceKind[] = ["skills", "prompts", "extensions", "themes"];
|
|
123
147
|
|
|
124
|
-
const
|
|
125
|
-
prompts: { kind: "
|
|
126
|
-
skills: { kind: "skill",
|
|
127
|
-
extensions: { kind: "extension",
|
|
128
|
-
themes: { kind: "theme",
|
|
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
|
|
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
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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(
|
|
186
|
+
return base.replace(/\.(md|ts|js|mjs|cjs|json|toml|ya?ml)$/i, "");
|
|
170
187
|
}
|
|
171
188
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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:
|
|
197
|
-
source:
|
|
198
|
-
scope
|
|
199
|
-
settingsPath:
|
|
200
|
-
path:
|
|
201
|
-
reason:
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
):
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
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 =
|
|
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)
|
|
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
|
|
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}${
|
|
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
|
-
${
|
|
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">✕</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
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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('
|
|
123
|
+
navigator.serviceWorker.register('./sw.js').catch((e) => console.warn('sw register failed:', e));
|
|
119
124
|
});
|
|
120
125
|
}
|
|
121
126
|
</script>
|
package/public/share.js
ADDED
|
@@ -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-
|
|
3
|
-
|
|
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;
|