@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,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap install reconciler driven by ~/.pi/dashboard/installable.json.
|
|
3
|
+
* Invoked by cli.ts before app.listen.
|
|
4
|
+
*
|
|
5
|
+
* File-absent path is a deliberate no-op: Bridge and Standalone starters
|
|
6
|
+
* never write installable.json; only Electron seeds it on first run.
|
|
7
|
+
* When the file is absent, this function logs and returns immediately so
|
|
8
|
+
* bootstrap.status transitions to "ready" without delay.
|
|
9
|
+
*
|
|
10
|
+
* See change: simplify-electron-bootstrap-derived-state.
|
|
11
|
+
*/
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { getManagedDir } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
|
|
16
|
+
import {
|
|
17
|
+
readInstallableList,
|
|
18
|
+
type InstallablePackage,
|
|
19
|
+
} from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
|
|
20
|
+
import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
|
|
21
|
+
import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
|
|
22
|
+
import type { BootstrapStateStore } from "./bootstrap-state.js";
|
|
23
|
+
|
|
24
|
+
// ── Injectable helpers (overridable in tests) ──────────────────────────────
|
|
25
|
+
|
|
26
|
+
export type InstallProgressCallback = (line: string) => void;
|
|
27
|
+
export type PackageInstaller = (
|
|
28
|
+
pkg: InstallablePackage,
|
|
29
|
+
onOutput: InstallProgressCallback,
|
|
30
|
+
) => Promise<void>;
|
|
31
|
+
|
|
32
|
+
// ── Installed-check ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return true if `pkgName` is resolvable from `managedDir/node_modules`.
|
|
36
|
+
* Version satisfies check is intentionally omitted (no semver dep) —
|
|
37
|
+
* we treat "resolves at all" as satisfied. This is sufficient for the
|
|
38
|
+
* Phase B bootstrap use case.
|
|
39
|
+
*/
|
|
40
|
+
export function isNpmPackageInstalled(pkgName: string, managedDir: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
// createRequire resolves from the given path; look in managedDir/node_modules.
|
|
43
|
+
const req = createRequire(path.join(managedDir, "package.json"));
|
|
44
|
+
req.resolve(pkgName + "/package.json");
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Default installers ─────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async function defaultNpmInstall(
|
|
54
|
+
pkg: InstallablePackage,
|
|
55
|
+
managedDir: string,
|
|
56
|
+
onOutput: InstallProgressCallback,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const spec =
|
|
59
|
+
pkg.version && pkg.version !== "*"
|
|
60
|
+
? `${pkg.name}@${pkg.version}`
|
|
61
|
+
: pkg.name;
|
|
62
|
+
const res = await bootstrapInstall({
|
|
63
|
+
packages: [spec],
|
|
64
|
+
managedDir,
|
|
65
|
+
progress: (p) => {
|
|
66
|
+
if (p.output) onOutput(p.output);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw new Error(res.error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function defaultPiExtensionInstall(
|
|
75
|
+
pkg: InstallablePackage,
|
|
76
|
+
onOutput: InstallProgressCallback,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const registry = getDefaultRegistry();
|
|
79
|
+
const { module: piModule } = await registry.resolveModule<{
|
|
80
|
+
DefaultPackageManager: any;
|
|
81
|
+
SettingsManager: any;
|
|
82
|
+
}>("pi-coding-agent");
|
|
83
|
+
const agentDir = path.join(os.homedir(), ".pi", "agent");
|
|
84
|
+
const settingsManager = piModule.SettingsManager.create(process.cwd(), agentDir);
|
|
85
|
+
const pm = new piModule.DefaultPackageManager({
|
|
86
|
+
cwd: process.cwd(),
|
|
87
|
+
agentDir,
|
|
88
|
+
settingsManager,
|
|
89
|
+
});
|
|
90
|
+
pm.setProgressCallback((event: { message?: string }) => {
|
|
91
|
+
if (event.message) onOutput(event.message);
|
|
92
|
+
});
|
|
93
|
+
await pm.installAndPersist(pkg.name, { local: false });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Options ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface BootstrapInstallFromListOptions {
|
|
99
|
+
/** Override config dir for installable.json (default: ~/.pi/dashboard/). */
|
|
100
|
+
configDir?: string;
|
|
101
|
+
/** Override managed dir for npm installs (default: ~/.pi-dashboard/). */
|
|
102
|
+
managedDir?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Injectable npm installer. Defaults to bootstrapInstall.
|
|
105
|
+
* Receives the InstallablePackage and a streaming output callback.
|
|
106
|
+
*/
|
|
107
|
+
npmInstall?: PackageInstaller;
|
|
108
|
+
/**
|
|
109
|
+
* Injectable pi-extension installer. Defaults to pi DefaultPackageManager.
|
|
110
|
+
* Receives the InstallablePackage and a streaming output callback.
|
|
111
|
+
*/
|
|
112
|
+
piInstall?: PackageInstaller;
|
|
113
|
+
/**
|
|
114
|
+
* Injectable installed-check for npm packages.
|
|
115
|
+
* Defaults to isNpmPackageInstalled.
|
|
116
|
+
*/
|
|
117
|
+
isInstalled?: (pkg: InstallablePackage, managedDir: string) => boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Main reconciler ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reconcile packages from installable.json against the managed directory.
|
|
124
|
+
*
|
|
125
|
+
* - File absent: log and return immediately (not a failure).
|
|
126
|
+
* - Per package: check installed → skip or install.
|
|
127
|
+
* - Required failure: set bootstrap status=failed, throw (abort server start).
|
|
128
|
+
* - Optional failure: log, record in failed[], continue.
|
|
129
|
+
*/
|
|
130
|
+
export async function bootstrapInstallFromList(
|
|
131
|
+
bootstrapState: BootstrapStateStore,
|
|
132
|
+
opts?: BootstrapInstallFromListOptions,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const configDir =
|
|
135
|
+
opts?.configDir ?? path.join(os.homedir(), ".pi", "dashboard");
|
|
136
|
+
const managedDir = opts?.managedDir ?? getManagedDir();
|
|
137
|
+
|
|
138
|
+
// Read installable.json; absent file is a deliberate no-op.
|
|
139
|
+
const list = await readInstallableList(configDir);
|
|
140
|
+
if (list === null) {
|
|
141
|
+
console.log(
|
|
142
|
+
"[bootstrap] bootstrap.installable.skipped reason=file-not-found",
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Only process packages that are active (not deprecated, not defaultOff).
|
|
148
|
+
const packages = list.packages.filter((p) => !p.deprecated && !p.defaultOff);
|
|
149
|
+
const total = packages.length;
|
|
150
|
+
let installedCount = 0;
|
|
151
|
+
const failed: string[] = [];
|
|
152
|
+
|
|
153
|
+
// Stamp initial installable progress into bootstrap state.
|
|
154
|
+
bootstrapState.set({ installable: { total, installed: 0, failed: [] } });
|
|
155
|
+
|
|
156
|
+
const checkInstalled =
|
|
157
|
+
opts?.isInstalled ?? ((p, dir) => isNpmPackageInstalled(p.name, dir));
|
|
158
|
+
const doNpmInstall: PackageInstaller =
|
|
159
|
+
opts?.npmInstall ??
|
|
160
|
+
((p, cb) => defaultNpmInstall(p, managedDir, cb));
|
|
161
|
+
const doPiInstall: PackageInstaller =
|
|
162
|
+
opts?.piInstall ?? defaultPiExtensionInstall;
|
|
163
|
+
|
|
164
|
+
for (const pkg of packages) {
|
|
165
|
+
// Fast path: already installed (npm packages only; pi-extension always attempts).
|
|
166
|
+
if (pkg.kind === "npm" && checkInstalled(pkg, managedDir)) {
|
|
167
|
+
console.log(
|
|
168
|
+
`[bootstrap] bootstrap.installable.package name=${pkg.name} status=satisfied`,
|
|
169
|
+
);
|
|
170
|
+
installedCount++;
|
|
171
|
+
bootstrapState.set({
|
|
172
|
+
installable: { total, installed: installedCount, failed },
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Emit installing progress.
|
|
178
|
+
bootstrapState.set({
|
|
179
|
+
progress: { step: pkg.name, output: "installing..." },
|
|
180
|
+
installable: { total, installed: installedCount, failed },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const onOutput = (line: string): void => {
|
|
185
|
+
bootstrapState.set({ progress: { step: pkg.name, output: line } });
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (pkg.kind === "npm") {
|
|
189
|
+
await doNpmInstall(pkg, onOutput);
|
|
190
|
+
} else {
|
|
191
|
+
await doPiInstall(pkg, onOutput);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
installedCount++;
|
|
195
|
+
bootstrapState.set({
|
|
196
|
+
progress: undefined,
|
|
197
|
+
installable: { total, installed: installedCount, failed },
|
|
198
|
+
});
|
|
199
|
+
console.log(
|
|
200
|
+
`[bootstrap] bootstrap.installable.package name=${pkg.name} status=done`,
|
|
201
|
+
);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
204
|
+
console.error(
|
|
205
|
+
`[bootstrap] bootstrap.installable.package name=${pkg.name} status=error error=${message}`,
|
|
206
|
+
);
|
|
207
|
+
failed.push(pkg.name);
|
|
208
|
+
bootstrapState.set({
|
|
209
|
+
progress: undefined,
|
|
210
|
+
installable: { total, installed: installedCount, failed: [...failed] },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (pkg.required) {
|
|
214
|
+
const errorMessage = `Required package "${pkg.name}" failed to install: ${message}`;
|
|
215
|
+
bootstrapState.set({
|
|
216
|
+
status: "failed",
|
|
217
|
+
error: { message: errorMessage },
|
|
218
|
+
});
|
|
219
|
+
throw new Error(errorMessage);
|
|
220
|
+
}
|
|
221
|
+
// Optional package failure: log, continue to next package.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Final state snapshot.
|
|
226
|
+
bootstrapState.set({
|
|
227
|
+
installable: { total, installed: installedCount, failed },
|
|
228
|
+
});
|
|
229
|
+
console.log(
|
|
230
|
+
`[bootstrap] bootstrap.installable.done total=${total} installed=${installedCount} failed=${failed.length}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DashboardStarter } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* In-memory bootstrap state store for the dashboard server.
|
|
3
5
|
*
|
|
@@ -51,6 +53,22 @@ export interface BootstrapState {
|
|
|
51
53
|
compatibility?: BootstrapCompatibility;
|
|
52
54
|
/** Set when `registerBridgeExtension` fails after a successful install. */
|
|
53
55
|
bridgeRegistrationError?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Who started this server process. Defaults to "Standalone" (direct CLI).
|
|
58
|
+
* Set at boot time from `parseDashboardStarter(process.env)`.
|
|
59
|
+
*/
|
|
60
|
+
starter?: DashboardStarter;
|
|
61
|
+
/**
|
|
62
|
+
* Installable list reconciliation progress.
|
|
63
|
+
* Set by bootstrapInstallFromList during Phase B reconcile.
|
|
64
|
+
* See change: simplify-electron-bootstrap-derived-state.
|
|
65
|
+
*/
|
|
66
|
+
installable?: {
|
|
67
|
+
total: number;
|
|
68
|
+
installed: number;
|
|
69
|
+
/** Package names that failed to install. */
|
|
70
|
+
failed: string[];
|
|
71
|
+
};
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
export type BootstrapListener = (state: BootstrapState) => void;
|
|
@@ -15,7 +15,44 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
|
|
|
15
15
|
import type { PendingForkRegistry } from "./pending-fork-registry.js";
|
|
16
16
|
import type { SessionOrderManager } from "./session-order-manager.js";
|
|
17
17
|
import type { PreferencesStore } from "./preferences-store.js";
|
|
18
|
-
import type
|
|
18
|
+
import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pure helper: build the per-cwd `openspec_update` messages a freshly
|
|
22
|
+
* connecting browser should receive. One message per known cwd.
|
|
23
|
+
* Disambiguates three states:
|
|
24
|
+
* - cache populated → cached payload
|
|
25
|
+
* - openspec dir but cold → { initialized: false, pending: true }
|
|
26
|
+
* - no openspec dir → { initialized: false, pending: false }
|
|
27
|
+
*
|
|
28
|
+
* Exported so cold-boot snapshot semantics can be unit-tested without
|
|
29
|
+
* spinning up a WS server. See change: fix-cold-boot-openspec-protocol.
|
|
30
|
+
*/
|
|
31
|
+
export function buildOpenSpecConnectSnapshot(
|
|
32
|
+
directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
|
|
33
|
+
hasDir: (cwd: string) => boolean,
|
|
34
|
+
): Array<ServerToBrowserMessage> {
|
|
35
|
+
const out: Array<ServerToBrowserMessage> = [];
|
|
36
|
+
for (const cwd of directoryService.knownDirectories()) {
|
|
37
|
+
const cached = directoryService.getOpenSpecData(cwd);
|
|
38
|
+
if (cached && cached.initialized) {
|
|
39
|
+
out.push({ type: "openspec_update", cwd, data: cached });
|
|
40
|
+
} else if (hasDir(cwd)) {
|
|
41
|
+
out.push({
|
|
42
|
+
type: "openspec_update",
|
|
43
|
+
cwd,
|
|
44
|
+
data: { initialized: false, pending: true, changes: [] },
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
out.push({
|
|
48
|
+
type: "openspec_update",
|
|
49
|
+
cwd,
|
|
50
|
+
data: { initialized: false, pending: false, changes: [] },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
19
56
|
import { createPendingResumeRegistry, type PendingResumeRegistry } from "./pending-resume-registry.js";
|
|
20
57
|
import { createViewedSessionTracker, type ViewedSessionTracker } from "./viewed-session-tracker.js";
|
|
21
58
|
import type { TerminalManager } from "./terminal-manager.js";
|
|
@@ -208,10 +245,21 @@ export function createBrowserGateway(
|
|
|
208
245
|
const subs = new Set<string>();
|
|
209
246
|
subscriptions.set(ws, subs);
|
|
210
247
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
// Atomic snapshot of the full session registry + per-cwd orders.
|
|
249
|
+
// Replaces the legacy per-session `session_added` loop and per-cwd
|
|
250
|
+
// `sessions_reordered` loop. Client REPLACES (not merges) its
|
|
251
|
+
// `sessions` Map and `sessionOrderMap` on receipt so stale ids from a
|
|
252
|
+
// previous server lifetime are dropped atomically.
|
|
253
|
+
// See change: fix-stale-sessions-on-reconnect.
|
|
254
|
+
{
|
|
255
|
+
const sessionsSnapshot = sessionManager.listAll();
|
|
256
|
+
const orders: Record<string, string[]> = {};
|
|
257
|
+
if (sessionOrderManager) {
|
|
258
|
+
for (const [cwd, sessionIds] of Object.entries(sessionOrderManager.getAllOrders())) {
|
|
259
|
+
if (sessionIds.length > 0) orders[cwd] = sessionIds;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
sendTo(ws, { type: "sessions_snapshot", sessions: sessionsSnapshot, orders });
|
|
215
263
|
}
|
|
216
264
|
|
|
217
265
|
// Send pinned directories on connect
|
|
@@ -219,23 +267,12 @@ export function createBrowserGateway(
|
|
|
219
267
|
sendTo(ws, { type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
|
|
220
268
|
}
|
|
221
269
|
|
|
222
|
-
// Send
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
for (const [cwd, sessionIds] of Object.entries(allOrders)) {
|
|
226
|
-
if (sessionIds.length > 0) {
|
|
227
|
-
sendTo(ws, { type: "sessions_reordered", cwd, sessionIds });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Send cached OpenSpec data for all known directories
|
|
270
|
+
// Send OpenSpec data for every known directory — exactly one
|
|
271
|
+
// `openspec_update` per cwd, never silently omit.
|
|
272
|
+
// See change: fix-cold-boot-openspec-protocol.
|
|
233
273
|
if (directoryService) {
|
|
234
|
-
for (const
|
|
235
|
-
|
|
236
|
-
if (data && data.initialized) {
|
|
237
|
-
sendTo(ws, { type: "openspec_update", cwd, data });
|
|
238
|
-
}
|
|
274
|
+
for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
|
|
275
|
+
sendTo(ws, msg);
|
|
239
276
|
}
|
|
240
277
|
}
|
|
241
278
|
|
|
@@ -148,6 +148,10 @@ export function handlePiGatewayForward(
|
|
|
148
148
|
case "request_models":
|
|
149
149
|
piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
|
|
150
150
|
break;
|
|
151
|
+
case "request_providers":
|
|
152
|
+
// See change: replace-hardcoded-provider-lists.
|
|
153
|
+
piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
|
|
154
|
+
break;
|
|
151
155
|
case "set_thinking_level":
|
|
152
156
|
piGateway.sendToSession(msg.sessionId, { type: "set_thinking_level", sessionId: msg.sessionId, level: msg.level });
|
|
153
157
|
break;
|
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
|
|
5
5
|
import type { BrowserHandlerContext } from "./handler-context.js";
|
|
6
6
|
import { spawnPiSession } from "../process-manager.js";
|
|
7
|
+
import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
|
|
7
8
|
import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
9
|
+
import { preflightSpawn } from "../spawn-preflight.js";
|
|
10
|
+
import { getSpawnRegisterWatchdog } from "../spawn-register-watchdog.js";
|
|
11
|
+
import { appendSpawnFailure } from "../spawn-failure-log.js";
|
|
8
12
|
import { createBranchedSessionFile } from "../session-file-reader.js";
|
|
9
13
|
import {
|
|
10
14
|
killPidWithGroup,
|
|
@@ -313,6 +317,24 @@ export async function handleSpawnSession(
|
|
|
313
317
|
pendingAttachRegistry?.enqueue(msg.cwd, msg.attachProposal);
|
|
314
318
|
}
|
|
315
319
|
|
|
320
|
+
// ── Preflight: fast synchronous checks before spawning. See change: spawn-failure-diagnostics.
|
|
321
|
+
const preflightResolver = new ToolResolver({ processExecPath: process.execPath, useLoginShell: false });
|
|
322
|
+
const preflight = preflightSpawn(msg.cwd, { resolver: preflightResolver });
|
|
323
|
+
if (!preflight.ok) {
|
|
324
|
+
const message = preflight.reasons.map((r) => r.message).join("; ");
|
|
325
|
+
sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
|
|
326
|
+
sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "PREFLIGHT_FAILED", reasons: preflight.reasons });
|
|
327
|
+
appendSpawnFailure({
|
|
328
|
+
ts: new Date().toISOString(),
|
|
329
|
+
cwd: msg.cwd,
|
|
330
|
+
strategy,
|
|
331
|
+
code: "PREFLIGHT_FAILED",
|
|
332
|
+
message,
|
|
333
|
+
reasons: preflight.reasons,
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
316
338
|
// Catch both thrown exceptions and { success: false } results; surface as
|
|
317
339
|
// spawn_error so the UI can render a retryable banner instead of failing
|
|
318
340
|
// silently. Previous behaviour left the user staring at an empty state
|
|
@@ -327,13 +349,49 @@ export async function handleSpawnSession(
|
|
|
327
349
|
}
|
|
328
350
|
sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: spawnResult.success, message: spawnResult.message });
|
|
329
351
|
if (!spawnResult.success) {
|
|
330
|
-
sendTo(ws, {
|
|
352
|
+
sendTo(ws, {
|
|
353
|
+
type: "spawn_error",
|
|
354
|
+
cwd: msg.cwd,
|
|
355
|
+
strategy,
|
|
356
|
+
message: spawnResult.message,
|
|
357
|
+
...(spawnResult.code ? { code: spawnResult.code } : {}),
|
|
358
|
+
...(spawnResult.stderr ? { stderr: spawnResult.stderr } : {}),
|
|
359
|
+
});
|
|
360
|
+
appendSpawnFailure({
|
|
361
|
+
ts: new Date().toISOString(),
|
|
362
|
+
cwd: msg.cwd,
|
|
363
|
+
strategy,
|
|
364
|
+
code: spawnResult.code ?? "SPAWN_ERRNO",
|
|
365
|
+
message: spawnResult.message,
|
|
366
|
+
...(spawnResult.stderr ? { stderrTail: spawnResult.stderr } : {}),
|
|
367
|
+
});
|
|
368
|
+
} else {
|
|
369
|
+
// Arm watchdog for every successful spawn. See change: spawn-failure-diagnostics.
|
|
370
|
+
const watchdog = getSpawnRegisterWatchdog();
|
|
371
|
+
watchdog.arm({
|
|
372
|
+
pid: spawnResult.pid,
|
|
373
|
+
cwd: msg.cwd,
|
|
374
|
+
mechanism: strategy as import("@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js").SpawnMechanism,
|
|
375
|
+
logPath: spawnResult.logPath,
|
|
376
|
+
// Read-on-arm: pass current config value so a Settings change takes effect
|
|
377
|
+
// on the next spawn without a server restart. See change: spawn-failure-diagnostics (fix W1).
|
|
378
|
+
timeoutMs: config.spawnRegisterTimeoutMs,
|
|
379
|
+
ws,
|
|
380
|
+
});
|
|
331
381
|
}
|
|
332
382
|
} catch (err) {
|
|
333
383
|
const message = err instanceof Error ? err.message : String(err);
|
|
334
384
|
const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr).slice(-2048) : undefined;
|
|
335
385
|
sendTo(ws, { type: "spawn_result", cwd: msg.cwd, success: false, message });
|
|
336
|
-
sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, stderr });
|
|
386
|
+
sendTo(ws, { type: "spawn_error", cwd: msg.cwd, strategy, message, code: "SPAWN_ERRNO", stderr });
|
|
387
|
+
appendSpawnFailure({
|
|
388
|
+
ts: new Date().toISOString(),
|
|
389
|
+
cwd: msg.cwd,
|
|
390
|
+
strategy,
|
|
391
|
+
code: "SPAWN_ERRNO",
|
|
392
|
+
message,
|
|
393
|
+
...(stderr ? { stderrTail: stderr } : {}),
|
|
394
|
+
});
|
|
337
395
|
}
|
|
338
396
|
}
|
|
339
397
|
|
|
@@ -68,7 +68,7 @@ async function sendEventBatches(
|
|
|
68
68
|
* Called immediately after every `replayPendingUiRequests` site so the full
|
|
69
69
|
* replay ordering is:
|
|
70
70
|
*
|
|
71
|
-
* events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
|
|
71
|
+
* asset_register batch → events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
|
|
72
72
|
*
|
|
73
73
|
* Exported so unit tests can drive it without standing up a full subscribe
|
|
74
74
|
* pipeline. See changes: add-extension-ui-modal, add-extension-ui-decorations.
|
|
@@ -96,6 +96,35 @@ export function replayUiState(
|
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Replay the per-session image asset registry to a single browser. Sends one
|
|
101
|
+
* `asset_register` message per `(hash, { data, mimeType })` entry in
|
|
102
|
+
* `Session.assets`. Called BEFORE `sendEventBatches` so any `pi-asset:<hash>`
|
|
103
|
+
* tokens in replayed `message_update` / `message_end` events have their
|
|
104
|
+
* referent in the client's session map by the time they're reduced.
|
|
105
|
+
*
|
|
106
|
+
* See change: chat-markdown-local-images-and-math.
|
|
107
|
+
*/
|
|
108
|
+
export function replaySessionAssets(
|
|
109
|
+
ws: WebSocket,
|
|
110
|
+
sessionId: string,
|
|
111
|
+
ctx: Pick<BrowserHandlerContext, "sessionManager" | "sendTo">,
|
|
112
|
+
): void {
|
|
113
|
+
const { sessionManager, sendTo } = ctx;
|
|
114
|
+
const session = sessionManager.get(sessionId);
|
|
115
|
+
if (!session?.assets) return;
|
|
116
|
+
for (const [hash, asset] of Object.entries(session.assets)) {
|
|
117
|
+
if (!asset || typeof asset.data !== "string" || typeof asset.mimeType !== "string") continue;
|
|
118
|
+
sendTo(ws, {
|
|
119
|
+
type: "asset_register",
|
|
120
|
+
sessionId,
|
|
121
|
+
hash,
|
|
122
|
+
mimeType: asset.mimeType,
|
|
123
|
+
data: asset.data,
|
|
124
|
+
} as any);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
99
128
|
export function handleSubscribe(
|
|
100
129
|
msg: Extract<BrowserToServerMessage, { type: "subscribe" }>,
|
|
101
130
|
subs: Set<string>,
|
|
@@ -108,6 +137,8 @@ export function handleSubscribe(
|
|
|
108
137
|
// while the browser is actually subscribed (responses use sendToSubscribers).
|
|
109
138
|
piGateway.sendToSession(msg.sessionId, { type: "request_commands", sessionId: msg.sessionId });
|
|
110
139
|
piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
|
|
140
|
+
// See change: replace-hardcoded-provider-lists.
|
|
141
|
+
piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
|
|
111
142
|
piGateway.sendToSession(msg.sessionId, { type: "request_roles", sessionId: msg.sessionId });
|
|
112
143
|
|
|
113
144
|
if (eventStore.hasEvents(msg.sessionId)) {
|
|
@@ -122,6 +153,10 @@ export function handleSubscribe(
|
|
|
122
153
|
if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
|
|
123
154
|
events = events.slice(events.length - MAX_REPLAY_EVENTS);
|
|
124
155
|
}
|
|
156
|
+
// Replay asset registry BEFORE events so pi-asset:<hash> tokens in
|
|
157
|
+
// message_update / message_end resolve on first reduce.
|
|
158
|
+
// See change: chat-markdown-local-images-and-math.
|
|
159
|
+
replaySessionAssets(ws, msg.sessionId, ctx);
|
|
125
160
|
markReplaying(ws, msg.sessionId);
|
|
126
161
|
sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
|
|
127
162
|
clearReplaying(ws, msg.sessionId, lastSent);
|
|
@@ -133,8 +168,18 @@ export function handleSubscribe(
|
|
|
133
168
|
if (MAX_REPLAY_EVENTS > 0 && events.length > MAX_REPLAY_EVENTS) {
|
|
134
169
|
events = events.slice(events.length - MAX_REPLAY_EVENTS);
|
|
135
170
|
}
|
|
136
|
-
//
|
|
137
|
-
|
|
171
|
+
// Replay asset registry on every subscribe (delta or full). Cheap when
|
|
172
|
+
// empty; assets already known to the client are simply re-overwritten
|
|
173
|
+
// with identical bytes. See change: chat-markdown-local-images-and-math.
|
|
174
|
+
replaySessionAssets(ws, msg.sessionId, ctx);
|
|
175
|
+
// Suppress live events during paginated replay to prevent out-of-order
|
|
176
|
+
// delivery. The client's `event_replay` reset rule (firstSeq <= maxSeq)
|
|
177
|
+
// misfires if a live `event` arrives between batches and bumps maxSeq
|
|
178
|
+
// past the next batch's firstSeq — wiping state to a fresh build of
|
|
179
|
+
// only the last batch. Suppression+catch-up via clearReplaying preserves
|
|
180
|
+
// ordering for both cold (lastSeq=0) and warm (lastSeq>0) subscribes.
|
|
181
|
+
// See change: fix-cold-subscribe-replay-interleave.
|
|
182
|
+
if (events.length > 0) {
|
|
138
183
|
markReplaying(ws, msg.sessionId);
|
|
139
184
|
sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
|
|
140
185
|
clearReplaying(ws, msg.sessionId, lastSent);
|
|
@@ -172,6 +217,8 @@ export function handleSubscribe(
|
|
|
172
217
|
}
|
|
173
218
|
const subscribers = getSubscribers(msg.sessionId);
|
|
174
219
|
for (const sub of subscribers) {
|
|
220
|
+
// Asset registry first — see change: chat-markdown-local-images-and-math.
|
|
221
|
+
replaySessionAssets(sub, msg.sessionId, ctx);
|
|
175
222
|
await sendEventBatches(sub, msg.sessionId, stored, sendTo);
|
|
176
223
|
replayPendingUiRequests(sub, msg.sessionId);
|
|
177
224
|
replayUiState(sub, msg.sessionId, ctx);
|
|
@@ -53,6 +53,8 @@ import {
|
|
|
53
53
|
import type { DashboardServer } from "./server.js";
|
|
54
54
|
import { updateBootstrapCompatibility } from "./pi-version-skew.js";
|
|
55
55
|
import type { BootstrapStateStore } from "./bootstrap-state.js";
|
|
56
|
+
import { parseDashboardStarter } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
|
|
57
|
+
import { bootstrapInstallFromList } from "./bootstrap-install-from-list.js";
|
|
56
58
|
|
|
57
59
|
/**
|
|
58
60
|
* Emit a stderr warning at CLI startup when the resolved pi version is
|
|
@@ -166,6 +168,12 @@ async function runForeground(config: ServerConfig): Promise<void> {
|
|
|
166
168
|
assertNodeVersionSupported();
|
|
167
169
|
const server = await createServer(config);
|
|
168
170
|
|
|
171
|
+
// Stamp the bootstrap state with who started this server process.
|
|
172
|
+
// parseDashboardStarter defaults to "Standalone" when DASHBOARD_STARTER is unset.
|
|
173
|
+
const starter = parseDashboardStarter(process.env);
|
|
174
|
+
server.bootstrapState.set({ starter });
|
|
175
|
+
console.log(`[bootstrap] starter=${starter}`);
|
|
176
|
+
|
|
169
177
|
let shuttingDown = false;
|
|
170
178
|
const shutdown = async () => {
|
|
171
179
|
if (shuttingDown) {
|
|
@@ -181,6 +189,19 @@ async function runForeground(config: ServerConfig): Promise<void> {
|
|
|
181
189
|
process.on("SIGINT", shutdown);
|
|
182
190
|
process.on("SIGTERM", shutdown);
|
|
183
191
|
|
|
192
|
+
// Reconcile installable.json before binding the port.
|
|
193
|
+
// Required-package failures throw and prevent server start.
|
|
194
|
+
// Optional failures are logged and continue.
|
|
195
|
+
// File-absent is a no-op (Bridge/Standalone starters don't seed installable.json).
|
|
196
|
+
// See change: simplify-electron-bootstrap-derived-state.
|
|
197
|
+
try {
|
|
198
|
+
await bootstrapInstallFromList(server.bootstrapState);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
201
|
+
console.error(`[bootstrap] installable reconcile failed (required package): ${message}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
184
205
|
await server.start();
|
|
185
206
|
|
|
186
207
|
// Kick off the degraded-mode first-run bootstrap if pi is unresolvable.
|
|
@@ -45,6 +45,37 @@ export interface DirectoryAddedResult {
|
|
|
45
45
|
openspecData: OpenSpecData;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* `true` if `OpenSpecData` represents "no useful data yet" — either
|
|
50
|
+
* absent, or `{ initialized: false, changes: [] }` (cold cache).
|
|
51
|
+
* Used by broadcast call-sites to decide whether a transition from
|
|
52
|
+
* empty→populated warrants firing `openspec_update`. Pulled into
|
|
53
|
+
* shared scope so `server.ts` (post-install repair) and
|
|
54
|
+
* `session-bootstrap.ts` (initial poll) both use the same predicate.
|
|
55
|
+
*
|
|
56
|
+
* See change: fix-cold-boot-openspec-protocol.
|
|
57
|
+
*/
|
|
58
|
+
export function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
|
|
59
|
+
if (!d) return true;
|
|
60
|
+
return !d.initialized && (!d.changes || d.changes.length === 0);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Synchronous, spawn-free probe for `<cwd>/openspec/changes`. Returns
|
|
65
|
+
* `true` iff the path exists and is a directory. Used by the WS
|
|
66
|
+
* on-connect snapshot to disambiguate "no openspec here" from
|
|
67
|
+
* "openspec here, polling pending". ~10 μs per call.
|
|
68
|
+
*
|
|
69
|
+
* See change: fix-cold-boot-openspec-protocol.
|
|
70
|
+
*/
|
|
71
|
+
export function hasOpenSpecDir(cwd: string): boolean {
|
|
72
|
+
try {
|
|
73
|
+
return fs.statSync(path.join(cwd, "openspec", "changes")).isDirectory();
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
export interface DirectoryService {
|
|
49
80
|
knownDirectories(): string[];
|
|
50
81
|
discoverSessions(cwd: string): DiscoveredSession[];
|