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

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 (112) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +31 -0
  3. package/docs/architecture.md +238 -23
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  10. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  11. package/packages/extension/src/bridge.ts +110 -1
  12. package/packages/extension/src/command-handler.ts +6 -0
  13. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  14. package/packages/extension/src/prompt-expander.ts +50 -2
  15. package/packages/extension/src/provider-register.ts +117 -0
  16. package/packages/extension/src/server-launcher.ts +18 -1
  17. package/packages/extension/src/session-sync.ts +5 -0
  18. package/packages/server/package.json +4 -4
  19. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  20. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  21. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  22. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  23. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  24. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  25. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  26. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  27. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  28. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  29. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  30. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  31. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  32. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  33. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  34. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  35. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  36. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  37. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  38. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  39. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  40. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  41. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  42. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  43. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  44. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  45. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  46. package/packages/server/src/bootstrap-state.ts +18 -0
  47. package/packages/server/src/browser-gateway.ts +58 -21
  48. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  49. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  50. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  51. package/packages/server/src/cli.ts +21 -0
  52. package/packages/server/src/directory-service.ts +31 -0
  53. package/packages/server/src/event-wiring.ts +48 -2
  54. package/packages/server/src/home-lock.d.ts +124 -0
  55. package/packages/server/src/home-lock.js +330 -0
  56. package/packages/server/src/home-lock.js.map +1 -0
  57. package/packages/server/src/idle-timer.ts +15 -1
  58. package/packages/server/src/pi-core-updater.ts +65 -9
  59. package/packages/server/src/pi-gateway.ts +6 -0
  60. package/packages/server/src/process-manager.ts +62 -11
  61. package/packages/server/src/provider-auth-handlers.ts +9 -0
  62. package/packages/server/src/provider-auth-storage.ts +83 -51
  63. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  64. package/packages/server/src/routes/doctor-routes.ts +140 -0
  65. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  66. package/packages/server/src/routes/system-routes.ts +38 -1
  67. package/packages/server/src/server.ts +8 -7
  68. package/packages/server/src/session-bootstrap.ts +27 -12
  69. package/packages/server/src/session-discovery.ts +10 -3
  70. package/packages/server/src/session-scanner.ts +4 -2
  71. package/packages/server/src/spawn-failure-log.ts +130 -0
  72. package/packages/server/src/spawn-preflight.ts +82 -0
  73. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  74. package/packages/server/src/terminal-manager.ts +12 -1
  75. package/packages/shared/package.json +1 -1
  76. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  77. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  78. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  79. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  80. package/packages/shared/src/__tests__/config.test.ts +48 -0
  81. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  82. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  83. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  84. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  85. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  86. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  87. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  88. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  89. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  90. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  91. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  93. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  94. package/packages/shared/src/bootstrap-install.ts +196 -2
  95. package/packages/shared/src/browser-protocol.ts +112 -1
  96. package/packages/shared/src/config.ts +15 -0
  97. package/packages/shared/src/dashboard-starter.ts +33 -0
  98. package/packages/shared/src/doctor-core.ts +821 -0
  99. package/packages/shared/src/index.ts +9 -0
  100. package/packages/shared/src/installable-list.ts +152 -0
  101. package/packages/shared/src/launch-source-flag.ts +14 -0
  102. package/packages/shared/src/launch-source-types.ts +18 -0
  103. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  104. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  105. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  106. package/packages/shared/src/protocol.ts +46 -2
  107. package/packages/shared/src/rest-api.ts +4 -0
  108. package/packages/shared/src/skill-block-parser.ts +115 -0
  109. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  110. package/packages/shared/src/tool-registry/definitions.ts +18 -5
  111. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  112. package/packages/shared/src/types.ts +57 -0
@@ -11,6 +11,8 @@ import path from "node:path";
11
11
  import os from "node:os";
12
12
  import { existsSync } from "node:fs";
13
13
  import type { PiCorePackage, PiCoreUpdateResult } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
14
+ import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
15
+ import { prependManagedNodeToPath } from "@blackbelt-technology/pi-dashboard-shared/platform/managed-node-path.js";
14
16
  import type { PackageManagerWrapper } from "./package-manager-wrapper.js";
15
17
 
16
18
  const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 min per package
@@ -33,10 +35,44 @@ export interface PiCoreUpdaterOptions {
33
35
  onAllComplete?: () => Promise<number>;
34
36
  }
35
37
 
36
- /** Default npm-update runner. */
37
- function defaultRunNpmUpdate(
38
+ /**
39
+ * Test seams for `defaultRunNpmUpdate`. Production callers omit
40
+ * `_seams`; tests inject fakes to avoid real spawns.
41
+ *
42
+ * `_resolveNpm` defaults to `getDefaultRegistry().resolveExecutor("npm")`.
43
+ * `_spawn` defaults to `node:child_process` `spawn`.
44
+ * `_envBuilder` defaults to `prependManagedNodeToPath(process.env)`.
45
+ */
46
+ export interface DefaultRunNpmUpdateSeams {
47
+ _resolveNpm?: () =>
48
+ | { ok: true; argv: string[] }
49
+ | { ok: false; reason: string };
50
+ _spawn?: typeof spawn;
51
+ _envBuilder?: () => NodeJS.ProcessEnv;
52
+ }
53
+
54
+ /**
55
+ * Default npm-update runner.
56
+ *
57
+ * After change `embed-managed-node-runtime`:
58
+ * - Resolves the `npm` binary via `ToolRegistry.resolve("npm")` so
59
+ * the managed-Node runtime (when installed) is preferred over the
60
+ * system PATH — the user-visible regression class this change
61
+ * exists to prevent (`npm update exited with code 1` on a fresh
62
+ * Windows install with no system Node).
63
+ * - Refuses to spawn a bare `"npm"` if the registry can't resolve
64
+ * it. Surfaces a clear `npm` unresolved error per the spec
65
+ * scenario "ToolRegistry resolution failure surfaces a clear
66
+ * error".
67
+ * - Prepends the managed Node directory to the spawned child's
68
+ * `PATH` via `prependManagedNodeToPath`, so any nested `node` /
69
+ * `npm` invocation inside the npm subprocess also resolves to the
70
+ * managed runtime.
71
+ */
72
+ export function defaultRunNpmUpdate(
38
73
  pkg: PiCorePackage,
39
74
  onOutput: (line: string) => void,
75
+ seams: DefaultRunNpmUpdateSeams = {},
40
76
  ): Promise<void> {
41
77
  return new Promise((resolve, reject) => {
42
78
  const args =
@@ -50,15 +86,35 @@ function defaultRunNpmUpdate(
50
86
  return;
51
87
  }
52
88
 
53
- // On Windows, system npm is npm.cmd (batch wrapper) spawn("npm")
54
- // without the .cmd extension fails with ENOENT. shell:true routes
55
- // the invocation through cmd.exe which resolves via PATHEXT.
56
- // See change: route-kill-paths-through-platform (same class of bug).
57
- const child = spawn("npm", args, {
89
+ // Resolve npm via ToolRegistry: managed runtime > override > PATH.
90
+ // On unresolved, refuse do not fall back to bare spawn("npm").
91
+ const resolveNpm =
92
+ seams._resolveNpm ??
93
+ (() => {
94
+ const r = getDefaultRegistry().resolveExecutor("npm");
95
+ return r.ok && r.path
96
+ ? { ok: true as const, argv: r.argv }
97
+ : { ok: false as const, reason: "no override, no managed runtime, no npm on PATH" };
98
+ });
99
+ const npmRes = resolveNpm();
100
+ if (!npmRes.ok) {
101
+ reject(new Error(
102
+ `npm could not be resolved (${npmRes.reason}). ` +
103
+ "Install Node.js or run `pi-dashboard repair` to restore the managed Node runtime.",
104
+ ));
105
+ return;
106
+ }
107
+
108
+ // `argv` is ready-to-spawn: on Windows + an npm-cli.js resolution
109
+ // it is `[node.exe, npm-cli.js]` (bypasses the .cmd shim and the
110
+ // cmd.exe console flash); elsewhere it is `[npm]`.
111
+ const [cmd, ...argvPrefix] = npmRes.argv;
112
+ const spawnFn = seams._spawn ?? spawn;
113
+ const envFn = seams._envBuilder ?? (() => prependManagedNodeToPath(process.env));
114
+ const child = spawnFn(cmd, [...argvPrefix, ...args], {
58
115
  cwd,
59
116
  stdio: ["ignore", "pipe", "pipe"],
60
- env: process.env,
61
- shell: process.platform === "win32", // platform-branch-ok: shell:true required on Windows so PATHEXT resolves npm.cmd (spawn('npm') without .cmd ENOENTs)
117
+ env: envFn(),
62
118
  windowsHide: true,
63
119
  });
64
120
 
@@ -5,6 +5,7 @@ import { WebSocketServer, WebSocket } from "ws";
5
5
  import type { ExtensionToServerMessage, ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
6
6
  import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
7
7
  import type { SessionManager } from "./memory-session-manager.js";
8
+ import { getSpawnRegisterWatchdog } from "./spawn-register-watchdog.js";
8
9
 
9
10
  export const HEARTBEAT_TIMEOUT = 180_000;
10
11
  export const WS_PING_INTERVAL = 60_000;
@@ -273,6 +274,11 @@ export function createPiGateway(
273
274
  }
274
275
 
275
276
  if (msg.type === "session_register") {
277
+ // Clear spawn-register watchdog BEFORE any throwing logic. See change: spawn-failure-diagnostics.
278
+ const watchdog = getSpawnRegisterWatchdog();
279
+ if (msg.pid !== undefined) watchdog.clearByPid(msg.pid);
280
+ watchdog.clearByCwd(msg.cwd);
281
+
276
282
  // If session ID changed (e.g., after /reload), clean up the old placeholder
277
283
  if (currentSessionId && currentSessionId !== msg.sessionId) {
278
284
  const oldSession = sessionManager.get(currentSessionId);
@@ -15,13 +15,14 @@
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";
25
26
  import { execSync, spawnSync, buildSafeArgv } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
26
27
  import {
27
28
  spawnDetached,
@@ -34,6 +35,7 @@ import {
34
35
  type SpawnMechanism,
35
36
  type UserSpawnStrategy,
36
37
  } from "@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js";
38
+ import type { SpawnFailureCode } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
37
39
 
38
40
  // ── Resolver seam (injectable for tests) ────────────────────────────────────
39
41
 
@@ -64,11 +66,29 @@ export interface SpawnResult {
64
66
  process?: ChildProcess;
65
67
  /** True when spawned from the dashboard (for writing session meta) */
66
68
  dashboardSpawned?: boolean;
69
+ /** Structured failure classifier. Set on every { success: false } path. See change: spawn-failure-diagnostics. */
70
+ code?: SpawnFailureCode;
71
+ /** Tail of pi's stderr log (Windows headless PI_CRASHED only). See change: spawn-failure-diagnostics. */
72
+ stderr?: string;
73
+ /** Path to the per-session stderr log (Windows headless). Forwarded to watchdog. See change: spawn-failure-diagnostics. */
74
+ logPath?: string;
67
75
  }
68
76
 
69
- /** Build env with managed install bin + current node binary dir prepended to PATH. */
77
+ /**
78
+ * Build env for pi-session spawns.
79
+ *
80
+ * Order of PATH prepends (highest priority first):
81
+ * 1. Managed Node runtime (`<managedDir>/node/{bin,}`) when installed.
82
+ * See change: embed-managed-node-runtime.
83
+ * 2. Managed bin (`<managedDir>/node_modules/.bin`).
84
+ * 3. Current Node binary dir, extra bin dirs, common user bin dirs.
85
+ *
86
+ * The managed-Node prepend happens AFTER the resolver's prepends so it
87
+ * lands at the very head of `PATH` — spawned children invoking plain
88
+ * `node` / `npm` resolve to the managed runtime first.
89
+ */
70
90
  export function buildSpawnEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
71
- return resolver.buildSpawnEnv(baseEnv);
91
+ return prependManagedNodeToPath(resolver.buildSpawnEnv(baseEnv));
72
92
  }
73
93
 
74
94
  /**
@@ -256,7 +276,7 @@ export async function spawnPiSession(
256
276
  options?: SessionOptions & { electronMode?: boolean },
257
277
  ): Promise<SpawnResult> {
258
278
  if (!existsSync(cwd)) {
259
- return { success: false, message: `Directory does not exist: ${cwd}` };
279
+ return { success: false, code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` };
260
280
  }
261
281
 
262
282
  const mechanism = chooseMechanism(options, options?.electronMode ?? false);
@@ -282,7 +302,7 @@ function spawnTmux(cwd: string, options?: SessionOptions): SpawnResult {
282
302
  message: `Pi session spawned in tmux (${exists ? "new window" : "new session"})`,
283
303
  };
284
304
  } catch (err: any) {
285
- return { success: false, message: `Failed to spawn session: ${err.message}` };
305
+ return { success: false, code: "TMUX_MISSING", message: `Failed to spawn session: ${err.message}` };
286
306
  }
287
307
  }
288
308
 
@@ -292,18 +312,18 @@ function spawnWslTmux(cwd: string, options?: SessionOptions): SpawnResult {
292
312
  execSync(cmd, { stdio: "ignore" });
293
313
  return { success: true, dashboardSpawned: true, message: "Pi session spawned via WSL tmux" };
294
314
  } catch (err: any) {
295
- return { success: false, message: `Failed to spawn via WSL tmux: ${err.message}` };
315
+ return { success: false, code: "TMUX_MISSING", message: `Failed to spawn via WSL tmux (wsl-tmux mechanism): ${err.message}` };
296
316
  }
297
317
  }
298
318
 
299
319
  async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
300
320
  const wt = resolver.which("wt");
301
321
  if (!wt) {
302
- return { success: false, message: "Windows Terminal (wt.exe) not found" };
322
+ return { success: false, code: "WT_MISSING", message: "Windows Terminal (wt.exe) not found" };
303
323
  }
304
324
  const piCmd = resolvePiCommand();
305
325
  if (!piCmd) {
306
- return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
326
+ return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
307
327
  }
308
328
 
309
329
  const piArgv = [...piCmd, ...buildInteractivePiArgs(options)];
@@ -317,7 +337,7 @@ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResu
317
337
  });
318
338
 
319
339
  if (!r.ok) {
320
- return { success: false, message: `Failed to launch Windows Terminal: ${r.error}` };
340
+ return { success: false, code: "SPAWN_ERRNO", message: `Failed to launch Windows Terminal: ${r.error}` };
321
341
  }
322
342
 
323
343
  return {
@@ -334,7 +354,7 @@ async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<Spa
334
354
  const env = buildSpawnEnv();
335
355
  const piCmd = resolvePiCommand();
336
356
  if (!piCmd) {
337
- return { success: false, message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
357
+ return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
338
358
  }
339
359
  const [bin, ...prefixArgs] = piCmd;
340
360
 
@@ -356,7 +376,7 @@ async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<Spa
356
376
  env,
357
377
  });
358
378
  if (!r.ok) {
359
- return { success: false, message: `Failed to spawn headless (Unix): ${r.error}` };
379
+ return { success: false, code: "SPAWN_ERRNO", message: `Failed to spawn headless (Unix): ${r.error}` };
360
380
  }
361
381
  return {
362
382
  success: true,
@@ -397,6 +417,7 @@ async function spawnHeadlessDetached(
397
417
  if (bin.toLowerCase().endsWith(".cmd") || bin.toLowerCase().endsWith(".bat")) {
398
418
  return {
399
419
  success: false,
420
+ code: "WIN_PI_CMD_ONLY",
400
421
  message:
401
422
  "Windows pi spawn requires node.exe + cli.js (managed install). " +
402
423
  "Found only pi.cmd on PATH. Run the dashboard setup wizard or " +
@@ -465,6 +486,8 @@ async function spawnHeadlessDetached(
465
486
  if (!r.ok || !r.process || !r.pid) {
466
487
  return {
467
488
  success: false,
489
+ code: "SPAWN_ERRNO",
490
+ logPath: logFd !== undefined ? logPath : undefined,
468
491
  message: `Failed to spawn pi: ${r.error ?? "unknown error"}. Command: ${cmdForLog}`,
469
492
  };
470
493
  }
@@ -473,8 +496,16 @@ async function spawnHeadlessDetached(
473
496
  // but still catch immediate crashes (missing modules, config errors).
474
497
  const gate = await waitForNoCrash({ child: r.process, windowMs: 300 });
475
498
  if (!gate.ok) {
499
+ // Read last 4 KB of stderr log for diagnostic forwarding. See change: spawn-failure-diagnostics.
500
+ let stderrTail: string | undefined;
501
+ if (logFd !== undefined) {
502
+ stderrTail = readLogTail(logPath);
503
+ }
476
504
  return {
477
505
  success: false,
506
+ code: "PI_CRASHED",
507
+ logPath: logFd !== undefined ? logPath : undefined,
508
+ stderr: stderrTail,
478
509
  message:
479
510
  `Pi process exited immediately (code ${gate.exitCode}). ` +
480
511
  `See ${logPath} for details.\nCommand: ${cmdForLog}`,
@@ -487,5 +518,25 @@ async function spawnHeadlessDetached(
487
518
  message: `Pi session spawned headless (pid ${r.pid})`,
488
519
  pid: r.pid,
489
520
  process: r.process,
521
+ logPath: logFd !== undefined ? logPath : undefined,
490
522
  };
491
523
  }
524
+
525
+ /**
526
+ * Read last `maxBytes` bytes of `filePath`, stripping leading UTF-8 continuation bytes.
527
+ * Returns `undefined` on any error or if file is empty.
528
+ * See change: spawn-failure-diagnostics.
529
+ */
530
+ function readLogTail(filePath: string, maxBytes = 4096): string | undefined {
531
+ try {
532
+ const buf = readFileSync(filePath);
533
+ if (!buf.length) return undefined;
534
+ const slice = buf.length <= maxBytes ? buf : buf.slice(buf.length - maxBytes);
535
+ // Strip leading UTF-8 continuation bytes (0x80..0xBF)
536
+ let start = 0;
537
+ while (start < slice.length && (slice[start]! & 0xC0) === 0x80) start++;
538
+ return slice.slice(start).toString("utf-8");
539
+ } catch {
540
+ return undefined;
541
+ }
542
+ }
@@ -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,41 @@
1
+ /**
2
+ * In-memory cache of provider catalogues pushed by bridges.
3
+ *
4
+ * Each pi process pushes a `providers_list` over WS, derived from its
5
+ * `ModelRegistry`. The server caches per-session and tracks the most-recent
6
+ * snapshot. `GET /api/provider-auth/status` reads `getLatestCatalogue()`.
7
+ *
8
+ * See change: replace-hardcoded-provider-lists.
9
+ */
10
+ import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
11
+
12
+ const bySession = new Map<string, ProviderInfo[]>();
13
+ let latest: ProviderInfo[] | null = null;
14
+
15
+ export function setCatalogueForSession(sessionId: string, providers: ProviderInfo[]): void {
16
+ bySession.set(sessionId, providers);
17
+ latest = providers;
18
+ }
19
+
20
+ export function getCatalogueForSession(sessionId: string): ProviderInfo[] | undefined {
21
+ return bySession.get(sessionId);
22
+ }
23
+
24
+ /**
25
+ * Most recent catalogue across any session. Returns [] when no bridge
26
+ * has pushed yet — callers should treat that as "waiting for pi".
27
+ */
28
+ export function getLatestCatalogue(): ProviderInfo[] {
29
+ return latest ?? [];
30
+ }
31
+
32
+ export function clearForSession(sessionId: string): void {
33
+ bySession.delete(sessionId);
34
+ if (bySession.size === 0) latest = null;
35
+ }
36
+
37
+ /** Test-only: reset all cached state. */
38
+ export function _resetForTests(): void {
39
+ bySession.clear();
40
+ latest = null;
41
+ }