@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,69 @@
1
+ /**
2
+ * Round-trip test for state-replay (per change: fix-per-message-fork):
3
+ * for every persisted entry, the reducer-equivalent message_start /
4
+ * message_end carries entryId === entry.id. Replay does NOT need
5
+ * entry_persisted back-fill because it reads from the persisted JSONL.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+ import { replayEntriesAsEvents } from "../state-replay.js";
9
+
10
+ describe("replayEntriesAsEvents — entryId fidelity", () => {
11
+ it("stamps entryId on user message_start matching the source entry id", () => {
12
+ const sessionId = "sess-1";
13
+ const entries = [
14
+ {
15
+ type: "message",
16
+ id: "u1",
17
+ parentId: "root",
18
+ timestamp: "2026-04-27T07:26:25.000Z",
19
+ message: { role: "user", content: [{ type: "text", text: "Hello" }] },
20
+ },
21
+ ];
22
+
23
+ const events = replayEntriesAsEvents(sessionId, entries);
24
+ const start = events.find((e) => e.event.eventType === "message_start");
25
+ expect(start).toBeDefined();
26
+ expect((start!.event.data as any).entryId).toBe("u1");
27
+ });
28
+
29
+ it("stamps entryId on assistant message_end matching the source entry id", () => {
30
+ const sessionId = "sess-1";
31
+ const entries = [
32
+ {
33
+ type: "message",
34
+ id: "a1",
35
+ parentId: "u1",
36
+ timestamp: "2026-04-27T07:26:30.000Z",
37
+ message: { role: "assistant", content: [{ type: "text", text: "Hi!" }] },
38
+ },
39
+ ];
40
+
41
+ const events = replayEntriesAsEvents(sessionId, entries);
42
+ const end = events.find((e) => e.event.eventType === "message_end");
43
+ expect(end).toBeDefined();
44
+ expect((end!.event.data as any).entryId).toBe("a1");
45
+ });
46
+
47
+ it("emits no entry_persisted events during replay", () => {
48
+ const sessionId = "sess-1";
49
+ const entries = [
50
+ {
51
+ type: "message",
52
+ id: "u1",
53
+ timestamp: "2026-04-27T07:26:25.000Z",
54
+ message: { role: "user", content: [{ type: "text", text: "Hi" }] },
55
+ },
56
+ {
57
+ type: "message",
58
+ id: "a1",
59
+ parentId: "u1",
60
+ timestamp: "2026-04-27T07:26:30.000Z",
61
+ message: { role: "assistant", content: [{ type: "text", text: "Hello!" }] },
62
+ },
63
+ ];
64
+
65
+ const events = replayEntriesAsEvents(sessionId, entries);
66
+ const persisted = events.filter((e) => e.event.eventType === "entry_persisted");
67
+ expect(persisted).toHaveLength(0);
68
+ });
69
+ });
@@ -9,6 +9,7 @@ export * from "./detached-spawn.js";
9
9
  export * from "./spawn-mechanism.js";
10
10
  export * from "./process-identify.js";
11
11
  export * from "./subprocess-adapter.js";
12
+ export * from "./node-spawn.js";
12
13
  export * as git from "./git.js";
13
14
  export * as openspec from "./openspec.js";
14
15
  export * as npm from "./npm.js";
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Canonical helper for spawning `node --import <loader> <entry>` argv.
3
+ *
4
+ * Node ≥ 20's ESM loader parses BOTH the `--import` loader position AND
5
+ * the entry-script position as URLs. Raw Windows paths like
6
+ * `B:\Dev\foo.ts` URL-parse to scheme `b:`, which is not in the ESM
7
+ * loader's allowlist (file, data, node) → the process crashes with
8
+ * `ERR_UNSUPPORTED_ESM_URL_SCHEME` before any filesystem access.
9
+ *
10
+ * Node's internal drive-letter heuristic catches the common cases
11
+ * (`C:\`, `D:\`) but has known gaps for `A:`, `B:`, and other letters.
12
+ * Rather than relying on the heuristic, we wrap the loader position
13
+ * with `file://` unconditionally.
14
+ *
15
+ * The entry-script position needs a more nuanced rule. Node's default
16
+ * resolver AND jiti's ESM hook both accept `file://` URL entries. But
17
+ * **tsx's ESM hook rejects `file://` URLs as entries** — tsx's resolver
18
+ * treats the entry as a user-typed specifier and attempts bare-import
19
+ * / relative-path resolution, producing `<cwd>/file:/...` errors.
20
+ * Since tsx is used as the jiti fallback on dev machines without pi
21
+ * installed (the most common Linux dev path), we must NOT URL-wrap
22
+ * the entry when the loader is tsx. Detection: the loader path
23
+ * contains `/tsx/` (every tsx install ships its hook under a `tsx/`
24
+ * directory; jiti's hook is under `jiti/`).
25
+ *
26
+ * This module is the canonical chokepoint. The repo-level lint test
27
+ * `no-raw-node-import.test.ts` refuses any other call site that
28
+ * passes a raw path to `--import` / `--loader`.
29
+ *
30
+ * See change: fix-windows-entry-script-url.
31
+ */
32
+ import path from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+ import type { SpawnOptions, ChildProcess } from "node:child_process"; // ban:child_process-ok — types only
35
+ import { spawn as execSpawn } from "./exec.js";
36
+
37
+ export interface SpawnNodeScriptOptions {
38
+ /** Path to node.exe / node (raw OS path — binary, not ESM-loaded). */
39
+ nodeBin?: string;
40
+
41
+ /** Path to the script Node will run. Raw path OR file:// URL. */
42
+ entry: string;
43
+
44
+ /** Optional ESM loader for --import. Raw path OR file:// URL. */
45
+ loader?: string;
46
+
47
+ /** Arguments passed to the script (after entry). */
48
+ args?: string[];
49
+
50
+ /** Standard spawn options (cwd, env, stdio, detached, etc.). */
51
+ spawnOptions?: SpawnOptions;
52
+ }
53
+
54
+ /**
55
+ * Detect whether a loader (file:// URL or raw path) is tsx.
56
+ *
57
+ * tsx's ESM hook rejects `file://` URLs at the entry-script position,
58
+ * so the caller must pass a raw OS path for the entry when this
59
+ * returns true. jiti and Node's default resolver both accept URL
60
+ * entries.
61
+ *
62
+ * Heuristic: every tsx install places its hook under a `tsx/` package
63
+ * directory (e.g. `.../node_modules/tsx/dist/esm/index.mjs`). The
64
+ * check is tolerant of `file://` URLs, raw POSIX paths, and raw
65
+ * Windows paths with either slash direction.
66
+ */
67
+ export function isTsxLoader(loader: string | null | undefined): boolean {
68
+ if (!loader) return false;
69
+ // Normalize backslashes so the `/tsx/` probe works on Windows paths.
70
+ const normalized = loader.replace(/\\/g, "/");
71
+ return /\/tsx\//i.test(normalized);
72
+ }
73
+
74
+ /**
75
+ * Convert a path-or-url string to a file:// URL.
76
+ *
77
+ * Pure and idempotent. Safe to call on strings that are already
78
+ * file:// URLs — returns them unchanged.
79
+ *
80
+ * Handles Windows-style input (drive letter + backslash) regardless of
81
+ * host OS, so unit tests on Linux/macOS can exercise the Windows path
82
+ * contract. Mirrors the pattern in
83
+ * `packages/shared/src/resolve-jiti.ts::buildJitiRegisterUrl`.
84
+ */
85
+ export function toFileUrl(pathOrUrl: string): string {
86
+ if (pathOrUrl.startsWith("file:")) return pathOrUrl;
87
+
88
+ const isWindowsStyle = /^[A-Za-z]:[\\/]/.test(pathOrUrl);
89
+ if (isWindowsStyle) {
90
+ // pathToFileURL on POSIX hosts URL-encodes backslashes rather than
91
+ // treating them as separators. Build the URL manually so tests on
92
+ // Linux produce the same result a Windows host would.
93
+ return `file:///${pathOrUrl.replace(/\\/g, "/")}`;
94
+ }
95
+
96
+ // Use path.resolve to ensure absolute path on the host OS, then
97
+ // let Node's pathToFileURL handle any host-specific quirks.
98
+ const absolute = path.isAbsolute(pathOrUrl) ? pathOrUrl : path.resolve(pathOrUrl);
99
+ return pathToFileURL(absolute).href;
100
+ }
101
+
102
+ /**
103
+ * Decide whether the entry-script position needs `file://` URL wrapping.
104
+ *
105
+ * Rule:
106
+ * - tsx loader: always raw path (tsx rejects file:// entries on every OS)
107
+ * - non-tsx (jiti / Node default) on POSIX: raw path
108
+ * (POSIX has no drive-letter / URL-scheme collision; jiti's resolver
109
+ * actively MISBEHAVES when handed `file://` URL entries — it
110
+ * normalises away the triple-slash and then treats `file:/...` as
111
+ * a relative specifier, producing `<cwd>/file:/...` ENOENT errors.)
112
+ * - non-tsx on Windows: file:// URL
113
+ * (Node parses drive letters like `B:` / `A:` as URL schemes in argv
114
+ * before loaders run, throwing ERR_UNSUPPORTED_ESM_URL_SCHEME.
115
+ * Wrapping with `file://` sidesteps the parse.)
116
+ *
117
+ * Keeps a `platform` parameter for testability so unit tests on a POSIX
118
+ * host can exercise the Windows branch without mutating `process.platform`.
119
+ */
120
+ export function shouldUrlWrapEntry(
121
+ loader: string | null | undefined,
122
+ platform: NodeJS.Platform = process.platform,
123
+ ): boolean {
124
+ if (isTsxLoader(loader)) return false;
125
+ return platform === "win32";
126
+ }
127
+
128
+ /**
129
+ * Spawn `node` with an optional `--import` loader and a script entry.
130
+ *
131
+ * The loader position is always URL-wrapped (Node's ESM loader
132
+ * requires `file://` on Windows drive letters outside the heuristic).
133
+ *
134
+ * The entry position follows `shouldUrlWrapEntry(loader, platform)` —
135
+ * URL on Windows + non-tsx, raw everywhere else.
136
+ *
137
+ * Delegates actual spawning to `platform/exec.ts::spawn` so the
138
+ * `windowsHide: true` default and other safe-spawn invariants are
139
+ * preserved. Does not import `node:child_process` directly (the type
140
+ * imports above are annotated with the opt-out marker).
141
+ */
142
+ export function spawnNodeScript(opts: SpawnNodeScriptOptions): ChildProcess {
143
+ const nodeBin = opts.nodeBin ?? process.execPath;
144
+ const wrapEntry = shouldUrlWrapEntry(opts.loader);
145
+
146
+ const argv: string[] = [];
147
+ if (opts.loader) {
148
+ argv.push("--import", toFileUrl(opts.loader));
149
+ }
150
+ argv.push(wrapEntry ? toFileUrl(opts.entry) : opts.entry);
151
+ if (opts.args) argv.push(...opts.args);
152
+
153
+ return execSpawn(nodeBin, argv, opts.spawnOptions ?? {});
154
+ }
@@ -57,6 +57,29 @@ export interface EventForwardMessage {
57
57
  event: DashboardEvent;
58
58
  }
59
59
 
60
+ /**
61
+ * Conventions on `event_forward` payloads relevant to per-message fork:
62
+ *
63
+ * - `message_start` and `message_end` events MAY carry an optional
64
+ * `data.nonce: string` stamped by the bridge. The reducer carries it
65
+ * onto the resulting ChatMessage so a later `entry_persisted` event
66
+ * can back-fill the entry id.
67
+ * - `entry_persisted` events have shape:
68
+ * {
69
+ * eventType: "entry_persisted",
70
+ * timestamp,
71
+ * data: { type: "entry_persisted", entryId: string, nonce: string }
72
+ * }
73
+ * They are emitted by the bridge after pi calls
74
+ * `sessionManager.appendMessage` and the entry id has been generated.
75
+ * See change: fix-per-message-fork.
76
+ */
77
+ export interface EntryPersistedEventData {
78
+ type: "entry_persisted";
79
+ entryId: string;
80
+ nonce: string;
81
+ }
82
+
60
83
  export interface CommandsListMessage {
61
84
  type: "commands_list";
62
85
  sessionId: string;
@@ -13,6 +13,15 @@ import type { EventForwardMessage } from "./protocol.js";
13
13
  * - message_update + message_end for assistant messages
14
14
  * - tool_execution_start / tool_execution_end for tool calls
15
15
  * - model_select for model changes
16
+ *
17
+ * NOTE on entryId (per change: fix-per-message-fork):
18
+ * Replay reads from the persisted JSONL, so each entry already has a
19
+ * stable `id`. We attach it directly as `entryId` on both `message_start`
20
+ * (user) and `message_end` (assistant) events. Replay therefore does NOT
21
+ * need to emit an `entry_persisted` follow-up — the back-fill protocol
22
+ * exists to bridge a timing gap that only happens for LIVE pi events on
23
+ * pi 0.69+, where the bridge sees `message_start` before pi has assigned
24
+ * the entry id. Replay has no such gap.
16
25
  */
17
26
  export function replayEntriesAsEvents(
18
27
  sessionId: string,
@@ -22,6 +22,7 @@ import {
22
22
  overrideStrategy,
23
23
  whereStrategy,
24
24
  } from "./strategies.js";
25
+ import type { Strategy } from "./types.js";
25
26
 
26
27
  // ── Classifier ──────────────────────────────────────────────────────────────
27
28
 
@@ -66,6 +67,68 @@ function moduleDefWithAliases(
66
67
  return { name: canonicalName, kind: "module", strategies, classify };
67
68
  }
68
69
 
70
+ // ── Build-time module definitions (electron, node-pty) ────────────────────
71
+
72
+ /**
73
+ * Bare-import strategy that resolves `<pkg>/package.json` and returns the
74
+ * containing directory. Used for build-time tools whose useful artifact is
75
+ * a sibling file of `package.json` (e.g. `electron/install.js`,
76
+ * `node-pty/prebuilds/`). Mirrors the semantics that build-time consumers
77
+ * (`publish.yml`, `Dockerfile.build`, `scripts/fix-pty-permissions.cjs`)
78
+ * need — see change: register-build-time-tools.
79
+ *
80
+ * `searchPaths` are passed to Node's resolver as the `paths` option,
81
+ * making the lookup work whether the package is hoisted to the repo root
82
+ * or nested under a workspace.
83
+ */
84
+ function bareImportPackageDirStrategy(
85
+ pkgName: string,
86
+ searchPaths?: readonly string[],
87
+ deps?: StrategyDeps,
88
+ ): Strategy {
89
+ const fallbackResolve = (id: string, from: string): string | null => {
90
+ try {
91
+ if (searchPaths && searchPaths.length > 0) {
92
+ const req = createRequire(from) as unknown as {
93
+ resolve(id: string, opts?: { paths?: readonly string[] }): string;
94
+ };
95
+ return req.resolve(id, { paths: searchPaths });
96
+ }
97
+ return createRequire(from).resolve(id);
98
+ } catch {
99
+ return null;
100
+ }
101
+ };
102
+ const resolveModule = deps?.resolveModule ?? fallbackResolve;
103
+ return {
104
+ name: "bare-import",
105
+ run() {
106
+ const pkgJson = resolveModule(`${pkgName}/package.json`, import.meta.url);
107
+ if (!pkgJson) {
108
+ return { ok: false, reason: `cannot resolve ${pkgName}/package.json` };
109
+ }
110
+ return { ok: true, path: path.dirname(pkgJson) };
111
+ },
112
+ };
113
+ }
114
+
115
+ /** Module def that returns the package directory (containing package.json). */
116
+ function packageDirModuleDef(
117
+ toolName: string,
118
+ pkgName: string,
119
+ options: { searchPaths?: readonly string[]; includeManaged?: boolean },
120
+ deps?: StrategyDeps,
121
+ ): ToolDefinition {
122
+ const strategies: Strategy[] = [
123
+ overrideStrategy(toolName, deps),
124
+ bareImportPackageDirStrategy(pkgName, options.searchPaths, deps),
125
+ ];
126
+ if (options.includeManaged) {
127
+ strategies.push(managedModuleStrategy(pkgName, "package.json", deps));
128
+ }
129
+ return { name: toolName, kind: "module", strategies, classify };
130
+ }
131
+
69
132
  // ── Registration ─────────────────────────────────────────────────
70
133
 
71
134
  // Tools intentionally NOT registered:
@@ -76,6 +139,14 @@ function moduleDefWithAliases(
76
139
  // - `pi-dashboard` — that's the package this code is part of.
77
140
  // "Is it installed" is a bootstrap concern handled directly in
78
141
  // `packages/electron/src/lib/dependency-detector.ts`.
142
+ //
143
+ // Build-time tools (see change: register-build-time-tools):
144
+ // - `electron` — module, returns the package directory containing
145
+ // `install.js`. Resolved with paths anchored at
146
+ // `packages/electron` to handle hoisted vs. nested
147
+ // layouts uniformly.
148
+ // - `node-pty` — module, returns the package directory containing
149
+ // `prebuilds/`. Standard module resolution suffices.
79
150
  // See change: consolidate-tool-resolution (follow-up).
80
151
 
81
152
  /**
@@ -333,6 +404,27 @@ export function registerDefaultTools(registry: ToolRegistry, deps?: StrategyDeps
333
404
  deps,
334
405
  ),
335
406
  );
407
+
408
+ // Build-time tools (see change: register-build-time-tools).
409
+ registry.register(
410
+ packageDirModuleDef(
411
+ "electron",
412
+ "electron",
413
+ {
414
+ searchPaths: [path.resolve("packages/electron")],
415
+ includeManaged: true,
416
+ },
417
+ deps,
418
+ ),
419
+ );
420
+ registry.register(
421
+ packageDirModuleDef(
422
+ "node-pty",
423
+ "node-pty",
424
+ { includeManaged: false },
425
+ deps,
426
+ ),
427
+ );
336
428
  }
337
429
 
338
430
  /** Handy re-exports for callers that want raw definitions for testing. */