@blackbelt-technology/pi-agent-dashboard 0.4.5 → 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 +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- 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-bus.test.ts +44 -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/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- 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 +6 -1
- package/packages/extension/src/vcs-info.ts +184 -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__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -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-diff-vcs.test.ts +61 -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-restart.test.ts +4 -4
- 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 +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -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/openspec-tasks.ts +50 -19
- 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/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- 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__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -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/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -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 +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -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
|
|
@@ -145,6 +147,7 @@ export function buildConfig(flags: Partial<ServerConfig>): ServerConfig {
|
|
|
145
147
|
maxWsBufferBytes: fileConfig.memoryLimits.maxWsBufferBytes,
|
|
146
148
|
editor: fileConfig.editor,
|
|
147
149
|
openspec: fileConfig.openspec,
|
|
150
|
+
reattachPlacement: fileConfig.reattachPlacement,
|
|
148
151
|
resolvedTrustedNetworks: fileConfig.resolvedTrustedNetworks,
|
|
149
152
|
corsAllowedOrigins: fileConfig.cors.allowedOrigins,
|
|
150
153
|
};
|
|
@@ -165,6 +168,12 @@ async function runForeground(config: ServerConfig): Promise<void> {
|
|
|
165
168
|
assertNodeVersionSupported();
|
|
166
169
|
const server = await createServer(config);
|
|
167
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
|
+
|
|
168
177
|
let shuttingDown = false;
|
|
169
178
|
const shutdown = async () => {
|
|
170
179
|
if (shuttingDown) {
|
|
@@ -180,6 +189,19 @@ async function runForeground(config: ServerConfig): Promise<void> {
|
|
|
180
189
|
process.on("SIGINT", shutdown);
|
|
181
190
|
process.on("SIGTERM", shutdown);
|
|
182
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
|
+
|
|
183
205
|
await server.start();
|
|
184
206
|
|
|
185
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[];
|
|
@@ -11,11 +11,12 @@ import type { PendingForkRegistry } from "./pending-fork-registry.js";
|
|
|
11
11
|
import type { DirectoryService } from "./directory-service.js";
|
|
12
12
|
import { extractSessionUpdates, isActivityEvent, isUnreadTrigger } from "./event-status-extraction.js";
|
|
13
13
|
import type { ViewedSessionTracker } from "./viewed-session-tracker.js";
|
|
14
|
+
import { setCatalogueForSession } from "./provider-catalogue-cache.js";
|
|
14
15
|
import { spawnPiSession } from "./process-manager.js";
|
|
15
16
|
import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
16
17
|
import { writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
|
|
17
18
|
import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
18
|
-
import { detectOpenSpecActivity } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
|
|
19
|
+
import { detectOpenSpecActivity, isValidOpenSpecChangeSlug } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
|
|
19
20
|
import { extractTurnStats } from "@blackbelt-technology/pi-dashboard-shared/stats-extractor.js";
|
|
20
21
|
import { attachRenameTarget, isNameAutoSetFromAttachment } from "./proposal-attach-naming.js";
|
|
21
22
|
|
|
@@ -217,10 +218,19 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
217
218
|
// Server-side OpenSpec activity detection from forwarded events
|
|
218
219
|
// Skip during replay — replayed events from a forked session would set stale phase/change
|
|
219
220
|
if (msg.event.eventType === "tool_execution_start" && !replayingSessions.has(sessionId)) {
|
|
220
|
-
const
|
|
221
|
+
const detectedRaw = detectOpenSpecActivity(
|
|
221
222
|
msg.event.data.toolName as string,
|
|
222
223
|
msg.event.data.args as Record<string, unknown> | undefined,
|
|
223
224
|
);
|
|
225
|
+
// Defense-in-depth (see change: fix-uuid-rename-bug). Even if a future
|
|
226
|
+
// detector regression returns a junk-shaped `changeName` (UUID, mixed
|
|
227
|
+
// case, etc.), refuse to stamp openspecChange / attachedProposal /
|
|
228
|
+
// name. Manual attach paths (browser handler, REST) bypass this and
|
|
229
|
+
// accept any name from a server-curated list.
|
|
230
|
+
const detected =
|
|
231
|
+
detectedRaw && (!detectedRaw.changeName || isValidOpenSpecChangeSlug(detectedRaw.changeName))
|
|
232
|
+
? detectedRaw
|
|
233
|
+
: null;
|
|
224
234
|
if (detected) {
|
|
225
235
|
const session = sessionManager.get(sessionId);
|
|
226
236
|
const activityUpdates: Partial<DashboardSession> = {};
|
|
@@ -582,6 +592,15 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
582
592
|
browserGateway.broadcastSessionUpdated(sessionId, gitUpdates);
|
|
583
593
|
}
|
|
584
594
|
|
|
595
|
+
if (msg.type === "jj_state_update") {
|
|
596
|
+
// jjState is intentionally allowed to be `undefined` (no jj) when
|
|
597
|
+
// the bridge sends `null`; the session-manager update applies the
|
|
598
|
+
// value verbatim. See change: add-jj-workspace-plugin.
|
|
599
|
+
const jjUpdates = { jjState: msg.jjState ?? undefined };
|
|
600
|
+
sessionManager.update(sessionId, jjUpdates);
|
|
601
|
+
browserGateway.broadcastSessionUpdated(sessionId, jjUpdates);
|
|
602
|
+
}
|
|
603
|
+
|
|
585
604
|
if (msg.type === "files_list") {
|
|
586
605
|
browserGateway.sendToSubscribers(sessionId, {
|
|
587
606
|
type: "files_list",
|
|
@@ -601,6 +620,14 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
601
620
|
} as any);
|
|
602
621
|
}
|
|
603
622
|
|
|
623
|
+
if (msg.type === "providers_list") {
|
|
624
|
+
// Cache the bridge-pushed catalogue. Browsers don't subscribe to it
|
|
625
|
+
// directly; they read via GET /api/provider-auth/status.
|
|
626
|
+
// See change: replace-hardcoded-provider-lists.
|
|
627
|
+
setCatalogueForSession(sessionId, msg.providers);
|
|
628
|
+
browserGateway.broadcastToAll({ type: "models_refreshed" } as any);
|
|
629
|
+
}
|
|
630
|
+
|
|
604
631
|
if (msg.type === "roles_list") {
|
|
605
632
|
browserGateway.broadcastToAll({
|
|
606
633
|
type: "roles_list",
|
|
@@ -669,6 +696,34 @@ export function wireEvents(deps: EventWiringDeps): void {
|
|
|
669
696
|
} as any);
|
|
670
697
|
}
|
|
671
698
|
|
|
699
|
+
// ── Asset register: per-session image asset cache + broadcast ──
|
|
700
|
+
// See change: chat-markdown-local-images-and-math.
|
|
701
|
+
if (msg.type === "asset_register") {
|
|
702
|
+
const { hash, mimeType, data } = msg;
|
|
703
|
+
// Reject malformed messages defensively. The bridge always populates
|
|
704
|
+
// these fields; this guard is purely defense-in-depth so a
|
|
705
|
+
// misbehaving extension cannot inject placeholder asset entries.
|
|
706
|
+
if (typeof hash === "string" && hash.length > 0 &&
|
|
707
|
+
typeof mimeType === "string" && mimeType.length > 0 &&
|
|
708
|
+
typeof data === "string" && data.length > 0) {
|
|
709
|
+
const session = sessionManager.get(sessionId);
|
|
710
|
+
if (session) {
|
|
711
|
+
const next = { ...(session.assets ?? {}) };
|
|
712
|
+
next[hash] = { data, mimeType };
|
|
713
|
+
sessionManager.update(sessionId, { assets: next });
|
|
714
|
+
}
|
|
715
|
+
// Broadcast verbatim regardless of whether the session is known —
|
|
716
|
+
// mirrors the Phase-1 / Phase-2 contract for extension UI messages.
|
|
717
|
+
browserGateway.sendToSubscribers(sessionId, {
|
|
718
|
+
type: "asset_register",
|
|
719
|
+
sessionId,
|
|
720
|
+
hash,
|
|
721
|
+
mimeType,
|
|
722
|
+
data,
|
|
723
|
+
} as any);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
672
727
|
// ── Extension UI System (Phase 2): live decorator cache + broadcast ──
|
|
673
728
|
// See change: add-extension-ui-decorations.
|
|
674
729
|
if (msg.type === "ext_ui_decorator") {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Metadata written alongside the lock file. JSON-serialized. */
|
|
2
|
+
export interface LockMetadata {
|
|
3
|
+
pid: number;
|
|
4
|
+
ppid: number;
|
|
5
|
+
httpPort: number;
|
|
6
|
+
piPort: number;
|
|
7
|
+
startedAt: number;
|
|
8
|
+
/** Stable per-instance identifier. Verified against /api/health to detect
|
|
9
|
+
* "port in use by unrelated dashboard or stale process with same pid." */
|
|
10
|
+
identity: string;
|
|
11
|
+
version: string;
|
|
12
|
+
url: string;
|
|
13
|
+
hostname: string;
|
|
14
|
+
}
|
|
15
|
+
/** Result of `acquireOrAttach`. Callers branch on `mode`. */
|
|
16
|
+
export type LockAcquireResult = {
|
|
17
|
+
mode: "acquired";
|
|
18
|
+
meta: LockMetadata;
|
|
19
|
+
/** Release the lock + remove the metadata sidecar. Idempotent. */
|
|
20
|
+
release: () => Promise<void>;
|
|
21
|
+
} | {
|
|
22
|
+
mode: "attach";
|
|
23
|
+
meta: LockMetadata;
|
|
24
|
+
};
|
|
25
|
+
/** Thrown when port is held by an unrelated process. Non-fatal to this
|
|
26
|
+
* module; caller decides (exit with message / retry / override). */
|
|
27
|
+
export declare class InstanceLockMismatchError extends Error {
|
|
28
|
+
readonly meta: LockMetadata;
|
|
29
|
+
readonly observedIdentity: string | null;
|
|
30
|
+
readonly code = "E_INSTANCE_MISMATCH";
|
|
31
|
+
constructor(meta: LockMetadata, observedIdentity: string | null);
|
|
32
|
+
}
|
|
33
|
+
export interface AcquireConfig {
|
|
34
|
+
httpPort: number;
|
|
35
|
+
piPort: number;
|
|
36
|
+
version: string;
|
|
37
|
+
identity?: string;
|
|
38
|
+
/** Injection hooks for tests. Production callers pass no options. */
|
|
39
|
+
hooks?: AcquireHooks;
|
|
40
|
+
}
|
|
41
|
+
export interface AcquireHooks {
|
|
42
|
+
now?: () => number;
|
|
43
|
+
hostname?: () => string;
|
|
44
|
+
lockPath?: string;
|
|
45
|
+
metaPath?: string;
|
|
46
|
+
probeHealth?: (port: number) => Promise<{
|
|
47
|
+
running: boolean;
|
|
48
|
+
pid?: number;
|
|
49
|
+
identity?: string;
|
|
50
|
+
} | null>;
|
|
51
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
52
|
+
/** Stale threshold forwarded to `proper-lockfile`. Default 10s. */
|
|
53
|
+
staleMs?: number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Canonical HOME directory.
|
|
57
|
+
*
|
|
58
|
+
* Uses `os.userInfo().homedir` in preference to `os.homedir()` because on
|
|
59
|
+
* POSIX the latter honors the `$HOME` environment variable (Node docs say:
|
|
60
|
+
* "On POSIX, it uses the `$HOME` environment variable if defined"), which
|
|
61
|
+
* the design (§4) explicitly prohibits — a GUI-launched process and a
|
|
62
|
+
* shell-launched process would otherwise disagree on "where HOME is".
|
|
63
|
+
* `userInfo().homedir` consults `getpwuid(3)` on POSIX, immune to `$HOME`.
|
|
64
|
+
*
|
|
65
|
+
* On Windows, both APIs ultimately use `USERPROFILE`, so the Git Bash
|
|
66
|
+
* drift case (`$HOME=/c/Users/R` vs `USERPROFILE=C:\Users\R`) is handled
|
|
67
|
+
* either way; keeping `userInfo().homedir` first is still correct.
|
|
68
|
+
*
|
|
69
|
+
* Result is then passed through `fs.realpathSync` to collapse symlinks,
|
|
70
|
+
* FileVault migrations, and other canonicalization drift. Tolerant: falls
|
|
71
|
+
* back to the raw path if realpath fails.
|
|
72
|
+
*/
|
|
73
|
+
export declare function canonicalHomedir(): string;
|
|
74
|
+
/**
|
|
75
|
+
* Lock file path. This is what `proper-lockfile` locks.
|
|
76
|
+
*/
|
|
77
|
+
export declare function getLockPath(homedir?: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* Metadata sidecar path (`<lockPath>.meta.json`).
|
|
80
|
+
*/
|
|
81
|
+
export declare function getMetaPath(lockPath?: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* Atomically write the metadata sidecar via tmp + rename.
|
|
84
|
+
* Never leaves a partial file visible.
|
|
85
|
+
*/
|
|
86
|
+
export declare function writeMetadataAtomic(meta: LockMetadata, metaPath?: string): void;
|
|
87
|
+
/**
|
|
88
|
+
* Read the metadata sidecar. Returns null on any failure (missing, corrupt,
|
|
89
|
+
* permission-denied). Callers MUST treat null as "assume stale."
|
|
90
|
+
*/
|
|
91
|
+
export declare function readMetadata(metaPath?: string): LockMetadata | null;
|
|
92
|
+
/**
|
|
93
|
+
* Remove the metadata sidecar. Silent on any error (missing is fine).
|
|
94
|
+
*/
|
|
95
|
+
export declare function removeMetadata(metaPath?: string): void;
|
|
96
|
+
/**
|
|
97
|
+
* Determine if the recorded lock holder is a responsive, identity-matching
|
|
98
|
+
* dashboard. Returns:
|
|
99
|
+
* - `"alive-match"`: attach to it
|
|
100
|
+
* - `"alive-mismatch"`: someone else is on that port
|
|
101
|
+
* - `"dead"`: treat as stale, proceed to acquire
|
|
102
|
+
*/
|
|
103
|
+
export declare function isLockHolderResponsive(meta: LockMetadata, hooks?: Pick<AcquireHooks, "probeHealth" | "isProcessAlive">): Promise<"alive-match" | "alive-mismatch" | "dead">;
|
|
104
|
+
/**
|
|
105
|
+
* Acquire the per-HOME lock, or fall back to attach semantics if a live
|
|
106
|
+
* dashboard already holds it.
|
|
107
|
+
*
|
|
108
|
+
* Flow:
|
|
109
|
+
* 1. Ensure `~/.pi/dashboard/` exists (proper-lockfile requires parent).
|
|
110
|
+
* 2. `proper-lockfile.lock(path, { stale, retries: 0 })`
|
|
111
|
+
* ↪ on success: write metadata, return { mode: "acquired", release }
|
|
112
|
+
* ↪ on ELOCKED: read metadata, check liveness
|
|
113
|
+
* - dead: steal via `proper-lockfile.lock({ realpath:false, stale: 0 })`
|
|
114
|
+
* (Note: proper-lockfile already does stale-stealing when
|
|
115
|
+
* `stale` is configured — we just retry once.)
|
|
116
|
+
* - alive-match: return { mode: "attach", meta }
|
|
117
|
+
* - alive-mismatch: throw InstanceLockMismatchError
|
|
118
|
+
*/
|
|
119
|
+
export declare function acquireOrAttach(config: AcquireConfig): Promise<LockAcquireResult>;
|
|
120
|
+
/**
|
|
121
|
+
* True when the user has opted out of the per-HOME lock. Caller should
|
|
122
|
+
* log a warning and skip acquireOrAttach when set.
|
|
123
|
+
*/
|
|
124
|
+
export declare function isLockDisabled(env?: NodeJS.ProcessEnv): boolean;
|