@blackbelt-technology/pi-agent-dashboard 0.4.0 → 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 (129) hide show
  1. package/AGENTS.md +104 -35
  2. package/README.md +390 -494
  3. package/docs/architecture.md +423 -20
  4. package/package.json +11 -8
  5. package/packages/extension/package.json +11 -4
  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 +91 -15
  8. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  14. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  15. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  16. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  17. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  18. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  19. package/packages/extension/src/ask-user-tool.ts +170 -61
  20. package/packages/extension/src/bridge.ts +199 -19
  21. package/packages/extension/src/multiselect-decode.ts +40 -0
  22. package/packages/extension/src/multiselect-list.ts +146 -0
  23. package/packages/extension/src/multiselect-polyfill.ts +73 -0
  24. package/packages/extension/src/server-launcher.ts +15 -3
  25. package/packages/extension/src/ui-modules.ts +272 -0
  26. package/packages/server/package.json +11 -5
  27. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  28. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  29. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  30. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  31. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  32. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  33. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  34. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  35. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  36. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  37. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  38. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  39. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  40. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  41. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  43. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  44. package/packages/server/src/__tests__/pi-version-skew.test.ts +72 -0
  45. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  46. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  47. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +34 -6
  49. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  50. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  51. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  52. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  53. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  54. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  55. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  56. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  57. package/packages/server/src/browse.ts +118 -13
  58. package/packages/server/src/browser-gateway.ts +19 -0
  59. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  60. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  61. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  62. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  63. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  64. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  65. package/packages/server/src/cli.ts +61 -15
  66. package/packages/server/src/directory-service.ts +156 -15
  67. package/packages/server/src/event-wiring.ts +111 -10
  68. package/packages/server/src/installed-package-enricher.ts +143 -0
  69. package/packages/server/src/package-manager-wrapper.ts +305 -8
  70. package/packages/server/src/package-source-helpers.ts +104 -0
  71. package/packages/server/src/pending-attach-registry.ts +112 -0
  72. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  73. package/packages/server/src/pi-core-checker.ts +9 -14
  74. package/packages/server/src/pi-gateway.ts +14 -0
  75. package/packages/server/src/pi-version-skew.ts +12 -1
  76. package/packages/server/src/proposal-attach-naming.ts +47 -0
  77. package/packages/server/src/restart-helper.ts +13 -2
  78. package/packages/server/src/routes/file-routes.ts +29 -3
  79. package/packages/server/src/routes/package-routes.ts +72 -3
  80. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  81. package/packages/server/src/routes/system-routes.ts +2 -0
  82. package/packages/server/src/server.ts +339 -10
  83. package/packages/server/src/session-api.ts +30 -5
  84. package/packages/server/src/session-order-manager.ts +22 -0
  85. package/packages/server/src/session-scanner.ts +10 -1
  86. package/packages/shared/package.json +9 -2
  87. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  88. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  89. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  90. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  91. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  93. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  94. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  95. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  96. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  97. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  98. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  99. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  100. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  101. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  102. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  103. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  104. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  105. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  106. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  107. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  108. package/packages/shared/src/browser-protocol.ts +110 -4
  109. package/packages/shared/src/config.ts +45 -0
  110. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  111. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  112. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  113. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  114. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  115. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  116. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  117. package/packages/shared/src/openspec-poller.ts +117 -3
  118. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  119. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  120. package/packages/shared/src/platform/index.ts +1 -0
  121. package/packages/shared/src/platform/node-spawn.ts +154 -0
  122. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  123. package/packages/shared/src/protocol.ts +79 -2
  124. package/packages/shared/src/recommended-extensions.ts +7 -1
  125. package/packages/shared/src/rest-api.ts +68 -3
  126. package/packages/shared/src/state-replay.ts +20 -1
  127. package/packages/shared/src/tool-registry/definitions.ts +92 -0
  128. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  129. package/packages/shared/src/types.ts +160 -0
@@ -168,7 +168,7 @@ export async function handleSendPrompt(
168
168
  msg: Extract<BrowserToServerMessage, { type: "send_prompt" }>,
169
169
  ctx: BrowserHandlerContext,
170
170
  ): Promise<void> {
171
- const { sessionManager, piGateway, headlessPidRegistry, pendingResumeRegistry, pendingDashboardSpawns, broadcast } = ctx;
171
+ const { sessionManager, piGateway, headlessPidRegistry, pendingResumeRegistry, pendingResumeIntents, pendingDashboardSpawns, broadcast } = ctx;
172
172
 
173
173
  // Intercept `/reload` on active headless sessions — forward the request to
174
174
  // our kill-and-respawn handler instead of routing the prompt to the bridge
@@ -194,6 +194,11 @@ export async function handleSendPrompt(
194
194
  sessionFile: promptSession.sessionFile,
195
195
  });
196
196
  if (alreadyResuming) return;
197
+ // Tag the resume intent as "front" so the upcoming ended→alive
198
+ // transition surfaces this card at the top of the alive tier. The
199
+ // user is actively typing into this session; surfacing it matches
200
+ // their mental model. See change: differentiate-resume-intent-by-trigger.
201
+ pendingResumeIntents?.record(msg.sessionId, "front");
197
202
  sessionManager.update(msg.sessionId, { resuming: true });
198
203
  broadcast({ type: "session_updated", sessionId: msg.sessionId, updates: { resuming: true } });
199
204
  const autoResumeConfig = loadConfig();
@@ -231,12 +236,17 @@ export async function handleResumeSession(
231
236
  msg: Extract<BrowserToServerMessage, { type: "resume_session" }>,
232
237
  ctx: BrowserHandlerContext,
233
238
  ): Promise<void> {
234
- const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, sendTo } = ctx;
239
+ const { ws, sessionManager, pendingForkRegistry, headlessPidRegistry, pendingDashboardSpawns, pendingResumeIntents, sendTo } = ctx;
235
240
  const session = sessionManager.get(msg.sessionId);
236
241
  if (!session) {
237
242
  sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session not found" });
238
243
  return;
239
244
  }
245
+ // Resolve placement intent. Old browsers omit the field; default to
246
+ // "front" so they keep getting today's behavior. Drag-to-resume sends
247
+ // "keep" so the dropped slot is preserved through the resume round-trip.
248
+ // See change: differentiate-resume-intent-by-trigger.
249
+ const placement: "front" | "keep" = msg.placement ?? "front";
240
250
  if (!session.sessionFile) {
241
251
  sendTo(ws, { type: "resume_result", sessionId: msg.sessionId, success: false, message: "Session file is unknown (pre-migration session)" });
242
252
  return;
@@ -264,6 +274,15 @@ export async function handleResumeSession(
264
274
  }
265
275
  }
266
276
 
277
+ // Tag the user-resume intent BEFORE spawning so the `onChange`
278
+ // ended→alive branch in `server.ts` can distinguish a user-initiated
279
+ // resume from a bridge auto-reattach on dashboard reboot, and choose
280
+ // placement (front vs. keep) appropriately. The fork path also tags
281
+ // but the tag is harmless: forks create new session ids that never
282
+ // appear in the ended→alive branch.
283
+ // See changes: preserve-session-order-on-reboot,
284
+ // differentiate-resume-intent-by-trigger.
285
+ pendingResumeIntents?.record(msg.sessionId, placement);
267
286
  const resumeConfig = loadConfig();
268
287
  const result = await spawnPiSession(session.cwd, {
269
288
  sessionFile: forkSessionFile,
@@ -283,10 +302,17 @@ export async function handleSpawnSession(
283
302
  msg: Extract<BrowserToServerMessage, { type: "spawn_session" }>,
284
303
  ctx: BrowserHandlerContext,
285
304
  ): Promise<void> {
286
- const { ws, headlessPidRegistry, pendingDashboardSpawns, sendTo } = ctx;
305
+ const { ws, headlessPidRegistry, pendingDashboardSpawns, pendingAttachRegistry, sendTo } = ctx;
287
306
  const config = loadConfig();
288
307
  const strategy = config.spawnStrategy ?? "tmux";
289
308
 
309
+ // Queue the optional attach intent BEFORE awaiting the spawn so a fast
310
+ // bridge `session_register` cannot lose the intent. See change:
311
+ // add-folder-task-checker-and-spawn-attach.
312
+ if (typeof msg.attachProposal === "string" && msg.attachProposal.length > 0) {
313
+ pendingAttachRegistry?.enqueue(msg.cwd, msg.attachProposal);
314
+ }
315
+
290
316
  // Catch both thrown exceptions and { success: false } results; surface as
291
317
  // spawn_error so the UI can render a retryable banner instead of failing
292
318
  // silently. Previous behaviour left the user staring at an empty state
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { BrowserToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
5
5
  import type { BrowserHandlerContext } from "./handler-context.js";
6
+ import { attachRenameTarget, detachShouldClearName } from "../proposal-attach-naming.js";
6
7
 
7
8
  export function handleRenameSession(
8
9
  msg: Extract<BrowserToServerMessage, { type: "rename_session" }>,
@@ -33,28 +34,61 @@ export function handleUnhideSession(
33
34
  ctx.broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
34
35
  }
35
36
 
37
+ /**
38
+ * Shared attach-proposal apply logic. Used by both:
39
+ * - the browser-initiated `handleAttachProposal` flow, and
40
+ * - the spawn-with-attach pop-on-register flow in `pi-gateway.ts`
41
+ * (see change: add-folder-task-checker-and-spawn-attach).
42
+ *
43
+ * Idempotent: calling twice with the same `changeName` is safe — the auto-rename
44
+ * is gated by `attachRenameTarget` which short-circuits when the witness equality
45
+ * already holds (see ./proposal-attach-naming.ts).
46
+ */
47
+ export function applyAttachProposal(
48
+ sessionId: string,
49
+ changeName: string,
50
+ ctx: Pick<BrowserHandlerContext, "sessionManager" | "piGateway" | "broadcast">,
51
+ ): void {
52
+ const { sessionManager, piGateway, broadcast } = ctx;
53
+ const session = sessionManager.get(sessionId);
54
+ const updates: Record<string, unknown> = { attachedProposal: changeName };
55
+
56
+ const newName = attachRenameTarget(session, changeName);
57
+ if (newName !== undefined) {
58
+ updates.name = newName;
59
+ piGateway.sendToSession(sessionId, { type: "rename_session", sessionId, name: newName });
60
+ }
61
+ sessionManager.update(sessionId, updates);
62
+ broadcast({ type: "session_updated", sessionId, updates });
63
+ }
64
+
36
65
  export function handleAttachProposal(
37
66
  msg: Extract<BrowserToServerMessage, { type: "attach_proposal" }>,
38
67
  ctx: BrowserHandlerContext,
39
68
  ): void {
40
- const { sessionManager, piGateway, broadcast } = ctx;
41
- const updates: Record<string, unknown> = { attachedProposal: msg.changeName };
42
- const session = sessionManager.get(msg.sessionId);
43
- if (session && !session.name?.trim()) {
44
- updates.name = msg.changeName;
45
- piGateway.sendToSession(msg.sessionId, { type: "rename_session", sessionId: msg.sessionId, name: msg.changeName });
46
- }
47
- sessionManager.update(msg.sessionId, updates);
48
- broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
69
+ applyAttachProposal(msg.sessionId, msg.changeName, ctx);
49
70
  }
50
71
 
51
72
  export function handleDetachProposal(
52
73
  msg: Extract<BrowserToServerMessage, { type: "detach_proposal" }>,
53
74
  ctx: BrowserHandlerContext,
54
75
  ): void {
55
- const updates = { attachedProposal: null, openspecPhase: null, openspecChange: null };
56
- ctx.sessionManager.update(msg.sessionId, updates);
57
- ctx.broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
76
+ const { sessionManager, piGateway, broadcast } = ctx;
77
+ const session = sessionManager.get(msg.sessionId);
78
+
79
+ // Idempotent auto-revert (see change: fix-mobile-attach-proposal-display).
80
+ // See design.md decision matrix and ./proposal-attach-naming.ts.
81
+ const updates: Record<string, unknown> = {
82
+ attachedProposal: null,
83
+ openspecPhase: null,
84
+ openspecChange: null,
85
+ };
86
+ if (detachShouldClearName(session)) {
87
+ updates.name = undefined;
88
+ piGateway.sendToSession(msg.sessionId, { type: "rename_session", sessionId: msg.sessionId, name: "" });
89
+ }
90
+ sessionManager.update(msg.sessionId, updates);
91
+ broadcast({ type: "session_updated", sessionId: msg.sessionId, updates });
58
92
  }
59
93
 
60
94
  export function handleFetchContent(
@@ -55,6 +55,47 @@ async function sendEventBatches(
55
55
  return stored.length > 0 ? stored[stored.length - 1].seq : 0;
56
56
  }
57
57
 
58
+ /**
59
+ * Replay extension-declared UI state to a single browser. Sends:
60
+ *
61
+ * 1. one `ui_modules_list` (when modules exist) — Phase 1
62
+ * 2. one `ui_data_list` per cached `(event, items)` entry — Phase 1
63
+ * 3. one `ext_ui_decorator` per cached `Session.uiDecorators` entry — Phase 2
64
+ *
65
+ * Replay decorator messages NEVER carry `removed: true` — only live entries
66
+ * are replayed; deleted entries are already absent from the cache.
67
+ *
68
+ * Called immediately after every `replayPendingUiRequests` site so the full
69
+ * replay ordering is:
70
+ *
71
+ * events → pending UI requests → ui_modules_list → ui_data_list → ext_ui_decorator
72
+ *
73
+ * Exported so unit tests can drive it without standing up a full subscribe
74
+ * pipeline. See changes: add-extension-ui-modal, add-extension-ui-decorations.
75
+ */
76
+ export function replayUiState(
77
+ ws: WebSocket,
78
+ sessionId: string,
79
+ ctx: Pick<BrowserHandlerContext, "sessionManager" | "sendTo">,
80
+ ): void {
81
+ const { sessionManager, sendTo } = ctx;
82
+ const session = sessionManager.get(sessionId);
83
+ if (!session) return;
84
+ if (session.uiModules && session.uiModules.length > 0) {
85
+ sendTo(ws, { type: "ui_modules_list", sessionId, modules: session.uiModules } as any);
86
+ }
87
+ if (session.uiDataMap) {
88
+ for (const [event, items] of Object.entries(session.uiDataMap)) {
89
+ sendTo(ws, { type: "ui_data_list", sessionId, event, items } as any);
90
+ }
91
+ }
92
+ if (session.uiDecorators) {
93
+ for (const descriptor of Object.values(session.uiDecorators)) {
94
+ sendTo(ws, { type: "ext_ui_decorator", sessionId, descriptor } as any);
95
+ }
96
+ }
97
+ }
98
+
58
99
  export function handleSubscribe(
59
100
  msg: Extract<BrowserToServerMessage, { type: "subscribe" }>,
60
101
  subs: Set<string>,
@@ -85,6 +126,7 @@ export function handleSubscribe(
85
126
  sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
86
127
  clearReplaying(ws, msg.sessionId, lastSent);
87
128
  replayPendingUiRequests(ws, msg.sessionId);
129
+ replayUiState(ws, msg.sessionId, ctx);
88
130
  });
89
131
  } else {
90
132
  let events = eventStore.getEvents(msg.sessionId, lastSeq + 1);
@@ -97,10 +139,12 @@ export function handleSubscribe(
97
139
  sendEventBatches(ws, msg.sessionId, events, sendTo).then((lastSent) => {
98
140
  clearReplaying(ws, msg.sessionId, lastSent);
99
141
  replayPendingUiRequests(ws, msg.sessionId);
142
+ replayUiState(ws, msg.sessionId, ctx);
100
143
  });
101
144
  } else {
102
145
  sendEventBatches(ws, msg.sessionId, events, sendTo).then(() => {
103
146
  replayPendingUiRequests(ws, msg.sessionId);
147
+ replayUiState(ws, msg.sessionId, ctx);
104
148
  });
105
149
  }
106
150
  }
@@ -113,7 +157,7 @@ export function handleSubscribe(
113
157
  events: [],
114
158
  isLast: false,
115
159
  });
116
- directoryService.loadSessionEvents(msg.sessionId, session.sessionFile).then(async (result) => {
160
+ directoryService.loadSessionEvents(msg.sessionId, session.sessionFile, session.contextWindow).then(async (result) => {
117
161
  if (result.success) {
118
162
  for (const evt of result.events) {
119
163
  eventStore.insertEvent(msg.sessionId, evt);
@@ -130,6 +174,7 @@ export function handleSubscribe(
130
174
  for (const sub of subscribers) {
131
175
  await sendEventBatches(sub, msg.sessionId, stored, sendTo);
132
176
  replayPendingUiRequests(sub, msg.sessionId);
177
+ replayUiState(sub, msg.sessionId, ctx);
133
178
  }
134
179
  } else {
135
180
  sendTo(ws, { type: "event_replay", sessionId: msg.sessionId, events: [], isLast: true });
@@ -18,6 +18,7 @@
18
18
  import { createServer, type ServerConfig } from "./server.js";
19
19
  import { loadConfig, ensureConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
20
20
  import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
21
+ import { spawnNodeScript } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
21
22
  import { createRequire } from "node:module";
22
23
  import { fileURLToPath, pathToFileURL } from "node:url";
23
24
  import fs from "node:fs";
@@ -51,6 +52,37 @@ import {
51
52
  } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
52
53
  import type { DashboardServer } from "./server.js";
53
54
  import { updateBootstrapCompatibility } from "./pi-version-skew.js";
55
+ import type { BootstrapStateStore } from "./bootstrap-state.js";
56
+
57
+ /**
58
+ * Emit a stderr warning at CLI startup when the resolved pi version is
59
+ * below `piCompatibility.minimum` (blocking) or below `.recommended`
60
+ * (advisory). Reads from the already-populated `bootstrapState` so no
61
+ * additional I/O happens here. See change: warn-pi-version-skew-in-cli.
62
+ */
63
+ function logCompatibilityWarning(store: BootstrapStateStore): void {
64
+ const s = store.get();
65
+ const c = s.compatibility;
66
+ if (!c || !c.current) return;
67
+ // Below minimum: `updateBootstrapCompatibility` sets `error.message`.
68
+ // We treat the presence of a blocking error + upgradeRecommended as the
69
+ // below-minimum signal; `upgradeRecommended` alone means below-recommended.
70
+ if (s.error?.message && c.upgradeRecommended) {
71
+ console.error(
72
+ `[bootstrap] ⚠ pi ${c.current} is below the required minimum ${c.minimum}.`,
73
+ );
74
+ console.error(
75
+ `[bootstrap] All pi-dependent features (sessions, resources, openspec) will return 503.`,
76
+ );
77
+ console.error(`[bootstrap] Run: pi-dashboard upgrade-pi`);
78
+ return;
79
+ }
80
+ if (c.upgradeRecommended) {
81
+ console.warn(
82
+ `[bootstrap] pi ${c.current} is below the recommended ${c.recommended} — consider running \`pi-dashboard upgrade-pi\``,
83
+ );
84
+ }
85
+ }
54
86
 
55
87
  const SUBCOMMANDS = ["start", "stop", "restart", "status", "upgrade-pi"] as const;
56
88
  type Subcommand = (typeof SUBCOMMANDS)[number];
@@ -191,6 +223,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
191
223
  "package.json",
192
224
  );
193
225
  updateBootstrapCompatibility(server.bootstrapState, serverPkg);
226
+ logCompatibilityWarning(server.bootstrapState);
194
227
  } catch (err) {
195
228
  console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
196
229
  }
@@ -226,12 +259,11 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
226
259
  return;
227
260
  }
228
261
 
229
- // Rescan registry so pi is re-resolved after the fresh install.
230
- // If `rescan` is not exposed, the resolver's strategy chain re-runs
231
- // on the next `resolve()` call anyway; we just want fresh timing.
232
- type Rescannable = { rescan?: (name: string) => void };
233
- const maybeRescan = (registry as unknown as Rescannable).rescan;
234
- if (typeof maybeRescan === "function") maybeRescan.call(registry, "pi");
262
+ // Post-install registry rescan + openspec/pi-resources force-refresh
263
+ // are now centralized in server.ts's bootstrapState.subscribe hook,
264
+ // which fires on every installing ready transition (this caller +
265
+ // triggerUpgradePi + triggerRetry).
266
+ // See change: fix-openspec-buttons-after-bootstrap-install.
235
267
 
236
268
  // Attempt bridge registration. Failures are non-fatal per spec §10.3.
237
269
  let bridgeErr: string | undefined;
@@ -260,6 +292,7 @@ async function runDegradedModeBootstrap(server: DashboardServer): Promise<void>
260
292
  "package.json",
261
293
  );
262
294
  updateBootstrapCompatibility(server.bootstrapState, serverPkg);
295
+ logCompatibilityWarning(server.bootstrapState);
263
296
  } catch (err) {
264
297
  console.warn("[bootstrap] version-skew check failed (non-fatal):", err);
265
298
  }
@@ -339,21 +372,31 @@ async function cmdStart(config: ServerConfig): Promise<void> {
339
372
  `\n[${new Date().toISOString()}] pi-dashboard start (parent pid ${process.pid}, port ${config.port})\n`,
340
373
  );
341
374
 
342
- // tsLoader is a file:// URL (required on Windows for node --import).
343
- // See change: fix-windows-server-parity.
344
- const child = spawn(process.execPath, ["--import", tsLoader, cliPath, ...args], {
345
- detached: true,
346
- stdio: ["ignore", logFd, logFd],
347
- env: { ...process.env },
375
+ // Both tsLoader and cliPath are wrapped as file:// URLs by spawnNodeScript.
376
+ // Required on Windows for node --import (see change: fix-windows-entry-script-url).
377
+ const child = spawnNodeScript({
378
+ loader: tsLoader,
379
+ entry: cliPath,
380
+ args,
381
+ spawnOptions: {
382
+ detached: true,
383
+ stdio: ["ignore", logFd, logFd],
384
+ env: { ...process.env },
385
+ },
348
386
  });
349
387
  child.unref();
350
388
  // Close the parent's copy of the fd — child has its own via stdio inheritance.
351
389
  try { fs.closeSync(logFd); } catch { /* ignore */ }
352
390
 
353
- // Wait for dashboard to become available (up to 5 seconds)
354
- const deadline = Date.now() + 5000;
391
+ // Wait for dashboard to become available. Windows + jiti cold-start can
392
+ // take 10s+ (TS compile on first boot, native module loads). 30s is the
393
+ // outer bound — if the server isn't up by then, something's genuinely wrong.
394
+ const READINESS_TIMEOUT_MS = 30_000;
395
+ const deadline = Date.now() + READINESS_TIMEOUT_MS;
355
396
  let started = false;
356
397
  while (Date.now() < deadline) {
398
+ // Also bail if the child has already exited (fast-path crash detection).
399
+ if (child.exitCode !== null) break;
357
400
  await new Promise((r) => setTimeout(r, 300));
358
401
  const status = await isDashboardRunning(config.port);
359
402
  if (status.running) {
@@ -366,7 +409,10 @@ async function cmdStart(config: ServerConfig): Promise<void> {
366
409
  const pid = readPid();
367
410
  console.log(`Dashboard server started (pid ${pid ?? child.pid}) at http://localhost:${config.port}`);
368
411
  } else {
369
- console.error("Failed to start dashboard server (timed out after 5s)");
412
+ const reason = child.exitCode !== null
413
+ ? `child process exited with code ${child.exitCode}`
414
+ : `timed out after ${READINESS_TIMEOUT_MS / 1000}s`;
415
+ console.error(`Failed to start dashboard server (${reason})`);
370
416
  console.error(`Check logs at ${path.join(logDir, "server.log")}`);
371
417
  process.exit(1);
372
418
  }
@@ -18,6 +18,8 @@ import {
18
18
  pollOpenSpecAsync,
19
19
  runOpenSpecList,
20
20
  runOpenSpecStatus,
21
+ createFsProbeFactory,
22
+ createFsSpecsProbeFactory,
21
23
  } from "@blackbelt-technology/pi-dashboard-shared/openspec-poller.js";
22
24
  import { DEFAULT_OPENSPEC_POLL, type OpenSpecPollConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
23
25
  import { createSemaphore, type Semaphore } from "@blackbelt-technology/pi-dashboard-shared/semaphore.js";
@@ -46,7 +48,7 @@ export interface DirectoryAddedResult {
46
48
  export interface DirectoryService {
47
49
  knownDirectories(): string[];
48
50
  discoverSessions(cwd: string): DiscoveredSession[];
49
- loadSessionEvents(sessionId: string, sessionFile: string): Promise<LoadResult>;
51
+ loadSessionEvents(sessionId: string, sessionFile: string, knownContextWindow?: number): Promise<LoadResult>;
50
52
  getOpenSpecData(cwd: string): OpenSpecData | undefined;
51
53
  /** Force refresh: bypasses the mtime gate. Still honors the semaphore. */
52
54
  refreshOpenSpec(cwd: string): Promise<OpenSpecData>;
@@ -86,6 +88,66 @@ function statMtimeOr(p: string): number | undefined {
86
88
  }
87
89
  }
88
90
 
91
+ /**
92
+ * Maximum mtime across a fixed list of paths. Missing paths (ENOENT) are
93
+ * skipped — they don't poison the result. Returns `undefined` only when
94
+ * every input is missing.
95
+ *
96
+ * Used by the change-detection gate to catch in-place file edits that
97
+ * don't bump any parent directory's mtime on POSIX. See change:
98
+ * fix-openspec-mtime-gate-blind-spots.
99
+ */
100
+ export function effectiveMtimeOr(paths: string[]): number | undefined {
101
+ let max: number | undefined;
102
+ for (const p of paths) {
103
+ const m = statMtimeOr(p);
104
+ if (m === undefined) continue;
105
+ if (max === undefined || m > max) max = m;
106
+ }
107
+ return max;
108
+ }
109
+
110
+ /**
111
+ * File set tracked by the per-change effective-mtime computation.
112
+ *
113
+ * The base set covers the change directory itself plus the three top-level
114
+ * artifact files. The `specs/` fan-out catches multi-spec authoring:
115
+ *
116
+ * - `<change>/specs/` — advances on capability dir create/remove
117
+ * - `<change>/specs/<cap>/` — advances when `spec.md` is created inside
118
+ * - `<change>/specs/<cap>/spec.md` — advances on in-place edits
119
+ *
120
+ * `readdirSync` is wrapped in try/catch so missing `specs/` (or any fs error)
121
+ * yields an empty fan-out rather than throwing.
122
+ *
123
+ * See change: fix-openspec-specs-mtime-gate-blind-spot.
124
+ */
125
+ function perChangeArtifactPaths(changesRoot: string, name: string): string[] {
126
+ const dir = path.join(changesRoot, name);
127
+ const base = [
128
+ dir,
129
+ path.join(dir, "tasks.md"),
130
+ path.join(dir, "proposal.md"),
131
+ path.join(dir, "design.md"),
132
+ ];
133
+ const specsDir = path.join(dir, "specs");
134
+ const specsExtras: string[] = [specsDir];
135
+ try {
136
+ const entries = fs.readdirSync(specsDir, { withFileTypes: true });
137
+ for (const e of entries) {
138
+ if (e.isDirectory()) {
139
+ const capDir = path.join(specsDir, e.name);
140
+ specsExtras.push(capDir);
141
+ specsExtras.push(path.join(capDir, "spec.md"));
142
+ }
143
+ }
144
+ } catch {
145
+ // ENOENT, permission denied, etc. — leave specsExtras with just specsDir
146
+ // (its own statMtimeOr will return undefined and be excluded from max).
147
+ }
148
+ return [...base, ...specsExtras];
149
+ }
150
+
89
151
  // ── Per-directory cache ────────────────────────────────────────────
90
152
  type PerChangeEntry = {
91
153
  mtimeMs: number | undefined;
@@ -137,7 +199,7 @@ export function createDirectoryService(
137
199
  return discoverSessionsForCwd(cwd);
138
200
  }
139
201
 
140
- async function loadSessionEvents(sessionId: string, sessionFile: string): Promise<LoadResult> {
202
+ async function loadSessionEvents(sessionId: string, sessionFile: string, knownContextWindow?: number): Promise<LoadResult> {
141
203
  if (loadingSet.has(sessionId)) {
142
204
  return { success: false, events: [], error: "already_loading" };
143
205
  }
@@ -145,7 +207,9 @@ export function createDirectoryService(
145
207
  try {
146
208
  const { loadSessionEntries } = await import("./session-file-reader.js");
147
209
  const entries = loadSessionEntries(sessionFile);
148
- const eventMessages = replayEntriesAsEvents(sessionId, entries);
210
+ // Pass persisted contextWindow so replay's stats_update events use the
211
+ // real value instead of inferContextWindow(modelId)'s 200k Claude default.
212
+ const eventMessages = replayEntriesAsEvents(sessionId, entries, knownContextWindow);
149
213
  const events = eventMessages.map((m) => m.event);
150
214
  return { success: true, events };
151
215
  } catch (err: any) {
@@ -181,8 +245,21 @@ export function createDirectoryService(
181
245
  }
182
246
 
183
247
  // ── Step 1: list (gated) ──
248
+ //
249
+ // The list-step gate signal must catch in-place edits to <change>/tasks.md
250
+ // because `completedTasks` / `totalTasks` are derived from those files. POSIX
251
+ // dir-mtime alone misses these edits (it only advances on entry create/
252
+ // delete/rename), so we union the parent-dir mtime with each known
253
+ // tasks.md file's mtime. See change: fix-openspec-mtime-gate-blind-spots.
184
254
  let listResult: typeof cache.listResult = cache.listResult;
185
- const listCacheValid = gateEnabled && cache.listMtimeMs === rootMtime && cache.listResult !== undefined;
255
+ let listSignal: number | undefined = rootMtime;
256
+ if (cache.listResult !== undefined) {
257
+ const taskFiles = cache.listResult.map((c) =>
258
+ path.join(changesRoot, c.name, "tasks.md"),
259
+ );
260
+ listSignal = effectiveMtimeOr([changesRoot, ...taskFiles]) ?? rootMtime;
261
+ }
262
+ const listCacheValid = gateEnabled && cache.listMtimeMs === listSignal && cache.listResult !== undefined;
186
263
  if (!listCacheValid) {
187
264
  const raw = await semaphore.run(() => runOpenSpecList(cwd));
188
265
  if (!raw || !Array.isArray(raw.changes)) {
@@ -195,7 +272,12 @@ export function createDirectoryService(
195
272
  return empty;
196
273
  }
197
274
  listResult = raw.changes;
198
- cache.listMtimeMs = rootMtime;
275
+ // Recompute the signal against the freshly returned change set so the
276
+ // cache stamps the same shape we'll compare against on the next tick.
277
+ const taskFiles = (listResult ?? []).map((c) =>
278
+ path.join(changesRoot, c.name, "tasks.md"),
279
+ );
280
+ cache.listMtimeMs = effectiveMtimeOr([changesRoot, ...taskFiles]) ?? rootMtime;
199
281
  cache.listResult = listResult;
200
282
  }
201
283
 
@@ -206,14 +288,27 @@ export function createDirectoryService(
206
288
  }
207
289
 
208
290
  // ── Step 2: per-change status (gated) ──
291
+ //
292
+ // TOCTOU note: we capture the file-aware effective mtime BEFORE invoking
293
+ // `openspec status` and stamp THAT value into the cache. If a tracked
294
+ // artifact file is written during the CLI invocation, the post-call mtime
295
+ // will differ from `preCallMtime` and we discard this tick's status for
296
+ // that change — leaving the prior cache entry (if any) untouched so the
297
+ // next gated tick re-polls naturally. See change:
298
+ // fix-openspec-mtime-gate-toctou.
209
299
  const statusResults = new Map<string, { artifacts?: Array<{ id: string; status: string }>; isComplete?: boolean } | null>();
300
+ const preCallMtimes = new Map<string, number | undefined>();
301
+ const racyNames = new Set<string>();
210
302
 
211
303
  await Promise.all((listResult ?? []).map(async (c) => {
212
- const changeDir = path.join(changesRoot, c.name);
213
- const changeMtime = statMtimeOr(changeDir);
304
+ // File-aware effective mtime: catches in-place edits to tasks.md /
305
+ // proposal.md / design.md that POSIX dir-mtime misses. See change:
306
+ // fix-openspec-mtime-gate-blind-spots.
307
+ const preCallMtime = effectiveMtimeOr(perChangeArtifactPaths(changesRoot, c.name));
308
+ preCallMtimes.set(c.name, preCallMtime);
214
309
  const cached = cache.changes.get(c.name);
215
310
 
216
- if (gateEnabled && cached && cached.mtimeMs !== undefined && cached.mtimeMs === changeMtime) {
311
+ if (gateEnabled && cached && cached.mtimeMs !== undefined && cached.mtimeMs === preCallMtime) {
217
312
  // Cache hit. Reuse the artifacts/isComplete from the cached OpenSpecChange.
218
313
  statusResults.set(c.name, {
219
314
  artifacts: cached.change.artifacts.map((a) => ({ id: a.id, status: a.status })),
@@ -223,17 +318,51 @@ export function createDirectoryService(
223
318
  }
224
319
 
225
320
  const status = await semaphore.run(() => runOpenSpecStatus(cwd, c.name));
321
+
322
+ // TOCTOU check. If any tracked artifact path was written between the
323
+ // pre-call stat and now, the CLI's view of disk is stale relative to
324
+ // the mtime we'd stamp — discard. See change: fix-openspec-mtime-gate-toctou.
325
+ const postCallMtime = effectiveMtimeOr(perChangeArtifactPaths(changesRoot, c.name));
326
+ if (preCallMtime !== postCallMtime) {
327
+ if (typeof process !== "undefined" && /pi-dashboard|openspec-poll/.test(process.env?.DEBUG ?? "")) {
328
+ // eslint-disable-next-line no-console
329
+ console.warn(
330
+ `[fix-openspec-mtime-gate-toctou] discarded racy status for ${c.name} (pre=${preCallMtime} post=${postCallMtime})`,
331
+ );
332
+ }
333
+ racyNames.add(c.name);
334
+ if (cached) {
335
+ // Reuse the prior cached status so buildOpenSpecData doesn't render
336
+ // an empty artifact list for this tick. Cache entry is preserved
337
+ // unchanged below by skipping the stamping loop for racy names.
338
+ statusResults.set(c.name, {
339
+ artifacts: cached.change.artifacts.map((a) => ({ id: a.id, status: a.status })),
340
+ ...(cached.change.isComplete !== undefined ? { isComplete: cached.change.isComplete } : {}),
341
+ });
342
+ }
343
+ return;
344
+ }
226
345
  statusResults.set(c.name, status);
227
346
  }));
228
347
 
229
348
  // ── Step 3: build + cache + return ──
230
- const data = buildOpenSpecData({ changes: listResult ?? [] }, statusResults);
231
-
232
- // Update per-change cache with the mtimes we just observed.
349
+ const data = buildOpenSpecData(
350
+ { changes: listResult ?? [] },
351
+ statusResults,
352
+ createFsProbeFactory(cwd),
353
+ createFsSpecsProbeFactory(cwd),
354
+ );
355
+
356
+ // Stamp the cache with the pre-call mtime — i.e. the mtime that
357
+ // demonstrably reflects the file state observed by the CLI. Skip racy
358
+ // names so the next gated tick re-polls. See change:
359
+ // fix-openspec-mtime-gate-toctou.
233
360
  for (const change of data.changes) {
234
- const changeDir = path.join(changesRoot, change.name);
235
- const changeMtime = statMtimeOr(changeDir);
236
- cache.changes.set(change.name, { mtimeMs: changeMtime, change });
361
+ if (racyNames.has(change.name)) continue;
362
+ const stampMtime = preCallMtimes.has(change.name)
363
+ ? preCallMtimes.get(change.name)
364
+ : effectiveMtimeOr(perChangeArtifactPaths(changesRoot, change.name));
365
+ cache.changes.set(change.name, { mtimeMs: stampMtime, change });
237
366
  }
238
367
  cache.data = data;
239
368
  caches.set(cwd, cache);
@@ -242,6 +371,14 @@ export function createDirectoryService(
242
371
 
243
372
  async function refreshOpenSpec(cwd: string): Promise<OpenSpecData> {
244
373
  try {
374
+ // User-initiated refresh bypasses the gate. The gate is heuristic; the
375
+ // CLI is authoritative. When the user clicks the OpenSpec refresh icon
376
+ // they expect fresh data, never silently-cached data — force-mode is
377
+ // the user's escape hatch when the gate's heuristic is wrong, while
378
+ // periodic paths (`pollDirectoryGated`, `onDirectoryAdded`,
379
+ // `handleOpenSpecBulkArchive` post-archive refresh) stay gated.
380
+ // See changes: fix-openspec-mtime-gate-toctou (re-introduced force=true),
381
+ // fix-openspec-mtime-gate-blind-spots (initial removal of force=true).
245
382
  return await pollOne(cwd, true);
246
383
  } catch {
247
384
  // Fall back to the legacy monolithic path so "refresh" never silently fails.
@@ -383,9 +520,13 @@ export function createDirectoryService(
383
520
  },
384
521
 
385
522
  async onDirectoryAdded(cwd: string): Promise<DirectoryAddedResult> {
523
+ // Internal path — use the gated poll, not the user-facing
524
+ // `refreshOpenSpec` (which now bypasses the gate). For a freshly-added
525
+ // directory the cache is empty, so the gate lets the CLI run anyway.
526
+ // See change: fix-openspec-mtime-gate-toctou.
386
527
  const [sessions, openspecData] = await Promise.all([
387
528
  discoverSessions(cwd),
388
- refreshOpenSpec(cwd),
529
+ pollDirectoryGated(cwd),
389
530
  ]);
390
531
  return { sessions, openspecData };
391
532
  },