@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
@@ -7,7 +7,114 @@
7
7
  */
8
8
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
9
  import { Type } from "@sinclair/typebox";
10
- import { StringEnum } from "@mariozechner/pi-ai";
10
+
11
+ // ──────────────────────────────────────────────────────────────────────────
12
+ // Single-question schema arms (reused inside the batch arm's questions array)
13
+ // ──────────────────────────────────────────────────────────────────────────
14
+
15
+ const ConfirmSchema = Type.Object({
16
+ method: Type.Literal("confirm", { description: "Yes/no question" }),
17
+ title: Type.String({ description: "The question to confirm" }),
18
+ message: Type.Optional(Type.String({ description: "Additional context or detailed question body" })),
19
+ });
20
+
21
+ const SelectSchema = Type.Object({
22
+ method: Type.Literal("select", { description: "Pick one option from a list" }),
23
+ title: Type.String({ description: "Short title for the question" }),
24
+ options: Type.Array(Type.String(), {
25
+ minItems: 2,
26
+ description: "Options the user chooses between (at least 2; use 'confirm' for yes/no)",
27
+ }),
28
+ message: Type.Optional(Type.String({ description: "Additional context" })),
29
+ });
30
+
31
+ const MultiselectSchema = Type.Object({
32
+ method: Type.Literal("multiselect", { description: "Pick multiple options from a list" }),
33
+ title: Type.String({ description: "Short title for the question" }),
34
+ options: Type.Array(Type.String(), {
35
+ minItems: 1,
36
+ description: "Options the user can multi-select",
37
+ }),
38
+ message: Type.Optional(Type.String({ description: "Additional context" })),
39
+ });
40
+
41
+ const InputSchema = Type.Object({
42
+ method: Type.Literal("input", { description: "Free-text input" }),
43
+ title: Type.String({ description: "Short title for the question" }),
44
+ placeholder: Type.Optional(Type.String({ description: "Placeholder text for the input field" })),
45
+ message: Type.Optional(Type.String({ description: "Additional context" })),
46
+ });
47
+
48
+ // Sub-question union deliberately omits the batch arm (no nesting).
49
+ const SubQuestionSchema = Type.Union([ConfirmSchema, SelectSchema, MultiselectSchema, InputSchema], {
50
+ description: "A single question inside a batch. Must not itself be a batch.",
51
+ });
52
+
53
+ const BatchSchema = Type.Object({
54
+ method: Type.Literal("batch", {
55
+ description: "Ask multiple related questions in one call; answers are returned as an ordered array.",
56
+ }),
57
+ title: Type.String({ description: "Header shown above the sequence of dialogs" }),
58
+ questions: Type.Array(SubQuestionSchema, {
59
+ minItems: 1,
60
+ description: "One or more sub-questions (confirm/select/multiselect/input). Cannot nest batch.",
61
+ }),
62
+ message: Type.Optional(Type.String({ description: "Additional context for the whole batch" })),
63
+ });
64
+
65
+ // ──────────────────────────────────────────────────────────────────────────
66
+ // Argument rescue helpers
67
+ // ──────────────────────────────────────────────────────────────────────────
68
+
69
+ type NormalizationWarning = string;
70
+
71
+ function normalizeSubQuestion(
72
+ sq: unknown,
73
+ warnings: NormalizationWarning[],
74
+ ): Record<string, unknown> {
75
+ if (!sq || typeof sq !== "object" || Array.isArray(sq)) return sq as any;
76
+ let obj = { ...(sq as Record<string, unknown>) };
77
+
78
+ // Flatten `input_type: {method, options, ...}` wrapper if present.
79
+ if (obj.input_type && typeof obj.input_type === "object" && !Array.isArray(obj.input_type)) {
80
+ const inner = obj.input_type as Record<string, unknown>;
81
+ const { input_type: _drop, ...rest } = obj;
82
+ obj = { ...inner, ...rest };
83
+ delete (obj as Record<string, unknown>).input_type;
84
+ }
85
+
86
+ // Rename `question` / `header` → `title` (only if title missing).
87
+ if (obj.title === undefined) {
88
+ if (typeof obj.question === "string") obj.title = obj.question;
89
+ else if (typeof obj.header === "string") obj.title = obj.header;
90
+ }
91
+
92
+ // Parse stringified options.
93
+ if (typeof obj.options === "string") {
94
+ try {
95
+ const parsed = JSON.parse(obj.options);
96
+ if (Array.isArray(parsed)) obj.options = parsed;
97
+ } catch {
98
+ /* leave as-is */
99
+ }
100
+ }
101
+
102
+ // Convert options: [{label, value}] → [label, ...] with a warning.
103
+ if (Array.isArray(obj.options) && obj.options.length > 0 && obj.options.every(
104
+ (o) => o && typeof o === "object" && !Array.isArray(o) && typeof (o as any).label === "string",
105
+ )) {
106
+ obj.options = (obj.options as Array<Record<string, unknown>>).map((o) => o.label as string);
107
+ warnings.push(
108
+ "ask_user: options with {label, value} pairs are not supported — only labels were used. Send options as string[].",
109
+ );
110
+ }
111
+
112
+ return obj;
113
+ }
114
+
115
+ // ──────────────────────────────────────────────────────────────────────────
116
+ // Tool registration
117
+ // ──────────────────────────────────────────────────────────────────────────
11
118
 
12
119
  export function registerAskUserTool(pi: ExtensionAPI): void {
13
120
  pi.registerTool({
@@ -16,49 +123,211 @@ export function registerAskUserTool(pi: ExtensionAPI): void {
16
123
  description:
17
124
  "Ask the user a question interactively. Use this when you need clarification, confirmation, or a choice from the user before proceeding.",
18
125
  promptSnippet:
19
- "Ask the user interactive questions (confirm, select, multiselect, or free text input)",
126
+ "Ask the user interactive questions (confirm, select, multiselect, input, or batch multiple related questions at once)",
20
127
  promptGuidelines: [
21
128
  "When you need to ask the user a question, ALWAYS use the ask_user tool instead of writing the question as plain text.",
22
129
  "Use method 'confirm' for yes/no questions, 'select' when offering specific choices, 'multiselect' when the user should pick multiple items from a list, and 'input' for open-ended questions.",
130
+ "Use method 'batch' with a `questions` array to ask multiple related questions in one call (e.g. project setup: name + language + init git). Prefer single-method calls for standalone questions.",
131
+ "Do not nest batches. Send `options` as a plain string[] — not [{label, value}].",
23
132
  "This applies to all workflows including OpenSpec, planning, and any situation where you need user input before proceeding.",
24
133
  ],
25
- parameters: Type.Object({
26
- method: StringEnum(["confirm", "select", "multiselect", "input"] as const, {
27
- description:
28
- "Type of question: confirm (yes/no), select (pick from options), multiselect (pick multiple), input (free text)",
29
- }),
30
- title: Type.Optional(Type.String({ description: "Short title for the question (optional, falls back to message)" })),
31
- message: Type.Optional(Type.String({ description: "Additional context or detailed question body (all methods)" })),
32
- options: Type.Optional(
33
- Type.Array(Type.String(), { description: "Options to choose from (for select)" }),
34
- ),
35
- placeholder: Type.Optional(Type.String({ description: "Placeholder text (for input)" })),
36
- }),
134
+ parameters: Type.Union(
135
+ [ConfirmSchema, SelectSchema, MultiselectSchema, InputSchema, BatchSchema],
136
+ { description: "Parameters for ask_user, discriminated by method." },
137
+ ),
37
138
  prepareArguments(args: unknown) {
38
- const obj = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
39
- // LLMs sometimes send options as a JSON string instead of an array
139
+ let obj = (args && typeof args === "object" ? { ...(args as Record<string, unknown>) } : {}) as Record<string, unknown>;
140
+
141
+ // 1. LLMs sometimes wrap everything under `params` (stringified or object).
142
+ if (obj.params !== undefined) {
143
+ let inner: Record<string, unknown> | undefined;
144
+ if (typeof obj.params === "string") {
145
+ try {
146
+ const parsed = JSON.parse(obj.params);
147
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
148
+ inner = parsed as Record<string, unknown>;
149
+ }
150
+ } catch { /* leave as-is */ }
151
+ } else if (obj.params && typeof obj.params === "object" && !Array.isArray(obj.params)) {
152
+ inner = obj.params as Record<string, unknown>;
153
+ }
154
+ if (inner) {
155
+ const { params: _omit, ...rest } = obj;
156
+ obj = { ...inner, ...rest };
157
+ delete (obj as Record<string, unknown>).params;
158
+ }
159
+ }
160
+
161
+ // 2. `question` → `title` (only if title missing).
162
+ if (obj.title === undefined && typeof obj.question === "string") {
163
+ obj.title = obj.question;
164
+ }
165
+
166
+ // 3. Stringified top-level `options` for single-method calls.
40
167
  if (typeof obj.options === "string") {
41
168
  try {
42
169
  const parsed = JSON.parse(obj.options);
43
170
  if (Array.isArray(parsed)) obj.options = parsed;
44
- } catch { /* leave as-is, validation will report */ }
171
+ } catch { /* leave as-is */ }
172
+ }
173
+
174
+ // 4. Batch rescue: `questions` as a JSON string → parsed array.
175
+ if (typeof obj.questions === "string") {
176
+ try {
177
+ const parsed = JSON.parse(obj.questions);
178
+ if (Array.isArray(parsed)) obj.questions = parsed;
179
+ } catch { /* leave as-is */ }
180
+ }
181
+
182
+ // 5. If `questions` is a non-empty array and `method` is absent, synthesize `method: "batch"`.
183
+ if (
184
+ !obj.method &&
185
+ Array.isArray(obj.questions) &&
186
+ obj.questions.length > 0
187
+ ) {
188
+ obj.method = "batch";
189
+ }
190
+
191
+ // 6. For any batch call (synthesized or explicit), backfill a missing outer `title`
192
+ // from the first sub-question so the schema validates. Opus frequently sends
193
+ // `{method:"batch", questions:[...]}` without an outer `title`.
194
+ if (
195
+ obj.method === "batch" &&
196
+ Array.isArray(obj.questions) &&
197
+ obj.questions.length > 0 &&
198
+ obj.title === undefined
199
+ ) {
200
+ const first = obj.questions[0] as Record<string, unknown> | undefined;
201
+ const candidate =
202
+ (first && (first.title ?? first.question ?? first.header)) || "Questions";
203
+ obj.title = typeof candidate === "string" ? candidate : "Questions";
45
204
  }
205
+
206
+ // 7. For batch calls, normalize each sub-question (input_type, question/header, {label,value}).
207
+ const warnings: NormalizationWarning[] = [];
208
+ if (obj.method === "batch" && Array.isArray(obj.questions)) {
209
+ obj.questions = obj.questions.map((sq) => normalizeSubQuestion(sq, warnings));
210
+ }
211
+
212
+ if (warnings.length > 0) {
213
+ // Non-enumerable so it doesn't interfere with schema validation.
214
+ Object.defineProperty(obj, "__normalizations", {
215
+ value: warnings,
216
+ enumerable: false,
217
+ configurable: true,
218
+ writable: true,
219
+ });
220
+ }
221
+
46
222
  return obj as any;
47
223
  },
48
224
  async execute(_toolCallId: any, params: any, _signal: any, _onUpdate: any, ctx: any) {
49
- let result: unknown;
225
+ // ── Batch branch ─────────────────────────────────────────────────
226
+ if (params.method === "batch" && Array.isArray(params.questions)) {
227
+ const results: Array<unknown> = [];
228
+ let cancelled = false;
50
229
 
51
- const msgOpts = params.message ? { message: params.message } : undefined;
230
+ for (const sq of params.questions) {
231
+ const subTitle = `${params.title} — ${sq.title ?? "Question"}`;
232
+ const subMsg = params.message ? { message: params.message } : undefined;
233
+
234
+ let answer: unknown;
235
+ try {
236
+ switch (sq.method) {
237
+ case "confirm":
238
+ answer = await ctx.ui.confirm(subTitle, sq.message ?? params.message ?? "");
239
+ break;
240
+ case "select": {
241
+ const opts = Array.isArray(sq.options) ? sq.options : [];
242
+ if (opts.length === 0) {
243
+ throw new Error(
244
+ `ask_user batch: sub-question method "select" requires a non-empty "options" array.`,
245
+ );
246
+ }
247
+ answer = await ctx.ui.select(subTitle, opts, subMsg);
248
+ break;
249
+ }
250
+ case "multiselect": {
251
+ const opts = Array.isArray(sq.options) ? sq.options : [];
252
+ if (opts.length === 0) {
253
+ throw new Error(
254
+ `ask_user batch: sub-question method "multiselect" requires a non-empty "options" array.`,
255
+ );
256
+ }
257
+ answer = await (ctx.ui as any).multiselect(subTitle, opts, subMsg);
258
+ break;
259
+ }
260
+ case "input":
261
+ answer = await ctx.ui.input(subTitle, sq.placeholder, subMsg);
262
+ break;
263
+ default:
264
+ throw new Error(`ask_user batch: unknown sub-question method "${sq.method}"`);
265
+ }
266
+ } catch (err) {
267
+ // Propagate hard errors (schema/logic bugs); cancellation is signalled by undefined.
268
+ throw err;
269
+ }
270
+
271
+ // Treat `undefined` from input/select/multiselect as cancellation.
272
+ // (confirm always resolves to a boolean and has no cancel path.)
273
+ if (
274
+ (sq.method === "input" || sq.method === "select" || sq.method === "multiselect") &&
275
+ answer === undefined
276
+ ) {
277
+ cancelled = true;
278
+ results.push(null);
279
+ break;
280
+ }
52
281
 
282
+ results.push(answer);
283
+ }
284
+
285
+ const warnings: string[] = (params as any).__normalizations ?? [];
286
+ const lines: string[] = [];
287
+ if (cancelled) {
288
+ lines.push(`User cancelled batch after ${results.filter((r) => r !== null).length} of ${params.questions.length} answers.`);
289
+ } else {
290
+ lines.push(`User completed batch (${results.length} answers).`);
291
+ }
292
+ params.questions.forEach((sq: any, i: number) => {
293
+ const ans = i < results.length ? results[i] : "(not asked)";
294
+ lines.push(` ${i + 1}. ${sq.title ?? sq.method}: ${JSON.stringify(ans)}`);
295
+ });
296
+ if (warnings.length > 0) {
297
+ lines.push("", "Warnings:");
298
+ for (const w of warnings) lines.push(` - ${w}`);
299
+ }
300
+
301
+ return {
302
+ content: [{ type: "text", text: lines.join("\n") }],
303
+ details: {
304
+ method: "batch",
305
+ results,
306
+ cancelled,
307
+ warnings,
308
+ },
309
+ };
310
+ }
311
+
312
+ // ── Single-question branches (unchanged behavior) ────────────────
313
+ let result: unknown;
314
+ const msgOpts = params.message ? { message: params.message } : undefined;
53
315
  const title = params.title || params.message || "Question";
54
316
 
55
- // LLMs sometimes send options as a JSON string instead of an array
56
317
  const options: string[] = Array.isArray(params.options)
57
318
  ? params.options
58
319
  : typeof params.options === "string"
59
320
  ? (() => { try { const p = JSON.parse(params.options); return Array.isArray(p) ? p : []; } catch { return []; } })()
60
321
  : [];
61
322
 
323
+ if ((params.method === "select" || params.method === "multiselect") && options.length === 0) {
324
+ throw new Error(
325
+ `ask_user: method "${params.method}" requires a non-empty "options" array. ` +
326
+ `Received: ${JSON.stringify(params.options)}. ` +
327
+ `If no choices are available, use method "input" instead.`,
328
+ );
329
+ }
330
+
62
331
  switch (params.method) {
63
332
  case "confirm":
64
333
  result = await ctx.ui.confirm(title, params.message ?? "");
@@ -5,6 +5,7 @@
5
5
  * forwards all pi events, and relays commands back.
6
6
  */
7
7
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import { Loader } from "@mariozechner/pi-tui";
8
9
  import { ConnectionManager } from "./connection.js";
9
10
  import { detectSessionSource } from "./source-detector.js";
10
11
  import { mapEventToProtocol } from "./event-forwarder.js";
@@ -25,7 +26,7 @@ import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
25
26
  import { PromptBus } from "./prompt-bus.js";
26
27
  import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
27
28
  import { registerAskUserTool } from "./ask-user-tool.js";
28
- import { activate as activateProviderRegister, onProviderChanged } from "./provider-register.js";
29
+ import { activate as activateProviderRegister, onProviderChanged, reloadProviders } from "./provider-register.js";
29
30
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
30
31
  import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
31
32
  import { scanChildProcesses } from "./process-scanner.js";
@@ -74,6 +75,10 @@ export default function (pi: ExtensionAPI) {
74
75
  // registered before session_start fires and models_list is sent.
75
76
  activateProviderRegister(pi);
76
77
 
78
+ // Anthropic-messages payload transforms (system prompt rewrite + tool
79
+ // filter/remap) are handled by the installed @benvargas/pi-claude-code-use
80
+ // package when present. No local duplication here.
81
+
77
82
  initBridge(pi);
78
83
  } catch (err) {
79
84
  // Never crash the host pi agent — dashboard is non-essential
@@ -211,7 +216,31 @@ function initBridge(pi: ExtensionAPI) {
211
216
  // Legacy extension_ui_response removed — now handled by prompt_response → promptBus.respond()
212
217
  // Reload auth credentials when dashboard notifies of changes
213
218
  if (msg.type === "credentials_updated") {
214
- try { cachedModelRegistry?.authStorage?.reload?.(); } catch { /* ignore */ }
219
+ try {
220
+ // Hot-reload providers.json diff BEFORE refreshing the registry,
221
+ // so any newly added providers are registered before getAvailable() runs.
222
+ const diff = await reloadProviders(pi).catch((err) => {
223
+ console.error("[dashboard] reloadProviders failed:", err);
224
+ return { added: [], removed: [], changed: [] };
225
+ });
226
+ if (diff.added.length || diff.removed.length || diff.changed.length) {
227
+ console.log(
228
+ `[dashboard] hot-reloaded providers: added=${JSON.stringify(diff.added)} removed=${JSON.stringify(diff.removed)} changed=${JSON.stringify(diff.changed)}`,
229
+ );
230
+ }
231
+ cachedModelRegistry?.authStorage?.reload?.();
232
+ cachedModelRegistry?.refresh?.();
233
+ } catch (err) { console.error("[dashboard] credentials reload failed:", err); }
234
+ // Push updated models list to dashboard client
235
+ if (cachedModelRegistry && sessionReady) {
236
+ try {
237
+ const models = cachedModelRegistry.getAvailable().map((m: any) => ({
238
+ provider: m.provider,
239
+ id: m.id,
240
+ }));
241
+ connection.send({ type: "models_list", sessionId, models });
242
+ } catch (err) { console.error("[dashboard] models_list push failed:", err); }
243
+ }
215
244
  return;
216
245
  }
217
246
  // Route flow management actions from dashboard buttons
@@ -456,7 +485,9 @@ function initBridge(pi: ExtensionAPI) {
456
485
  }
457
486
  // Fallback: send as user message (template-expanded).
458
487
  // Uses deliverAs:followUp so it queues properly when agent is streaming.
459
- const expanded = expandPromptTemplateFromDisk(text, process.cwd());
488
+ // expandPromptTemplateFromDisk handles skill commands (/skill:xxx) and
489
+ // prompt templates by reading the file content from disk.
490
+ const expanded = expandPromptTemplateFromDisk(text, process.cwd(), pi);
460
491
  (pi.sendUserMessage as any)(expanded, { deliverAs: "followUp" });
461
492
  },
462
493
  });
@@ -581,8 +612,23 @@ function initBridge(pi: ExtensionAPI) {
581
612
  }
582
613
  }
583
614
 
584
- // For message_start and message_end, enrich with entryId (current leaf)
585
- if (eventType === "message_start" || eventType === "message_end") {
615
+ // For message_start, enrich with entryId immediately (current leaf)
616
+ if (eventType === "message_start") {
617
+ const entryId = ctx.sessionManager?.getLeafId?.();
618
+ if (entryId) {
619
+ const enriched = { ...event, entryId };
620
+ const msg = mapEventToProtocol(sessionId, enriched);
621
+ connection.send(msg);
622
+ return;
623
+ }
624
+ }
625
+
626
+ // For message_end, defer getLeafId() so it runs after pi core persists the entry.
627
+ // Pi core calls _emit (which invokes this handler) BEFORE appendMessage (which updates leafId).
628
+ // Since _emit doesn't await async handlers, yielding via queueMicrotask lets appendMessage
629
+ // run first, so getLeafId() returns the correct entry ID for the just-persisted message.
630
+ if (eventType === "message_end") {
631
+ await new Promise<void>(resolve => queueMicrotask(resolve));
586
632
  const entryId = ctx.sessionManager?.getLeafId?.();
587
633
  if (entryId) {
588
634
  const enriched = { ...event, entryId };
@@ -917,17 +963,72 @@ function initBridge(pi: ExtensionAPI) {
917
963
  }
918
964
 
919
965
  // Discover or auto-start server (non-blocking — connection will reconnect)
966
+ //
967
+ // When a real launchServer() is about to run (not on mDNS/health-check
968
+ // paths), mount an animated TUI widget above the editor using pi-tui's
969
+ // Loader (a real Component, self-animating at 80ms, like pi-flows'
970
+ // architect-widget). The previous implementation used
971
+ // ctx.ui.setStatus(...) which only writes a footer string and relies on
972
+ // the TUI render loop being ticked elsewhere — on the cold-start path
973
+ // nothing else requests renders, so the spinner never animated and often
974
+ // never appeared. setWidget(key, factory, {placement:"aboveEditor"}) gives
975
+ // us a managed component that owns its own render loop and is always
976
+ // visible while the launch is in flight.
977
+ let spinnerTimer: NodeJS.Timeout | null = null;
978
+ let spinnerStart = 0;
979
+ let activeLoader: Loader | null = null;
980
+ const stopSpinner = () => {
981
+ if (spinnerTimer) {
982
+ clearInterval(spinnerTimer);
983
+ spinnerTimer = null;
984
+ }
985
+ activeLoader = null;
986
+ ctx.ui.setWidget("pi-dashboard-launch", undefined);
987
+ };
920
988
  autoStartServer(config, {
921
989
  discoverDashboard,
922
990
  isDashboardRunning,
923
991
  launchServer,
924
992
  notify: (msg, level) => ctx.ui.notify(msg, level),
993
+ onLaunchStart: () => {
994
+ spinnerStart = Date.now();
995
+ const buildMessage = () => {
996
+ const elapsed = Math.floor((Date.now() - spinnerStart) / 1000);
997
+ return `starting dashboard server … (${elapsed}s)`;
998
+ };
999
+ ctx.ui.setWidget(
1000
+ "pi-dashboard-launch",
1001
+ (tui: unknown, theme: { fg: (role: string, s: string) => string }) => {
1002
+ const loader = new Loader(
1003
+ tui as ConstructorParameters<typeof Loader>[0],
1004
+ (s: string) => theme.fg("accent", s),
1005
+ (s: string) => theme.fg("muted", s),
1006
+ buildMessage(),
1007
+ );
1008
+ activeLoader = loader;
1009
+ // Loader has stop() but no dispose(); wire dispose so that
1010
+ // setExtensionWidget's teardown stops the 80ms animation interval.
1011
+ (loader as Loader & { dispose?: () => void }).dispose = () => loader.stop();
1012
+ return loader;
1013
+ },
1014
+ { placement: "aboveEditor" },
1015
+ );
1016
+ // Refresh the elapsed-seconds label every second. Frame animation is
1017
+ // driven by the Loader's own 80ms interval.
1018
+ spinnerTimer = setInterval(() => {
1019
+ activeLoader?.setMessage(buildMessage());
1020
+ }, 1000);
1021
+ },
1022
+ onLaunchEnd: () => {
1023
+ stopSpinner();
1024
+ },
925
1025
  }).then((result) => {
1026
+ stopSpinner(); // safety net — covers onLaunchEnd not firing
926
1027
  if (result.server && result.server.piPort !== config.piPort) {
927
1028
  // Server found on a different piPort than configured — update connection URL
928
1029
  connection.updateUrl(`ws://${result.server.host === 'localhost' ? 'localhost' : result.server.host}:${result.server.piPort}`);
929
1030
  }
930
- }).catch(() => {});
1031
+ }).catch(() => { stopSpinner(); });
931
1032
 
932
1033
  // Send initial git info
933
1034
  sendGitInfoIfChanged(ctx.cwd);
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Handles server→extension messages by dispatching to pi API.
3
3
  */
4
- import { spawnSync } from "node:child_process";
4
+ import { readdirSync } from "node:fs";
5
+ import { join, relative } from "node:path";
5
6
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
7
  import type {
7
8
  ServerToExtensionMessage,
@@ -10,47 +11,33 @@ import type {
10
11
  import { killProcessByPgid } from "./process-scanner.js";
11
12
  import type { FileEntry, PiSessionInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
12
13
  import { filterHiddenCommands } from "./bridge-context.js";
14
+ import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
13
15
 
14
- /** Escape regex special characters for fd pattern */
15
- function escapeRegex(value: string): string {
16
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
- }
16
+ const IGNORE_DIRS = new Set([".git", "node_modules", ".next", "dist", "build", ".cache", "__pycache__", ".venv"]);
17
+ const MAX_RESULTS = 20;
18
18
 
19
- /** Search files using fd */
20
19
  function searchFiles(cwd: string, query: string): FileEntry[] {
21
- const args = [
22
- "--base-directory", cwd,
23
- "--max-results", "20",
24
- "--type", "f",
25
- "--type", "d",
26
- "--full-path",
27
- "--hidden",
28
- "--exclude", ".git",
29
- ];
30
-
31
- if (query) {
32
- args.push(escapeRegex(query));
33
- }
34
-
35
- try {
36
- const result = spawnSync("fd", args, {
37
- encoding: "utf-8",
38
- stdio: ["pipe", "pipe", "pipe"],
39
- timeout: 5000,
40
- });
41
-
42
- if (result.status !== 0 || !result.stdout) {
43
- return [];
20
+ const results: FileEntry[] = [];
21
+ const lowerQuery = query?.toLowerCase() ?? "";
22
+
23
+ function walk(dir: string, depth: number): void {
24
+ if (results.length >= MAX_RESULTS || depth > 6) return;
25
+ let entries;
26
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
27
+ for (const entry of entries) {
28
+ if (results.length >= MAX_RESULTS) return;
29
+ if (IGNORE_DIRS.has(entry.name)) continue;
30
+ const fullPath = join(dir, entry.name);
31
+ const relPath = relative(cwd, fullPath).replace(/\\/g, "/") + (entry.isDirectory() ? "/" : "");
32
+ if (!lowerQuery || relPath.toLowerCase().includes(lowerQuery)) {
33
+ results.push({ path: relPath, isDirectory: entry.isDirectory() });
34
+ }
35
+ if (entry.isDirectory()) walk(fullPath, depth + 1);
44
36
  }
45
-
46
- return result.stdout.trim().split("\n").filter(Boolean).map((line) => {
47
- const normalized = line.replace(/\\/g, "/");
48
- const isDirectory = normalized.endsWith("/");
49
- return { path: normalized, isDirectory };
50
- });
51
- } catch {
52
- return [];
53
37
  }
38
+
39
+ walk(cwd, 0);
40
+ return results;
54
41
  }
55
42
 
56
43
  /** Parsed result from parseSendPrompt */
@@ -288,8 +275,15 @@ export function createCommandHandler(
288
275
  return undefined;
289
276
  }
290
277
 
291
- // Passthrough: send as regular user message (with image handling)
292
- sendUserMessageWithImages(pi, msg.text, msg.images);
278
+ // Passthrough: send as regular user message (with image handling).
279
+ // Multi-line slash commands (e.g. "/skill:foo\nuser text") are classified as
280
+ // passthrough by parseSendPrompt to preserve images (the slash route strips them),
281
+ // so we expand prompt templates / skills here before sending.
282
+ let outgoing = msg.text;
283
+ if (outgoing.startsWith("/")) {
284
+ outgoing = expandPromptTemplateFromDisk(outgoing, process.cwd(), pi);
285
+ }
286
+ sendUserMessageWithImages(pi, outgoing, msg.images);
293
287
  return undefined;
294
288
  }
295
289
 
@@ -338,6 +332,7 @@ export function createCommandHandler(
338
332
  const registry = options?.getModelRegistry?.();
339
333
  if (registry) {
340
334
  try {
335
+ registry.authStorage?.reload?.();
341
336
  registry.refresh();
342
337
  const models = registry.getAvailable().map((m: any) => ({
343
338
  provider: m.provider,
@@ -2,7 +2,7 @@
2
2
  * Dev build-on-reload helper.
3
3
  * Builds the Vite client and requests server shutdown.
4
4
  */
5
- import { execSync as defaultExecSync } from "node:child_process";
5
+ import { execSync as defaultExecSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
6
6
 
7
7
  export interface DevBuildOptions {
8
8
  packageRoot: string;