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.
- package/extensions/inspect.ts +267 -105
- package/package.json +1 -1
- package/public/app.js +76 -5
- package/public/style.css +17 -0
- package/server.js +22 -0
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
|
@@ -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
|
|
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)
|
|
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
|
|
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}${
|
|
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;
|