@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.
- package/AGENTS.md +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
}
|