@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -5,6 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import os from "node:os";
7
7
  import { loadConfig, type DashboardConfig, type AuthConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
8
+ import { refreshModelRegistry } from "./model-proxy/registry-singleton.js";
8
9
 
9
10
  const REDACTED = "***";
10
11
 
@@ -114,9 +115,17 @@ export function writeConfigPartial(partial: Record<string, any>): WriteConfigRes
114
115
  partial.auth = mergedAuth;
115
116
  }
116
117
 
117
- // Merge tunnel sub-object
118
+ // Merge tunnel sub-object (deep-merge nested watchdog)
118
119
  if (partial.tunnel) {
119
- partial.tunnel = { ...existing.tunnel, ...partial.tunnel };
120
+ const existingTunnel = existing.tunnel ?? {};
121
+ const mergedWatchdog = partial.tunnel.watchdog
122
+ ? { ...(existingTunnel.watchdog ?? {}), ...partial.tunnel.watchdog }
123
+ : existingTunnel.watchdog;
124
+ partial.tunnel = {
125
+ ...existingTunnel,
126
+ ...partial.tunnel,
127
+ ...(mergedWatchdog ? { watchdog: mergedWatchdog } : {}),
128
+ };
120
129
  }
121
130
 
122
131
  // Merge memoryLimits sub-object
@@ -139,6 +148,9 @@ export function writeConfigPartial(partial: Record<string, any>): WriteConfigRes
139
148
  fs.mkdirSync(dir, { recursive: true });
140
149
  fs.writeFileSync(file, JSON.stringify(merged, null, 2) + "\n");
141
150
 
151
+ // Eager-refresh model proxy registry (config may affect proxy settings).
152
+ refreshModelRegistry().catch(() => {});
153
+
142
154
  return { success: true, restartRequired };
143
155
  } catch (err: any) {
144
156
  return { success: false, restartRequired: false, error: err.message };
@@ -76,6 +76,23 @@ export function hasOpenSpecDir(cwd: string): boolean {
76
76
  }
77
77
  }
78
78
 
79
+ /**
80
+ * `true` iff `<cwd>/openspec/` exists and is a directory. Strictly weaker than
81
+ * `hasOpenSpecDir` (which also requires the `changes/` subdir). Used by the
82
+ * WS on-connect snapshot to emit the new `hasOpenspecDir` field on every
83
+ * payload so freshly-initialized projects without `changes/` yet still surface
84
+ * the OPENSPEC subcard as an init/attach affordance on the client.
85
+ *
86
+ * See change: auto-hide-empty-session-subcards.
87
+ */
88
+ export function hasOpenSpecRoot(cwd: string): boolean {
89
+ try {
90
+ return fs.statSync(path.join(cwd, "openspec")).isDirectory();
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
79
96
  export interface DirectoryService {
80
97
  knownDirectories(): string[];
81
98
  discoverSessions(cwd: string): DiscoveredSession[];
@@ -199,12 +216,25 @@ function emptyDirCache(): DirCache {
199
216
  return { listMtimeMs: undefined, listResult: undefined, changes: new Map(), data: undefined };
200
217
  }
201
218
 
219
+ export interface DirectoryServiceOptions {
220
+ /**
221
+ * Optional async post-processor applied to `OpenSpecData` after
222
+ * `buildOpenSpecData` and before caching. Used to inject the per-cwd
223
+ * `groupId` join from the OpenSpec change-grouping store.
224
+ * Errors propagate as a logged warning + the unenriched data.
225
+ * See change: add-openspec-change-grouping.
226
+ */
227
+ enrichOpenSpecData?: (cwd: string, data: OpenSpecData) => Promise<OpenSpecData> | OpenSpecData;
228
+ }
229
+
202
230
  export function createDirectoryService(
203
231
  preferencesStore: PreferencesStore,
204
232
  sessionManager: SessionManager,
205
233
  initialConfig?: Partial<OpenSpecPollConfig>,
234
+ options: DirectoryServiceOptions = {},
206
235
  ): DirectoryService {
207
236
  let cfg: OpenSpecPollConfig = { ...DEFAULT_OPENSPEC_POLL, ...(initialConfig ?? {}) };
237
+ const enrichOpenSpecData = options.enrichOpenSpecData;
208
238
 
209
239
  const caches = new Map<string, DirCache>();
210
240
  const piResourcesCache = new Map<string, PiResourcesResult>();
@@ -261,12 +291,22 @@ export function createDirectoryService(
261
291
  const cache = caches.get(cwd) ?? emptyDirCache();
262
292
  const gateEnabled = cfg.changeDetection === "mtime" && !force;
263
293
 
294
+ const openspecRoot = path.join(cwd, "openspec");
264
295
  const changesRoot = path.join(cwd, "openspec", "changes");
296
+ // `hasOpenspecDir` is strictly weaker than `initialized`: it's true when
297
+ // the project is OpenSpec-initialized (`openspec/` dir exists) even if no
298
+ // proposals are authored yet (no `openspec/changes/` subdir). The session
299
+ // card visibility gate uses this signal so a fresh `openspec init` project
300
+ // still shows the OPENSPEC subcard as an init/attach affordance.
301
+ // See change: auto-hide-empty-session-subcards.
302
+ const hasOpenspecDir = statMtimeOr(openspecRoot) !== undefined;
265
303
  const rootMtime = statMtimeOr(changesRoot);
266
304
 
267
- // If the directory doesn't exist, short-circuit (matches old behavior).
305
+ // If the changes/ subdirectory doesn't exist, short-circuit (matches old
306
+ // behavior re: list polling). `hasOpenspecDir` still carries the broader
307
+ // "is this an OpenSpec project?" signal for the client.
268
308
  if (rootMtime === undefined) {
269
- const empty: OpenSpecData = { initialized: false, changes: [] };
309
+ const empty: OpenSpecData = { initialized: false, changes: [], hasOpenspecDir };
270
310
  cache.data = empty;
271
311
  cache.listMtimeMs = undefined;
272
312
  cache.listResult = undefined;
@@ -294,7 +334,7 @@ export function createDirectoryService(
294
334
  if (!listCacheValid) {
295
335
  const raw = await semaphore.run(() => runOpenSpecList(cwd));
296
336
  if (!raw || !Array.isArray(raw.changes)) {
297
- const empty: OpenSpecData = { initialized: false, changes: [] };
337
+ const empty: OpenSpecData = { initialized: false, changes: [], hasOpenspecDir };
298
338
  cache.data = empty;
299
339
  cache.listMtimeMs = rootMtime;
300
340
  cache.listResult = undefined;
@@ -377,12 +417,30 @@ export function createDirectoryService(
377
417
  }));
378
418
 
379
419
  // ── Step 3: build + cache + return ──
380
- const data = buildOpenSpecData(
420
+ let data = buildOpenSpecData(
381
421
  { changes: listResult ?? [] },
382
422
  statusResults,
383
423
  createFsProbeFactory(cwd),
384
424
  createFsSpecsProbeFactory(cwd),
385
425
  );
426
+ // `hasOpenspecDir` is true here by definition: we only reach Step 3 when
427
+ // `<cwd>/openspec/changes/` exists, which implies `<cwd>/openspec/` exists.
428
+ // See change: auto-hide-empty-session-subcards.
429
+ data = { ...data, hasOpenspecDir };
430
+ if (enrichOpenSpecData) {
431
+ try {
432
+ data = await enrichOpenSpecData(cwd, data);
433
+ } catch (err) {
434
+ // Don\'t fail the whole poll if the enricher (e.g. group-store read)
435
+ // throws — log and continue with the unenriched data. See change:
436
+ // add-openspec-change-grouping.
437
+ // eslint-disable-next-line no-console
438
+ if (typeof process !== "undefined" && /pi-dashboard|openspec-poll/.test(process.env?.DEBUG ?? "")) {
439
+ // eslint-disable-next-line no-console
440
+ console.warn(`[directory-service] enrichOpenSpecData(${cwd}) threw:`, err);
441
+ }
442
+ }
443
+ }
386
444
 
387
445
  // Stamp the cache with the pre-call mtime — i.e. the mtime that
388
446
  // demonstrably reflects the file state observed by the CLI. Skip racy
@@ -401,6 +459,20 @@ export function createDirectoryService(
401
459
  }
402
460
 
403
461
  async function refreshOpenSpec(cwd: string): Promise<OpenSpecData> {
462
+ // Master gate: when `openspec.enabled` is false, every refresh path is a
463
+ // no-op. Return the cleared-state shape so callers (including
464
+ // `openspec_refresh` browser handler) converge to the disabled UX.
465
+ // See change: auto-hide-empty-session-subcards.
466
+ if (cfg.enabled === false) {
467
+ // Disabled state: `hasOpenspecDir: false` ensures the client wrapper
468
+ // hides the OPENSPEC subcard for every cwd. See change:
469
+ // auto-hide-empty-session-subcards.
470
+ const cleared: OpenSpecData = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
471
+ const cache = caches.get(cwd) ?? emptyDirCache();
472
+ cache.data = cleared;
473
+ caches.set(cwd, cache);
474
+ return cleared;
475
+ }
404
476
  try {
405
477
  // User-initiated refresh bypasses the gate. The gate is heuristic; the
406
478
  // CLI is authoritative. When the user clicks the OpenSpec refresh icon
@@ -422,6 +494,15 @@ export function createDirectoryService(
422
494
  }
423
495
 
424
496
  async function pollDirectoryGated(cwd: string): Promise<OpenSpecData> {
497
+ // Master gate: when `openspec.enabled` is false, never spawn a CLI for the
498
+ // periodic poll path either. See change: auto-hide-empty-session-subcards.
499
+ if (cfg.enabled === false) {
500
+ const cleared: OpenSpecData = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
501
+ const cache = caches.get(cwd) ?? emptyDirCache();
502
+ cache.data = cleared;
503
+ caches.set(cwd, cache);
504
+ return cleared;
505
+ }
425
506
  return pollOne(cwd, false);
426
507
  }
427
508
 
@@ -439,6 +520,9 @@ export function createDirectoryService(
439
520
  let openspecTickInFlight = false;
440
521
  async function scheduleOpenSpecTick() {
441
522
  if (openspecTickInFlight) return;
523
+ // Master gate: when `openspec.enabled` is false, the tick is a no-op.
524
+ // No CLI spawns. See change: auto-hide-empty-session-subcards.
525
+ if (cfg.enabled === false) return;
442
526
  openspecTickInFlight = true;
443
527
  const tickStart = Date.now();
444
528
  let spawnsBefore = 0;
@@ -542,12 +626,30 @@ export function createDirectoryService(
542
626
 
543
627
  reconfigurePolling(newCfg: OpenSpecPollConfig) {
544
628
  const oldInterval = cfg.pollIntervalSeconds;
629
+ const wasEnabled = cfg.enabled;
545
630
  cfg = { ...newCfg };
546
631
  semaphore.setMax(cfg.maxConcurrentSpawns);
547
632
  // Only re-install timers if they were running and the interval actually changed.
548
633
  if (pollTimer && oldInterval !== cfg.pollIntervalSeconds) {
549
634
  installTimers();
550
635
  }
636
+ // On the `true → false` transition, clear every per-cwd `OpenSpecData`
637
+ // cache and notify the broadcast channel so connected browsers converge
638
+ // to the disabled-state shape. The `false → true` transition is a
639
+ // no-op here — the next regular poll tick will re-populate caches.
640
+ // See change: auto-hide-empty-session-subcards.
641
+ if (wasEnabled !== false && cfg.enabled === false) {
642
+ const cleared: OpenSpecData = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
643
+ for (const [cwd, cache] of caches.entries()) {
644
+ cache.data = cleared;
645
+ caches.set(cwd, cache);
646
+ try {
647
+ onChangeCallback?.(cwd, cleared);
648
+ } catch (err) {
649
+ console.warn(`[openspec-poll] onChange after disable failed for ${cwd}:`, err);
650
+ }
651
+ }
652
+ }
551
653
  },
552
654
 
553
655
  async onDirectoryAdded(cwd: string): Promise<DirectoryAddedResult> {
@@ -19,6 +19,8 @@ import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared
19
19
  import { detectOpenSpecActivity, isValidOpenSpecChangeSlug } from "@blackbelt-technology/pi-dashboard-shared/openspec-activity-detector.js";
20
20
  import { extractTurnStats } from "@blackbelt-technology/pi-dashboard-shared/stats-extractor.js";
21
21
  import { attachRenameTarget, isNameAutoSetFromAttachment } from "./proposal-attach-naming.js";
22
+ import { handleDispatchExtensionCommand } from "./rpc-keeper/dispatch-router.js";
23
+ import { keeperOptsFromSpawnResult } from "./headless-pid-registry.js";
22
24
 
23
25
  export interface EventWiringDeps {
24
26
  sessionManager: SessionManager;
@@ -43,6 +45,14 @@ export interface EventWiringDeps {
43
45
  * See change: session-card-unread-stripes.
44
46
  */
45
47
  viewedSessionTracker?: ViewedSessionTracker;
48
+ /**
49
+ * Optional client-correlation registry. When provided, the wiring
50
+ * consumes the requestId for the resolved spawnToken after a successful
51
+ * three-tier link and surfaces it on `session_added` as `spawnRequestId`,
52
+ * letting the client auto-select / dismiss its placeholder by exact
53
+ * correlation. See change: spawn-correlation-token.
54
+ */
55
+ pendingClientCorrelations?: import("./pending-client-correlations.js").PendingClientCorrelations;
46
56
  }
47
57
 
48
58
  /**
@@ -62,6 +72,7 @@ export function wireEvents(deps: EventWiringDeps): void {
62
72
  pendingDashboardSpawns,
63
73
  pendingAttachRegistry,
64
74
  viewedSessionTracker,
75
+ pendingClientCorrelations,
65
76
  } = deps;
66
77
 
67
78
  // Broadcast placeholder session to browsers when auto-created from early events
@@ -452,7 +463,34 @@ export function wireEvents(deps: EventWiringDeps): void {
452
463
  }
453
464
  }
454
465
 
455
- browserGateway.headlessPidRegistry.linkSession(sessionId, msg.cwd);
466
+ // Three-tier link: token → pid → cwd-FIFO. Each tier is independently
467
+ // correct; `linkByToken` is the strong identity introduced by
468
+ // `spawn-correlation-token`. cwd-FIFO is the legacy fallback for old
469
+ // bridges that send neither token nor pid (and is logged so we can see
470
+ // when it actually triggers).
471
+ let linked = false;
472
+ if (msg.spawnToken) {
473
+ linked = browserGateway.headlessPidRegistry.linkByToken(msg.spawnToken, sessionId, msg.pid);
474
+ }
475
+ if (!linked && msg.pid !== undefined) {
476
+ linked = browserGateway.headlessPidRegistry.linkByPid(sessionId, msg.pid);
477
+ }
478
+ if (!linked) {
479
+ if (msg.spawnToken || msg.pid !== undefined) {
480
+ console.error(
481
+ `[event-wiring] cwd-FIFO fallback for session ${sessionId} — token=${msg.spawnToken ?? ""} pid=${msg.pid ?? ""} cwd=${msg.cwd}`,
482
+ );
483
+ }
484
+ browserGateway.headlessPidRegistry.linkSession(sessionId, msg.cwd);
485
+ }
486
+
487
+ // Resolve the originating browser `requestId` (when known) so the
488
+ // upcoming session_added broadcast can carry spawnRequestId and the
489
+ // client can auto-select / dismiss its placeholder.
490
+ // See change: spawn-correlation-token.
491
+ const spawnRequestId = (msg.spawnToken && pendingClientCorrelations)
492
+ ? pendingClientCorrelations.consume(msg.spawnToken)
493
+ : undefined;
456
494
 
457
495
  const isNewSession = !knownSessionIds.has(sessionId);
458
496
  knownSessionIds.add(sessionId);
@@ -469,7 +507,11 @@ export function wireEvents(deps: EventWiringDeps): void {
469
507
  }
470
508
  }
471
509
 
472
- const forkParent = pendingForkRegistry.consumeFork(msg.cwd);
510
+ // Fork-parent lookup is keyed by spawn token (was: cwd, racy on
511
+ // multi-fork-in-same-cwd). See change: spawn-correlation-token.
512
+ const forkParent = msg.spawnToken
513
+ ? pendingForkRegistry.consumeFork(msg.spawnToken)
514
+ : undefined;
473
515
  sessionOrderManager.insert(msg.cwd, sessionId);
474
516
 
475
517
  if (forkParent) {
@@ -489,7 +531,7 @@ export function wireEvents(deps: EventWiringDeps): void {
489
531
 
490
532
  const updatedSession = sessionManager.get(sessionId);
491
533
  if (updatedSession) {
492
- browserGateway.broadcastSessionAdded(updatedSession);
534
+ browserGateway.broadcastSessionAdded(updatedSession, spawnRequestId ? { spawnRequestId } : undefined);
493
535
  }
494
536
 
495
537
  const isNewCwd = !sessionManager.listAll().some(
@@ -623,9 +665,23 @@ export function wireEvents(deps: EventWiringDeps): void {
623
665
  if (msg.type === "providers_list") {
624
666
  // Cache the bridge-pushed catalogue. Browsers don't subscribe to it
625
667
  // directly; they read via GET /api/provider-auth/status.
626
- // See change: replace-hardcoded-provider-lists.
668
+ // Broadcast `models_refreshed` ONLY when the catalogue contents
669
+ // actually changed. Routine state-syncs (every fork/resume/reconnect/
670
+ // subscribe) re-send identical content; broadcasting unconditionally
671
+ // wipes every browser's modelsMap and — because App.tsx's
672
+ // auto-subscribe effect skips re-requesting models for any session
673
+ // that's already in `subscribedRef`, leaves previously-visited
674
+ // sessions with an empty model selector until reconnect.
675
+ //
676
+ // The catalogue cache is now a pure read consumer for the Settings
677
+ // UI (`GET /api/provider-auth/status`). No broadcast: the model-
678
+ // selector dropdown lives on the independent `models_list` channel
679
+ // which is per-session-broadcast already; per-session updates are
680
+ // self-healing without a global wipe.
681
+ // See changes: replace-hardcoded-provider-lists,
682
+ // fix-providers-list-spurious-models-refreshed,
683
+ // simplify-model-selection-channels.
627
684
  setCatalogueForSession(sessionId, msg.providers);
628
- browserGateway.broadcastToAll({ type: "models_refreshed" } as any);
629
685
  }
630
686
 
631
687
  if (msg.type === "roles_list") {
@@ -757,7 +813,13 @@ export function wireEvents(deps: EventWiringDeps): void {
757
813
  if (msg.type === "spawn_new_session") {
758
814
  spawnPiSession(msg.cwd, { strategy: loadConfig().spawnStrategy }).then((result) => {
759
815
  if (result.process && result.pid) {
760
- browserGateway.headlessPidRegistry.register(result.pid, msg.cwd, result.process);
816
+ browserGateway.headlessPidRegistry.register(
817
+ result.pid,
818
+ msg.cwd,
819
+ result.process,
820
+ result.spawnToken,
821
+ keeperOptsFromSpawnResult(result),
822
+ );
761
823
  }
762
824
  browserGateway.broadcastToAll({
763
825
  type: "spawn_result",
@@ -808,5 +870,27 @@ export function wireEvents(deps: EventWiringDeps): void {
808
870
  });
809
871
  }
810
872
 
873
+ // RPC keeper dispatch: bridge → server slash command forward.
874
+ // Fire-and-forget; the handler itself emits browser-bound
875
+ // `command_feedback` events on success and on every failure path.
876
+ // The terminal event is persisted via eventStore.insertEvent so it
877
+ // survives browser reattach (otherwise the chat pill stays "in progress").
878
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 8).
879
+ if (msg.type === "dispatch_extension_command") {
880
+ void handleDispatchExtensionCommand(msg, {
881
+ headlessPidRegistry: browserGateway.headlessPidRegistry,
882
+ emitCommandFeedback: (sid, command, status, message) => {
883
+ const event = {
884
+ eventType: "command_feedback",
885
+ timestamp: Date.now(),
886
+ data: message === undefined ? { command, status } : { command, status, message },
887
+ };
888
+ const seq = eventStore.insertEvent(sid, event);
889
+ const stored = eventStore.getEvent(sid, seq) ?? event;
890
+ browserGateway.broadcastEvent(sid, seq, stored);
891
+ },
892
+ });
893
+ }
894
+
811
895
  };
812
896
  }