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