@blackbelt-technology/pi-agent-dashboard 0.5.1 → 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 (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -6
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -19,6 +19,7 @@ import {
19
19
  findPidByMarker,
20
20
  } from "@blackbelt-technology/pi-dashboard-shared/platform/process-identify.js";
21
21
  import { shouldInterceptReload } from "./session-action-helpers.js";
22
+ import { keeperOptsFromSpawnResult } from "../headless-pid-registry.js";
22
23
 
23
24
  /**
24
25
  * Status message + code emitted when fork is attempted on a session whose
@@ -176,7 +177,13 @@ export async function handleHeadlessReload(
176
177
  }
177
178
 
178
179
  if (spawnResult.pid && spawnResult.process) {
179
- headlessPidRegistry.register(spawnResult.pid, session.cwd, spawnResult.process);
180
+ headlessPidRegistry.register(
181
+ spawnResult.pid,
182
+ session.cwd,
183
+ spawnResult.process,
184
+ spawnResult.spawnToken,
185
+ keeperOptsFromSpawnResult(spawnResult),
186
+ );
180
187
  }
181
188
 
182
189
  emitCommandFeedback(ctx, msg.sessionId, "completed");
@@ -235,7 +242,13 @@ export async function handleSendPrompt(
235
242
  pendingDashboardSpawns?.set(promptSession.cwd, (pendingDashboardSpawns?.get(promptSession.cwd) ?? 0) + 1);
236
243
  }
237
244
  if (spawnResult.process && spawnResult.pid) {
238
- headlessPidRegistry.register(spawnResult.pid, promptSession.cwd, spawnResult.process);
245
+ headlessPidRegistry.register(
246
+ spawnResult.pid,
247
+ promptSession.cwd,
248
+ spawnResult.process,
249
+ spawnResult.spawnToken,
250
+ keeperOptsFromSpawnResult(spawnResult),
251
+ );
239
252
  }
240
253
  } else {
241
254
  const sent = piGateway.sendToSession(msg.sessionId, {
@@ -303,6 +316,7 @@ export async function handleResumeSession(
303
316
  session.cwd,
304
317
  degradeResult.process,
305
318
  degradeResult.spawnToken,
319
+ keeperOptsFromSpawnResult(degradeResult),
306
320
  );
307
321
  }
308
322
  if (msg.requestId && degradeResult.spawnToken && pendingClientCorrelations) {
@@ -364,7 +378,13 @@ export async function handleResumeSession(
364
378
  pendingDashboardSpawns?.set(session.cwd, (pendingDashboardSpawns?.get(session.cwd) ?? 0) + 1);
365
379
  }
366
380
  if (result.process && result.pid) {
367
- headlessPidRegistry.register(result.pid, session.cwd, result.process, result.spawnToken);
381
+ headlessPidRegistry.register(
382
+ result.pid,
383
+ session.cwd,
384
+ result.process,
385
+ result.spawnToken,
386
+ keeperOptsFromSpawnResult(result),
387
+ );
368
388
  }
369
389
  sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: result.success, message: result.message, requestId: msg.requestId });
370
390
  }
@@ -412,7 +432,13 @@ export async function handleSpawnSession(
412
432
  try {
413
433
  const spawnResult = await spawnPiSession(msg.cwd, { strategy });
414
434
  if (spawnResult.process && spawnResult.pid) {
415
- headlessPidRegistry.register(spawnResult.pid, msg.cwd, spawnResult.process, spawnResult.spawnToken);
435
+ headlessPidRegistry.register(
436
+ spawnResult.pid,
437
+ msg.cwd,
438
+ spawnResult.process,
439
+ spawnResult.spawnToken,
440
+ keeperOptsFromSpawnResult(spawnResult),
441
+ );
416
442
  }
417
443
  // Record client-correlation so the eventual session_added carries
418
444
  // spawnRequestId. See change: spawn-correlation-token.
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node --import tsx
1
+ #!/usr/bin/env node
2
2
  /**
3
3
  * PI Dashboard Server CLI
4
4
  *
@@ -17,11 +17,13 @@
17
17
  */
18
18
  import { createServer, type ServerConfig } from "./server.js";
19
19
  import { loadConfig, ensureConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
20
- import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
21
- import { spawnNodeScript } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
22
- import { createRequire } from "node:module";
23
- import { fileURLToPath, pathToFileURL } from "node:url";
24
- import fs from "node:fs";
20
+ import {
21
+ launchDashboardServer,
22
+ JitiNotFoundError,
23
+ PortConflictError,
24
+ EarlyExitError,
25
+ } from "@blackbelt-technology/pi-dashboard-shared/server-launcher.js";
26
+ import { fileURLToPath } from "node:url";
25
27
  import os from "node:os";
26
28
  import path from "node:path";
27
29
  import { readPid, removePid, isServerRunning } from "./server-pid.js";
@@ -42,7 +44,7 @@ export function findPortHolders(
42
44
  }
43
45
  import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
44
46
  import { discoverDashboard } from "@blackbelt-technology/pi-dashboard-shared/mdns-discovery.js";
45
- import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
47
+
46
48
  import { assertNodeVersionSupported } from "./node-guard.js";
47
49
  import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
48
50
  import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
@@ -141,6 +143,7 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
141
143
  shutdownIdleSeconds: fileConfig.shutdownIdleSeconds,
142
144
  tunnel: flags.tunnel ?? fileConfig.tunnel.enabled,
143
145
  tunnelReservedToken: fileConfig.tunnel.reservedToken,
146
+ tunnelWatchdog: fileConfig.tunnel.watchdog,
144
147
  authConfig: fileConfig.auth,
145
148
  maxEventsPerSession: fileConfig.memoryLimits.maxEventsPerSession,
146
149
  maxStringFieldSize: fileConfig.memoryLimits.maxStringFieldSize,
@@ -252,7 +255,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
252
255
  return;
253
256
  }
254
257
 
255
- const installPackages = ["@earendil-works/pi-coding-agent", "@fission-ai/openspec", "tsx"];
258
+ const installPackages = ["@earendil-works/pi-coding-agent", "@fission-ai/openspec"];
256
259
  server.bootstrapState.setLastInstallPackages(installPackages);
257
260
  console.log("[bootstrap] installing (pi unresolved, running background install)");
258
261
  server.bootstrapState.set({
@@ -351,7 +354,10 @@ async function cmdStart(config: ServerConfig): Promise<void> {
351
354
  process.exit(1);
352
355
  }
353
356
 
354
- // Spawn ourselves in foreground mode (no subcommand) as a detached process
357
+ // Spawn ourselves in foreground mode (no subcommand) as a detached process.
358
+ // All concerns below — jiti loader resolution, --import argv URL-wrapping,
359
+ // env merge, log-file header, readiness polling, port-conflict / early-exit
360
+ // detection — are owned by the shared `launchDashboardServer` primitive.
355
361
  const cliPath = fileURLToPath(import.meta.url);
356
362
  const args: string[] = [];
357
363
  if (config.port !== 8000) args.push("--port", String(config.port));
@@ -359,83 +365,39 @@ async function cmdStart(config: ServerConfig): Promise<void> {
359
365
  if (config.dev) args.push("--dev");
360
366
  if (!config.tunnel) args.push("--no-tunnel");
361
367
 
362
- let tsLoader: string;
363
- try {
364
- tsLoader = resolveJitiImport();
365
- } catch {
366
- // Fallback to tsx when jiti is not available (e.g. running outside pi).
367
- // The loader is passed to `node --import`; on Windows, Node >= 20 rejects
368
- // raw absolute paths with a drive letter (parsed as URL scheme), so we
369
- // return a file:// URL. See change: fix-windows-server-parity.
370
- try {
371
- const tsxMain = createRequire(cliPath).resolve("tsx");
372
- const tsxLoaderPath = path.join(path.dirname(tsxMain), "esm", "index.mjs");
373
- tsLoader = pathToFileURL(tsxLoaderPath).href;
374
- } catch {
375
- console.error(
376
- "[pi-dashboard] Cannot find TypeScript loader. " +
377
- "Install tsx (`npm install`) or run inside a pi session."
378
- );
379
- process.exit(1);
380
- }
381
- }
382
-
383
- // Redirect daemon stdout/stderr to a log file for crash diagnosis.
384
- // Log is opened in append mode ("a") so output from prior start attempts
385
- // is preserved across retries — critical for diagnosing intermittent or
386
- // silent launch failures. A timestamped header line distinguishes runs.
387
- // See change: fix-windows-server-parity.
388
368
  const logDir = path.join(os.homedir(), ".pi", "dashboard");
389
- fs.mkdirSync(logDir, { recursive: true });
390
369
  const logPath = path.join(logDir, "server.log");
391
- const logFd = fs.openSync(logPath, "a");
392
- fs.writeSync(
393
- logFd,
394
- `\n[${new Date().toISOString()}] pi-dashboard start (parent pid ${process.pid}, port ${config.port})\n`,
395
- );
396
-
397
- // Both tsLoader and cliPath are wrapped as file:// URLs by spawnNodeScript.
398
- // Required on Windows for node --import (see change: fix-windows-entry-script-url).
399
- const child = spawnNodeScript({
400
- loader: tsLoader,
401
- entry: cliPath,
402
- args,
403
- spawnOptions: {
404
- detached: true,
405
- stdio: ["ignore", logFd, logFd],
370
+
371
+ try {
372
+ const result = await launchDashboardServer({
373
+ cliPath,
374
+ extraArgs: args,
375
+ stdio: { logFile: logPath },
376
+ starter: "Standalone",
377
+ healthTimeoutMs: 30_000,
378
+ port: config.port,
406
379
  env: { ...process.env },
407
- },
408
- });
409
- child.unref();
410
- // Close the parent's copy of the fd — child has its own via stdio inheritance.
411
- try { fs.closeSync(logFd); } catch { /* ignore */ }
412
-
413
- // Wait for dashboard to become available. Windows + jiti cold-start can
414
- // take 10s+ (TS compile on first boot, native module loads). 30s is the
415
- // outer bound — if the server isn't up by then, something's genuinely wrong.
416
- const READINESS_TIMEOUT_MS = 30_000;
417
- const deadline = Date.now() + READINESS_TIMEOUT_MS;
418
- let started = false;
419
- while (Date.now() < deadline) {
420
- // Also bail if the child has already exited (fast-path crash detection).
421
- if (child.exitCode !== null) break;
422
- await new Promise((r) => setTimeout(r, 300));
423
- const status = await isDashboardRunning(config.port);
424
- if (status.running) {
425
- started = true;
426
- break;
380
+ });
381
+ const reportedPid = result.reportedPid ?? readPid() ?? result.childPid;
382
+ console.log(`Dashboard server started (pid ${reportedPid}) at http://localhost:${config.port}`);
383
+ } catch (err: unknown) {
384
+ if (err instanceof JitiNotFoundError) {
385
+ console.error(`[pi-dashboard] ${err.message}`);
386
+ process.exit(1);
427
387
  }
428
- }
429
-
430
- if (started) {
431
- const pid = readPid();
432
- console.log(`Dashboard server started (pid ${pid ?? child.pid}) at http://localhost:${config.port}`);
433
- } else {
434
- const reason = child.exitCode !== null
435
- ? `child process exited with code ${child.exitCode}`
436
- : `timed out after ${READINESS_TIMEOUT_MS / 1000}s`;
388
+ if (err instanceof PortConflictError) {
389
+ console.error(`Port ${err.port} is occupied by another service (not the dashboard).`);
390
+ console.error(`Change the port in ~/.pi/dashboard/config.json or use --port <n>`);
391
+ process.exit(1);
392
+ }
393
+ if (err instanceof EarlyExitError) {
394
+ console.error(`Failed to start dashboard server (child process exited with code ${err.code})`);
395
+ console.error(`Check logs at ${logPath}`);
396
+ process.exit(1);
397
+ }
398
+ const reason = err instanceof Error ? err.message : String(err);
437
399
  console.error(`Failed to start dashboard server (${reason})`);
438
- console.error(`Check logs at ${path.join(logDir, "server.log")}`);
400
+ console.error(`Check logs at ${logPath}`);
439
401
  process.exit(1);
440
402
  }
441
403
  }
@@ -666,7 +628,25 @@ async function cmdStatus(port: number): Promise<void> {
666
628
  console.log(`Dashboard server is running (pid ${pid}) on port ${port}`);
667
629
  }
668
630
 
631
+ /**
632
+ * Install process-level safety net so a single misbehaving plugin or
633
+ * library cannot kill the whole dashboard. Logs the offending error and
634
+ * keeps the event loop running. We do NOT exit; the surrounding daemon
635
+ * harness already restarts on real crashes (signal/exit-code), and
636
+ * silently swallowing recoverable async faults is the lesser evil here.
637
+ */
638
+ function installCrashSafetyNet(): void {
639
+ process.on("unhandledRejection", (reason: unknown) => {
640
+ const err = reason instanceof Error ? reason : new Error(String(reason));
641
+ console.error("[crash-safety] unhandledRejection (suppressed):", err.stack || err.message);
642
+ });
643
+ process.on("uncaughtException", (err: Error) => {
644
+ console.error("[crash-safety] uncaughtException (suppressed):", err.stack || err.message);
645
+ });
646
+ }
647
+
669
648
  async function main() {
649
+ installCrashSafetyNet();
670
650
  ensureConfig();
671
651
 
672
652
  const { subcommand, flags } = parseArgs(process.argv.slice(2));
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import os from "node:os";
7
7
  import { loadConfig, type DashboardConfig, type AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
+ import { refreshModelRegistry } from "./model-proxy/registry-singleton.js";
8
9
 
9
10
  const REDACTED = "***";
10
11
 
@@ -114,9 +115,17 @@ export function writeConfigPartial(partial: Record<string, any>): WriteConfigRes
114
115
  partial.auth = mergedAuth;
115
116
  }
116
117
 
117
- // Merge tunnel sub-object
118
+ // Merge tunnel sub-object (deep-merge nested watchdog)
118
119
  if (partial.tunnel) {
119
- partial.tunnel = { ...existing.tunnel, ...partial.tunnel };
120
+ const existingTunnel = existing.tunnel ?? {};
121
+ const mergedWatchdog = partial.tunnel.watchdog
122
+ ? { ...(existingTunnel.watchdog ?? {}), ...partial.tunnel.watchdog }
123
+ : existingTunnel.watchdog;
124
+ partial.tunnel = {
125
+ ...existingTunnel,
126
+ ...partial.tunnel,
127
+ ...(mergedWatchdog ? { watchdog: mergedWatchdog } : {}),
128
+ };
120
129
  }
121
130
 
122
131
  // Merge memoryLimits sub-object
@@ -139,6 +148,9 @@ export function writeConfigPartial(partial: Record<string, any>): WriteConfigRes
139
148
  fs.mkdirSync(dir, { recursive: true });
140
149
  fs.writeFileSync(file, JSON.stringify(merged, null, 2) + "\n");
141
150
 
151
+ // Eager-refresh model proxy registry (config may affect proxy settings).
152
+ refreshModelRegistry().catch(() => {});
153
+
142
154
  return { success: true, restartRequired };
143
155
  } catch (err: any) {
144
156
  return { success: false, restartRequired: false, error: err.message };
@@ -76,6 +76,23 @@ export function hasOpenSpecDir(cwd: string): boolean {
76
76
  }
77
77
  }
78
78
 
79
+ /**
80
+ * `true` iff `<cwd>/openspec/` exists and is a directory. Strictly weaker than
81
+ * `hasOpenSpecDir` (which also requires the `changes/` subdir). Used by the
82
+ * WS on-connect snapshot to emit the new `hasOpenspecDir` field on every
83
+ * payload so freshly-initialized projects without `changes/` yet still surface
84
+ * the OPENSPEC subcard as an init/attach affordance on the client.
85
+ *
86
+ * See change: auto-hide-empty-session-subcards.
87
+ */
88
+ export function hasOpenSpecRoot(cwd: string): boolean {
89
+ try {
90
+ return fs.statSync(path.join(cwd, "openspec")).isDirectory();
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
79
96
  export interface DirectoryService {
80
97
  knownDirectories(): string[];
81
98
  discoverSessions(cwd: string): DiscoveredSession[];
@@ -199,12 +216,25 @@ function emptyDirCache(): DirCache {
199
216
  return { listMtimeMs: undefined, listResult: undefined, changes: new Map(), data: undefined };
200
217
  }
201
218
 
219
+ export interface DirectoryServiceOptions {
220
+ /**
221
+ * Optional async post-processor applied to `OpenSpecData` after
222
+ * `buildOpenSpecData` and before caching. Used to inject the per-cwd
223
+ * `groupId` join from the OpenSpec change-grouping store.
224
+ * Errors propagate as a logged warning + the unenriched data.
225
+ * See change: add-openspec-change-grouping.
226
+ */
227
+ enrichOpenSpecData?: (cwd: string, data: OpenSpecData) => Promise<OpenSpecData> | OpenSpecData;
228
+ }
229
+
202
230
  export function createDirectoryService(
203
231
  preferencesStore: PreferencesStore,
204
232
  sessionManager: SessionManager,
205
233
  initialConfig?: Partial<OpenSpecPollConfig>,
234
+ options: DirectoryServiceOptions = {},
206
235
  ): DirectoryService {
207
236
  let cfg: OpenSpecPollConfig = { ...DEFAULT_OPENSPEC_POLL, ...(initialConfig ?? {}) };
237
+ const enrichOpenSpecData = options.enrichOpenSpecData;
208
238
 
209
239
  const caches = new Map<string, DirCache>();
210
240
  const piResourcesCache = new Map<string, PiResourcesResult>();
@@ -261,12 +291,22 @@ export function createDirectoryService(
261
291
  const cache = caches.get(cwd) ?? emptyDirCache();
262
292
  const gateEnabled = cfg.changeDetection === "mtime" && !force;
263
293
 
294
+ const openspecRoot = path.join(cwd, "openspec");
264
295
  const changesRoot = path.join(cwd, "openspec", "changes");
296
+ // `hasOpenspecDir` is strictly weaker than `initialized`: it's true when
297
+ // the project is OpenSpec-initialized (`openspec/` dir exists) even if no
298
+ // proposals are authored yet (no `openspec/changes/` subdir). The session
299
+ // card visibility gate uses this signal so a fresh `openspec init` project
300
+ // still shows the OPENSPEC subcard as an init/attach affordance.
301
+ // See change: auto-hide-empty-session-subcards.
302
+ const hasOpenspecDir = statMtimeOr(openspecRoot) !== undefined;
265
303
  const rootMtime = statMtimeOr(changesRoot);
266
304
 
267
- // If the directory doesn't exist, short-circuit (matches old behavior).
305
+ // If the changes/ subdirectory doesn't exist, short-circuit (matches old
306
+ // behavior re: list polling). `hasOpenspecDir` still carries the broader
307
+ // "is this an OpenSpec project?" signal for the client.
268
308
  if (rootMtime === undefined) {
269
- const empty: OpenSpecData = { initialized: false, changes: [] };
309
+ const empty: OpenSpecData = { initialized: false, changes: [], hasOpenspecDir };
270
310
  cache.data = empty;
271
311
  cache.listMtimeMs = undefined;
272
312
  cache.listResult = undefined;
@@ -294,7 +334,7 @@ export function createDirectoryService(
294
334
  if (!listCacheValid) {
295
335
  const raw = await semaphore.run(() => runOpenSpecList(cwd));
296
336
  if (!raw || !Array.isArray(raw.changes)) {
297
- const empty: OpenSpecData = { initialized: false, changes: [] };
337
+ const empty: OpenSpecData = { initialized: false, changes: [], hasOpenspecDir };
298
338
  cache.data = empty;
299
339
  cache.listMtimeMs = rootMtime;
300
340
  cache.listResult = undefined;
@@ -377,12 +417,30 @@ export function createDirectoryService(
377
417
  }));
378
418
 
379
419
  // ── Step 3: build + cache + return ──
380
- const data = buildOpenSpecData(
420
+ let data = buildOpenSpecData(
381
421
  { changes: listResult ?? [] },
382
422
  statusResults,
383
423
  createFsProbeFactory(cwd),
384
424
  createFsSpecsProbeFactory(cwd),
385
425
  );
426
+ // `hasOpenspecDir` is true here by definition: we only reach Step 3 when
427
+ // `<cwd>/openspec/changes/` exists, which implies `<cwd>/openspec/` exists.
428
+ // See change: auto-hide-empty-session-subcards.
429
+ data = { ...data, hasOpenspecDir };
430
+ if (enrichOpenSpecData) {
431
+ try {
432
+ data = await enrichOpenSpecData(cwd, data);
433
+ } catch (err) {
434
+ // Don\'t fail the whole poll if the enricher (e.g. group-store read)
435
+ // throws — log and continue with the unenriched data. See change:
436
+ // add-openspec-change-grouping.
437
+ // eslint-disable-next-line no-console
438
+ if (typeof process !== "undefined" && /pi-dashboard|openspec-poll/.test(process.env?.DEBUG ?? "")) {
439
+ // eslint-disable-next-line no-console
440
+ console.warn(`[directory-service] enrichOpenSpecData(${cwd}) threw:`, err);
441
+ }
442
+ }
443
+ }
386
444
 
387
445
  // Stamp the cache with the pre-call mtime — i.e. the mtime that
388
446
  // demonstrably reflects the file state observed by the CLI. Skip racy
@@ -401,6 +459,20 @@ export function createDirectoryService(
401
459
  }
402
460
 
403
461
  async function refreshOpenSpec(cwd: string): Promise<OpenSpecData> {
462
+ // Master gate: when `openspec.enabled` is false, every refresh path is a
463
+ // no-op. Return the cleared-state shape so callers (including
464
+ // `openspec_refresh` browser handler) converge to the disabled UX.
465
+ // See change: auto-hide-empty-session-subcards.
466
+ if (cfg.enabled === false) {
467
+ // Disabled state: `hasOpenspecDir: false` ensures the client wrapper
468
+ // hides the OPENSPEC subcard for every cwd. See change:
469
+ // auto-hide-empty-session-subcards.
470
+ const cleared: OpenSpecData = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
471
+ const cache = caches.get(cwd) ?? emptyDirCache();
472
+ cache.data = cleared;
473
+ caches.set(cwd, cache);
474
+ return cleared;
475
+ }
404
476
  try {
405
477
  // User-initiated refresh bypasses the gate. The gate is heuristic; the
406
478
  // CLI is authoritative. When the user clicks the OpenSpec refresh icon
@@ -422,6 +494,15 @@ export function createDirectoryService(
422
494
  }
423
495
 
424
496
  async function pollDirectoryGated(cwd: string): Promise<OpenSpecData> {
497
+ // Master gate: when `openspec.enabled` is false, never spawn a CLI for the
498
+ // periodic poll path either. See change: auto-hide-empty-session-subcards.
499
+ if (cfg.enabled === false) {
500
+ const cleared: OpenSpecData = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
501
+ const cache = caches.get(cwd) ?? emptyDirCache();
502
+ cache.data = cleared;
503
+ caches.set(cwd, cache);
504
+ return cleared;
505
+ }
425
506
  return pollOne(cwd, false);
426
507
  }
427
508
 
@@ -439,6 +520,9 @@ export function createDirectoryService(
439
520
  let openspecTickInFlight = false;
440
521
  async function scheduleOpenSpecTick() {
441
522
  if (openspecTickInFlight) return;
523
+ // Master gate: when `openspec.enabled` is false, the tick is a no-op.
524
+ // No CLI spawns. See change: auto-hide-empty-session-subcards.
525
+ if (cfg.enabled === false) return;
442
526
  openspecTickInFlight = true;
443
527
  const tickStart = Date.now();
444
528
  let spawnsBefore = 0;
@@ -542,12 +626,30 @@ export function createDirectoryService(
542
626
 
543
627
  reconfigurePolling(newCfg: OpenSpecPollConfig) {
544
628
  const oldInterval = cfg.pollIntervalSeconds;
629
+ const wasEnabled = cfg.enabled;
545
630
  cfg = { ...newCfg };
546
631
  semaphore.setMax(cfg.maxConcurrentSpawns);
547
632
  // Only re-install timers if they were running and the interval actually changed.
548
633
  if (pollTimer && oldInterval !== cfg.pollIntervalSeconds) {
549
634
  installTimers();
550
635
  }
636
+ // On the `true → false` transition, clear every per-cwd `OpenSpecData`
637
+ // cache and notify the broadcast channel so connected browsers converge
638
+ // to the disabled-state shape. The `false → true` transition is a
639
+ // no-op here — the next regular poll tick will re-populate caches.
640
+ // See change: auto-hide-empty-session-subcards.
641
+ if (wasEnabled !== false && cfg.enabled === false) {
642
+ const cleared: OpenSpecData = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
643
+ for (const [cwd, cache] of caches.entries()) {
644
+ cache.data = cleared;
645
+ caches.set(cwd, cache);
646
+ try {
647
+ onChangeCallback?.(cwd, cleared);
648
+ } catch (err) {
649
+ console.warn(`[openspec-poll] onChange after disable failed for ${cwd}:`, err);
650
+ }
651
+ }
652
+ }
551
653
  },
552
654
 
553
655
  async onDirectoryAdded(cwd: string): Promise<DirectoryAddedResult> {
@@ -19,6 +19,8 @@ import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared
19
19
  import { detectOpenSpecActivity, isValidOpenSpecChangeSlug } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
20
20
  import { extractTurnStats } from "@blackbelt-technology/pi-dashboard-shared/stats-extractor.js";
21
21
  import { attachRenameTarget, isNameAutoSetFromAttachment } from "./proposal-attach-naming.js";
22
+ import { handleDispatchExtensionCommand } from "./rpc-keeper/dispatch-router.js";
23
+ import { keeperOptsFromSpawnResult } from "./headless-pid-registry.js";
22
24
 
23
25
  export interface EventWiringDeps {
24
26
  sessionManager: SessionManager;
@@ -811,7 +813,13 @@ export function wireEvents(deps: EventWiringDeps): void {
811
813
  if (msg.type === "spawn_new_session") {
812
814
  spawnPiSession(msg.cwd, { strategy: loadConfig().spawnStrategy }).then((result) => {
813
815
  if (result.process && result.pid) {
814
- browserGateway.headlessPidRegistry.register(result.pid, msg.cwd, result.process);
816
+ browserGateway.headlessPidRegistry.register(
817
+ result.pid,
818
+ msg.cwd,
819
+ result.process,
820
+ result.spawnToken,
821
+ keeperOptsFromSpawnResult(result),
822
+ );
815
823
  }
816
824
  browserGateway.broadcastToAll({
817
825
  type: "spawn_result",
@@ -862,5 +870,27 @@ export function wireEvents(deps: EventWiringDeps): void {
862
870
  });
863
871
  }
864
872
 
873
+ // RPC keeper dispatch: bridge → server slash command forward.
874
+ // Fire-and-forget; the handler itself emits browser-bound
875
+ // `command_feedback` events on success and on every failure path.
876
+ // The terminal event is persisted via eventStore.insertEvent so it
877
+ // survives browser reattach (otherwise the chat pill stays "in progress").
878
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 8).
879
+ if (msg.type === "dispatch_extension_command") {
880
+ void handleDispatchExtensionCommand(msg, {
881
+ headlessPidRegistry: browserGateway.headlessPidRegistry,
882
+ emitCommandFeedback: (sid, command, status, message) => {
883
+ const event = {
884
+ eventType: "command_feedback",
885
+ timestamp: Date.now(),
886
+ data: message === undefined ? { command, status } : { command, status, message },
887
+ };
888
+ const seq = eventStore.insertEvent(sid, event);
889
+ const stored = eventStore.getEvent(sid, seq) ?? event;
890
+ browserGateway.broadcastEvent(sid, seq, stored);
891
+ },
892
+ });
893
+ }
894
+
865
895
  };
866
896
  }