@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.
Files changed (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Jujutsu (jj) REST API routes (localhost-only).
3
+ *
4
+ * Endpoints:
5
+ * POST /api/jj/workspace/add — create workspace + spawn session
6
+ * POST /api/jj/workspace/forget — refuses on unfolded work; force escape
7
+ * POST /api/jj/init-colocated — refuses on dirty git index
8
+ * GET /api/jj/workspace/list — enumerate workspaces under cwd
9
+ *
10
+ * All endpoints are network-guarded. Workspace add reuses the same
11
+ * pending-attach + spawnPiSession lever as the OpenSpec attach-and-spawn
12
+ * flow. See changes: add-jj-workspace-plugin, add-folder-task-checker-and-spawn-attach.
13
+ */
14
+ import path from "node:path";
15
+ import fs from "node:fs/promises";
16
+ import { existsSync } from "node:fs";
17
+ import type { FastifyInstance } from "fastify";
18
+ import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
19
+ import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
20
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
21
+ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
22
+ import type { BrowserGateway } from "../browser-gateway.js";
23
+ import type { PendingAttachRegistry } from "../pending-attach-registry.js";
24
+ import { spawnPiSession } from "../process-manager.js";
25
+ import type { NetworkGuard } from "./route-deps.js";
26
+ import { safeRealpathSync } from "../resolve-path.js";
27
+
28
+ /** Workspace name regex per spec (filesystem + bookmark safety). */
29
+ const NAME_RE = /^[a-z0-9-]+$/;
30
+
31
+ export interface JjRoutesDeps {
32
+ browserGateway: BrowserGateway;
33
+ pendingAttachRegistry: PendingAttachRegistry;
34
+ networkGuard: NetworkGuard;
35
+ /** Optional plugin config accessor (defaults to current dashboard config). */
36
+ getWorkspaceRoot?: () => string;
37
+ }
38
+
39
+ /**
40
+ * Resolve the workspace-root setting for a given repo. Currently global
41
+ * via the plugin config; per-repo override is explicitly out of scope
42
+ * (Decision 14). Falls back to `.shadow` when config is absent.
43
+ */
44
+ function resolveWorkspaceRoot(deps: JjRoutesDeps): string {
45
+ if (deps.getWorkspaceRoot) return deps.getWorkspaceRoot();
46
+ // The plugin config is read from the dashboard config blob's `plugins.jj`
47
+ // namespace. Until the runtime config-validator wires that path here, we
48
+ // fall back to the documented default.
49
+ try {
50
+ const cfg = loadConfig() as unknown as { plugins?: { jj?: { workspaceRoot?: string } } };
51
+ return cfg.plugins?.jj?.workspaceRoot ?? ".shadow";
52
+ } catch {
53
+ return ".shadow";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Pure preflight checks for `init-colocated`. Returns `null` on OK,
59
+ * else a `{ code, message }` object the caller can shape into 4xx.
60
+ */
61
+ export function checkInitColocatedPreconditions(cwd: string):
62
+ | null
63
+ | { code: "INVALID_CWD" | "ALREADY_JJ" | "DIRTY_INDEX" | "NOT_GIT_REPO"; message: string } {
64
+ if (!cwd) return { code: "INVALID_CWD", message: "cwd is required" };
65
+ if (!existsSync(cwd)) return { code: "INVALID_CWD", message: `cwd does not exist: ${cwd}` };
66
+ if (existsSync(path.join(cwd, ".jj"))) {
67
+ return { code: "ALREADY_JJ", message: "cwd is already a jj repo" };
68
+ }
69
+ if (!existsSync(path.join(cwd, ".git"))) {
70
+ return { code: "NOT_GIT_REPO", message: "cwd is not a git repo" };
71
+ }
72
+ // git diff --cached --quiet exits 1 when index is dirty. Recipe-based
73
+ // helper for clarity and consistency with the rest of the codebase.
74
+ const indexResult = git.statusPorcelain({ cwd });
75
+ if (indexResult.ok) {
76
+ // Lines beginning with M, A, D, R, C, U in column 1 indicate INDEX
77
+ // changes (column 2 is the working tree). We refuse on any column-1
78
+ // mutation.
79
+ const dirty = indexResult.value
80
+ .split("\n")
81
+ .filter((l) => l.length >= 2 && /[MADRCU]/.test(l[0]!));
82
+ if (dirty.length > 0) {
83
+ return {
84
+ code: "DIRTY_INDEX",
85
+ message:
86
+ `git index has staged changes (${dirty.length} entr${dirty.length === 1 ? "y" : "ies"}); ` +
87
+ `commit or 'git reset' first. See spec scenario "Init refused on dirty index".`,
88
+ };
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ export function registerJjRoutes(fastify: FastifyInstance, deps: JjRoutesDeps) {
95
+ const { browserGateway, pendingAttachRegistry, networkGuard } = deps;
96
+
97
+ // ── GET /api/jj/workspace/list?cwd=… ────────────────────────────────────
98
+ fastify.get<{ Querystring: { cwd?: string } }>(
99
+ "/api/jj/workspace/list",
100
+ { preHandler: networkGuard },
101
+ async (request, reply) => {
102
+ const cwd = request.query.cwd;
103
+ if (!cwd) {
104
+ reply.code(400);
105
+ return { success: false, error: "cwd is required" } satisfies ApiResponse;
106
+ }
107
+ if (!existsSync(path.join(cwd, ".jj"))) {
108
+ return { success: true, data: { workspaces: [] } } satisfies ApiResponse;
109
+ }
110
+ const result = jj.workspaceList({ cwd });
111
+ if (!result.ok) {
112
+ reply.code(500);
113
+ return {
114
+ success: false,
115
+ error: `jj workspace list failed: ${describeError(result.error)}`,
116
+ } satisfies ApiResponse;
117
+ }
118
+ const workspaces = jj.parseWorkspaceList(result.value);
119
+ return { success: true, data: { workspaces } } satisfies ApiResponse;
120
+ },
121
+ );
122
+
123
+ // ── POST /api/jj/workspace/add ──────────────────────────────────────────
124
+ fastify.post<{
125
+ Body: { fromCwd?: string; name?: string; baseRev?: string; taskDescription?: string };
126
+ }>(
127
+ "/api/jj/workspace/add",
128
+ { preHandler: networkGuard },
129
+ async (request, reply) => {
130
+ const { fromCwd, name, baseRev, taskDescription } = request.body ?? {};
131
+
132
+ if (!fromCwd) {
133
+ reply.code(400);
134
+ return { success: false, error: "fromCwd is required" } satisfies ApiResponse;
135
+ }
136
+ if (!name || !NAME_RE.test(name)) {
137
+ reply.code(400);
138
+ return {
139
+ success: false,
140
+ error: "INVALID_NAME: name must match /^[a-z0-9-]+$/",
141
+ } satisfies ApiResponse;
142
+ }
143
+ if (!existsSync(path.join(fromCwd, ".jj"))) {
144
+ reply.code(400);
145
+ return {
146
+ success: false,
147
+ error: "fromCwd is not a jj repo",
148
+ } satisfies ApiResponse;
149
+ }
150
+
151
+ const workspaceRoot = resolveWorkspaceRoot(deps);
152
+ const destPath = path.join(fromCwd, workspaceRoot, name);
153
+ if (existsSync(destPath)) {
154
+ reply.code(409);
155
+ return {
156
+ success: false,
157
+ error: `destination already exists: ${destPath}`,
158
+ } satisfies ApiResponse;
159
+ }
160
+ // Ensure the workspace-root parent directory exists. `jj workspace
161
+ // add` does NOT create intermediate dirs and fails with
162
+ // "Cannot access <path>" on a missing parent. mkdir -p is safe and
163
+ // idempotent. The .shadow root should be in .gitignore (the spec's
164
+ // FolderOpenSpecSection-style hint is tracked as follow-up).
165
+ const parentDir = path.dirname(destPath);
166
+ try {
167
+ await fs.mkdir(parentDir, { recursive: true });
168
+ } catch (err) {
169
+ reply.code(500);
170
+ return {
171
+ success: false,
172
+ error: `failed to create workspace parent dir ${parentDir}: ${err instanceof Error ? err.message : String(err)}`,
173
+ } satisfies ApiResponse;
174
+ }
175
+
176
+ // Resolve the base revision when omitted: current bookmark of fromCwd's
177
+ // working copy, falling back to `trunk()` revset.
178
+ let resolvedBase = baseRev;
179
+ if (!resolvedBase) {
180
+ const bookmarksResult = jj.logRevset({
181
+ cwd: fromCwd,
182
+ revset: "@",
183
+ template: 'bookmarks ++ "\\n"',
184
+ });
185
+ if (bookmarksResult.ok) {
186
+ const first = bookmarksResult.value.trim().split("\n")[0]?.trim();
187
+ if (first) resolvedBase = first;
188
+ }
189
+ if (!resolvedBase) resolvedBase = "trunk()";
190
+ }
191
+
192
+ const addResult = jj.workspaceAdd({
193
+ cwd: fromCwd,
194
+ destPath,
195
+ baseRev: resolvedBase,
196
+ });
197
+ if (!addResult.ok) {
198
+ reply.code(500);
199
+ return {
200
+ success: false,
201
+ error: `jj workspace add failed: ${describeError(addResult.error)}`,
202
+ } satisfies ApiResponse;
203
+ }
204
+
205
+ const realDestPath = safeRealpathSync(destPath);
206
+ pendingAttachRegistry.enqueue(realDestPath, name);
207
+
208
+ // Spawn a session in the new workspace. Mirrors the OpenSpec
209
+ // attach-and-spawn flow; the bridge's `session_register` will
210
+ // consume the pending-attach intent and apply the auto-rename.
211
+ try {
212
+ const config = loadConfig();
213
+ const spawnResult = await spawnPiSession(realDestPath, {
214
+ strategy: config.spawnStrategy,
215
+ });
216
+ if (spawnResult.process && spawnResult.pid) {
217
+ browserGateway.headlessPidRegistry.register(
218
+ spawnResult.pid,
219
+ realDestPath,
220
+ spawnResult.process,
221
+ );
222
+ }
223
+ if (!spawnResult.success) {
224
+ reply.code(202);
225
+ return {
226
+ success: true,
227
+ data: {
228
+ workspacePath: realDestPath,
229
+ spawned: false,
230
+ spawnMessage: spawnResult.message,
231
+ },
232
+ } satisfies ApiResponse;
233
+ }
234
+ return {
235
+ success: true,
236
+ data: {
237
+ workspacePath: realDestPath,
238
+ spawned: true,
239
+ taskDescription: taskDescription ?? null,
240
+ },
241
+ } satisfies ApiResponse;
242
+ } catch (err) {
243
+ reply.code(202);
244
+ return {
245
+ success: true,
246
+ data: {
247
+ workspacePath: realDestPath,
248
+ spawned: false,
249
+ spawnMessage: err instanceof Error ? err.message : String(err),
250
+ },
251
+ } satisfies ApiResponse;
252
+ }
253
+ },
254
+ );
255
+
256
+ // ── POST /api/jj/workspace/forget ───────────────────────────────────────
257
+ fastify.post<{
258
+ Body: { cwd?: string; name?: string; force?: boolean };
259
+ }>(
260
+ "/api/jj/workspace/forget",
261
+ { preHandler: networkGuard },
262
+ async (request, reply) => {
263
+ const { cwd, name, force } = request.body ?? {};
264
+
265
+ if (!cwd) {
266
+ reply.code(400);
267
+ return { success: false, error: "cwd is required" } satisfies ApiResponse;
268
+ }
269
+ if (!name || !NAME_RE.test(name)) {
270
+ reply.code(400);
271
+ return {
272
+ success: false,
273
+ error: "INVALID_NAME: name must match /^[a-z0-9-]+$/",
274
+ } satisfies ApiResponse;
275
+ }
276
+ if (!existsSync(path.join(cwd, ".jj"))) {
277
+ reply.code(400);
278
+ return {
279
+ success: false,
280
+ error: "cwd is not a jj repo",
281
+ } satisfies ApiResponse;
282
+ }
283
+
284
+ // Inspect for unfolded commits: anything in the workspace's `@`
285
+ // that isn't an ancestor of trunk. `trunk()..<name>@` is the
286
+ // straight-line revset for that; we filter out empty changes
287
+ // (`~empty()`) so the empty `@` of a freshly-created workspace
288
+ // doesn't trigger the unfolded-work refusal.
289
+ // Note: jj 0.40's `fork_point()` takes a single revset; we use
290
+ // the simpler `..` range form which works on every supported jj.
291
+ let unfolded: string[] = [];
292
+ const logResult = jj.logRevset({
293
+ cwd,
294
+ revset: `trunk()..${name}@ & ~empty()`,
295
+ template: 'change_id.short() ++ " " ++ description.first_line() ++ "\\n"',
296
+ });
297
+ if (logResult.ok) {
298
+ unfolded = logResult.value
299
+ .split("\n")
300
+ .map((l) => l.trim())
301
+ .filter(Boolean);
302
+ }
303
+ // A failed revset (e.g. unknown bookmark / fork_point unsupported) is
304
+ // *not* sufficient to skip the safety check — refuse with a generic
305
+ // error so the user sees the underlying jj message.
306
+ if (!logResult.ok) {
307
+ reply.code(500);
308
+ return {
309
+ success: false,
310
+ error: `jj log probe failed: ${describeError(logResult.error)}`,
311
+ } satisfies ApiResponse;
312
+ }
313
+
314
+ if (unfolded.length > 0 && !force) {
315
+ reply.code(409);
316
+ return {
317
+ success: false,
318
+ error: "UNFOLDED_WORK",
319
+ data: { unfolded },
320
+ } as unknown as ApiResponse;
321
+ }
322
+
323
+ // Forget + remove directory.
324
+ const forgetResult = jj.workspaceForget({ cwd, name });
325
+ if (!forgetResult.ok) {
326
+ reply.code(500);
327
+ return {
328
+ success: false,
329
+ error: `jj workspace forget failed: ${describeError(forgetResult.error)}`,
330
+ } satisfies ApiResponse;
331
+ }
332
+
333
+ const workspaceRoot = resolveWorkspaceRoot(deps);
334
+ const dirPath = path.join(cwd, workspaceRoot, name);
335
+ try {
336
+ await fs.rm(dirPath, { recursive: true, force: true });
337
+ } catch (err) {
338
+ // Forget already succeeded; surface the rm error but don't fail
339
+ // the operation overall — the workspace is gone from jj's view.
340
+ request.log.warn(
341
+ `jj workspace dir cleanup failed (${dirPath}): ${err instanceof Error ? err.message : String(err)}`,
342
+ );
343
+ }
344
+
345
+ return { success: true, data: { name, force: Boolean(force) } } satisfies ApiResponse;
346
+ },
347
+ );
348
+
349
+ // ── POST /api/jj/init-colocated ─────────────────────────────────────────
350
+ fastify.post<{ Body: { cwd?: string } }>(
351
+ "/api/jj/init-colocated",
352
+ { preHandler: networkGuard },
353
+ async (request, reply) => {
354
+ const { cwd } = request.body ?? {};
355
+ const precheck = checkInitColocatedPreconditions(cwd ?? "");
356
+ if (precheck) {
357
+ reply.code(precheck.code === "DIRTY_INDEX" ? 409 : 400);
358
+ return {
359
+ success: false,
360
+ error: precheck.code,
361
+ data: { message: precheck.message },
362
+ } as unknown as ApiResponse;
363
+ }
364
+ const result = jj.gitInitColocate({ cwd: cwd! });
365
+ if (!result.ok) {
366
+ reply.code(500);
367
+ return {
368
+ success: false,
369
+ error: `jj git init --colocate failed: ${describeError(result.error)}`,
370
+ } satisfies ApiResponse;
371
+ }
372
+ return { success: true, data: { cwd } } satisfies ApiResponse;
373
+ },
374
+ );
375
+ }
376
+
377
+ function describeError(error: { kind: string; [k: string]: unknown }): string {
378
+ if (error.kind === "not-found") return `binary not found: ${String(error.binary ?? "jj")}`;
379
+ if (error.kind === "timeout") return `timed out after ${String(error.timeoutMs)}ms`;
380
+ if (error.kind === "exit") {
381
+ const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
382
+ return stderr.split("\n")[0] || `exited ${String(error.code)}`;
383
+ }
384
+ if (error.kind === "spawn-failure") return String(error.message ?? "spawn failed");
385
+ return error.kind;
386
+ }
@@ -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
 
@@ -8,7 +8,7 @@ import type { SessionManager } from "../memory-session-manager.js";
8
8
  import type { EventStore } from "../memory-event-store.js";
9
9
  import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
10
10
  import type { NetworkGuard } from "./route-deps.js";
11
- import { extractFileChanges, enrichWithGitDiff } from "../session-diff.js";
11
+ import { extractFileChanges, enrichWithVcsDiff } from "../session-diff.js";
12
12
 
13
13
  export function registerSessionRoutes(
14
14
  fastify: FastifyInstance,
@@ -52,8 +52,17 @@ export function registerSessionRoutes(
52
52
  }
53
53
  const events = eventStore.getEvents(sessionId, 0).map((e) => e.event);
54
54
  const files = extractFileChanges(events, session.cwd);
55
- const { enrichedFiles, isGitRepo: isGit } = enrichWithGitDiff(session.cwd, files);
56
- return { success: true, data: { files: enrichedFiles, isGitRepo: isGit } } satisfies ApiResponse;
55
+ const result = enrichWithVcsDiff(session.cwd, files, session.jjState);
56
+ return {
57
+ success: true,
58
+ data: {
59
+ files: result.enrichedFiles,
60
+ isGitRepo: result.isGitRepo,
61
+ vcsKind: result.vcsKind,
62
+ diffBase: result.diffBase,
63
+ baseLabel: result.baseLabel,
64
+ },
65
+ } satisfies ApiResponse;
57
66
  },
58
67
  );
59
68
 
@@ -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";
@@ -52,6 +53,7 @@ import { registerPiCoreRoutes } from "./routes/pi-core-routes.js";
52
53
  import { PiCoreChecker } from "./pi-core-checker.js";
53
54
  import { PiCoreUpdater } from "./pi-core-updater.js";
54
55
  import { registerToolRoutes } from "./routes/tool-routes.js";
56
+ import { registerJjRoutes } from "./routes/jj-routes.js";
55
57
  import { registerBootstrapRoutes } from "./routes/bootstrap-routes.js";
56
58
  import { createBootstrapState, type BootstrapStateStore } from "./bootstrap-state.js";
57
59
  import { createBootstrapQueue } from "./bootstrap-queue.js";
@@ -90,6 +92,10 @@ export interface ServerConfig {
90
92
  editor: import("@blackbelt-technology/pi-dashboard-shared/config.js").EditorConfig;
91
93
  /** OpenSpec polling config (interval, concurrency, change detection, jitter) */
92
94
  openspec?: import("@blackbelt-technology/pi-dashboard-shared/config.js").OpenSpecPollConfig;
95
+ /** Reattach-placement policy applied when a bridge re-registers after
96
+ * a dashboard restart. Defaults to `"always"`.
97
+ * See change: reattach-move-to-front. */
98
+ reattachPlacement?: import("@blackbelt-technology/pi-dashboard-shared/config.js").ReattachPlacement;
93
99
  /** Merged trusted networks from config */
94
100
  resolvedTrustedNetworks?: string[];
95
101
  /** CORS allowed origins from config */
@@ -138,10 +144,6 @@ export interface PostInstallRepairDeps {
138
144
  browserGateway: { broadcastToAll(msg: ServerToBrowserMessage): void };
139
145
  }
140
146
 
141
- function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
142
- if (!d) return true;
143
- return !d.initialized && (!d.changes || d.changes.length === 0);
144
- }
145
147
 
146
148
  /**
147
149
  * Centralized post-install repair work fired on every `installing → ready`
@@ -366,7 +368,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
366
368
  applyReattachPolicy(
367
369
  sessionId,
368
370
  session.cwd,
369
- config.reattachPlacement,
371
+ config.reattachPlacement ?? "always",
370
372
  { sessionManager, sessionOrderManager, browserGateway },
371
373
  ctx.priorStatus,
372
374
  );
@@ -416,7 +418,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
416
418
  applyReattachPolicy(
417
419
  sessionId,
418
420
  session.cwd,
419
- config.reattachPlacement,
421
+ config.reattachPlacement ?? "always",
420
422
  { sessionManager, sessionOrderManager, browserGateway },
421
423
  ctx.priorStatus,
422
424
  );
@@ -558,7 +560,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
558
560
  });
559
561
 
560
562
  // Auto-shutdown idle timer
561
- const idleTimer = createIdleTimer(config, piGateway);
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);
562
566
 
563
567
  const fastify = Fastify({
564
568
  logger: false,
@@ -704,8 +708,11 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
704
708
  if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
705
709
  },
706
710
  });
707
- 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);
708
714
  registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
715
+ registerJjRoutes(fastify, { browserGateway, pendingAttachRegistry, networkGuard });
709
716
 
710
717
  // ── Bootstrap REST routes ────────────────────────────────────────
711
718
  // The routes module is registered here; state + queue are declared
@@ -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 { DirectoryService } from "./directory-service.js";
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
- // NOTE: `refreshOpenSpec` / `pollOpenSpec` is currently synchronous internally
79
- // (spawnSync per change) — on Windows with many active changes (~19) and
80
- // multiple pinned directories this can block the event loop for minutes,
81
- // making the HTTP server unresponsive during startup. We intentionally do
82
- // NOT await it here so HTTP + WebSocket startup completes immediately;
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
- // A proper fix is to migrate the openspec Recipe to async spawn; tracked
87
- // separately. See change: consolidate-tool-resolution.
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 { await directoryService.refreshOpenSpec(cwd); }
91
- catch (err) { console.error(`[dashboard] initial openspec poll failed for ${cwd}:`, err); }
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
  }