@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/doctor — server-side diagnostic endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Calls `runSharedChecks(...)` with server-appropriate `deps`, post-stamps
|
|
5
|
+
* `section` + `suggestion`, returns `{ checks, summary, generatedAt }`.
|
|
6
|
+
*
|
|
7
|
+
* Auth-gated identically to `/api/config`; unauthenticated requests yield
|
|
8
|
+
* the same status code as `/api/config` (the auth plugin's onRequest hook
|
|
9
|
+
* intercepts before this handler runs).
|
|
10
|
+
*
|
|
11
|
+
* On a thrown error from `runSharedChecks`, returns a 200 with a single
|
|
12
|
+
* fallback `error` row rather than a 500 — the web client always has
|
|
13
|
+
* something to render. See change: doctor-rich-output (tasks 4.1–4.5).
|
|
14
|
+
*/
|
|
15
|
+
import type { FastifyInstance } from "fastify";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import {
|
|
20
|
+
runSharedChecks,
|
|
21
|
+
stampSectionsAndSuggestions,
|
|
22
|
+
safeExec,
|
|
23
|
+
type DoctorCheck,
|
|
24
|
+
type DoctorReport,
|
|
25
|
+
type SharedChecksDeps,
|
|
26
|
+
} from "@blackbelt-technology/pi-dashboard-shared/doctor-core.js";
|
|
27
|
+
|
|
28
|
+
function getManagedDir(): string {
|
|
29
|
+
return process.env.MANAGED_DIR || path.join(os.homedir(), ".pi-dashboard");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function detectSystemNode(): { found: boolean; path?: string } {
|
|
33
|
+
const cmd = process.platform === "win32" ? "where node" : "which node"; // platform-branch-ok: localised PATH-lookup primitive
|
|
34
|
+
const r = safeExec(cmd, { timeoutMs: 3000 });
|
|
35
|
+
if (!r.ok) return { found: false };
|
|
36
|
+
const first = r.stdout.trim().split("\n")[0];
|
|
37
|
+
return first ? { found: true, path: first } : { found: false };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function detectOnPath(name: string): { found: boolean; path?: string; source?: string } {
|
|
41
|
+
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`; // platform-branch-ok: localised PATH-lookup primitive
|
|
42
|
+
const r = safeExec(cmd, { timeoutMs: 3000 });
|
|
43
|
+
if (!r.ok) return { found: false };
|
|
44
|
+
const first = r.stdout.trim().split("\n")[0];
|
|
45
|
+
return first ? { found: true, path: first, source: "system" } : { found: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isApiKeyConfigured(): boolean {
|
|
49
|
+
try {
|
|
50
|
+
const settings = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
51
|
+
if (!existsSync(settings)) return false;
|
|
52
|
+
const data = JSON.parse(readFileSync(settings, "utf-8"));
|
|
53
|
+
if (data?.anthropicApiKey || data?.openaiApiKey || data?.apiKey) return true;
|
|
54
|
+
if (data?.providers && typeof data.providers === "object") {
|
|
55
|
+
for (const v of Object.values(data.providers as Record<string, unknown>)) {
|
|
56
|
+
if (v && typeof v === "object" && "apiKey" in (v as object)) return true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildDefaultDeps(): SharedChecksDeps {
|
|
66
|
+
return {
|
|
67
|
+
managedDir: getManagedDir(),
|
|
68
|
+
detectSystemNode,
|
|
69
|
+
detectPi: () => detectOnPath("pi"),
|
|
70
|
+
detectOpenSpec: () => detectOnPath("openspec"),
|
|
71
|
+
isApiKeyConfigured,
|
|
72
|
+
probeServer: async () => {
|
|
73
|
+
const r = safeExec("curl -sf http://localhost:8000/api/health", { timeoutMs: 3000 });
|
|
74
|
+
if (!r.ok || !r.stdout.trim()) return { running: false };
|
|
75
|
+
try {
|
|
76
|
+
const h = JSON.parse(r.stdout);
|
|
77
|
+
return {
|
|
78
|
+
running: true,
|
|
79
|
+
version: typeof h.version === "string" ? h.version : undefined,
|
|
80
|
+
mode: typeof h.mode === "string" ? h.mode : undefined,
|
|
81
|
+
starter: typeof h.starter === "string" ? h.starter : null,
|
|
82
|
+
installable:
|
|
83
|
+
h.installable && typeof h.installable === "object"
|
|
84
|
+
? {
|
|
85
|
+
total: h.installable.total ?? 0,
|
|
86
|
+
installed: h.installable.installed ?? 0,
|
|
87
|
+
failed: Array.isArray(h.installable.failed) ? h.installable.failed : [],
|
|
88
|
+
}
|
|
89
|
+
: null,
|
|
90
|
+
};
|
|
91
|
+
} catch {
|
|
92
|
+
return { running: true };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function summarize(checks: DoctorCheck[]): DoctorReport["summary"] {
|
|
99
|
+
return {
|
|
100
|
+
ok: checks.filter((c) => c.status === "ok").length,
|
|
101
|
+
warnings: checks.filter((c) => c.status === "warning").length,
|
|
102
|
+
errors: checks.filter((c) => c.status === "error").length,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface DoctorRouteDeps {
|
|
107
|
+
/** Override for tests — substitutes a different `runSharedChecks` deps shape (or throws to exercise fault tolerance). */
|
|
108
|
+
buildDeps?: () => SharedChecksDeps;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function registerDoctorRoutes(fastify: FastifyInstance, deps: DoctorRouteDeps = {}): void {
|
|
112
|
+
fastify.get("/api/doctor", async (_request, _reply): Promise<DoctorReport> => {
|
|
113
|
+
try {
|
|
114
|
+
const sharedDeps = deps.buildDeps ? deps.buildDeps() : buildDefaultDeps();
|
|
115
|
+
const checks = await runSharedChecks(sharedDeps);
|
|
116
|
+
stampSectionsAndSuggestions(checks);
|
|
117
|
+
return {
|
|
118
|
+
checks,
|
|
119
|
+
summary: summarize(checks),
|
|
120
|
+
generatedAt: Date.now(),
|
|
121
|
+
};
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
124
|
+
const fallback: DoctorCheck = {
|
|
125
|
+
name: "Doctor failed to produce a report",
|
|
126
|
+
section: "diagnostics",
|
|
127
|
+
status: "error",
|
|
128
|
+
message: "Unexpected internal failure",
|
|
129
|
+
detail: `${e.message}\n${(e.stack || "").split("\n").slice(0, 4).join("\n")}`,
|
|
130
|
+
suggestion:
|
|
131
|
+
"Check `~/.pi-dashboard/doctor.log` on the server, then file an issue with the captured error.",
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
checks: [fallback],
|
|
135
|
+
summary: { ok: 0, warnings: 0, errors: 1 },
|
|
136
|
+
generatedAt: Date.now(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveAuthJsonKey,
|
|
19
19
|
type ApiKeyCredential,
|
|
20
20
|
} from "../provider-auth-storage.js";
|
|
21
|
+
import { getLatestCatalogue } from "../provider-catalogue-cache.js";
|
|
21
22
|
import { startCallbackServer } from "../oauth-callback-server.js";
|
|
22
23
|
import type { PiGateway } from "../pi-gateway.js";
|
|
23
24
|
import type { BrowserGateway } from "../browser-gateway.js";
|
|
@@ -98,6 +99,14 @@ export function registerProviderAuthRoutes(
|
|
|
98
99
|
|
|
99
100
|
// Full status (OAuth + API key)
|
|
100
101
|
fastify.get("/api/provider-auth/status", async () => {
|
|
102
|
+
// Cold-cache nudge: if no bridge has pushed a catalogue yet, ask
|
|
103
|
+
// every connected pi to send one. Best-effort, doesn't block this
|
|
104
|
+
// response. See change: replace-hardcoded-provider-lists.
|
|
105
|
+
if (getLatestCatalogue().length === 0) {
|
|
106
|
+
for (const sid of piGateway.getConnectedSessionIds()) {
|
|
107
|
+
piGateway.sendToSession(sid, { type: "request_providers", sessionId: sid });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
101
110
|
return getAuthStatus();
|
|
102
111
|
});
|
|
103
112
|
|
|
@@ -19,8 +19,10 @@ import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.j
|
|
|
19
19
|
import path from "node:path";
|
|
20
20
|
import os from "node:os";
|
|
21
21
|
import { localhostGuard, netmaskToCidrBits, networkAddress } from "../localhost-guard.js";
|
|
22
|
+
import { readSpawnFailures } from "../spawn-failure-log.js";
|
|
22
23
|
import { getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
|
|
23
24
|
import type { NetworkInterface } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
|
|
25
|
+
import type { BootstrapStateStore } from "../bootstrap-state.js";
|
|
24
26
|
|
|
25
27
|
export function registerSystemRoutes(
|
|
26
28
|
fastify: FastifyInstance,
|
|
@@ -33,9 +35,10 @@ export function registerSystemRoutes(
|
|
|
33
35
|
version?: string;
|
|
34
36
|
directoryService?: DirectoryService;
|
|
35
37
|
piGateway?: PiGateway;
|
|
38
|
+
bootstrapState?: BootstrapStateStore;
|
|
36
39
|
},
|
|
37
40
|
) {
|
|
38
|
-
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway } = deps;
|
|
41
|
+
const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway, bootstrapState } = deps;
|
|
39
42
|
|
|
40
43
|
// Quiesce windows for the bridge `server_restarting` broadcast. See change
|
|
41
44
|
// `fix-restart-bridge-auto-start-race`. Bridges that receive this message
|
|
@@ -189,6 +192,8 @@ export function registerSystemRoutes(
|
|
|
189
192
|
return {
|
|
190
193
|
ok: true,
|
|
191
194
|
pid: process.pid,
|
|
195
|
+
starter: bootstrapState?.get().starter ?? "Standalone",
|
|
196
|
+
installable: bootstrapState?.get().installable,
|
|
192
197
|
version: version ?? "unknown",
|
|
193
198
|
uptime: Math.floor((Date.now() - serverStartTime) / 1000),
|
|
194
199
|
mode: config.dev ? "dev" : "production",
|
|
@@ -223,6 +228,26 @@ export function registerSystemRoutes(
|
|
|
223
228
|
},
|
|
224
229
|
);
|
|
225
230
|
|
|
231
|
+
// Re-extract endpoint — Electron-only; 403 for Bridge/Standalone, 202 for Electron.
|
|
232
|
+
// See change: simplify-electron-bootstrap-derived-state (task 6.4).
|
|
233
|
+
fastify.post(
|
|
234
|
+
"/api/electron/reextract",
|
|
235
|
+
{ preHandler: networkGuard },
|
|
236
|
+
async (_request, reply) => {
|
|
237
|
+
const starter = bootstrapState?.get().starter ?? "Standalone";
|
|
238
|
+
if (starter !== "Electron") {
|
|
239
|
+
reply.status(403);
|
|
240
|
+
return {
|
|
241
|
+
error: "reextract_not_allowed",
|
|
242
|
+
message: `Re-extract is only available when the server was started by Electron (current starter: ${starter})`,
|
|
243
|
+
starter,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
reply.status(202);
|
|
247
|
+
return { ok: true, message: "Re-extraction scheduled. Electron will restart the server." };
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
|
|
226
251
|
// Restart endpoint — flush state, spawn new server, then exit
|
|
227
252
|
fastify.post<{ Body: { dev?: boolean } }>(
|
|
228
253
|
"/api/restart",
|
|
@@ -269,6 +294,18 @@ export function registerSystemRoutes(
|
|
|
269
294
|
);
|
|
270
295
|
|
|
271
296
|
// Network interfaces for trusted networks UI (localhost-only for security)
|
|
297
|
+
// GET /api/spawn-failures — rolling log of failed spawn attempts. See change: spawn-failure-diagnostics.
|
|
298
|
+
fastify.get<{ Querystring: { limit?: string } }>(
|
|
299
|
+
"/api/spawn-failures",
|
|
300
|
+
async (request) => {
|
|
301
|
+
const rawLimit = request.query.limit;
|
|
302
|
+
const parsed = rawLimit !== undefined ? parseInt(rawLimit, 10) : NaN;
|
|
303
|
+
const limit = Number.isNaN(parsed) ? 50 : parsed;
|
|
304
|
+
const entries = readSpawnFailures(limit);
|
|
305
|
+
return { entries };
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
272
309
|
fastify.get(
|
|
273
310
|
"/api/network-interfaces",
|
|
274
311
|
{ preHandler: localhostGuard },
|
|
@@ -23,7 +23,7 @@ import { createPendingResumeIntentRegistry } from "./pending-resume-intent-regis
|
|
|
23
23
|
import { applyReattachPolicy } from "./reattach-placement.js";
|
|
24
24
|
|
|
25
25
|
// pending-load-manager removed — server loads sessions directly via DirectoryService
|
|
26
|
-
import { createDirectoryService, type DirectoryService } from "./directory-service.js";
|
|
26
|
+
import { createDirectoryService, isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
|
|
27
27
|
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
|
28
28
|
import { createTerminalGateway, type TerminalGateway } from "./terminal-gateway.js";
|
|
29
29
|
import { writePid, removePid } from "./server-pid.js";
|
|
@@ -45,6 +45,7 @@ import { registerGitRoutes } from "./routes/git-routes.js";
|
|
|
45
45
|
import { registerFileRoutes } from "./routes/file-routes.js";
|
|
46
46
|
import { registerOpenSpecRoutes } from "./routes/openspec-routes.js";
|
|
47
47
|
import { registerSystemRoutes } from "./routes/system-routes.js";
|
|
48
|
+
import { registerDoctorRoutes } from "./routes/doctor-routes.js";
|
|
48
49
|
import { registerProviderAuthRoutes } from "./routes/provider-auth-routes.js";
|
|
49
50
|
import { registerPackageRoutes } from "./routes/package-routes.js";
|
|
50
51
|
import { registerRecommendedRoutes, invalidateRecommendedCache } from "./routes/recommended-routes.js";
|
|
@@ -143,10 +144,6 @@ export interface PostInstallRepairDeps {
|
|
|
143
144
|
browserGateway: { broadcastToAll(msg: ServerToBrowserMessage): void };
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
|
|
147
|
-
if (!d) return true;
|
|
148
|
-
return !d.initialized && (!d.changes || d.changes.length === 0);
|
|
149
|
-
}
|
|
150
147
|
|
|
151
148
|
/**
|
|
152
149
|
* Centralized post-install repair work fired on every `installing → ready`
|
|
@@ -563,7 +560,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
563
560
|
});
|
|
564
561
|
|
|
565
562
|
// Auto-shutdown idle timer
|
|
566
|
-
|
|
563
|
+
// Active terminals keep the server alive even when no pi sessions are
|
|
564
|
+
// attached. See change: fix-terminal-half-height-dual-mount.
|
|
565
|
+
const idleTimer = createIdleTimer(config, piGateway, () => terminalManager.list().length > 0);
|
|
567
566
|
|
|
568
567
|
const fastify = Fastify({
|
|
569
568
|
logger: false,
|
|
@@ -709,7 +708,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
|
|
|
709
708
|
if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
|
|
710
709
|
},
|
|
711
710
|
});
|
|
712
|
-
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
|
|
711
|
+
registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway, bootstrapState });
|
|
712
|
+
// GET /api/doctor — see change: doctor-rich-output (task 4.2). Auth-gated identically to /api/config.
|
|
713
|
+
registerDoctorRoutes(fastify);
|
|
713
714
|
registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
|
|
714
715
|
registerJjRoutes(fastify, { browserGateway, pendingAttachRegistry, networkGuard });
|
|
715
716
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { SessionManager } from "./memory-session-manager.js";
|
|
6
6
|
import type { BrowserGateway } from "./browser-gateway.js";
|
|
7
|
-
import type
|
|
7
|
+
import { isOpenSpecDataEmpty, type DirectoryService } from "./directory-service.js";
|
|
8
8
|
import { extractSessionStats } from "./session-stats-reader.js";
|
|
9
9
|
|
|
10
10
|
export interface SessionBootstrapDeps {
|
|
@@ -75,20 +75,35 @@ export async function discoverAndBroadcastSessions(deps: SessionBootstrapDeps):
|
|
|
75
75
|
|
|
76
76
|
// Initial OpenSpec poll for all known directories.
|
|
77
77
|
//
|
|
78
|
-
//
|
|
79
|
-
// (spawnSync per change) — on Windows with many active changes
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// openspec data populates in the background and pushes `openspec_update`
|
|
84
|
-
// broadcasts to browsers as each directory finishes.
|
|
78
|
+
// Fire-and-forget: `refreshOpenSpec` / `pollOpenSpec` is synchronous internally
|
|
79
|
+
// (spawnSync per change) — on Windows with many active changes and multiple
|
|
80
|
+
// pinned directories this can block the event loop for minutes, making the
|
|
81
|
+
// HTTP server unresponsive during startup. We intentionally do NOT await it
|
|
82
|
+
// here so HTTP + WebSocket startup completes immediately.
|
|
85
83
|
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
84
|
+
// After each directory's poll completes, broadcast `openspec_update` to all
|
|
85
|
+
// connected browsers if the prior cache was empty/undefined or the polled
|
|
86
|
+
// data differs from prior — mirroring the proven `runPostInstallRepair`
|
|
87
|
+
// pattern in `server.ts`. This is what unblocks cold-boot Electron clients
|
|
88
|
+
// that connected before the cache was hot.
|
|
89
|
+
//
|
|
90
|
+
// A proper fix for the slow `spawnSync` path is to migrate the openspec
|
|
91
|
+
// Recipe to async spawn; tracked separately. See change:
|
|
92
|
+
// consolidate-tool-resolution. This change covers the broadcast wiring only.
|
|
93
|
+
// See change: fix-cold-boot-openspec-protocol.
|
|
88
94
|
void Promise.all(
|
|
89
95
|
directoryService.knownDirectories().map(async (cwd) => {
|
|
90
|
-
try {
|
|
91
|
-
|
|
96
|
+
try {
|
|
97
|
+
const prior = directoryService.getOpenSpecData(cwd);
|
|
98
|
+
const fresh = await directoryService.refreshOpenSpec(cwd);
|
|
99
|
+
const priorEmpty = isOpenSpecDataEmpty(prior);
|
|
100
|
+
const dataDiffers = JSON.stringify(prior) !== JSON.stringify(fresh);
|
|
101
|
+
if (priorEmpty || dataDiffers) {
|
|
102
|
+
browserGateway.broadcastToAll({ type: "openspec_update", cwd, data: fresh });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err);
|
|
106
|
+
}
|
|
92
107
|
}),
|
|
93
108
|
);
|
|
94
109
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
import os from "node:os";
|
|
9
|
+
import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
|
|
9
10
|
|
|
10
11
|
export interface DiscoveredSession {
|
|
11
12
|
id: string;
|
|
@@ -53,15 +54,21 @@ function readSessionHeader(filePath: string): {
|
|
|
53
54
|
if (entry.type === "session_info" && entry.name) {
|
|
54
55
|
name = entry.name;
|
|
55
56
|
}
|
|
56
|
-
// Find first user message
|
|
57
|
+
// Find first user message. Skill invocations are stored as a
|
|
58
|
+
// `<skill name=...>...</skill>\n\nargs` envelope (~264 chars for typical
|
|
59
|
+
// absolute paths) which is longer than the 200-char firstMessage budget,
|
|
60
|
+
// so a naive .slice(0, 200) cuts the wrapper in half. condenseForFirstMessage
|
|
61
|
+
// returns the condensed slash form (`/skill:name args`) when the input
|
|
62
|
+
// matches the envelope, falling back to the raw slice otherwise.
|
|
63
|
+
// See change: render-skill-invocations-collapsibly.
|
|
57
64
|
if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
|
|
58
65
|
const msg = entry.message;
|
|
59
66
|
if (typeof msg.content === "string") {
|
|
60
|
-
firstMessage = msg.content
|
|
67
|
+
firstMessage = condenseForFirstMessage(msg.content, 200);
|
|
61
68
|
} else if (Array.isArray(msg.content)) {
|
|
62
69
|
for (const part of msg.content) {
|
|
63
70
|
if (part.type === "text" && part.text) {
|
|
64
|
-
firstMessage = part.text
|
|
71
|
+
firstMessage = condenseForFirstMessage(part.text, 200);
|
|
65
72
|
break;
|
|
66
73
|
}
|
|
67
74
|
}
|
|
@@ -8,6 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import type { DashboardSession, SessionSource } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
10
10
|
import { type SessionMeta, metaPath, readSessionMeta, writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
|
|
11
|
+
import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
|
|
11
12
|
import { extractSessionStats } from "./session-stats-reader.js";
|
|
12
13
|
|
|
13
14
|
function getSessionsDir(): string {
|
|
@@ -236,14 +237,15 @@ function readJsonlHeaderSync(filePath: string): { id: string; cwd: string; name?
|
|
|
236
237
|
const entry = JSON.parse(line);
|
|
237
238
|
if (entry.type === "session" && entry.id) header = entry;
|
|
238
239
|
if (entry.type === "session_info" && entry.name) name = entry.name;
|
|
240
|
+
// See change: render-skill-invocations-collapsibly.
|
|
239
241
|
if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
|
|
240
242
|
const msg = entry.message;
|
|
241
243
|
if (typeof msg.content === "string") {
|
|
242
|
-
firstMessage = msg.content
|
|
244
|
+
firstMessage = condenseForFirstMessage(msg.content, 200);
|
|
243
245
|
} else if (Array.isArray(msg.content)) {
|
|
244
246
|
for (const part of msg.content) {
|
|
245
247
|
if (part.type === "text" && part.text) {
|
|
246
|
-
firstMessage = part.text
|
|
248
|
+
firstMessage = condenseForFirstMessage(part.text, 200);
|
|
247
249
|
break;
|
|
248
250
|
}
|
|
249
251
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling NDJSON log of failed pi session spawn attempts.
|
|
3
|
+
*
|
|
4
|
+
* Location: ~/.pi/dashboard/sessions/spawn-failures.log
|
|
5
|
+
* Rotation: single-shot at 10 MB (renames to .log.1, overwrites any prior .log.1).
|
|
6
|
+
* Format: one JSON object per line, terminated by \n.
|
|
7
|
+
*
|
|
8
|
+
* See change: spawn-failure-diagnostics.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
appendFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
statSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import type { PreflightReason } from "./spawn-preflight.js";
|
|
21
|
+
|
|
22
|
+
export interface SpawnFailureEntry {
|
|
23
|
+
/** ISO 8601 UTC timestamp. */
|
|
24
|
+
ts: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
strategy: string;
|
|
27
|
+
code: string;
|
|
28
|
+
message: string;
|
|
29
|
+
stderrTail?: string;
|
|
30
|
+
pid?: number;
|
|
31
|
+
reasons?: PreflightReason[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
35
|
+
const DEFAULT_LIMIT = 50;
|
|
36
|
+
const MAX_LIMIT = 500;
|
|
37
|
+
|
|
38
|
+
let _logDirOverride: string | null = null;
|
|
39
|
+
|
|
40
|
+
/** Override the log directory — for tests only. See change: spawn-failure-diagnostics. */
|
|
41
|
+
export function _setLogDirForTests(dir: string | null): void {
|
|
42
|
+
_logDirOverride = dir;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function logDir(): string {
|
|
46
|
+
return _logDirOverride ?? path.join(os.homedir(), ".pi", "dashboard", "sessions");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function logPath(): string {
|
|
50
|
+
return path.join(logDir(), "spawn-failures.log");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function logPath1(): string {
|
|
54
|
+
return path.join(logDir(), "spawn-failures.log.1");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Append a failure entry to the rolling log.
|
|
59
|
+
* Never throws — errors are caught and reported via `console.error`.
|
|
60
|
+
*/
|
|
61
|
+
export function appendSpawnFailure(entry: SpawnFailureEntry): void {
|
|
62
|
+
try {
|
|
63
|
+
const dir = logDir();
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
const filePath = logPath();
|
|
67
|
+
const line = JSON.stringify(entry) + "\n";
|
|
68
|
+
|
|
69
|
+
// Rotate if file exceeds threshold.
|
|
70
|
+
if (existsSync(filePath)) {
|
|
71
|
+
try {
|
|
72
|
+
const { size } = statSync(filePath);
|
|
73
|
+
if (size > LOG_MAX_BYTES) {
|
|
74
|
+
renameSync(filePath, logPath1());
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// If stat/rename fails, just write anyway.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
appendFileSync(filePath, line, "utf-8");
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error("[spawn-failure-log] Failed to append entry:", err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the last `limit` entries from the rolling log (both .log.1 and .log).
|
|
89
|
+
* Skips malformed lines. Returns [] when no log exists.
|
|
90
|
+
*/
|
|
91
|
+
export function readSpawnFailures(limit: number = DEFAULT_LIMIT): SpawnFailureEntry[] {
|
|
92
|
+
const effectiveLimit = Number.isNaN(limit) ? DEFAULT_LIMIT : Math.max(0, Math.min(limit, MAX_LIMIT));
|
|
93
|
+
if (effectiveLimit === 0) return [];
|
|
94
|
+
|
|
95
|
+
const lines: string[] = [];
|
|
96
|
+
|
|
97
|
+
// Read older log first, then newer.
|
|
98
|
+
for (const filePath of [logPath1(), logPath()]) {
|
|
99
|
+
if (!existsSync(filePath)) continue;
|
|
100
|
+
try {
|
|
101
|
+
const content = readFileSync(filePath, "utf-8");
|
|
102
|
+
lines.push(...content.split("\n").filter((l) => l.trim()));
|
|
103
|
+
} catch {
|
|
104
|
+
// Skip unreadable file.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Parse, skipping malformed lines.
|
|
109
|
+
const entries: SpawnFailureEntry[] = [];
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
try {
|
|
112
|
+
const obj = JSON.parse(line) as Record<string, unknown>;
|
|
113
|
+
// Require the minimum fields.
|
|
114
|
+
if (
|
|
115
|
+
typeof obj.ts === "string" &&
|
|
116
|
+
typeof obj.cwd === "string" &&
|
|
117
|
+
typeof obj.strategy === "string" &&
|
|
118
|
+
typeof obj.code === "string" &&
|
|
119
|
+
typeof obj.message === "string"
|
|
120
|
+
) {
|
|
121
|
+
entries.push(obj as unknown as SpawnFailureEntry);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Skip malformed line.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Return last N in file order.
|
|
129
|
+
return entries.length <= effectiveLimit ? entries : entries.slice(entries.length - effectiveLimit);
|
|
130
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous spawn preflight check.
|
|
3
|
+
*
|
|
4
|
+
* Runs before every `spawnPiSession` invocation to catch fast-fail conditions
|
|
5
|
+
* (bad cwd, missing binaries) without racing the spawn itself. All checks run
|
|
6
|
+
* regardless of earlier failures so the caller gets all reasons in one pass.
|
|
7
|
+
*
|
|
8
|
+
* The ToolResolver passed in MUST have `useLoginShell: false` — preflight
|
|
9
|
+
* must never spawn a login shell on the spawn-click hot path. If a resolver
|
|
10
|
+
* with `useLoginShell: true` is passed, the check still runs but a one-time
|
|
11
|
+
* warning is emitted.
|
|
12
|
+
*
|
|
13
|
+
* See change: spawn-failure-diagnostics.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, accessSync, statSync, constants } from "node:fs";
|
|
16
|
+
import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
|
|
17
|
+
|
|
18
|
+
export interface PreflightReason {
|
|
19
|
+
code: string;
|
|
20
|
+
message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PreflightResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
reasons: PreflightReason[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run all preflight checks for `cwd` and return the accumulated reasons.
|
|
30
|
+
* `ok` is `true` iff `reasons.length === 0`.
|
|
31
|
+
*
|
|
32
|
+
* @param deps.resolver - Must be constructed with `useLoginShell: false`.
|
|
33
|
+
* If omitted, a login-shell-disabled resolver is created automatically.
|
|
34
|
+
* Passing a resolver with `useLoginShell: true` violates the preflight
|
|
35
|
+
* contract; the function still runs but may call into the login shell.
|
|
36
|
+
*/
|
|
37
|
+
export function preflightSpawn(
|
|
38
|
+
cwd: string,
|
|
39
|
+
deps?: { resolver?: ToolResolver },
|
|
40
|
+
): PreflightResult {
|
|
41
|
+
const resolver = deps?.resolver ?? new ToolResolver({ processExecPath: process.execPath, useLoginShell: false });
|
|
42
|
+
|
|
43
|
+
const reasons: PreflightReason[] = [];
|
|
44
|
+
|
|
45
|
+
// 1. cwd exists
|
|
46
|
+
const cwdExists = existsSync(cwd);
|
|
47
|
+
if (!cwdExists) {
|
|
48
|
+
reasons.push({ code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` });
|
|
49
|
+
// No point checking isDirectory / writable if it doesn't exist.
|
|
50
|
+
} else {
|
|
51
|
+
// 2. cwd is a directory
|
|
52
|
+
try {
|
|
53
|
+
const stat = statSync(cwd);
|
|
54
|
+
if (!stat.isDirectory()) {
|
|
55
|
+
reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Path is not a directory: ${cwd}` });
|
|
56
|
+
}
|
|
57
|
+
} catch (err: any) {
|
|
58
|
+
reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Cannot stat path: ${err.message}` });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. cwd is writable
|
|
62
|
+
try {
|
|
63
|
+
accessSync(cwd, constants.W_OK);
|
|
64
|
+
} catch {
|
|
65
|
+
reasons.push({ code: "DIR_NOT_WRITABLE", message: `Directory is not writable: ${cwd}` });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 4. pi resolves
|
|
70
|
+
const piCmd = resolver.resolvePi();
|
|
71
|
+
if (piCmd === null) {
|
|
72
|
+
reasons.push({ code: "PI_NOT_FOUND", message: "pi binary not found via managed install or system PATH" });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 5. node resolves
|
|
76
|
+
const nodeCmd = resolver.resolveNode();
|
|
77
|
+
if (nodeCmd === null) {
|
|
78
|
+
reasons.push({ code: "NODE_NOT_FOUND", message: "node binary not found via managed install or system PATH" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { ok: reasons.length === 0, reasons };
|
|
82
|
+
}
|