@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.
- package/AGENTS.md +30 -8
- package/README.md +386 -494
- package/docs/architecture.md +63 -9
- package/package.json +8 -5
- package/packages/extension/package.json +6 -4
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
- package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
- package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
- package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
- package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
- package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
- package/packages/extension/src/ask-user-tool.ts +5 -4
- package/packages/extension/src/bridge.ts +102 -15
- package/packages/extension/src/multiselect-list.ts +146 -0
- package/packages/extension/src/multiselect-polyfill.ts +43 -0
- package/packages/extension/src/server-launcher.ts +15 -3
- package/packages/server/package.json +5 -5
- package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
- package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
- package/packages/server/src/cli.ts +56 -9
- package/packages/server/src/pi-version-skew.ts +12 -1
- package/packages/server/src/restart-helper.ts +13 -2
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
- package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
- package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
- package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
- package/packages/shared/src/platform/index.ts +1 -0
- package/packages/shared/src/platform/node-spawn.ts +154 -0
- package/packages/shared/src/protocol.ts +23 -0
- package/packages/shared/src/state-replay.ts +9 -0
- 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.
|
|
81
|
-
//
|
|
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",
|
|
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.
|
|
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.
|
|
14
|
-
"recommended": "0.
|
|
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.
|
|
30
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.4.
|
|
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("
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
343
|
-
//
|
|
344
|
-
const child =
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|