@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1

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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  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__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -0
@@ -266,4 +266,72 @@ describe("detectOpenSpecActivity", () => {
266
266
  expect(result).toEqual({ changeName: "add-auth", isActive: true });
267
267
  });
268
268
  });
269
+
270
+ describe("non-slug-shaped change names (fix-uuid-rename-bug)", () => {
271
+ const UUID = "019df0aa-1234-5678-9abc-def012345678";
272
+
273
+ it("returns null for UUID-shaped path on Read", () => {
274
+ const result = detectOpenSpecActivity("read", {
275
+ path: `openspec/changes/${UUID}/proposal.md`,
276
+ });
277
+ expect(result).toBeNull();
278
+ });
279
+
280
+ it("returns null for UUID-shaped path on Write", () => {
281
+ const result = detectOpenSpecActivity("write", {
282
+ path: `openspec/changes/${UUID}/proposal.md`,
283
+ });
284
+ expect(result).toBeNull();
285
+ });
286
+
287
+ it("returns null for UUID-shaped CLI argument", () => {
288
+ const result = detectOpenSpecActivity("bash", {
289
+ command: `openspec archive ${UUID}`,
290
+ });
291
+ expect(result).toBeNull();
292
+ });
293
+
294
+ it("returns null for uppercase change name on Read", () => {
295
+ const result = detectOpenSpecActivity("read", {
296
+ path: "openspec/changes/AddAuth/proposal.md",
297
+ });
298
+ expect(result).toBeNull();
299
+ });
300
+
301
+ it("returns null for underscore-containing CLI argument", () => {
302
+ const result = detectOpenSpecActivity("bash", {
303
+ command: "openspec archive add_auth",
304
+ });
305
+ expect(result).toBeNull();
306
+ });
307
+
308
+ it("returns null for digit-prefixed CLI argument", () => {
309
+ const result = detectOpenSpecActivity("bash", {
310
+ command: "openspec archive 1bad",
311
+ });
312
+ expect(result).toBeNull();
313
+ });
314
+
315
+ it("returns null for token exceeding 64-character cap", () => {
316
+ const longName = "a".repeat(65);
317
+ const result = detectOpenSpecActivity("write", {
318
+ path: `openspec/changes/${longName}/spec.md`,
319
+ });
320
+ expect(result).toBeNull();
321
+ });
322
+
323
+ it("still extracts valid digit-containing kebab slug", () => {
324
+ const result = detectOpenSpecActivity("bash", {
325
+ command: "openspec archive valid-name-123",
326
+ });
327
+ expect(result).toEqual({ changeName: "valid-name-123", isActive: true });
328
+ });
329
+
330
+ it("still extracts valid slug from Write path", () => {
331
+ const result = detectOpenSpecActivity("write", {
332
+ path: "openspec/changes/fix-mobile-attach/proposal.md",
333
+ });
334
+ expect(result).toEqual({ changeName: "fix-mobile-attach", isActive: true });
335
+ });
336
+ });
269
337
  });
@@ -2,15 +2,23 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
2
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { expandPromptTemplateFromDisk } from "../prompt-expander.js";
5
+ import { parseSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
5
6
 
6
7
  const tmpDir = join(import.meta.dirname ?? __dirname, "__tmp_prompt_test__");
7
8
  const promptsDir = join(tmpDir, ".pi", "prompts");
9
+ const skillsDir = join(tmpDir, ".pi", "skills");
8
10
 
9
11
  beforeEach(() => {
10
12
  mkdirSync(promptsDir, { recursive: true });
11
13
  writeFileSync(join(promptsDir, "opsx-continue.md"), "---\ndescription: continue\n---\nContinue the change");
12
14
  writeFileSync(join(promptsDir, "opsx-apply.md"), "Apply the change");
13
15
  writeFileSync(join(promptsDir, "hello.md"), "Hello world");
16
+ // Skill fixture
17
+ mkdirSync(join(skillsDir, "my-skill"), { recursive: true });
18
+ writeFileSync(
19
+ join(skillsDir, "my-skill", "SKILL.md"),
20
+ "---\nname: my-skill\ndescription: A demo skill\n---\nFirst body line\nSecond body line",
21
+ );
14
22
  });
15
23
 
16
24
  afterEach(() => {
@@ -51,4 +59,41 @@ describe("expandPromptTemplateFromDisk", () => {
51
59
  expect(result).toBe("Continue the change");
52
60
  expect(result).not.toContain("---");
53
61
  });
62
+
63
+ // See change: render-skill-invocations-collapsibly.
64
+
65
+ it("wraps /skill:my-skill output in a <skill> envelope (with args)", () => {
66
+ const result = expandPromptTemplateFromDisk("/skill:my-skill do the thing", tmpDir);
67
+ expect(result.startsWith('<skill name="my-skill" location="')).toBe(true);
68
+ expect(result).toContain("References are relative to ");
69
+ expect(result).toContain("First body line\nSecond body line");
70
+ expect(result.endsWith("\n\ndo the thing")).toBe(true);
71
+ // round-trips through parseSkillBlock
72
+ const parsed = parseSkillBlock(result);
73
+ expect(parsed).not.toBeNull();
74
+ expect(parsed!.name).toBe("my-skill");
75
+ expect(parsed!.args).toBe("do the thing");
76
+ expect(parsed!.condensed).toBe("/skill:my-skill do the thing");
77
+ });
78
+
79
+ it("wraps /skill:my-skill output in a <skill> envelope (without args)", () => {
80
+ const result = expandPromptTemplateFromDisk("/skill:my-skill", tmpDir);
81
+ expect(result.startsWith('<skill name="my-skill" location="')).toBe(true);
82
+ expect(result.endsWith("</skill>")).toBe(true);
83
+ expect(result).not.toContain("</skill>\n\n");
84
+ const parsed = parseSkillBlock(result);
85
+ expect(parsed!.args).toBeUndefined();
86
+ expect(parsed!.condensed).toBe("/skill:my-skill");
87
+ });
88
+
89
+ it("prompt template /opsx-continue stays unwrapped (no <skill> tag)", () => {
90
+ const result = expandPromptTemplateFromDisk("/opsx-continue my-change", tmpDir);
91
+ expect(result).not.toContain("<skill name=");
92
+ expect(result).not.toContain("</skill>");
93
+ });
94
+
95
+ it("colon-alias prompt template /opsx:continue stays unwrapped", () => {
96
+ const result = expandPromptTemplateFromDisk("/opsx:continue x", tmpDir);
97
+ expect(result).not.toContain("<skill name=");
98
+ });
54
99
  });
@@ -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
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { resolveServerCliPath, buildSpawnArgs } from "../server-launcher.js";
2
+ import { resolveServerCliPath, buildSpawnArgs, buildSpawnEnv } from "../server-launcher.js";
3
3
  import { existsSync } from "node:fs";
4
4
  import path from "node:path";
5
5
 
@@ -58,4 +58,27 @@ describe("server-launcher", () => {
58
58
  expect(args).toEqual(["--port", "3000", "--pi-port", "4000"]);
59
59
  });
60
60
  });
61
+
62
+ describe("buildSpawnEnv", () => {
63
+ it("always includes DASHBOARD_STARTER=Bridge", () => {
64
+ const env = buildSpawnEnv({});
65
+ expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
66
+ });
67
+
68
+ it("overrides any existing DASHBOARD_STARTER in baseEnv", () => {
69
+ const env = buildSpawnEnv({ DASHBOARD_STARTER: "Standalone" });
70
+ expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
71
+ });
72
+
73
+ it("preserves other env vars from baseEnv", () => {
74
+ const env = buildSpawnEnv({ MY_VAR: "hello" });
75
+ expect(env["MY_VAR"]).toBe("hello");
76
+ expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
77
+ });
78
+
79
+ it("filters out undefined values from baseEnv", () => {
80
+ const env = buildSpawnEnv({ DEFINED: "yes", UNDEF: undefined });
81
+ expect(Object.keys(env)).not.toContain("UNDEF");
82
+ });
83
+ });
61
84
  });
@@ -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
 
@@ -2,7 +2,7 @@
2
2
  * Shared mutable state for bridge modules.
3
3
  * Avoids passing 14+ closure variables to every extracted function.
4
4
  */
5
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import type { ConnectionManager } from "./connection.js";
7
7
 
8
8
  export interface BridgeContext {