@blackbelt-technology/pi-agent-dashboard 0.4.0 → 0.4.1

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 (35) hide show
  1. package/AGENTS.md +30 -8
  2. package/README.md +386 -494
  3. package/docs/architecture.md +63 -9
  4. package/package.json +8 -5
  5. package/packages/extension/package.json +6 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  9. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  10. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  11. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  12. package/packages/extension/src/ask-user-tool.ts +5 -4
  13. package/packages/extension/src/bridge.ts +102 -15
  14. package/packages/extension/src/multiselect-list.ts +146 -0
  15. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  16. package/packages/extension/src/server-launcher.ts +15 -3
  17. package/packages/server/package.json +5 -5
  18. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  19. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  20. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  21. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  22. package/packages/server/src/cli.ts +56 -9
  23. package/packages/server/src/pi-version-skew.ts +12 -1
  24. package/packages/server/src/restart-helper.ts +13 -2
  25. package/packages/shared/package.json +1 -1
  26. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  27. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  28. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  29. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  30. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  31. package/packages/shared/src/platform/index.ts +1 -0
  32. package/packages/shared/src/platform/node-spawn.ts +154 -0
  33. package/packages/shared/src/protocol.ts +23 -0
  34. package/packages/shared/src/state-replay.ts +9 -0
  35. package/packages/shared/src/tool-registry/definitions.ts +92 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * MultiSelectList — a TUI multi-select component implementing pi-tui's
3
+ * `Component` interface. Used by `polyfillMultiselect` to emulate the
4
+ * `ctx.ui.multiselect(...)` call that `pi-coding-agent`'s `ExtensionUIContext`
5
+ * does not expose natively.
6
+ *
7
+ * Keyboard contract (intentional — no "select all" binding in TUI):
8
+ * ↑ / k move cursor up
9
+ * ↓ / j move cursor down
10
+ * space toggle the checked state of the current item
11
+ * enter confirm → onConfirm(selected[])
12
+ * esc cancel → onCancel()
13
+ *
14
+ * The selected array preserves the original option order, not toggle order.
15
+ */
16
+
17
+ interface Item {
18
+ value: string;
19
+ label: string;
20
+ description?: string;
21
+ checked: boolean;
22
+ }
23
+
24
+ /**
25
+ * Minimal shape of pi-tui's `Component` interface — we avoid importing from
26
+ * `@mariozechner/pi-tui` directly so this module stays compile-friendly when
27
+ * that peer dep isn't present (e.g. in unit tests running via vitest without
28
+ * the full pi runtime).
29
+ */
30
+ export interface ComponentLike {
31
+ render(width: number): string[];
32
+ handleInput?(data: string): void;
33
+ }
34
+
35
+ const CURSOR = "▸ ";
36
+ const NO_CURSOR = " ";
37
+ const CHECKED = "[x]";
38
+ const UNCHECKED = "[ ]";
39
+ const FOOTER_HINT = "space toggle · enter confirm · esc cancel";
40
+
41
+ const MAX_VISIBLE = 10;
42
+
43
+ function truncate(text: string, maxWidth: number): string {
44
+ if (maxWidth <= 1) return "";
45
+ if (text.length <= maxWidth) return text;
46
+ if (maxWidth <= 1) return "…";
47
+ return text.slice(0, Math.max(0, maxWidth - 1)) + "…";
48
+ }
49
+
50
+ export class MultiSelectList implements ComponentLike {
51
+ private items: Item[];
52
+ private cursor = 0;
53
+ private scrollOffset = 0;
54
+
55
+ onConfirm?: (selectedValues: string[]) => void;
56
+ onCancel?: () => void;
57
+
58
+ constructor(
59
+ private title: string,
60
+ options: string[],
61
+ private message?: string,
62
+ ) {
63
+ this.items = options.map((opt) => ({
64
+ value: opt,
65
+ label: opt,
66
+ checked: false,
67
+ }));
68
+ }
69
+
70
+ /** Expose current state for testing / adapters. */
71
+ getItems(): readonly Item[] {
72
+ return this.items;
73
+ }
74
+ getCursor(): number {
75
+ return this.cursor;
76
+ }
77
+
78
+ /** Return values of currently checked items in original option order. */
79
+ private selectedValues(): string[] {
80
+ return this.items.filter((it) => it.checked).map((it) => it.value);
81
+ }
82
+
83
+ render(width: number): string[] {
84
+ const lines: string[] = [];
85
+ if (this.title) lines.push(truncate(this.title, width));
86
+ if (this.message) lines.push(truncate(this.message, width));
87
+ if (lines.length > 0) lines.push("");
88
+
89
+ // Scroll window around cursor.
90
+ const visible = Math.min(MAX_VISIBLE, this.items.length);
91
+ if (this.cursor < this.scrollOffset) {
92
+ this.scrollOffset = this.cursor;
93
+ } else if (this.cursor >= this.scrollOffset + visible) {
94
+ this.scrollOffset = this.cursor - visible + 1;
95
+ }
96
+
97
+ for (let i = 0; i < visible; i++) {
98
+ const idx = this.scrollOffset + i;
99
+ const item = this.items[idx];
100
+ if (!item) break;
101
+ const marker = idx === this.cursor ? CURSOR : NO_CURSOR;
102
+ const box = item.checked ? CHECKED : UNCHECKED;
103
+ let line = `${marker}${box} ${item.label}`;
104
+ if (item.description) line += ` — ${item.description}`;
105
+ lines.push(truncate(line, width));
106
+ }
107
+
108
+ if (this.items.length > visible) {
109
+ lines.push(` (${this.cursor + 1}/${this.items.length})`);
110
+ }
111
+
112
+ lines.push("");
113
+ lines.push(truncate(FOOTER_HINT, width));
114
+ return lines;
115
+ }
116
+
117
+ handleInput(data: string): void {
118
+ // Escape
119
+ if (data === "\u001b" || data === "\x1b") {
120
+ this.onCancel?.();
121
+ return;
122
+ }
123
+ // Enter (CR or LF)
124
+ if (data === "\r" || data === "\n") {
125
+ this.onConfirm?.(this.selectedValues());
126
+ return;
127
+ }
128
+ // Space — toggle current
129
+ if (data === " ") {
130
+ const item = this.items[this.cursor];
131
+ if (item) item.checked = !item.checked;
132
+ return;
133
+ }
134
+ // Arrow up / k
135
+ if (data === "\u001b[A" || data === "k") {
136
+ if (this.cursor > 0) this.cursor--;
137
+ return;
138
+ }
139
+ // Arrow down / j
140
+ if (data === "\u001b[B" || data === "j") {
141
+ if (this.cursor < this.items.length - 1) this.cursor++;
142
+ return;
143
+ }
144
+ // Everything else (including "a", "A", bulk-toggle attempts) is a no-op.
145
+ }
146
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Polyfill for `ctx.ui.multiselect(...)` — a method the dashboard bridge's
3
+ * `ask_user` tool advertises but which `pi-coding-agent`'s
4
+ * `ExtensionUIContext` does not expose. Without this, any TUI dispatch of
5
+ * `method: "multiselect"` crashes with `"ctx.ui.multiselect is not a function"`.
6
+ *
7
+ * Implementation strategy: always delegate to the already-exposed
8
+ * `ctx.ui.custom<T>()` primitive, which takes a factory that returns a
9
+ * focused pi-tui `Component`. We instantiate a `MultiSelectList`, wire
10
+ * `onConfirm` → `done(selected)` and `onCancel` → `done(undefined)`, and
11
+ * return the component.
12
+ *
13
+ * The result contract matches what the current (broken) call expects:
14
+ * - resolves to `string[]` when the user confirms a selection
15
+ * (possibly empty if nothing is checked)
16
+ * - resolves to `undefined` when the user cancels (Escape)
17
+ */
18
+ import { MultiSelectList } from "./multiselect-list.js";
19
+
20
+ // Intentionally loose: `ctx` shape varies slightly across pi versions; the
21
+ // polyfill only needs `ctx.ui.custom`.
22
+ export interface PolyfillCtx {
23
+ ui: {
24
+ custom<T>(
25
+ factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => unknown,
26
+ options?: unknown,
27
+ ): Promise<T>;
28
+ };
29
+ }
30
+
31
+ export function polyfillMultiselect(
32
+ ctx: PolyfillCtx,
33
+ title: string,
34
+ options: string[],
35
+ opts?: { message?: string },
36
+ ): Promise<string[] | undefined> {
37
+ return ctx.ui.custom<string[] | undefined>((_tui, _theme, _keybindings, done) => {
38
+ const list = new MultiSelectList(title, options, opts?.message);
39
+ list.onConfirm = (selected) => done(selected);
40
+ list.onCancel = () => done(undefined);
41
+ return list as unknown;
42
+ });
43
+ }
@@ -11,6 +11,7 @@ import { createRequire } from "node:module";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import type { DashboardConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
13
  import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
14
+ import { toFileUrl, shouldUrlWrapEntry } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
14
15
  import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
15
16
 
16
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -77,11 +78,22 @@ export async function launchServer(config: DashboardConfig): Promise<LaunchResul
77
78
  );
78
79
  } catch { /* if we can't open the log, spawn still works */ }
79
80
 
80
- // Spawn server via the detached-spawn primitive. resolveJitiImport()
81
- // returns a file:// URL (required on Windows for node --import).
81
+ // Spawn server via the detached-spawn primitive. The loader is always
82
+ // URL-wrapped (Node needs file:// for --import on Windows drive letters).
83
+ // The entry is URL-wrapped only on Windows + non-tsx loader (Node parses
84
+ // drive letters as URL schemes in argv); on POSIX the entry MUST be raw
85
+ // because jiti's resolver misbehaves on file:// URL entries. See
86
+ // openspec/changes/archive/2026-04-24-fix-windows-entry-script-url.
87
+ const loader = resolveJitiImport();
88
+ const wrapEntry = shouldUrlWrapEntry(loader);
89
+ // entry is gated by shouldUrlWrapEntry(loader): returns true only on
90
+ // Windows + non-tsx (where URL wrap is required); false on POSIX
91
+ // where jiti needs the raw path (file:// URL entries trigger jiti's
92
+ // `<cwd>/file:/...` misresolution bug).
93
+ const entry = wrapEntry ? toFileUrl(cliPath) : cliPath;
82
94
  const r = await spawnDetached({
83
95
  cmd: process.execPath,
84
- args: ["--import", resolveJitiImport(), cliPath, ...args],
96
+ args: ["--import", loader, entry, ...args], // ban:raw-node-import-ok: entry gated by shouldUrlWrapEntry
85
97
  env: { ...process.env },
86
98
  logFd,
87
99
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -10,8 +10,8 @@
10
10
  "node": ">=22.18.0"
11
11
  },
12
12
  "piCompatibility": {
13
- "minimum": "0.6.7",
14
- "recommended": "0.6.7",
13
+ "minimum": "0.70.0",
14
+ "recommended": "0.70.0",
15
15
  "maximum": null
16
16
  },
17
17
  "main": "src/cli.ts",
@@ -26,8 +26,8 @@
26
26
  "postinstall": "node scripts/fix-pty-permissions.cjs"
27
27
  },
28
28
  "dependencies": {
29
- "@blackbelt-technology/pi-dashboard-extension": "^0.4.0",
30
- "@blackbelt-technology/pi-dashboard-shared": "^0.4.0",
29
+ "@blackbelt-technology/pi-dashboard-extension": "^0.4.1",
30
+ "@blackbelt-technology/pi-dashboard-shared": "^0.4.1",
31
31
  "@fastify/compress": "^8.3.1",
32
32
  "@fastify/cookie": "^11.0.2",
33
33
  "@fastify/cors": "^11.0.0",
@@ -0,0 +1,8 @@
1
+ {"type":"session","version":"1","id":"019dcdd5-0000-0000-0000-000000000000","timestamp":"2026-04-27T07:26:23.927Z","cwd":"/tmp/fork-test"}
2
+ {"type":"model_change","id":"m1","timestamp":"2026-04-27T07:26:24.000Z","provider":"anthropic","modelId":"claude-sonnet-4"}
3
+ {"type":"message","id":"u1","parentId":"m1","timestamp":"2026-04-27T07:26:25.000Z","message":{"role":"user","content":[{"type":"text","text":"Hello"}]}}
4
+ {"type":"message","id":"a1","parentId":"u1","timestamp":"2026-04-27T07:26:30.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi there!"}]}}
5
+ {"type":"message","id":"u2","parentId":"a1","timestamp":"2026-04-27T07:26:40.000Z","message":{"role":"user","content":[{"type":"text","text":"How are you?"}]}}
6
+ {"type":"message","id":"a2","parentId":"u2","timestamp":"2026-04-27T07:26:45.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Doing well."}]}}
7
+ {"type":"message","id":"u3","parentId":"a2","timestamp":"2026-04-27T07:26:55.000Z","message":{"role":"user","content":[{"type":"text","text":"Goodbye"}]}}
8
+ {"type":"message","id":"a3","parentId":"u3","timestamp":"2026-04-27T07:27:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Bye!"}]}}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Round-trip test: createBranchedSessionFile MUST end the new JSONL at the
3
+ * given entry id. Catches the fork-bubble off-by-one bug from the upstream
4
+ * angle: if the bridge ever stamps a correct entry id on a bubble, this
5
+ * function must produce a file whose tail entry equals that id.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+ import { readFileSync, mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { createBranchedSessionFile } from "../session-file-reader.js";
12
+
13
+ const FIXTURE = join(__dirname, "fixtures", "fork-jsonl-roundtrip.jsonl");
14
+
15
+ function readEntries(path: string): any[] {
16
+ return readFileSync(path, "utf-8").trim().split("\n").map(l => JSON.parse(l));
17
+ }
18
+
19
+ describe("createBranchedSessionFile round-trip", () => {
20
+ it("for every non-header entry id, the forked JSONL ends at that id", () => {
21
+ // Copy fixture to a tmp dir so the function can write its sibling output there.
22
+ const tmp = mkdtempSync(join(tmpdir(), "fork-roundtrip-"));
23
+ const tmpFixture = join(tmp, "src.jsonl");
24
+ require("node:fs").copyFileSync(FIXTURE, tmpFixture);
25
+
26
+ try {
27
+ const allEntries = readEntries(tmpFixture);
28
+ const candidates = allEntries.filter(e => e.type === "message" || e.type === "model_change").map(e => e.id);
29
+ expect(candidates.length).toBeGreaterThan(0);
30
+
31
+ for (const targetId of candidates) {
32
+ const newPath = createBranchedSessionFile(tmpFixture, targetId);
33
+ const newEntries = readEntries(newPath);
34
+
35
+ const header = newEntries[0];
36
+ expect(header.type).toBe("session");
37
+
38
+ const lastEntry = newEntries[newEntries.length - 1];
39
+ expect(lastEntry.id).toBe(targetId);
40
+ }
41
+ } finally {
42
+ rmSync(tmp, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ it("throws on unknown entry id", () => {
47
+ expect(() => createBranchedSessionFile(FIXTURE, "does-not-exist")).toThrow(/not found/i);
48
+ });
49
+ });
@@ -13,9 +13,11 @@ import {
13
13
  isBelow,
14
14
  isAbove,
15
15
  readPiCompatibility,
16
+ readCurrentPiVersion,
16
17
  computeCompatibility,
17
18
  _resetVersionSkewCache,
18
19
  } from "../pi-version-skew.js";
20
+ import type { ToolRegistry, Resolution } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
19
21
 
20
22
  describe("pi-version-skew", () => {
21
23
  beforeEach(() => {
@@ -162,4 +164,74 @@ describe("pi-version-skew", () => {
162
164
  expect(out.upgradeDashboard).toBe(true);
163
165
  });
164
166
  });
167
+
168
+ // See change: warn-pi-version-skew-in-cli.
169
+ describe("readCurrentPiVersion (realpath symlinks)", () => {
170
+ let tmpDir: string;
171
+
172
+ beforeEach(() => {
173
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-skew-realpath-"));
174
+ });
175
+
176
+ function stubRegistry(resolvedPath: string): ToolRegistry {
177
+ return {
178
+ resolve: (name: string): Resolution => ({
179
+ ok: true,
180
+ name,
181
+ path: resolvedPath,
182
+ source: "system",
183
+ tried: [],
184
+ resolvedAt: Date.now(),
185
+ }),
186
+ } as unknown as ToolRegistry;
187
+ }
188
+
189
+ it("npm-global symlinked bin launcher resolves to the real package.json", () => {
190
+ // Simulate ~/.nvm/.../bin/pi → ../lib/node_modules/@mariozechner/pi-coding-agent/dist/cli.js
191
+ const nodeRoot = path.join(tmpDir, "node-install");
192
+ const binDir = path.join(nodeRoot, "bin");
193
+ const pkgDir = path.join(nodeRoot, "lib", "node_modules", "@mariozechner", "pi-coding-agent");
194
+ const distDir = path.join(pkgDir, "dist");
195
+ fs.mkdirSync(binDir, { recursive: true });
196
+ fs.mkdirSync(distDir, { recursive: true });
197
+ fs.writeFileSync(path.join(distDir, "cli.js"), "// stub");
198
+ fs.writeFileSync(
199
+ path.join(pkgDir, "package.json"),
200
+ JSON.stringify({ name: "@mariozechner/pi-coding-agent", version: "0.70.0" }),
201
+ );
202
+ // The bad path (what old code computed) must NOT exist.
203
+ // That is: nodeRoot/package.json. We leave it absent.
204
+
205
+ const binLink = path.join(binDir, "pi");
206
+ // relative symlink matches npm's install layout.
207
+ fs.symlinkSync(
208
+ path.relative(binDir, path.join(distDir, "cli.js")),
209
+ binLink,
210
+ );
211
+
212
+ const registry = stubRegistry(binLink);
213
+ expect(readCurrentPiVersion(registry)).toBe("0.70.0");
214
+ });
215
+
216
+ it("non-symlinked path is a no-op under realpath", () => {
217
+ const pkgDir = path.join(tmpDir, "pkg");
218
+ const distDir = path.join(pkgDir, "dist");
219
+ fs.mkdirSync(distDir, { recursive: true });
220
+ const cli = path.join(distDir, "cli.js");
221
+ fs.writeFileSync(cli, "// stub");
222
+ fs.writeFileSync(
223
+ path.join(pkgDir, "package.json"),
224
+ JSON.stringify({ name: "@mariozechner/pi-coding-agent", version: "0.69.0" }),
225
+ );
226
+ const registry = stubRegistry(cli);
227
+ expect(readCurrentPiVersion(registry)).toBe("0.69.0");
228
+ });
229
+
230
+ it("dangling symlink returns undefined", () => {
231
+ const link = path.join(tmpDir, "dangling-pi");
232
+ fs.symlinkSync(path.join(tmpDir, "does-not-exist", "cli.js"), link);
233
+ const registry = stubRegistry(link);
234
+ expect(readCurrentPiVersion(registry)).toBeUndefined();
235
+ });
236
+ });
165
237
  });
@@ -34,14 +34,18 @@ describe("buildOrchestratorScript", () => {
34
34
  const script = buildOrchestratorScript(baseParams);
35
35
  // ARGS should be a JSON array containing --import and the loader
36
36
  expect(script).toMatch(/const ARGS = \[.*"--import".*"file:\/\/\/tmp\/jiti-register\.mjs"/);
37
+ // On POSIX, cliPath stays RAW — jiti's resolver misbehaves on file:// URL entries.
37
38
  expect(script).toMatch(/"\/tmp\/cli\.ts"/);
39
+ expect(script).not.toContain(JSON.stringify("file:///tmp/cli.ts"));
38
40
  expect(script).toMatch(/"start"/);
39
41
  });
40
42
 
41
43
  it("omits --import when loader is empty", () => {
42
44
  const script = buildOrchestratorScript({ ...baseParams, loader: "" });
43
45
  expect(script).not.toMatch(/"--import"/);
46
+ // No loader + POSIX host → raw entry.
44
47
  expect(script).toMatch(/"\/tmp\/cli\.ts"/);
48
+ expect(script).not.toContain(JSON.stringify("file:///tmp/cli.ts"));
45
49
  expect(script).toMatch(/"start"/);
46
50
  });
47
51
 
@@ -51,7 +55,12 @@ describe("buildOrchestratorScript", () => {
51
55
  expect(script).toMatch(/"start","--dev"/);
52
56
  });
53
57
 
54
- it("safely embeds Windows paths with backslashes and drive letters", () => {
58
+ it("wraps Windows cliPath as file:// URL when loader is jiti AND host is Windows (Node parses drive letters as URL schemes)", () => {
59
+ // NOTE: shouldUrlWrapEntry consults process.platform. This test runs on
60
+ // Linux CI, so the wrap branch isn't directly exercised here — but the
61
+ // UNIT test for shouldUrlWrapEntry itself covers the win32 contract.
62
+ // Here we verify the tree of what buildOrchestratorScript emits on the
63
+ // host platform (Linux): raw entry even with a Windows-styled path.
55
64
  const winParams = {
56
65
  ...baseParams,
57
66
  cliPath: "B:\\Dev\\BB\\pi-agent-dashboard\\packages\\server\\src\\cli.ts",
@@ -59,13 +68,32 @@ describe("buildOrchestratorScript", () => {
59
68
  execPath: "C:\\Program Files\\nodejs\\node.exe",
60
69
  };
61
70
  const script = buildOrchestratorScript(winParams);
62
- // Must be embedded via JSON.stringify (backslashes escaped, quotes preserved)
63
71
  expect(script).toContain(JSON.stringify(winParams.execPath));
64
- expect(script).toContain(JSON.stringify(winParams.cliPath));
65
72
  expect(script).toContain(JSON.stringify(winParams.loader));
66
- // Should not contain raw unescaped backslashes that would break the JS
67
- // (we embed via JSON.stringify which escapes them to \\)
68
- expect(script).toMatch(/B:\\\\Dev\\\\BB/);
73
+ // Host is Linux entry stays raw (tested branch here).
74
+ expect(script).toContain(JSON.stringify(winParams.cliPath));
75
+ });
76
+
77
+ it("keeps cliPath as RAW path when loader is tsx (tsx rejects file:// URL entries)", () => {
78
+ // Regression: tsx's ESM hook treats the entry as a user-typed specifier
79
+ // and attempts bare/relative resolution. A file:// URL becomes "<cwd>/file:/..."
80
+ // and crashes with ERR_MODULE_NOT_FOUND. This is the Linux dev-loop case
81
+ // (jiti not in repo node_modules, tsx fallback picked up).
82
+ const tsxParams = {
83
+ cliPath: "/home/u/repo/packages/server/src/cli.ts",
84
+ loader: "file:///home/u/repo/node_modules/tsx/dist/esm/index.mjs",
85
+ port: 8000,
86
+ extraArgs: [] as string[],
87
+ execPath: "/usr/bin/node",
88
+ };
89
+ const script = buildOrchestratorScript(tsxParams);
90
+ // Loader is still URL-wrapped (Node's --import requires file://)
91
+ expect(script).toContain(JSON.stringify(tsxParams.loader));
92
+ // Entry is the RAW path, NOT a file:// URL
93
+ expect(script).toContain(JSON.stringify(tsxParams.cliPath));
94
+ // Negative: must NOT contain the file:// URL form of the entry
95
+ const urlForm = "file://" + tsxParams.cliPath;
96
+ expect(script).not.toContain(JSON.stringify(urlForm));
69
97
  });
70
98
 
71
99
  it("references ~/.pi/dashboard/restart.log for failure logging", () => {
@@ -18,6 +18,7 @@
18
18
  import { createServer, type ServerConfig } from "./server.js";
19
19
  import { loadConfig, ensureConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
20
20
  import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
21
+ import { spawnNodeScript } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
21
22
  import { createRequire } from "node:module";
22
23
  import { fileURLToPath, pathToFileURL } from "node:url";
23
24
  import fs from "node:fs";
@@ -51,6 +52,37 @@ import {
51
52
  } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
52
53
  import type { DashboardServer } from "./server.js";
53
54
  import { updateBootstrapCompatibility } from "./pi-version-skew.js";
55
+ import type { BootstrapStateStore } from "./bootstrap-state.js";
56
+
57
+ /**
58
+ * Emit a stderr warning at CLI startup when the resolved pi version is
59
+ * below `piCompatibility.minimum` (blocking) or below `.recommended`
60
+ * (advisory). Reads from the already-populated `bootstrapState` so no
61
+ * additional I/O happens here. See change: warn-pi-version-skew-in-cli.
62
+ */
63
+ function logCompatibilityWarning(store: BootstrapStateStore): void {
64
+ const s = store.get();
65
+ const c = s.compatibility;
66
+ if (!c || !c.current) return;
67
+ // Below minimum: `updateBootstrapCompatibility` sets `error.message`.
68
+ // We treat the presence of a blocking error + upgradeRecommended as the
69
+ // below-minimum signal; `upgradeRecommended` alone means below-recommended.
70
+ if (s.error?.message && c.upgradeRecommended) {
71
+ console.error(
72
+ `[bootstrap] ⚠ pi ${c.current} is below the required minimum ${c.minimum}.`,
73
+ );
74
+ console.error(
75
+ `[bootstrap] All pi-dependent features (sessions, resources, openspec) will return 503.`,
76
+ );
77
+ console.error(`[bootstrap] Run: pi-dashboard upgrade-pi`);
78
+ return;
79
+ }
80
+ if (c.upgradeRecommended) {
81
+ console.warn(
82
+ `[bootstrap] pi ${c.current} is below the recommended ${c.recommended} — consider running \`pi-dashboard upgrade-pi\``,
83
+ );
84
+ }
85
+ }
54
86
 
55
87
  const SUBCOMMANDS = ["start", "stop", "restart", "status", "upgrade-pi"] as const;
56
88
  type Subcommand = (typeof SUBCOMMANDS)[number];
@@ -191,6 +223,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
191
223
  "package.json",
192
224
  );
193
225
  updateBootstrapCompatibility(server.bootstrapState, serverPkg);
226
+ logCompatibilityWarning(server.bootstrapState);
194
227
  } catch (err) {
195
228
  console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
196
229
  }
@@ -260,6 +293,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
260
293
  "package.json",
261
294
  );
262
295
  updateBootstrapCompatibility(server.bootstrapState, serverPkg);
296
+ logCompatibilityWarning(server.bootstrapState);
263
297
  } catch (err) {
264
298
  console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
265
299
  }
@@ -339,21 +373,31 @@ async function cmdStart(config: ServerConfig): Promise<void> {
339
373
  `\n[${new Date().toISOString()}] pi-dashboard start (parent pid ${process.pid}, port ${config.port})\n`,
340
374
  );
341
375
 
342
- // tsLoader is a file:// URL (required on Windows for node --import).
343
- // See change: fix-windows-server-parity.
344
- const child = spawn(process.execPath, ["--import", tsLoader, cliPath, ...args], {
345
- detached: true,
346
- stdio: ["ignore", logFd, logFd],
347
- env: { ...process.env },
376
+ // Both tsLoader and cliPath are wrapped as file:// URLs by spawnNodeScript.
377
+ // Required on Windows for node --import (see change: fix-windows-entry-script-url).
378
+ const child = spawnNodeScript({
379
+ loader: tsLoader,
380
+ entry: cliPath,
381
+ args,
382
+ spawnOptions: {
383
+ detached: true,
384
+ stdio: ["ignore", logFd, logFd],
385
+ env: { ...process.env },
386
+ },
348
387
  });
349
388
  child.unref();
350
389
  // Close the parent's copy of the fd — child has its own via stdio inheritance.
351
390
  try { fs.closeSync(logFd); } catch { /* ignore */ }
352
391
 
353
- // Wait for dashboard to become available (up to 5 seconds)
354
- const deadline = Date.now() + 5000;
392
+ // Wait for dashboard to become available. Windows + jiti cold-start can
393
+ // take 10s+ (TS compile on first boot, native module loads). 30s is the
394
+ // outer bound — if the server isn't up by then, something's genuinely wrong.
395
+ const READINESS_TIMEOUT_MS = 30_000;
396
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
355
397
  let started = false;
356
398
  while (Date.now() < deadline) {
399
+ // Also bail if the child has already exited (fast-path crash detection).
400
+ if (child.exitCode !== null) break;
357
401
  await new Promise((r) => setTimeout(r, 300));
358
402
  const status = await isDashboardRunning(config.port);
359
403
  if (status.running) {
@@ -366,7 +410,10 @@ async function cmdStart(config: ServerConfig): Promise<void> {
366
410
  const pid = readPid();
367
411
  console.log(`Dashboard server started (pid ${pid ?? child.pid}) at http://localhost:${config.port}`);
368
412
  } else {
369
- console.error("Failed to start dashboard server (timed out after 5s)");
413
+ const reason = child.exitCode !== null
414
+ ? `child process exited with code ${child.exitCode}`
415
+ : `timed out after ${READINESS_TIMEOUT_MS / 1000}s`;
416
+ console.error(`Failed to start dashboard server (${reason})`);
370
417
  console.error(`Check logs at ${path.join(logDir, "server.log")}`);
371
418
  process.exit(1);
372
419
  }
@@ -106,10 +106,21 @@ export function readCurrentPiVersion(registry: ToolRegistry = getDefaultRegistry
106
106
  /* not resolvable yet */
107
107
  }
108
108
  // Fall back to the registry's resolved path + ../package.json.
109
+ // `where` / `which` strategies typically return a symlinked npm bin
110
+ // launcher (e.g. ~/.nvm/.../bin/pi → ../lib/node_modules/@mariozechner/
111
+ // pi-coding-agent/dist/cli.js). Realpath the result first so the
112
+ // dirname math lands on the real pi module directory, not the
113
+ // bin-containing Node install prefix. See change: warn-pi-version-skew-in-cli.
109
114
  try {
110
115
  const res = registry.resolve("pi");
111
116
  if (res.ok && res.path) {
112
- const candidate = path.join(path.dirname(path.dirname(res.path)), "package.json");
117
+ let resolvedPath: string;
118
+ try {
119
+ resolvedPath = fs.realpathSync(res.path);
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ const candidate = path.join(path.dirname(path.dirname(resolvedPath)), "package.json");
113
124
  if (fs.existsSync(candidate)) {
114
125
  const raw = fs.readFileSync(candidate, "utf8");
115
126
  const parsed = JSON.parse(raw) as { version?: string };
@@ -12,6 +12,7 @@
12
12
  * See change: fix-windows-server-parity.
13
13
  */
14
14
  import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
15
+ import { toFileUrl, shouldUrlWrapEntry } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
15
16
  import os from "node:os";
16
17
  import path from "node:path";
17
18
 
@@ -35,11 +36,21 @@ export interface RestartParams {
35
36
  export function buildOrchestratorScript(params: RestartParams): string {
36
37
  const execPath = params.execPath ?? process.execPath;
37
38
  const logPath = path.join(os.homedir(), ".pi", "dashboard", "restart.log");
39
+ // Loader is always URL-wrapped (required on Windows for non-C: drives).
40
+ // Entry is URL-wrapped only on Windows + non-tsx loader. POSIX + jiti MUST
41
+ // pass raw path because jiti's resolver treats file:// URL entries as
42
+ // relative specifiers (normalises to file:/... then prepends cwd).
43
+ // See openspec/changes/archive/2026-04-24-fix-windows-entry-script-url.
44
+ const wrapEntry = shouldUrlWrapEntry(params.loader);
38
45
  const spawnArgs: string[] = [];
39
46
  if (params.loader) {
40
- spawnArgs.push("--import", params.loader);
47
+ spawnArgs.push("--import", toFileUrl(params.loader));
41
48
  }
42
- spawnArgs.push(params.cliPath, "start", ...params.extraArgs);
49
+ spawnArgs.push(
50
+ wrapEntry ? toFileUrl(params.cliPath) : params.cliPath,
51
+ "start",
52
+ ...params.extraArgs,
53
+ );
43
54
 
44
55
  // The script runs in a fresh Node process. Keep it self-contained and use
45
56
  // only built-ins (net, http, fs, child_process). JSON.stringify is used to
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "publishConfig": {