@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  import { expandPromptTemplateFromDisk } from "../prompt-expander.js";
5
5
  import { parseSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
6
6
 
@@ -96,4 +96,113 @@ describe("expandPromptTemplateFromDisk", () => {
96
96
  const result = expandPromptTemplateFromDisk("/opsx:continue x", tmpDir);
97
97
  expect(result).not.toContain("<skill name=");
98
98
  });
99
+
100
+ // Change: unify-opsx-colon-hyphen-aliases — symmetric : ↔ - resolution.
101
+
102
+ function makeSkillFile(relPath: string, body = "skill body"): string {
103
+ const abs = join(tmpDir, relPath);
104
+ mkdirSync(dirname(abs), { recursive: true });
105
+ writeFileSync(abs, `---\nname: ignored\n---\n${body}`);
106
+ return abs;
107
+ }
108
+
109
+ it("expands hyphen-typed slash command resolving a colon-registered pi.getCommands skill", () => {
110
+ const skillPath = makeSkillFile("registry/colon/SKILL.md");
111
+ const pi = {
112
+ getCommands: () => [{ name: "opsx:archive", source: "skill", path: skillPath }],
113
+ };
114
+ const result = expandPromptTemplateFromDisk("/opsx-archive my-change", tmpDir, pi);
115
+ expect(result.startsWith('<skill name="opsx:archive" location="')).toBe(true);
116
+ expect(result.endsWith("\n\nmy-change")).toBe(true);
117
+ const parsed = parseSkillBlock(result);
118
+ expect(parsed!.name).toBe("opsx:archive");
119
+ expect(parsed!.args).toBe("my-change");
120
+ });
121
+
122
+ it("expands colon-typed slash command resolving a hyphen-registered pi.getCommands skill", () => {
123
+ const skillPath = makeSkillFile("registry/hyphen/SKILL.md");
124
+ const pi = {
125
+ getCommands: () => [{ name: "opsx-archive", source: "skill", path: skillPath }],
126
+ };
127
+ const result = expandPromptTemplateFromDisk("/opsx:archive my-change", tmpDir, pi);
128
+ expect(result.startsWith('<skill name="opsx-archive" location="')).toBe(true);
129
+ expect(result.endsWith("\n\nmy-change")).toBe(true);
130
+ const parsed = parseSkillBlock(result);
131
+ expect(parsed!.name).toBe("opsx-archive");
132
+ });
133
+
134
+ it("expands colon-typed slash command resolving a hyphen-named local SKILL.md directory", () => {
135
+ mkdirSync(join(skillsDir, "opsx-archive"), { recursive: true });
136
+ writeFileSync(join(skillsDir, "opsx-archive", "SKILL.md"), "---\nname: x\n---\nbody");
137
+ const result = expandPromptTemplateFromDisk("/opsx:archive arg", tmpDir);
138
+ expect(result.startsWith('<skill name="opsx-archive" location="')).toBe(true);
139
+ const parsed = parseSkillBlock(result);
140
+ expect(parsed!.name).toBe("opsx-archive");
141
+ expect(parsed!.args).toBe("arg");
142
+ });
143
+
144
+ it("expands hyphen-typed slash command resolving a colon-named local SKILL.md directory", () => {
145
+ mkdirSync(join(skillsDir, "opsx:archive"), { recursive: true });
146
+ writeFileSync(join(skillsDir, "opsx:archive", "SKILL.md"), "---\nname: x\n---\nbody");
147
+ const result = expandPromptTemplateFromDisk("/opsx-archive arg", tmpDir);
148
+ expect(result.startsWith('<skill name="opsx:archive" location="')).toBe(true);
149
+ const parsed = parseSkillBlock(result);
150
+ expect(parsed!.name).toBe("opsx:archive");
151
+ });
152
+
153
+ it("original-form precedence: colon-typed prefers colon-registered skill over hyphen-form prompt template", () => {
154
+ // Local prompt opsx-foo.md exists; registry has skill opsx:foo.
155
+ writeFileSync(join(promptsDir, "opsx-foo.md"), "prompt body");
156
+ const skillPath = makeSkillFile("registry/precedence/SKILL.md", "skill body");
157
+ const pi = {
158
+ getCommands: () => [{ name: "opsx:foo", source: "skill", path: skillPath }],
159
+ };
160
+ // /opsx:foo → must wrap as skill (registry hit on original form).
161
+ const colon = expandPromptTemplateFromDisk("/opsx:foo", tmpDir, pi);
162
+ expect(colon.startsWith('<skill name="opsx:foo" location="')).toBe(true);
163
+ // /opsx-foo → must NOT wrap (local prompt hit on original form).
164
+ const hyphen = expandPromptTemplateFromDisk("/opsx-foo", tmpDir, pi);
165
+ expect(hyphen).not.toContain("<skill name=");
166
+ expect(hyphen).toContain("prompt body");
167
+ });
168
+
169
+ it("original-form-first across distinct pi.getCommands entries", () => {
170
+ const aPath = makeSkillFile("registry/A/SKILL.md", "A body");
171
+ const bPath = makeSkillFile("registry/B/SKILL.md", "B body");
172
+ const pi = {
173
+ getCommands: () => [
174
+ { name: "opsx:foo", source: "skill", path: aPath },
175
+ { name: "opsx-foo", source: "skill", path: bPath },
176
+ ],
177
+ };
178
+ const colon = expandPromptTemplateFromDisk("/opsx:foo arg", tmpDir, pi);
179
+ expect(colon).toContain(`location="${aPath}"`);
180
+ expect(colon).toContain('name="opsx:foo"');
181
+ expect(colon).not.toContain(`location="${bPath}"`);
182
+ const hyphen = expandPromptTemplateFromDisk("/opsx-foo arg", tmpDir, pi);
183
+ expect(hyphen).toContain(`location="${bPath}"`);
184
+ expect(hyphen).toContain('name="opsx-foo"');
185
+ expect(hyphen).not.toContain(`location="${aPath}"`);
186
+ });
187
+
188
+ it("original form in pi-registry beats remapped form in local-scan", () => {
189
+ // Local prompt opsx-foo.md exists; registry has skill opsx:foo.
190
+ writeFileSync(join(promptsDir, "opsx-foo.md"), "prompt body");
191
+ const skillPath = makeSkillFile("registry/outer/SKILL.md", "skill body");
192
+ const pi = {
193
+ getCommands: () => [{ name: "opsx:foo", source: "skill", path: skillPath }],
194
+ };
195
+ // /opsx:foo: outer-loop probes original form across ALL stores first.
196
+ // Step 3 hit on registry — must NOT fall through to remapped opsx-foo local prompt.
197
+ const result = expandPromptTemplateFromDisk("/opsx:foo", tmpDir, pi);
198
+ expect(result.startsWith('<skill name="opsx:foo" location="')).toBe(true);
199
+ expect(result).not.toContain("prompt body");
200
+ });
201
+
202
+ it("misspelled name with wrong separator returns input unchanged", () => {
203
+ rmSync(tmpDir, { recursive: true, force: true });
204
+ mkdirSync(tmpDir, { recursive: true });
205
+ const result = expandPromptTemplateFromDisk("/opsx:nonexistent foo", tmpDir);
206
+ expect(result).toBe("/opsx:nonexistent foo");
207
+ });
99
208
  });
@@ -367,6 +367,80 @@ describe("reloadProviders", () => {
367
367
  expect(opus.input).toEqual(["text", "image"]);
368
368
  });
369
369
 
370
+ // ── custom-flag race regression (see change: fix-custom-provider-flag-race) ──
371
+ // The bridge's first `providers_list` push fires from `session_start`
372
+ // shortly after `activate()` kicked off async `registerEntry()` calls.
373
+ // The catalogue's `custom: true` flag MUST be set on that first push,
374
+ // even when each provider's `/v1/models` endpoint hasn't responded yet —
375
+ // otherwise custom providers from `~/.pi/agent/providers.json` leak into
376
+ // Settings → Provider Authentication → API Keys (where they don't belong;
377
+ // the LLM Providers section already manages them).
378
+
379
+ it("custom flag is set on first providers_list push, before discoverModels resolves (regression)", async () => {
380
+ const mod = await importFresh();
381
+ const { pi } = makeMockPi();
382
+
383
+ // Capture event handlers so we can fire model_select to set modelRegistryRef.
384
+ const handlers = new Map<string, (event: any, ctx: any) => Promise<void> | void>();
385
+ pi.on = vi.fn((event: string, handler: any) => { handlers.set(event, handler); });
386
+
387
+ // Stub fetch with a never-resolving promise — simulates a slow or
388
+ // unreachable /v1/models endpoint. The fix's correctness does NOT depend
389
+ // on this resolving; the synchronous `lastRegistered.set` runs before the
390
+ // await.
391
+ let resolveFetch: ((value: Response) => void) | null = null;
392
+ globalThis.fetch = vi.fn(
393
+ () => new Promise<Response>((r) => { resolveFetch = r; }),
394
+ ) as any;
395
+
396
+ // Two custom providers. With the fix, both end up in lastRegistered
397
+ // synchronously when activate() iterates them.
398
+ writeProvidersJson(tmpHome, {
399
+ proxy: { baseUrl: "https://example.com/v1", apiKey: "sk-test", api: "openai-completions" },
400
+ "your-llmproxy": { baseUrl: "https://example2.com/v1", apiKey: "sk-test", api: "openai-completions" },
401
+ });
402
+
403
+ // activate() fires registerEntry async (.catch(() => {})). The synchronous
404
+ // body runs to the first await before yielding.
405
+ mod.activate(pi);
406
+
407
+ // Capture modelRegistry via a model_select event — buildProviderCatalogue()
408
+ // returns [] when modelRegistryRef is null. We use model_select rather
409
+ // than session_start because session_start would re-register every entry
410
+ // (also stalling on the never-resolving fetch).
411
+ const fakeRegistry = {
412
+ find: () => null,
413
+ getAll: () => [
414
+ { provider: "proxy", id: "some-model" },
415
+ { provider: "your-llmproxy", id: "some-model" },
416
+ { provider: "deepseek", id: "deepseek-chat" },
417
+ ],
418
+ getProviderDisplayName: (id: string) => id,
419
+ authStorage: {
420
+ getOAuthProviders: () => [],
421
+ getAuthStatus: () => ({ configured: false }),
422
+ get: () => undefined,
423
+ },
424
+ };
425
+ const modelSelectHandler = handlers.get("model_select");
426
+ expect(modelSelectHandler).toBeDefined();
427
+ await modelSelectHandler!({}, { modelRegistry: fakeRegistry, model: undefined });
428
+
429
+ // Build the catalogue while discovery is still in flight. With the fix,
430
+ // both custom providers are flagged custom: true. Without it, lastRegistered
431
+ // is still empty (the post-await `lastRegistered.set` never runs because
432
+ // fetch never resolves) and the flags are missing.
433
+ const cat = mod.buildProviderCatalogue();
434
+
435
+ expect(cat.find((c) => c.id === "proxy")?.custom).toBe(true);
436
+ expect(cat.find((c) => c.id === "your-llmproxy")?.custom).toBe(true);
437
+ // Built-in pi-ai providers must remain unflagged.
438
+ expect(cat.find((c) => c.id === "deepseek")?.custom).toBeUndefined();
439
+
440
+ // Cleanup: settle the dangling fetches so the test process doesn't leak.
441
+ if (resolveFetch) (resolveFetch as (value: Response) => void)(new Response(JSON.stringify({ data: [] }), { status: 200 }));
442
+ });
443
+
370
444
  it("discovered unknown model falls back to api-appropriate defaults (openai-completions → 128k)", async () => {
371
445
  const mod = await importFresh();
372
446
  const { pi, registerProvider } = makeMockPi();
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { RetryTracker, RETRYABLE_PATTERN } from "../retry-tracker.js";
3
+
4
+ describe("RetryTracker", () => {
5
+ it("synthesizes auto_retry_start on retryable assistant error", () => {
6
+ const t = new RetryTracker();
7
+ const ev = t.observeMessageEnd("s1", {
8
+ role: "assistant",
9
+ stopReason: "error",
10
+ errorMessage: "rate limit exceeded",
11
+ });
12
+ expect(ev).not.toBeNull();
13
+ expect(ev!.eventType).toBe("auto_retry_start");
14
+ expect(ev!.data).toEqual({
15
+ attempt: 1,
16
+ maxAttempts: -1,
17
+ delayMs: -1,
18
+ errorMessage: "rate limit exceeded",
19
+ });
20
+ expect(t.isRetrying("s1")).toBe(true);
21
+ });
22
+
23
+ it("does not synthesize for non-retryable error (e.g. context overflow)", () => {
24
+ const t = new RetryTracker();
25
+ const ev = t.observeMessageEnd("s1", {
26
+ role: "assistant",
27
+ stopReason: "error",
28
+ errorMessage: "prompt is too long: 300000 tokens > 200000 maximum",
29
+ });
30
+ expect(ev).toBeNull();
31
+ expect(t.isRetrying("s1")).toBe(false);
32
+ });
33
+
34
+ it("does not synthesize for non-assistant messages", () => {
35
+ const t = new RetryTracker();
36
+ expect(t.observeMessageEnd("s1", { role: "user" })).toBeNull();
37
+ expect(t.observeMessageEnd("s1", { role: "toolResult", stopReason: "error" })).toBeNull();
38
+ });
39
+
40
+ it("does not synthesize for missing or empty errorMessage", () => {
41
+ const t = new RetryTracker();
42
+ expect(t.observeMessageEnd("s1", { role: "assistant", stopReason: "error" })).toBeNull();
43
+ expect(
44
+ t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "" }),
45
+ ).toBeNull();
46
+ });
47
+
48
+ it("increments attempt counter across multiple retryable errors", () => {
49
+ const t = new RetryTracker();
50
+ const a = t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
51
+ const b = t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
52
+ const c = t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
53
+ expect((a!.data as any).attempt).toBe(1);
54
+ expect((b!.data as any).attempt).toBe(2);
55
+ expect((c!.data as any).attempt).toBe(3);
56
+ });
57
+
58
+ it("synthesizes auto_retry_end success on successful assistant message_end after retry", () => {
59
+ const t = new RetryTracker();
60
+ t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
61
+ const ev = t.observeMessageEnd("s1", { role: "assistant", stopReason: "end_turn" });
62
+ expect(ev).not.toBeNull();
63
+ expect(ev!.eventType).toBe("auto_retry_end");
64
+ expect(ev!.data).toEqual({ success: true, attempt: 1 });
65
+ expect(t.isRetrying("s1")).toBe(false);
66
+ });
67
+
68
+ it("does not synthesize auto_retry_end when no retry was tracked", () => {
69
+ const t = new RetryTracker();
70
+ expect(t.observeMessageEnd("s1", { role: "assistant", stopReason: "end_turn" })).toBeNull();
71
+ });
72
+
73
+ it("synthesizes auto_retry_end failure on agent_end with terminal error", () => {
74
+ const t = new RetryTracker();
75
+ t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "rate limit" });
76
+ t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "rate limit" });
77
+ const ev = t.observeAgentEnd("s1", {
78
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "Rate limit exceeded permanently" }],
79
+ });
80
+ expect(ev).not.toBeNull();
81
+ expect(ev!.eventType).toBe("auto_retry_end");
82
+ expect(ev!.data).toEqual({
83
+ success: false,
84
+ attempt: 2,
85
+ finalError: "Rate limit exceeded permanently",
86
+ });
87
+ expect(t.isRetrying("s1")).toBe(false);
88
+ });
89
+
90
+ it("synthesizes auto_retry_end success on agent_end with non-error terminal message", () => {
91
+ const t = new RetryTracker();
92
+ t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
93
+ const ev = t.observeAgentEnd("s1", {
94
+ messages: [{ role: "assistant", stopReason: "end_turn" }],
95
+ });
96
+ expect(ev).not.toBeNull();
97
+ expect((ev!.data as any).success).toBe(true);
98
+ });
99
+
100
+ it("agent_end without prior retry returns null", () => {
101
+ const t = new RetryTracker();
102
+ expect(t.observeAgentEnd("s1", { messages: [] })).toBeNull();
103
+ });
104
+
105
+ it("noteAbort clears tracker so subsequent agent_end does not double-emit", () => {
106
+ const t = new RetryTracker();
107
+ t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
108
+ t.noteAbort("s1");
109
+ expect(t.isRetrying("s1")).toBe(false);
110
+ expect(t.observeAgentEnd("s1", { messages: [] })).toBeNull();
111
+ });
112
+
113
+ it("scopes retry state per-session", () => {
114
+ const t = new RetryTracker();
115
+ t.observeMessageEnd("s1", { role: "assistant", stopReason: "error", errorMessage: "429" });
116
+ expect(t.isRetrying("s1")).toBe(true);
117
+ expect(t.isRetrying("s2")).toBe(false);
118
+ });
119
+
120
+ it.each([
121
+ "rate limit exceeded",
122
+ "Rate Limit hit",
123
+ "overloaded_error",
124
+ "too many requests",
125
+ "HTTP 429",
126
+ "HTTP 500 Internal Server Error",
127
+ "service unavailable",
128
+ "fetch failed",
129
+ "socket hang up",
130
+ "connection refused",
131
+ "connection lost",
132
+ "request timed out",
133
+ "terminated",
134
+ "retry delay exceeded",
135
+ ])("RETRYABLE_PATTERN matches: %s", (msg) => {
136
+ expect(RETRYABLE_PATTERN.test(msg)).toBe(true);
137
+ });
138
+
139
+ it.each([
140
+ "prompt is too long: 300000 tokens > 200000 maximum",
141
+ "tool execution failed",
142
+ "invalid input",
143
+ "",
144
+ ])("RETRYABLE_PATTERN does NOT match: %s", (msg) => {
145
+ expect(RETRYABLE_PATTERN.test(msg)).toBe(false);
146
+ });
147
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Pins the Bridge → `launchDashboardServer` forwarding contract: the
3
+ * extension's `launchServer` must always pass `starter: "Bridge"`,
4
+ * `stdio: "ignore"`, and `healthTimeoutMs: 2_000`. The shared
5
+ * launcher is mocked so this test never spawns a real server.
6
+ *
7
+ * See change: unify-server-launch-ts-loader (§3.1.2).
8
+ */
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+ import type { launchDashboardServer } from "@blackbelt-technology/pi-dashboard-shared/server-launcher.js";
11
+
12
+ const { launchSpy } = vi.hoisted(() => ({
13
+ launchSpy: vi.fn<typeof launchDashboardServer>(async () => ({ childPid: 1, reportedPid: 1, healthOk: true as const })),
14
+ }));
15
+
16
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/server-launcher.js", () => ({
17
+ launchDashboardServer: launchSpy,
18
+ JitiNotFoundError: class JitiNotFoundError extends Error {},
19
+ PortConflictError: class PortConflictError extends Error {},
20
+ EarlyExitError: class EarlyExitError extends Error { code: number | null = null; },
21
+ }));
22
+
23
+ import { launchServer } from "../server-launcher.js";
24
+
25
+ const cfg = {
26
+ port: 3000,
27
+ piPort: 4000,
28
+ autoStart: true,
29
+ autoShutdown: true,
30
+ shutdownIdleSeconds: 300,
31
+ spawnStrategy: "tmux" as const,
32
+ tunnel: { enabled: true },
33
+ devBuildOnReload: false,
34
+ memoryLimits: { maxEventsPerSession: 5000, maxStringFieldSize: 0, maxWsBufferBytes: 4194304 },
35
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
36
+ defaultModel: "",
37
+ trustedNetworks: [],
38
+ resolvedTrustedNetworks: [],
39
+ cors: { allowedOrigins: [] },
40
+ electronMode: false,
41
+ } as any;
42
+
43
+ beforeEach(() => {
44
+ launchSpy.mockClear();
45
+ launchSpy.mockResolvedValue({ childPid: 1, reportedPid: 1, healthOk: true } as any);
46
+ });
47
+
48
+ describe("Bridge launchServer → launchDashboardServer forwarding", () => {
49
+ it("passes starter:Bridge + stdio:ignore + 2s health timeout + port", async () => {
50
+ const r = await launchServer(cfg);
51
+ expect(r.success).toBe(true);
52
+ expect(launchSpy).toHaveBeenCalledOnce();
53
+ const opts = launchSpy.mock.calls[0]![0]!;
54
+ expect(opts.starter).toBe("Bridge");
55
+ expect(opts.stdio).toBe("ignore");
56
+ expect(opts.healthTimeoutMs).toBe(2000);
57
+ expect(opts.port).toBe(3000);
58
+ expect(opts.extraArgs).toEqual(["--port", "3000", "--pi-port", "4000"]);
59
+ });
60
+
61
+ it("maps JitiNotFoundError to a failed LaunchResult (no throw)", async () => {
62
+ const { JitiNotFoundError } = await import("@blackbelt-technology/pi-dashboard-shared/server-launcher.js");
63
+ launchSpy.mockRejectedValueOnce(new JitiNotFoundError("loader missing"));
64
+ const r = await launchServer(cfg);
65
+ expect(r.success).toBe(false);
66
+ expect(r.message).toContain("loader missing");
67
+ });
68
+
69
+ it("maps EarlyExitError to a failed LaunchResult mentioning the exit code", async () => {
70
+ const { EarlyExitError } = await import("@blackbelt-technology/pi-dashboard-shared/server-launcher.js");
71
+ const err = new (EarlyExitError as unknown as new (...args: unknown[]) => Error & { code: number })();
72
+ err.code = 17;
73
+ launchSpy.mockRejectedValueOnce(err);
74
+ const r = await launchServer(cfg);
75
+ expect(r.success).toBe(false);
76
+ expect(r.message).toMatch(/code=17/);
77
+ });
78
+ });
@@ -133,3 +133,75 @@ describe("handleSessionChange", () => {
133
133
  expect(registerMsg.registerReason).toBe("spawn");
134
134
  });
135
135
  });
136
+
137
+ // See change: spawn-correlation-token — bridge token inclusion contract.
138
+ describe("sendStateSync: spawnToken from env", () => {
139
+ const ENV_VAR = "PI_DASHBOARD_SPAWN_TOKEN";
140
+
141
+ function withEnvVar<T>(value: string | undefined, fn: () => T): T {
142
+ const prior = process.env[ENV_VAR];
143
+ if (value === undefined) delete process.env[ENV_VAR];
144
+ else process.env[ENV_VAR] = value;
145
+ try {
146
+ return fn();
147
+ } finally {
148
+ if (prior === undefined) delete process.env[ENV_VAR];
149
+ else process.env[ENV_VAR] = prior;
150
+ }
151
+ }
152
+
153
+ it("first register includes spawnToken from env", () => {
154
+ withEnvVar("tok_first", () => {
155
+ const bc = createMockBridgeContext({ hasRegisteredOnce: false } as any);
156
+ sendStateSync(bc, () => []);
157
+ const sent = (bc as any)._sent;
158
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
159
+ expect(registerMsg.spawnToken).toBe("tok_first");
160
+ expect(registerMsg.registerReason).toBe("spawn");
161
+ });
162
+ });
163
+
164
+ it("reattach register omits spawnToken (even when env still set)", () => {
165
+ withEnvVar("tok_first", () => {
166
+ const bc = createMockBridgeContext({ hasRegisteredOnce: true } as any);
167
+ sendStateSync(bc, () => []);
168
+ const sent = (bc as any)._sent;
169
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
170
+ expect(registerMsg.spawnToken).toBeUndefined();
171
+ expect(registerMsg.registerReason).toBe("reattach");
172
+ });
173
+ });
174
+
175
+ it("first register without env var omits spawnToken", () => {
176
+ withEnvVar(undefined, () => {
177
+ const bc = createMockBridgeContext({ hasRegisteredOnce: false } as any);
178
+ sendStateSync(bc, () => []);
179
+ const sent = (bc as any)._sent;
180
+ const registerMsg = sent.find((m: any) => m.type === "session_register");
181
+ expect(registerMsg.spawnToken).toBeUndefined();
182
+ expect(registerMsg.registerReason).toBe("spawn");
183
+ });
184
+ });
185
+
186
+ it("handleSessionChange register omits spawnToken (in-process new/fork/resume)", () => {
187
+ withEnvVar("tok_first", () => {
188
+ const bc = createMockBridgeContext({ hasRegisteredOnce: true } as any);
189
+ const ctx = {
190
+ cwd: "/proj",
191
+ sessionManager: {
192
+ getSessionId: () => "sess-fork",
193
+ getSessionFile: () => "/path/new.json",
194
+ getSessionDir: () => "/path",
195
+ getBranch: () => [],
196
+ getEntries: () => [],
197
+ },
198
+ };
199
+ handleSessionChange(bc, ctx as any, () => []);
200
+ const sent = (bc as any)._sent;
201
+ const registerMsg = sent.find((m: any) => m.type === "session_register" && m.sessionId === "sess-fork");
202
+ expect(registerMsg).toBeDefined();
203
+ expect(registerMsg.spawnToken).toBeUndefined();
204
+ expect(registerMsg.registerReason).toBe("spawn");
205
+ });
206
+ });
207
+ });
@@ -0,0 +1,105 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { UsageLimitOrderer, USAGE_LIMIT_PATTERN } from "../usage-limit-orderer.js";
3
+
4
+ describe("UsageLimitOrderer", () => {
5
+ it("returns null when no retry was pending", () => {
6
+ const o = new UsageLimitOrderer();
7
+ const result = o.maybeSynthesize("s1", {
8
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
9
+ });
10
+ expect(result).toBeNull();
11
+ });
12
+
13
+ it("returns null when retry was pending but error is not a usage-limit", () => {
14
+ const o = new UsageLimitOrderer();
15
+ o.noteRetryStart("s1");
16
+ const result = o.maybeSynthesize("s1", {
17
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "tool execution failed" }],
18
+ });
19
+ expect(result).toBeNull();
20
+ });
21
+
22
+ it("returns null on a non-error agent_end even with pending retry", () => {
23
+ const o = new UsageLimitOrderer();
24
+ o.noteRetryStart("s1");
25
+ const result = o.maybeSynthesize("s1", {
26
+ messages: [{ role: "assistant", stopReason: "end_turn" }],
27
+ });
28
+ expect(result).toBeNull();
29
+ });
30
+
31
+ it("synthesizes auto_retry_end on usage_limit_reached when retry was pending", () => {
32
+ const o = new UsageLimitOrderer();
33
+ o.noteRetryStart("s1");
34
+ const result = o.maybeSynthesize("s1", {
35
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached: 5000 RPM" }],
36
+ });
37
+ expect(result).not.toBeNull();
38
+ expect(result!.eventType).toBe("auto_retry_end");
39
+ expect(result!.data).toEqual({ success: false, attempt: -1, finalError: "usage_limit_reached: 5000 RPM" });
40
+ });
41
+
42
+ it.each([
43
+ "usage_limit_reached",
44
+ "usage_not_included",
45
+ "quota_exceeded",
46
+ "monthly limit reached for free tier",
47
+ "hourly limit hit",
48
+ "Your quota will reset after 18h31m10s",
49
+ ])("matches usage-limit variant: %s", (msg) => {
50
+ expect(USAGE_LIMIT_PATTERN.test(msg)).toBe(true);
51
+ });
52
+
53
+ it.each([
54
+ "rate limit exceeded",
55
+ "overloaded_error",
56
+ "tool execution failed",
57
+ "fetch failed",
58
+ "",
59
+ ])("does not match non-usage-limit variant: %s", (msg) => {
60
+ expect(USAGE_LIMIT_PATTERN.test(msg)).toBe(false);
61
+ });
62
+
63
+ it("clears pending after agent_end (no double-synthesis on subsequent agent_end)", () => {
64
+ const o = new UsageLimitOrderer();
65
+ o.noteRetryStart("s1");
66
+ const first = o.maybeSynthesize("s1", {
67
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
68
+ });
69
+ expect(first).not.toBeNull();
70
+ // Same payload again — pending was cleared, so no synthesis.
71
+ const second = o.maybeSynthesize("s1", {
72
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
73
+ });
74
+ expect(second).toBeNull();
75
+ });
76
+
77
+ it("noteRetryEnd clears pending so subsequent agent_end does not synthesize", () => {
78
+ const o = new UsageLimitOrderer();
79
+ o.noteRetryStart("s1");
80
+ o.noteRetryEnd("s1");
81
+ expect(o.hasPending("s1")).toBe(false);
82
+ const result = o.maybeSynthesize("s1", {
83
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
84
+ });
85
+ expect(result).toBeNull();
86
+ });
87
+
88
+ it("scopes pending state per-session", () => {
89
+ const o = new UsageLimitOrderer();
90
+ o.noteRetryStart("s1");
91
+ expect(o.hasPending("s2")).toBe(false);
92
+ const result = o.maybeSynthesize("s2", {
93
+ messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
94
+ });
95
+ expect(result).toBeNull();
96
+ });
97
+
98
+ it("returns null on missing or empty messages array", () => {
99
+ const o = new UsageLimitOrderer();
100
+ o.noteRetryStart("s1");
101
+ expect(o.maybeSynthesize("s1", {})).toBeNull();
102
+ o.noteRetryStart("s1");
103
+ expect(o.maybeSynthesize("s1", { messages: [] })).toBeNull();
104
+ });
105
+ });
@@ -5,7 +5,7 @@
5
5
  * static tool-name conflicts with other extensions (e.g. pi-flows) that also
6
6
  * register ask_user. Runtime registration bypasses detectExtensionConflicts.
7
7
  */
8
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import { Type } from "typebox";
10
10
  import { polyfillMultiselect } from "./multiselect-polyfill.js";
11
11