@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
@@ -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 {
@@ -40,9 +40,11 @@ export interface BridgeContext {
40
40
  hasRegisteredOnce: boolean;
41
41
  }
42
42
 
43
- // Commands that the dashboard handles natively with superior UX.
44
- // These are filtered from the command list sent to dashboard clients.
45
- const DASHBOARD_NATIVE_COMMANDS = new Set(["roles"]);
43
+ // Commands that the dashboard handles natively with superior UX, filtered from
44
+ // the command list sent to dashboard clients AND from extension-slash detection.
45
+ // Current set: { "roles" }. Bridge-registered `__dashboard_reload` is filtered
46
+ // separately by the `__`-prefix rule. See change: fix-extension-slash-commands-in-dashboard.
47
+ export const DASHBOARD_NATIVE_COMMANDS = new Set(["roles"]);
46
48
 
47
49
  /** Filter out hidden commands (names starting with __) and dashboard-native commands from commands list */
48
50
  export function filterHiddenCommands(commands: any[]): any[] {
@@ -52,6 +54,68 @@ export function filterHiddenCommands(commands: any[]): any[] {
52
54
  );
53
55
  }
54
56
 
57
+ /**
58
+ * Pure predicate: does `text` name an extension-registered slash command?
59
+ *
60
+ * Returns true iff:
61
+ * - `text` starts with `/` and contains no embedded newline
62
+ * - the token after `/` (up to first space or end) appears in `commandList`
63
+ * with `source === "extension"`
64
+ * - that token is NOT in `DASHBOARD_NATIVE_COMMANDS` (and not `__`-prefixed)
65
+ *
66
+ * Pure: no pi calls, no mutation. See change: fix-extension-slash-commands-in-dashboard.
67
+ */
68
+ export function isExtensionSlashCommand(
69
+ text: string,
70
+ commandList: ReadonlyArray<{ name: string; source?: string }>,
71
+ ): boolean {
72
+ if (typeof text !== "string" || !text.startsWith("/")) return false;
73
+ if (text.includes("\n")) return false;
74
+ const rest = text.slice(1);
75
+ const spaceIdx = rest.indexOf(" ");
76
+ const cmdName = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
77
+ if (!cmdName) return false;
78
+ if (cmdName.startsWith("__")) return false;
79
+ if (DASHBOARD_NATIVE_COMMANDS.has(cmdName)) return false;
80
+ return commandList.some((c) => c?.name === cmdName && c?.source === "extension");
81
+ }
82
+
83
+ /**
84
+ * Feature-detect upstream `pi.dispatchCommand(text, opts)` (pi 0.71+).
85
+ * Returns true iff the field is a function on the supplied object.
86
+ * See change: fix-extension-slash-commands-in-dashboard.
87
+ */
88
+ export function hasDispatchCommand(pi: unknown): boolean {
89
+ return typeof (pi as any)?.dispatchCommand === "function";
90
+ }
91
+
92
+ /**
93
+ * Pure predicate: is this bridge running inside a dashboard-spawned
94
+ * headless `pi --mode rpc` session?
95
+ *
96
+ * Both probes MUST be true:
97
+ * 1. `process.env.PI_DASHBOARD_SPAWNED === "1"` (set by
98
+ * `process-manager.ts::buildSpawnEnv` for every dashboard-spawned session).
99
+ * 2. `process.argv` contains `--mode` adjacent to `rpc`.
100
+ *
101
+ * Either alone is insufficient: env-only matches dashboard-spawned tmux
102
+ * sessions; argv-only matches non-dashboard RPC invocations.
103
+ *
104
+ * Optional `env` / `argv` parameters exist purely for unit testing
105
+ * (defaulting to the live process state). See change:
106
+ * add-rpc-stdin-dispatch-with-keeper-sidecar (task 7.1).
107
+ */
108
+ export function isHeadlessRpcSession(
109
+ env: NodeJS.ProcessEnv = process.env,
110
+ argv: ReadonlyArray<string> = process.argv,
111
+ ): boolean {
112
+ if (env.PI_DASHBOARD_SPAWNED !== "1") return false;
113
+ for (let i = 0; i < argv.length - 1; i++) {
114
+ if (argv[i] === "--mode" && argv[i + 1] === "rpc") return true;
115
+ }
116
+ return false;
117
+ }
118
+
55
119
  /** Extract first user message text from session entries */
56
120
  export function extractFirstMessage(ctx: any): string | undefined {
57
121
  try {
@@ -4,12 +4,14 @@
4
4
  * Global extension that connects to the dashboard server,
5
5
  * forwards all pi events, and relays commands back.
6
6
  */
7
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
- import { Loader } from "@mariozechner/pi-tui";
7
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+ import { Loader } from "@earendil-works/pi-tui";
9
9
  import { ConnectionManager } from "./connection.js";
10
10
  import { detectSessionSource } from "./source-detector.js";
11
11
  import { mapEventToProtocol } from "./event-forwarder.js";
12
12
  import { createCommandHandler } from "./command-handler.js";
13
+ import { RetryTracker } from "./retry-tracker.js";
14
+ import { UsageLimitOrderer } from "./usage-limit-orderer.js";
13
15
  import fs from "node:fs";
14
16
  import os from "node:os";
15
17
  import path from "node:path";
@@ -33,6 +35,7 @@ import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./proce
33
35
  import { scanChildProcesses } from "./process-scanner.js";
34
36
  import type { BridgeContext } from "./bridge-context.js";
35
37
  import { filterHiddenCommands, extractFirstMessage, getCurrentModelString } from "./bridge-context.js";
38
+ import { tryDispatchExtensionCommand } from "./slash-dispatch.js";
36
39
  import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySessionEntries, handleSessionChange as _handleSessionChange } from "./session-sync.js";
37
40
  import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged, sendJjStateIfChanged as _sendJjStateIfChanged, resetReconnectCaches as _resetReconnectCaches } from "./model-tracker.js";
38
41
  import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
@@ -191,6 +194,22 @@ function initBridge(pi: ExtensionAPI) {
191
194
  let hasRegisteredOnce = false; // see change: reattach-move-to-front
192
195
  let promptBus: PromptBus | undefined;
193
196
 
197
+ // Provider-retry synthesis trackers. pi's ExtensionAPI does not expose
198
+ // `auto_retry_*` events, so the bridge synthesizes them from observed
199
+ // `message_end` / `agent_end` events. See change: fix-provider-retry-infinite-loop.
200
+ const retryTracker = new RetryTracker();
201
+ const usageLimitOrderer = new UsageLimitOrderer();
202
+
203
+ /** Forward a synthesized auto_retry_* event using the standard event_forward shape. */
204
+ const sendSyntheticRetryEvent = (eventType: string, data: Record<string, unknown>): void => {
205
+ if (!isActive() || !sessionReady) return;
206
+ connection.send({
207
+ type: "event_forward",
208
+ sessionId,
209
+ event: { eventType, timestamp: Date.now(), data },
210
+ });
211
+ };
212
+
194
213
  // ── Per-message entry id tracking (for fix-per-message-fork) ──
195
214
  // Pi 0.69+ awaits extension handlers BEFORE sessionManager.appendMessage runs,
196
215
  // which means getLeafId() at emit time returns the previous leaf, not the
@@ -648,6 +667,14 @@ function initBridge(pi: ExtensionAPI) {
648
667
  if (cachedCtx?.abort) {
649
668
  cachedCtx.abort();
650
669
  }
670
+ // Clear retry-synthesis trackers — the user-initiated abort path
671
+ // already synthesizes its own auto_retry_end via command-handler.
672
+ // See change: fix-provider-retry-infinite-loop.
673
+ retryTracker.noteAbort(sessionId);
674
+ usageLimitOrderer.noteRetryEnd(sessionId);
675
+ },
676
+ isIdle: () => {
677
+ try { return cachedCtx?.isIdle?.() ?? false; } catch { return false; }
651
678
  },
652
679
  eventSink: (msg) => connection.send(msg),
653
680
  compact: (opts) => {
@@ -668,26 +695,37 @@ function initBridge(pi: ExtensionAPI) {
668
695
  spawnNew: () => {
669
696
  connection.send({ type: "spawn_new_session", sessionId, cwd: process.cwd() });
670
697
  },
671
- sessionPrompt: (text) => {
672
- // Route slash commands: management events, flow:run, then fallback
698
+ sessionPrompt: async (text) => {
699
+ // Route slash commands: management events, flow:run, extension dispatch, then fallback.
700
+ // See change: fix-extension-slash-commands-in-dashboard.
673
701
  if (text.startsWith("/") && pi.events) {
674
702
  const cmdText = text.slice(1);
675
703
  const spaceIdx = cmdText.indexOf(" ");
676
704
  const cmdName = spaceIdx === -1 ? cmdText : cmdText.slice(0, spaceIdx);
677
705
  const cmdArgs = spaceIdx === -1 ? "" : cmdText.slice(spaceIdx + 1);
678
706
 
679
- // Flow management commands from buttons use flow_management message type.
680
- // Typed /flows:new, /flows:edit, /flows:delete in chat input fall through
681
- // to the slash command handler below, which invokes pi's command system
682
- // via pi.sendUserMessage (with ui-proxy handling ctx.ui calls).
683
-
684
- // Check if it's a user-defined flow via flow:list-flows
707
+ // Flow fast-path: typed /<user-defined-flow-name> wins over extension dispatch.
685
708
  const flowsList = getFlowsList();
686
709
  if (flowsList.some(f => f.name === cmdName)) {
687
710
  pi.events.emit("flow:run", { flowName: cmdName, task: cmdArgs.trim() || undefined });
688
711
  return;
689
712
  }
690
713
  }
714
+
715
+ // Extension-command dispatch (routing step 9). When matched, the helper
716
+ // emits its own command_feedback events and we MUST NOT fall through.
717
+ // The `connection` arg enables Path C (headless RPC → server-routed
718
+ // dispatch via the keeper UDS); see change:
719
+ // add-rpc-stdin-dispatch-with-keeper-sidecar.
720
+ const handled = await tryDispatchExtensionCommand(
721
+ pi,
722
+ text,
723
+ sessionId,
724
+ (msg) => connection.send(msg),
725
+ connection,
726
+ );
727
+ if (handled) return;
728
+
691
729
  // Fallback: send as user message (template-expanded).
692
730
  // Uses deliverAs:followUp so it queues properly when agent is streaming.
693
731
  // expandPromptTemplateFromDisk handles skill commands (/skill:xxx) and
@@ -802,7 +840,25 @@ function initBridge(pi: ExtensionAPI) {
802
840
  if (!sessionReady) return;
803
841
  // Track agent streaming state (survives reconnect/reload)
804
842
  if (eventType === "agent_start") getBridgeState().isAgentStreaming = true;
805
- if (eventType === "agent_end") getBridgeState().isAgentStreaming = false;
843
+ if (eventType === "agent_end") {
844
+ getBridgeState().isAgentStreaming = false;
845
+ // Provider-retry synthesis: forward auto_retry_end BEFORE agent_end
846
+ // when retries were in flight, so the dashboard's retry banner
847
+ // clears before the error banner appears. The usage-limit orderer
848
+ // takes precedence (it carries the actual error string); the retry
849
+ // tracker handles the non-usage-limit case. See change:
850
+ // fix-provider-retry-infinite-loop.
851
+ const orderedSynth = usageLimitOrderer.maybeSynthesize(sessionId, (event as any));
852
+ if (orderedSynth) {
853
+ sendSyntheticRetryEvent(orderedSynth.eventType, orderedSynth.data);
854
+ retryTracker.noteAbort(sessionId); // clear tracker; orderer's event is authoritative
855
+ } else {
856
+ const trackerSynth = retryTracker.observeAgentEnd(sessionId, event as any);
857
+ if (trackerSynth) {
858
+ sendSyntheticRetryEvent(trackerSynth.eventType, trackerSynth.data);
859
+ }
860
+ }
861
+ }
806
862
  // For model_select, enrich the event data with thinkingLevel
807
863
  if (eventType === "model_select") {
808
864
  const enriched = { ...event, thinkingLevel: (pi as any).getThinkingLevel?.() };
@@ -872,6 +928,18 @@ function initBridge(pi: ExtensionAPI) {
872
928
  const enriched = { ...event, entryId, nonce };
873
929
  const protoMsg = mapEventToProtocol(sessionId, enriched);
874
930
  connection.send(protoMsg);
931
+ // After forwarding the original message_end, ask the retry tracker
932
+ // whether to synthesize an auto_retry_* event. See change:
933
+ // fix-provider-retry-infinite-loop.
934
+ const synthetic = retryTracker.observeMessageEnd(sessionId, messageRef as any);
935
+ if (synthetic) {
936
+ sendSyntheticRetryEvent(synthetic.eventType, synthetic.data);
937
+ if (synthetic.eventType === "auto_retry_start") {
938
+ usageLimitOrderer.noteRetryStart(sessionId);
939
+ } else {
940
+ usageLimitOrderer.noteRetryEnd(sessionId);
941
+ }
942
+ }
875
943
  }, 0);
876
944
  return;
877
945
  }
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { readdirSync } from "node:fs";
5
5
  import { join, relative } from "node:path";
6
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
7
  import type {
8
8
  ServerToExtensionMessage,
9
9
  ExtensionToServerMessage,
@@ -12,6 +12,7 @@ import { killProcessByPgid } from "./process-scanner.js";
12
12
  import type { FileEntry, PiSessionInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
13
13
  import { filterHiddenCommands } from "./bridge-context.js";
14
14
  import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
15
+ import { tryDispatchExtensionCommand } from "./slash-dispatch.js";
15
16
  import { buildProviderCatalogue } from "./provider-register.js";
16
17
 
17
18
  const IGNORE_DIRS = new Set([".git", "node_modules", ".next", "dist", "build", ".cache", "__pycache__", ".venv"]);
@@ -145,6 +146,11 @@ export function createCommandHandler(
145
146
  getThinkingLevel?: () => string | undefined;
146
147
  shutdown?: () => void;
147
148
  abort?: () => void;
149
+ /**
150
+ * Probe agent idleness for the persistent-abort scheduler.
151
+ * See change: fix-provider-retry-infinite-loop.
152
+ */
153
+ isIdle?: () => boolean;
148
154
  getCwd?: () => string;
149
155
  /** Callback to send events (e.g., bash_output, command_feedback) back to server */
150
156
  eventSink?: (msg: ExtensionToServerMessage) => void;
@@ -156,11 +162,44 @@ export function createCommandHandler(
156
162
  spawnNew?: () => void;
157
163
  /** Switch model via pi.setModel() */
158
164
  setModel?: (provider: string, modelId: string) => Promise<void>;
159
- /** Route slash commands through session.prompt() */
160
- sessionPrompt?: (text: string) => void;
165
+ /**
166
+ * Route slash commands through pi's command system. May be sync or async.
167
+ * In bridge wiring this also runs the extension-command dispatch branch
168
+ * (see slash-dispatch.ts). The handler awaits the result so command_feedback
169
+ * events emitted by the dispatch path arrive before this turn returns.
170
+ * See change: fix-extension-slash-commands-in-dashboard.
171
+ */
172
+ sessionPrompt?: (text: string) => void | Promise<void>;
161
173
  },
162
174
  ): CommandHandler {
163
175
  const getSessionId = typeof sessionIdOrGetter === "function" ? sessionIdOrGetter : () => sessionIdOrGetter;
176
+
177
+ /**
178
+ * Persistent-abort scheduler. Re-invokes `options.abort()` at 200ms
179
+ * intervals for up to 2 seconds, breaking early when `options.isIdle()`
180
+ * returns true. Closes the retry race window in pi-coding-agent.
181
+ * See change: fix-provider-retry-infinite-loop.
182
+ */
183
+ const PERSISTENT_ABORT_INTERVAL_MS = 200;
184
+ const PERSISTENT_ABORT_MAX_MS = 2000;
185
+ function schedulePersistentAbort(opts: NonNullable<typeof options>): void {
186
+ if (!opts.abort) return;
187
+ const startedAt = Date.now();
188
+ const interval = setInterval(() => {
189
+ if (Date.now() - startedAt >= PERSISTENT_ABORT_MAX_MS) {
190
+ clearInterval(interval);
191
+ return;
192
+ }
193
+ try {
194
+ if (opts.isIdle?.()) {
195
+ clearInterval(interval);
196
+ return;
197
+ }
198
+ } catch { /* probe failure — keep trying */ }
199
+ try { opts.abort?.(); } catch { /* idempotent */ }
200
+ }, PERSISTENT_ABORT_INTERVAL_MS);
201
+ }
202
+
164
203
  return {
165
204
  async handle(msg: ServerToExtensionMessage): Promise<ExtensionToServerMessage | undefined> {
166
205
  const sessionId = getSessionId();
@@ -260,19 +299,29 @@ export function createCommandHandler(
260
299
 
261
300
  if (parsed.type === "slash") {
262
301
  if (options?.sessionPrompt) {
263
- options.sessionPrompt(parsed.text);
302
+ // sessionPrompt (bridge) owns slash-dispatch + flow fast-path +
303
+ // template expansion. It also owns command_feedback emission for
304
+ // extension-command dispatch. Do NOT emit completed here — would
305
+ // duplicate the dispatch path's terminal event.
306
+ // See change: fix-extension-slash-commands-in-dashboard.
307
+ await options.sessionPrompt(parsed.text);
264
308
  } else {
265
- pi.sendUserMessage(parsed.text);
309
+ // Test / non-bridge callers: apply the extension-command dispatch
310
+ // branch inline before falling through to sendUserMessage. Keeps
311
+ // both call sites in lockstep per spec routing-step 9.
312
+ const handled = await tryDispatchExtensionCommand(
313
+ pi,
314
+ parsed.text,
315
+ sessionId,
316
+ options?.eventSink,
317
+ );
318
+ if (!handled) {
319
+ // sendUserMessage exempt from gating: only typed single-line
320
+ // slashes that are NOT extension commands reach this — i.e.
321
+ // skills, prompt templates, unrecognized slashes.
322
+ pi.sendUserMessage(parsed.text);
323
+ }
266
324
  }
267
- options?.eventSink?.({
268
- type: "event_forward",
269
- sessionId,
270
- event: {
271
- eventType: "command_feedback",
272
- timestamp: Date.now(),
273
- data: { command: parsed.text, status: "completed" },
274
- },
275
- });
276
325
  return undefined;
277
326
  }
278
327
 
@@ -280,6 +329,12 @@ export function createCommandHandler(
280
329
  // Multi-line slash commands (e.g. "/skill:foo\nuser text") are classified as
281
330
  // passthrough by parseSendPrompt to preserve images (the slash route strips them),
282
331
  // so we expand prompt templates / skills here before sending.
332
+ //
333
+ // sendUserMessage exempt from extension-dispatch gating: this path handles
334
+ // multi-line slashes and image-bearing messages. Per spec, only typed
335
+ // single-line slash text gates through extension dispatch — multi-line and
336
+ // image-bearing messages go raw to the LLM as before.
337
+ // See change: fix-extension-slash-commands-in-dashboard.
283
338
  let outgoing = msg.text;
284
339
  if (outgoing.startsWith("/")) {
285
340
  outgoing = expandPromptTemplateFromDisk(outgoing, process.cwd(), pi);
@@ -292,6 +347,31 @@ export function createCommandHandler(
292
347
  if (options?.abort) {
293
348
  options.abort();
294
349
  }
350
+ // Synthesize an immediate auto_retry_end so the dashboard clears
351
+ // any in-flight retry banner without waiting for pi's natural
352
+ // auto_retry_end (which is delayed by the abortable-sleep cancel
353
+ // window AND, on extension API, never reaches us at all — see
354
+ // https://github.com/badlogic/pi-mono/discussions/2073). The
355
+ // reducer no-ops auto_retry_end when retryState is undefined,
356
+ // so this is idempotent against later events.
357
+ if (options?.eventSink) {
358
+ options.eventSink({
359
+ type: "event_forward",
360
+ sessionId,
361
+ event: {
362
+ eventType: "auto_retry_end",
363
+ timestamp: Date.now(),
364
+ data: { success: false, attempt: -1, finalError: "Aborted by user" },
365
+ },
366
+ });
367
+ }
368
+ // Persistent-abort scheduler: pi-coding-agent's _retryAbortController
369
+ // is briefly `undefined` between sleep-end and the next
370
+ // agent.continue() call. An abort that arrives in that window is
371
+ // a no-op against the retry. Re-invoke abort every 200ms for up
372
+ // to 2s, breaking early when the agent is idle.
373
+ // See change: fix-provider-retry-infinite-loop.
374
+ if (options) schedulePersistentAbort(options);
295
375
  return undefined;
296
376
 
297
377
  case "request_commands": {
@@ -394,7 +474,7 @@ export function createCommandHandler(
394
474
  case "list_sessions": {
395
475
  try {
396
476
  // Dynamic import to avoid hard dependency at module load
397
- const { SessionManager } = await import("@mariozechner/pi-coding-agent") as any;
477
+ const { SessionManager } = await import("@earendil-works/pi-coding-agent") as any;
398
478
  const cwd = msg.cwd || options?.getCwd?.() || process.cwd();
399
479
  const sessionInfos = await SessionManager.list(cwd);
400
480
  const sessions: PiSessionInfo[] = (sessionInfos || []).map((s: any) => ({
@@ -2,7 +2,7 @@
2
2
  * Flow event wiring: registers listeners for pi-flows events
3
3
  * and forwards them as protocol messages to the dashboard server.
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 { BridgeContext } from "./bridge-context.js";
7
7
  import { filterHiddenCommands } from "./bridge-context.js";
8
8
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
@@ -23,7 +23,7 @@ interface Item {
23
23
 
24
24
  /**
25
25
  * Minimal shape of pi-tui's `Component` interface — we avoid importing from
26
- * `@mariozechner/pi-tui` directly so this module stays compile-friendly when
26
+ * `@earendil-works/pi-tui` directly so this module stays compile-friendly when
27
27
  * that peer dep isn't present (e.g. in unit tests running via vitest without
28
28
  * the full pi runtime).
29
29
  */
@@ -1,15 +1,8 @@
1
1
  // Ambient declarations for pi runtime packages.
2
- // The actual types are provided by whichever host (pi or OMP) loads this extension.
2
+ // The actual types are provided by whichever host loads this extension.
3
3
  // tsconfig paths handles resolution when one of the packages is installed;
4
4
  // these declarations serve as fallback when neither is available (e.g. CI, dev without pi).
5
- declare module "@mariozechner/pi-coding-agent" {
6
- export type ExtensionAPI = import("@oh-my-pi/pi-coding-agent").ExtensionAPI;
7
- }
8
- declare module "@mariozechner/pi-ai" {
9
- export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
10
- }
11
-
12
- declare module "@oh-my-pi/pi-coding-agent" {
5
+ declare module "@earendil-works/pi-coding-agent" {
13
6
  export interface ModelRegistry {
14
7
  getAvailable(): Array<{ provider: string; id: string }>;
15
8
  refresh(): void;
@@ -35,3 +28,17 @@ declare module "@oh-my-pi/pi-coding-agent" {
35
28
  events: EventBus;
36
29
  }
37
30
  }
31
+
32
+ // Legacy fork — re-exports the same ExtensionAPI shape so existing installs still type-check.
33
+ declare module "@mariozechner/pi-coding-agent" {
34
+ export type ExtensionAPI = import("@earendil-works/pi-coding-agent").ExtensionAPI;
35
+ export type ModelRegistry = import("@earendil-works/pi-coding-agent").ModelRegistry;
36
+ export type EventBus = import("@earendil-works/pi-coding-agent").EventBus;
37
+ }
38
+
39
+ declare module "@earendil-works/pi-ai" {
40
+ export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
41
+ }
42
+ declare module "@mariozechner/pi-ai" {
43
+ export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
44
+ }
@@ -6,7 +6,7 @@
6
6
  * by reading template/skill files directly and expanding them.
7
7
  */
8
8
  import { readFileSync, existsSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
9
+ import { dirname, join } from "node:path";
10
10
  import { readdirSync, statSync } from "node:fs";
11
11
  import { buildSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
12
12
 
@@ -54,6 +54,69 @@ function readTemplate(filePath: string): string {
54
54
  return match ? match[1].trim() : content.trim();
55
55
  }
56
56
 
57
+ /**
58
+ * Build the deduped, ordered list of candidate names for `:` ↔ `-` alias resolution.
59
+ * Original form always comes first, preserving the user's typed punctuation as
60
+ * authoritative intent (see design Decision 4: original-form-first precedence).
61
+ */
62
+ function candidateNames(name: string): string[] {
63
+ const variants = new Set<string>();
64
+ variants.add(name);
65
+ if (name.includes(":")) variants.add(name.replace(/:/g, "-"));
66
+ if (name.includes("-")) variants.add(name.replace(/-/g, ":"));
67
+ return [...variants];
68
+ }
69
+
70
+ type Resolution = {
71
+ filePath: string;
72
+ source: "prompt" | "skill";
73
+ resolvedName: string;
74
+ };
75
+
76
+ /**
77
+ * Resolve `templateName` against (a) local prompt/skill scan and (b) pi.getCommands().
78
+ *
79
+ * Probe order is OUTER-loop over candidate-name variants, INNER probe over the
80
+ * three stores. This guarantees original-form-first precedence: every store is
81
+ * consulted on the typed form before any remapped variant is consulted on any
82
+ * store. See design Decision 4.
83
+ */
84
+ function resolveTemplate(
85
+ templateName: string,
86
+ templates: Map<string, string>,
87
+ pi: any | undefined,
88
+ ): Resolution | null {
89
+ for (const cand of candidateNames(templateName)) {
90
+ // Step 1: local-scan prompt/skill key (may be `skill:<dir>` for SKILL.md dirs).
91
+ const local = templates.get(cand);
92
+ if (local) {
93
+ return {
94
+ filePath: local,
95
+ source: cand.startsWith("skill:") ? "skill" : "prompt",
96
+ resolvedName: cand,
97
+ };
98
+ }
99
+ // Step 2: local SKILL.md directory keyed as `skill:<cand>`.
100
+ const localSkill = templates.get(`skill:${cand}`);
101
+ if (localSkill) {
102
+ return { filePath: localSkill, source: "skill", resolvedName: cand };
103
+ }
104
+ // Step 3: pi.getCommands() registry skill.
105
+ if (pi?.getCommands) {
106
+ try {
107
+ const commands = pi.getCommands();
108
+ const skill = commands.find(
109
+ (c: any) => c.name === cand && c.source === "skill" && c.path,
110
+ );
111
+ if (skill?.path && existsSync(skill.path)) {
112
+ return { filePath: skill.path, source: "skill", resolvedName: cand };
113
+ }
114
+ } catch { /* ignore */ }
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+
57
120
  /**
58
121
  * Expand a slash command by finding and reading the prompt template from disk.
59
122
  * Returns the expanded text, or the original text if no template found.
@@ -73,45 +136,21 @@ export function expandPromptTemplateFromDisk(text: string, cwd: string, pi?: any
73
136
  const argsString = m?.[2] ?? "";
74
137
 
75
138
  const templates = findPromptTemplates(cwd);
76
- let filePath = templates.get(templateName);
77
-
78
- // Support colon as alias for hyphen (e.g. /opsx:continue → opsx-continue)
79
- if (!filePath && templateName.includes(":")) {
80
- filePath = templates.get(templateName.replace(/:/g, "-"));
81
- }
82
-
83
- // Fallback: check pi.getCommands() for globally installed skills and package skills
84
- // that aren't in the local .pi/skills/ directory.
85
- if (!filePath && pi?.getCommands) {
86
- try {
87
- const commands = pi.getCommands();
88
- const skill = commands.find(
89
- (c: any) => c.name === templateName && c.source === "skill" && c.path,
90
- );
91
- if (skill?.path && existsSync(skill.path)) {
92
- filePath = skill.path;
93
- }
94
- } catch { /* ignore */ }
95
- }
96
-
97
- if (!filePath) return text;
139
+ const resolution = resolveTemplate(templateName, templates, pi);
140
+ if (!resolution) return text;
98
141
 
99
142
  try {
100
- const content = readTemplate(filePath);
143
+ const content = readTemplate(resolution.filePath);
101
144
 
102
- // Skill detection: either the local-scan key starts with `skill:` or the
103
- // pi.getCommands() fallback resolved a command whose `source === "skill"`
104
- // (we re-check below). Skill expansions wrap in pi's `<skill>` envelope so
105
- // the dashboard ingress path is byte-identical to pi's own _expandSkillCommand,
106
- // which lets the client and server recover the slash-command form.
107
- // See change: render-skill-invocations-collapsibly.
108
- const isSkill = isSkillResolution(templateName, filePath, pi);
109
- if (isSkill) {
110
- const bareName = templateName.replace(/^skill:/, "");
145
+ if (resolution.source === "skill") {
146
+ // Strip leading `skill:` prefix (only present for local-scan step-1 hits
147
+ // whose key was `skill:<dir>`); registry hits and step-2 hits already
148
+ // hold the bare name.
149
+ const bareName = resolution.resolvedName.replace(/^skill:/, "");
111
150
  return buildSkillBlock({
112
151
  name: bareName,
113
- filePath,
114
- baseDir: dirname(filePath),
152
+ filePath: resolution.filePath,
153
+ baseDir: dirname(resolution.filePath),
115
154
  body: content,
116
155
  userArgs: argsString || undefined,
117
156
  });
@@ -126,31 +165,3 @@ export function expandPromptTemplateFromDisk(text: string, cwd: string, pi?: any
126
165
  return text;
127
166
  }
128
167
  }
129
-
130
- /**
131
- * Detect whether the resolved `filePath` came from a skill source.
132
- *
133
- * The local-scan key tells us directly when it starts with `skill:`. For the
134
- * pi.getCommands() fallback we re-query and check `source === "skill"` against
135
- * the same templateName.
136
- */
137
- function isSkillResolution(
138
- templateName: string,
139
- filePath: string,
140
- pi: any | undefined,
141
- ): boolean {
142
- if (templateName.startsWith("skill:")) return true;
143
- // The colon-alias path (e.g. /opsx:continue) maps to a hyphen filename and is
144
- // a prompt template, not a skill. Skills always use the `skill:` prefix in
145
- // both the local scan and pi.getCommands().
146
- if (!pi?.getCommands) return false;
147
- try {
148
- const commands = pi.getCommands();
149
- const match = commands.find(
150
- (c: any) => c.name === templateName && c.source === "skill" && c.path === filePath,
151
- );
152
- return !!match;
153
- } catch {
154
- return false;
155
- }
156
- }