@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -0,0 +1,277 @@
1
+ /**
2
+ * `launchDashboardServer` — single shared spawn primitive for the
3
+ * dashboard server. Used by every starter (Bridge, Standalone CLI,
4
+ * Electron). Owns:
5
+ *
6
+ * - jiti loader resolution via `ToolResolver.resolveJiti({ anchor })`
7
+ * - argv construction via `spawnNodeScript` (which delegates to
8
+ * `buildNodeImportArgvParts` for the `--import` chunk)
9
+ * - env merge: `ToolResolver.buildSpawnEnv()` ∪ caller `env`
10
+ * (caller wins on conflict, e.g. `DASHBOARD_STARTER`)
11
+ * - log-file policy: caller-owned absolute path; we mkdir, open in
12
+ * append mode, write a header line, pass the fd, then close the
13
+ * parent's copy after spawn.
14
+ * - readiness policy: poll `isDashboardRunning(port)` and resolve /
15
+ * reject on the first of: health-ok, port-conflict, child early
16
+ * exit, or `healthTimeoutMs` elapsed.
17
+ *
18
+ * Does NOT own the log-file PATH — that's caller policy. Conventions:
19
+ * - extension: `stdio: "ignore"`
20
+ * - cli (`cmdStart`): `~/.pi/dashboard/server.log`
21
+ * - electron: existing electron log path
22
+ *
23
+ * See change: unify-server-launch-ts-loader.
24
+ */
25
+ import { dirname } from "node:path";
26
+ import {
27
+ closeSync,
28
+ mkdirSync,
29
+ openSync,
30
+ writeSync,
31
+ } from "node:fs";
32
+ import type { ChildProcess, SpawnOptions } from "node:child_process"; // ban:child_process-ok — types only
33
+ import { spawnNodeScript } from "./platform/node-spawn.js";
34
+ import { ToolResolver } from "./platform/binary-lookup.js";
35
+ import { isDashboardRunning } from "./server-identity.js";
36
+
37
+ // ── Errors ──────────────────────────────────────────────────────────────────
38
+
39
+ /** No jiti install resolved at any anchor. */
40
+ export class JitiNotFoundError extends Error {
41
+ constructor(message =
42
+ "Cannot find pi's TypeScript loader (jiti). " +
43
+ "Is @earendil-works/pi-coding-agent or @mariozechner/pi-coding-agent installed?",
44
+ ) {
45
+ super(message);
46
+ this.name = "JitiNotFoundError";
47
+ }
48
+ }
49
+
50
+ /** Target port is occupied by a non-dashboard service. */
51
+ export class PortConflictError extends Error {
52
+ readonly port: number;
53
+ constructor(port: number) {
54
+ super(`Port ${port} is occupied by a non-dashboard service`);
55
+ this.name = "PortConflictError";
56
+ this.port = port;
57
+ }
58
+ }
59
+
60
+ /** Spawned child exited before reaching health-ok. */
61
+ export class EarlyExitError extends Error {
62
+ readonly code: number | null;
63
+ readonly signal: NodeJS.Signals | null;
64
+ constructor(code: number | null, signal: NodeJS.Signals | null = null) {
65
+ super(`Server child exited (code=${code}, signal=${signal}) before reaching health`);
66
+ this.name = "EarlyExitError";
67
+ this.code = code;
68
+ this.signal = signal;
69
+ }
70
+ }
71
+
72
+ // ── Options + result ────────────────────────────────────────────────────────
73
+
74
+ export interface LaunchOpts {
75
+ /** Path to node binary. Defaults to `process.execPath`. */
76
+ nodeBin?: string;
77
+ /** Path to the dashboard server CLI script. */
78
+ cliPath: string;
79
+ /** Args appended after the entry script (e.g. `--port`, `--pi-port`, `start`). */
80
+ extraArgs?: readonly string[];
81
+ /** Caller-supplied jiti-resolution anchor (e.g. cliPath inside a node_modules tree). */
82
+ anchor?: string;
83
+ /**
84
+ * Caller env overrides merged ON TOP of `ToolResolver.buildSpawnEnv()`.
85
+ * Conflicting keys: caller wins. Pass `DASHBOARD_STARTER` here.
86
+ * Omit to fall back to the resolver-merged process env.
87
+ */
88
+ env?: Record<string, string | undefined>;
89
+ /**
90
+ * Stdio routing. `"ignore"` for fire-and-forget (extension); a
91
+ * `{ logFile }` object for caller-owned append-mode log capture.
92
+ */
93
+ stdio: "ignore" | { logFile: string };
94
+ /**
95
+ * Optional starter label written to the log header line and (when
96
+ * present) injected as `DASHBOARD_STARTER` env var if `env` does not
97
+ * already supply it. Plain string ("Bridge", "Standalone", "Electron").
98
+ */
99
+ starter?: string;
100
+ /** Health-check timeout in milliseconds. */
101
+ healthTimeoutMs: number;
102
+ /** Port to probe via `isDashboardRunning(port)`. */
103
+ port: number;
104
+ /**
105
+ * Whether the spawned server detaches from the parent's process
106
+ * group / Windows Job Object. Default: `true` (server outlives the
107
+ * launcher — correct for Bridge auto-spawn and Standalone CLI).
108
+ *
109
+ * Pass `false` when the caller deliberately ties the server's
110
+ * lifecycle to its own (Electron — server should die when Electron
111
+ * quits unless Electron explicitly decides to keep it).
112
+ */
113
+ detach?: boolean;
114
+ /**
115
+ * Working directory for the spawned process. Defaults to the
116
+ * launcher's own cwd. Electron passes the project directory.
117
+ */
118
+ cwd?: string;
119
+ // ── Test seams (production omits) ────────────────────────────────────────
120
+ /** Replace `ToolResolver.resolveJiti` (returns loader URL or null). */
121
+ _resolveJiti?: () => string | null;
122
+ /** Replace `spawnNodeScript` (returns ChildProcess). */
123
+ _spawnNodeScript?: typeof spawnNodeScript;
124
+ /** Replace `isDashboardRunning`. */
125
+ _isDashboardRunning?: typeof isDashboardRunning;
126
+ /** Replace fs primitives used for log-file handling. */
127
+ _fs?: {
128
+ mkdirSync?: typeof mkdirSync;
129
+ openSync?: typeof openSync;
130
+ closeSync?: typeof closeSync;
131
+ writeSync?: typeof writeSync;
132
+ };
133
+ /** Override poll interval (ms). Default 300. */
134
+ _pollIntervalMs?: number;
135
+ /** Override `Date.now` for deterministic timeout testing. */
136
+ _now?: () => number;
137
+ /** Override the sleep function used between polls. */
138
+ _sleep?: (ms: number) => Promise<void>;
139
+ }
140
+
141
+ export interface LaunchResult {
142
+ /** Spawned process pid (always present once spawn succeeded). */
143
+ childPid: number;
144
+ /** PID reported by `/api/health` (matches `dashboard.pid`); null if unavailable. */
145
+ reportedPid: number | null;
146
+ /** Always true when this resolves — readiness was confirmed. */
147
+ healthOk: true;
148
+ }
149
+
150
+ // ── Implementation ──────────────────────────────────────────────────────────
151
+
152
+ const DEFAULT_POLL_MS = 300;
153
+
154
+ function defaultSleep(ms: number): Promise<void> {
155
+ return new Promise((r) => setTimeout(r, ms));
156
+ }
157
+
158
+ /**
159
+ * Filter out `undefined` values from an env-record (NodeJS.ProcessEnv
160
+ * tolerates undefined; child_process.spawn does not).
161
+ */
162
+ function compactEnv(base: NodeJS.ProcessEnv): Record<string, string> {
163
+ const out: Record<string, string> = {};
164
+ for (const [k, v] of Object.entries(base)) {
165
+ if (typeof v === "string") out[k] = v;
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /**
171
+ * Spawn the dashboard server and wait for `/api/health` to confirm
172
+ * identity. Resolves with `{ childPid, reportedPid, healthOk: true }`
173
+ * on success; rejects with `JitiNotFoundError` / `PortConflictError`
174
+ * / `EarlyExitError` / readiness-timeout `Error` per the spec.
175
+ */
176
+ export async function launchDashboardServer(opts: LaunchOpts): Promise<LaunchResult> {
177
+ const nodeBin = opts.nodeBin ?? process.execPath;
178
+ const resolveJiti = opts._resolveJiti ?? (() => new ToolResolver({ processExecPath: nodeBin }).resolveJiti({ anchor: opts.anchor }));
179
+ const spawn = opts._spawnNodeScript ?? spawnNodeScript;
180
+ const probe = opts._isDashboardRunning ?? isDashboardRunning;
181
+ const pollIntervalMs = opts._pollIntervalMs ?? DEFAULT_POLL_MS;
182
+ const now = opts._now ?? Date.now;
183
+ const sleep = opts._sleep ?? defaultSleep;
184
+ const fsMkdir = opts._fs?.mkdirSync ?? mkdirSync;
185
+ const fsOpen = opts._fs?.openSync ?? openSync;
186
+ const fsClose = opts._fs?.closeSync ?? closeSync;
187
+ const fsWrite = opts._fs?.writeSync ?? writeSync;
188
+
189
+ // 1. Loader resolution.
190
+ const loader = resolveJiti();
191
+ if (!loader) throw new JitiNotFoundError();
192
+
193
+ // 2. Env: ToolResolver.buildSpawnEnv() merged with caller env (caller wins).
194
+ const baseEnv = new ToolResolver({ processExecPath: nodeBin }).buildSpawnEnv(process.env);
195
+ const env: Record<string, string> = compactEnv(baseEnv);
196
+ if (opts.starter && !(opts.env && "DASHBOARD_STARTER" in opts.env)) {
197
+ env["DASHBOARD_STARTER"] = opts.starter;
198
+ }
199
+ if (opts.env) {
200
+ for (const [k, v] of Object.entries(opts.env)) {
201
+ if (typeof v === "string") env[k] = v;
202
+ else if (v === undefined) delete env[k];
203
+ }
204
+ }
205
+
206
+ // 3. Stdio + log header.
207
+ let logFd: number | undefined;
208
+ let stdio: SpawnOptions["stdio"];
209
+ if (opts.stdio === "ignore") {
210
+ stdio = "ignore";
211
+ } else {
212
+ const { logFile } = opts.stdio;
213
+ fsMkdir(dirname(logFile), { recursive: true });
214
+ logFd = fsOpen(logFile, "a");
215
+ const header = `[${new Date().toISOString()}] ${opts.starter ?? "dashboard"} launch (parent pid ${process.pid}, port ${opts.port}, cli ${opts.cliPath})\n`;
216
+ try { fsWrite(logFd, header); } catch { /* best-effort */ }
217
+ stdio = ["ignore", logFd, logFd];
218
+ }
219
+
220
+ // 4. Spawn. spawnNodeScript handles --import URL-wrapping + entry rule.
221
+ let child: ChildProcess;
222
+ try {
223
+ child = spawn({
224
+ nodeBin,
225
+ loader,
226
+ entry: opts.cliPath,
227
+ args: opts.extraArgs ? [...opts.extraArgs] : undefined,
228
+ spawnOptions: {
229
+ detached: opts.detach ?? true,
230
+ stdio,
231
+ env,
232
+ cwd: opts.cwd,
233
+ windowsHide: true,
234
+ },
235
+ });
236
+ } finally {
237
+ // Always close the parent's copy of the log fd; the child has its own.
238
+ if (logFd !== undefined) {
239
+ try { fsClose(logFd); } catch { /* ignore */ }
240
+ }
241
+ }
242
+
243
+ try { child.unref(); } catch { /* ignore */ }
244
+
245
+ if (!child.pid) {
246
+ throw new EarlyExitError(child.exitCode ?? null, child.signalCode ?? null);
247
+ }
248
+
249
+ // 5. Readiness loop.
250
+ const deadline = now() + opts.healthTimeoutMs;
251
+ while (true) {
252
+ // Early-exit detection (beats timeout per spec).
253
+ if (child.exitCode !== null) {
254
+ throw new EarlyExitError(child.exitCode, child.signalCode ?? null);
255
+ }
256
+ let status;
257
+ try {
258
+ status = await probe(opts.port);
259
+ } catch {
260
+ status = { running: false } as const;
261
+ }
262
+ if (status.running) {
263
+ return {
264
+ childPid: child.pid,
265
+ reportedPid: status.pid ?? null,
266
+ healthOk: true,
267
+ };
268
+ }
269
+ if (status.portConflict) {
270
+ throw new PortConflictError(opts.port);
271
+ }
272
+ if (now() >= deadline) {
273
+ throw new Error("readiness timeout");
274
+ }
275
+ await sleep(pollIntervalMs);
276
+ }
277
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Skill block parser & builder.
3
3
  *
4
- * Pi's `_expandSkillCommand` (in `@mariozechner/pi-coding-agent`) wraps skill
4
+ * Pi's `_expandSkillCommand` (in `@earendil-works/pi-coding-agent`) wraps skill
5
5
  * expansions in a `<skill name="..." location="...">…</skill>\n\nargs` envelope.
6
6
  * The dashboard's bridge expander (`packages/extension/src/prompt-expander.ts`)
7
7
  * aligns to the same byte format. This module is the single source of truth for
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Registration and resolution tests for the `pi-ai` module-kind tool.
3
+ *
4
+ * Verifies:
5
+ * - Registry resolves pi-ai when ~/.pi-dashboard/node_modules/@mariozechner/pi-ai/dist/index.js exists (managed)
6
+ * - Falls back to npmGlobalStrategy when only globally installed
7
+ * - Returns failed resolution with diagnostic trail when none match
8
+ * - Override takes precedence
9
+ *
10
+ * Note: bareImportStrategy uses real module resolution (createRequire) and
11
+ * cannot be injected via StrategyDeps.resolveModule in moduleDefWithAliases.
12
+ * It is implicitly tested (fails gracefully when pi-ai isn't a project dep).
13
+ *
14
+ * See change: add-dashboard-model-proxy (task 2.1).
15
+ */
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { describe, expect, it } from "vitest";
19
+ import {
20
+ ToolRegistry,
21
+ registerDefaultTools,
22
+ OverridesStore,
23
+ } from "../index.js";
24
+
25
+ const HOME = os.homedir();
26
+ const MANAGED_PATH = path.join(
27
+ HOME,
28
+ ".pi-dashboard",
29
+ "node_modules",
30
+ "@mariozechner",
31
+ "pi-ai",
32
+ "dist",
33
+ "index.js",
34
+ );
35
+
36
+ function freshRegistry(opts: {
37
+ exists?: (p: string) => boolean;
38
+ which?: (name: string) => string | null;
39
+ npmRootGlobal?: () => string;
40
+ overrides?: Record<string, string>;
41
+ }) {
42
+ const store = new OverridesStore({
43
+ filePath: path.join(os.tmpdir(), `pi-ai-test-${Math.random()}.json`),
44
+ warn: () => {},
45
+ });
46
+ for (const [k, v] of Object.entries(opts.overrides ?? {})) store.set(k, v);
47
+
48
+ const r = new ToolRegistry({
49
+ overrides: store,
50
+ platform: "linux",
51
+ });
52
+ registerDefaultTools(r, {
53
+ exists: opts.exists ?? (() => false),
54
+ which: opts.which ?? (() => null),
55
+ npmRootGlobal: opts.npmRootGlobal ?? (() => ""),
56
+ });
57
+ return r;
58
+ }
59
+
60
+ describe("pi-ai: module registration", () => {
61
+ it("resolves via managed path when ~/.pi-dashboard/node_modules/@mariozechner/pi-ai exists", () => {
62
+ const r = freshRegistry({
63
+ exists: (p) => p === MANAGED_PATH,
64
+ });
65
+ const result = r.resolve("pi-ai");
66
+ expect(result.ok).toBe(true);
67
+ expect(result.path).toBe(MANAGED_PATH);
68
+ expect(result.source).toBe("managed");
69
+ });
70
+
71
+ it("falls back to npm-global when only globally installed", () => {
72
+ const globalRoot = "/usr/lib/node_modules";
73
+ const globalPath = path.join(
74
+ globalRoot,
75
+ "@mariozechner",
76
+ "pi-ai",
77
+ "dist",
78
+ "index.js",
79
+ );
80
+ const r = freshRegistry({
81
+ exists: (p) => p === globalPath,
82
+ npmRootGlobal: () => globalRoot,
83
+ });
84
+ const result = r.resolve("pi-ai");
85
+ expect(result.ok).toBe(true);
86
+ expect(result.path).toBe(globalPath);
87
+ expect(result.source).toBe("npm-global");
88
+ });
89
+
90
+ it("returns failed resolution with diagnostic trail when none match", () => {
91
+ const r = freshRegistry({});
92
+ const result = r.resolve("pi-ai");
93
+ expect(result.ok).toBe(false);
94
+ expect(result.tried).toBeDefined();
95
+ expect(result.tried!.length).toBeGreaterThan(0);
96
+ // Should have tried override, bare-import, managed, npm-global
97
+ const strategyNames = result.tried!.map((t) => t.strategy);
98
+ expect(strategyNames).toContain("override");
99
+ expect(strategyNames).toContain("bare-import");
100
+ expect(strategyNames).toContain("managed");
101
+ expect(strategyNames).toContain("npm-global");
102
+ });
103
+
104
+ it("override takes precedence over managed", () => {
105
+ const overridePath = "/custom/pi-ai/dist/index.js";
106
+ const r = freshRegistry({
107
+ exists: (p) => p === overridePath || p === MANAGED_PATH,
108
+ overrides: { "pi-ai": overridePath },
109
+ });
110
+ const result = r.resolve("pi-ai");
111
+ expect(result.ok).toBe(true);
112
+ expect(result.path).toBe(overridePath);
113
+ expect(result.source).toBe("override");
114
+ });
115
+
116
+ it("is registered and resolvable by name", () => {
117
+ const r = freshRegistry({
118
+ exists: (p) => p === MANAGED_PATH,
119
+ });
120
+ const result = r.resolve("pi-ai");
121
+ expect(result.name).toBe("pi-ai");
122
+ expect(result.ok).toBe(true);
123
+ });
124
+ });
@@ -60,7 +60,7 @@ function binaryDef(binaryName: string, deps?: StrategyDeps): ToolDefinition {
60
60
 
61
61
  // ── Module definitions ──────────────────────────────────────────────────────
62
62
 
63
- /** Sibling probe for an aliased package name (pi: `@mariozechner/*` + `@oh-my-pi/*`). */
63
+ /** Sibling probe for an aliased package name (pi: `@earendil-works/*` + `@mariozechner/*`). */
64
64
  function moduleDefWithAliases(
65
65
  canonicalName: string,
66
66
  pkgNames: readonly string[],
@@ -188,7 +188,7 @@ const nodeScriptToArgv: ToolDefinition["toArgv"] = (resolvedPath, { platform, re
188
188
  * On Unix, the chain finds `pi` on PATH; argv = [pi].
189
189
  */
190
190
  function piExecutorDef(deps?: StrategyDeps): ToolDefinition {
191
- const piPkgAliases = ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"];
191
+ const piPkgAliases = ["@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"];
192
192
  const cliEntry = path.join("dist", "cli.js");
193
193
 
194
194
  const winStrategies = [
@@ -413,7 +413,19 @@ export function registerDefaultTools(registry: ToolRegistry, deps?: StrategyDeps
413
413
  registry.register(
414
414
  moduleDefWithAliases(
415
415
  "pi-coding-agent",
416
- ["@mariozechner/pi-coding-agent", "@oh-my-pi/pi-coding-agent"],
416
+ ["@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"],
417
+ path.join("dist", "index.js"),
418
+ deps,
419
+ ),
420
+ );
421
+
422
+ // pi-ai module — used by model-proxy to call upstream LLM providers.
423
+ // Aliases: @earendil-works/pi-ai (preferred) + @mariozechner/pi-ai (legacy fallback).
424
+ // See change: add-dashboard-model-proxy.
425
+ registry.register(
426
+ moduleDefWithAliases(
427
+ "pi-ai",
428
+ ["@earendil-works/pi-ai", "@mariozechner/pi-ai"],
417
429
  path.join("dist", "index.js"),
418
430
  deps,
419
431
  ),
@@ -404,6 +404,40 @@ export interface OpenSpecChange {
404
404
  * "Archive anyway" escape hatch when artifacts are authored but tasks remain unchecked.
405
405
  */
406
406
  isComplete?: boolean;
407
+ /**
408
+ * Group assignment joined server-side from `<cwd>/openspec/groups/groups.json`.
409
+ * `null` or absent means Ungrouped. Clients SHALL NOT recompute the join.
410
+ * See change: add-openspec-change-grouping.
411
+ */
412
+ groupId?: string | null;
413
+ }
414
+
415
+ /** Schema version for the per-repo OpenSpec groups file at
416
+ * `<cwd>/openspec/groups/groups.json`. Bumped only on incompatible shape changes.
417
+ * See change: add-openspec-change-grouping. */
418
+ export const OPENSPEC_GROUPS_SCHEMA_VERSION = 1 as const;
419
+
420
+ /** A user-defined group of OpenSpec changes within a single repo.
421
+ * See change: add-openspec-change-grouping. */
422
+ export interface OpenSpecGroup {
423
+ /** Server-generated slug from `name` plus collision suffix. Stable across rename. */
424
+ id: string;
425
+ /** User-visible label; editable. */
426
+ name: string;
427
+ /** Optional CSS hex color (`#RRGGBB`). Clients fall back to a default palette when omitted. */
428
+ color?: string;
429
+ /** Display order; server keeps values contiguous `0..groups.length - 1` after every reorder. */
430
+ order: number;
431
+ }
432
+
433
+ /** Shape of the on-disk groups file at `<cwd>/openspec/groups/groups.json`.
434
+ * Single combined file for groups + assignments — one read, one write, atomic.
435
+ * See change: add-openspec-change-grouping. */
436
+ export interface OpenSpecGroupsFile {
437
+ schemaVersion: number;
438
+ groups: OpenSpecGroup[];
439
+ /** `changeName` → `groupId`. Unassigned changes have no entry. */
440
+ assignments: Record<string, string>;
407
441
  }
408
442
 
409
443
  /** Lifecycle state of an OpenSpec change, derived from artifacts + task status */
@@ -425,6 +459,12 @@ export function deriveChangeState(change: OpenSpecChange): ChangeState {
425
459
 
426
460
  /** OpenSpec data for a session's project */
427
461
  export interface OpenSpecData {
462
+ /**
463
+ * `openspec list` returned authoritative data for this cwd. Requires both
464
+ * `<cwd>/openspec/` AND `<cwd>/openspec/changes/` to exist AND the CLI to
465
+ * succeed. Does NOT distinguish "openspec project, no changes yet" from
466
+ * "truly not an openspec project" — see `hasOpenspecDir` for that.
467
+ */
428
468
  initialized: boolean;
429
469
  changes: OpenSpecChange[];
430
470
  /**
@@ -440,6 +480,21 @@ export interface OpenSpecData {
440
480
  * See change: fix-cold-boot-openspec-protocol.
441
481
  */
442
482
  pending?: boolean;
483
+ /**
484
+ * Whether `<cwd>/openspec/` directory exists. Strictly weaker than
485
+ * `initialized`: this can be `true` while `initialized` is `false` when
486
+ * the project is OpenSpec-initialized (`openspec init` was run) but
487
+ * `openspec/changes/` doesn't exist yet (no proposals authored). In that
488
+ * case `openspec list` errors out and `initialized` stays `false`, but
489
+ * the session card should still show the OPENSPEC subcard as an
490
+ * init/attach affordance.
491
+ *
492
+ * Optional for backwards compatibility — absence means "unknown, fall
493
+ * back to `initialized || pending`" on the client side.
494
+ *
495
+ * See change: auto-hide-empty-session-subcards.
496
+ */
497
+ hasOpenspecDir?: boolean;
443
498
  }
444
499
 
445
500
  /** OpenSpec workflow phase detected from tool calls */
@@ -649,4 +704,11 @@ export interface ApiResponse<T = unknown> {
649
704
  success: boolean;
650
705
  data?: T;
651
706
  error?: string;
707
+ /**
708
+ * Optional structured failure-classifier code paired with `error`.
709
+ * Lets clients render specific UI for known failure modes
710
+ * (e.g., `"FORK_EMPTY_SESSION"`).
711
+ * See change: fix-fork-empty-session-silent-timeout.
712
+ */
713
+ code?: string;
652
714
  }
@@ -1,53 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { buildJitiRegisterUrl, resolveJitiImport } from "../resolve-jiti.js";
3
-
4
- describe("buildJitiRegisterUrl", () => {
5
- // Pure function: given a jiti package.json path, return the file:// URL of
6
- // its register hook. The URL contract is the critical invariant — Node's
7
- // --import on Windows rejects raw drive-letter paths (parses "C:" as a
8
- // URL scheme). See change: fix-windows-server-parity.
9
-
10
- it("returns a file:// URL", () => {
11
- const url = buildJitiRegisterUrl("/usr/lib/node_modules/@mariozechner/jiti/package.json");
12
- expect(url.startsWith("file://")).toBe(true);
13
- });
14
-
15
- it("URL is parseable by new URL() without throwing", () => {
16
- const url = buildJitiRegisterUrl("/usr/lib/node_modules/@mariozechner/jiti/package.json");
17
- expect(() => new URL(url)).not.toThrow();
18
- });
19
-
20
- it("points at lib/jiti-register.mjs under the package dir", () => {
21
- const url = buildJitiRegisterUrl("/usr/lib/node_modules/@mariozechner/jiti/package.json");
22
- expect(url.endsWith("/lib/jiti-register.mjs")).toBe(true);
23
- });
24
-
25
- it("handles Windows drive-letter paths (regression for ERR_UNSUPPORTED_ESM_URL_SCHEME)", () => {
26
- // This is the exact shape that crashed pre-fix: a raw path with a
27
- // drive letter was passed to `node --import` and Node parsed "B:" as
28
- // a URL scheme. A file:// URL sidesteps the parser entirely.
29
- const url = buildJitiRegisterUrl("B:\\Dev\\Nodejs\\global\\node_modules\\@mariozechner\\jiti\\package.json");
30
- expect(url.startsWith("file:///")).toBe(true);
31
- expect(() => new URL(url)).not.toThrow();
32
- expect(new URL(url).protocol).toBe("file:");
33
- // The drive letter survives as part of the pathname, not as a protocol
34
- expect(url.toLowerCase()).toContain("/b:/");
35
- expect(url.endsWith("/lib/jiti-register.mjs")).toBe(true);
36
- });
37
-
38
- });
39
-
40
- describe("resolveJitiImport", () => {
41
- // Integration-lite: in vitest context (not inside pi's jiti loader),
42
- // process.argv[1] points at the test runner, not pi — so peer-dep
43
- // resolution fails and the function throws a helpful error. The
44
- // URL-contract behavior is covered by buildJitiRegisterUrl above.
45
-
46
- it("throws with clear error when pi-coding-agent is not resolvable", () => {
47
- expect(() => resolveJitiImport()).toThrow("Cannot find pi's TypeScript loader");
48
- });
49
-
50
- it("error message mentions pi-coding-agent", () => {
51
- expect(() => resolveJitiImport()).toThrow("pi-coding-agent");
52
- });
53
- });