@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/AGENTS.md +79 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. package/packages/shared/src/types.ts +160 -0
@@ -18,6 +18,8 @@ import { createPreferencesStore, type PreferencesStore } from "./preferences-sto
18
18
  import { createMetaPersistence, type MetaPersistence } from "./meta-persistence.js";
19
19
  import { createSessionOrderManager, type SessionOrderManager } from "./session-order-manager.js";
20
20
  import { createPendingForkRegistry, type PendingForkRegistry } from "./pending-fork-registry.js";
21
+ import { createPendingAttachRegistry } from "./pending-attach-registry.js";
22
+ import { createPendingResumeIntentRegistry } from "./pending-resume-intent-registry.js";
21
23
 
22
24
  // pending-load-manager removed — server loads sessions directly via DirectoryService
23
25
  import { createDirectoryService, type DirectoryService } from "./directory-service.js";
@@ -35,6 +37,7 @@ import { registerAuthPlugin, validateWsUpgrade } from "./auth-plugin.js";
35
37
  import { findBundledExtension, registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
36
38
  import { createNetworkGuard, isLoopback, isBypassedHost } from "./localhost-guard.js";
37
39
  import type { AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
40
+ import { loadConfig, CONFIG_FILE } from "@blackbelt-technology/pi-dashboard-shared/config.js";
38
41
  import { registerSessionApi } from "./session-api.js";
39
42
  import { registerSessionRoutes } from "./routes/session-routes.js";
40
43
  import { registerGitRoutes } from "./routes/git-routes.js";
@@ -59,6 +62,11 @@ import { createEditorManager, type EditorManager } from "./editor-manager.js";
59
62
  import { createEditorPidRegistry } from "./editor-pid-registry.js";
60
63
  import { registerEditorRoutes } from "./routes/editor-routes.js";
61
64
  import { registerKnownServersRoutes } from "./routes/known-servers-routes.js";
65
+ import { registerPluginConfigRoutes } from "./routes/plugin-config-routes.js";
66
+ import { loadServerEntries, discoverPlugins, getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
67
+ import { createServerPluginContext } from "@blackbelt-technology/dashboard-plugin-runtime/server";
68
+ import { getPluginConfig as getPluginConfigFromFile } from "@blackbelt-technology/pi-dashboard-shared/config.js";
69
+ import { registerAllPluginBridges } from "@blackbelt-technology/pi-dashboard-shared/plugin-bridge-register.js";
62
70
  import { registerEditorProxy, handleEditorUpgrade } from "./editor-proxy.js";
63
71
  import { detectCodeServerBinary } from "./editor-detection.js";
64
72
 
@@ -107,6 +115,128 @@ export interface DashboardServer {
107
115
  piPort(): number | null;
108
116
  }
109
117
 
118
+ // ── Post-install repair (centralized hook) ─────────────────────────
119
+ // On every `installing → ready` bootstrap-state transition the server
120
+ // re-runs the full ToolRegistry rescan, force-refreshes OpenSpec data
121
+ // for every known directory, and refreshes pi-resources. Without this
122
+ // the OpenSpec session-card buttons stay hidden until the next gated
123
+ // poll tick (or never, if the gate's mtime heuristic declines).
124
+ // See change: fix-openspec-buttons-after-bootstrap-install.
125
+
126
+ import type { OpenSpecData } from "@blackbelt-technology/pi-dashboard-shared/types.js";
127
+ import type { ServerToBrowserMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
128
+
129
+ export interface PostInstallRepairDeps {
130
+ registry: { rescan(name?: string): void };
131
+ directoryService: {
132
+ knownDirectories(): string[];
133
+ getOpenSpecData(cwd: string): OpenSpecData | undefined;
134
+ refreshOpenSpec(cwd: string): Promise<OpenSpecData>;
135
+ refreshPiResources(cwd: string): Promise<unknown>;
136
+ };
137
+ browserGateway: { broadcastToAll(msg: ServerToBrowserMessage): void };
138
+ }
139
+
140
+ function isOpenSpecDataEmpty(d: OpenSpecData | undefined): boolean {
141
+ if (!d) return true;
142
+ return !d.initialized && (!d.changes || d.changes.length === 0);
143
+ }
144
+
145
+ /**
146
+ * Centralized post-install repair work fired on every `installing → ready`
147
+ * bootstrap-state transition. Idempotent. Failures per-cwd are isolated.
148
+ *
149
+ * Steps:
150
+ * 1) `registry.rescan()` (no arg — full registry invalidate). Restores
151
+ * the literal contract from `unified-bootstrap-install` task 4.3.
152
+ * 2) For every `directoryService.knownDirectories()` cwd, force-refresh
153
+ * OpenSpec (bypassing the mtime gate). If the prior cache was empty
154
+ * or the refreshed payload differs, broadcast `openspec_update`.
155
+ * 3) For every cwd, force-refresh pi-resources (silent on failure).
156
+ *
157
+ * The DEBUG=pi-dashboard|openspec-poll envvar enables a single-line
158
+ * diagnostic log on completion, matching the existing daemon-log style.
159
+ */
160
+ export async function runPostInstallRepair(deps: PostInstallRepairDeps): Promise<void> {
161
+ const debug =
162
+ typeof process !== "undefined" &&
163
+ typeof process.env?.DEBUG === "string" &&
164
+ /pi-dashboard|openspec-poll/.test(process.env.DEBUG);
165
+
166
+ // 1) full registry rescan
167
+ deps.registry.rescan();
168
+ if (debug) console.log("[bootstrap] post-install: rescanned tool registry");
169
+
170
+ const cwds = deps.directoryService.knownDirectories();
171
+
172
+ // 2) per-cwd OpenSpec force-refresh + selective broadcast
173
+ await Promise.all(
174
+ cwds.map(async (cwd) => {
175
+ try {
176
+ const prior = deps.directoryService.getOpenSpecData(cwd);
177
+ const fresh = await deps.directoryService.refreshOpenSpec(cwd);
178
+ const priorEmpty = isOpenSpecDataEmpty(prior);
179
+ const dataDiffers = JSON.stringify(prior) !== JSON.stringify(fresh);
180
+ if (priorEmpty || dataDiffers) {
181
+ deps.browserGateway.broadcastToAll({ type: "openspec_update", cwd, data: fresh });
182
+ }
183
+ } catch (err) {
184
+ console.error(
185
+ `[bootstrap] post-install openspec refresh failed for ${cwd}:`,
186
+ err,
187
+ );
188
+ }
189
+ }),
190
+ );
191
+
192
+ // 3) per-cwd pi-resources force-refresh (silent fail)
193
+ await Promise.all(
194
+ cwds.map(async (cwd) => {
195
+ try {
196
+ await deps.directoryService.refreshPiResources(cwd);
197
+ } catch {
198
+ // matches existing pattern in directory-service.ts::schedulePiResourcesTick
199
+ }
200
+ }),
201
+ );
202
+
203
+ if (debug) console.log("[bootstrap] post-install: openspec + pi-resources refresh complete");
204
+ }
205
+
206
+ export interface BootstrapTransitionHandlerDeps {
207
+ /** Invoked once per `installing → ready` transition, fire-and-forget. */
208
+ onTransitionToReady: () => Promise<void> | void;
209
+ /** Existing queue flush invoked on the same transition. */
210
+ flushQueue: () => Promise<void> | void;
211
+ }
212
+
213
+ /**
214
+ * Returns a stateful handler that drives `onTransitionToReady` and
215
+ * `flushQueue` once per `installing → ready` (or `failed → ready`)
216
+ * transition. The handler ignores the very first ready snapshot
217
+ * because the bootstrap state defaults to ready.
218
+ *
219
+ * Both callbacks run fire-and-forget so the subscribe callback returns
220
+ * synchronously — matches the existing `void bootstrapQueue.flushAll()`
221
+ * pattern in the inline subscribe call site.
222
+ */
223
+ export function makeBootstrapTransitionHandler(
224
+ deps: BootstrapTransitionHandlerDeps,
225
+ ): (snapshot: { status: "ready" | "installing" | "failed" }) => void {
226
+ let last: "ready" | "installing" | "failed" = "ready";
227
+ return (snapshot) => {
228
+ if (last !== "ready" && snapshot.status === "ready") {
229
+ void Promise.resolve(deps.flushQueue()).catch((err) => {
230
+ console.error("[bootstrap] flushQueue failed:", err);
231
+ });
232
+ void Promise.resolve(deps.onTransitionToReady()).catch((err) => {
233
+ console.error("[bootstrap] post-install repair failed:", err);
234
+ });
235
+ }
236
+ last = snapshot.status;
237
+ };
238
+ }
239
+
110
240
  export async function createServer(config: ServerConfig): Promise<DashboardServer> {
111
241
  // Ensure bridge extension is registered in pi's global settings
112
242
  // (needed for bundled installs where pi can't discover it from package.json)
@@ -176,11 +306,112 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
176
306
  firstMessage: session.firstMessage,
177
307
  cachedAt: Date.now(),
178
308
  });
309
+ // When a session ends, drop its id from the persisted drag-reorder list
310
+ // for that cwd. Drag-reorder is meaningful for live sessions only; ended
311
+ // ones must fall to the bottom in their natural endedAt order (rendered
312
+ // top-of-bucket on most-recent-first) rather than retaining a position
313
+ // that interleaves them with active sessions.
314
+ // See change: pin-and-search-sessions, top-of-tier-on-status-change.
315
+ // Status-transition tracking: prune+broadcast runs ONCE per
316
+ // transition to ended. Subsequent `update()` calls on an already-
317
+ // ended session (e.g. heartbeat tail, click-induced state sync,
318
+ // late events from the bridge) do NOT re-trigger the prune —
319
+ // otherwise the card visibly jumps to the tail of the ended group
320
+ // every time the user interacts with it.
321
+ // See change: pin-and-search-sessions.
322
+ const wasEnded = endedSessionIds.has(sessionId);
323
+ const isEnded = session.status === "ended";
324
+ if (isEnded && !wasEnded) {
325
+ // Just transitioned alive→ended.
326
+ endedSessionIds.add(sessionId);
327
+ const orderBefore = sessionOrderManager.getOrder(session.cwd) ?? [];
328
+ sessionOrderManager.remove(session.cwd, sessionId);
329
+ const orderAfter = sessionOrderManager.getOrder(session.cwd) ?? [];
330
+ if (orderBefore.length !== orderAfter.length) {
331
+ browserGateway.broadcastToAll({
332
+ type: "sessions_reordered",
333
+ cwd: session.cwd,
334
+ sessionIds: orderAfter,
335
+ });
336
+ }
337
+ } else if (!isEnded && wasEnded) {
338
+ // Resume: ended→alive. Three real outcomes land here, distinguished
339
+ // by the value `pendingResumeIntents.consume(...)` returns:
340
+ // "front" — Resume button, REST resume, prompt-auto-resume.
341
+ // User wants the card surfaced at the top of alive.
342
+ // "keep" — Drag-to-resume. The dropped slot was already
343
+ // persisted via `reorder_sessions`; do NOT clobber it.
344
+ // null — Bridge auto-reattach (dashboard restarted, pi
345
+ // process still alive, no user intent tagged).
346
+ // Preserve the user's existing layout.
347
+ // We always clear the transition tracker so a future alive→ended
348
+ // for this session fires correctly.
349
+ // See changes: preserve-session-order-on-reboot,
350
+ // top-of-tier-on-status-change,
351
+ // differentiate-resume-intent-by-trigger.
352
+ endedSessionIds.delete(sessionId);
353
+ const intent = pendingResumeIntents.consume(sessionId);
354
+ if (intent === null) {
355
+ // Bridge auto-reattach — leave order alone.
356
+ return;
357
+ }
358
+ if (intent === "keep") {
359
+ // Drag-to-resume — dropped slot wins; the earlier reorder_sessions
360
+ // already broadcast. Do NOT mutate sessionOrder, do NOT broadcast.
361
+ return;
362
+ }
363
+ // intent === "front": move-to-front so the just-resumed card
364
+ // surfaces at the top of the alive tier, even on repeated end →
365
+ // resume cycles where the id might still be in the order.
366
+ sessionOrderManager.moveToFront(session.cwd, sessionId);
367
+ const next = sessionOrderManager.getOrder(session.cwd) ?? [];
368
+ browserGateway.broadcastToAll({
369
+ type: "sessions_reordered",
370
+ cwd: session.cwd,
371
+ sessionIds: next,
372
+ });
373
+ }
179
374
  };
375
+ // Track which session ids we've seen as ended at least once, so the
376
+ // onChange hook can detect actual alive→ended transitions vs. mere
377
+ // re-emits of the ended state.
378
+ const endedSessionIds = new Set<string>(
379
+ sessionManager.listAll().filter((s) => s.status === "ended").map((s) => s.id),
380
+ );
381
+
382
+ // Startup reconciliation: persisted `sessionOrder` may contain ended
383
+ // session ids from before the alive→ended prune was implemented. Strip
384
+ // them now so the next render sees a consistent state where ended ids
385
+ // never appear in the order pass.
386
+ // See change: pin-and-search-sessions.
387
+ for (const [cwd, ids] of Object.entries(sessionOrderManager.getAllOrders())) {
388
+ const aliveIds = ids.filter((id) => {
389
+ const s = sessionManager.get(id);
390
+ // Keep ids we don't know about — they may belong to other cwds or
391
+ // be live but not yet registered. Strip only the ones explicitly
392
+ // marked ended.
393
+ return !s || s.status !== "ended";
394
+ });
395
+ if (aliveIds.length !== ids.length) {
396
+ sessionOrderManager.reorder(cwd, aliveIds);
397
+ }
398
+ }
180
399
 
181
400
  // Track cwds with pending dashboard-spawned sessions (for writing .meta.json).
182
401
  // Uses a counter per cwd to handle multiple spawns and avoid reconnects consuming entries.
183
402
  const pendingDashboardSpawns = new Map<string, number>();
403
+
404
+ // Pending spawn-with-attach intents (cwd → FIFO queue of changeNames).
405
+ // Consumed in event-wiring.ts on session_register. See change:
406
+ // add-folder-task-checker-and-spawn-attach.
407
+ const pendingAttachRegistry = createPendingAttachRegistry();
408
+ // Pending user-initiated resume intents (sessionId → timestamp).
409
+ // Consumed by `sessionManager.onChange` in the ended→alive branch to
410
+ // gate the sessionOrder mutation behind explicit user intent so that
411
+ // bridge auto-reattach on dashboard reboot does not mutate the user's
412
+ // drag-order.
413
+ // See change: preserve-session-order-on-reboot.
414
+ const pendingResumeIntents = createPendingResumeIntentRegistry();
184
415
  // Track known session IDs so we can distinguish new sessions from reconnections.
185
416
  const knownSessionIds = new Set<string>();
186
417
  // Populate from persisted sessions
@@ -237,7 +468,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
237
468
  },
238
469
  });
239
470
 
240
- const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes);
471
+ const browserGateway = createBrowserGateway(sessionManager, eventStore, piGateway, undefined, pendingForkRegistry, sessionOrderManager, preferencesStore, directoryService, terminalManager, pendingDashboardSpawns, config.maxWsBufferBytes, pendingAttachRegistry, pendingResumeIntents);
241
472
 
242
473
  // Resolve package version once at startup
243
474
  const __require = createRequire(import.meta.url);
@@ -271,6 +502,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
271
502
  directoryService,
272
503
  knownSessionIds,
273
504
  pendingDashboardSpawns,
505
+ pendingAttachRegistry,
274
506
  });
275
507
 
276
508
  // Auto-shutdown idle timer
@@ -362,17 +594,24 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
362
594
  // See change: unified-bootstrap-install.
363
595
  const bootstrapState = createBootstrapState();
364
596
  const bootstrapQueue = createBootstrapQueue();
365
- let lastBootstrapStatus: "ready" | "installing" | "failed" = "ready";
597
+ // Centralized post-install repair: full ToolRegistry rescan +
598
+ // OpenSpec / pi-resources force-refresh on every `installing → ready`
599
+ // transition. See change: fix-openspec-buttons-after-bootstrap-install.
600
+ const handleBootstrapTransition = makeBootstrapTransitionHandler({
601
+ flushQueue: () => bootstrapQueue.flushAll(),
602
+ onTransitionToReady: () =>
603
+ runPostInstallRepair({
604
+ registry: getDefaultRegistry(),
605
+ directoryService,
606
+ browserGateway,
607
+ }),
608
+ });
366
609
  const unsubscribeBootstrap = bootstrapState.subscribe((snapshot) => {
367
610
  browserGateway.broadcastToAll({
368
611
  type: "bootstrap_status_update",
369
612
  state: snapshot,
370
613
  });
371
- // Flush queued pi-dependent operations on ready transition.
372
- if (lastBootstrapStatus !== "ready" && snapshot.status === "ready") {
373
- void bootstrapQueue.flushAll();
374
- }
375
- lastBootstrapStatus = snapshot.status;
614
+ handleBootstrapTransition(snapshot);
376
615
  });
377
616
  const unsubscribeQueueComplete = bootstrapQueue.onTicketComplete((evt) => {
378
617
  browserGateway.broadcastToAll({
@@ -392,6 +631,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
392
631
  pendingDashboardSpawns,
393
632
  bootstrapState,
394
633
  bootstrapQueue,
634
+ pendingResumeIntents,
395
635
  });
396
636
 
397
637
  // Register route modules
@@ -521,9 +761,16 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
521
761
  // Package management
522
762
  const packageManagerWrapper = new PackageManagerWrapper();
523
763
 
524
- // Forward progress events to all browser clients
525
- packageManagerWrapper.setProgressListener((operationId, event) => {
526
- browserGateway.broadcastToAll({ type: "package_progress", operationId, event } as any);
764
+ // Forward progress events to all browser clients. The third arg
765
+ // (`moveId`) is set when the event is part of a composite move op;
766
+ // clients group events by moveId. See change: unify-package-management-ui.
767
+ packageManagerWrapper.setProgressListener((operationId, event, moveId) => {
768
+ browserGateway.broadcastToAll({
769
+ type: "package_progress",
770
+ operationId,
771
+ ...(moveId ? { moveId } : {}),
772
+ event,
773
+ } as any);
527
774
  });
528
775
 
529
776
  // On completion: broadcast to browsers + invalidate the recommended cache
@@ -538,6 +785,8 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
538
785
  error: result.error,
539
786
  diagnostics: result.diagnostics,
540
787
  sessionsReloaded: (result as any).sessionsReloaded,
788
+ ...(result.moveId ? { moveId: result.moveId } : {}),
789
+ ...(result.partialSuccess ? { partialSuccess: result.partialSuccess } : {}),
541
790
  } as any);
542
791
  if (result.success) invalidateRecommendedCache();
543
792
  });
@@ -626,6 +875,10 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
626
875
 
627
876
  registerProviderAuthRoutes(fastify, { piGateway, browserGateway });
628
877
  registerKnownServersRoutes(fastify, { networkGuard, getPeerServers: () => peerServers });
878
+ registerPluginConfigRoutes(fastify, {
879
+ networkGuard,
880
+ broadcast: (msg) => browserGateway.broadcast(msg),
881
+ });
629
882
  registerProviderRoutes(fastify, { networkGuard, piGateway, browserGateway });
630
883
 
631
884
  // Serve static files / SPA fallback.
@@ -848,6 +1101,82 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
848
1101
  // Discover sessions and start OpenSpec polling (async, non-blocking)
849
1102
  discoverAndBroadcastSessions({ sessionManager, browserGateway, directoryService });
850
1103
 
1104
+ // Load plugin server entries (non-blocking; failures isolated per plugin)
1105
+ loadServerEntries({
1106
+ isEnabled: (pluginId) => {
1107
+ const cfg = loadConfig();
1108
+ const pluginCfg = getPluginConfigFromFile(cfg, pluginId) as Record<string, unknown>;
1109
+ return pluginCfg.enabled !== false; // default: enabled
1110
+ },
1111
+ createContext: (plugin) => createServerPluginContext(
1112
+ {
1113
+ fastify,
1114
+ sessionManager: {
1115
+ listActive: () => sessionManager.listActive(),
1116
+ listAll: () => sessionManager.listAll(),
1117
+ getSession: (id: string) => sessionManager.get(id),
1118
+ },
1119
+ eventStore: {
1120
+ getEvents: (sessionId) => eventStore.getEvents(sessionId, 0),
1121
+ getLatestEvent: (sessionId) => {
1122
+ const events = eventStore.getEvents(sessionId, 0);
1123
+ return events.length > 0 ? events[events.length - 1] : undefined;
1124
+ },
1125
+ },
1126
+ broadcastToSubscribers: (msg) => browserGateway.broadcast(msg as any),
1127
+ // Plugin pi/browser handler registration — stub for now;
1128
+ // full dynamic handler registration requires a registry refactor
1129
+ // tracked in extract-*-as-plugin changes.
1130
+ registerPiHandler: (_type, _handler) => {},
1131
+ registerBrowserHandler: (_type, _handler) => {},
1132
+ getPluginConfig: (id) => {
1133
+ const cfg = loadConfig();
1134
+ return getPluginConfigFromFile(cfg, id);
1135
+ },
1136
+ updatePluginConfig: async (id, partial) => {
1137
+ // Inline partial write (reuses CONFIG_FILE path from shared config)
1138
+ const cfg = loadConfig();
1139
+ const current = getPluginConfigFromFile(cfg, id);
1140
+ const merged = { ...current, ...partial };
1141
+ let rawConfig: Record<string, unknown> = {};
1142
+ try {
1143
+ const raw = (await import('node:fs')).default.readFileSync(CONFIG_FILE, 'utf-8');
1144
+ rawConfig = JSON.parse(raw);
1145
+ } catch { /* start fresh */ }
1146
+ rawConfig.plugins = { ...(rawConfig.plugins as Record<string, unknown> ?? {}), [id]: merged };
1147
+ const fs = (await import('node:fs')).default;
1148
+ const tmpFile = CONFIG_FILE + '.tmp.' + process.pid;
1149
+ fs.writeFileSync(tmpFile, JSON.stringify(rawConfig, null, 2) + '\n');
1150
+ fs.renameSync(tmpFile, CONFIG_FILE);
1151
+ browserGateway.broadcast({ type: 'plugin_config_update', id, config: merged } as any);
1152
+ },
1153
+ },
1154
+ plugin.manifest.id,
1155
+ ),
1156
+ }).catch((err) => console.error('[plugin-loader] Unexpected error:', err));
1157
+
1158
+ // Auto-register plugin bridge entries
1159
+ const discoveredPlugins = discoverPlugins();
1160
+ const pluginsWithBridges = discoveredPlugins
1161
+ .filter(p => p.bridgeEntryPath)
1162
+ .map(p => ({ pluginId: p.manifest.id, bridgePath: p.bridgeEntryPath! }));
1163
+ if (pluginsWithBridges.length) {
1164
+ const results = registerAllPluginBridges(pluginsWithBridges);
1165
+ for (const [id, result] of Object.entries(results)) {
1166
+ if (result.type === 'conflict') {
1167
+ const store = getPluginStatusStore();
1168
+ const existing = store.getStatus(id);
1169
+ store.setStatus({
1170
+ id,
1171
+ enabled: existing?.enabled ?? true,
1172
+ loaded: existing?.loaded ?? false,
1173
+ error: `Bridge path conflict: existing=${result.existingPath}, new=${result.newPath}`,
1174
+ claims: existing?.claims ?? 0,
1175
+ });
1176
+ }
1177
+ }
1178
+ }
1179
+
851
1180
  idleTimer.start();
852
1181
  },
853
1182
 
@@ -11,8 +11,10 @@ import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/type
11
11
  import { spawnPiSession } from "./process-manager.js";
12
12
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
13
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
14
+ import type { PendingResumeIntentRegistry } from "./pending-resume-intent-registry.js";
14
15
  import type { BootstrapStateStore } from "./bootstrap-state.js";
15
16
  import type { BootstrapQueue } from "./bootstrap-queue.js";
17
+ import { attachRenameTarget, detachShouldClearName } from "./proposal-attach-naming.js";
16
18
 
17
19
  export interface SessionApiDeps {
18
20
  sessionManager: SessionManager;
@@ -27,6 +29,13 @@ export interface SessionApiDeps {
27
29
  */
28
30
  bootstrapState?: BootstrapStateStore;
29
31
  bootstrapQueue?: BootstrapQueue;
32
+ /**
33
+ * User-resume-intent registry. Tagged in the resume endpoint so the
34
+ * `sessionManager.onChange` ended→alive branch can distinguish a
35
+ * REST-initiated user resume from a bridge auto-reattach on reboot.
36
+ * See change: preserve-session-order-on-reboot.
37
+ */
38
+ pendingResumeIntents?: PendingResumeIntentRegistry;
30
39
  }
31
40
 
32
41
  type IdParams = { Params: { id: string } };
@@ -39,7 +48,7 @@ function getSessionOrFail(sessionManager: SessionManager, id: string): { session
39
48
  }
40
49
 
41
50
  export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDeps) {
42
- const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue } = deps;
51
+ const { sessionManager, piGateway, browserGateway, pendingForkRegistry, pendingDashboardSpawns, bootstrapState, bootstrapQueue, pendingResumeIntents } = deps;
43
52
 
44
53
  /**
45
54
  * Gate pi-dependent operations on bootstrap status. Returns:
@@ -276,6 +285,12 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
276
285
  if (mode === "fork" && pendingForkRegistry) {
277
286
  pendingForkRegistry.recordFork(session.cwd, id);
278
287
  }
288
+ // Tag the user-resume intent BEFORE spawning. REST resume always
289
+ // uses "front" placement — the only "keep" path is drag-to-resume
290
+ // which goes through the WebSocket handler, not this REST endpoint.
291
+ // See changes: preserve-session-order-on-reboot,
292
+ // differentiate-resume-intent-by-trigger.
293
+ pendingResumeIntents?.record(id, "front");
279
294
  const config = loadConfig();
280
295
  const spawnResult = await spawnPiSession(session.cwd, {
281
296
  sessionFile: session.sessionFile,
@@ -370,9 +385,11 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
370
385
  }
371
386
  const updates: Record<string, unknown> = { attachedProposal: changeName };
372
387
  const session = result.session;
373
- if (!session.name?.trim()) {
374
- updates.name = changeName;
375
- piGateway.sendToSession(id, { type: "rename_session", sessionId: id, name: changeName });
388
+ // Idempotent auto-rename (see change: fix-mobile-attach-proposal-display).
389
+ const newName = attachRenameTarget(session, changeName);
390
+ if (newName !== undefined) {
391
+ updates.name = newName;
392
+ piGateway.sendToSession(id, { type: "rename_session", sessionId: id, name: newName });
376
393
  }
377
394
  sessionManager.update(id, updates);
378
395
  browserGateway.broadcastSessionUpdated(id, updates);
@@ -390,7 +407,15 @@ export function registerSessionApi(fastify: FastifyInstance, deps: SessionApiDep
390
407
  reply.code(404);
391
408
  return result.error;
392
409
  }
393
- const updates = { attachedProposal: null, openspecPhase: null, openspecChange: null };
410
+ const session = result.session;
411
+ const updates: Record<string, unknown> = {
412
+ attachedProposal: null, openspecPhase: null, openspecChange: null,
413
+ };
414
+ // Idempotent auto-revert (see change: fix-mobile-attach-proposal-display).
415
+ if (detachShouldClearName(session)) {
416
+ updates.name = undefined;
417
+ piGateway.sendToSession(id, { type: "rename_session", sessionId: id, name: "" });
418
+ }
394
419
  sessionManager.update(id, updates);
395
420
  browserGateway.broadcastSessionUpdated(id, updates);
396
421
  return { success: true } satisfies ApiResponse;
@@ -11,6 +11,16 @@ export interface SessionOrderManager {
11
11
  reorder(cwd: string, sessionIds: string[]): void;
12
12
  /** Remove a session from its cwd order. */
13
13
  remove(cwd: string, sessionId: string): void;
14
+ /**
15
+ * Move a session id to the front (index 0) of its cwd order. Idempotent:
16
+ * if the id is already at the front, the order is unchanged but a persist
17
+ * still fires (callers gate broadcasts on actual mutation).
18
+ * If the id is absent, it is inserted at the front.
19
+ * Used by the user-intent resume path to surface the just-resumed session
20
+ * at the top of the alive tier even on repeated end → resume cycles.
21
+ * See change: top-of-tier-on-status-change.
22
+ */
23
+ moveToFront(cwd: string, sessionId: string): void;
14
24
  /** Get order for a cwd, optionally filtering to only valid IDs. */
15
25
  getOrder(cwd: string, validIds?: Set<string>): string[];
16
26
  /** Get all cwd→order entries. */
@@ -59,6 +69,18 @@ export function createSessionOrderManager(preferencesStore: PreferencesStore): S
59
69
  persist();
60
70
  },
61
71
 
72
+ moveToFront(cwd: string, sessionId: string): void {
73
+ // remove + unshift = move-to-front. Works whether the id was
74
+ // absent, mid-list, or already at index 0.
75
+ // See change: top-of-tier-on-status-change.
76
+ if (!orderMap[cwd]) {
77
+ orderMap[cwd] = [];
78
+ }
79
+ orderMap[cwd] = orderMap[cwd].filter((id) => id !== sessionId);
80
+ orderMap[cwd].unshift(sessionId);
81
+ persist();
82
+ },
83
+
62
84
  getOrder(cwd: string, validIds?: Set<string>): string[] {
63
85
  const arr = orderMap[cwd];
64
86
  if (!arr) return [];
@@ -138,6 +138,15 @@ export function scanAllSessions(sessionsDir?: string): ScanResult {
138
138
  // Stale cache — re-extract stats and merge
139
139
  const stats = extractSessionStats(sessionFile);
140
140
  if (stats) {
141
+ // Pi's JSONL has no turn_end/contextUsage events, so stats.contextWindow
142
+ // is always inferContextWindow(model) — a hardcoded heuristic that pins
143
+ // any Claude model to 200k and ignores 1M Sonnet variants. The persisted
144
+ // meta.contextWindow came from a real live `turn_end` event, so it's
145
+ // authoritative; only fall back to the inferred value when the model
146
+ // changed (persisted value no longer applies) or none was persisted.
147
+ const effectiveModel = stats.model ?? meta.model;
148
+ const preserveContextWindow =
149
+ meta.contextWindow !== undefined && effectiveModel === meta.model;
141
150
  const updated: SessionMeta = {
142
151
  ...meta,
143
152
  model: stats.model ?? meta.model,
@@ -148,7 +157,7 @@ export function scanAllSessions(sessionsDir?: string): ScanResult {
148
157
  cacheWrite: stats.cacheWrite,
149
158
  cost: stats.cost,
150
159
  contextTokens: stats.lastTotalTokens,
151
- contextWindow: stats.contextWindow,
160
+ contextWindow: preserveContextWindow ? meta.contextWindow : stats.contextWindow,
152
161
  cachedAt: Date.now(),
153
162
  };
154
163
  writeSessionMeta(sessionFile, updated);
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/BlackBeltTechnology/pi-agent-dashboard",
9
+ "directory": "packages/shared"
10
+ },
6
11
  "publishConfig": {
7
12
  "access": "public"
8
13
  },
9
14
  "exports": {
10
15
  "./*.js": "./src/*.ts",
11
- "./*": "./src/*"
16
+ "./*": "./src/*",
17
+ "./dashboard-plugin/*.js": "./src/dashboard-plugin/*.ts",
18
+ "./dashboard-plugin/*": "./src/dashboard-plugin/*"
12
19
  },
13
20
  "files": [
14
21
  "src/"