@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.
- package/AGENTS.md +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- 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
|
-
//
|
|
45
|
-
|
|
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
|
|
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
|
-
/**
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 {
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
110
|
+
message: `Server process exited (code=${err.code}) before health check. See ~/.pi/dashboard/server.log`,
|
|
148
111
|
};
|
|
149
112
|
}
|
|
150
|
-
|
|
151
|
-
return { success:
|
|
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
|
}
|