@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
@@ -13,7 +13,9 @@ import type { NetworkGuard } from "./route-deps.js";
13
13
  import { detectEditors, EDITORS } from "../editor-registry.js";
14
14
  import { detectCodeServerBinary, resetDetectionCache } from "../editor-detection.js";
15
15
  import { readConfigRedacted, writeConfigPartial } from "../config-api.js";
16
- import { createTunnel, deleteTunnel, getTunnelStatus } from "../tunnel.js";
16
+ import { createTunnel, deleteTunnel, getTunnelStatus, getTunnelUrl } from "../tunnel.js";
17
+ import { getModelProxyStatus } from "../model-proxy/registry-singleton.js";
18
+ import { startTunnelWatchdog, stopTunnelWatchdog } from "../tunnel-watchdog.js";
17
19
  import { spawnRestart } from "../restart-helper.js";
18
20
  import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
19
21
  import path from "node:path";
@@ -152,6 +154,29 @@ export function registerSystemRoutes(
152
154
  if (partial.openspec !== undefined && directoryService) {
153
155
  directoryService.reconfigurePolling(reloaded.openspec);
154
156
  }
157
+ // Live-reload tunnel watchdog when its config changes (no restart needed).
158
+ // We always restart the watchdog when partial.tunnel is present and a
159
+ // tunnel is currently active — covers both watchdog flag changes and
160
+ // numeric tweaks. Cheap operation: stop + start with new config.
161
+ if (partial.tunnel !== undefined) {
162
+ config.tunnelWatchdog = reloaded.tunnel.watchdog;
163
+ if (getTunnelUrl()) {
164
+ stopTunnelWatchdog();
165
+ const wd = reloaded.tunnel.watchdog;
166
+ if (wd?.enabled !== false) {
167
+ startTunnelWatchdog(
168
+ {
169
+ getUrl: getTunnelUrl,
170
+ recycle: async () => {
171
+ await deleteTunnel(config.port);
172
+ return await createTunnel(config.port, config.tunnelReservedToken);
173
+ },
174
+ },
175
+ wd,
176
+ );
177
+ }
178
+ }
179
+ }
155
180
 
156
181
  return { success: true, restartRequired: result.restartRequired };
157
182
  },
@@ -167,13 +192,29 @@ export function registerSystemRoutes(
167
192
  if (status.status === "active") return { ok: true, url: status.url };
168
193
  if (status.status === "unavailable") return { ok: false, error: "zrok not installed" };
169
194
  const url = await createTunnel(config.port, config.tunnelReservedToken);
170
- if (url) return { ok: true, url };
195
+ if (url) {
196
+ const wd = config.tunnelWatchdog;
197
+ if (wd?.enabled !== false) {
198
+ startTunnelWatchdog(
199
+ {
200
+ getUrl: getTunnelUrl,
201
+ recycle: async () => {
202
+ await deleteTunnel(config.port);
203
+ return await createTunnel(config.port, config.tunnelReservedToken);
204
+ },
205
+ },
206
+ wd,
207
+ );
208
+ }
209
+ return { ok: true, url };
210
+ }
171
211
  return { ok: false, error: "Failed to create tunnel" };
172
212
  });
173
213
 
174
214
  fastify.post("/api/tunnel-disconnect", async () => {
175
215
  // Pass port so orphan zrok processes bound to this endpoint are also
176
216
  // swept (not just the one we tracked via pid-file).
217
+ stopTunnelWatchdog();
177
218
  await deleteTunnel(config.port);
178
219
  return { ok: true };
179
220
  });
@@ -206,6 +247,7 @@ export function registerSystemRoutes(
206
247
  },
207
248
  agents: agentMetrics,
208
249
  plugins: getPluginStatusStore().listAll(),
250
+ proxy: getModelProxyStatus(),
209
251
  };
210
252
  });
211
253
 
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ # PATH-shim used in keeper.test.ts to make `pi --mode rpc` invoke our
3
+ # mock-pi.cjs instead of the real pi binary.
4
+ #
5
+ # The keeper spawns `pi --mode rpc` from PATH; we prepend the dir
6
+ # containing this script (named `pi`) to PATH so this shim wins.
7
+ # The path to mock-pi.cjs is passed via env var so the shim can be
8
+ # placed anywhere without copying mock-pi.cjs alongside it.
9
+ exec node "${MOCK_PI_CJS_PATH:?MOCK_PI_CJS_PATH env var required}" "$@"
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mock pi for keeper integration tests.
4
+ *
5
+ * Reads JSON-line input from stdin and appends each line to the file at
6
+ * `process.env.MOCK_PI_LOG`. Exits 0 on stdin EOF.
7
+ *
8
+ * Behavior modes (via env):
9
+ * MOCK_PI_MODE=normal (default) — read until EOF, log lines, exit 0
10
+ * MOCK_PI_MODE=crash — exit non-zero immediately (tests
11
+ * keeper crash-detection window)
12
+ *
13
+ * CommonJS-pure, only Node built-ins.
14
+ */
15
+ "use strict";
16
+
17
+ const fs = require("fs");
18
+
19
+ const mode = process.env.MOCK_PI_MODE || "normal";
20
+ const logPath = process.env.MOCK_PI_LOG;
21
+
22
+ if (mode === "crash") {
23
+ process.stderr.write("[mock-pi] crash mode: exiting 1 immediately\n");
24
+ process.exit(1);
25
+ }
26
+
27
+ if (!logPath) {
28
+ process.stderr.write("[mock-pi] FATAL: MOCK_PI_LOG env var required\n");
29
+ process.exit(2);
30
+ }
31
+
32
+ let buf = "";
33
+ process.stdin.setEncoding("utf8");
34
+ process.stdin.on("data", (chunk) => {
35
+ buf += chunk;
36
+ let nl;
37
+ // eslint-disable-next-line no-cond-assign
38
+ while ((nl = buf.indexOf("\n")) !== -1) {
39
+ const line = buf.slice(0, nl);
40
+ buf = buf.slice(nl + 1);
41
+ fs.appendFileSync(logPath, line + "\n");
42
+ }
43
+ });
44
+ process.stdin.on("end", () => {
45
+ if (buf.length > 0) {
46
+ fs.appendFileSync(logPath, buf + "\n");
47
+ }
48
+ process.exit(0);
49
+ });
50
+ process.stdin.on("error", () => process.exit(0));
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Keeper integration tests.
3
+ *
4
+ * Spawns `node packages/server/src/rpc-keeper/keeper.cjs <sessionId>` as a
5
+ * real subprocess (NOT via jiti / tsx — the whole point is that keeper.cjs
6
+ * runs under bare node). A `pi` PATH shim invokes a `mock-pi.cjs` fixture
7
+ * so we exercise the spawn path without needing a real pi binary.
8
+ *
9
+ * Note re tasks.md 3.1: spec says ".test.cjs". We write the driver in TS
10
+ * (existing vitest glob is `*.test.ts`); the BINARY-under-test is still
11
+ * pure CJS. The CJS contract is what we verify — the test runner is irrelevant.
12
+ *
13
+ * Tasks covered: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7.
14
+ */
15
+ import { spawn, type ChildProcess } from "node:child_process";
16
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, unlinkSync, rmSync } from "node:fs";
17
+ import net from "node:net";
18
+ import path from "node:path";
19
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
20
+
21
+ const KEEPER_PATH = path.resolve(__dirname, "..", "keeper.cjs");
22
+ const FIXTURES_DIR = path.resolve(__dirname, "fixtures");
23
+ const SHIM_DIR = FIXTURES_DIR;
24
+
25
+ // macOS UDS sun_path is 104 bytes. The root `npm test` HOME under
26
+ // /var/folders/.../pi-test-XXXXXX is ~73 chars before any further nesting,
27
+ // which exceeds the limit once we append `.pi/dashboard/sessions/<uuid>.rpc.sock`.
28
+ // Each test mints its OWN short HOME under /tmp/p... (≤ 12 chars), passed to
29
+ // the keeper subprocess via env. The npm-test HOME isolation tripwire is
30
+ // unaffected — we only override HOME for the spawned child, not the test
31
+ // runner itself. We still create the per-test HOME under /tmp (not the npm-test
32
+ // HOME) because /tmp is short, AND we keep the test isolated from production paths.
33
+ function sessionsDirIn(home: string): string {
34
+ return path.join(home, ".pi", "dashboard", "sessions");
35
+ }
36
+ function sockPathIn(home: string, sid: string): string {
37
+ return process.platform === "win32"
38
+ ? `\\\\.\\pipe\\pi-rpc-${sid}`
39
+ : path.join(sessionsDirIn(home), `${sid}.rpc.sock`);
40
+ }
41
+ function pidPathIn(home: string, sid: string): string {
42
+ return process.platform === "win32"
43
+ ? path.join(sessionsDirIn(home), `pi-rpc-${sid}.pid`)
44
+ : `${sockPathIn(home, sid)}.pid`;
45
+ }
46
+ function keeperLogIn(home: string, sid: string): string {
47
+ return path.join(sessionsDirIn(home), `keeper-${sid}.log`);
48
+ }
49
+
50
+ function makeSessionId(): string {
51
+ // Short ID to keep total UDS path comfortably under 104 bytes even on
52
+ // edge-case test environments.
53
+ return `t${Math.floor(Math.random() * 1e9).toString(36)}`;
54
+ }
55
+
56
+ function makeShortHome(): string {
57
+ // /tmp resolves to /private/tmp on macOS but Node uses the path as-given
58
+ // for UDS bind; either resolved form fits well under 104 bytes.
59
+ // mkdtempSync('/tmp/p') yields '/tmp/pXXXXXX' (≈12 chars).
60
+ return mkdtempSync(path.join("/tmp", "p"));
61
+ }
62
+
63
+ interface SpawnedKeeper {
64
+ child: ChildProcess;
65
+ sessionId: string;
66
+ home: string;
67
+ mockPiLog: string;
68
+ exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
69
+ }
70
+
71
+ // Convenience accessors that route through the keeper's own home.
72
+ function sockPathFor(s: SpawnedKeeper): string { return sockPathIn(s.home, s.sessionId); }
73
+ function pidPathFor(s: SpawnedKeeper): string { return pidPathIn(s.home, s.sessionId); }
74
+ function keeperLogFor(s: SpawnedKeeper): string { return keeperLogIn(s.home, s.sessionId); }
75
+
76
+ interface SpawnKeeperOpts {
77
+ /** "normal" (default) or "crash" (mock-pi exits 1 immediately) */
78
+ mode?: "normal" | "crash";
79
+ /** Override sessionId; otherwise auto-generated */
80
+ sessionId?: string;
81
+ }
82
+
83
+ interface SpawnKeeperOptsExt extends SpawnKeeperOpts {
84
+ /** Override HOME (default: short tmp dir under /tmp/p...). */
85
+ home?: string;
86
+ /** If true, do NOT pre-create sessionsDir (tests stale-socket scenarios). */
87
+ skipMkdir?: boolean;
88
+ }
89
+
90
+ async function spawnKeeper(opts: SpawnKeeperOptsExt = {}): Promise<SpawnedKeeper> {
91
+ const sessionId = opts.sessionId ?? makeSessionId();
92
+ const home = opts.home ?? makeShortHome();
93
+ if (!opts.skipMkdir) mkdirSync(sessionsDirIn(home), { recursive: true });
94
+
95
+ const mockPiLog = path.join(sessionsDirIn(home), `mock-pi-${sessionId}.log`);
96
+
97
+ // PATH shim: prepend a dir containing a `pi` script that execs our mock.
98
+ const tmpBin = path.join(home, "bin");
99
+ mkdirSync(tmpBin, { recursive: true });
100
+ const piShimDest = path.join(tmpBin, "pi");
101
+ const shimSrc = path.join(SHIM_DIR, "mock-pi-shim.sh");
102
+ writeFileSync(piShimDest, readFileSync(shimSrc, "utf8"), { mode: 0o755 });
103
+
104
+ const env: NodeJS.ProcessEnv = {
105
+ ...process.env,
106
+ HOME: home,
107
+ PATH: `${tmpBin}${path.delimiter}${process.env.PATH ?? ""}`,
108
+ MOCK_PI_CJS_PATH: path.join(SHIM_DIR, "mock-pi.cjs"),
109
+ MOCK_PI_LOG: mockPiLog,
110
+ MOCK_PI_MODE: opts.mode ?? "normal",
111
+ };
112
+
113
+ const child = spawn(process.execPath, [KEEPER_PATH, sessionId], {
114
+ env,
115
+ stdio: ["ignore", "pipe", "pipe"],
116
+ });
117
+
118
+ // Capture stderr for diagnostics on test failure.
119
+ child.stderr?.on("data", (b) => {
120
+ if (process.env.KEEPER_TEST_DEBUG) process.stderr.write(`[keeper:${sessionId}] ${b}`);
121
+ });
122
+
123
+ const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
124
+ (resolve) => {
125
+ child.once("exit", (code, signal) => resolve({ code, signal }));
126
+ },
127
+ );
128
+
129
+ return { child, sessionId, home, mockPiLog, exited };
130
+ }
131
+
132
+ async function waitFor(predicate: () => boolean, timeoutMs = 3000, intervalMs = 25): Promise<void> {
133
+ const start = Date.now();
134
+ while (Date.now() - start < timeoutMs) {
135
+ if (predicate()) return;
136
+ await new Promise((r) => setTimeout(r, intervalMs));
137
+ }
138
+ throw new Error(`waitFor timed out after ${timeoutMs}ms`);
139
+ }
140
+
141
+ async function readyKeeper(s: SpawnedKeeper): Promise<void> {
142
+ // "Ready" = (a) socket bound, (b) pid sidecar written, (c) past 300ms
143
+ // crash window AND keeper still running.
144
+ await waitFor(() => existsSync(pidPathFor(s)));
145
+ if (process.platform !== "win32") {
146
+ await waitFor(() => existsSync(sockPathFor(s)));
147
+ }
148
+ // Past the crash window
149
+ await new Promise((r) => setTimeout(r, 350));
150
+ if (s.child.exitCode !== null) {
151
+ const log = existsSync(keeperLogFor(s))
152
+ ? readFileSync(keeperLogFor(s), "utf8")
153
+ : "(no log)";
154
+ throw new Error(`keeper exited prematurely (code=${s.child.exitCode}). Log:\n${log}`);
155
+ }
156
+ }
157
+
158
+ async function writeLineToKeeper(s: SpawnedKeeper, line: string): Promise<void> {
159
+ await new Promise<void>((resolve, reject) => {
160
+ const sock = net.createConnection(sockPathFor(s));
161
+ sock.once("connect", () => {
162
+ sock.end(line + "\n", "utf8", () => resolve());
163
+ });
164
+ sock.once("error", reject);
165
+ });
166
+ }
167
+
168
+ async function killAndAwait(s: SpawnedKeeper, signal: NodeJS.Signals = "SIGTERM"): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
169
+ if (s.child.exitCode === null) s.child.kill(signal);
170
+ return s.exited;
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Cleanup state across tests
175
+ // ---------------------------------------------------------------------------
176
+
177
+ const trackedKeepers: SpawnedKeeper[] = [];
178
+ beforeEach(() => {
179
+ trackedKeepers.length = 0;
180
+ });
181
+ afterEach(async () => {
182
+ for (const k of trackedKeepers) {
183
+ if (k.child.exitCode === null) {
184
+ k.child.kill("SIGKILL");
185
+ await k.exited.catch(() => undefined);
186
+ }
187
+ try { rmSync(k.home, { recursive: true, force: true }); } catch { /* ignore */ }
188
+ }
189
+ });
190
+
191
+ function track(s: SpawnedKeeper): SpawnedKeeper {
192
+ trackedKeepers.push(s);
193
+ return s;
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Tests
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe.skipIf(process.platform === "win32")("rpc-keeper (Unix UDS)", () => {
201
+ it("3.2 forwards a JSON line from UDS connection to mock-pi stdin", async () => {
202
+ const k = track(await spawnKeeper());
203
+ await readyKeeper(k);
204
+
205
+ const line = '{"type":"prompt","message":"hello","id":"1"}';
206
+ await writeLineToKeeper(k, line);
207
+
208
+ // Mock pi appends each line to MOCK_PI_LOG. Wait for it.
209
+ await waitFor(() => existsSync(k.mockPiLog) && readFileSync(k.mockPiLog, "utf8").includes("hello"));
210
+
211
+ const contents = readFileSync(k.mockPiLog, "utf8");
212
+ expect(contents.trimEnd()).toBe(line);
213
+
214
+ // The keeper still has pi alive — clean up.
215
+ await killAndAwait(k);
216
+ }, 10_000);
217
+
218
+ it("3.3 keeper exits 0 and unlinks files when pi exits", async () => {
219
+ const k = track(await spawnKeeper());
220
+ await readyKeeper(k);
221
+
222
+ expect(existsSync(sockPathFor(k))).toBe(true);
223
+ expect(existsSync(pidPathFor(k))).toBe(true);
224
+
225
+ // Read the keeper's pi child PID via lsof? Simpler: kill the keeper's
226
+ // parent's pi child by PGID-equivalent strategy — but that's racy.
227
+ // Instead, use the shutdown path that's the same code: send SIGTERM
228
+ // to the keeper, which closes pi's stdin → mock-pi sees EOF → exit 0.
229
+ // This test exercises the shared shutdown handler path that ALSO
230
+ // fires on pi-exit (via child.on("exit") → shutdown(0)).
231
+ const result = await killAndAwait(k, "SIGTERM");
232
+
233
+ expect(result.code).toBe(0);
234
+ expect(existsSync(sockPathFor(k))).toBe(false);
235
+ expect(existsSync(pidPathFor(k))).toBe(false);
236
+ }, 10_000);
237
+
238
+ it("3.3b keeper exits 0 and unlinks files when pi child exits naturally", async () => {
239
+ // Stronger version of 3.3: trigger pi's exit (not keeper's signal).
240
+ // We connect, send EOF to mock-pi indirectly by closing all input
241
+ // routes. Easiest path: write a line and end the conn — mock-pi will
242
+ // log the line but won't exit (it waits for stdin EOF, which only
243
+ // closes when keeper closes pi.stdin, which only happens on keeper
244
+ // shutdown). So instead: send SIGTERM to the mock-pi child PID by
245
+ // searching its process tree.
246
+ const k = track(await spawnKeeper());
247
+ await readyKeeper(k);
248
+
249
+ // Find mock-pi children of the keeper (best-effort via /proc on Linux,
250
+ // ps on macOS).
251
+ const mockPids = await findChildPids(k.child.pid!);
252
+ expect(mockPids.length).toBeGreaterThan(0);
253
+
254
+ for (const pid of mockPids) {
255
+ try { process.kill(pid, "SIGTERM"); } catch { /* gone */ }
256
+ }
257
+
258
+ const result = await k.exited;
259
+ expect(result.code).toBe(0);
260
+ expect(existsSync(sockPathFor(k))).toBe(false);
261
+ expect(existsSync(pidPathFor(k))).toBe(false);
262
+ }, 10_000);
263
+
264
+ it("3.4 stale-socket recovery (pre-create socket file, keeper unlinks + retries)", async () => {
265
+ const sessionId = makeSessionId();
266
+ const home = makeShortHome();
267
+ mkdirSync(sessionsDirIn(home), { recursive: true });
268
+ // Pre-create a regular file at the socket path. Bind fails with EADDRINUSE.
269
+ writeFileSync(sockPathIn(home, sessionId), "", { mode: 0o600 });
270
+
271
+ const k = track(await spawnKeeper({ sessionId, home }));
272
+ await readyKeeper(k);
273
+
274
+ // Recovery succeeded: the path is now bound (existsSync returns true for sockets too).
275
+ expect(existsSync(sockPathFor(k))).toBe(true);
276
+
277
+ await killAndAwait(k);
278
+ }, 10_000);
279
+
280
+ it("3.5 crash-detection: mock-pi exits immediately, keeper exits non-zero within 1s", async () => {
281
+ const k = track(await spawnKeeper({ mode: "crash" }));
282
+
283
+ // Should NOT reach readyKeeper — wait for exit instead, with a tight bound.
284
+ const result = await Promise.race([
285
+ k.exited,
286
+ new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((_, reject) =>
287
+ setTimeout(() => reject(new Error("keeper did not exit within 2s")), 2000),
288
+ ),
289
+ ]);
290
+ expect(result.code).not.toBe(0);
291
+
292
+ // Files cleaned up
293
+ expect(existsSync(sockPathFor(k))).toBe(false);
294
+ expect(existsSync(pidPathFor(k))).toBe(false);
295
+ }, 5_000);
296
+
297
+ it("3.6 concurrent connections — 3 simultaneous UDS connections, all 3 lines forwarded", async () => {
298
+ const k = track(await spawnKeeper());
299
+ await readyKeeper(k);
300
+
301
+ const lines = [
302
+ '{"type":"prompt","message":"line-A","id":"a"}',
303
+ '{"type":"prompt","message":"line-B","id":"b"}',
304
+ '{"type":"prompt","message":"line-C","id":"c"}',
305
+ ];
306
+
307
+ await Promise.all(lines.map((line) => writeLineToKeeper(k, line)));
308
+
309
+ await waitFor(() => {
310
+ if (!existsSync(k.mockPiLog)) return false;
311
+ const c = readFileSync(k.mockPiLog, "utf8");
312
+ return lines.every((l) => c.includes(l));
313
+ });
314
+
315
+ const out = readFileSync(k.mockPiLog, "utf8")
316
+ .split("\n")
317
+ .filter((l) => l.length > 0)
318
+ .sort();
319
+ expect(out).toEqual([...lines].sort());
320
+
321
+ await killAndAwait(k);
322
+ }, 10_000);
323
+ });
324
+
325
+ describe.skipIf(process.platform !== "win32")("rpc-keeper (Windows named pipe)", () => {
326
+ // Task 3.7: same scenarios as Unix, gated by platform.
327
+ // Windows path uses `\\.\pipe\pi-rpc-<sid>` and `<sessionsDir>/pi-rpc-<sid>.pid`.
328
+ // Leaving as a single smoke test for now — full coverage of all 3.x cases
329
+ // requires a Windows CI runner. The spec scenarios apply identically; the
330
+ // helper functions above already path-switch by platform.
331
+
332
+ it("3.7 keeper bound named pipe, forwards a line, exits cleanly on signal", async () => {
333
+ const k = track(await spawnKeeper());
334
+ await readyKeeper(k);
335
+
336
+ const line = '{"type":"prompt","message":"hello","id":"1"}';
337
+ await writeLineToKeeper(k, line);
338
+
339
+ await waitFor(() => existsSync(k.mockPiLog) && readFileSync(k.mockPiLog, "utf8").includes("hello"));
340
+
341
+ const result = await killAndAwait(k);
342
+ expect(result.code).toBe(0);
343
+ // Named pipe path is virtual on Windows — only the PID sidecar is unlinked.
344
+ expect(existsSync(pidPathFor(k))).toBe(false);
345
+ }, 15_000);
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Helpers
350
+ // ---------------------------------------------------------------------------
351
+
352
+ async function findChildPids(parentPid: number): Promise<number[]> {
353
+ // macOS / Linux: `ps -o pid= --ppid <pid>`
354
+ return new Promise((resolve) => {
355
+ // -A is required to see processes outside the calling terminal session;
356
+ // vitest workers don't have a controlling tty, so without -A the keeper's
357
+ // child node process is invisible.
358
+ const ps = spawn("ps", ["-A", "-o", "pid=", "-o", "ppid="], { stdio: ["ignore", "pipe", "ignore"] });
359
+ let out = "";
360
+ ps.stdout.on("data", (b) => { out += b; });
361
+ ps.once("exit", () => {
362
+ const pids: number[] = [];
363
+ for (const raw of out.split("\n")) {
364
+ const m = raw.trim().match(/^(\d+)\s+(\d+)$/);
365
+ if (m && Number(m[2]) === parentPid) pids.push(Number(m[1]));
366
+ }
367
+ resolve(pids);
368
+ });
369
+ ps.once("error", () => resolve([]));
370
+ });
371
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Dispatch router for the bridge→server `dispatch_extension_command`
3
+ * message. Forwards the slash command to the per-session RPC keeper UDS
4
+ * and emits the terminal `command_feedback` event to browser subscribers
5
+ * (the bridge already emitted `started`).
6
+ *
7
+ * Optimistic completion (design.md Decision 7): a successful UDS write
8
+ * means pi RECEIVED the line; if pi rejects it, an `extension_error`
9
+ * event flows back over the bridge WS path and is rendered as a separate
10
+ * chat row by the existing event-reducer.
11
+ *
12
+ * The terminal `completed` / `error` event MUST be persisted in the
13
+ * dashboard's `eventStore` (same path the bridge's `event_forward` takes)
14
+ * — otherwise the chat pill renders the bridge's persisted `started`
15
+ * but the server's optimistic terminal is ephemeral and the upsert never
16
+ * fires on browser reattach. Stuck-on-"in progress" was the visible
17
+ * symptom of routing the broadcast directly via `sendToSubscribers`
18
+ * without storing first.
19
+ *
20
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 8).
21
+ */
22
+ import type { DispatchExtensionCommandMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
23
+ import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
24
+
25
+ export interface DispatchRouterContext {
26
+ headlessPidRegistry: HeadlessPidRegistry;
27
+ /**
28
+ * Persist + broadcast a `command_feedback` event for `sessionId`.
29
+ * Wired by `event-wiring.ts` to `eventStore.insertEvent` +
30
+ * `browserGateway.broadcastEvent` so the event survives browser
31
+ * reattach via the existing replay path.
32
+ */
33
+ emitCommandFeedback(
34
+ sessionId: string,
35
+ command: string,
36
+ status: "completed" | "error",
37
+ message?: string,
38
+ ): void;
39
+ }
40
+
41
+ /**
42
+ * Build the pi RPC line forwarded over the keeper UDS. Pure helper so
43
+ * unit tests don't have to JSON-parse to assert the shape.
44
+ */
45
+ export function buildPiRpcLine(command: string, requestId: string): string {
46
+ return JSON.stringify({ type: "prompt", message: command, id: requestId });
47
+ }
48
+
49
+ const ERR_NO_KEEPER = "RPC keeper unavailable for this session";
50
+ const ERR_WRITE_PREFIX = "Failed to write RPC line";
51
+
52
+ /**
53
+ * Handle `dispatch_extension_command`: write the pi RPC line to the
54
+ * session's keeper UDS, then persist + broadcast the optimistic terminal
55
+ * `command_feedback`. Never throws; failures surface as
56
+ * `command_feedback {status:"error"}` via `emitCommandFeedback`.
57
+ */
58
+ export async function handleDispatchExtensionCommand(
59
+ msg: DispatchExtensionCommandMessage,
60
+ ctx: DispatchRouterContext,
61
+ ): Promise<void> {
62
+ const { sessionId, command, requestId } = msg;
63
+ const line = buildPiRpcLine(command, requestId);
64
+ console.error(
65
+ `[dispatch-router] dispatch_extension_command sid=${sessionId} cmd=${command} reqId=${requestId.slice(0, 8)}`,
66
+ );
67
+
68
+ let ok = false;
69
+ try {
70
+ ok = await ctx.headlessPidRegistry.writeRpc(sessionId, line);
71
+ } catch (err: any) {
72
+ const reason = err instanceof Error ? err.message : String(err);
73
+ ctx.emitCommandFeedback(sessionId, command, "error", `${ERR_WRITE_PREFIX}: ${reason}`);
74
+ return;
75
+ }
76
+
77
+ if (!ok) {
78
+ console.error(`[dispatch-router] writeRpc returned false for sid=${sessionId} (no keeper or socket gone)`);
79
+ ctx.emitCommandFeedback(sessionId, command, "error", ERR_NO_KEEPER);
80
+ return;
81
+ }
82
+
83
+ console.error(`[dispatch-router] writeRpc OK for sid=${sessionId}, emitting optimistic completed`);
84
+ ctx.emitCommandFeedback(sessionId, command, "completed");
85
+ }