pi-inspect 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # pi-inspect
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-inspect.svg)](https://www.npmjs.com/package/pi-inspect)
4
+ [![npm downloads](https://img.shields.io/npm/dm/pi-inspect.svg)](https://www.npmjs.com/package/pi-inspect)
5
+
6
+ Introspection dashboard for the [pi coding agent](https://pi.dev) — see what's actually loaded into a session: tools, slash commands, skills, and the system prompt injected on init.
7
+
8
+ ![pi-inspect demo](https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo.png)
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ pi install npm:pi-inspect
14
+ ```
15
+
16
+ Then use `/inspect start | stop | restart | status | open | list | snapshot` from inside pi.
17
+
18
+ ## Usage (inside a pi session)
19
+
20
+ | Command | What it does |
21
+ | --- | --- |
22
+ | `/inspect` | Open the dashboard for the **current** session in your browser (`http://localhost:5462/?session=<id>`) |
23
+ | `/inspect <sessionId>` | Open dashboard pinned to a specific past session |
24
+ | `/inspect snapshot` | Re-capture the current session snapshot now |
25
+ | `/inspect list` | Print all captured session IDs in the terminal |
26
+ | `/inspect open web\|app` | Open in browser or as a PWA window |
27
+ | `/inspect start` / `stop` / `restart` / `status` | Manage the local server |
28
+
29
+ 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.
30
+
31
+ ## What it captures
32
+
33
+ - **Tools** — name, description, parameter schema, source
34
+ - **Slash commands** — name, source
35
+ - **System prompt** — full text injected on init, split into system / user `AGENTS.md` / project `AGENTS.md` sections
36
+ - **Session meta** — cwd, model, sessionId, sessionName, captured timestamp
37
+
38
+ Snapshots live at `~/.pi/agent/inspect/snapshots/<sessionId>.json`.
39
+
40
+ ## Port
41
+
42
+ `5462` — override via `PORT` env var.
@@ -0,0 +1,309 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createConnection } from "node:net";
4
+ import { homedir } from "node:os";
5
+ import { join as joinPath, resolve as resolvePath } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+
9
+ const extDir = fileURLToPath(new URL(".", import.meta.url));
10
+ const port = 5462;
11
+ const url = `http://localhost:${port}`;
12
+
13
+ const INSPECT_DIR = joinPath(homedir(), ".pi", "agent", "inspect");
14
+ const SNAP_DIR = joinPath(INSPECT_DIR, "snapshots");
15
+ const INDEX_PATH = joinPath(SNAP_DIR, "index.json");
16
+
17
+ let child: ChildProcess | null = null;
18
+ let lastStderr = "";
19
+ let piRef: ExtensionAPI | null = null;
20
+
21
+ function probePort(p: number, timeoutMs = 250): Promise<boolean> {
22
+ return new Promise((resolve) => {
23
+ const sock = createConnection({ port: p, host: "127.0.0.1" });
24
+ const done = (ok: boolean) => { sock.destroy(); resolve(ok); };
25
+ sock.setTimeout(timeoutMs);
26
+ sock.once("connect", () => done(true));
27
+ sock.once("timeout", () => done(false));
28
+ sock.once("error", () => done(false));
29
+ });
30
+ }
31
+
32
+ async function waitForPort(p: number, totalMs = 5000): Promise<boolean> {
33
+ const start = Date.now();
34
+ while (Date.now() - start < totalMs) {
35
+ if (await probePort(p)) return true;
36
+ await new Promise((r) => setTimeout(r, 150));
37
+ }
38
+ return false;
39
+ }
40
+
41
+ function findPidsOnPort(p: number): number[] {
42
+ if (process.platform === "win32") {
43
+ const r = spawnSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf8" });
44
+ if (r.status !== 0) return [];
45
+ const pids = new Set<number>();
46
+ for (const line of r.stdout.split(/\r?\n/)) {
47
+ const m = line.match(/^\s*TCP\s+\S+:(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
48
+ if (m && Number(m[1]) === p) pids.add(Number(m[2]));
49
+ }
50
+ return [...pids];
51
+ }
52
+ const r = spawnSync("lsof", ["-tiTCP:" + p, "-sTCP:LISTEN"], { encoding: "utf8" });
53
+ if (r.status !== 0) return [];
54
+ return r.stdout.split(/\s+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
55
+ }
56
+
57
+ function killPid(pid: number): void {
58
+ if (process.platform === "win32") {
59
+ spawnSync("taskkill", ["/F", "/PID", String(pid)], { stdio: "ignore" });
60
+ } else {
61
+ try { process.kill(pid, "SIGKILL"); } catch {}
62
+ }
63
+ }
64
+
65
+ function sanitize(id: string): string {
66
+ return id.replace(/[^a-zA-Z0-9._-]/g, "_");
67
+ }
68
+
69
+ type IndexEntry = {
70
+ id: string;
71
+ cwd: string | null;
72
+ name: string | null;
73
+ model: string | null;
74
+ capturedAt: number;
75
+ };
76
+
77
+ function readIndex(): IndexEntry[] {
78
+ try {
79
+ const parsed = JSON.parse(readFileSync(INDEX_PATH, "utf8")) as { sessions?: IndexEntry[] };
80
+ return Array.isArray(parsed.sessions) ? parsed.sessions : [];
81
+ } catch (e: any) {
82
+ if (e?.code !== "ENOENT") console.warn(`pi-inspect: read index: ${e.message}`);
83
+ return [];
84
+ }
85
+ }
86
+
87
+ function writeIndex(entries: IndexEntry[]): void {
88
+ mkdirSync(SNAP_DIR, { recursive: true });
89
+ writeFileSync(INDEX_PATH, JSON.stringify({ sessions: entries }, null, 2), "utf8");
90
+ }
91
+
92
+ function upsertIndex(entry: IndexEntry): void {
93
+ const list = readIndex().filter((e) => e.id !== entry.id);
94
+ list.push(entry);
95
+ writeIndex(list);
96
+ }
97
+
98
+ function writeSnapshot(id: string, snapshot: unknown): void {
99
+ mkdirSync(SNAP_DIR, { recursive: true });
100
+ writeFileSync(joinPath(SNAP_DIR, `${sanitize(id)}.json`), JSON.stringify(snapshot, null, 2), "utf8");
101
+ }
102
+
103
+ function captureSnapshot(ctx: any): { id: string; entry: IndexEntry } | null {
104
+ const sm = ctx.sessionManager;
105
+ const id = sm?.getSessionId?.();
106
+ if (!id) return null;
107
+ const cwd = sm?.getCwd?.() ?? null;
108
+ const name = sm?.getSessionName?.() ?? null;
109
+ const model = ctx.getModel?.()?.id ?? null;
110
+ const systemPrompt = typeof ctx.getSystemPrompt === "function" ? ctx.getSystemPrompt() : null;
111
+ const pi = piRef as any;
112
+ const commands = typeof pi?.getCommands === "function" ? pi.getCommands() : [];
113
+ const tools = typeof pi?.getAllTools === "function" ? pi.getAllTools() : [];
114
+ const activeTools = typeof pi?.getActiveTools === "function" ? pi.getActiveTools() : [];
115
+ const capturedAt = Date.now();
116
+ const snap = { sessionId: id, sessionName: name, cwd, model, systemPrompt, commands, tools, activeTools, capturedAt };
117
+ try {
118
+ writeSnapshot(id, snap);
119
+ upsertIndex({ id, cwd, name, model, capturedAt });
120
+ } catch (e: any) {
121
+ console.warn(`pi-inspect: snapshot write failed: ${e?.message ?? e}`);
122
+ return null;
123
+ }
124
+ return { id, entry: { id, cwd, name, model, capturedAt } };
125
+ }
126
+
127
+ async function startServer(notify: (m: string, l?: "info" | "error") => void): Promise<boolean> {
128
+ if (await probePort(port)) return true;
129
+ lastStderr = "";
130
+ const serverPath = resolvePath(extDir, "..", "server.js");
131
+ child = spawn(process.execPath, [serverPath], {
132
+ env: { ...process.env, PORT: String(port) },
133
+ stdio: ["ignore", "ignore", "pipe"],
134
+ detached: true,
135
+ windowsHide: true,
136
+ });
137
+ child.stderr?.on("data", (b) => { lastStderr += b.toString(); });
138
+ child.on("exit", () => { child = null; });
139
+ if (!(await waitForPort(port))) {
140
+ notify(`pi-inspect failed to start.\n${lastStderr.slice(-500) || "(no stderr)"}`, "error");
141
+ return false;
142
+ }
143
+ child.stderr?.removeAllListeners("data");
144
+ child.stderr?.resume();
145
+ child.unref();
146
+ return true;
147
+ }
148
+
149
+ async function stopServer(notify: (m: string, l?: "info" | "error") => void): Promise<void> {
150
+ if (child) child.kill("SIGINT");
151
+ child = null;
152
+ if (await probePort(port)) {
153
+ const pids = findPidsOnPort(port);
154
+ for (const pid of pids) killPid(pid);
155
+ await new Promise((r) => setTimeout(r, 300));
156
+ }
157
+ notify("pi-inspect stopped");
158
+ }
159
+
160
+ const SUBCOMMANDS = ["start", "stop", "restart", "status", "open", "list", "snapshot"] as const;
161
+ type Sub = (typeof SUBCOMMANDS)[number];
162
+ const OPEN_TARGETS = ["web", "app"] as const;
163
+
164
+ function showHelp(notify: (m: string, l?: "info" | "error") => void) {
165
+ notify(
166
+ [
167
+ "Usage: /inspect <command>",
168
+ "",
169
+ " start Start the dashboard server",
170
+ " stop Stop the dashboard server",
171
+ " restart Restart the dashboard server",
172
+ " status Show server status",
173
+ " open web Open dashboard in browser (default)",
174
+ " open app Open dashboard as PWA window",
175
+ " list List captured session snapshots",
176
+ " snapshot Re-capture the current session now",
177
+ "",
178
+ "Bare `/inspect` ensures the server is running and opens the current session.",
179
+ "`/inspect <sessionId>` opens a specific past session.",
180
+ ].join("\n"),
181
+ );
182
+ }
183
+
184
+ export default function inspectExtension(pi: ExtensionAPI) {
185
+ piRef = pi;
186
+ pi.on("session_start", async (_event, ctx) => {
187
+ captureSnapshot(ctx);
188
+ });
189
+ // session_start fires before all extensions register their tools/commands.
190
+ // before_agent_start fires after the user's first prompt with the fully assembled state — re-capture then.
191
+ pi.on("before_agent_start", async (_event, ctx) => {
192
+ captureSnapshot(ctx);
193
+ });
194
+
195
+ pi.registerCommand("inspect", {
196
+ description:
197
+ "pi-inspect dashboard: start | stop | restart | status | open web|app | list | snapshot. " +
198
+ "Bare `/inspect` opens the current session; `/inspect <sessionId>` opens a specific session.",
199
+ getArgumentCompletions: (prefix) => {
200
+ const tokens = prefix.trim().split(/\s+/).filter(Boolean);
201
+ if (tokens.length >= 1 && tokens[0] === "open") {
202
+ const v = tokens[1] ?? "";
203
+ return OPEN_TARGETS
204
+ .filter((t) => t.startsWith(v))
205
+ .map((t) => ({ value: `open ${t}`, label: t }));
206
+ }
207
+ return SUBCOMMANDS.filter((s) => s.startsWith(prefix)).map((s) => ({ value: s, label: s }));
208
+ },
209
+ handler: async (args, ctx) => {
210
+ const notify = (m: string, l: "info" | "error" = "info") => ctx.ui.notify(m, l);
211
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
212
+ const first = tokens[0] as Sub | string | undefined;
213
+
214
+ if (first === "help" || first === "--help" || first === "-h") {
215
+ showHelp(notify);
216
+ return;
217
+ }
218
+
219
+ if (first === "stop") return stopServer(notify);
220
+
221
+ if (first === "status") {
222
+ const up = await probePort(port);
223
+ notify(up ? `running on ${url}` : "not running");
224
+ return;
225
+ }
226
+
227
+ if (first === "start") {
228
+ if (!(await startServer(notify))) return;
229
+ captureSnapshot(ctx);
230
+ notify(`pi-inspect started → ${url}`);
231
+ return;
232
+ }
233
+
234
+ if (first === "restart") {
235
+ await stopServer(notify);
236
+ await new Promise((r) => setTimeout(r, 200));
237
+ if (!(await startServer(notify))) return;
238
+ notify(`pi-inspect restarted → ${url}`);
239
+ return;
240
+ }
241
+
242
+ if (first === "list") {
243
+ const list = readIndex();
244
+ if (!list.length) { notify("no snapshots yet — run /inspect to capture this session"); return; }
245
+ const sorted = [...list].sort((a, b) => b.capturedAt - a.capturedAt);
246
+ const lines = sorted.map((e) =>
247
+ ` ${e.id} ${e.name ?? "(no name)"} cwd=${e.cwd ?? "?"} model=${e.model ?? "?"}`,
248
+ );
249
+ notify(`pi-inspect snapshots:\n${lines.join("\n")}`);
250
+ return;
251
+ }
252
+
253
+ if (first === "snapshot") {
254
+ const r = captureSnapshot(ctx);
255
+ notify(r ? `snapshot captured: ${r.id}` : "no active session to snapshot", r ? "info" : "error");
256
+ return;
257
+ }
258
+
259
+ // `open [web|app]` and default (bare /inspect or /inspect <sessionId>)
260
+ const isExplicitOpen = first === "open";
261
+ const openTarget = (isExplicitOpen ? (tokens[1] ?? "web") : "web") as "web" | "app";
262
+
263
+ if (!(await startServer(notify))) return;
264
+ captureSnapshot(ctx);
265
+
266
+ let openId: string | null = null;
267
+ if (!isExplicitOpen && first && !(SUBCOMMANDS as readonly string[]).includes(first)) {
268
+ openId = first; // /inspect <sessionId>
269
+ } else {
270
+ openId = ctx.sessionManager?.getSessionId?.() ?? null;
271
+ }
272
+
273
+ const target = openId ? `${url}/?session=${encodeURIComponent(openId)}` : url;
274
+
275
+ // If a dashboard tab is already connected, ask it to navigate instead of opening a new window.
276
+ if (!isExplicitOpen) {
277
+ try {
278
+ const r = await fetch(`${url}/api/focus`, {
279
+ method: "POST",
280
+ headers: { "content-type": "application/json" },
281
+ body: JSON.stringify({ session: openId }),
282
+ });
283
+ const { delivered } = (await r.json()) as { delivered: number };
284
+ if (delivered > 0) {
285
+ notify(`focused existing dashboard → session ${openId ?? "(latest)"}`);
286
+ return;
287
+ }
288
+ } catch {}
289
+ }
290
+
291
+ if (openTarget === "app") {
292
+ const { default: openFn, apps } = await import("open");
293
+ for (const name of [apps.chrome, apps.edge, apps.browser]) {
294
+ try {
295
+ await openFn(target, { app: { name, arguments: [`--app=${target}`] } });
296
+ notify(`opened ${target} (app)`);
297
+ return;
298
+ } catch {}
299
+ }
300
+ notify("Could not find Chrome/Edge for PWA window mode", "error");
301
+ return;
302
+ }
303
+
304
+ const { default: openFn } = await import("open");
305
+ await openFn(target);
306
+ notify(`opened ${target}`);
307
+ },
308
+ });
309
+ }
@@ -0,0 +1 @@
1
+ { "type": "module" }
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "esModuleInterop": true,
7
+ "skipLibCheck": true,
8
+ "strict": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "noEmit": true,
11
+ "resolveJsonModule": true,
12
+ "lib": ["es2024"],
13
+ "types": ["node"]
14
+ },
15
+ "include": ["./**/*.ts"]
16
+ }
@@ -0,0 +1,56 @@
1
+ const fs = require('node:fs');
2
+ const fsp = require('node:fs/promises');
3
+ const path = require('node:path');
4
+ const os = require('node:os');
5
+
6
+ const SNAPSHOT_DIR = process.env.INSPECT_SNAPSHOT_DIR
7
+ || path.join(os.homedir(), '.pi', 'agent', 'inspect', 'snapshots');
8
+ const INDEX_PATH = path.join(SNAPSHOT_DIR, 'index.json');
9
+
10
+ function snapshotDir() {
11
+ return SNAPSHOT_DIR;
12
+ }
13
+
14
+ function snapshotPath(sessionId) {
15
+ return path.join(SNAPSHOT_DIR, `${sanitize(sessionId)}.json`);
16
+ }
17
+
18
+ function sanitize(id) {
19
+ return String(id).replace(/[^a-zA-Z0-9._-]/g, '_');
20
+ }
21
+
22
+ async function readIndex() {
23
+ try {
24
+ const raw = await fsp.readFile(INDEX_PATH, 'utf8');
25
+ const parsed = JSON.parse(raw);
26
+ return Array.isArray(parsed?.sessions) ? parsed.sessions : [];
27
+ } catch (e) {
28
+ if (e.code !== 'ENOENT') console.warn(`pi-inspect: read index: ${e.message}`);
29
+ return [];
30
+ }
31
+ }
32
+
33
+ async function readSnapshot(sessionId) {
34
+ try {
35
+ const raw = await fsp.readFile(snapshotPath(sessionId), 'utf8');
36
+ return JSON.parse(raw);
37
+ } catch (e) {
38
+ if (e.code === 'ENOENT') return null;
39
+ throw e;
40
+ }
41
+ }
42
+
43
+ async function readLatestSnapshot() {
44
+ const sessions = await readIndex();
45
+ if (!sessions.length) return null;
46
+ const sorted = [...sessions].sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0));
47
+ return readSnapshot(sorted[0].id);
48
+ }
49
+
50
+ module.exports = {
51
+ snapshotDir,
52
+ snapshotPath,
53
+ readIndex,
54
+ readSnapshot,
55
+ readLatestSnapshot,
56
+ };
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "pi-inspect",
3
+ "version": "0.1.0",
4
+ "description": "Introspection dashboard for the pi coding agent — tools, slash commands, skills, and the system prompt injected on init.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "inspect",
9
+ "introspection",
10
+ "dashboard",
11
+ "observability"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "nikiforovall",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/NikiforovAll/pi-inspect.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/NikiforovAll/pi-inspect/issues"
21
+ },
22
+ "homepage": "https://github.com/NikiforovAll/pi-inspect#readme",
23
+ "bin": {
24
+ "pi-inspect": "server.js"
25
+ },
26
+ "main": "server.js",
27
+ "files": [
28
+ "server.js",
29
+ "lib/",
30
+ "public/",
31
+ "extensions/",
32
+ "themes/",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "start": "node server.js",
37
+ "dev": "node server.js --open",
38
+ "typecheck:extensions": "tsc -p extensions/tsconfig.json --noEmit"
39
+ },
40
+ "dependencies": {
41
+ "chokidar": "^3.5.3",
42
+ "express": "^4.19.2",
43
+ "open": "^10.1.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^25.6.2",
47
+ "typescript": "^5.8.2"
48
+ },
49
+ "peerDependencies": {
50
+ "@earendil-works/pi-coding-agent": "*"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@earendil-works/pi-coding-agent": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "pi": {
58
+ "extensions": [
59
+ "./extensions"
60
+ ],
61
+ "image": "https://raw.githubusercontent.com/NikiforovAll/pi-inspect/main/assets/demo.png"
62
+ },
63
+ "engines": {
64
+ "node": ">=20.0.0"
65
+ }
66
+ }