@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  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__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -15,13 +15,15 @@
15
15
  *
16
16
  * See change: consolidate-windows-spawn-and-platform-handlers.
17
17
  */
18
- import { existsSync, mkdirSync, openSync, closeSync } from "node:fs";
18
+ import { existsSync, mkdirSync, openSync, closeSync, readFileSync } from "node:fs";
19
19
  import path from "node:path";
20
20
  import os from "node:os";
21
21
  import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
22
22
  import type { SpawnStrategy } from "@blackbelt-technology/pi-dashboard-shared/config.js";
23
23
  import { MANAGED_BIN } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
24
24
  import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
25
+ import { prependManagedNodeToPath } from "@blackbelt-technology/pi-dashboard-shared/platform/managed-node-path.js";
26
+ import { mintSpawnToken } from "./spawn-token.js";
25
27
  import { execSync, spawnSync, buildSafeArgv } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
26
28
  import {
27
29
  spawnDetached,
@@ -34,6 +36,7 @@ import {
34
36
  type SpawnMechanism,
35
37
  type UserSpawnStrategy,
36
38
  } from "@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js";
39
+ import type { SpawnFailureCode } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
37
40
 
38
41
  // ── Resolver seam (injectable for tests) ────────────────────────────────────
39
42
 
@@ -55,6 +58,15 @@ export interface SessionOptions {
55
58
  sessionFile?: string;
56
59
  mode?: "continue" | "fork";
57
60
  strategy?: SpawnStrategy;
61
+ /**
62
+ * Server-minted spawn correlation token. When provided, injected into
63
+ * the spawned process env as `PI_DASHBOARD_SPAWN_TOKEN`. The bridge
64
+ * echoes it back in the first `session_register` so the server can
65
+ * resolve identity precisely (linkByToken). When omitted, callers
66
+ * fall through to pid-link or cwd-FIFO matching.
67
+ * See change: spawn-correlation-token.
68
+ */
69
+ spawnToken?: string;
58
70
  }
59
71
 
60
72
  export interface SpawnResult {
@@ -64,11 +76,46 @@ export interface SpawnResult {
64
76
  process?: ChildProcess;
65
77
  /** True when spawned from the dashboard (for writing session meta) */
66
78
  dashboardSpawned?: boolean;
79
+ /** Structured failure classifier. Set on every { success: false } path. See change: spawn-failure-diagnostics. */
80
+ code?: SpawnFailureCode;
81
+ /** Tail of pi's stderr log (Windows headless PI_CRASHED only). See change: spawn-failure-diagnostics. */
82
+ stderr?: string;
83
+ /** Path to the per-session stderr log (Windows headless). Forwarded to watchdog. See change: spawn-failure-diagnostics. */
84
+ logPath?: string;
85
+ /**
86
+ * Token minted by `spawnPiSession` and injected into the spawned process's
87
+ * env as `PI_DASHBOARD_SPAWN_TOKEN`. Returned so callers can register it
88
+ * with the headless-pid registry, watchdog, and pending-* registries.
89
+ * See change: spawn-correlation-token.
90
+ */
91
+ spawnToken?: string;
67
92
  }
68
93
 
69
- /** Build env with managed install bin + current node binary dir prepended to PATH. */
70
- export function buildSpawnEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
71
- return resolver.buildSpawnEnv(baseEnv);
94
+ /**
95
+ * Build env for pi-session spawns.
96
+ *
97
+ * Order of PATH prepends (highest priority first):
98
+ * 1. Managed Node runtime (`<managedDir>/node/{bin,}`) when installed.
99
+ * See change: embed-managed-node-runtime.
100
+ * 2. Managed bin (`<managedDir>/node_modules/.bin`).
101
+ * 3. Current Node binary dir, extra bin dirs, common user bin dirs.
102
+ *
103
+ * The managed-Node prepend happens AFTER the resolver's prepends so it
104
+ * lands at the very head of `PATH` — spawned children invoking plain
105
+ * `node` / `npm` resolve to the managed runtime first.
106
+ */
107
+ export function buildSpawnEnv(
108
+ baseEnv: NodeJS.ProcessEnv = process.env,
109
+ opts?: { spawnToken?: string },
110
+ ): NodeJS.ProcessEnv {
111
+ const env = prependManagedNodeToPath(resolver.buildSpawnEnv(baseEnv));
112
+ if (opts?.spawnToken) {
113
+ // Inject the correlation token so the bridge inside the spawned pi
114
+ // process can read it and echo back in `session_register`.
115
+ // See change: spawn-correlation-token.
116
+ return { ...env, PI_DASHBOARD_SPAWN_TOKEN: opts.spawnToken };
117
+ }
118
+ return env;
72
119
  }
73
120
 
74
121
  /**
@@ -256,17 +303,28 @@ export async function spawnPiSession(
256
303
  options?: SessionOptions & { electronMode?: boolean },
257
304
  ): Promise<SpawnResult> {
258
305
  if (!existsSync(cwd)) {
259
- return { success: false, message: `Directory does not exist: ${cwd}` };
306
+ return { success: false, code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` };
260
307
  }
261
308
 
262
- const mechanism = chooseMechanism(options, options?.electronMode ?? false);
309
+ // Mint a spawn token if the caller didn't provide one. Token is injected
310
+ // into the spawned process's env (via buildSpawnEnv) and surfaced on
311
+ // SpawnResult so callers can register it with the registries.
312
+ // See change: spawn-correlation-token.
313
+ const spawnToken = options?.spawnToken ?? mintSpawnToken();
314
+ const opts: SessionOptions & { electronMode?: boolean } = { ...(options ?? {}), spawnToken };
315
+
316
+ const mechanism = chooseMechanism(opts, opts?.electronMode ?? false);
263
317
 
318
+ let result: SpawnResult;
264
319
  switch (mechanism) {
265
- case "tmux": return spawnTmux(cwd, options);
266
- case "wt": return spawnWt(cwd, options);
267
- case "wsl-tmux": return spawnWslTmux(cwd, options);
268
- case "headless": return spawnHeadless(cwd, options);
320
+ case "tmux": result = spawnTmux(cwd, opts); break;
321
+ case "wt": result = await spawnWt(cwd, opts); break;
322
+ case "wsl-tmux": result = spawnWslTmux(cwd, opts); break;
323
+ case "headless": result = await spawnHeadless(cwd, opts); break;
269
324
  }
325
+ // Surface the token on every result (success or failure) so callers
326
+ // can clean up registries deterministically.
327
+ return { ...result, spawnToken };
270
328
  }
271
329
 
272
330
  // ── Per-mechanism spawn ────────────────────────────────────────────────────
@@ -274,36 +332,41 @@ export async function spawnPiSession(
274
332
  function spawnTmux(cwd: string, options?: SessionOptions): SpawnResult {
275
333
  const exists = dashboardSessionExists();
276
334
  const cmd = buildTmuxCommand(cwd, exists, options);
335
+ // Pass env explicitly so PI_DASHBOARD_SPAWN_TOKEN reaches the tmux pane's
336
+ // pi process (tmux inherits the caller's env into new windows/sessions).
337
+ // See change: spawn-correlation-token.
338
+ const env = buildSpawnEnv(process.env, { spawnToken: options?.spawnToken });
277
339
  try {
278
- execSync(cmd, { stdio: "ignore" });
340
+ execSync(cmd, { stdio: "ignore", env });
279
341
  return {
280
342
  success: true,
281
343
  dashboardSpawned: true,
282
344
  message: `Pi session spawned in tmux (${exists ? "new window" : "new session"})`,
283
345
  };
284
346
  } catch (err: any) {
285
- return { success: false, message: `Failed to spawn session: ${err.message}` };
347
+ return { success: false, code: "TMUX_MISSING", message: `Failed to spawn session: ${err.message}` };
286
348
  }
287
349
  }
288
350
 
289
351
  function spawnWslTmux(cwd: string, options?: SessionOptions): SpawnResult {
290
352
  try {
291
353
  const cmd = `wsl ${buildTmuxCommand(cwd, false, options)}`;
292
- execSync(cmd, { stdio: "ignore" });
354
+ const env = buildSpawnEnv(process.env, { spawnToken: options?.spawnToken });
355
+ execSync(cmd, { stdio: "ignore", env });
293
356
  return { success: true, dashboardSpawned: true, message: "Pi session spawned via WSL tmux" };
294
357
  } catch (err: any) {
295
- return { success: false, message: `Failed to spawn via WSL tmux: ${err.message}` };
358
+ return { success: false, code: "TMUX_MISSING", message: `Failed to spawn via WSL tmux (wsl-tmux mechanism): ${err.message}` };
296
359
  }
297
360
  }
298
361
 
299
362
  async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
300
363
  const wt = resolver.which("wt");
301
364
  if (!wt) {
302
- return { success: false, message: "Windows Terminal (wt.exe) not found" };
365
+ return { success: false, code: "WT_MISSING", message: "Windows Terminal (wt.exe) not found" };
303
366
  }
304
367
  const piCmd = resolvePiCommand();
305
368
  if (!piCmd) {
306
- return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
369
+ return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
307
370
  }
308
371
 
309
372
  const piArgv = [...piCmd, ...buildInteractivePiArgs(options)];
@@ -313,11 +376,11 @@ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResu
313
376
  cmd: wt,
314
377
  args,
315
378
  cwd,
316
- env: buildSpawnEnv(),
379
+ env: buildSpawnEnv(process.env, { spawnToken: options?.spawnToken }),
317
380
  });
318
381
 
319
382
  if (!r.ok) {
320
- return { success: false, message: `Failed to launch Windows Terminal: ${r.error}` };
383
+ return { success: false, code: "SPAWN_ERRNO", message: `Failed to launch Windows Terminal: ${r.error}` };
321
384
  }
322
385
 
323
386
  return {
@@ -331,10 +394,10 @@ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResu
331
394
 
332
395
  async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
333
396
  const args = buildHeadlessArgs(options);
334
- const env = buildSpawnEnv();
397
+ const env = buildSpawnEnv(process.env, { spawnToken: options?.spawnToken });
335
398
  const piCmd = resolvePiCommand();
336
399
  if (!piCmd) {
337
- return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
400
+ return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
338
401
  }
339
402
  const [bin, ...prefixArgs] = piCmd;
340
403
 
@@ -356,7 +419,7 @@ async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<Spa
356
419
  env,
357
420
  });
358
421
  if (!r.ok) {
359
- return { success: false, message: `Failed to spawn headless (Unix): ${r.error}` };
422
+ return { success: false, code: "SPAWN_ERRNO", message: `Failed to spawn headless (Unix): ${r.error}` };
360
423
  }
361
424
  return {
362
425
  success: true,
@@ -397,6 +460,7 @@ async function spawnHeadlessDetached(
397
460
  if (bin.toLowerCase().endsWith(".cmd") || bin.toLowerCase().endsWith(".bat")) {
398
461
  return {
399
462
  success: false,
463
+ code: "WIN_PI_CMD_ONLY",
400
464
  message:
401
465
  "Windows pi spawn requires node.exe + cli.js (managed install). " +
402
466
  "Found only pi.cmd on PATH. Run the dashboard setup wizard or " +
@@ -465,6 +529,8 @@ async function spawnHeadlessDetached(
465
529
  if (!r.ok || !r.process || !r.pid) {
466
530
  return {
467
531
  success: false,
532
+ code: "SPAWN_ERRNO",
533
+ logPath: logFd !== undefined ? logPath : undefined,
468
534
  message: `Failed to spawn pi: ${r.error ?? "unknown error"}. Command: ${cmdForLog}`,
469
535
  };
470
536
  }
@@ -473,8 +539,16 @@ async function spawnHeadlessDetached(
473
539
  // but still catch immediate crashes (missing modules, config errors).
474
540
  const gate = await waitForNoCrash({ child: r.process, windowMs: 300 });
475
541
  if (!gate.ok) {
542
+ // Read last 4 KB of stderr log for diagnostic forwarding. See change: spawn-failure-diagnostics.
543
+ let stderrTail: string | undefined;
544
+ if (logFd !== undefined) {
545
+ stderrTail = readLogTail(logPath);
546
+ }
476
547
  return {
477
548
  success: false,
549
+ code: "PI_CRASHED",
550
+ logPath: logFd !== undefined ? logPath : undefined,
551
+ stderr: stderrTail,
478
552
  message:
479
553
  `Pi process exited immediately (code ${gate.exitCode}). ` +
480
554
  `See ${logPath} for details.\nCommand: ${cmdForLog}`,
@@ -487,5 +561,25 @@ async function spawnHeadlessDetached(
487
561
  message: `Pi session spawned headless (pid ${r.pid})`,
488
562
  pid: r.pid,
489
563
  process: r.process,
564
+ logPath: logFd !== undefined ? logPath : undefined,
490
565
  };
491
566
  }
567
+
568
+ /**
569
+ * Read last `maxBytes` bytes of `filePath`, stripping leading UTF-8 continuation bytes.
570
+ * Returns `undefined` on any error or if file is empty.
571
+ * See change: spawn-failure-diagnostics.
572
+ */
573
+ function readLogTail(filePath: string, maxBytes = 4096): string | undefined {
574
+ try {
575
+ const buf = readFileSync(filePath);
576
+ if (!buf.length) return undefined;
577
+ const slice = buf.length <= maxBytes ? buf : buf.slice(buf.length - maxBytes);
578
+ // Strip leading UTF-8 continuation bytes (0x80..0xBF)
579
+ let start = 0;
580
+ while (start < slice.length && (slice[start]! & 0xC0) === 0x80) start++;
581
+ return slice.slice(start).toString("utf-8");
582
+ } catch {
583
+ return undefined;
584
+ }
585
+ }
@@ -29,6 +29,8 @@ export function generateState(): string {
29
29
  export interface AuthCodeHandler {
30
30
  flowType: "auth_code";
31
31
  providerId: string;
32
+ /** Human-readable name surfaced to the UI. */
33
+ displayName: string;
32
34
  /** Port registered with the OAuth provider for the redirect URI */
33
35
  callbackPort: number;
34
36
  /** Path registered with the OAuth provider for the redirect URI */
@@ -40,6 +42,8 @@ export interface AuthCodeHandler {
40
42
  export interface DeviceCodeHandler {
41
43
  flowType: "device_code";
42
44
  providerId: string;
45
+ /** Human-readable name surfaced to the UI. */
46
+ displayName: string;
43
47
  requestDeviceCode(enterpriseDomain?: string): Promise<DeviceCodeData>;
44
48
  pollForToken(deviceCode: string, interval: number, expiresIn: number, extra?: Record<string, unknown>): Promise<OAuthCredential>;
45
49
  }
@@ -93,6 +97,7 @@ const ANTHROPIC_SCOPES = "org:create_api_key user:profile user:inference user:se
93
97
  export const anthropicHandler: AuthCodeHandler = {
94
98
  flowType: "auth_code",
95
99
  providerId: "anthropic",
100
+ displayName: "Anthropic (Claude Pro/Max)",
96
101
  callbackPort: 53692,
97
102
  callbackPath: "/callback",
98
103
 
@@ -146,6 +151,7 @@ const CODEX_SCOPE = "openid profile email offline_access";
146
151
  export const codexHandler: AuthCodeHandler = {
147
152
  flowType: "auth_code",
148
153
  providerId: "openai-codex",
154
+ displayName: "ChatGPT Plus/Pro (Codex)",
149
155
  callbackPort: 1455,
150
156
  callbackPath: "/auth/callback",
151
157
 
@@ -206,6 +212,7 @@ function githubUrls(domain: string) {
206
212
  export const githubCopilotHandler: DeviceCodeHandler = {
207
213
  flowType: "device_code",
208
214
  providerId: "github-copilot",
215
+ displayName: "GitHub Copilot",
209
216
 
210
217
  async requestDeviceCode(enterpriseDomain) {
211
218
  const domain = enterpriseDomain || "github.com";
@@ -336,6 +343,7 @@ const GEMINI_SCOPES = [
336
343
  export const geminiCliHandler: AuthCodeHandler = {
337
344
  flowType: "auth_code",
338
345
  providerId: "google-gemini-cli",
346
+ displayName: "Google Gemini CLI",
339
347
  callbackPort: 8085,
340
348
  callbackPath: "/oauth2callback",
341
349
 
@@ -384,6 +392,7 @@ const AG_SCOPES = [
384
392
  export const antigravityHandler: AuthCodeHandler = {
385
393
  flowType: "auth_code",
386
394
  providerId: "google-antigravity",
395
+ displayName: "Antigravity",
387
396
  callbackPort: 51121,
388
397
  callbackPath: "/oauth-callback",
389
398
 
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * Read/write ~/.pi/agent/auth.json for pi provider credentials.
3
3
  * Uses lockfile + atomic write to avoid race conditions with running pi sessions.
4
+ *
5
+ * The OAuth provider list derives from the local handler registry
6
+ * (`getAllHandlers()` in provider-auth-handlers.ts). The API-key list
7
+ * derives from the bridge-pushed catalogue (provider-catalogue-cache.ts).
8
+ * See change: replace-hardcoded-provider-lists.
4
9
  */
5
10
  import fs from "node:fs";
6
11
  import path from "node:path";
7
12
  import os from "node:os";
8
13
  import type { ProviderAuthStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
14
+ import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
15
+ import { getAllHandlers, type ProviderHandler } from "./provider-auth-handlers.js";
16
+ import { getLatestCatalogue } from "./provider-catalogue-cache.js";
9
17
 
10
18
  // ── Constants ────────────────────────────────────────────────────────────────
11
19
 
@@ -21,33 +29,12 @@ export type OAuthCredential = { type: "oauth"; refresh: string; access: string;
21
29
  export type AuthCredential = ApiKeyCredential | OAuthCredential;
22
30
  export type AuthData = Record<string, AuthCredential>;
23
31
 
24
- // ── OAuth provider metadata (for status display) ────────────────────────────
25
-
26
32
  interface OAuthProviderMeta {
27
33
  id: string;
28
34
  name: string;
29
35
  flowType: "auth_code" | "device_code";
30
36
  }
31
37
 
32
- const OAUTH_PROVIDERS: OAuthProviderMeta[] = [
33
- { id: "anthropic", name: "Anthropic (Claude Pro/Max)", flowType: "auth_code" },
34
- { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex)", flowType: "auth_code" },
35
- { id: "github-copilot", name: "GitHub Copilot", flowType: "device_code" },
36
- { id: "google-gemini-cli", name: "Google Gemini CLI", flowType: "auth_code" },
37
- { id: "google-antigravity", name: "Antigravity", flowType: "auth_code" },
38
- ];
39
-
40
- const API_KEY_PROVIDERS = [
41
- { id: "anthropic-api", authJsonKey: "anthropic", name: "Anthropic (API Key)" },
42
- { id: "openai", authJsonKey: "openai", name: "OpenAI" },
43
- { id: "google", authJsonKey: "google", name: "Google Gemini (API Key)" },
44
- { id: "mistral", authJsonKey: "mistral", name: "Mistral" },
45
- { id: "groq", authJsonKey: "groq", name: "Groq" },
46
- { id: "xai", authJsonKey: "xai", name: "xAI" },
47
- { id: "openrouter", authJsonKey: "openrouter", name: "OpenRouter" },
48
- { id: "zai", authJsonKey: "zai", name: "Z.ai" },
49
- ];
50
-
51
38
  // ── Lock helpers ─────────────────────────────────────────────────────────────
52
39
 
53
40
  function acquireLock(): void {
@@ -114,7 +101,7 @@ function writeAuthJson(data: AuthData): void {
114
101
  fs.renameSync(tmp, AUTH_PATH);
115
102
  }
116
103
 
117
- // ── Public API ───────────────────────────────────────────────────────────────
104
+ // ── Public API: write/remove ─────────────────────────────────────────────────
118
105
 
119
106
  export function writeCredential(provider: string, credential: AuthCredential): void {
120
107
  acquireLock();
@@ -138,63 +125,108 @@ export function removeCredential(provider: string): void {
138
125
  }
139
126
  }
140
127
 
141
- export function getAuthStatus(): ProviderAuthStatus[] {
142
- const data = readAuthJson();
128
+ // ── Pure status builder (testable) ───────────────────────────────────────────
129
+
130
+ /**
131
+ * Pure derivation of `ProviderAuthStatus[]` from auth.json data, the
132
+ * bridge-pushed provider catalogue, and the local OAuth handler set.
133
+ * No I/O. See change: replace-hardcoded-provider-lists.
134
+ */
135
+ export function _buildAuthStatus(
136
+ catalogue: ProviderInfo[],
137
+ authData: AuthData,
138
+ oauthHandlers: ProviderHandler[],
139
+ ): ProviderAuthStatus[] {
143
140
  const statuses: ProviderAuthStatus[] = [];
141
+ const oauthIds = new Set(oauthHandlers.map((h) => h.providerId));
144
142
 
145
- // OAuth providers
146
- for (const p of OAUTH_PROVIDERS) {
147
- const cred = data[p.id];
143
+ // OAuth rows from local handler registry.
144
+ for (const h of oauthHandlers) {
145
+ const cred = authData[h.providerId];
148
146
  if (cred && cred.type === "oauth") {
149
147
  statuses.push({
150
- id: p.id,
151
- name: p.name,
152
- flowType: p.flowType,
148
+ id: h.providerId,
149
+ name: h.displayName,
150
+ flowType: h.flowType,
153
151
  authenticated: true,
154
152
  expires: (cred as OAuthCredential).expires,
155
153
  });
156
154
  } else {
157
155
  statuses.push({
158
- id: p.id,
159
- name: p.name,
160
- flowType: p.flowType,
156
+ id: h.providerId,
157
+ name: h.displayName,
158
+ flowType: h.flowType,
161
159
  authenticated: false,
162
160
  });
163
161
  }
164
162
  }
165
163
 
166
- // API key providers (skip if the same key is already shown as OAuth)
167
- for (const p of API_KEY_PROVIDERS) {
168
- const cred = data[p.authJsonKey];
169
- // If key is already listed as OAuth provider (e.g., "anthropic"), skip the API key variant
170
- if (OAUTH_PROVIDERS.some((op) => op.id === p.authJsonKey) && cred?.type === "oauth") continue;
171
- const hasKey = !!(cred && cred.type === "api_key" && (cred as ApiKeyCredential).key);
172
- const entry: ProviderAuthStatus = {
173
- id: p.id,
174
- name: p.name,
164
+ // API-key rows from bridge-pushed catalogue.
165
+ // Skip custom providers (registered via pi.registerProvider() from
166
+ // ~/.pi/agent/providers.json) those are managed by the dedicated
167
+ // LLM Providers settings section. OAuth rows for custom providers
168
+ // were already emitted above when the OAuth handler registry has
169
+ // a matching id.
170
+ for (const entry of catalogue) {
171
+ if (entry.custom) continue;
172
+ const hasOAuthCollision = oauthIds.has(entry.id);
173
+ const uiId = hasOAuthCollision ? `${entry.id}-api` : entry.id;
174
+ const displayName = hasOAuthCollision
175
+ ? `${entry.displayName} (API Key)`
176
+ : entry.displayName;
177
+ const authJsonKey = entry.id;
178
+ const cred = authData[authJsonKey];
179
+ const hasStoredKey = !!(cred && cred.type === "api_key" && (cred as ApiKeyCredential).key);
180
+
181
+ const row: ProviderAuthStatus = {
182
+ id: uiId,
183
+ name: displayName,
175
184
  flowType: "api_key",
176
- authenticated: hasKey,
185
+ authenticated: hasStoredKey || !!entry.ambient,
177
186
  };
178
- if (hasKey) {
187
+ if (hasStoredKey) {
179
188
  const key = (cred as ApiKeyCredential).key;
180
- entry.maskedKey = key.length >= 12 ? `${key.slice(0, 5)}...${key.slice(-3)}` : "****";
189
+ row.maskedKey = key.length >= 12 ? `${key.slice(0, 5)}...${key.slice(-3)}` : "****";
190
+ } else if (entry.ambient) {
191
+ row.maskedKey = "(ambient)";
181
192
  }
182
- statuses.push(entry);
193
+ if (entry.envVar) row.envVar = entry.envVar;
194
+ if (entry.ambient) row.ambient = true;
195
+ statuses.push(row);
183
196
  }
184
197
 
185
198
  return statuses;
186
199
  }
187
200
 
201
+ // ── Public API: status / OAuth meta / id resolution ─────────────────────────
202
+
203
+ export function getAuthStatus(): ProviderAuthStatus[] {
204
+ return _buildAuthStatus(getLatestCatalogue(), readAuthJson(), getAllHandlers());
205
+ }
206
+
188
207
  export function getOAuthProvidersMeta(): OAuthProviderMeta[] {
189
- return OAUTH_PROVIDERS;
208
+ return getAllHandlers().map((h) => ({
209
+ id: h.providerId,
210
+ name: h.displayName,
211
+ flowType: h.flowType,
212
+ }));
190
213
  }
191
214
 
192
215
  /**
193
216
  * Resolve a UI provider ID to the auth.json key.
194
- * API key providers have an `authJsonKey` mapping (e.g., "anthropic-api" → "anthropic").
195
- * OAuth providers and unknown IDs pass through unchanged.
217
+ *
218
+ * The catalogue encodes API-key rows with `<id>-api` suffix when an
219
+ * OAuth handler exists for the same id. This unwraps the suffix back
220
+ * to the underlying auth.json key. OAuth ids pass through unchanged
221
+ * (their UI id == their auth.json key). Unknown ids pass through too,
222
+ * matching the previous behavior.
196
223
  */
197
224
  export function resolveAuthJsonKey(providerId: string): string {
198
- const apiKeyProvider = API_KEY_PROVIDERS.find(p => p.id === providerId);
199
- return apiKeyProvider?.authJsonKey ?? providerId;
225
+ const oauthIds = new Set(getAllHandlers().map((h) => h.providerId));
226
+ // <id>-api suffix → strip suffix iff the bare id is an OAuth handler.
227
+ if (providerId.endsWith("-api")) {
228
+ const bare = providerId.slice(0, -"-api".length);
229
+ if (oauthIds.has(bare)) return bare;
230
+ }
231
+ return providerId;
200
232
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * In-memory cache of the most-recently-pushed provider catalogue.
3
+ *
4
+ * Each pi process pushes a `providers_list` over WS, derived from its
5
+ * `ModelRegistry`. The server caches the latest snapshot. `GET
6
+ * /api/provider-auth/status` reads `getLatestCatalogue()`.
7
+ *
8
+ * The catalogue is a property of the machine's auth + provider config,
9
+ * not of individual sessions: every bridge in the same process tree
10
+ * derives an identical catalogue from `~/.pi/agent/auth.json` +
11
+ * `~/.pi/agent/providers.json` + pi-ai's MODELS table. We therefore
12
+ * keep ONE global snapshot — the last push wins. A previous version
13
+ * kept a per-session Map plus a `changed` deep-equality gate to avoid
14
+ * spurious `models_refreshed` broadcasts; that broadcast is gone
15
+ * (see change: simplify-model-selection-channels), so the gate is
16
+ * unnecessary and the per-session split was redundant.
17
+ *
18
+ * See changes: replace-hardcoded-provider-lists,
19
+ * fix-providers-list-spurious-models-refreshed,
20
+ * simplify-model-selection-channels.
21
+ */
22
+ import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
23
+
24
+ let latest: ProviderInfo[] | null = null;
25
+
26
+ /**
27
+ * Replace the cached catalogue. Called from event-wiring.ts on every
28
+ * `providers_list` arrival. No-op semantically beyond the assignment;
29
+ * no signal to callers because no caller needs one.
30
+ */
31
+ export function setCatalogueForSession(_sessionId: string, providers: ProviderInfo[]): void {
32
+ latest = providers;
33
+ }
34
+
35
+ /**
36
+ * Most recent catalogue across any session. Returns [] when no bridge
37
+ * has pushed yet — callers should treat that as "waiting for pi" and
38
+ * may issue a `request_providers` nudge to fetch one synchronously.
39
+ */
40
+ export function getLatestCatalogue(): ProviderInfo[] {
41
+ return latest ?? [];
42
+ }
43
+
44
+ /** Test-only: reset all cached state. */
45
+ export function _resetForTests(): void {
46
+ latest = null;
47
+ }