@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Git info gathering — detects branch, remote URL, and PR number.
3
+ * Delegates to the shared git tool module so there's no inline execSync
4
+ * and every call benefits from the runner's safety defaults (windowsHide,
5
+ * timeout, tolerated exit codes).
6
+ * See change: platform-command-executor.
3
7
  */
4
- import { execSync } from "node:child_process";
8
+ import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
5
9
  import { buildGitLinks, type GitLinks } from "./git-link-builder.js";
6
10
 
7
11
  export interface GitInfo {
@@ -11,39 +15,25 @@ export interface GitInfo {
11
15
  gitPrUrl?: string;
12
16
  }
13
17
 
14
- /** Run a shell command and return trimmed stdout, or undefined on failure. */
15
- function runGit(command: string, cwd: string): string | undefined {
16
- try {
17
- return execSync(command, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 }).trim();
18
- } catch {
19
- return undefined;
20
- }
21
- }
22
-
23
18
  /** Detect the current git branch. Returns short SHA for detached HEAD. */
24
19
  export function detectBranch(cwd: string): string | undefined {
25
- const ref = runGit("git rev-parse --abbrev-ref HEAD", cwd);
20
+ const ref = git.currentBranchOr({ cwd });
26
21
  if (!ref) return undefined;
27
22
  if (ref === "HEAD") {
28
23
  // Detached HEAD — return short commit SHA
29
- return runGit("git rev-parse --short HEAD", cwd) ?? "HEAD";
24
+ return git.headShaOr({ cwd, short: true }) ?? "HEAD";
30
25
  }
31
26
  return ref;
32
27
  }
33
28
 
34
29
  /** Detect the remote origin URL. */
35
30
  export function detectRemoteUrl(cwd: string): string | undefined {
36
- return runGit("git remote get-url origin", cwd);
31
+ return git.remoteUrlOr({ cwd });
37
32
  }
38
33
 
39
34
  /** Detect the PR number via gh CLI (best effort). */
40
35
  export function detectPrNumber(cwd: string): number | undefined {
41
- const result = runGit("gh pr view --json number -q .number", cwd);
42
- if (result) {
43
- const num = parseInt(result, 10);
44
- if (!isNaN(num)) return num;
45
- }
46
- return undefined;
36
+ return git.prNumberOr({ cwd });
47
37
  }
48
38
 
49
39
  /** Gather all git info for a directory. Returns undefined if not a git repo. */
@@ -30,6 +30,7 @@ declare module "@oh-my-pi/pi-coding-agent" {
30
30
  registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: any) => Promise<void> }): void;
31
31
  registerTool(tool: any): void;
32
32
  registerProvider(name: string, config: any): void;
33
+ unregisterProvider(name: string): void;
33
34
  exec(command: string, args: string[], options?: { timeout?: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>;
34
35
  events: EventBus;
35
36
  }
@@ -12,8 +12,43 @@
12
12
  * This handles the reparenting problem: children get reparented to PID 1
13
13
  * when the bash wrapper exits, but we captured their PGIDs while alive.
14
14
  */
15
- import { spawnSync as defaultSpawnSync } from "node:child_process";
16
- import type { SpawnSyncReturns } from "node:child_process";
15
+ import { spawnSync as defaultSpawnSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
16
+ import type { SpawnSyncReturns } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
17
+ import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
18
+ import { killPidWithGroup } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
19
+
20
+ /**
21
+ * Resolve a Windows system tool name (wmic / powershell / tasklist /
22
+ * taskkill) to its full `.exe` path via the global tool registry. If
23
+ * the registry lookup fails we fall back to the bare name and let
24
+ * `spawnSync` do PATHEXT resolution.
25
+ *
26
+ * Spawning the FULL path bypasses any cmd.exe / PATHEXT resolution
27
+ * layers, keeping `windowsHide: true` honored end-to-end. See change:
28
+ * consolidate-windows-spawn-and-platform-handlers.
29
+ *
30
+ * Uses `getDefaultRegistry` (not `peek*`) because the bridge extension
31
+ * runs inside pi's process and may be the FIRST caller to construct
32
+ * the registry in that process. Idempotent and cached per-process.
33
+ */
34
+ const systemToolCache = new Map<string, string>();
35
+ function resolveSystemTool(name: string): string {
36
+ const cached = systemToolCache.get(name);
37
+ if (cached) return cached;
38
+ try {
39
+ const reg = getDefaultRegistry();
40
+ if (reg.has(name)) {
41
+ const res = reg.resolve(name);
42
+ if (res.ok && res.path) {
43
+ systemToolCache.set(name, res.path);
44
+ return res.path;
45
+ }
46
+ }
47
+ } catch { /* registry unavailable in some test contexts */ }
48
+ // Cache the bare name so we only miss once per process.
49
+ systemToolCache.set(name, name);
50
+ return name;
51
+ }
17
52
 
18
53
  export interface ChildProcessInfo {
19
54
  pid: number;
@@ -24,36 +59,12 @@ export interface ChildProcessInfo {
24
59
 
25
60
  /**
26
61
  * Parse ps ETIME format into milliseconds.
27
- * Formats: mm:ss, hh:mm:ss, dd-hh:mm:ss
62
+ * Re-exported from the shared platform primitive to keep the public API of
63
+ * this module stable while centralizing the pure helper.
64
+ * See change: consolidate-platform-handlers.
28
65
  */
29
- export function parseEtime(etime: string): number {
30
- const trimmed = etime.trim();
31
- if (!trimmed) return 0;
32
-
33
- let days = 0;
34
- let rest = trimmed;
35
-
36
- const dashIdx = rest.indexOf("-");
37
- if (dashIdx !== -1) {
38
- days = parseInt(rest.slice(0, dashIdx), 10);
39
- if (isNaN(days)) return 0;
40
- rest = rest.slice(dashIdx + 1);
41
- }
42
-
43
- const parts = rest.split(":").map((p) => parseInt(p, 10));
44
- if (parts.some(isNaN)) return 0;
45
-
46
- let hours = 0, minutes = 0, seconds = 0;
47
- if (parts.length === 3) {
48
- [hours, minutes, seconds] = parts;
49
- } else if (parts.length === 2) {
50
- [minutes, seconds] = parts;
51
- } else {
52
- return 0;
53
- }
54
-
55
- return ((days * 86400) + (hours * 3600) + (minutes * 60) + seconds) * 1000;
56
- }
66
+ export { parseEtime } from "@blackbelt-technology/pi-dashboard-shared/platform/process-scan.js";
67
+ import { parseEtime } from "@blackbelt-technology/pi-dashboard-shared/platform/process-scan.js";
57
68
 
58
69
  const DEFAULT_MIN_ELAPSED_MS = 30_000;
59
70
 
@@ -112,7 +123,8 @@ export function captureChildPgids(
112
123
  trackedPgids: Set<number>,
113
124
  options?: ScanOptions,
114
125
  ): void {
115
- if ((options as any)?._platform === "win32" || (!((options as any)?._platform) && process.platform === "win32")) return;
126
+ const platform = (options as any)?._platform ?? process.platform;
127
+ if (platform === "win32") return;
116
128
 
117
129
  const spawnSync: SpawnSyncFn = options?._spawnSync ?? defaultSpawnSync;
118
130
 
@@ -244,7 +256,9 @@ export function killProcessByPgid(pgid: number, options?: ScanOptions): boolean
244
256
  return killWindowsProcess(pgid, options);
245
257
  }
246
258
  try {
247
- process.kill(-pgid, "SIGTERM");
259
+ // Route through the platform helper so the pid → -pgid mapping stays
260
+ // in one place. See change: route-kill-paths-through-platform.
261
+ killPidWithGroup(pgid, "SIGTERM", { platform });
248
262
  return true;
249
263
  } catch {
250
264
  return false;
@@ -282,9 +296,20 @@ function wmicDateToElapsedMs(creationDate: string): number {
282
296
  function getWindowsDescendants(parentPid: number, spawnSync: SpawnSyncFn): ChildProcessInfo[] {
283
297
  try {
284
298
  const result = spawnSync(
285
- "wmic",
299
+ resolveSystemTool("wmic"),
286
300
  ["process", "where", `ParentProcessId=${parentPid}`, "get", "CommandLine,CreationDate,ParentProcessId,ProcessId", "/format:list"],
287
- { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }
301
+ {
302
+ encoding: "utf-8",
303
+ timeout: 5000,
304
+ stdio: ["pipe", "pipe", "pipe"],
305
+ // Critical: without this, every 10-second process scan flashes a
306
+ // console window. wmic is deprecated on Win 11 but still the
307
+ // default here; PowerShell fallback also needs windowsHide.
308
+ // Also: `resolveSystemTool` above returns the FULL .exe path
309
+ // when the registry is available, so there's no PATHEXT /
310
+ // cmd.exe resolution layer that could leak a console.
311
+ windowsHide: true,
312
+ },
288
313
  );
289
314
  if (result.status !== 0 || !result.stdout) {
290
315
  // wmic removed in newer Windows 11 — fallback to tasklist
@@ -336,9 +361,17 @@ function getWindowsDescendantsTasklist(parentPid: number, spawnSync: SpawnSyncFn
336
361
  // tasklist /FI filters by parent — but tasklist doesn't support ParentProcessId filter
337
362
  // Use PowerShell Get-CimInstance as fallback
338
363
  const result = spawnSync(
339
- "powershell",
364
+ resolveSystemTool("powershell"),
340
365
  ["-NoProfile", "-Command", `Get-CimInstance Win32_Process -Filter "ParentProcessId=${parentPid}" | Select-Object ProcessId,CommandLine,CreationDate | ConvertTo-Json`],
341
- { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
366
+ {
367
+ encoding: "utf-8",
368
+ timeout: 10000,
369
+ stdio: ["pipe", "pipe", "pipe"],
370
+ // Suppress console flash for the PowerShell fallback path.
371
+ // `resolveSystemTool` returns the full .exe path when registry
372
+ // is available.
373
+ windowsHide: true,
374
+ },
342
375
  );
343
376
  if (result.status !== 0 || !result.stdout) return [];
344
377
 
@@ -384,7 +417,8 @@ export function scanWindowsProcesses(
384
417
  export function killWindowsProcess(pid: number, options?: ScanOptions): boolean {
385
418
  const spawnSync: SpawnSyncFn = options?._spawnSync ?? defaultSpawnSync;
386
419
  try {
387
- const result = spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
420
+ const result = spawnSync(resolveSystemTool("taskkill"), ["/PID", String(pid), "/T", "/F"], {
421
+ windowsHide: true,
388
422
  encoding: "utf-8",
389
423
  timeout: 5000,
390
424
  stdio: ["pipe", "pipe", "pipe"],
@@ -56,13 +56,20 @@ function readTemplate(filePath: string): string {
56
56
  /**
57
57
  * Expand a slash command by finding and reading the prompt template from disk.
58
58
  * Returns the expanded text, or the original text if no template found.
59
+ *
60
+ * @param pi Optional pi extension API — used to find globally installed skills
61
+ * and package skills via pi.getCommands() when local scan misses them.
59
62
  */
60
- export function expandPromptTemplateFromDisk(text: string, cwd: string): string {
63
+ export function expandPromptTemplateFromDisk(text: string, cwd: string, pi?: any): string {
61
64
  if (!text.startsWith("/")) return text;
62
65
 
63
- const spaceIndex = text.indexOf(" ");
64
- const templateName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
65
- const argsString = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1);
66
+ // Split template name from args on first whitespace (space OR newline).
67
+ // Using indexOf(" ") alone breaks multi-line payloads like "/skill:foo\nargs"
68
+ // because the first space can lie inside the args, producing a name such as
69
+ // "skill:foo\nargs-first-word" that never matches a template.
70
+ const m = text.slice(1).match(/^(\S+)\s*([\s\S]*)$/);
71
+ const templateName = m?.[1] ?? text.slice(1);
72
+ const argsString = m?.[2] ?? "";
66
73
 
67
74
  const templates = findPromptTemplates(cwd);
68
75
  let filePath = templates.get(templateName);
@@ -72,6 +79,20 @@ export function expandPromptTemplateFromDisk(text: string, cwd: string): string
72
79
  filePath = templates.get(templateName.replace(/:/g, "-"));
73
80
  }
74
81
 
82
+ // Fallback: check pi.getCommands() for globally installed skills and package skills
83
+ // that aren't in the local .pi/skills/ directory.
84
+ if (!filePath && pi?.getCommands) {
85
+ try {
86
+ const commands = pi.getCommands();
87
+ const skill = commands.find(
88
+ (c: any) => c.name === templateName && c.source === "skill" && c.path,
89
+ );
90
+ if (skill?.path && existsSync(skill.path)) {
91
+ filePath = skill.path;
92
+ }
93
+ } catch { /* ignore */ }
94
+ }
95
+
75
96
  if (!filePath) return text;
76
97
 
77
98
  try {
@@ -24,16 +24,166 @@ interface ProviderEntry {
24
24
  api?: string;
25
25
  }
26
26
 
27
+ type InputModality = "text" | "image";
28
+
29
+ export interface ModelMetadata {
30
+ contextWindow: number;
31
+ maxTokens: number;
32
+ reasoning: boolean;
33
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
34
+ input: InputModality[];
35
+ }
36
+
37
+ /**
38
+ * A catalog probe: given (provider, id), return the catalog entry or null.
39
+ * In production this is `modelRegistry.find(provider, id)` from pi's
40
+ * ModelRegistry (which knows both built-in pi-ai models AND user-configured
41
+ * custom models). Exposed as a parameter so unit tests can supply a fake
42
+ * catalog without needing pi-ai installed.
43
+ */
44
+ export type CatalogProbe = (provider: string, modelId: string) => {
45
+ contextWindow: number;
46
+ maxTokens: number;
47
+ reasoning: boolean;
48
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
49
+ input: readonly ("text" | "image")[];
50
+ } | null | undefined;
51
+
52
+ // -- Model metadata enrichment --------------------------------------------
53
+ //
54
+ // Custom-provider `/v1/models` endpoints return only { id, object, ... } —
55
+ // they do not advertise context_window, max_tokens, cost, or reasoning
56
+ // capability. Rather than hardcode 200k / 16k / $0 / no-reasoning for every
57
+ // discovered model (the prior behavior, which was wrong for Opus 4.6+/Sonnet
58
+ // 4.6+/GPT-5/Gemini 2.5), we consult pi's `modelRegistry.find(provider, id)`
59
+ // — which surfaces pi-ai's bundled catalog plus any custom models — for
60
+ // accurate metadata and fall back to api-appropriate defaults when the
61
+ // catalog has no match.
62
+ //
63
+ // See change: enrich-custom-provider-model-metadata.
64
+
65
+ // API type → ordered list of candidate providers in pi's catalog.
66
+ // Provider keys match pi-ai's MODELS export as surfaced by modelRegistry.
67
+ // Order matters: first match wins.
68
+ const CANDIDATE_PROVIDERS: Record<string, readonly string[]> = {
69
+ "anthropic-messages": ["anthropic", "opencode"],
70
+ "google-generative-ai": ["google", "google-vertex"],
71
+ "openai-completions": ["openai", "openrouter", "groq", "xai", "mistral"],
72
+ };
73
+
74
+ // Api-typed fallback defaults when the catalog has no match. Modern floors:
75
+ // - anthropic-messages: 200k ctx (Claude 3/4 floor), 64k maxTok
76
+ // - google-generative-ai: 1M ctx (Gemini 1.5+/2.x floor), 65k maxTok
77
+ // - openai-completions (default): 128k ctx (GPT-4o floor), 16k maxTok
78
+ const FALLBACK_DEFAULTS: Record<string, Omit<ModelMetadata, "input">> = {
79
+ "anthropic-messages": {
80
+ contextWindow: 200_000,
81
+ maxTokens: 64_000,
82
+ reasoning: false,
83
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
84
+ },
85
+ "google-generative-ai": {
86
+ contextWindow: 1_000_000,
87
+ maxTokens: 65_536,
88
+ reasoning: false,
89
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
90
+ },
91
+ "openai-completions": {
92
+ contextWindow: 128_000,
93
+ maxTokens: 16_384,
94
+ reasoning: false,
95
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
96
+ },
97
+ };
98
+
99
+ const DEFAULT_INPUT: InputModality[] = ["text", "image"];
100
+
101
+ /**
102
+ * Resolve a discovered custom-provider model id to full metadata by consulting
103
+ * pi's model catalog via the supplied `probe` function. Falls back to
104
+ * api-appropriate defaults when no catalog entry matches OR when no probe is
105
+ * available (e.g., modelRegistry not yet captured from spawn-context).
106
+ *
107
+ * Strips common proxy-prefix path segments (`cc/`, `anthropic/`,
108
+ * `openrouter/anthropic/…`) before lookup so prefixed ids resolve to the same
109
+ * catalog entry as the bare id.
110
+ *
111
+ * Exported (with the `probe` parameter) for unit testing. Production callers
112
+ * use `registerEntry()` which injects `modelRegistry.find`.
113
+ */
114
+ export function enrichModelMetadata(
115
+ discoveredId: string,
116
+ api?: string,
117
+ probe?: CatalogProbe | null,
118
+ ): ModelMetadata {
119
+ const resolvedApi = api && api in CANDIDATE_PROVIDERS ? api : "openai-completions";
120
+ const candidates = CANDIDATE_PROVIDERS[resolvedApi] ?? CANDIDATE_PROVIDERS["openai-completions"];
121
+
122
+ // Build dedup'd list of ids to try: full, then everything after the last `/`.
123
+ const lookupIds: string[] = [discoveredId];
124
+ const lastSlash = discoveredId.lastIndexOf("/");
125
+ if (lastSlash >= 0 && lastSlash < discoveredId.length - 1) {
126
+ const bare = discoveredId.slice(lastSlash + 1);
127
+ if (bare && bare !== discoveredId) lookupIds.push(bare);
128
+ }
129
+
130
+ if (probe) {
131
+ for (const id of lookupIds) {
132
+ for (const provider of candidates) {
133
+ let match: ReturnType<CatalogProbe> | undefined;
134
+ try {
135
+ match = probe(provider, id);
136
+ } catch {
137
+ match = undefined;
138
+ }
139
+ if (match) {
140
+ return {
141
+ contextWindow: match.contextWindow,
142
+ maxTokens: match.maxTokens,
143
+ reasoning: match.reasoning,
144
+ cost: match.cost,
145
+ input: [...match.input] as InputModality[],
146
+ };
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // No probe, or no catalog match — use api-appropriate fallback with
153
+ // image-capable default (see change: enable-image-input-custom-providers).
154
+ const fallback = FALLBACK_DEFAULTS[resolvedApi] ?? FALLBACK_DEFAULTS["openai-completions"];
155
+ return {
156
+ ...fallback,
157
+ input: [...DEFAULT_INPUT],
158
+ };
159
+ }
160
+
27
161
  // -- Config path ----------------------------------------------------------
28
162
 
29
- const CONFIG_PATH = join(homedir(), ".pi", "agent", "providers.json");
163
+ // Resolved lazily so HOME can be changed in tests.
164
+ function configPath(): string {
165
+ return join(homedir(), ".pi", "agent", "providers.json");
166
+ }
167
+ const CONFIG_PATH = configPath();
168
+
169
+ // Snapshot of last-registered provider entries so reloadProviders can diff.
170
+ const lastRegistered = new Map<string, ProviderEntry>();
171
+
172
+ function entriesEqual(a: ProviderEntry, b: ProviderEntry): boolean {
173
+ return (
174
+ a.baseUrl === b.baseUrl &&
175
+ a.apiKey === b.apiKey &&
176
+ (a.api ?? "openai-completions") === (b.api ?? "openai-completions")
177
+ );
178
+ }
30
179
 
31
180
  // -- Config I/O (read-only — providers section) ----------------------------
32
181
 
33
182
  function loadProviders(): Record<string, ProviderEntry> {
34
- if (existsSync(CONFIG_PATH)) {
183
+ const path = configPath();
184
+ if (existsSync(path)) {
35
185
  try {
36
- const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
186
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
37
187
  const providers: Record<string, ProviderEntry> = { ...raw.providers };
38
188
  for (const [, entry] of Object.entries(providers) as [string, any][]) {
39
189
  if (entry.apiKeyEnv && !entry.apiKey) {
@@ -43,8 +193,10 @@ function loadProviders(): Record<string, ProviderEntry> {
43
193
  delete (entry as any).modelIds;
44
194
  }
45
195
  return providers;
46
- } catch {
47
- // Fall through to empty
196
+ } catch (err: any) {
197
+ console.error(
198
+ `[dashboard] providers.json reload failed: ${err?.message ?? String(err)}`,
199
+ );
48
200
  }
49
201
  }
50
202
  return {};
@@ -124,6 +276,11 @@ let currentSessionModelId = "";
124
276
 
125
277
  let piRef: ExtensionAPI | null = null;
126
278
 
279
+ // Captured from any pi event handler's ctx.modelRegistry (first available wins).
280
+ // Used by getModelRegistry() to probe pi's catalog for model metadata enrichment.
281
+ // See change: enrich-custom-provider-model-metadata.
282
+ let modelRegistryRef: any = null;
283
+
127
284
  // Callback for notifying the bridge when providers change
128
285
  let onProvidersChanged: (() => void) | null = null;
129
286
 
@@ -148,13 +305,15 @@ export function onProviderChanged(callback: () => void): void {
148
305
  onProvidersChanged = callback;
149
306
  }
150
307
 
151
- // -- Helper: get modelRegistry via event ----------------------------------
152
-
308
+ // -- Helper: get modelRegistry --------------------------------------------
309
+ //
310
+ // pi's ModelRegistry is passed as `ctx.modelRegistry` to every extension
311
+ // event handler (see ExtensionContext in pi's types). We capture the first
312
+ // reference we see in `session_start` and reuse it thereafter. This avoids
313
+ // depending on pi-flows' `flow:get-spawn-context` event which is not
314
+ // guaranteed to be present in every install.
153
315
  function getModelRegistry(): any {
154
- if (!piRef) return null;
155
- const spawnCtx: any = {};
156
- piRef.events.emit("flow:get-spawn-context", spawnCtx);
157
- return spawnCtx.modelRegistry ?? null;
316
+ return modelRegistryRef;
158
317
  }
159
318
 
160
319
  // -- Provider registration (with auto-discovery) --------------------------
@@ -162,14 +321,23 @@ function getModelRegistry(): any {
162
321
  async function registerEntry(pi: ExtensionAPI, name: string, entry: ProviderEntry): Promise<number> {
163
322
  const discovered = await discoverModels(entry.baseUrl, entry.apiKey);
164
323
 
324
+ // Metadata (contextWindow, maxTokens, reasoning, cost, input) is resolved
325
+ // via pi's `modelRegistry.find(provider, id)` when the registry is
326
+ // reachable, with api-appropriate fallbacks otherwise — the previous
327
+ // hardcoded 200k / 16k / $0 / no-reasoning was silently wrong for
328
+ // Opus 4.6+/Sonnet 4.6+/GPT-5/Gemini-2.x proxied via OpenAI-compatible
329
+ // endpoints. See enrichModelMetadata above, and change:
330
+ // enrich-custom-provider-model-metadata.
331
+ const registry = getModelRegistry();
332
+ const probe: CatalogProbe | null =
333
+ registry && typeof registry.find === "function"
334
+ ? (provider, modelId) => registry.find(provider, modelId) ?? null
335
+ : null;
336
+
165
337
  const models = discovered.map((m) => ({
166
338
  id: m.id,
167
339
  name: m.id,
168
- reasoning: false,
169
- input: ["text"] as ("text" | "image")[],
170
- contextWindow: 200000,
171
- maxTokens: 16384,
172
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
340
+ ...enrichModelMetadata(m.id, entry.api, probe),
173
341
  }));
174
342
 
175
343
  pi.registerProvider(name, {
@@ -179,12 +347,83 @@ async function registerEntry(pi: ExtensionAPI, name: string, entry: ProviderEntr
179
347
  models,
180
348
  });
181
349
 
350
+ // Record snapshot so reloadProviders can detect subsequent changes.
351
+ lastRegistered.set(name, {
352
+ baseUrl: entry.baseUrl,
353
+ apiKey: entry.apiKey,
354
+ api: entry.api ?? "openai-completions",
355
+ });
356
+
182
357
  // Notify bridge directly (same package — no cross-package event needed)
183
358
  onProvidersChanged?.();
184
359
 
185
360
  return discovered.length;
186
361
  }
187
362
 
363
+ /**
364
+ * Diff the current providers.json against the last-registered snapshot and
365
+ * apply add / remove / change operations via `pi.registerProvider` and
366
+ * `pi.unregisterProvider`. Called by the bridge's `credentials_updated`
367
+ * handler so adding/editing/removing providers in the dashboard UI takes
368
+ * effect without a session restart.
369
+ *
370
+ * Malformed providers.json or IO errors produce an empty diff and do not
371
+ * throw, so the caller can still run `modelRegistry.refresh()` for other
372
+ * credential updates.
373
+ */
374
+ export async function reloadProviders(
375
+ pi: ExtensionAPI,
376
+ ): Promise<{ added: string[]; removed: string[]; changed: string[] }> {
377
+ piRef = pi;
378
+ const added: string[] = [];
379
+ const removed: string[] = [];
380
+ const changed: string[] = [];
381
+
382
+ let current: Record<string, ProviderEntry>;
383
+ try {
384
+ current = loadProviders();
385
+ } catch {
386
+ return { added, removed, changed };
387
+ }
388
+
389
+ // Detect removals and changes against previous snapshot.
390
+ for (const [name, prev] of lastRegistered) {
391
+ const next = current[name];
392
+ if (!next) {
393
+ try {
394
+ pi.unregisterProvider(name);
395
+ } catch (err: any) {
396
+ console.error(`[dashboard] unregisterProvider("${name}") failed: ${err?.message ?? String(err)}`);
397
+ }
398
+ lastRegistered.delete(name);
399
+ removed.push(name);
400
+ } else if (!entriesEqual(prev, next)) {
401
+ try {
402
+ pi.unregisterProvider(name);
403
+ } catch (err: any) {
404
+ console.error(`[dashboard] unregisterProvider("${name}") failed: ${err?.message ?? String(err)}`);
405
+ }
406
+ lastRegistered.delete(name);
407
+ changed.push(name);
408
+ }
409
+ }
410
+
411
+ // Register new entries and changed entries (order-dependent: unregister ran first above).
412
+ for (const [name, entry] of Object.entries(current)) {
413
+ if (lastRegistered.has(name)) continue;
414
+ try {
415
+ await registerEntry(pi, name, entry);
416
+ if (!added.includes(name) && !changed.includes(name)) {
417
+ added.push(name);
418
+ }
419
+ } catch (err: any) {
420
+ console.error(`[dashboard] registerProvider("${name}") failed: ${err?.message ?? String(err)}`);
421
+ }
422
+ }
423
+
424
+ return { added, removed, changed };
425
+ }
426
+
188
427
  // -- Extension entry point ------------------------------------------------
189
428
 
190
429
  export function activate(pi: ExtensionAPI) {
@@ -248,6 +487,11 @@ export function activate(pi: ExtensionAPI) {
248
487
  // ── Session lifecycle ──────────────────────────────────────────────
249
488
 
250
489
  pi.on("model_select", async (_event, ctx) => {
490
+ // Also capture modelRegistry here as a belt-and-suspenders in case
491
+ // session_start ran before activate() finished in some edge case.
492
+ if (!modelRegistryRef && ctx.modelRegistry) {
493
+ modelRegistryRef = ctx.modelRegistry;
494
+ }
251
495
  if (ctx.model) {
252
496
  currentSessionProvider = ctx.model.provider ?? "";
253
497
  currentSessionModelId = ctx.model.id ?? "";
@@ -255,6 +499,50 @@ export function activate(pi: ExtensionAPI) {
255
499
  });
256
500
 
257
501
  pi.on("session_start", async (_event, ctx) => {
502
+ // Capture the modelRegistry reference the first time we see it, then
503
+ // re-register already-registered providers so their model metadata gets
504
+ // enriched from pi's catalog (they were registered at activate() before
505
+ // any ctx was available, so they currently carry fallback defaults).
506
+ // See change: enrich-custom-provider-model-metadata.
507
+ if (!modelRegistryRef && ctx.modelRegistry) {
508
+ modelRegistryRef = ctx.modelRegistry;
509
+ if (lastRegistered.size > 0) {
510
+ // Force re-registration: clear snapshot so reloadProviders re-adds all
511
+ // entries (which will now probe the captured registry).
512
+ const names = Array.from(lastRegistered.keys());
513
+ lastRegistered.clear();
514
+ for (const name of names) {
515
+ const entry = providers[name];
516
+ if (entry) {
517
+ try {
518
+ await registerEntry(pi, name, entry);
519
+ } catch (err: any) {
520
+ console.error(`[dashboard] re-registerProvider("${name}") failed: ${err?.message ?? String(err)}`);
521
+ }
522
+ }
523
+ }
524
+
525
+ // If the session's currently-selected model belongs to one of the
526
+ // providers we just re-registered, re-apply it via pi.setModel() so
527
+ // the snapshot on agent.state.model picks up the enriched metadata
528
+ // (reasoning / contextWindow / cost). Without this, pi's session
529
+ // still holds the pre-enrichment descriptor with reasoning: false,
530
+ // causing setThinkingLevel to clamp to "off" even though the registry
531
+ // now has reasoning: true. See change: enrich-custom-provider-model-metadata.
532
+ const current = ctx.model as any;
533
+ if (current?.provider && current?.id && names.includes(current.provider)) {
534
+ try {
535
+ const refreshed = ctx.modelRegistry.find(current.provider, current.id);
536
+ if (refreshed && (pi as any).setModel) {
537
+ await (pi as any).setModel(refreshed);
538
+ }
539
+ } catch (err: any) {
540
+ console.error(`[dashboard] re-setModel after enrichment failed: ${err?.message ?? String(err)}`);
541
+ }
542
+ }
543
+ }
544
+ }
545
+
258
546
  if (ctx.model) {
259
547
  currentSessionProvider = ctx.model.provider ?? "";
260
548
  currentSessionModelId = ctx.model.id ?? "";