@blackbelt-technology/pi-agent-dashboard 0.5.1 → 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 (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -6
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Pins the Bridge → `launchDashboardServer` forwarding contract: the
3
+ * extension's `launchServer` must always pass `starter: "Bridge"`,
4
+ * `stdio: "ignore"`, and `healthTimeoutMs: 2_000`. The shared
5
+ * launcher is mocked so this test never spawns a real server.
6
+ *
7
+ * See change: unify-server-launch-ts-loader (§3.1.2).
8
+ */
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+ import type { launchDashboardServer } from "@blackbelt-technology/pi-dashboard-shared/server-launcher.js";
11
+
12
+ const { launchSpy } = vi.hoisted(() => ({
13
+ launchSpy: vi.fn<typeof launchDashboardServer>(async () => ({ childPid: 1, reportedPid: 1, healthOk: true as const })),
14
+ }));
15
+
16
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/server-launcher.js", () => ({
17
+ launchDashboardServer: launchSpy,
18
+ JitiNotFoundError: class JitiNotFoundError extends Error {},
19
+ PortConflictError: class PortConflictError extends Error {},
20
+ EarlyExitError: class EarlyExitError extends Error { code: number | null = null; },
21
+ }));
22
+
23
+ import { launchServer } from "../server-launcher.js";
24
+
25
+ const cfg = {
26
+ port: 3000,
27
+ piPort: 4000,
28
+ autoStart: true,
29
+ autoShutdown: true,
30
+ shutdownIdleSeconds: 300,
31
+ spawnStrategy: "tmux" as const,
32
+ tunnel: { enabled: true },
33
+ devBuildOnReload: false,
34
+ memoryLimits: { maxEventsPerSession: 5000, maxStringFieldSize: 0, maxWsBufferBytes: 4194304 },
35
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
36
+ defaultModel: "",
37
+ trustedNetworks: [],
38
+ resolvedTrustedNetworks: [],
39
+ cors: { allowedOrigins: [] },
40
+ electronMode: false,
41
+ } as any;
42
+
43
+ beforeEach(() => {
44
+ launchSpy.mockClear();
45
+ launchSpy.mockResolvedValue({ childPid: 1, reportedPid: 1, healthOk: true } as any);
46
+ });
47
+
48
+ describe("Bridge launchServer → launchDashboardServer forwarding", () => {
49
+ it("passes starter:Bridge + stdio:ignore + 2s health timeout + port", async () => {
50
+ const r = await launchServer(cfg);
51
+ expect(r.success).toBe(true);
52
+ expect(launchSpy).toHaveBeenCalledOnce();
53
+ const opts = launchSpy.mock.calls[0]![0]!;
54
+ expect(opts.starter).toBe("Bridge");
55
+ expect(opts.stdio).toBe("ignore");
56
+ expect(opts.healthTimeoutMs).toBe(2000);
57
+ expect(opts.port).toBe(3000);
58
+ expect(opts.extraArgs).toEqual(["--port", "3000", "--pi-port", "4000"]);
59
+ });
60
+
61
+ it("maps JitiNotFoundError to a failed LaunchResult (no throw)", async () => {
62
+ const { JitiNotFoundError } = await import("@blackbelt-technology/pi-dashboard-shared/server-launcher.js");
63
+ launchSpy.mockRejectedValueOnce(new JitiNotFoundError("loader missing"));
64
+ const r = await launchServer(cfg);
65
+ expect(r.success).toBe(false);
66
+ expect(r.message).toContain("loader missing");
67
+ });
68
+
69
+ it("maps EarlyExitError to a failed LaunchResult mentioning the exit code", async () => {
70
+ const { EarlyExitError } = await import("@blackbelt-technology/pi-dashboard-shared/server-launcher.js");
71
+ const err = new (EarlyExitError as unknown as new (...args: unknown[]) => Error & { code: number })();
72
+ err.code = 17;
73
+ launchSpy.mockRejectedValueOnce(err);
74
+ const r = await launchServer(cfg);
75
+ expect(r.success).toBe(false);
76
+ expect(r.message).toMatch(/code=17/);
77
+ });
78
+ });
@@ -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 {
@@ -35,6 +35,7 @@ import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./proce
35
35
  import { scanChildProcesses } from "./process-scanner.js";
36
36
  import type { BridgeContext } from "./bridge-context.js";
37
37
  import { filterHiddenCommands, extractFirstMessage, getCurrentModelString } from "./bridge-context.js";
38
+ import { tryDispatchExtensionCommand } from "./slash-dispatch.js";
38
39
  import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySessionEntries, handleSessionChange as _handleSessionChange } from "./session-sync.js";
39
40
  import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged, sendJjStateIfChanged as _sendJjStateIfChanged, resetReconnectCaches as _resetReconnectCaches } from "./model-tracker.js";
40
41
  import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
@@ -694,26 +695,37 @@ function initBridge(pi: ExtensionAPI) {
694
695
  spawnNew: () => {
695
696
  connection.send({ type: "spawn_new_session", sessionId, cwd: process.cwd() });
696
697
  },
697
- sessionPrompt: (text) => {
698
- // 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.
699
701
  if (text.startsWith("/") && pi.events) {
700
702
  const cmdText = text.slice(1);
701
703
  const spaceIdx = cmdText.indexOf(" ");
702
704
  const cmdName = spaceIdx === -1 ? cmdText : cmdText.slice(0, spaceIdx);
703
705
  const cmdArgs = spaceIdx === -1 ? "" : cmdText.slice(spaceIdx + 1);
704
706
 
705
- // Flow management commands from buttons use flow_management message type.
706
- // Typed /flows:new, /flows:edit, /flows:delete in chat input fall through
707
- // to the slash command handler below, which invokes pi's command system
708
- // via pi.sendUserMessage (with ui-proxy handling ctx.ui calls).
709
-
710
- // 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.
711
708
  const flowsList = getFlowsList();
712
709
  if (flowsList.some(f => f.name === cmdName)) {
713
710
  pi.events.emit("flow:run", { flowName: cmdName, task: cmdArgs.trim() || undefined });
714
711
  return;
715
712
  }
716
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
+
717
729
  // Fallback: send as user message (template-expanded).
718
730
  // Uses deliverAs:followUp so it queues properly when agent is streaming.
719
731
  // expandPromptTemplateFromDisk handles skill commands (/skill:xxx) and
@@ -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"]);
@@ -161,8 +162,14 @@ export function createCommandHandler(
161
162
  spawnNew?: () => void;
162
163
  /** Switch model via pi.setModel() */
163
164
  setModel?: (provider: string, modelId: string) => Promise<void>;
164
- /** Route slash commands through session.prompt() */
165
- 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>;
166
173
  },
167
174
  ): CommandHandler {
168
175
  const getSessionId = typeof sessionIdOrGetter === "function" ? sessionIdOrGetter : () => sessionIdOrGetter;
@@ -292,19 +299,29 @@ export function createCommandHandler(
292
299
 
293
300
  if (parsed.type === "slash") {
294
301
  if (options?.sessionPrompt) {
295
- 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);
296
308
  } else {
297
- 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
+ }
298
324
  }
299
- options?.eventSink?.({
300
- type: "event_forward",
301
- sessionId,
302
- event: {
303
- eventType: "command_feedback",
304
- timestamp: Date.now(),
305
- data: { command: parsed.text, status: "completed" },
306
- },
307
- });
308
325
  return undefined;
309
326
  }
310
327
 
@@ -312,6 +329,12 @@ export function createCommandHandler(
312
329
  // Multi-line slash commands (e.g. "/skill:foo\nuser text") are classified as
313
330
  // passthrough by parseSendPrompt to preserve images (the slash route strips them),
314
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.
315
338
  let outgoing = msg.text;
316
339
  if (outgoing.startsWith("/")) {
317
340
  outgoing = expandPromptTemplateFromDisk(outgoing, process.cwd(), pi);
@@ -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
- }
@@ -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
  }