@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.3.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 (76) hide show
  1. package/AGENTS.md +114 -9
  2. package/README.md +218 -97
  3. package/docs/architecture.md +107 -7
  4. package/package.json +9 -4
  5. package/packages/extension/package.json +1 -1
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  8. package/packages/extension/src/ask-user-tool.ts +289 -20
  9. package/packages/extension/src/bridge.ts +38 -4
  10. package/packages/extension/src/command-handler.ts +34 -39
  11. package/packages/extension/src/prompt-expander.ts +25 -4
  12. package/packages/server/package.json +2 -1
  13. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  14. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  15. package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
  16. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  17. package/packages/server/src/__tests__/cors.test.ts +34 -2
  18. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  19. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  20. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  21. package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
  22. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  23. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  24. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  25. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  26. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  27. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
  28. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  29. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  30. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  31. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  32. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  33. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  34. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  35. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  36. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  37. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  38. package/packages/server/src/__tests__/tunnel.test.ts +91 -0
  39. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  40. package/packages/server/src/browse.ts +100 -6
  41. package/packages/server/src/browser-gateway.ts +16 -3
  42. package/packages/server/src/editor-manager.ts +20 -1
  43. package/packages/server/src/editor-pid-registry.ts +198 -0
  44. package/packages/server/src/fix-pty-permissions.ts +44 -0
  45. package/packages/server/src/headless-pid-registry.ts +9 -0
  46. package/packages/server/src/npm-search-proxy.ts +71 -0
  47. package/packages/server/src/openspec-tasks.ts +158 -0
  48. package/packages/server/src/package-manager-wrapper.ts +31 -0
  49. package/packages/server/src/pi-core-checker.ts +290 -0
  50. package/packages/server/src/pi-core-updater.ts +166 -0
  51. package/packages/server/src/pi-gateway.ts +7 -0
  52. package/packages/server/src/process-manager.ts +1 -1
  53. package/packages/server/src/routes/file-routes.ts +30 -3
  54. package/packages/server/src/routes/openspec-routes.ts +83 -1
  55. package/packages/server/src/routes/pi-core-routes.ts +117 -0
  56. package/packages/server/src/routes/provider-auth-routes.ts +4 -2
  57. package/packages/server/src/routes/provider-routes.ts +12 -2
  58. package/packages/server/src/routes/recommended-routes.ts +227 -0
  59. package/packages/server/src/routes/system-routes.ts +10 -1
  60. package/packages/server/src/server.ts +151 -15
  61. package/packages/server/src/terminal-manager.ts +4 -0
  62. package/packages/server/src/test-env-guard.ts +26 -0
  63. package/packages/server/src/test-support/test-server.ts +63 -0
  64. package/packages/server/src/tunnel.ts +132 -8
  65. package/packages/shared/package.json +1 -1
  66. package/packages/shared/src/__tests__/config.test.ts +3 -3
  67. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  68. package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
  69. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  70. package/packages/shared/src/browser-protocol.ts +23 -1
  71. package/packages/shared/src/openspec-poller.ts +8 -3
  72. package/packages/shared/src/recommended-extensions.ts +180 -0
  73. package/packages/shared/src/rest-api.ts +71 -0
  74. package/packages/shared/src/source-matching.ts +126 -0
  75. package/packages/shared/src/test-support/setup-home.ts +74 -0
  76. package/packages/shared/src/types.ts +7 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * REST route for the dashboard's curated "recommended extensions" list.
3
+ *
4
+ * GET /api/packages/recommended
5
+ *
6
+ * Returns the static RECOMMENDED_EXTENSIONS manifest enriched with:
7
+ * - live description + version from npm or GitHub (falls back to
8
+ * fallbackDescription on network failure)
9
+ * - installed.scope cross-reference via packageManagerWrapper
10
+ * - activeInPi flag from ~/.pi/agent/settings.json packages[]
11
+ * - updateAvailable flag
12
+ *
13
+ * Results are cached for 60 seconds. The cache is busted when any package
14
+ * install / remove / update operation completes successfully.
15
+ */
16
+ import type { FastifyInstance } from "fastify";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
21
+ import type { EnrichedRecommendedExtension } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
22
+ import {
23
+ RECOMMENDED_EXTENSIONS,
24
+ type RecommendedExtension,
25
+ } from "@blackbelt-technology/pi-dashboard-shared/recommended-extensions.js";
26
+ import {
27
+ parseSourceKey,
28
+ sourcesMatch,
29
+ type SourceKey,
30
+ } from "@blackbelt-technology/pi-dashboard-shared/source-matching.js";
31
+ export { parseSourceKey, sourcesMatch, type SourceKey };
32
+ import {
33
+ fetchPackageMeta,
34
+ fetchGithubPackageJson,
35
+ type PackageMeta,
36
+ } from "../npm-search-proxy.js";
37
+ import type { PackageManagerWrapper } from "../package-manager-wrapper.js";
38
+
39
+ const CACHE_TTL_MS = 60 * 1000;
40
+
41
+ interface CacheEntry {
42
+ at: number;
43
+ data: EnrichedRecommendedExtension[];
44
+ }
45
+
46
+ let cache: CacheEntry | null = null;
47
+
48
+ /** Invalidate the recommended-extensions cache. */
49
+ export function invalidateRecommendedCache(): void {
50
+ cache = null;
51
+ }
52
+
53
+ /**
54
+ * Parse a pi install source into a lookup key for matching against
55
+ * listInstalled() results.
56
+ *
57
+ * Supported forms (matches pi's DefaultPackageManager.parseSource):
58
+ * npm:<name>[@<version>]
59
+ * git@<host>:<owner>/<repo>.git
60
+ * git:<host>/<owner>/<repo>[#<ref>]
61
+ * https://<host>/<owner>/<repo>[.git][#<ref>]
62
+ *
63
+ * Returns:
64
+ * { kind: "npm", name } for npm sources
65
+ * { kind: "git", host, owner, repo } for git sources
66
+ * { kind: "raw", source } for anything else (local paths)
67
+ *
68
+ * Source-matching logic lives in
69
+ * `@blackbelt-technology/pi-dashboard-shared/source-matching.js` so the
70
+ * Electron wizard's bootstrap enricher can apply the same rules without
71
+ * depending on the server runtime. We re-export above so existing
72
+ * imports from this module keep working.
73
+ */
74
+
75
+ /** Read pi's project-local `.pi/settings.json` (if any) for the given cwd. */
76
+ function readLocalSources(cwd: string): string[] {
77
+ const settingsPath = path.join(cwd, ".pi", "settings.json");
78
+ try {
79
+ if (!fs.existsSync(settingsPath)) return [];
80
+ const raw = fs.readFileSync(settingsPath, "utf-8").trim();
81
+ if (!raw) return [];
82
+ const data = JSON.parse(raw);
83
+ const pkgs = Array.isArray(data?.packages) ? (data.packages as unknown[]) : [];
84
+ return pkgs.filter((p): p is string => typeof p === "string");
85
+ } catch {
86
+ return [];
87
+ }
88
+ }
89
+
90
+ /** Collect active package sources from both the user's global
91
+ * `~/.pi/agent/settings.json` and the project's `<cwd>/.pi/settings.json`.
92
+ * Mirrors pi's SettingsManager behavior: a package is "active" in pi if
93
+ * it appears in EITHER scope's packages[] list. */
94
+ function readActiveSources(cwd?: string): string[] {
95
+ const sources: string[] = [];
96
+
97
+ const globalPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
98
+ try {
99
+ if (fs.existsSync(globalPath)) {
100
+ const raw = fs.readFileSync(globalPath, "utf-8").trim();
101
+ if (raw) {
102
+ const data = JSON.parse(raw);
103
+ const pkgs = Array.isArray(data?.packages) ? (data.packages as unknown[]) : [];
104
+ for (const p of pkgs) if (typeof p === "string") sources.push(p);
105
+ }
106
+ }
107
+ } catch {
108
+ /* ignore corrupt global settings */
109
+ }
110
+
111
+ if (cwd) {
112
+ for (const p of readLocalSources(cwd)) sources.push(p);
113
+ }
114
+
115
+ return sources;
116
+ }
117
+
118
+ function semverOlder(installed: string | undefined, latest: string | undefined): boolean {
119
+ if (!installed || !latest) return false;
120
+ if (installed === latest) return false;
121
+ // Very conservative comparison: if they differ textually, assume an
122
+ // update may be available. The Packages tab's check-updates flow can
123
+ // resolve the definitive answer.
124
+ return installed !== latest;
125
+ }
126
+
127
+ async function enrichEntry(
128
+ entry: RecommendedExtension,
129
+ installedGlobal: Array<{ source: string; installedPath?: string }>,
130
+ installedLocal: Array<{ source: string; installedPath?: string }>,
131
+ activeSources: string[],
132
+ ): Promise<EnrichedRecommendedExtension> {
133
+ const key = parseSourceKey(entry.source);
134
+ let meta: PackageMeta | null = null;
135
+
136
+ if (key.kind === "npm") {
137
+ meta = await fetchPackageMeta(key.name);
138
+ } else if (key.kind === "git" && key.host.toLowerCase() === "github.com") {
139
+ meta = await fetchGithubPackageJson(key.owner, key.repo);
140
+ }
141
+
142
+ const description = meta?.description ?? entry.fallbackDescription;
143
+ const version = meta?.version;
144
+
145
+ const inGlobal = installedGlobal.some((p) => sourcesMatch(p.source, entry.source));
146
+ const inLocal = installedLocal.some((p) => sourcesMatch(p.source, entry.source));
147
+ const installedScope: "global" | "local" | null = inGlobal
148
+ ? "global"
149
+ : inLocal
150
+ ? "local"
151
+ : null;
152
+
153
+ const activeInPi = activeSources.some((s) => sourcesMatch(s, entry.source));
154
+
155
+ // Best-effort update indicator: for npm sources, try to read the installed
156
+ // package.json version and compare to the live registry version. For git
157
+ // sources we currently don't track ref pins, so updateAvailable defaults
158
+ // to false (the Packages-tab check-updates action handles this separately).
159
+ let updateAvailable = false;
160
+ if (version && key.kind === "npm" && installedScope) {
161
+ const installed = inGlobal ? installedGlobal : installedLocal;
162
+ const match = installed.find((p) => sourcesMatch(p.source, entry.source));
163
+ if (match?.installedPath) {
164
+ try {
165
+ const pj = path.join(match.installedPath, "package.json");
166
+ if (fs.existsSync(pj)) {
167
+ const parsed = JSON.parse(fs.readFileSync(pj, "utf-8"));
168
+ updateAvailable = semverOlder(parsed?.version, version);
169
+ }
170
+ } catch {
171
+ /* ignore */
172
+ }
173
+ }
174
+ }
175
+
176
+ return {
177
+ ...entry,
178
+ description,
179
+ version,
180
+ installed: { scope: installedScope },
181
+ activeInPi,
182
+ updateAvailable,
183
+ };
184
+ }
185
+
186
+ export function registerRecommendedRoutes(
187
+ fastify: FastifyInstance,
188
+ deps: { packageManagerWrapper: PackageManagerWrapper },
189
+ ): void {
190
+ fastify.get("/api/packages/recommended", async () => {
191
+ const now = Date.now();
192
+ if (cache && now - cache.at < CACHE_TTL_MS) {
193
+ return { success: true, data: { recommended: cache.data } } satisfies ApiResponse<{
194
+ recommended: EnrichedRecommendedExtension[];
195
+ }>;
196
+ }
197
+
198
+ let installedGlobal: Array<{ source: string; installedPath?: string }> = [];
199
+ let installedLocal: Array<{ source: string; installedPath?: string }> = [];
200
+ try {
201
+ installedGlobal = (await deps.packageManagerWrapper.listInstalled("global")) as any[];
202
+ } catch {
203
+ /* proceed with empty */
204
+ }
205
+ try {
206
+ installedLocal = (await deps.packageManagerWrapper.listInstalled("local")) as any[];
207
+ } catch {
208
+ /* proceed with empty */
209
+ }
210
+
211
+ // Include both global + project-local settings.json `packages[]`.
212
+ // The server's CWD is a reasonable proxy for the active project.
213
+ const activeSources = readActiveSources(process.cwd());
214
+
215
+ const enriched = await Promise.all(
216
+ RECOMMENDED_EXTENSIONS.map((entry) =>
217
+ enrichEntry(entry, installedGlobal, installedLocal, activeSources),
218
+ ),
219
+ );
220
+
221
+ cache = { at: now, data: enriched };
222
+
223
+ return { success: true, data: { recommended: enriched } } satisfies ApiResponse<{
224
+ recommended: EnrichedRecommendedExtension[];
225
+ }>;
226
+ });
227
+ }
@@ -147,7 +147,9 @@ export function registerSystemRoutes(
147
147
  });
148
148
 
149
149
  fastify.post("/api/tunnel-disconnect", async () => {
150
- await deleteTunnel();
150
+ // Pass port so orphan zrok processes bound to this endpoint are also
151
+ // swept (not just the one we tracked via pid-file).
152
+ await deleteTunnel(config.port);
151
153
  return { ok: true };
152
154
  });
153
155
 
@@ -186,6 +188,9 @@ export function registerSystemRoutes(
186
188
  async () => {
187
189
  metaPersistence.flushAll();
188
190
  preferencesStore.flush();
191
+ // Tear down the zrok tunnel (and sweep orphans on our port) so restarts
192
+ // don't leak reservations that leave stale URLs backed by nothing.
193
+ try { await deleteTunnel(config.port); } catch { /* best-effort */ }
189
194
  setTimeout(() => process.exit(0), 100);
190
195
  return { ok: true };
191
196
  },
@@ -199,6 +204,10 @@ export function registerSystemRoutes(
199
204
  metaPersistence.flushAll();
200
205
  preferencesStore.flush();
201
206
 
207
+ // Tear down tunnel before spawning the replacement process so the new
208
+ // server doesn't race an orphan zrok agent on the same port.
209
+ try { await deleteTunnel(config.port); } catch { /* best-effort */ }
210
+
202
211
  const cliPath = process.argv[1];
203
212
  if (!cliPath) return { ok: false, error: "Cannot determine CLI path" };
204
213
 
@@ -4,6 +4,7 @@
4
4
  import Fastify from "fastify";
5
5
  import fastifyStatic from "@fastify/static";
6
6
  import cors from "@fastify/cors";
7
+ import compress from "@fastify/compress";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import os from "node:os";
@@ -29,7 +30,7 @@ import { createIdleTimer } from "./idle-timer.js";
29
30
  import { discoverAndBroadcastSessions } from "./session-bootstrap.js";
30
31
  import { scanAllSessions } from "./session-scanner.js";
31
32
  import { needsMigration, runMigration } from "./migrate-persistence.js";
32
- import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel } from "./tunnel.js";
33
+ import { detectZrokBinary, cleanupStaleZrok, createTunnel, deleteTunnel, scavengeOrphanZrokProcesses, getTunnelUrl } from "./tunnel.js";
33
34
  import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
34
35
  import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
35
36
  import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
@@ -42,9 +43,14 @@ import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
42
43
  import { registerSystemRoutes } from "./routes/system-routes.js";
43
44
  import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
44
45
  import { registerPackageRoutes } from "./routes/package-routes.js";
46
+ import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
47
+ import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
48
+ import { PiCoreChecker } from "./pi-core-checker.js";
49
+ import { PiCoreUpdater } from "./pi-core-updater.js";
45
50
  import { registerProviderRoutes } from "./routes/provider-routes.js";
46
51
  import { PackageManagerWrapper } from "./package-manager-wrapper.js";
47
52
  import { createEditorManager, type EditorManager } from "./editor-manager.js";
53
+ import { createEditorPidRegistry } from "./editor-pid-registry.js";
48
54
  import { registerEditorRoutes } from "./routes/editor-routes.js";
49
55
  import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
50
56
  import { registerEditorProxy, handleEditorUpgrade } from "./editor-proxy.js";
@@ -79,6 +85,10 @@ export interface DashboardServer {
79
85
  sessionManager: SessionManager;
80
86
  eventStore: EventStore;
81
87
  browserGateway: BrowserGateway;
88
+ /** Resolved HTTP port after start() (useful for port:0 in tests). Returns null if not listening. */
89
+ httpPort(): number | null;
90
+ /** Resolved pi gateway port after start(). Returns null if not listening. */
91
+ piPort(): number | null;
82
92
  }
83
93
 
84
94
  export async function createServer(config: ServerConfig): Promise<DashboardServer> {
@@ -190,9 +200,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
190
200
 
191
201
  // Create editor manager for code-server instances
192
202
  const editorDetection = detectCodeServerBinary(config.editor);
203
+ const editorPidRegistry = createEditorPidRegistry();
193
204
  const editorManager = createEditorManager({
194
205
  config: config.editor,
195
206
  detection: editorDetection,
207
+ pidRegistry: editorPidRegistry,
196
208
  onStatusChange: (cwd, id, status) => {
197
209
  browserGateway.broadcastToAll({ type: "editor_status", cwd, id, status });
198
210
  },
@@ -243,23 +255,62 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
243
255
  connectionTimeout: 10_000,
244
256
  });
245
257
 
246
- // CORS: allow localhost by default + configured origins
258
+ // Compression: gzip/deflate for HTTP responses. Critical for large client
259
+ // bundles (~3 MB JS) served over tunnels like zrok which abort big transfers.
260
+ // Brotli is intentionally disabled — zrok's free public proxy has been
261
+ // observed to truncate/stream-reset `content-encoding: br` responses under
262
+ // parallel browser load (curl succeeds, Chrome reports ERR_ABORTED 500).
263
+ // gzip is universally supported and round-trips cleanly through zrok.
264
+ // threshold=1024 skips tiny responses; global=true compresses all routes.
265
+ await fastify.register(compress, {
266
+ global: true,
267
+ threshold: 1024,
268
+ encodings: ["gzip", "deflate"],
269
+ });
270
+
271
+ // CORS: allow localhost, the active zrok tunnel URL, any *.share.zrok.io
272
+ // host (so tunnel URL rotation doesn't break loads), and configured origins.
273
+ //
274
+ // Two critical correctness notes:
275
+ // (1) Vite emits `<script type="module" crossorigin>` tags, which browsers
276
+ // always request in CORS mode — even when same-origin. If the server
277
+ // doesn't emit `Access-Control-Allow-Origin` for the request's own
278
+ // origin, the browser aborts the script with ERR_ABORTED 500. So when
279
+ // accessed via a tunnel URL, that URL MUST be in the allow list or all
280
+ // asset loads fail in the browser (while curl — which sends no Origin
281
+ // header — works fine). This is the exact failure mode that looked
282
+ // like a zrok problem for hours of debugging.
283
+ // (2) On origin mismatch, return `cb(null, false)` (no CORS headers) rather
284
+ // than `cb(new Error(…), false)`. The latter causes @fastify/cors to
285
+ // surface the error as HTTP 500 on every asset — far worse than
286
+ // silently omitting CORS headers and letting the browser enforce its
287
+ // own same-origin policy.
247
288
  const corsAllowedOrigins = config.corsAllowedOrigins ?? [];
248
289
  await fastify.register(cors, {
249
290
  origin: (origin, cb) => {
250
- // Same-origin (no Origin header) — always allow
291
+ // Same-origin navigation (no Origin header) — always allow.
251
292
  if (!origin) return cb(null, true);
252
- // Localhost / 127.0.0.1 / [::1] — any port
253
293
  try {
254
294
  const u = new URL(origin);
255
295
  const host = u.hostname;
296
+ // Loopback — any port.
256
297
  if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1") {
257
298
  return cb(null, true);
258
299
  }
259
- } catch { /* ignore parse errors */ }
260
- // Configured origins
300
+ // Active zrok tunnel URL checked dynamically so URL rotation is
301
+ // picked up without a server restart.
302
+ const tunnelUrl = getTunnelUrl();
303
+ if (tunnelUrl && origin === tunnelUrl) return cb(null, true);
304
+ // Any *.share.zrok.io host — covers the brief window between a new
305
+ // reservation being created and the in-memory `activeTunnelUrl`
306
+ // being populated, plus any other zrok share the user points at us.
307
+ if (host.endsWith(".share.zrok.io")) return cb(null, true);
308
+ } catch { /* ignore URL parse errors */ }
309
+ // Explicitly configured origins.
261
310
  if (corsAllowedOrigins.includes(origin)) return cb(null, true);
262
- cb(new Error("CORS origin not allowed"), false);
311
+ // Unknown cross-origin request — don't emit CORS headers, but don't
312
+ // 500 either. Browser will block the request for us.
313
+ cb(null, false);
263
314
  },
264
315
  credentials: true,
265
316
  });
@@ -294,7 +345,16 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
294
345
  registerSessionRoutes(fastify, { sessionManager, eventStore, networkGuard });
295
346
  registerGitRoutes(fastify, { networkGuard });
296
347
  registerFileRoutes(fastify, { sessionManager, preferencesStore, networkGuard });
297
- registerOpenSpecRoutes(fastify, { sessionManager, preferencesStore, directoryService, networkGuard });
348
+ registerOpenSpecRoutes(fastify, {
349
+ sessionManager,
350
+ preferencesStore,
351
+ directoryService,
352
+ networkGuard,
353
+ onOpenSpecChanged: (cwd) => {
354
+ const data = directoryService.getOpenSpecData(cwd);
355
+ if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
356
+ },
357
+ });
298
358
  registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion });
299
359
  // Package management
300
360
  const packageManagerWrapper = new PackageManagerWrapper();
@@ -304,7 +364,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
304
364
  browserGateway.broadcastToAll({ type: "package_progress", operationId, event } as any);
305
365
  });
306
366
 
307
- // On completion: broadcast to browsers
367
+ // On completion: broadcast to browsers + invalidate the recommended cache
308
368
  packageManagerWrapper.setCompleteListener((result) => {
309
369
  browserGateway.broadcastToAll({
310
370
  type: "package_operation_complete",
@@ -316,6 +376,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
316
376
  error: result.error,
317
377
  sessionsReloaded: (result as any).sessionsReloaded,
318
378
  } as any);
379
+ if (result.success) invalidateRecommendedCache();
319
380
  });
320
381
 
321
382
  // Reload all active sessions after a successful package operation
@@ -337,14 +398,60 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
337
398
  });
338
399
 
339
400
  registerPackageRoutes(fastify, { packageManagerWrapper });
401
+ registerRecommendedRoutes(fastify, { packageManagerWrapper });
402
+
403
+ // Pi core version check + update (complements the extension package manager).
404
+ const piCoreChecker = new PiCoreChecker();
405
+ const piCoreUpdater = new PiCoreUpdater({
406
+ packageManagerWrapper,
407
+ onAllComplete: async () => {
408
+ const connectedIds = piGateway.getConnectedSessionIds();
409
+ let count = 0;
410
+ for (const sid of connectedIds) {
411
+ const session = sessionManager.get(sid);
412
+ if (session && session.status !== "ended") {
413
+ piGateway.sendToSession(sid, {
414
+ type: "send_prompt",
415
+ sessionId: sid,
416
+ text: "/reload",
417
+ });
418
+ count++;
419
+ }
420
+ }
421
+ return count;
422
+ },
423
+ });
424
+ piCoreUpdater.setProgressListener((event) => {
425
+ browserGateway.broadcastToAll({
426
+ type: "pi_core_update_progress",
427
+ name: event.name,
428
+ phase: event.phase,
429
+ message: event.message,
430
+ });
431
+ });
432
+ registerPiCoreRoutes(fastify, {
433
+ piCoreChecker,
434
+ piCoreUpdater,
435
+ onUpdateComplete: (payload) => {
436
+ browserGateway.broadcastToAll({
437
+ type: "pi_core_update_complete",
438
+ results: payload.results,
439
+ sessionsReloaded: payload.sessionsReloaded,
440
+ });
441
+ },
442
+ });
340
443
 
341
- // Editor (code-server) routes and proxy
444
+ // Editor (code-server) routes and proxy.
445
+ // NOTE: routes are *registered* here but cannot dispatch until fastify.listen runs
446
+ // inside server.start(). The orphan sweep in editorPidRegistry.cleanupOrphans()
447
+ // runs at the top of server.start() BEFORE fastify.listen, so any
448
+ // POST /api/editor/start call is guaranteed to see a post-sweep clean state.
342
449
  registerEditorRoutes(fastify, editorManager, { networkGuard });
343
450
  registerEditorProxy(fastify, editorManager);
344
451
 
345
- registerProviderAuthRoutes(fastify, { piGateway });
452
+ registerProviderAuthRoutes(fastify, { piGateway, browserGateway });
346
453
  registerKnownServersRoutes(fastify, { networkGuard, getPeerServers: () => peerServers });
347
- registerProviderRoutes(fastify, { networkGuard });
454
+ registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway });
348
455
 
349
456
  // Serve static files / SPA fallback
350
457
  // Search order: npm package → workspace sibling → legacy dist/client
@@ -371,6 +478,13 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
371
478
  await fastify.register(fastifyStatic, {
372
479
  root: clientDir,
373
480
  prefix: "/",
481
+ // Serve pre-compressed sibling files (assets/foo.js.gz alongside foo.js)
482
+ // directly when the client accepts gzip. This gives every compressed
483
+ // response a stable Content-Length header — dynamic compression via
484
+ // @fastify/compress streams responses without Content-Length, which
485
+ // some HTTP/2 proxy chains (notably zrok free-tier) occasionally
486
+ // stream-reset as ERR_ABORTED 500 in browsers.
487
+ preCompressed: true,
374
488
  setHeaders: (res, filePath) => {
375
489
  if (filePath.endsWith(".html")) {
376
490
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
@@ -435,10 +549,23 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
435
549
  eventStore,
436
550
  browserGateway,
437
551
 
552
+ httpPort() {
553
+ const addr = fastify.server.address();
554
+ if (addr && typeof addr === "object") return addr.port;
555
+ return null;
556
+ },
557
+ piPort() {
558
+ return piGateway.address();
559
+ },
560
+
438
561
  async start() {
439
562
  // Clean up orphan headless processes from a previous server instance
440
563
  browserGateway.headlessPidRegistry.cleanupOrphans();
441
564
 
565
+ // Clean up orphan code-server processes from a previous server instance.
566
+ // Runs before fastify.listen, so no editor start request can race with the sweep.
567
+ await editorPidRegistry.cleanupOrphans();
568
+
442
569
  piGateway.start(config.piPort);
443
570
 
444
571
  fastify.server.on("upgrade", (request, socket, head) => {
@@ -501,10 +628,19 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
501
628
  console.warn(`mDNS browser failed (peer discovery disabled):`, err);
502
629
  }
503
630
 
631
+ // Always sweep leftover zrok processes on startup, even when tunnel is
632
+ // disabled (--no-tunnel). Orphans from a previous run hold reservations
633
+ // on the zrok edge and keep old URLs "alive but broken" until their
634
+ // agents are killed. Scavenge runs unconditionally when the binary is
635
+ // present; the tunnel-creation branch below is gated separately.
636
+ const hasZrok = detectZrokBinary();
637
+ if (hasZrok) {
638
+ cleanupStaleZrok();
639
+ scavengeOrphanZrokProcesses(config.port);
640
+ }
641
+
504
642
  if (config.tunnel) {
505
- const hasZrok = detectZrokBinary();
506
643
  if (hasZrok) {
507
- cleanupStaleZrok();
508
644
  const tunnelUrl = await createTunnel(config.port, config.tunnelReservedToken);
509
645
  if (tunnelUrl) {
510
646
  console.log(`🌐 Tunnel: ${tunnelUrl}`);
@@ -534,7 +670,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
534
670
  preferencesStore.flush();
535
671
  preferencesStore.dispose();
536
672
 
537
- await deleteTunnel();
673
+ await deleteTunnel(config.port);
538
674
  piGateway.stop();
539
675
  for (const client of browserGateway.wss.clients) {
540
676
  client.terminate();
@@ -4,6 +4,7 @@
4
4
  import * as pty from "node-pty";
5
5
  import type { IPty } from "node-pty";
6
6
  import { randomBytes } from "node:crypto";
7
+ import { fixPtyPermissions } from "./fix-pty-permissions.js";
7
8
  import type { TerminalSession, TerminalControlMessage } from "@blackbelt-technology/pi-dashboard-shared/terminal-types.js";
8
9
  import type { WebSocket } from "ws";
9
10
 
@@ -99,6 +100,9 @@ function generateId(): string {
99
100
  }
100
101
 
101
102
  export function createTerminalManager(options?: TerminalManagerOptions): TerminalManager {
103
+ // Fix node-pty spawn-helper permissions at runtime (defense in depth)
104
+ fixPtyPermissions();
105
+
102
106
  const entries = new Map<string, TerminalEntry>();
103
107
  const bufferSize = options?.bufferSize ?? DEFAULT_BUFFER_SIZE;
104
108
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Defense-in-depth guard against destructive PID-registry sweeps during tests.
3
+ *
4
+ * Production startup code paths (headlessPidRegistry.cleanupOrphans/killAll,
5
+ * editorPidRegistry.cleanupOrphans) read PID files and send SIGTERM. If they
6
+ * ever run under vitest AGAINST the developer's real $HOME, they can kill
7
+ * live pi sessions.
8
+ *
9
+ * This guard returns true when:
10
+ * - we appear to be inside a vitest run (VITEST env var), AND
11
+ * - HOME still points at the real user home (tripwire missed).
12
+ *
13
+ * Callers SHOULD `console.warn` and return without performing destructive work
14
+ * when this returns true.
15
+ *
16
+ * Normal production servers (VITEST not set) always get `false` and behave as
17
+ * before.
18
+ */
19
+ import os from "node:os";
20
+
21
+ export function isUnsafeTestHomeScan(): boolean {
22
+ if (process.env.VITEST !== "true") return false;
23
+ const currentHome = process.env.HOME ?? "";
24
+ const realHome = os.userInfo().homedir;
25
+ return currentHome === "" || currentHome === realHome;
26
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * createTestServer — boot a real DashboardServer on OS-assigned ports for
3
+ * integration tests, with safe defaults (no auto-shutdown, no tunnel).
4
+ *
5
+ * Use with the `setup-home` vitest setupFile (in @blackbelt-technology/pi-dashboard-shared/test-support)
6
+ * so that HOME is also isolated.
7
+ *
8
+ * Example:
9
+ * const { server, httpPort, piPort, stop } = await createTestServer();
10
+ * const res = await fetch(`http://localhost:${httpPort}/api/health`);
11
+ * ...
12
+ * await stop();
13
+ */
14
+ import { createServer, type DashboardServer, type ServerConfig } from "../server.js";
15
+
16
+ export interface TestServerHandle {
17
+ server: DashboardServer;
18
+ httpPort: number;
19
+ piPort: number;
20
+ stop: () => Promise<void>;
21
+ }
22
+
23
+ export type TestServerOverrides = Partial<ServerConfig>;
24
+
25
+ const DEFAULTS: ServerConfig = {
26
+ port: 0,
27
+ piPort: 0,
28
+ dev: true,
29
+ autoShutdown: false,
30
+ shutdownIdleSeconds: 999,
31
+ tunnel: false,
32
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
33
+ };
34
+
35
+ export async function createTestServer(
36
+ overrides: TestServerOverrides = {},
37
+ ): Promise<TestServerHandle> {
38
+ const config: ServerConfig = { ...DEFAULTS, ...overrides };
39
+ const server = await createServer(config);
40
+ await server.start();
41
+
42
+ const httpPort = server.httpPort();
43
+ const piPort = server.piPort();
44
+ if (httpPort == null || piPort == null) {
45
+ await server.stop();
46
+ throw new Error(
47
+ `createTestServer: failed to resolve ports (httpPort=${httpPort}, piPort=${piPort})`,
48
+ );
49
+ }
50
+
51
+ return {
52
+ server,
53
+ httpPort,
54
+ piPort,
55
+ stop: async () => {
56
+ try {
57
+ await server.stop();
58
+ } catch {
59
+ // best-effort — tests may race on shutdown
60
+ }
61
+ },
62
+ };
63
+ }