@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
@@ -35,6 +35,7 @@ import { discoverAndBroadcastSessions } from "./session-bootstrap.js";
35
35
  import { scanAllSessions } from "./session-scanner.js";
36
36
  import { needsMigration, runMigration } from "./migrate-persistence.js";
37
37
  import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel, scavengeOrphanZrokProcesses, getTunnelUrl } from "./tunnel.js";
38
+ import { startTunnelWatchdog, stopTunnelWatchdog } from "./tunnel-watchdog.js";
38
39
  import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
39
40
  import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
40
41
  import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
@@ -45,6 +46,8 @@ import { registerSessionRoutes } from "./routes/session-routes.js";
45
46
  import { registerGitRoutes } from "./routes/git-routes.js";
46
47
  import { registerFileRoutes } from "./routes/file-routes.js";
47
48
  import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
49
+ import { registerOpenSpecGroupRoutes } from "./routes/openspec-group-routes.js";
50
+ import { createOpenSpecGroupStore, joinGroupIdsToOpenSpecData } from "./openspec-group-store.js";
48
51
  import { registerSystemRoutes } from "./routes/system-routes.js";
49
52
  import { registerDoctorRoutes } from "./routes/doctor-routes.js";
50
53
  import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
@@ -58,6 +61,7 @@ import { registerToolRoutes } from "./routes/tool-routes.js";
58
61
  import { registerJjRoutes } from "./routes/jj-routes.js";
59
62
  import { registerBootstrapRoutes } from "./routes/bootstrap-routes.js";
60
63
  import { createBootstrapState, type BootstrapStateStore } from "./bootstrap-state.js";
64
+ import { detectLegacyPiInstalls } from "./legacy-pi-cleanup.js";
61
65
  import { createBootstrapQueue } from "./bootstrap-queue.js";
62
66
  import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
63
67
  import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
@@ -68,6 +72,11 @@ import { createEditorPidRegistry } from "./editor-pid-registry.js";
68
72
  import { registerEditorRoutes } from "./routes/editor-routes.js";
69
73
  import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
70
74
  import { registerPluginConfigRoutes } from "./routes/plugin-config-routes.js";
75
+ import { createModelProxyAuthGate } from "./model-proxy/auth-gate.js";
76
+ import { registerModelProxyRoutes } from "./routes/model-proxy-routes.js";
77
+ import { registerModelProxyApiKeyRoutes } from "./routes/model-proxy-api-key-routes.js";
78
+ import { registerModelProxyRefreshRoutes } from "./routes/model-proxy-refresh-routes.js";
79
+ import { getModelRegistry, getStreamSimpleFn } from "./model-proxy/registry-singleton.js";
71
80
  import { writeConfigPartial } from "./config-api.js";
72
81
  import { loadServerEntries, discoverPlugins, getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
73
82
  import { createServerPluginContext } from "@blackbelt-technology/dashboard-plugin-runtime/server";
@@ -84,6 +93,12 @@ export interface ServerConfig {
84
93
  shutdownIdleSeconds: number;
85
94
  tunnel: boolean;
86
95
  tunnelReservedToken?: string;
96
+ tunnelWatchdog?: {
97
+ enabled: boolean;
98
+ intervalMs: number;
99
+ failureThreshold: number;
100
+ probeTimeoutMs: number;
101
+ };
87
102
  authConfig?: AuthConfig;
88
103
  /** Override WS ping interval for pi-gateway (ms). Default 60000. Set 0 to disable. */
89
104
  pingInterval?: number;
@@ -479,14 +494,32 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
479
494
  knownSessionIds.add(s.id);
480
495
  }
481
496
 
497
+ // Create the OpenSpec change-grouping store BEFORE the directory-service so
498
+ // the latter can join `groupId` into every `OpenSpecChange` it produces.
499
+ // See change: add-openspec-change-grouping (task 4.2).
500
+ const openspecGroupStore = createOpenSpecGroupStore();
501
+
482
502
  const directoryService = createDirectoryService(
483
503
  preferencesStore,
484
504
  sessionManager,
485
505
  config.openspec,
506
+ {
507
+ enrichOpenSpecData: async (cwd, data) => {
508
+ try {
509
+ const file = await openspecGroupStore.read(cwd);
510
+ return joinGroupIdsToOpenSpecData(data, file.assignments);
511
+ } catch {
512
+ // Bad file (e.g., unsupported schemaVersion) — fall back to unjoined.
513
+ return data;
514
+ }
515
+ },
516
+ },
486
517
  );
487
518
 
488
519
  // mDNS peer discovery state
489
520
  let mdnsBrowser: DashboardBrowser | null = null;
521
+ // Optional second-port Fastify instance for model proxy (/v1/*)
522
+ let secondFastify: Awaited<ReturnType<typeof import("fastify").default>> | null = null;
490
523
  const peerServers = new Map<string, DiscoveredServer>();
491
524
 
492
525
  const piGateway = createPiGateway(sessionManager, {
@@ -661,6 +694,14 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
661
694
  // routes can gate spawn operations on bootstrap status.
662
695
  // See change: unified-bootstrap-install.
663
696
  const bootstrapState = createBootstrapState();
697
+ // Scan for legacy `@mariozechner/pi-coding-agent` installs so the UI can
698
+ // offer a one-click cleanup. See: legacy-pi-cleanup.ts. Best-effort —
699
+ // detection failure is non-fatal (returns []).
700
+ try {
701
+ bootstrapState.set({ legacyPiInstalls: detectLegacyPiInstalls() });
702
+ } catch (err: any) {
703
+ console.warn("[legacy-pi] detection failed:", err?.message ?? err);
704
+ }
664
705
  const bootstrapQueue = createBootstrapQueue();
665
706
  // Centralized post-install repair: full ToolRegistry rescan +
666
707
  // OpenSpec / pi-resources force-refresh on every `installing → ready`
@@ -721,6 +762,29 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
721
762
  if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
722
763
  },
723
764
  });
765
+ // OpenSpec change-grouping routes (store created earlier next to
766
+ // directory-service so the join can run during polls).
767
+ // See change: add-openspec-change-grouping.
768
+ openspecGroupStore.subscribe((cwd, payload) => {
769
+ browserGateway.broadcastToAll({
770
+ type: "openspec_groups_update",
771
+ cwd,
772
+ groups: payload.groups,
773
+ assignments: payload.assignments,
774
+ });
775
+ // Refresh OpenSpecData so the joined `groupId` field reflects the new
776
+ // assignments on subscribers that don't consume `openspec_groups_update`
777
+ // directly. Fire-and-forget; failures are logged inside refreshOpenSpec.
778
+ directoryService.refreshOpenSpec(cwd).then((data) => {
779
+ browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
780
+ }).catch(() => {});
781
+ });
782
+ registerOpenSpecGroupRoutes(fastify, {
783
+ sessionManager,
784
+ preferencesStore,
785
+ networkGuard,
786
+ store: openspecGroupStore,
787
+ });
724
788
  registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway, bootstrapState });
725
789
  // GET /api/doctor — see change: doctor-rich-output (task 4.2). Auth-gated identically to /api/config.
726
790
  registerDoctorRoutes(fastify);
@@ -792,7 +856,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
792
856
  const prev = bootstrapState.getLastInstallPackages();
793
857
  const packages = prev.length > 0
794
858
  ? prev
795
- : ["@earendil-works/pi-coding-agent", "@fission-ai/openspec", "tsx"];
859
+ : ["@earendil-works/pi-coding-agent", "@fission-ai/openspec"];
796
860
  bootstrapState.set({
797
861
  status: "installing",
798
862
  progress: { step: "retry", output: `restarting install (${packages.length} pkg${packages.length === 1 ? "" : "s"})…` },
@@ -953,7 +1017,51 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
953
1017
  networkGuard,
954
1018
  broadcast: (msg) => browserGateway.broadcast(msg),
955
1019
  });
956
- registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway });
1020
+ registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway, port: config.port });
1021
+
1022
+ // ── Model Proxy ───────────────────────────────────────────────────
1023
+ {
1024
+ const fullCfg = loadConfig();
1025
+ if (fullCfg.modelProxy.enabled) {
1026
+ // Register proxy auth gate (runs BEFORE JWT hook for /v1/* routes)
1027
+ const proxyAuthGate = createModelProxyAuthGate({
1028
+ getConfig: () => loadConfig().modelProxy,
1029
+ persistKeyUsage: (apiKeys) => {
1030
+ writeConfigPartial({ modelProxy: { apiKeys } });
1031
+ },
1032
+ });
1033
+ fastify.addHook("onRequest", proxyAuthGate);
1034
+
1035
+ // Register /v1/* routes
1036
+ registerModelProxyRoutes(fastify, {
1037
+ getConfig: () => loadConfig().modelProxy,
1038
+ getRegistry: async () => {
1039
+ try {
1040
+ return await getModelRegistry();
1041
+ } catch {
1042
+ return null;
1043
+ }
1044
+ },
1045
+ streamSimple: (opts: any) => {
1046
+ const fn = getStreamSimpleFn();
1047
+ if (!fn) throw new Error("streamSimple not available");
1048
+ return fn(opts.model, { messages: opts.messages, system: opts.system, tools: opts.tools }, opts);
1049
+ },
1050
+ });
1051
+
1052
+ // Register API key management routes (JWT-gated)
1053
+ registerModelProxyApiKeyRoutes(fastify, {
1054
+ networkGuard,
1055
+ getModelProxyConfig: () => loadConfig().modelProxy,
1056
+ writeModelProxyApiKeys: async (apiKeys) => {
1057
+ writeConfigPartial({ modelProxy: { apiKeys } });
1058
+ },
1059
+ });
1060
+
1061
+ // Register refresh route (JWT-gated)
1062
+ registerModelProxyRefreshRoutes(fastify);
1063
+ }
1064
+ }
957
1065
 
958
1066
  // Serve static files / SPA fallback.
959
1067
  //
@@ -1086,6 +1194,19 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
1086
1194
  // Clean up orphan headless processes from a previous server instance
1087
1195
  browserGateway.headlessPidRegistry.cleanupOrphans();
1088
1196
 
1197
+ // Wire the singleton KeeperManager into the headless-pid registry so
1198
+ // `writeRpc` can forward `dispatch_extension_command` lines to the
1199
+ // session's keeper UDS, and so `cleanupKeeperOrphans` can reattach
1200
+ // surviving keepers after a server restart. Same instance the spawn
1201
+ // path uses. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
1202
+ try {
1203
+ const { getKeeperManager } = await import("./process-manager.js");
1204
+ browserGateway.headlessPidRegistry.setKeeperWriter(getKeeperManager());
1205
+ await browserGateway.headlessPidRegistry.cleanupKeeperOrphans();
1206
+ } catch (err) {
1207
+ console.warn("[dashboard] keeper-manager wire-up failed (RPC dispatch disabled):", err);
1208
+ }
1209
+
1089
1210
  // Clean up orphan code-server processes from a previous server instance.
1090
1211
  // Runs before fastify.listen, so no editor start request can race with the sweep.
1091
1212
  await editorPidRegistry.cleanupOrphans();
@@ -1184,6 +1305,40 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
1184
1305
  console.log(`Dashboard server running at http://localhost:${config.port}`);
1185
1306
  console.log(`Pi gateway listening on port ${config.piPort}`);
1186
1307
 
1308
+ // ── Optional second port for model proxy (/v1/*) ──────────────
1309
+ {
1310
+ const proxyCfg = loadConfig().modelProxy;
1311
+ if (proxyCfg.enabled && proxyCfg.secondPort) {
1312
+ try {
1313
+ const F = (await import("fastify")).default;
1314
+ const sf = F({ logger: false });
1315
+ const proxyAuthGate = createModelProxyAuthGate({
1316
+ getConfig: () => loadConfig().modelProxy,
1317
+ persistKeyUsage: (apiKeys) => {
1318
+ writeConfigPartial({ modelProxy: { apiKeys } });
1319
+ },
1320
+ });
1321
+ sf.addHook("onRequest", proxyAuthGate);
1322
+ registerModelProxyRoutes(sf, {
1323
+ getConfig: () => loadConfig().modelProxy,
1324
+ getRegistry: async () => {
1325
+ try { return await getModelRegistry(); } catch { return null; }
1326
+ },
1327
+ streamSimple: (opts: any) => {
1328
+ const fn = getStreamSimpleFn();
1329
+ if (!fn) throw new Error("streamSimple not available");
1330
+ return fn(opts.model, { messages: opts.messages, system: opts.system, tools: opts.tools }, opts);
1331
+ },
1332
+ });
1333
+ await sf.listen({ port: proxyCfg.secondPort, host: "127.0.0.1" });
1334
+ secondFastify = sf as any;
1335
+ console.log(`Model proxy second port listening at http://127.0.0.1:${proxyCfg.secondPort}`);
1336
+ } catch (err) {
1337
+ console.warn(`Model proxy second port bind failed (continuing without):`, err);
1338
+ }
1339
+ }
1340
+ }
1341
+
1187
1342
  // Advertise via mDNS
1188
1343
  try {
1189
1344
  advertiseDashboard(config.port, config.piPort);
@@ -1225,6 +1380,21 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
1225
1380
  const tunnelUrl = await createTunnel(config.port, config.tunnelReservedToken);
1226
1381
  if (tunnelUrl) {
1227
1382
  console.log(`🌐 Tunnel: ${tunnelUrl}`);
1383
+ // Start the watchdog so a stale zrok edge connection is detected
1384
+ // and recycled automatically (preserves reserved token / URL).
1385
+ const wd = config.tunnelWatchdog;
1386
+ if (wd?.enabled !== false) {
1387
+ startTunnelWatchdog(
1388
+ {
1389
+ getUrl: getTunnelUrl,
1390
+ recycle: async () => {
1391
+ await deleteTunnel(config.port);
1392
+ return await createTunnel(config.port, config.tunnelReservedToken);
1393
+ },
1394
+ },
1395
+ wd,
1396
+ );
1397
+ }
1228
1398
  }
1229
1399
  }
1230
1400
  }
@@ -1277,6 +1447,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
1277
1447
  unsubscribeQueueComplete();
1278
1448
  bootstrapState.dispose();
1279
1449
  bootstrapQueue.clear("server shutting down");
1450
+ stopTunnelWatchdog();
1280
1451
  await deleteTunnel(config.port);
1281
1452
  piGateway.stop();
1282
1453
  for (const client of browserGateway.wss.clients) {
@@ -1292,6 +1463,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
1292
1463
  editorManager.stopAll();
1293
1464
  // Close any pending OAuth callback servers
1294
1465
  try { const { closeAllCallbackServers } = await import("./oauth-callback-server.js"); await closeAllCallbackServers(); } catch {}
1466
+ // Close second port before main server
1467
+ if (secondFastify) {
1468
+ try { await secondFastify.close(); } catch { /* ignore */ }
1469
+ secondFastify = null;
1470
+ }
1295
1471
  await fastify.close();
1296
1472
  },
1297
1473
  };
@@ -17,6 +17,7 @@ import type { BootstrapStateStore } from "./bootstrap-state.js";
17
17
  import type { BootstrapQueue } from "./bootstrap-queue.js";
18
18
  import { attachRenameTarget, detachShouldClearName } from "./proposal-attach-naming.js";
19
19
  import { FORK_DEGRADED_TO_NEW_MESSAGE, FORK_DEGRADED_TO_NEW_CODE } from "./browser-handlers/session-action-handler.js";
20
+ import { keeperOptsFromSpawnResult } from "./headless-pid-registry.js";
20
21
 
21
22
  export interface SessionApiDeps {
22
23
  sessionManager: SessionManager;
@@ -239,7 +240,13 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
239
240
  const config = loadConfig();
240
241
  const spawnResult = await spawnPiSession(cwd, { strategy: config.spawnStrategy });
241
242
  if (spawnResult.process && spawnResult.pid) {
242
- browserGateway.headlessPidRegistry.register(spawnResult.pid, cwd, spawnResult.process);
243
+ browserGateway.headlessPidRegistry.register(
244
+ spawnResult.pid,
245
+ cwd,
246
+ spawnResult.process,
247
+ spawnResult.spawnToken,
248
+ keeperOptsFromSpawnResult(spawnResult),
249
+ );
243
250
  }
244
251
  if (spawnResult.dashboardSpawned && spawnResult.success) {
245
252
  pendingDashboardSpawns?.set(cwd, (pendingDashboardSpawns?.get(cwd) ?? 0) + 1);
@@ -309,6 +316,7 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
309
316
  session.cwd,
310
317
  degradeResult.process,
311
318
  degradeResult.spawnToken,
319
+ keeperOptsFromSpawnResult(degradeResult),
312
320
  );
313
321
  }
314
322
  if (degradeResult.dashboardSpawned && degradeResult.success) {
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Tunnel watchdog: periodically probes the public tunnel URL through the
3
+ * zrok edge and recycles the tunnel when consecutive failures (5xx, network
4
+ * errors, timeouts) exceed a threshold.
5
+ *
6
+ * The zrok `share` subprocess can stay running for days while its connection
7
+ * to the zrok edge silently goes stale, returning HTTP 502 from the public
8
+ * URL even though the local upstream is healthy. The fix is a `deleteTunnel`
9
+ * + `createTunnel` cycle (preserves the reserved token, so the URL stays the
10
+ * same).
11
+ *
12
+ * Probe semantics: we treat ONLY 5xx and network/timeout failures as bad.
13
+ * Any 2xx/3xx/4xx response proves zrok edge ↔ local server connectivity is
14
+ * fine and counts as success — even if the local route is auth-gated.
15
+ */
16
+
17
+ export interface TunnelWatchdogDeps {
18
+ /** Returns the active public tunnel URL, or null if no tunnel is up. */
19
+ getUrl: () => string | null;
20
+ /** Recycle the tunnel: delete and recreate. Returns the new URL or null. */
21
+ recycle: () => Promise<string | null>;
22
+ /** Optional fetch override for tests. */
23
+ fetchFn?: typeof fetch;
24
+ /** Optional logger; defaults to console. */
25
+ log?: (level: "info" | "warn" | "error", msg: string) => void;
26
+ }
27
+
28
+ export interface TunnelWatchdogConfig {
29
+ /** Master switch. Default: true. */
30
+ enabled: boolean;
31
+ /** Probe cadence. Default: 60_000. */
32
+ intervalMs: number;
33
+ /** Consecutive failures before recycling. Default: 2. */
34
+ failureThreshold: number;
35
+ /** Per-probe HTTP timeout. Default: 10_000. */
36
+ probeTimeoutMs: number;
37
+ }
38
+
39
+ export const DEFAULT_TUNNEL_WATCHDOG_CONFIG: TunnelWatchdogConfig = {
40
+ enabled: true,
41
+ intervalMs: 60_000,
42
+ failureThreshold: 2,
43
+ probeTimeoutMs: 10_000,
44
+ };
45
+
46
+ export interface TunnelWatchdogStatus {
47
+ running: boolean;
48
+ intervalMs: number;
49
+ failureThreshold: number;
50
+ probeTimeoutMs: number;
51
+ lastProbeAt: number | null;
52
+ lastSuccessAt: number | null;
53
+ lastFailureAt: number | null;
54
+ lastFailureReason: string | null;
55
+ consecutiveFailures: number;
56
+ lastRecycleAt: number | null;
57
+ recycleCount: number;
58
+ }
59
+
60
+ interface WatchdogState {
61
+ cfg: TunnelWatchdogConfig;
62
+ deps: TunnelWatchdogDeps;
63
+ timer: ReturnType<typeof setTimeout> | null;
64
+ inFlight: boolean;
65
+ recycling: boolean;
66
+ /** Current backoff multiplier applied after a recycle failure (1, 2, 4, …, capped). */
67
+ backoffMultiplier: number;
68
+ status: TunnelWatchdogStatus;
69
+ }
70
+
71
+ let state: WatchdogState | null = null;
72
+
73
+ const MAX_BACKOFF_MULTIPLIER = 8;
74
+
75
+ function defaultLog(level: "info" | "warn" | "error", msg: string): void {
76
+ const prefix = "[tunnel-watchdog]";
77
+ if (level === "warn") console.warn(prefix, msg);
78
+ else if (level === "error") console.error(prefix, msg);
79
+ else console.log(prefix, msg);
80
+ }
81
+
82
+ function makeInitialStatus(cfg: TunnelWatchdogConfig): TunnelWatchdogStatus {
83
+ return {
84
+ running: false,
85
+ intervalMs: cfg.intervalMs,
86
+ failureThreshold: cfg.failureThreshold,
87
+ probeTimeoutMs: cfg.probeTimeoutMs,
88
+ lastProbeAt: null,
89
+ lastSuccessAt: null,
90
+ lastFailureAt: null,
91
+ lastFailureReason: null,
92
+ consecutiveFailures: 0,
93
+ lastRecycleAt: null,
94
+ recycleCount: 0,
95
+ };
96
+ }
97
+
98
+ /** Probe outcome: ok=true on 2xx/3xx/4xx, false on 5xx/network/timeout. */
99
+ export async function probeTunnel(
100
+ url: string,
101
+ timeoutMs: number,
102
+ fetchFn: typeof fetch = fetch,
103
+ ): Promise<{ ok: boolean; status?: number; reason?: string }> {
104
+ const probeUrl = url.replace(/\/+$/, "") + "/api/health";
105
+ const ctrl = new AbortController();
106
+ const t = setTimeout(() => ctrl.abort(), timeoutMs);
107
+ try {
108
+ const res = await fetchFn(probeUrl, { method: "GET", signal: ctrl.signal });
109
+ if (res.status >= 500) {
110
+ return { ok: false, status: res.status, reason: `http ${res.status}` };
111
+ }
112
+ return { ok: true, status: res.status };
113
+ } catch (err: any) {
114
+ const reason = err?.name === "AbortError" ? `timeout ${timeoutMs}ms` : (err?.message || "network error");
115
+ return { ok: false, reason };
116
+ } finally {
117
+ clearTimeout(t);
118
+ }
119
+ }
120
+
121
+ function scheduleNext(): void {
122
+ if (!state) return;
123
+ const delay = state.cfg.intervalMs * state.backoffMultiplier;
124
+ state.timer = setTimeout(() => { void tick(); }, delay);
125
+ // Don't keep the event loop alive for the watchdog alone.
126
+ if (typeof (state.timer as any).unref === "function") (state.timer as any).unref();
127
+ }
128
+
129
+ async function tick(): Promise<void> {
130
+ if (!state) return;
131
+ if (state.inFlight) { scheduleNext(); return; }
132
+ state.inFlight = true;
133
+ try {
134
+ const url = state.deps.getUrl();
135
+ if (!url) {
136
+ // No tunnel up — nothing to probe; keep ticking in case it comes up.
137
+ return;
138
+ }
139
+ const fetchFn = state.deps.fetchFn ?? fetch;
140
+ state.status.lastProbeAt = Date.now();
141
+ const result = await probeTunnel(url, state.cfg.probeTimeoutMs, fetchFn);
142
+ if (result.ok) {
143
+ state.status.lastSuccessAt = Date.now();
144
+ state.status.consecutiveFailures = 0;
145
+ state.backoffMultiplier = 1;
146
+ return;
147
+ }
148
+ state.status.lastFailureAt = Date.now();
149
+ state.status.lastFailureReason = result.reason ?? "unknown";
150
+ state.status.consecutiveFailures += 1;
151
+ (state.deps.log ?? defaultLog)(
152
+ "warn",
153
+ `probe failed (${state.status.consecutiveFailures}/${state.cfg.failureThreshold}): ${state.status.lastFailureReason}`,
154
+ );
155
+ if (state.status.consecutiveFailures >= state.cfg.failureThreshold && !state.recycling) {
156
+ await runRecycle();
157
+ }
158
+ } finally {
159
+ state.inFlight = false;
160
+ if (state) scheduleNext();
161
+ }
162
+ }
163
+
164
+ async function runRecycle(): Promise<void> {
165
+ if (!state) return;
166
+ state.recycling = true;
167
+ const log = state.deps.log ?? defaultLog;
168
+ log("warn", `recycling tunnel after ${state.status.consecutiveFailures} consecutive failures`);
169
+ try {
170
+ const newUrl = await state.deps.recycle();
171
+ state.status.lastRecycleAt = Date.now();
172
+ state.status.recycleCount += 1;
173
+ state.status.consecutiveFailures = 0;
174
+ if (newUrl) {
175
+ log("info", `tunnel recycled: ${newUrl}`);
176
+ state.backoffMultiplier = 1;
177
+ } else {
178
+ log("error", "tunnel recycle returned no URL — backing off");
179
+ state.backoffMultiplier = Math.min(state.backoffMultiplier * 2 || 2, MAX_BACKOFF_MULTIPLIER);
180
+ }
181
+ } catch (err: any) {
182
+ log("error", `tunnel recycle threw: ${err?.message ?? err}`);
183
+ state.backoffMultiplier = Math.min(state.backoffMultiplier * 2 || 2, MAX_BACKOFF_MULTIPLIER);
184
+ } finally {
185
+ state.recycling = false;
186
+ }
187
+ }
188
+
189
+ export function startTunnelWatchdog(
190
+ deps: TunnelWatchdogDeps,
191
+ cfg: Partial<TunnelWatchdogConfig> = {},
192
+ ): void {
193
+ if (state) return; // already running
194
+ const merged: TunnelWatchdogConfig = { ...DEFAULT_TUNNEL_WATCHDOG_CONFIG, ...cfg };
195
+ if (!merged.enabled) return;
196
+ state = {
197
+ cfg: merged,
198
+ deps,
199
+ timer: null,
200
+ inFlight: false,
201
+ recycling: false,
202
+ backoffMultiplier: 1,
203
+ status: makeInitialStatus(merged),
204
+ };
205
+ state.status.running = true;
206
+ scheduleNext();
207
+ }
208
+
209
+ export function stopTunnelWatchdog(): void {
210
+ if (!state) return;
211
+ if (state.timer) clearTimeout(state.timer);
212
+ state.status.running = false;
213
+ state = null;
214
+ }
215
+
216
+ export function getTunnelWatchdogStatus(): TunnelWatchdogStatus | null {
217
+ if (!state) return null;
218
+ return { ...state.status };
219
+ }
220
+
221
+ /** Test-only: force a tick now (returns when the tick completes). */
222
+ export async function _runTickForTest(): Promise<void> {
223
+ await tick();
224
+ }
225
+
226
+ /** Test-only: reset module-level state. */
227
+ export function _resetForTest(): void {
228
+ if (state?.timer) clearTimeout(state.timer);
229
+ state = null;
230
+ }
@@ -17,6 +17,7 @@ import {
17
17
 
18
18
  const zrokResolver = new ToolResolver({ processExecPath: process.execPath });
19
19
  import type { TunnelStatus } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
20
+ import { getTunnelWatchdogStatus } from "./tunnel-watchdog.js";
20
21
  import { CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js";
21
22
 
22
23
  export type { TunnelStatus };
@@ -458,7 +459,10 @@ export function getTunnelUrl(): string | null {
458
459
  export function getTunnelStatus(): TunnelStatus {
459
460
  const serverOs = process.platform;
460
461
  if (activeTunnelUrl) {
461
- return { status: "active", url: activeTunnelUrl, serverOs };
462
+ const wd = getTunnelWatchdogStatus();
463
+ return wd
464
+ ? { status: "active", url: activeTunnelUrl, serverOs, watchdog: wd }
465
+ : { status: "active", url: activeTunnelUrl, serverOs };
462
466
  }
463
467
  if (detectZrokBinary()) {
464
468
  return { status: "inactive", serverOs };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "repository": {