@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
@@ -11,7 +11,7 @@
11
11
  * flow:resolve-model / flow:get-available-models
12
12
  */
13
13
 
14
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
  import { existsSync, readFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { join } from "node:path";
@@ -373,7 +373,7 @@ async function loadPiAi(): Promise<PiAiHelpers> {
373
373
  if (_piAiLoadAttempted) return {};
374
374
  _piAiLoadAttempted = true;
375
375
  try {
376
- const mod: any = await import("@mariozechner/pi-ai");
376
+ const mod: any = await import("@earendil-works/pi-ai");
377
377
  _piAiModule = { findEnvKeys: mod.findEnvKeys, getEnvApiKey: mod.getEnvApiKey };
378
378
  return _piAiModule;
379
379
  } catch {
@@ -436,6 +436,20 @@ function getModelRegistry(): any {
436
436
  // -- Provider registration (with auto-discovery) --------------------------
437
437
 
438
438
  async function registerEntry(pi: ExtensionAPI, name: string, entry: ProviderEntry): Promise<number> {
439
+ // Record snapshot SYNCHRONOUSLY before awaiting discovery so the very
440
+ // first providers_list push (typically fired from `session_start`
441
+ // shortly after `activate()` kicked off async registerEntry calls) carries
442
+ // the correct `custom: true` flags. Otherwise a slow / unreachable
443
+ // /v1/models endpoint causes custom providers from
444
+ // `~/.pi/agent/providers.json` to leak into Settings → Provider
445
+ // Authentication → API Keys until the discovery probe resolves.
446
+ // See change: fix-custom-provider-flag-race.
447
+ lastRegistered.set(name, {
448
+ baseUrl: entry.baseUrl,
449
+ apiKey: entry.apiKey,
450
+ api: entry.api ?? "openai-completions",
451
+ });
452
+
439
453
  const discovered = await discoverModels(entry.baseUrl, entry.apiKey);
440
454
 
441
455
  // Metadata (contextWindow, maxTokens, reasoning, cost, input) is resolved
@@ -464,13 +478,6 @@ async function registerEntry(pi: ExtensionAPI, name: string, entry: ProviderEntr
464
478
  models,
465
479
  });
466
480
 
467
- // Record snapshot so reloadProviders can detect subsequent changes.
468
- lastRegistered.set(name, {
469
- baseUrl: entry.baseUrl,
470
- apiKey: entry.apiKey,
471
- api: entry.api ?? "openai-completions",
472
- });
473
-
474
481
  // Notify bridge directly (same package — no cross-package event needed)
475
482
  onProvidersChanged?.();
476
483
 
@@ -0,0 +1,123 @@
1
+ /**
2
+ * RetryTracker — synthesizes `auto_retry_start` / `auto_retry_end` events from
3
+ * observed pi events.
4
+ *
5
+ * Background: pi's ExtensionAPI does NOT expose `auto_retry_*` events to
6
+ * extensions (verified against pi 0.70/0.73 — see
7
+ * https://github.com/badlogic/pi-mono/discussions/2073). They fire only via
8
+ * `AgentSession._emit → _eventListeners` which only the embedded SDK can
9
+ * subscribe to.
10
+ *
11
+ * Workaround: pi-coding-agent's `_handleRetryableError` fires `message_end`
12
+ * for the failed assistant message BEFORE entering its retry sleep. The
13
+ * bridge sees that `message_end` via `pi.on("message_end")`. By matching
14
+ * the same regex pi-coding-agent uses internally, we can detect that a
15
+ * retry is about to happen and emit our own `auto_retry_start` to the
16
+ * dashboard. When the next non-error `message_end` or `agent_end` arrives,
17
+ * we emit `auto_retry_end`.
18
+ *
19
+ * `delayMs` and `maxAttempts` are unknowable from observed events (pi's
20
+ * settings are not exposed); we send sentinel `-1` for both. The
21
+ * RetryBanner renders an indeterminate "retrying…" UI in that case.
22
+ *
23
+ * See change: fix-provider-retry-infinite-loop.
24
+ */
25
+
26
+ /**
27
+ * Regex copied verbatim from pi-coding-agent `agent-session.js`
28
+ * `_isRetryableError`. If pi adds new retryable categories, this regex
29
+ * goes stale — but the failure mode is "tracker silently misses some
30
+ * retries", never "tracker breaks". Sync at major pi version bumps.
31
+ */
32
+ export const RETRYABLE_PATTERN =
33
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
34
+
35
+ export interface SyntheticRetryEvent {
36
+ eventType: "auto_retry_start" | "auto_retry_end";
37
+ data: Record<string, unknown>;
38
+ }
39
+
40
+ /** Minimal shape we pluck from a `message_end` event. */
41
+ export interface ObservedAssistantMessage {
42
+ role?: string;
43
+ stopReason?: string;
44
+ errorMessage?: string;
45
+ }
46
+
47
+ export class RetryTracker {
48
+ /** sessionId → 1-based attempt counter for the current retry chain. */
49
+ private attempt = new Map<string, number>();
50
+
51
+ /**
52
+ * Process a `message_end` event. Returns a synthetic event the bridge
53
+ * should ALSO forward (after the original message_end), or null.
54
+ */
55
+ observeMessageEnd(
56
+ sessionId: string,
57
+ message: ObservedAssistantMessage | undefined | null,
58
+ ): SyntheticRetryEvent | null {
59
+ if (!message || message.role !== "assistant") return null;
60
+
61
+ if (message.stopReason === "error") {
62
+ const err = typeof message.errorMessage === "string" ? message.errorMessage : "";
63
+ if (!err || !RETRYABLE_PATTERN.test(err)) return null;
64
+ const next = (this.attempt.get(sessionId) ?? 0) + 1;
65
+ this.attempt.set(sessionId, next);
66
+ return {
67
+ eventType: "auto_retry_start",
68
+ data: { attempt: next, maxAttempts: -1, delayMs: -1, errorMessage: err },
69
+ };
70
+ }
71
+
72
+ // Non-error assistant message — clears any in-flight retry chain.
73
+ if (this.attempt.has(sessionId)) {
74
+ const last = this.attempt.get(sessionId) ?? 0;
75
+ this.attempt.delete(sessionId);
76
+ return { eventType: "auto_retry_end", data: { success: true, attempt: last } };
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Process an `agent_end` event. Returns a synthetic event the bridge
83
+ * should forward BEFORE the original agent_end, or null.
84
+ *
85
+ * Always clears any in-flight retry tracking (terminal turn boundary).
86
+ */
87
+ observeAgentEnd(
88
+ sessionId: string,
89
+ agentEndData: { messages?: unknown } | undefined | null,
90
+ ): SyntheticRetryEvent | null {
91
+ const wasRetrying = this.attempt.has(sessionId);
92
+ const last = this.attempt.get(sessionId) ?? -1;
93
+ this.attempt.delete(sessionId);
94
+ if (!wasRetrying) return null;
95
+
96
+ // Inspect terminal message for error context.
97
+ const messages = agentEndData?.messages;
98
+ const lastMsg =
99
+ Array.isArray(messages) && messages.length > 0
100
+ ? (messages[messages.length - 1] as ObservedAssistantMessage)
101
+ : undefined;
102
+ if (lastMsg?.stopReason === "error" && typeof lastMsg.errorMessage === "string") {
103
+ return {
104
+ eventType: "auto_retry_end",
105
+ data: { success: false, attempt: last, finalError: lastMsg.errorMessage },
106
+ };
107
+ }
108
+ return { eventType: "auto_retry_end", data: { success: true, attempt: last } };
109
+ }
110
+
111
+ /**
112
+ * Notify the tracker of a user abort. Clears in-flight tracking so a
113
+ * subsequent agent_end does not double-emit auto_retry_end.
114
+ */
115
+ noteAbort(sessionId: string): void {
116
+ this.attempt.delete(sessionId);
117
+ }
118
+
119
+ /** Test-only / bridge-coordination: is a retry currently in flight? */
120
+ isRetrying(sessionId: string): boolean {
121
+ return this.attempt.has(sessionId);
122
+ }
123
+ }
@@ -3,16 +3,16 @@
3
3
  * The spawned server runs in foreground mode (no subcommand) and writes
4
4
  * its own PID file at ~/.pi/dashboard/server.pid.
5
5
  */
6
- import { spawnDetached, waitForReady } from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
7
- import fs from "node:fs";
8
- import os from "node:os";
9
6
  import path from "node:path";
10
7
  import { createRequire } from "node:module";
11
8
  import { fileURLToPath } from "node:url";
12
9
  import type { DashboardConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
13
- import { resolveJitiImport } from "@blackbelt-technology/pi-dashboard-shared/resolve-jiti.js";
14
- import { toFileUrl, shouldUrlWrapEntry } from "@blackbelt-technology/pi-dashboard-shared/platform/node-spawn.js";
15
- import { isDashboardRunning } from "@blackbelt-technology/pi-dashboard-shared/server-identity.js";
10
+ import {
11
+ launchDashboardServer,
12
+ JitiNotFoundError,
13
+ PortConflictError,
14
+ EarlyExitError,
15
+ } from "@blackbelt-technology/pi-dashboard-shared/server-launcher.js";
16
16
 
17
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
18
  const require = createRequire(import.meta.url);
@@ -74,82 +74,43 @@ export function buildSpawnArgs(config: DashboardConfig): string[] {
74
74
 
75
75
  /**
76
76
  * Launch the dashboard server as a detached background process.
77
- * Returns success/failure after a brief wait to detect early crashes.
77
+ * Delegates to the shared `launchDashboardServer` primitive which owns
78
+ * loader resolution, argv shape, env merge, log-file policy, and
79
+ * readiness polling (see `packages/shared/src/server-launcher.ts`).
80
+ *
81
+ * Bridge-specific contract preserved: `DASHBOARD_STARTER=Bridge`,
82
+ * `stdio: "ignore"` (Bridge auto-spawn never owns the log file),
83
+ * 2 s health timeout (Bridge expects a fast cold-start when the
84
+ * server is already on the same machine).
78
85
  */
79
86
  export async function launchServer(config: DashboardConfig): Promise<LaunchResult> {
80
87
  const cliPath = resolveServerCliPath();
81
88
  const args = buildSpawnArgs(config);
82
89
 
83
90
  try {
84
- // Open the server.log in append mode so any startup error is visible.
85
- // Matches the log location used by `pi-dashboard start`.
86
- let logFd: number | undefined;
87
- try {
88
- const logDir = path.join(os.homedir(), ".pi", "dashboard");
89
- fs.mkdirSync(logDir, { recursive: true });
90
- const logPath = path.join(logDir, "server.log");
91
- logFd = fs.openSync(logPath, "a");
92
- fs.writeSync(
93
- logFd,
94
- `\n[${new Date().toISOString()}] bridge auto-start (parent pid ${process.pid}, port ${config.port})\n`,
95
- );
96
- } catch { /* if we can't open the log, spawn still works */ }
97
-
98
- // Spawn server via the detached-spawn primitive. The loader is always
99
- // URL-wrapped (Node needs file:// for --import on Windows drive letters).
100
- // The entry is URL-wrapped only on Windows + non-tsx loader (Node parses
101
- // drive letters as URL schemes in argv); on POSIX the entry MUST be raw
102
- // because jiti's resolver misbehaves on file:// URL entries. See
103
- // openspec/changes/archive/2026-04-24-fix-windows-entry-script-url.
104
- const loader = resolveJitiImport();
105
- const wrapEntry = shouldUrlWrapEntry(loader);
106
- // entry is gated by shouldUrlWrapEntry(loader): returns true only on
107
- // Windows + non-tsx (where URL wrap is required); false on POSIX
108
- // where jiti needs the raw path (file:// URL entries trigger jiti's
109
- // `<cwd>/file:/...` misresolution bug).
110
- const entry = wrapEntry ? toFileUrl(cliPath) : cliPath;
111
- const r = await spawnDetached({
112
- cmd: process.execPath,
113
- args: ["--import", loader, entry, ...args], // ban:raw-node-import-ok: entry gated by shouldUrlWrapEntry
114
- env: { ...process.env, DASHBOARD_STARTER: "Bridge" },
115
- logFd,
91
+ await launchDashboardServer({
92
+ cliPath,
93
+ extraArgs: args,
94
+ stdio: "ignore",
95
+ healthTimeoutMs: 2_000,
96
+ port: config.port,
97
+ starter: "Bridge",
116
98
  });
117
-
118
- // Close the parent's copy of the log fd — the child has its own.
119
- if (logFd !== undefined) {
120
- try { fs.closeSync(logFd); } catch { /* ignore */ }
99
+ return { success: true, message: "Server started" };
100
+ } catch (err: unknown) {
101
+ if (err instanceof JitiNotFoundError) {
102
+ return { success: false, message: err.message };
121
103
  }
122
-
123
- if (!r.ok || !r.process) {
124
- return { success: false, message: `Server process failed to spawn: ${r.error ?? "unknown"}` };
104
+ if (err instanceof PortConflictError) {
105
+ return { success: false, message: err.message };
125
106
  }
126
-
127
- // Wait for the server to actually become available via positive
128
- // HTTP probe. NO deadline — we rely on child-exit for failure
129
- // detection. A timeout here only catches the pathological case
130
- // "process alive but never ready", which is rarer than the
131
- // false-positive case "slow cold-start mistakenly flagged as
132
- // failure" (Fastify + jiti compile + session scan can take 15–30s
133
- // on Windows). If the child crashes, `waitForReady` returns
134
- // { ok: false, error: "child exited with code N" } via its
135
- // `child` listener. If the child hangs alive-but-broken, the user
136
- // can kill it manually — timers don't help that case anyway.
137
- const ready = await waitForReady({
138
- probe: async () => (await isDashboardRunning(config.port)).running,
139
- pollIntervalMs: 300,
140
- child: r.process,
141
- // deadlineMs intentionally omitted — wait indefinitely.
142
- });
143
-
144
- if (!ready.ok) {
107
+ if (err instanceof EarlyExitError) {
145
108
  return {
146
109
  success: false,
147
- message: `Server process failed: ${ready.error ?? "unknown"}. See ~/.pi/dashboard/server.log`,
110
+ message: `Server process exited (code=${err.code}) before health check. See ~/.pi/dashboard/server.log`,
148
111
  };
149
112
  }
150
-
151
- return { success: true, message: "Server started" };
152
- } catch (err: any) {
153
- return { success: false, message: err.message };
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ return { success: false, message };
154
115
  }
155
116
  }
@@ -39,7 +39,15 @@ export function sendStateSync(
39
39
  // dashboard restart) is a "reattach". Server applies the configured
40
40
  // `reattachPlacement` policy on "reattach".
41
41
  // See change: reattach-move-to-front.
42
- const registerReason: "spawn" | "reattach" = bc.hasRegisteredOnce ? "reattach" : "spawn";
42
+ const isFirstRegister = !bc.hasRegisteredOnce;
43
+ const registerReason: "spawn" | "reattach" = isFirstRegister ? "spawn" : "reattach";
44
+
45
+ // Include the spawn correlation token (server-minted UUID injected via
46
+ // env var at spawn time) ONLY on the first register. Subsequent
47
+ // registers (reattach after dashboard restart, in-process Ctrl+F fork)
48
+ // omit it because the sessionId is already known to the server.
49
+ // See change: spawn-correlation-token (Decision 3).
50
+ const spawnToken = isFirstRegister ? process.env.PI_DASHBOARD_SPAWN_TOKEN : undefined;
43
51
 
44
52
  bc.connection.send({
45
53
  type: "session_register",
@@ -55,6 +63,7 @@ export function sendStateSync(
55
63
  eventCount,
56
64
  pid: process.pid,
57
65
  registerReason,
66
+ ...(spawnToken ? { spawnToken } : {}),
58
67
  });
59
68
 
60
69
  bc.hasRegisteredOnce = true;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared extension-slash-command dispatch branch used by both bridge.ts
3
+ * (sessionPrompt callback) and command-handler.ts (slash else-arm fallback).
4
+ *
5
+ * Routing-step 9 from `command-routing` spec — three-way decision:
6
+ * - Path B: when `pi.dispatchCommand` is a function → call it directly.
7
+ * - Path C: when `pi.dispatchCommand` is absent AND the bridge runs inside a
8
+ * dashboard-spawned headless `pi --mode rpc` AND a `connection` is wired
9
+ * → emit `dispatch_extension_command` to the server (server forwards to
10
+ * the per-session RPC keeper UDS and emits the terminal command_feedback).
11
+ * - Path D (stopgap, last resort): `pi.dispatchCommand` absent AND the bridge
12
+ * is NOT headless (tmux / wt / unrecognized spawn shape) OR no `connection`
13
+ * was supplied → emit `command_feedback {status:"error"}` with a pi-version
14
+ * reminder.
15
+ *
16
+ * If `text` is NOT an extension command, return `false` so the caller can
17
+ * fall through to its existing template-expansion / sendUserMessage path.
18
+ *
19
+ * Guarantees: EXACTLY ONE `started` event AND EXACTLY ONE terminal event
20
+ * (`completed` xor `error`) per dispatch, across all three paths combined.
21
+ * Path C does NOT emit a terminal event — the server emits it.
22
+ *
23
+ * See change: fix-extension-slash-commands-in-dashboard,
24
+ * add-rpc-stdin-dispatch-with-keeper-sidecar.
25
+ */
26
+ import crypto from "node:crypto";
27
+ import type { ExtensionToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
28
+ import { hasDispatchCommand, isExtensionSlashCommand, isHeadlessRpcSession } from "./bridge-context.js";
29
+
30
+ export type FeedbackSink = (msg: ExtensionToServerMessage) => void;
31
+
32
+ /**
33
+ * Minimal connection surface used by Path C. Concrete implementation is
34
+ * `ConnectionManager` (`connection.ts`) but a structural type keeps this
35
+ * helper unit-testable without a real WebSocket.
36
+ */
37
+ export interface DispatchConnection {
38
+ send(msg: ExtensionToServerMessage): void;
39
+ }
40
+
41
+ const PI_071_REQUIRED =
42
+ "Extension slash commands cannot be dispatched from the dashboard yet — requires pi 0.71+ (`pi.dispatchCommand`). Invoke from the pi TUI, or use the extension's tools directly.";
43
+
44
+ function emitFeedback(
45
+ sink: FeedbackSink | undefined,
46
+ sessionId: string,
47
+ command: string,
48
+ status: "started" | "completed" | "error",
49
+ message?: string,
50
+ ): void {
51
+ if (!sink) return;
52
+ sink({
53
+ type: "event_forward",
54
+ sessionId,
55
+ event: {
56
+ eventType: "command_feedback",
57
+ timestamp: Date.now(),
58
+ data: message === undefined ? { command, status } : { command, status, message },
59
+ },
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Try to dispatch a slash command as an extension command.
65
+ *
66
+ * @returns `true` if the helper handled the text (extension command detected;
67
+ * dispatch attempted or stopgap emitted). The caller MUST NOT fall
68
+ * through to template expansion or `sendUserMessage`.
69
+ * @returns `false` if `text` is not an extension slash command. The caller
70
+ * SHOULD continue with its existing fallback path.
71
+ */
72
+ export async function tryDispatchExtensionCommand(
73
+ pi: unknown,
74
+ text: string,
75
+ sessionId: string,
76
+ sink: FeedbackSink | undefined,
77
+ connection?: DispatchConnection,
78
+ ): Promise<boolean> {
79
+ // Defensive: pi.getCommands() can throw on a stale ctx during dispose.
80
+ let commands: Array<{ name: string; source?: string }> = [];
81
+ try {
82
+ const got = (pi as any)?.getCommands?.();
83
+ if (Array.isArray(got)) commands = got;
84
+ } catch (err) {
85
+ console.warn("[dashboard] getCommands stale on slash-dispatch", err);
86
+ return false; // fall through to existing path; preserve today's behavior
87
+ }
88
+
89
+ if (!isExtensionSlashCommand(text, commands)) return false;
90
+
91
+ emitFeedback(sink, sessionId, text, "started");
92
+
93
+ // Path B (preferred when available): pi 0.71+ exposes dispatchCommand.
94
+ if (hasDispatchCommand(pi)) {
95
+ try {
96
+ await (pi as any).dispatchCommand(text, { streamingBehavior: "followUp" });
97
+ emitFeedback(sink, sessionId, text, "completed");
98
+ } catch (err: any) {
99
+ const message = err instanceof Error ? err.message : String(err);
100
+ emitFeedback(sink, sessionId, text, "error", message);
101
+ }
102
+ return true;
103
+ }
104
+
105
+ // Path C: headless RPC session, dispatchCommand absent. Hand off to the
106
+ // server, which writes the line to the session's RPC keeper UDS and
107
+ // emits the terminal command_feedback. The bridge does NOT emit a
108
+ // terminal event for this path — that would duplicate the reducer's
109
+ // started→terminal upsert. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
110
+ if (connection && isHeadlessRpcSession()) {
111
+ connection.send({
112
+ type: "dispatch_extension_command",
113
+ sessionId,
114
+ command: text,
115
+ requestId: crypto.randomUUID(),
116
+ });
117
+ return true;
118
+ }
119
+
120
+ // Path D (stopgap): no dispatchCommand and not headless (tmux / wt / unrecognized).
121
+ emitFeedback(sink, sessionId, text, "error", PI_071_REQUIRED);
122
+ return true;
123
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Usage-limit event orderer.
3
+ *
4
+ * Tracks per-session whether an `auto_retry_start` was forwarded without a
5
+ * matching `auto_retry_end`, and — when an `agent_end` arrives whose terminal
6
+ * assistant message has a usage-limit / quota errorMessage — synthesizes an
7
+ * `auto_retry_end { success: false }` to emit BEFORE the `agent_end`.
8
+ *
9
+ * Pure logic (no I/O). The bridge wires this into its forwarding pipeline.
10
+ *
11
+ * See change: fix-provider-retry-infinite-loop.
12
+ */
13
+
14
+ export const USAGE_LIMIT_PATTERN =
15
+ /usage[_ ]limit[_ ]reached|usage_not_included|quota[_ ]exceeded|monthly limit|hourly limit|reset after \d+[hms]/i;
16
+
17
+ export interface SyntheticEventEnvelope {
18
+ eventType: "auto_retry_end";
19
+ data: { success: false; attempt: -1; finalError: string };
20
+ }
21
+
22
+ export class UsageLimitOrderer {
23
+ /** sessionId → true while a retry is in flight (no auto_retry_end seen yet). */
24
+ private pending = new Set<string>();
25
+
26
+ /**
27
+ * Notify the orderer of an outbound `auto_retry_start` for sessionId.
28
+ */
29
+ noteRetryStart(sessionId: string): void {
30
+ this.pending.add(sessionId);
31
+ }
32
+
33
+ /**
34
+ * Notify the orderer of an outbound `auto_retry_end` for sessionId.
35
+ * Subsequent `agent_end` events will not synthesize unless a new retry
36
+ * has been started.
37
+ */
38
+ noteRetryEnd(sessionId: string): void {
39
+ this.pending.delete(sessionId);
40
+ }
41
+
42
+ /**
43
+ * Inspect an `agent_end` payload. If the terminal message has a
44
+ * usage-limit error AND we have an unmatched retry-start for this session,
45
+ * return the synthetic event the bridge should forward BEFORE the agent_end.
46
+ *
47
+ * Returns null when no synthesis is needed. Always clears the pending flag
48
+ * after a terminal agent_end (errored or not) so we don't double-synthesize.
49
+ */
50
+ maybeSynthesize(
51
+ sessionId: string,
52
+ agentEndData: Record<string, unknown> | undefined,
53
+ ): SyntheticEventEnvelope | null {
54
+ const wasPending = this.pending.has(sessionId);
55
+ // Always clear on agent_end: a terminal turn ends any retry tracking.
56
+ this.pending.delete(sessionId);
57
+
58
+ if (!wasPending || !agentEndData) return null;
59
+ const messages = agentEndData.messages;
60
+ if (!Array.isArray(messages) || messages.length === 0) return null;
61
+ const last = messages[messages.length - 1] as Record<string, unknown> | undefined;
62
+ if (!last || last.stopReason !== "error") return null;
63
+ const errorMessage = typeof last.errorMessage === "string" ? last.errorMessage : "";
64
+ if (!errorMessage || !USAGE_LIMIT_PATTERN.test(errorMessage)) return null;
65
+
66
+ return {
67
+ eventType: "auto_retry_end",
68
+ data: { success: false, attempt: -1, finalError: errorMessage },
69
+ };
70
+ }
71
+
72
+ /** Test-only: inspect pending state. */
73
+ hasPending(sessionId: string): boolean {
74
+ return this.pending.has(sessionId);
75
+ }
76
+ }
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pi-dashboard CLI entry point.
4
+ *
5
+ * The actual CLI is `../src/cli.ts`. This wrapper exists because a
6
+ * `#!/usr/bin/env` shebang cannot interpolate a dynamic `--import`
7
+ * loader path. The wrapper resolves jiti from pi's tree at runtime
8
+ * and re-execs Node with `--import <jiti-url> cli.ts <args>`.
9
+ *
10
+ * No tsx fallback: if jiti cannot be resolved, the wrapper exits 1
11
+ * with an install-hint pointing at pi. Mirrors the resolution shape
12
+ * in `packages/shared/src/resolve-jiti.ts` (cannot import the .ts
13
+ * module before a TS loader is registered, so the lookup is inlined).
14
+ *
15
+ * See change: replace-tsx-with-jiti.
16
+ */
17
+ import { createRequire } from "node:module";
18
+ import { realpathSync } from "node:fs";
19
+ import { spawn } from "node:child_process";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { pathToFileURL, fileURLToPath } from "node:url";
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const cliPath = resolve(here, "..", "src", "cli.ts");
25
+
26
+ // Mirrors packages/shared/src/resolve-jiti.ts JITI_PACKAGES.
27
+ const JITI_PACKAGES = ["jiti", "@mariozechner/jiti"];
28
+
29
+ /** Resolve pi's jiti register hook as a file:// URL. Returns null on miss. */
30
+ function resolveJitiUrl() {
31
+ const anchor = process.argv[1];
32
+ if (!anchor) return null;
33
+ let resolved;
34
+ try {
35
+ resolved = realpathSync(anchor);
36
+ } catch {
37
+ return null;
38
+ }
39
+ const req = createRequire(resolved);
40
+ for (const pkg of JITI_PACKAGES) {
41
+ try {
42
+ const pkgJson = req.resolve(`${pkg}/package.json`);
43
+ const registerPath = join(dirname(pkgJson), "lib", "jiti-register.mjs");
44
+ return pathToFileURL(registerPath).href;
45
+ } catch {
46
+ /* try next */
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+
52
+ const loader = resolveJitiUrl();
53
+ if (!loader) {
54
+ process.stderr.write(
55
+ "pi-dashboard: cannot find jiti. " +
56
+ "Install pi: 'npm install -g @earendil-works/pi-coding-agent'\n",
57
+ );
58
+ process.exit(1);
59
+ }
60
+
61
+ // Mirrors shouldUrlWrapEntry() in packages/shared/src/platform/node-spawn.ts:
62
+ // jiti needs the entry URL-wrapped on Windows (Node rejects raw drive-letter
63
+ // paths for --import). POSIX takes the raw path.
64
+ const entry = process.platform === "win32" ? pathToFileURL(cliPath).href : cliPath;
65
+
66
+ const child = spawn(
67
+ process.execPath,
68
+ ["--import", loader, entry, ...process.argv.slice(2)],
69
+ { stdio: "inherit", windowsHide: true },
70
+ );
71
+
72
+ child.on("exit", (code, signal) => {
73
+ if (signal) {
74
+ // Re-raise the signal so the parent shell sees the same exit reason.
75
+ process.kill(process.pid, signal);
76
+ } else {
77
+ process.exit(code ?? 0);
78
+ }
79
+ });
80
+
81
+ child.on("error", (err) => {
82
+ process.stderr.write(`pi-dashboard: failed to spawn Node: ${err.message}\n`);
83
+ process.exit(1);
84
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
6
  "repository": {
@@ -15,15 +15,16 @@
15
15
  "node": ">=22.18.0"
16
16
  },
17
17
  "piCompatibility": {
18
- "minimum": "0.70.0",
19
- "recommended": "0.70.0",
18
+ "minimum": "0.74.0",
19
+ "recommended": "0.74.0",
20
20
  "maximum": null
21
21
  },
22
22
  "main": "src/cli.ts",
23
23
  "bin": {
24
- "pi-dashboard": "src/cli.ts"
24
+ "pi-dashboard": "bin/pi-dashboard.mjs"
25
25
  },
26
26
  "files": [
27
+ "bin/",
27
28
  "src/",
28
29
  "scripts/"
29
30
  ],
@@ -31,9 +32,9 @@
31
32
  "postinstall": "node scripts/fix-pty-permissions.cjs"
32
33
  },
33
34
  "dependencies": {
34
- "@blackbelt-technology/dashboard-plugin-runtime": "^0.5.0",
35
- "@blackbelt-technology/pi-dashboard-extension": "^0.5.0",
36
- "@blackbelt-technology/pi-dashboard-shared": "^0.5.0",
35
+ "@blackbelt-technology/dashboard-plugin-runtime": "^0.5.2",
36
+ "@blackbelt-technology/pi-dashboard-extension": "^0.5.2",
37
+ "@blackbelt-technology/pi-dashboard-shared": "^0.5.2",
37
38
  "@fastify/compress": "^8.3.1",
38
39
  "@fastify/cookie": "^11.0.2",
39
40
  "@fastify/cors": "^11.0.0",