@blackbelt-technology/pi-agent-dashboard 0.5.0 → 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 (102) hide show
  1. package/README.md +19 -7
  2. package/package.json +13 -13
  3. package/packages/extension/package.json +11 -3
  4. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  5. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  7. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  8. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  9. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  10. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  11. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  12. package/packages/extension/src/ask-user-tool.ts +1 -1
  13. package/packages/extension/src/bridge-context.ts +1 -1
  14. package/packages/extension/src/bridge.ts +59 -3
  15. package/packages/extension/src/command-handler.ts +59 -2
  16. package/packages/extension/src/flow-event-wiring.ts +1 -1
  17. package/packages/extension/src/multiselect-list.ts +1 -1
  18. package/packages/extension/src/pi-env.d.ts +16 -9
  19. package/packages/extension/src/provider-register.ts +16 -9
  20. package/packages/extension/src/retry-tracker.ts +123 -0
  21. package/packages/extension/src/session-sync.ts +10 -1
  22. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  23. package/packages/server/package.json +6 -6
  24. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  25. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  26. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  27. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  28. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  29. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  30. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  31. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  33. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  34. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  35. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  36. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  37. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  38. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  39. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  40. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  41. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  42. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  43. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  44. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  45. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  46. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  47. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  48. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  49. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  50. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  52. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  53. package/packages/server/src/browser-gateway.ts +12 -3
  54. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  55. package/packages/server/src/browser-handlers/session-action-handler.ts +100 -17
  56. package/packages/server/src/changelog-fs.ts +167 -0
  57. package/packages/server/src/changelog-parser.ts +321 -0
  58. package/packages/server/src/changelog-remote.ts +134 -0
  59. package/packages/server/src/cli.ts +2 -2
  60. package/packages/server/src/event-wiring.ts +59 -5
  61. package/packages/server/src/headless-pid-registry.ts +54 -5
  62. package/packages/server/src/pending-client-correlations.ts +73 -0
  63. package/packages/server/src/pending-fork-registry.ts +24 -12
  64. package/packages/server/src/pi-core-checker.ts +77 -17
  65. package/packages/server/src/pi-core-updater.ts +16 -6
  66. package/packages/server/src/pi-dev-version-check.ts +145 -0
  67. package/packages/server/src/pi-gateway.ts +4 -0
  68. package/packages/server/src/pi-version-skew.ts +12 -4
  69. package/packages/server/src/process-manager.ts +54 -11
  70. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  71. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  72. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  73. package/packages/server/src/routes/provider-auth-routes.ts +5 -1
  74. package/packages/server/src/routes/provider-routes.ts +4 -4
  75. package/packages/server/src/server.ts +77 -59
  76. package/packages/server/src/session-api.ts +54 -3
  77. package/packages/server/src/session-discovery.ts +1 -1
  78. package/packages/server/src/session-file-reader.ts +1 -1
  79. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  80. package/packages/server/src/spawn-token.ts +20 -0
  81. package/packages/shared/package.json +1 -1
  82. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  83. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  84. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  85. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  86. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  87. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  88. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  89. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  90. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  91. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  92. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  93. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  94. package/packages/shared/src/bootstrap-install.ts +1 -1
  95. package/packages/shared/src/browser-protocol.ts +43 -0
  96. package/packages/shared/src/changelog-types.ts +111 -0
  97. package/packages/shared/src/platform/node-spawn.ts +29 -21
  98. package/packages/shared/src/protocol.ts +8 -0
  99. package/packages/shared/src/resolve-jiti.ts +62 -9
  100. package/packages/shared/src/skill-block-parser.ts +1 -1
  101. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  102. package/packages/shared/src/types.ts +7 -0
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm](https://img.shields.io/npm/v/@blackbelt-technology/pi-agent-dashboard)](https://www.npmjs.com/package/@blackbelt-technology/pi-agent-dashboard)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- A web-based dashboard for monitoring and interacting with [pi](https://github.com/badlogic/pi-mono) agent sessions from any browser, including mobile.
7
+ **One browser tab to command an army of [pi](https://github.com/badlogic/pi-mono) agents.** Spawn parallel sessions, watch reasoning live, attach OpenSpec changes, ship work — from your laptop or phone.
8
8
 
9
9
  🌐 **Website & demo:** [blackbelttechnology.github.io/pi-agent-dashboard](https://blackbelttechnology.github.io/pi-agent-dashboard) — animated tour, screenshots, and install guide.
10
10
  📝 **Changelog:** [`CHANGELOG.md`](CHANGELOG.md)
@@ -13,6 +13,18 @@ A web-based dashboard for monitoring and interacting with [pi](https://github.co
13
13
 
14
14
  ---
15
15
 
16
+ ## Screenshots
17
+
18
+ <table>
19
+ <tr>
20
+ <td width="33%" align="center"><a href="docs/screenshots/readme-overview.png"><img src="docs/screenshots/readme-overview.png" alt="Sessions overview — folders, branches, OpenSpec changes, live token spend" /></a><br/><sub><b>Overview</b> — sessions grouped by folder, branch & OpenSpec context, live cost</sub></td>
21
+ <td width="33%" align="center"><a href="docs/screenshots/readme-session.png"><img src="docs/screenshots/readme-session.png" alt="Active session — chat, attached OpenSpec change, ask_user prompt, token gauge" /></a><br/><sub><b>Session</b> — chat, OpenSpec apply, interactive <code>ask_user</code>, context gauge</sub></td>
22
+ <td width="33%" align="center"><a href="docs/screenshots/readme-settings.png"><img src="docs/screenshots/readme-settings.png" alt="Settings — ports, spawn strategy, tunnel, resolved tools table" /></a><br/><sub><b>Settings</b> — ports, spawn strategy, zrok tunnel, tool resolution</sub></td>
23
+ </tr>
24
+ </table>
25
+
26
+ ---
27
+
16
28
  ## Table of contents
17
29
 
18
30
  - [Quickstart](#quickstart)
@@ -44,7 +56,7 @@ Download a pre-built installer from [GitHub Releases](https://github.com/BlackBe
44
56
  |----------|----------|
45
57
  | **macOS** (Apple Silicon / Intel) | `.dmg` (arm64 / x64) |
46
58
  | **Linux** (x64 / ARM64) | `.deb` or `.AppImage` |
47
- | **Windows** (x64 / ARM64) | `.exe` (NSIS), `.zip`, or portable `.exe` |
59
+ | **Windows** (x64 / ARM64) | `.zip` |
48
60
 
49
61
  On first launch a setup wizard walks you through mode selection (standalone vs. power-user), API key / OAuth sign-in, and [recommended extensions](#recommended-extensions). The standalone mode bundles Node.js and auto-installs pi + dashboard + openspec into `~/.pi-dashboard/` — **no terminal, npm, or Node.js required**.
50
62
 
@@ -640,7 +652,7 @@ Output by platform:
640
652
  |----------|--------|----------|
641
653
  | macOS | `.dmg` | `packages/electron/out/make/` |
642
654
  | Linux | `.deb` + `.AppImage` | `packages/electron/out/make/` |
643
- | Windows | `.exe` (NSIS) + `.zip` + portable `.exe` | `packages/electron/out/make/` |
655
+ | Windows | `.zip` | `packages/electron/out/make/` |
644
656
 
645
657
  ### Cross-platform builds (Docker)
646
658
 
@@ -649,7 +661,7 @@ From macOS or Linux, build installers for all platforms:
649
661
  ```bash
650
662
  npm run electron:build -- --all # macOS (native) + Linux + Windows (Docker)
651
663
  npm run electron:build -- --linux # Linux .deb + .AppImage only
652
- npm run electron:build -- --windows # Windows .exe (NSIS) only
664
+ npm run electron:build -- --windows # Windows .zip only
653
665
  npm run electron:build -- --linux --windows # Both, skip native
654
666
  ```
655
667
 
@@ -663,7 +675,7 @@ npm run electron:build -- --mac-both
663
675
 
664
676
  Requires Rosetta 2 (`softwareupdate --install-rosetta --agree-to-license`) so node-pty's x64 prebuilt binary can be unpacked during the cross-arch run. The script wipes per-arch caches between the two builds (`resources/.last-arch` sentinel) so back-to-back runs don't accidentally ship arm64 binaries inside an x64 DMG. Intel macs cannot cross-build arm64 locally (Rosetta is one-way) — use CI for arm64 validation.
665
677
 
666
- Docker builds use a Node 22 Debian container with NSIS installed for Windows cross-compilation. Output goes to `packages/electron/out/make/`.
678
+ Docker builds use a Node 22 Debian container for Windows cross-compilation. Output goes to `packages/electron/out/make/`.
667
679
 
668
680
  ### Electron dev mode
669
681
 
@@ -718,8 +730,8 @@ This runs CI, publishes to npm with `--provenance` for supply-chain transparency
718
730
  | `macos-15-intel` | macOS x64 | `.dmg` (Intel; last GitHub-hosted x86_64 image, EOL 2027-08) |
719
731
  | `ubuntu-latest` | Linux x64 | `.deb` + `.AppImage` |
720
732
  | `ubuntu-24.04-arm` | Linux arm64 | `.deb` |
721
- | `windows-latest` | Windows x64 | `.exe` (NSIS) + `.zip` + portable |
722
- | `windows-latest` | Windows arm64 | `.zip` + portable (x64 Node.js via WoW64) |
733
+ | `windows-latest` | Windows x64 | `.zip` |
734
+ | `windows-latest` | Windows arm64 | `.zip` (x64 Node.js via WoW64) |
723
735
 
724
736
  All artifacts are uploaded to a **draft GitHub Release**. Release notes are extracted automatically from the matching `## [<version>]` section of [`CHANGELOG.md`](CHANGELOG.md).
725
737
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-agent-dashboard",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Web dashboard for monitoring and interacting with pi agent sessions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -73,9 +73,9 @@
73
73
  "node": ">=22.12.0 <25"
74
74
  },
75
75
  "dependencies": {
76
- "@blackbelt-technology/pi-dashboard-extension": "^0.5.0",
77
- "@blackbelt-technology/pi-dashboard-server": "^0.5.0",
78
- "@blackbelt-technology/pi-dashboard-web": "^0.5.0"
76
+ "@blackbelt-technology/pi-dashboard-extension": "^0.5.1",
77
+ "@blackbelt-technology/pi-dashboard-server": "^0.5.1",
78
+ "@blackbelt-technology/pi-dashboard-web": "^0.5.1"
79
79
  },
80
80
  "optionalDependencies": {
81
81
  "appdmg": "^0.6.6"
@@ -87,31 +87,31 @@
87
87
  "vitest": "^4.0.0"
88
88
  },
89
89
  "peerDependencies": {
90
+ "@earendil-works/pi-ai": "*",
91
+ "@earendil-works/pi-coding-agent": "*",
92
+ "@earendil-works/pi-tui": "*",
90
93
  "@mariozechner/pi-ai": "*",
91
94
  "@mariozechner/pi-coding-agent": "*",
92
95
  "@mariozechner/pi-tui": "*",
93
- "@oh-my-pi/pi-ai": "*",
94
- "@oh-my-pi/pi-coding-agent": "*",
95
- "@oh-my-pi/pi-tui": "*",
96
96
  "typebox": "*"
97
97
  },
98
98
  "peerDependenciesMeta": {
99
- "@mariozechner/pi-coding-agent": {
99
+ "@earendil-works/pi-coding-agent": {
100
100
  "optional": true
101
101
  },
102
- "@mariozechner/pi-ai": {
102
+ "@earendil-works/pi-ai": {
103
103
  "optional": true
104
104
  },
105
- "@mariozechner/pi-tui": {
105
+ "@earendil-works/pi-tui": {
106
106
  "optional": true
107
107
  },
108
- "@oh-my-pi/pi-coding-agent": {
108
+ "@mariozechner/pi-coding-agent": {
109
109
  "optional": true
110
110
  },
111
- "@oh-my-pi/pi-ai": {
111
+ "@mariozechner/pi-ai": {
112
112
  "optional": true
113
113
  },
114
- "@oh-my-pi/pi-tui": {
114
+ "@mariozechner/pi-tui": {
115
115
  "optional": true
116
116
  },
117
117
  "typebox": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-extension",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Pi bridge extension for pi-dashboard",
5
5
  "type": "module",
6
6
  "repository": {
@@ -24,15 +24,23 @@
24
24
  ".pi/skills/pi-dashboard/"
25
25
  ],
26
26
  "dependencies": {
27
- "@blackbelt-technology/pi-dashboard-shared": "^0.5.0",
27
+ "@blackbelt-technology/pi-dashboard-shared": "^0.5.1",
28
28
  "ws": "^8.18.0"
29
29
  },
30
30
  "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*",
31
33
  "@mariozechner/pi-coding-agent": "*",
32
34
  "@mariozechner/pi-tui": "*",
33
35
  "typebox": "*"
34
36
  },
35
37
  "peerDependenciesMeta": {
38
+ "@earendil-works/pi-coding-agent": {
39
+ "optional": true
40
+ },
41
+ "@earendil-works/pi-tui": {
42
+ "optional": true
43
+ },
36
44
  "@mariozechner/pi-coding-agent": {
37
45
  "optional": true
38
46
  },
@@ -41,7 +49,7 @@
41
49
  }
42
50
  },
43
51
  "devDependencies": {
44
- "@mariozechner/pi-tui": "*",
52
+ "@earendil-works/pi-tui": "*",
45
53
  "@types/ws": "^8.18.1",
46
54
  "typebox": "^1.1.33"
47
55
  }
@@ -12,7 +12,7 @@ vi.mock("typebox", () => ({
12
12
  },
13
13
  }));
14
14
 
15
- vi.mock("@mariozechner/pi-ai", () => ({
15
+ vi.mock("@earendil-works/pi-ai", () => ({
16
16
  StringEnum: vi.fn(() => ({})),
17
17
  }));
18
18
 
@@ -201,6 +201,74 @@ describe("CommandHandler", () => {
201
201
  await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
202
202
  });
203
203
 
204
+ it("abort schedules persistent-abort retries until isIdle returns true", async () => {
205
+ // See change: fix-provider-retry-infinite-loop.
206
+ vi.useFakeTimers();
207
+ const pi = createMockPi();
208
+ const abort = vi.fn();
209
+ let idleAfter = 3; // become idle after 3 polls
210
+ const isIdle = vi.fn(() => --idleAfter <= 0);
211
+ const handler = createCommandHandler(pi as any, "s1", { abort, isIdle, eventSink: vi.fn() });
212
+
213
+ await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
214
+ expect(abort).toHaveBeenCalledOnce();
215
+
216
+ // Advance through the persistent-abort schedule. Each 200ms tick
217
+ // checks isIdle first, then calls abort if not idle.
218
+ vi.advanceTimersByTime(200); // tick 1: idleAfter 3→2, abort
219
+ vi.advanceTimersByTime(200); // tick 2: idleAfter 2→1, abort
220
+ vi.advanceTimersByTime(200); // tick 3: idleAfter 1→0, isIdle true, no abort, scheduler stops
221
+ vi.advanceTimersByTime(1000); // no more aborts
222
+
223
+ expect(abort.mock.calls.length).toBe(3); // initial + 2 retries
224
+ vi.useRealTimers();
225
+ });
226
+
227
+ it("persistent-abort scheduler stops after 2 seconds even if never idle", async () => {
228
+ vi.useFakeTimers();
229
+ const pi = createMockPi();
230
+ const abort = vi.fn();
231
+ const isIdle = vi.fn(() => false); // never idle
232
+ const handler = createCommandHandler(pi as any, "s1", { abort, isIdle, eventSink: vi.fn() });
233
+
234
+ await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
235
+
236
+ vi.advanceTimersByTime(2500); // safely past 2s cap
237
+ // initial + ~10 retries (2000ms / 200ms)
238
+ const calls = abort.mock.calls.length;
239
+ expect(calls).toBeGreaterThanOrEqual(10);
240
+ expect(calls).toBeLessThanOrEqual(11);
241
+
242
+ // Past cap, no more calls
243
+ const before = abort.mock.calls.length;
244
+ vi.advanceTimersByTime(1000);
245
+ expect(abort.mock.calls.length).toBe(before);
246
+ vi.useRealTimers();
247
+ });
248
+
249
+ it("abort synthesizes auto_retry_end event after invoking abort callback (provider-retry-state)", async () => {
250
+ // See change: fix-provider-retry-infinite-loop.
251
+ const pi = createMockPi();
252
+ const calls: Array<{ name: string; arg?: unknown }> = [];
253
+ const abort = vi.fn(() => calls.push({ name: "abort" }));
254
+ const eventSink = vi.fn((m: unknown) => calls.push({ name: "eventSink", arg: m }));
255
+ const handler = createCommandHandler(pi as any, "s1", { abort, eventSink });
256
+
257
+ await handler.handle({ type: "abort", sessionId: "s1" } as ServerToExtensionMessage);
258
+
259
+ expect(abort).toHaveBeenCalledOnce();
260
+ expect(eventSink).toHaveBeenCalledOnce();
261
+ // Order: abort() first, then synthesized event
262
+ expect(calls[0]!.name).toBe("abort");
263
+ expect(calls[1]!.name).toBe("eventSink");
264
+ const evt = (calls[1]!.arg as any);
265
+ expect(evt.type).toBe("event_forward");
266
+ expect(evt.sessionId).toBe("s1");
267
+ expect(evt.event.eventType).toBe("auto_retry_end");
268
+ expect(evt.event.data).toEqual({ success: false, attempt: -1, finalError: "Aborted by user" });
269
+ expect(typeof evt.event.timestamp).toBe("number");
270
+ });
271
+
204
272
  it("should handle request_commands message", async () => {
205
273
  const pi = createMockPi();
206
274
  const handler = createCommandHandler(pi as any, "s1");
@@ -5,7 +5,7 @@
5
5
  * defaults when the probe has no match.
6
6
  *
7
7
  * The helper takes an optional `probe` parameter so unit tests can supply a
8
- * fake catalog without needing `@mariozechner/pi-ai` installed — in
8
+ * fake catalog without needing `@earendil-works/pi-ai` installed — in
9
9
  * production, registerEntry() injects `modelRegistry.find` as the probe.
10
10
  *
11
11
  * Spec: openspec/changes/enrich-custom-provider-model-metadata/specs/provider-auth-bridge/spec.md
@@ -11,7 +11,7 @@
11
11
  * pi 0.70's RPC mode (the only mode dashboard headless sessions run
12
12
  * under) defines `ExtensionUIContext.custom` as an unconditional no-op
13
13
  * (`async custom() { return undefined; }`, see
14
- * `~/.nvm/.../@mariozechner/pi-coding-agent/dist/modes/rpc/rpc-mode.js`
14
+ * `~/.nvm/.../@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-mode.js`
15
15
  * lines 150-153). Awaiting that primitive resolves to `undefined`
16
16
  * synchronously, and the TUI arm's `bus.respond({ cancelled: true,
17
17
  * source: "tui" })` triggers the PromptBus's first-response-wins
@@ -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
+ });
@@ -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
+ });