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.
- package/CHANGELOG.md +28 -0
- package/README.md +65 -3
- package/extensions/inspect.ts +54 -52
- package/lib/agent-dir.js +18 -0
- package/lib/app.js +144 -0
- package/lib/args.js +30 -0
- package/lib/editor.js +26 -0
- package/lib/snapshot.js +57 -79
- package/lib/watch.js +29 -0
- package/package.json +9 -8
- package/public/app.js +676 -591
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/index.html +96 -75
- package/public/share.js +119 -0
- package/public/style.css +416 -1245
- package/public/sw.js +14 -20
- package/server.js +27 -198
- package/themes/pi-dark.json +0 -21
- 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
|
|
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
|
-
|
|
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
|
-
`
|
|
107
|
+
Set `PI_CODING_AGENT_DIR` to point the server at another agent dir, for example a dev one.
|
package/extensions/inspect.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
346
|
+
setters[req.resourceKind][scope](updated);
|
|
346
347
|
await sm.flush();
|
|
347
348
|
}
|
|
348
349
|
|
|
349
|
-
let lastCtx:
|
|
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
|
|
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
|
-
|
|
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:
|
|
411
|
+
async function captureSnapshot(ctx: ExtensionContext): Promise<{ id: string; entry: IndexEntry } | null> {
|
|
408
412
|
const sm = ctx.sessionManager;
|
|
409
|
-
const id = sm
|
|
410
|
-
if (!id) return null;
|
|
411
|
-
const cwd = sm
|
|
412
|
-
const name = sm
|
|
413
|
-
const model = ctx.
|
|
414
|
-
const systemPrompt =
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
-
const
|
|
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
|
|
421
|
-
|
|
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,
|
|
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:
|
package/lib/agent-dir.js
ADDED
|
@@ -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 };
|