@blackbelt-technology/pi-agent-dashboard 0.3.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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -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__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Session-spawn mechanism selection.
3
+ *
4
+ * The user expresses preference via a two-valued config type
5
+ * (`SpawnStrategy` = "tmux" | "headless"). The dashboard internally
6
+ * decides WHICH actual mechanism to use given the OS and what's
7
+ * available on this host. This module is the single source of truth
8
+ * for that decision.
9
+ *
10
+ * Mechanisms:
11
+ * • "tmux" — Unix terminal multiplexer (Linux, macOS)
12
+ * • "wt" — Windows Terminal new-tab (Win10/11)
13
+ * • "wsl-tmux" — WSL-hosted tmux (Windows, niche)
14
+ * • "headless" — RPC-mode pi, no TTY, bridge over WebSocket
15
+ *
16
+ * `selectMechanism` is pure: no I/O, no subprocess calls. Availability
17
+ * is determined by the caller (typically via `ToolRegistry.resolve`)
18
+ * and passed in. This keeps the decision trivially testable.
19
+ *
20
+ * See change: consolidate-windows-spawn-and-platform-handlers.
21
+ */
22
+
23
+ export type SpawnMechanism = "tmux" | "wt" | "wsl-tmux" | "headless";
24
+
25
+ /** User-visible config value (from `SpawnStrategy` in shared/config.ts). */
26
+ export type UserSpawnStrategy = "tmux" | "headless";
27
+
28
+ export interface SpawnMechanismContext {
29
+ platform: NodeJS.Platform;
30
+ userStrategy: UserSpawnStrategy;
31
+ electronMode: boolean;
32
+ available: {
33
+ tmux: boolean;
34
+ wt: boolean;
35
+ wslTmux: boolean;
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Select one spawn mechanism for this platform given the user's
41
+ * preference, the electron-mode flag, and tool availability.
42
+ *
43
+ * Rules (in order):
44
+ * 1. electronMode forces "headless".
45
+ * 2. userStrategy "headless" forces "headless".
46
+ * 3. Unix (linux/darwin): tmux if available, else headless.
47
+ * 4. Windows: wt > wsl-tmux > headless.
48
+ * 5. Any other platform falls back to headless.
49
+ */
50
+ export function selectMechanism(ctx: SpawnMechanismContext): SpawnMechanism {
51
+ if (ctx.electronMode) return "headless";
52
+ if (ctx.userStrategy === "headless") return "headless";
53
+
54
+ if (ctx.platform === "linux" || ctx.platform === "darwin") {
55
+ return ctx.available.tmux ? "tmux" : "headless";
56
+ }
57
+ if (ctx.platform === "win32") {
58
+ if (ctx.available.wt) return "wt";
59
+ if (ctx.available.wslTmux) return "wsl-tmux";
60
+ return "headless";
61
+ }
62
+ return "headless";
63
+ }
64
+
65
+ // ── Windows Terminal argv builder ───────────────────────────────────────────
66
+
67
+ export interface WtArgsOptions {
68
+ /** Absolute cwd for the new tab. Spaces / parens / quotes are safe in argv form. */
69
+ cwd: string;
70
+ /** Tab title, typically the basename of cwd. */
71
+ title: string;
72
+ /**
73
+ * Pre-resolved pi argv: typically [node.exe, cli.js, --mode?, rpc?, --fork?, file?].
74
+ * Interactive wt sessions OMIT --mode rpc so pi runs its TUI.
75
+ */
76
+ piArgv: string[];
77
+ }
78
+
79
+ /**
80
+ * Build argv (NOT a shell string) to invoke Windows Terminal so it opens
81
+ * a new tab in the existing WT window and runs `piArgv` there.
82
+ *
83
+ * Design notes:
84
+ * • argv form — passed to spawn with shell:false, so wt re-parses it
85
+ * internally. No need to escape spaces, semicolons, or quotes in cwd.
86
+ * • `-w 0` reuses the most-recently-used WT window; new tab, not new
87
+ * window. Matches tmux `new-window` semantics.
88
+ * • No `-p <profile>` — respect the user's default WT profile
89
+ * (cmd / pwsh / WSL).
90
+ * • `--` sentinel before piArgv so any `-` or `/` prefix in piArgv
91
+ * can't be misparsed as a wt option.
92
+ */
93
+ export function buildWtArgs(opts: WtArgsOptions): string[] {
94
+ return [
95
+ "-w", "0",
96
+ "new-tab",
97
+ "-d", opts.cwd,
98
+ "--title", opts.title,
99
+ "--",
100
+ ...opts.piArgv,
101
+ ];
102
+ }
103
+
104
+ // ── Shared helper: append session/fork flags uniformly ─────────────────────
105
+
106
+ export interface SessionFlags {
107
+ sessionFile?: string;
108
+ mode?: "continue" | "fork";
109
+ }
110
+
111
+ /**
112
+ * Return `["--session", file]` or `["--fork", file]` or `[]`.
113
+ * Every mechanism MUST use this to append flags; dropping them silently
114
+ * is the exact bug that motivated this change (B1, B2).
115
+ */
116
+ export function sessionFlagsToArgv(flags: SessionFlags): string[] {
117
+ if (flags.sessionFile && flags.mode === "continue") {
118
+ return ["--session", flags.sessionFile];
119
+ }
120
+ if (flags.sessionFile && flags.mode === "fork") {
121
+ return ["--fork", flags.sessionFile];
122
+ }
123
+ return [];
124
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Subprocess adapter — strategy pattern for OS-aware subprocess invocation.
3
+ *
4
+ * The adapter is the single point of entry for spawning any subprocess
5
+ * from dashboard code or from libraries we wrap. It dispatches to a
6
+ * platform-specific implementation:
7
+ *
8
+ * - Windows: `.cmd`/`.bat` shims go through explicit `cmd.exe /d /s /c`
9
+ * invocation with `windowsHide: true` and `shell: false` (the only
10
+ * reliable way to avoid Node issue #21825's flashing console).
11
+ * Native `.exe`s spawn directly.
12
+ * - Unix: direct spawn, no shell, no special cases.
13
+ *
14
+ * Why an adapter instead of a global monkey-patch?
15
+ *
16
+ * - Explicit dependency injection. Callers (and tests) know exactly
17
+ * which spawn implementation they get.
18
+ * - Isolated — third-party code that needs this behaviour gets it via
19
+ * a thin subclass that consumes the adapter (see
20
+ * `createSafePackageManagerClass` in
21
+ * `packages/server/src/package-manager-wrapper.ts`). No cross-
22
+ * cutting global state.
23
+ * - Testable: fake adapter => assert argv without spawning real
24
+ * subprocesses.
25
+ *
26
+ * See change: consolidate-windows-spawn-and-platform-handlers.
27
+ */
28
+ import type {
29
+ ChildProcess,
30
+ SpawnOptions,
31
+ SpawnSyncOptions,
32
+ SpawnSyncReturns,
33
+ } from "node:child_process";
34
+ import {
35
+ spawn as safeSpawn,
36
+ spawnSync as safeSpawnSync,
37
+ buildSafeArgv,
38
+ } from "./exec.js";
39
+
40
+ // ── Interface ──────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Cross-platform subprocess adapter. Implementations guarantee:
44
+ * - `windowsHide: true` on Windows, always.
45
+ * - No `shell: true` ever — `.cmd` shims are invoked via explicit
46
+ * `cmd.exe /d /s /c` argv.
47
+ * - Arg arrays are passed verbatim, no shell-escaping surprises.
48
+ */
49
+ export interface SubprocessAdapter {
50
+ /** Async spawn. Returns the live ChildProcess. */
51
+ spawn(command: string, args?: readonly string[], options?: SpawnOptions): ChildProcess;
52
+
53
+ /** Synchronous spawn. Blocks until completion. */
54
+ spawnSync<T extends string | Buffer = Buffer>(
55
+ command: string,
56
+ args?: readonly string[],
57
+ options?: SpawnSyncOptions,
58
+ ): SpawnSyncReturns<T>;
59
+ }
60
+
61
+ // ── Windows implementation ─────────────────────────────────────────────────
62
+
63
+ class WindowsSubprocessAdapter implements SubprocessAdapter {
64
+ spawn(command: string, args: readonly string[] = [], options?: SpawnOptions): ChildProcess {
65
+ const { argv, spawnOptions } = buildSafeArgv(command, args, "win32");
66
+ return safeSpawn(argv[0], argv.slice(1), { ...(options ?? {}), ...spawnOptions });
67
+ }
68
+
69
+ spawnSync<T extends string | Buffer = Buffer>(
70
+ command: string,
71
+ args: readonly string[] = [],
72
+ options?: SpawnSyncOptions,
73
+ ): SpawnSyncReturns<T> {
74
+ const { argv, spawnOptions } = buildSafeArgv(command, args, "win32");
75
+ return safeSpawnSync<T>(argv[0], argv.slice(1), { ...(options ?? {}), ...spawnOptions });
76
+ }
77
+ }
78
+
79
+ // ── Unix implementation ────────────────────────────────────────────────────
80
+
81
+ class UnixSubprocessAdapter implements SubprocessAdapter {
82
+ spawn(command: string, args: readonly string[] = [], options?: SpawnOptions): ChildProcess {
83
+ return safeSpawn(command, args, { ...(options ?? {}), shell: false });
84
+ }
85
+
86
+ spawnSync<T extends string | Buffer = Buffer>(
87
+ command: string,
88
+ args: readonly string[] = [],
89
+ options?: SpawnSyncOptions,
90
+ ): SpawnSyncReturns<T> {
91
+ return safeSpawnSync<T>(command, args, { ...(options ?? {}), shell: false });
92
+ }
93
+ }
94
+
95
+ // ── Factory ────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Return the appropriate adapter for the given platform. Default:
99
+ * `process.platform`. Tests pass explicit values without mutating the
100
+ * global.
101
+ */
102
+ export function createSubprocessAdapter(
103
+ platform: NodeJS.Platform = process.platform,
104
+ ): SubprocessAdapter {
105
+ if (platform === "win32") return new WindowsSubprocessAdapter();
106
+ return new UnixSubprocessAdapter();
107
+ }
108
+
109
+ /**
110
+ * Process-wide default adapter. Constructed lazily on first access.
111
+ * Callers that want a different strategy (e.g. tests injecting a fake)
112
+ * pass the adapter explicitly to their constructor instead of using
113
+ * this singleton.
114
+ */
115
+ let defaultAdapter: SubprocessAdapter | null = null;
116
+ export function getDefaultSubprocessAdapter(): SubprocessAdapter {
117
+ if (!defaultAdapter) defaultAdapter = createSubprocessAdapter();
118
+ return defaultAdapter;
119
+ }
120
+
121
+ /** Test-only: drop the cached default adapter. */
122
+ export function _resetDefaultSubprocessAdapter(): void {
123
+ defaultAdapter = null;
124
+ }
@@ -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;
@@ -82,7 +82,7 @@ export interface EnrichedRecommendedExtension extends RecommendedExtension {
82
82
  export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
83
83
  {
84
84
  id: "pi-anthropic-messages",
85
- source: "git@github.com:BlackBeltTechnology/pi-anthropic-messages.git",
85
+ source: "https://github.com/BlackBeltTechnology/pi-anthropic-messages.git",
86
86
  displayName: "pi-anthropic-messages",
87
87
  fallbackDescription:
88
88
  "Protocol bridge that makes pi's custom tools work with any " +
@@ -114,7 +114,7 @@ export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
114
114
  },
115
115
  {
116
116
  id: "pi-flows",
117
- source: "git@github.com:BlackBeltTechnology/pi-flows.git",
117
+ source: "https://github.com/BlackBeltTechnology/pi-flows.git",
118
118
  displayName: "pi-flows",
119
119
  fallbackDescription:
120
120
  "Flow engine, dashboard, and orchestration extensions for pi. " +
@@ -167,6 +167,22 @@ export const RECOMMENDED_EXTENSIONS: readonly RecommendedExtension[] = [
167
167
  },
168
168
  ];
169
169
 
170
+ /**
171
+ * Ids of recommended extensions that ship inside the Electron installer
172
+ * as a pre-bundled source tree. See
173
+ * `packages/electron/scripts/bundle-recommended-extensions.sh` and
174
+ * `installBundledExtensions()` in `dependency-installer.ts`. Every id
175
+ * MUST also appear in `RECOMMENDED_EXTENSIONS` and MUST have a git-based
176
+ * `source` (enforced by a test).
177
+ *
178
+ * Kept deliberately short — only first-party, source-only, native-dep-free
179
+ * extensions belong here.
180
+ */
181
+ export const BUNDLED_EXTENSION_IDS: readonly string[] = [
182
+ "pi-anthropic-messages",
183
+ "pi-flows",
184
+ ];
185
+
170
186
  /** Retrieve a recommended entry by id, or `undefined`. */
171
187
  export function getRecommendedExtension(id: string): RecommendedExtension | undefined {
172
188
  return RECOMMENDED_EXTENSIONS.find((e) => e.id === id);
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import { createRequire } from "node:module";
11
- import { realpathSync } from "node:fs";
11
+ import { existsSync, realpathSync } from "node:fs";
12
12
  import path from "node:path";
13
+ import { pathToFileURL } from "node:url";
13
14
 
14
15
  const JITI_PACKAGES = [
15
16
  "@mariozechner/jiti",
@@ -17,8 +18,38 @@ const JITI_PACKAGES = [
17
18
  ];
18
19
 
19
20
  /**
20
- * Returns the absolute path to jiti's register hook (lib/jiti-register.mjs).
21
+ * Pure helper: given a jiti package.json path, return the file:// URL of
22
+ * its register hook. Exported for testing — no I/O.
23
+ *
24
+ * Returns a file:// URL (not a raw path) because Node >= 20 on Windows
25
+ * rejects raw absolute paths with a drive letter for --import (parses
26
+ * "C:" / "B:" as a URL scheme → ERR_UNSUPPORTED_ESM_URL_SCHEME). file://
27
+ * URLs are accepted on every OS.
28
+ * See change: fix-windows-server-parity.
29
+ */
30
+ export function buildJitiRegisterUrl(pkgJsonPath: string): string {
31
+ // Detect Windows-style input (drive letter + backslash) regardless of
32
+ // host OS, so unit tests can exercise the Windows path contract on macOS/Linux.
33
+ // Production behaviour is unchanged because the host-OS `path`/`pathToFileURL`
34
+ // match the input style automatically.
35
+ const isWindowsStyle = /^[A-Za-z]:[\\/]/.test(pkgJsonPath);
36
+ if (isWindowsStyle) {
37
+ // Manually build file:///C:/path/lib/jiti-register.mjs — pathToFileURL on
38
+ // POSIX hosts URL-encodes backslashes rather than treating them as
39
+ // separators. Do the join with path.win32 and format the URL ourselves.
40
+ const registerPath = path.win32.join(path.win32.dirname(pkgJsonPath), "lib", "jiti-register.mjs");
41
+ return `file:///${registerPath.replace(/\\/g, "/")}`;
42
+ }
43
+ const registerPath = path.join(path.dirname(pkgJsonPath), "lib", "jiti-register.mjs");
44
+ return pathToFileURL(registerPath).href;
45
+ }
46
+
47
+ /**
48
+ * Returns jiti's register hook as a file:// URL suitable for `node --import`.
21
49
  * Uses process.argv[1] (pi's entry point) to anchor module resolution.
50
+ *
51
+ * The return value is ALWAYS a file:// URL (never a raw path). See
52
+ * buildJitiRegisterUrl for the URL contract rationale.
22
53
  */
23
54
  export function resolveJitiImport(): string {
24
55
  const anchor = process.argv[1];
@@ -30,7 +61,7 @@ export function resolveJitiImport(): string {
30
61
  for (const jiti of JITI_PACKAGES) {
31
62
  try {
32
63
  const pkgJson = req.resolve(`${jiti}/package.json`);
33
- return path.join(path.dirname(pkgJson), "lib", "jiti-register.mjs");
64
+ return buildJitiRegisterUrl(pkgJson);
34
65
  } catch { /* next */ }
35
66
  }
36
67
  } catch { /* fall through */ }
@@ -41,3 +72,31 @@ export function resolveJitiImport(): string {
41
72
  "Is @mariozechner/pi-coding-agent or @oh-my-pi/pi-coding-agent installed?"
42
73
  );
43
74
  }
75
+
76
+ /**
77
+ * Resolve jiti's register hook from an arbitrary anchor path (e.g. a
78
+ * pi-coding-agent package.json in a managed install, or a pi binary on
79
+ * the system PATH). Returns a file:// URL or null if jiti cannot be
80
+ * resolved from the anchor.
81
+ *
82
+ * This is the Electron/managed-install variant of `resolveJitiImport`
83
+ * — the difference is the caller supplies the anchor explicitly
84
+ * instead of using `process.argv[1]`. Consolidates what used to be a
85
+ * duplicate `resolveJitiFromAnchor` in
86
+ * `packages/electron/src/lib/server-lifecycle.ts`.
87
+ * See change: consolidate-platform-handlers.
88
+ */
89
+ export function resolveJitiFromAnchor(anchorPath: string): string | null {
90
+ if (!existsSync(anchorPath)) return null;
91
+ try {
92
+ const req = createRequire(anchorPath);
93
+ for (const jiti of JITI_PACKAGES) {
94
+ try {
95
+ const pkgJson = req.resolve(`${jiti}/package.json`);
96
+ const registerPath = path.join(path.dirname(pkgJson), "lib", "jiti-register.mjs");
97
+ if (existsSync(registerPath)) return pathToFileURL(registerPath).href;
98
+ } catch { /* next */ }
99
+ }
100
+ } catch { /* ignore */ }
101
+ return null;
102
+ }
@@ -80,6 +80,16 @@ export interface BrowseResult {
80
80
  entries: BrowseEntry[];
81
81
  parent: string | null;
82
82
  current: string;
83
+ /**
84
+ * The server's `process.platform` — lets the client use OS-correct path
85
+ * handling (separator, case-sensitivity, drive-letter rules) without
86
+ * having to sniff `navigator.userAgent`. Optional for backward
87
+ * compatibility; consumers fall back to inferring from the `current`
88
+ * path shape when absent.
89
+ *
90
+ * See change: platform-path-normalization.
91
+ */
92
+ platform?: NodeJS.Platform;
83
93
  }
84
94
 
85
95
  export type BrowseResponse = ApiResponse<BrowseResult>;
@@ -352,3 +362,19 @@ export interface NetworkInterface {
352
362
  export type ListRecommendedExtensionsResponse = ApiResponse<{
353
363
  recommended: EnrichedRecommendedExtension[];
354
364
  }>;
365
+
366
+ // ── Tool registry ────────────────────
367
+
368
+ import type { Resolution } from "./tool-registry/types.js";
369
+ export type { Resolution, Source, TriedEntry } from "./tool-registry/types.js";
370
+
371
+ export type ListToolsResponse = ApiResponse<{ tools: Resolution[] }>;
372
+ export type GetToolResponse = ApiResponse<Resolution>;
373
+
374
+ export interface RescanToolsRequest {
375
+ name?: string;
376
+ }
377
+
378
+ export interface SetToolOverrideRequest {
379
+ path: string;
380
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Tiny FIFO semaphore for throttling concurrent async operations.
3
+ *
4
+ * Used by the server's openspec polling scheduler to cap how many
5
+ * `openspec` CLI spawns may be running at once. Rolled in-repo instead
6
+ * of pulling `p-limit` because we need `setMax()` for live reconfig
7
+ * (when the user edits `openspec.maxConcurrentSpawns` in settings).
8
+ *
9
+ * Contract:
10
+ * - `run(fn)` runs `fn` through the gate. At most `max` tasks are
11
+ * in-flight; excess tasks queue FIFO.
12
+ * - `setMax(n)` resizes. Growing drains the queue up to the new cap
13
+ * on the next microtask. Shrinking does not interrupt in-flight
14
+ * tasks; it only affects newly queued ones.
15
+ * - `size()` = active + queued.
16
+ * - If the task throws/rejects, the slot is released and queued
17
+ * tasks proceed.
18
+ */
19
+ export interface Semaphore {
20
+ run<T>(fn: () => Promise<T>): Promise<T>;
21
+ setMax(n: number): void;
22
+ size(): number;
23
+ }
24
+
25
+ export function createSemaphore(max: number): Semaphore {
26
+ if (!Number.isFinite(max) || max < 1) {
27
+ throw new Error(`Semaphore max must be a positive integer, got ${max}`);
28
+ }
29
+ let limit = Math.floor(max);
30
+ let active = 0;
31
+ const queue: Array<() => void> = [];
32
+
33
+ function drain() {
34
+ while (active < limit && queue.length > 0) {
35
+ const next = queue.shift()!;
36
+ active++;
37
+ next();
38
+ }
39
+ }
40
+
41
+ function release() {
42
+ active--;
43
+ // Schedule drain on microtask so `run()` callers see a stable state first.
44
+ queueMicrotask(drain);
45
+ }
46
+
47
+ return {
48
+ run<T>(fn: () => Promise<T>): Promise<T> {
49
+ return new Promise<T>((resolve, reject) => {
50
+ const start = () => {
51
+ let settled = false;
52
+ try {
53
+ Promise.resolve()
54
+ .then(fn)
55
+ .then(
56
+ (value) => { if (!settled) { settled = true; release(); resolve(value); } },
57
+ (err) => { if (!settled) { settled = true; release(); reject(err); } },
58
+ );
59
+ } catch (err) {
60
+ if (!settled) { settled = true; release(); reject(err); }
61
+ }
62
+ };
63
+ if (active < limit) {
64
+ active++;
65
+ start();
66
+ } else {
67
+ queue.push(start);
68
+ }
69
+ });
70
+ },
71
+ setMax(n: number): void {
72
+ if (!Number.isFinite(n) || n < 1) {
73
+ throw new Error(`Semaphore max must be a positive integer, got ${n}`);
74
+ }
75
+ limit = Math.floor(n);
76
+ // Drain synchronously so callers that do `setMax(n); await tick` see queued tasks started.
77
+ drain();
78
+ },
79
+ size(): number {
80
+ return active + queue.length;
81
+ },
82
+ };
83
+ }
@@ -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,