pi-inspect 0.4.0 → 0.6.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +65 -3
  3. package/extensions/inspect.ts +54 -52
  4. package/lib/agent-dir.js +18 -0
  5. package/lib/app.js +144 -0
  6. package/lib/args.js +30 -0
  7. package/lib/editor.js +26 -0
  8. package/lib/snapshot.js +57 -79
  9. package/lib/watch.js +29 -0
  10. package/package.json +9 -8
  11. package/public/app.js +676 -591
  12. package/public/fonts/OFL-IBM-Plex.txt +93 -0
  13. package/public/fonts/OFL-Playfair-Display.txt +93 -0
  14. package/public/fonts/fonts.css +244 -0
  15. package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
  16. package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
  17. package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
  18. package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
  19. package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
  20. package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
  21. package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  22. package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  23. package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
  24. package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  25. package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  26. package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
  27. package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
  28. package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
  29. package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
  30. package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
  31. package/public/fonts/playfair-display-latin-400.woff2 +0 -0
  32. package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
  33. package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
  34. package/public/index.html +96 -75
  35. package/public/share.js +119 -0
  36. package/public/style.css +416 -1245
  37. package/public/sw.js +14 -20
  38. package/server.js +27 -198
  39. package/themes/pi-dark.json +0 -21
  40. package/themes/pi-light.json +0 -21
package/CHANGELOG.md ADDED
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0
4
+
5
+ ### Breaking changes
6
+
7
+ - The server listens on `127.0.0.1` only. To reach it from another machine, start it with `--host 0.0.0.0`.
8
+
9
+ ### Features
10
+
11
+ - **New dashboard.** A keyboard-first tree of tools, commands, skills, and the system prompt, in light and dark mode.
12
+ - **Server options.** `--port` (`0` picks a free port), `--host`, `--open`, `--no-open`, and `--session`.
13
+ - **pi-kanban pane.** `GET /api/companion` and the embed theme handshake let the pi-kanban board show pi-inspect in a pane, in the board's colors.
14
+ - The snapshot records the model from pi's current context.
15
+
16
+ ### Fixes
17
+
18
+ - `pi -p` with pi-inspect loaded exits when the prompt is done.
19
+
20
+ ### Security
21
+
22
+ - The API answers only to local host names, and refuses `POST` requests from other origins.
23
+
24
+ ### Other
25
+
26
+ - Express 5, without chokidar. The browser opener loads only when it is needed.
27
+ - Snapshot files are written atomically.
28
+ - `node:test` suites for the store, routes, flags, and server.
package/README.md CHANGED
@@ -31,6 +31,37 @@ 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
+ A session is captured when it starts with pi-inspect loaded. If you open a session that was never captured, the picker marks it "not captured". Run `/inspect snapshot` in that session to capture it.
35
+
36
+ The theme follows your system setting until you toggle it with `t`.
37
+
38
+ ## Keyboard
39
+
40
+ | Key | What it does |
41
+ | --- | --- |
42
+ | `j` / `k`, arrows | Next / previous row. Moving onto an item shows it. |
43
+ | `g` / `G` | First / last row |
44
+ | `l` / `h` | Expand a group / collapse it or go to the parent |
45
+ | `Enter` | Open the row (toggle a group) |
46
+ | `e` | Expand or collapse all |
47
+ | `/` | Search by name or source |
48
+ | `0`–`5` | Kind: all, context, tools, commands, prompts, skills |
49
+ | `s` | Pick a session |
50
+ | `o` | Open the item's file in your editor |
51
+ | `c` | Copy the item's path |
52
+ | `r` | Refresh |
53
+ | `t` | Toggle light and dark |
54
+ | `Esc` | Close the item, or leave an input |
55
+ | `?` | Show all keys |
56
+
57
+ ## Sharing a snapshot
58
+
59
+ Click the share button 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.
60
+
61
+ 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.
62
+
63
+ Heads up: the link includes the system prompt and `cwd`. Don't share secrets you wouldn't paste in chat.
64
+
34
65
  ## What it captures
35
66
 
36
67
  - **Tools** — name, description, parameter schema, source
@@ -38,8 +69,39 @@ State is driven entirely through the `?session=` URL param — share or refresh
38
69
  - **System prompt** — full text injected on init, split into system / user `AGENTS.md` / project `AGENTS.md` sections
39
70
  - **Session meta** — cwd, model, sessionId, sessionName, captured timestamp
40
71
 
41
- Snapshots live at `~/.pi/agent/inspect/snapshots/<sessionId>.json`.
72
+ Snapshots live at `<agentDir>/inspect/snapshots/<sessionId>.json`. The agent dir is `PI_CODING_AGENT_DIR`, or `~/.pi/agent` when it is not set, as in pi.
73
+
74
+ ## Server options
42
75
 
43
- ## Port
76
+ | Flag | Env var | What it does |
77
+ | --- | --- | --- |
78
+ | `--port <n>` | `PORT` | Port. Default `5462`. `0` picks a free port. The server prints the URL it listens on. |
79
+ | `--host <addr>` | | Listen address. Default `127.0.0.1`. |
80
+ | `--open` | | Open the dashboard in the browser |
81
+ | `--no-open` | | Do not open the browser, also when `--open` is given |
82
+ | `--session <id>` | | The session to open with `--open` |
83
+
84
+ A flag wins over its env var.
85
+
86
+ The server refuses `POST` requests whose `Origin` is not its own, so other web pages cannot delete snapshots or change settings. The API also answers only to `localhost`, `127.0.0.1`, `[::1]` and the `--host` name, which blocks DNS-rebinding pages. With `--host 0.0.0.0` or `::` the API answers to every host name.
87
+
88
+ ## Embedding
89
+
90
+ A host such as the pi-kanban board can run the dashboard as a companion and frame it:
91
+
92
+ - `GET /api/companion` returns `{ name, version, session }`. `session` is the page for one session, `/?session={id}`.
93
+ - Add `embed=1` to the query of the framed URL. The dashboard then posts `{ type: 'pi-companion:ready' }` to its parent.
94
+ - The parent answers with `{ type: 'pi-companion:theme', mode, colors }`, and sends it again when its theme changes. `mode` is `light` or `dark`. `colors` holds `bgDeep`, `bgSurface`, `bgElevated`, `bgHover`, `border`, `textPrimary`, `textSecondary`, `textTertiary`, `textMuted`, `accent`, `accentText`, `success`, `warning`, and `error`, each a CSS color.
95
+
96
+ In embed mode the theme toggle and the GitHub link are hidden, and the theme is not saved. The page uses no browser dialogs, so it works in a sandboxed frame without `allow-modals`.
97
+
98
+ ## Development
99
+
100
+ ```sh
101
+ npm install
102
+ npm test # node:test: snapshot store, routes, flags, server spawn, watcher
103
+ npm run typecheck:extensions # type-checks extensions/inspect.ts against pi's types
104
+ node server.js --port 0 --no-open
105
+ ```
44
106
 
45
- `5462` — override via `PORT` env var.
107
+ Set `PI_CODING_AGENT_DIR` to point the server at another agent dir, for example a dev one.
@@ -3,6 +3,7 @@ import {
3
3
  mkdirSync,
4
4
  readdirSync,
5
5
  readFileSync,
6
+ realpathSync,
6
7
  renameSync,
7
8
  statSync,
8
9
  unlinkSync,
@@ -11,7 +12,6 @@ import {
11
12
  type FSWatcher,
12
13
  } from "node:fs";
13
14
  import { createConnection } from "node:net";
14
- import { homedir } from "node:os";
15
15
  import {
16
16
  dirname,
17
17
  join as joinPath,
@@ -22,6 +22,9 @@ import { fileURLToPath } from "node:url";
22
22
  import {
23
23
  DefaultPackageManager,
24
24
  type ExtensionAPI,
25
+ type ExtensionContext,
26
+ getAgentDir,
27
+ type PackageSource,
25
28
  parseFrontmatter,
26
29
  type ResolvedResource,
27
30
  SettingsManager,
@@ -31,7 +34,8 @@ const extDir = fileURLToPath(new URL(".", import.meta.url));
31
34
  const port = 5462;
32
35
  const url = `http://localhost:${port}`;
33
36
 
34
- const INSPECT_DIR = joinPath(homedir(), ".pi", "agent", "inspect");
37
+ const AGENT_DIR = getAgentDir();
38
+ const INSPECT_DIR = joinPath(AGENT_DIR, "inspect");
35
39
  const SNAP_DIR = joinPath(INSPECT_DIR, "snapshots");
36
40
  const INDEX_PATH = joinPath(SNAP_DIR, "index.json");
37
41
  const REQ_DIR = joinPath(INSPECT_DIR, "requests");
@@ -107,9 +111,16 @@ function readIndex(): IndexEntry[] {
107
111
  }
108
112
  }
109
113
 
114
+ // The dashboard server watches SNAP_DIR and reads files on change, so it must never see a half-written one.
115
+ function writeJsonAtomic(file: string, value: unknown): void {
116
+ mkdirSync(dirname(file), { recursive: true });
117
+ const tmp = `${file}.${process.pid}.tmp`;
118
+ writeFileSync(tmp, JSON.stringify(value, null, 2), "utf8");
119
+ renameSync(tmp, file);
120
+ }
121
+
110
122
  function writeIndex(entries: IndexEntry[]): void {
111
- mkdirSync(SNAP_DIR, { recursive: true });
112
- writeFileSync(INDEX_PATH, JSON.stringify({ sessions: entries }, null, 2), "utf8");
123
+ writeJsonAtomic(INDEX_PATH, { sessions: entries });
113
124
  }
114
125
 
115
126
  function upsertIndex(entry: IndexEntry): void {
@@ -119,8 +130,7 @@ function upsertIndex(entry: IndexEntry): void {
119
130
  }
120
131
 
121
132
  function writeSnapshot(id: string, snapshot: unknown): void {
122
- mkdirSync(SNAP_DIR, { recursive: true });
123
- writeFileSync(joinPath(SNAP_DIR, `${sanitize(id)}.json`), JSON.stringify(snapshot, null, 2), "utf8");
133
+ writeJsonAtomic(joinPath(SNAP_DIR, `${sanitize(id)}.json`), snapshot);
124
134
  }
125
135
 
126
136
  type DisabledKind = "command" | "prompt" | "skill" | "extension" | "theme";
@@ -139,7 +149,6 @@ type DisabledItem = {
139
149
  baseDir: string;
140
150
  };
141
151
 
142
- const AGENT_DIR = joinPath(homedir(), ".pi", "agent");
143
152
  const SETTINGS_PATH = joinPath(AGENT_DIR, "settings.json");
144
153
 
145
154
  type ResourceKind = "skills" | "prompts" | "extensions" | "themes";
@@ -308,22 +317,15 @@ async function processToggleRequest(req: ToggleRequest, sessionCwd: string): Pro
308
317
  const baseDir = md.baseDir ?? dirname(target.path);
309
318
  const pattern = relativePath(baseDir, target.path);
310
319
  const settings = scope === "project" ? sm.getProjectSettings() : sm.getGlobalSettings();
311
- const packages = [...(settings.packages ?? [])];
320
+ const packages: PackageSource[] = [...(settings.packages ?? [])];
312
321
  const idx = packages.findIndex((p) => (typeof p === "string" ? p : p.source) === md.source);
313
322
  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;
323
+ const found = packages[idx];
324
+ const pkg = typeof found === "string" ? { source: found } : { ...found };
325
+ const updated = applyPattern(pkg[req.resourceKind] ?? [], pattern, enabled);
326
+ pkg[req.resourceKind] = updated.length > 0 ? updated : undefined;
327
+ const hasFilters = RESOURCE_KINDS.some((k) => pkg[k] !== undefined);
328
+ packages[idx] = hasFilters ? pkg : pkg.source;
327
329
  if (scope === "project") sm.setProjectPackages(packages);
328
330
  else sm.setPackages(packages);
329
331
  await sm.flush();
@@ -334,19 +336,18 @@ async function processToggleRequest(req: ToggleRequest, sessionCwd: string): Pro
334
336
  const baseDir = scope === "project" ? joinPath(sessionCwd, ".pi") : AGENT_DIR;
335
337
  const pattern = relativePath(baseDir, target.path);
336
338
  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" },
339
+ const updated = applyPattern(settings[req.resourceKind] ?? [], pattern, enabled);
340
+ const setters: Record<ResourceKind, Record<DisabledScope, (paths: string[]) => void>> = {
341
+ skills: { user: (p) => sm.setSkillPaths(p), project: (p) => sm.setProjectSkillPaths(p) },
342
+ prompts: { user: (p) => sm.setPromptTemplatePaths(p), project: (p) => sm.setProjectPromptTemplatePaths(p) },
343
+ extensions: { user: (p) => sm.setExtensionPaths(p), project: (p) => sm.setProjectExtensionPaths(p) },
344
+ themes: { user: (p) => sm.setThemePaths(p), project: (p) => sm.setProjectThemePaths(p) },
344
345
  };
345
- (sm as any)[setters[req.resourceKind][scope]](updated);
346
+ setters[req.resourceKind][scope](updated);
346
347
  await sm.flush();
347
348
  }
348
349
 
349
- let lastCtx: any = null;
350
+ let lastCtx: ExtensionContext | null = null;
350
351
  let reqWatcher: FSWatcher | null = null;
351
352
  const recentlyHandled = new Set<string>();
352
353
 
@@ -365,7 +366,7 @@ async function handleRequestFile(full: string): Promise<void> {
365
366
  return;
366
367
  }
367
368
 
368
- const cwd = lastCtx?.sessionManager?.getCwd?.() ?? process.cwd();
369
+ const cwd = lastCtx?.sessionManager.getCwd() ?? process.cwd();
369
370
  try {
370
371
  await processToggleRequest(req, cwd);
371
372
  try { unlinkSync(full); } catch {}
@@ -391,7 +392,8 @@ function setupRequestWatcher(): void {
391
392
  } catch {}
392
393
  if (reqWatcher) return;
393
394
  try {
394
- reqWatcher = fsWatch(REQ_DIR, (_event, filename) => {
395
+ // libuv aborts the process on an event under a Windows 8.3 short path, so watch the long form.
396
+ reqWatcher = fsWatch(realpathSync.native(REQ_DIR), (_event, filename) => {
395
397
  if (!filename) return;
396
398
  const name = String(filename);
397
399
  if (!name.endsWith(".json") || name.endsWith(".err.json")) return;
@@ -399,31 +401,27 @@ function setupRequestWatcher(): void {
399
401
  try { statSync(full); } catch { return; }
400
402
  void handleRequestFile(full);
401
403
  });
404
+ // A live watcher keeps the event loop alive, so `pi -p` would never exit.
405
+ reqWatcher.unref();
402
406
  } catch (e: any) {
403
407
  console.warn(`pi-inspect: request watcher failed: ${e?.message ?? e}`);
404
408
  }
405
409
  }
406
410
 
407
- async function captureSnapshot(ctx: any): Promise<{ id: string; entry: IndexEntry } | null> {
411
+ async function captureSnapshot(ctx: ExtensionContext): Promise<{ id: string; entry: IndexEntry } | null> {
408
412
  const sm = ctx.sessionManager;
409
- const id = sm?.getSessionId?.();
410
- if (!id) return null;
411
- const cwd = sm?.getCwd?.() ?? null;
412
- const name = sm?.getSessionName?.() ?? null;
413
- const model = ctx.getModel?.()?.id ?? null;
414
- const systemPrompt = typeof ctx.getSystemPrompt === "function" ? ctx.getSystemPrompt() : null;
415
- const pi = piRef as any;
416
- const commands = typeof pi?.getCommands === "function" ? pi.getCommands() : [];
417
- const tools = typeof pi?.getAllTools === "function" ? pi.getAllTools() : [];
418
- const activeTools = typeof pi?.getActiveTools === "function" ? pi.getActiveTools() : [];
413
+ const id = sm.getSessionId();
414
+ if (!id || !piRef) return null;
415
+ const cwd = sm.getCwd() ?? null;
416
+ const name = sm.getSessionName() ?? null;
417
+ const model = ctx.model?.id ?? null;
418
+ const systemPrompt = ctx.getSystemPrompt();
419
+ const commands = piRef.getCommands();
420
+ const tools = piRef.getAllTools();
421
+ const activeTools = piRef.getActiveTools();
419
422
  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());
423
+ for (const { sourceInfo } of [...commands, ...tools]) {
424
+ if (sourceInfo?.path) activePaths.add(sourceInfo.path.replace(/\\/g, "/").toLowerCase());
427
425
  }
428
426
  const { disabled: disabledItems, pending: pendingItems } = await discoverFromPackages(cwd, activePaths);
429
427
  const capturedAt = Date.now();
@@ -442,8 +440,8 @@ async function startServer(notify: (m: string, l?: "info" | "error") => void): P
442
440
  if (await probePort(port)) return true;
443
441
  lastStderr = "";
444
442
  const serverPath = resolvePath(extDir, "..", "server.js");
445
- child = spawn(process.execPath, [serverPath], {
446
- env: { ...process.env, PORT: String(port) },
443
+ child = spawn(process.execPath, [serverPath, "--port", String(port), "--no-open"], {
444
+ env: { ...process.env, PI_CODING_AGENT_DIR: AGENT_DIR },
447
445
  stdio: ["ignore", "ignore", "pipe"],
448
446
  detached: true,
449
447
  windowsHide: true,
@@ -508,6 +506,10 @@ export default function inspectExtension(pi: ExtensionAPI) {
508
506
  lastCtx = ctx;
509
507
  await captureSnapshot(ctx);
510
508
  });
509
+ pi.on("session_shutdown", () => {
510
+ reqWatcher?.close();
511
+ reqWatcher = null;
512
+ });
511
513
 
512
514
  pi.registerCommand("inspect", {
513
515
  description:
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+ const os = require('node:os');
3
+ const path = require('node:path');
4
+
5
+ // Same rule as pi's getAgentDir: PI_CODING_AGENT_DIR with ~ expanded, else ~/.pi/agent.
6
+ function agentDir(env = process.env) {
7
+ const dir = env.PI_CODING_AGENT_DIR;
8
+ if (!dir) return path.join(os.homedir(), '.pi', 'agent');
9
+ if (dir === '~') return os.homedir();
10
+ if (/^~[\\/]/.test(dir)) return path.join(os.homedir(), dir.slice(2));
11
+ return path.resolve(dir);
12
+ }
13
+
14
+ function inspectDir(env = process.env) {
15
+ return path.join(agentDir(env), 'inspect');
16
+ }
17
+
18
+ module.exports = { agentDir, inspectDir };
package/lib/app.js ADDED
@@ -0,0 +1,144 @@
1
+ 'use strict';
2
+ const express = require('express');
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const githubSource = require('./github-source');
6
+ const { openInEditor } = require('./editor');
7
+ const { writeJsonAtomic } = require('./snapshot');
8
+ const pkg = require('../package.json');
9
+
10
+ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
11
+ const RESOURCE_KINDS = ['skills', 'prompts', 'extensions', 'themes'];
12
+
13
+ // Path template a host such as the pi-kanban board fills in to open one session.
14
+ const SESSION_PATH = '/?session={id}';
15
+
16
+ function collectSourceRoots(snap) {
17
+ const roots = new Set();
18
+ for (const x of [...(snap.tools ?? []), ...(snap.commands ?? [])]) {
19
+ const baseDir = x?.sourceInfo?.baseDir;
20
+ if (typeof baseDir === 'string' && baseDir) roots.add(baseDir);
21
+ }
22
+ return [...roots];
23
+ }
24
+
25
+ const LOOPBACK_NAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
26
+ const ANY_ADDRESS = new Set(['0.0.0.0', '::', '[::]']);
27
+
28
+ // A DNS-rebinding page reaches the server under its own host name, where its origin checks pass.
29
+ // Serve the API only to loopback names and the listen address, unless the server listens on every address.
30
+ function hostAllowlist(listenHost) {
31
+ return (req, res, next) => {
32
+ if (ANY_ADDRESS.has(listenHost) || LOOPBACK_NAMES.has(req.hostname) || req.hostname === listenHost) return next();
33
+ res.status(403).json({ ok: false, error: 'host not allowed' });
34
+ };
35
+ }
36
+
37
+ // The routes change files or open programs, so a web page on another origin must not reach them.
38
+ // Browsers always send Origin on POST; tools such as the extension's fetch send none.
39
+ function sameOriginOnly(req, res, next) {
40
+ const origin = req.get('origin');
41
+ if (origin) {
42
+ let host = null;
43
+ try { host = new URL(origin).host; } catch {}
44
+ if (host !== req.get('host')) return res.status(403).json({ ok: false, error: 'cross-origin request refused' });
45
+ }
46
+ next();
47
+ }
48
+
49
+ function createApp({ store, inspectDir, host = '127.0.0.1', openEditor = openInEditor, resolveGithub = githubSource.resolveMany }) {
50
+ const app = express();
51
+ app.disable('x-powered-by');
52
+ app.use('/api', hostAllowlist(host));
53
+ app.use(express.json({ limit: '1mb' }));
54
+ app.post('/api/*splat', sameOriginOnly);
55
+
56
+ const sseClients = new Set();
57
+ function broadcast(event, data) {
58
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
59
+ for (const c of sseClients) c.write(payload);
60
+ }
61
+
62
+ app.get('/api/version', (_req, res) => {
63
+ res.json({ name: pkg.name, version: pkg.version });
64
+ });
65
+
66
+ app.get('/api/companion', (_req, res) => {
67
+ res.json({ name: pkg.name, version: pkg.version, session: SESSION_PATH });
68
+ });
69
+
70
+ app.get('/api/sessions', async (_req, res) => {
71
+ res.json({ sessions: await store.listSessions() });
72
+ });
73
+
74
+ app.post('/api/sessions/cleanup', async (req, res) => {
75
+ const keep = req.body?.keep;
76
+ if (!keep || typeof keep !== 'string') return res.status(400).json({ ok: false, error: 'missing keep' });
77
+ res.json({ ok: true, ...(await store.keepOnly(keep)) });
78
+ });
79
+
80
+ const githubMemo = new Map();
81
+ app.get('/api/introspect', async (req, res) => {
82
+ const sid = typeof req.query.session === 'string' && req.query.session ? req.query.session : null;
83
+ const snap = sid ? await store.readSnapshot(sid) : await store.readLatestSnapshot();
84
+ if (!snap) return res.status(404).json({ error: 'no snapshot found', sessionId: sid });
85
+ let githubSources = snap.sessionId ? githubMemo.get(snap.sessionId) : null;
86
+ if (!githubSources) {
87
+ githubSources = await resolveGithub(collectSourceRoots(snap));
88
+ if (snap.sessionId) githubMemo.set(snap.sessionId, githubSources);
89
+ }
90
+ res.json({ ...snap, githubSources });
91
+ });
92
+
93
+ app.post('/api/open', (req, res) => {
94
+ const target = req.body?.path;
95
+ if (!target || typeof target !== 'string') return res.status(400).json({ ok: false, error: 'missing path' });
96
+ const file = path.normalize(target);
97
+ if (!fs.existsSync(file)) return res.status(404).json({ ok: false, error: `path not found: ${file}` });
98
+ res.json({ ok: true, editor: openEditor(file), path: file });
99
+ });
100
+
101
+ // The extension in the live pi session applies the request; see setupRequestWatcher in extensions/inspect.ts.
102
+ app.post('/api/toggle', async (req, res) => {
103
+ const { action, resourceKind, path: target, scope } = req.body || {};
104
+ if (action !== 'enable' && action !== 'disable') return res.status(400).json({ ok: false, error: 'action must be enable or disable' });
105
+ if (!RESOURCE_KINDS.includes(resourceKind)) return res.status(400).json({ ok: false, error: 'invalid resourceKind' });
106
+ if (!target || typeof target !== 'string') return res.status(400).json({ ok: false, error: 'missing path' });
107
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
108
+ await writeJsonAtomic(path.join(inspectDir, 'requests', `${id}.json`), {
109
+ id, ts: Date.now(), action, resourceKind, path: target, scope: scope === 'project' ? 'project' : 'user',
110
+ });
111
+ res.json({ ok: true, id });
112
+ });
113
+
114
+ app.post('/api/focus', (req, res) => {
115
+ const session = typeof req.body?.session === 'string' ? req.body.session : null;
116
+ const delivered = sseClients.size;
117
+ if (delivered) broadcast('navigate', { session });
118
+ res.json({ delivered });
119
+ });
120
+
121
+ app.get('/api/events', (req, res) => {
122
+ res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
123
+ res.flushHeaders();
124
+ res.write(': hello\n\n');
125
+ sseClients.add(res);
126
+ req.on('close', () => sseClients.delete(res));
127
+ });
128
+
129
+ app.use('/api', (_req, res) => res.status(404).json({ error: 'not found' }));
130
+ app.use(express.static(PUBLIC_DIR));
131
+
132
+ app.use((err, _req, res, _next) => {
133
+ res.status(err.status || 500).json({ ok: false, error: err.message });
134
+ });
135
+
136
+ function closeClients() {
137
+ for (const c of sseClients) c.end();
138
+ sseClients.clear();
139
+ }
140
+
141
+ return { app, broadcast, closeClients };
142
+ }
143
+
144
+ module.exports = { createApp, SESSION_PATH };
package/lib/args.js ADDED
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ const { parseArgs } = require('node:util');
3
+
4
+ const DEFAULT_PORT = 5462;
5
+ const DEFAULT_HOST = '127.0.0.1';
6
+
7
+ // Throws on unknown flags or a bad port; the caller prints the message and exits 1.
8
+ function parseOptions(argv, env = process.env) {
9
+ const { values } = parseArgs({
10
+ args: argv,
11
+ options: {
12
+ port: { type: 'string' },
13
+ host: { type: 'string' },
14
+ open: { type: 'boolean' },
15
+ 'no-open': { type: 'boolean' },
16
+ session: { type: 'string' },
17
+ },
18
+ });
19
+ const rawPort = values.port ?? (env.PORT || String(DEFAULT_PORT));
20
+ const port = /^\d+$/.test(rawPort) ? Number(rawPort) : NaN;
21
+ if (!(port >= 0 && port <= 65535)) throw new Error(`invalid port: ${rawPort}`);
22
+ return {
23
+ port,
24
+ host: values.host || DEFAULT_HOST,
25
+ open: Boolean(values.open && !values['no-open']),
26
+ session: values.session ?? null,
27
+ };
28
+ }
29
+
30
+ module.exports = { parseOptions };
package/lib/editor.js ADDED
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+ const { spawn } = require('node:child_process');
3
+
4
+ function editorCommand(env = process.env) {
5
+ return env.EDITOR || env.VISUAL || (process.platform === 'win32' ? 'code' : 'vi');
6
+ }
7
+
8
+ // Detached, so closing the dashboard server leaves the editor open.
9
+ function openInEditor(file, env = process.env) {
10
+ const editor = editorCommand(env);
11
+ const isWin = process.platform === 'win32';
12
+ const [cmd, ...rest] = editor.split(/\s+/);
13
+ const args = [...rest, file];
14
+ // `code` is a .cmd shim on Windows, which only runs through a shell.
15
+ const child = spawn(cmd, isWin ? args.map((a) => `"${String(a).replace(/"/g, '\\"')}"`) : args, {
16
+ detached: true,
17
+ stdio: 'ignore',
18
+ shell: isWin,
19
+ windowsVerbatimArguments: isWin,
20
+ });
21
+ child.on('error', (err) => console.warn(`pi-inspect: open failed: ${err.message}`));
22
+ child.unref();
23
+ return editor;
24
+ }
25
+
26
+ module.exports = { openInEditor };