@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -0,0 +1,364 @@
1
+ /**
2
+ * KeeperManager — server-side helper for spawning, writing to, killing,
3
+ * and discovering RPC keeper sidecars.
4
+ *
5
+ * One keeper process per headless session. The keeper itself is
6
+ * `keeper.cjs` (CJS-pure). KeeperManager bridges between the dashboard
7
+ * server's TypeScript world and the spawned CJS subprocess.
8
+ *
9
+ * Tasks: 4.1, 4.2, 4.3, 4.4, 4.5.
10
+ * See: openspec/changes/add-rpc-stdin-dispatch-with-keeper-sidecar
11
+ * - specs/rpc-keeper-sidecar/spec.md (lifecycle + discovery contract)
12
+ * - design.md Decisions 4 + 8
13
+ */
14
+ import {
15
+ existsSync,
16
+ mkdirSync,
17
+ openSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ unlinkSync,
21
+ } from "node:fs";
22
+ import net from "node:net";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import type { ChildProcess } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
27
+ import {
28
+ spawnDetached as defaultSpawnDetached,
29
+ type SpawnDetachedOptions,
30
+ type SpawnDetachedResult,
31
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/detached-spawn.js";
32
+ import {
33
+ isProcessAlive,
34
+ killPidWithGroup,
35
+ } from "@blackbelt-technology/pi-dashboard-shared/platform/process.js";
36
+
37
+ // ── Path conventions ─────────────────────────────────────────────────────────
38
+
39
+ function defaultSessionsDir(): string {
40
+ return path.join(os.homedir(), ".pi", "dashboard", "sessions");
41
+ }
42
+
43
+ function defaultKeeperPath(): string {
44
+ // `keeper.cjs` sits alongside this module. Works under jiti (source dir)
45
+ // and any preserve-structure build (dist/rpc-keeper/keeper.cjs).
46
+ const here = path.dirname(fileURLToPath(import.meta.url));
47
+ return path.join(here, "keeper.cjs");
48
+ }
49
+
50
+ export function sockPathFor(
51
+ sessionsDir: string,
52
+ sessionId: string,
53
+ platform: NodeJS.Platform = process.platform,
54
+ ): string {
55
+ return platform === "win32"
56
+ ? `\\\\.\\pipe\\pi-rpc-${sessionId}`
57
+ : path.join(sessionsDir, `${sessionId}.rpc.sock`);
58
+ }
59
+
60
+ export function pidPathFor(
61
+ sessionsDir: string,
62
+ sessionId: string,
63
+ platform: NodeJS.Platform = process.platform,
64
+ ): string {
65
+ return platform === "win32"
66
+ ? path.join(sessionsDir, `pi-rpc-${sessionId}.pid`)
67
+ : `${sockPathFor(sessionsDir, sessionId, platform)}.pid`;
68
+ }
69
+
70
+ function keeperLogPath(sessionsDir: string, sessionId: string): string {
71
+ return path.join(sessionsDir, `keeper-${sessionId}.log`);
72
+ }
73
+
74
+ // ── Public types ─────────────────────────────────────────────────────────────
75
+
76
+ export interface KeeperSpawnResult {
77
+ success: boolean;
78
+ /** Keeper process PID. NOT pi's PID (pi PID is linked later via token correlation). */
79
+ pid?: number;
80
+ /** Absolute path to the UDS / named pipe the keeper listens on. */
81
+ sockPath?: string;
82
+ /** Underlying child process handle. */
83
+ process?: ChildProcess;
84
+ /** Error message when `success: false`. */
85
+ error?: string;
86
+ }
87
+
88
+ export interface KeeperEntry {
89
+ sessionId: string;
90
+ keeperPid: number;
91
+ sockPath: string;
92
+ }
93
+
94
+ export interface KeeperManager {
95
+ /** Spawn a keeper for `sessionId`. Resolves once the keeper has a PID. */
96
+ spawnKeeperFor(
97
+ sessionId: string,
98
+ cwd: string,
99
+ env: NodeJS.ProcessEnv,
100
+ piArgs?: string[],
101
+ ): Promise<KeeperSpawnResult>;
102
+ /** Connect to keeper UDS, write `line + \n`, close. Never throws. */
103
+ writeRpc(sessionId: string, line: string): Promise<boolean>;
104
+ /**
105
+ * Connect to an arbitrary UDS / named-pipe path, write `line + \n`, close.
106
+ * Used by `headless-pid-registry.writeRpc` so the registry can delegate
107
+ * line-write semantics (3-attempt retry with backoffs, never throws)
108
+ * without re-implementing the connect logic. Returns false on all-attempts-failed.
109
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
110
+ */
111
+ writeRpcToSockPath(sockPath: string, line: string): Promise<boolean>;
112
+ /** SIGTERM the keeper PID for `sessionId` (via process-group on Unix). */
113
+ killKeeper(sessionId: string): boolean;
114
+ /** Scan sessions dir; return live keeper+pi pairs; unlink stale entries. */
115
+ discoverExistingKeepers(): Promise<KeeperEntry[]>;
116
+ /** For tests / introspection. */
117
+ readonly sessionsDir: string;
118
+ }
119
+
120
+ // ── Dependency-injection options ─────────────────────────────────────────────
121
+
122
+ export interface KeeperManagerOptions {
123
+ /** Override the sessions dir (default `~/.pi/dashboard/sessions`). */
124
+ sessionsDir?: string;
125
+ /** Override the absolute path to `keeper.cjs`. */
126
+ keeperPath?: string;
127
+ /** Override the node binary used to invoke the keeper (default `process.execPath`). */
128
+ nodeBinary?: string;
129
+ /**
130
+ * Callback used by `discoverExistingKeepers` to verify the corresponding
131
+ * pi process is alive (the keeper-pid liveness is checked internally).
132
+ * Default: always returns true — caller MUST inject a real probe (typically
133
+ * wired to `headlessPidRegistry`) when using `discoverExistingKeepers` for
134
+ * orphan reconciliation.
135
+ */
136
+ isPiAliveForSession?: (sessionId: string, keeperPid: number) => boolean;
137
+ /**
138
+ * Override the OS for path-convention computation. Default: `process.platform`.
139
+ * Only affects socket / pid-sidecar path shape; spawn dispatch is handled
140
+ * inside `spawnDetached` already.
141
+ */
142
+ platform?: NodeJS.Platform;
143
+ /** Test seam — override `spawnDetached`. */
144
+ spawnDetached?: (opts: SpawnDetachedOptions) => Promise<SpawnDetachedResult>;
145
+ /** Test seam — override `net.createConnection`. */
146
+ createConnection?: typeof net.createConnection;
147
+ }
148
+
149
+ // ── Implementation ───────────────────────────────────────────────────────────
150
+
151
+ /** Per-attempt connect timeout for `writeRpc`. */
152
+ const WRITE_RPC_ATTEMPT_TIMEOUT_MS = 350;
153
+ /** Backoffs before retry attempts 2 and 3. Task 4.3. */
154
+ const WRITE_RPC_RETRY_DELAYS_MS = [50, 150];
155
+ /** Total attempts including the initial one. */
156
+ const WRITE_RPC_MAX_ATTEMPTS = 3;
157
+
158
+ export function createKeeperManager(opts: KeeperManagerOptions = {}): KeeperManager {
159
+ const sessionsDir = opts.sessionsDir ?? defaultSessionsDir();
160
+ const keeperPath = opts.keeperPath ?? defaultKeeperPath();
161
+ const nodeBinary = opts.nodeBinary ?? process.execPath;
162
+ const platform = opts.platform ?? process.platform;
163
+ const isPiAlive = opts.isPiAliveForSession ?? (() => true);
164
+ const spawnDetached = opts.spawnDetached ?? defaultSpawnDetached;
165
+ const createConnection = opts.createConnection ?? net.createConnection;
166
+
167
+ // sessionId → keeperPid for fast killKeeper without rescanning the dir.
168
+ // (Discovery rebuilds this from the filesystem on startup.)
169
+ const tracked = new Map<string, number>();
170
+
171
+ function ensureSessionsDir(): void {
172
+ try { mkdirSync(sessionsDir, { recursive: true }); } catch { /* mkdir failure surfaced by keeper itself */ }
173
+ }
174
+
175
+ async function spawnKeeperFor(
176
+ sessionId: string,
177
+ cwd: string,
178
+ env: NodeJS.ProcessEnv,
179
+ piArgs?: string[],
180
+ ): Promise<KeeperSpawnResult> {
181
+ if (!sessionId || typeof sessionId !== "string") {
182
+ return { success: false, error: "sessionId required" };
183
+ }
184
+ if (!existsSync(keeperPath)) {
185
+ return { success: false, error: `keeper.cjs not found at ${keeperPath}` };
186
+ }
187
+ ensureSessionsDir();
188
+
189
+ // Per-spawn log for the parent-side stdio capture. The keeper itself
190
+ // writes its primary log to `keeper-<sid>.log`; this captures any
191
+ // bootstrap stderr (e.g. keeper failed to open its own log).
192
+ const launchLogPath = path.join(sessionsDir, `keeper-launch-${sessionId}.log`);
193
+ let logFd: number | undefined;
194
+ try { logFd = openSync(launchLogPath, "a"); } catch { logFd = undefined; }
195
+
196
+ // Forward pi argv to the keeper via env var (avoids shell-quoting
197
+ // pitfalls of stuffing them into argv). Keeper reads PI_KEEPER_PI_ARGS
198
+ // and strips it from pi's env before spawning pi. Defaults to bare RPC
199
+ // when piArgs is omitted, preserving simple test/direct-invocation use.
200
+ const keeperEnv: NodeJS.ProcessEnv = piArgs && piArgs.length > 0
201
+ ? { ...env, PI_KEEPER_PI_ARGS: JSON.stringify(piArgs) }
202
+ : env;
203
+
204
+ // Delegate to the shared cross-platform primitive so libuv-correct
205
+ // defaults (detached: true on POSIX, Job-Object exclusion + windowsHide
206
+ // on win32) are uniform.
207
+ const r = await spawnDetached({
208
+ cmd: nodeBinary,
209
+ args: [keeperPath, sessionId],
210
+ cwd,
211
+ env: keeperEnv,
212
+ logFd,
213
+ stdinMode: "ignore",
214
+ detach: true,
215
+ });
216
+
217
+ if (!r.ok || typeof r.pid !== "number") {
218
+ return { success: false, error: r.error ?? "spawn returned no pid" };
219
+ }
220
+
221
+ // Detach: let the keeper continue if this Node process exits.
222
+ try { r.process?.unref(); } catch { /* ignore */ }
223
+
224
+ tracked.set(sessionId, r.pid);
225
+
226
+ return {
227
+ success: true,
228
+ pid: r.pid,
229
+ sockPath: sockPathFor(sessionsDir, sessionId, platform),
230
+ process: r.process,
231
+ };
232
+ }
233
+
234
+ function tryConnectAndWrite(sockPath: string, line: string, timeoutMs: number): Promise<boolean> {
235
+ return new Promise((resolve) => {
236
+ let settled = false;
237
+ const settle = (ok: boolean): void => {
238
+ if (settled) return;
239
+ settled = true;
240
+ resolve(ok);
241
+ };
242
+
243
+ let sock: net.Socket;
244
+ try {
245
+ sock = createConnection(sockPath);
246
+ } catch {
247
+ settle(false);
248
+ return;
249
+ }
250
+
251
+ const timer = setTimeout(() => {
252
+ try { sock.destroy(); } catch { /* ignore */ }
253
+ settle(false);
254
+ }, timeoutMs);
255
+
256
+ sock.once("connect", () => {
257
+ sock.end(line.endsWith("\n") ? line : line + "\n", "utf8", () => {
258
+ clearTimeout(timer);
259
+ settle(true);
260
+ });
261
+ });
262
+ sock.once("error", () => {
263
+ clearTimeout(timer);
264
+ settle(false);
265
+ });
266
+ });
267
+ }
268
+
269
+ async function writeRpcToSockPath(sockPath: string, line: string): Promise<boolean> {
270
+ for (let attempt = 0; attempt < WRITE_RPC_MAX_ATTEMPTS; attempt++) {
271
+ if (attempt > 0) {
272
+ await new Promise((r) => setTimeout(r, WRITE_RPC_RETRY_DELAYS_MS[attempt - 1]));
273
+ }
274
+ const ok = await tryConnectAndWrite(sockPath, line, WRITE_RPC_ATTEMPT_TIMEOUT_MS).catch(() => false);
275
+ if (ok) return true;
276
+ }
277
+ return false;
278
+ }
279
+
280
+ async function writeRpc(sessionId: string, line: string): Promise<boolean> {
281
+ const sockPath = sockPathFor(sessionsDir, sessionId, platform);
282
+ return writeRpcToSockPath(sockPath, line);
283
+ }
284
+
285
+ function killKeeper(sessionId: string): boolean {
286
+ const pid = tracked.get(sessionId);
287
+ if (typeof pid !== "number") return false;
288
+ try {
289
+ killPidWithGroup(pid, "SIGTERM");
290
+ return true;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ function readPidSidecar(p: string): number | null {
297
+ try {
298
+ const raw = readFileSync(p, "utf8").trim();
299
+ const n = Number.parseInt(raw, 10);
300
+ return Number.isFinite(n) && n > 0 ? n : null;
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+
306
+ function unlinkQuiet(p: string): void {
307
+ try { unlinkSync(p); } catch { /* ignore */ }
308
+ }
309
+
310
+ async function discoverExistingKeepers(): Promise<KeeperEntry[]> {
311
+ if (!existsSync(sessionsDir)) return [];
312
+ let names: string[];
313
+ try { names = readdirSync(sessionsDir); } catch { return []; }
314
+
315
+ const result: KeeperEntry[] = [];
316
+ // The PID sidecar is the source of truth (Windows named pipes have no
317
+ // filesystem entry to scan). On Unix the .pid sidecar lives alongside
318
+ // the .sock; on Windows it's named `pi-rpc-<sid>.pid`.
319
+ const isWin = platform === "win32";
320
+ for (const name of names) {
321
+ let sessionId: string | null = null;
322
+ if (isWin) {
323
+ const m = name.match(/^pi-rpc-(.+)\.pid$/);
324
+ if (m) sessionId = m[1];
325
+ } else {
326
+ const m = name.match(/^(.+)\.rpc\.sock\.pid$/);
327
+ if (m) sessionId = m[1];
328
+ }
329
+ if (!sessionId) continue;
330
+
331
+ const pidFile = path.join(sessionsDir, name);
332
+ const sockPath = sockPathFor(sessionsDir, sessionId, platform);
333
+ const keeperPid = readPidSidecar(pidFile);
334
+
335
+ if (!keeperPid || !isProcessAlive(keeperPid)) {
336
+ // Stale keeper sidecar: clean it up. Best-effort socket unlink too.
337
+ unlinkQuiet(pidFile);
338
+ if (!isWin) unlinkQuiet(sockPath);
339
+ continue;
340
+ }
341
+
342
+ if (!isPiAlive(sessionId, keeperPid)) {
343
+ // Keeper alive but pi dead → kill keeper, clean up.
344
+ try { killPidWithGroup(keeperPid, "SIGTERM"); } catch { /* ignore */ }
345
+ unlinkQuiet(pidFile);
346
+ if (!isWin) unlinkQuiet(sockPath);
347
+ continue;
348
+ }
349
+
350
+ tracked.set(sessionId, keeperPid);
351
+ result.push({ sessionId, keeperPid, sockPath });
352
+ }
353
+ return result;
354
+ }
355
+
356
+ return {
357
+ spawnKeeperFor,
358
+ writeRpc,
359
+ writeRpcToSockPath,
360
+ killKeeper,
361
+ discoverExistingKeepers,
362
+ sessionsDir,
363
+ };
364
+ }
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * RPC keeper sidecar.
4
+ *
5
+ * Spawned by the dashboard server as `node keeper.cjs <sessionId>`.
6
+ * Owns pi's stdin pipe; forwards JSON-line writes received on a per-session
7
+ * UDS / named pipe to pi's stdin verbatim. Outlives the dashboard server.
8
+ *
9
+ * CommonJS-pure: only Node built-ins. No jiti / tsx / typescript loader.
10
+ * Mirrors the constraint pattern of `preload-fastify.cjs`.
11
+ *
12
+ * See: openspec/changes/add-rpc-stdin-dispatch-with-keeper-sidecar
13
+ * - specs/rpc-keeper-sidecar/spec.md
14
+ * - design.md (Decisions 1, 2, 3, 8, 9)
15
+ */
16
+
17
+ "use strict";
18
+
19
+ const child_process = require("child_process");
20
+ const fs = require("fs");
21
+ const net = require("net");
22
+ const os = require("os");
23
+ const path = require("path");
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Args + paths
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const sessionId = process.argv[2];
30
+ if (!sessionId || typeof sessionId !== "string") {
31
+ // Cannot open the keeper log without a sessionId. Write to stderr (which the
32
+ // KeeperManager wires to the spawn log) and exit non-zero.
33
+ process.stderr.write("[keeper] FATAL: missing sessionId argv[2]\n");
34
+ process.exit(2);
35
+ }
36
+
37
+ const SESSIONS_DIR = path.join(os.homedir(), ".pi", "dashboard", "sessions");
38
+ try {
39
+ fs.mkdirSync(SESSIONS_DIR, { recursive: true });
40
+ } catch (_e) { /* ignore — fs.openSync below will fail with a clearer error */ }
41
+
42
+ // Socket / pipe path conventions per spec (Decision 3 in design.md)
43
+ const isWindows = process.platform === "win32";
44
+ const sockPath = isWindows
45
+ ? `\\\\.\\pipe\\pi-rpc-${sessionId}`
46
+ : path.join(SESSIONS_DIR, `${sessionId}.rpc.sock`);
47
+
48
+ // PID sidecar conventions per rpc-keeper-sidecar Requirement
49
+ const pidPath = isWindows
50
+ ? path.join(SESSIONS_DIR, `pi-rpc-${sessionId}.pid`)
51
+ : `${sockPath}.pid`;
52
+
53
+ const logPath = path.join(SESSIONS_DIR, `keeper-${sessionId}.log`);
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Logger
57
+ // ---------------------------------------------------------------------------
58
+
59
+ let logFd;
60
+ try {
61
+ logFd = fs.openSync(logPath, "a");
62
+ } catch (e) {
63
+ process.stderr.write(`[keeper ${sessionId}] FATAL: cannot open log ${logPath}: ${e && e.message}\n`);
64
+ process.exit(2);
65
+ }
66
+
67
+ function log(line) {
68
+ try {
69
+ fs.writeSync(logFd, `[${new Date().toISOString()}] ${line}\n`);
70
+ } catch (_e) { /* swallow — log failure should not crash the keeper */ }
71
+ }
72
+
73
+ log(`keeper starting: sessionId=${sessionId} pid=${process.pid} sockPath=${sockPath}`);
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Shutdown coordination
77
+ // ---------------------------------------------------------------------------
78
+
79
+ let shuttingDown = false;
80
+ let server; // net.Server
81
+ let piChild; // child_process.ChildProcess
82
+
83
+ function unlinkQuiet(p) {
84
+ try { fs.unlinkSync(p); } catch (_e) { /* ignore */ }
85
+ }
86
+
87
+ function shutdown(exitCode, reason) {
88
+ if (shuttingDown) return;
89
+ shuttingDown = true;
90
+ log(`shutdown: code=${exitCode} reason=${reason || "n/a"}`);
91
+
92
+ // Close the server first so no new connections come in.
93
+ try { if (server) server.close(); } catch (_e) { /* ignore */ }
94
+
95
+ // Best-effort cleanup. On Windows named pipes the socket file itself is
96
+ // virtual and need not be unlinked; on Unix we unlink the socket file.
97
+ if (!isWindows) unlinkQuiet(sockPath);
98
+ unlinkQuiet(pidPath);
99
+
100
+ // Don't wait on logFd close — process.exit will tear down fds.
101
+ process.exit(exitCode);
102
+ }
103
+
104
+ process.on("SIGTERM", () => shutdown(0, "SIGTERM"));
105
+ process.on("SIGINT", () => shutdown(0, "SIGINT"));
106
+ process.on("uncaughtException", (e) => {
107
+ log(`uncaughtException: ${e && e.stack ? e.stack : e}`);
108
+ shutdown(1, "uncaughtException");
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Bind socket BEFORE spawning pi (Decision 4 / Failure-modes Requirement)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function startServer(retried) {
116
+ return new Promise((resolve) => {
117
+ const s = net.createServer(handleConnection);
118
+
119
+ s.once("error", (err) => {
120
+ // EADDRINUSE (Unix) / EADDRINUSE-like on Windows pipes: stale socket file
121
+ // from a previous keeper that crashed without cleanup. Per spec: unlink
122
+ // and retry exactly once.
123
+ if (!retried && err && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
124
+ log(`bind failed (${err.code}); unlinking stale path and retrying once`);
125
+ if (!isWindows) unlinkQuiet(sockPath);
126
+ // small backoff before retry
127
+ setTimeout(() => {
128
+ startServer(true).then(resolve);
129
+ }, 50);
130
+ return;
131
+ }
132
+ log(`FATAL: bind failed (retried=${retried}): ${err && err.message}`);
133
+ shutdown(2, "bind-failed");
134
+ resolve(null);
135
+ });
136
+
137
+ s.listen(sockPath, () => {
138
+ log(`socket bound: ${sockPath}`);
139
+ // Set restrictive permissions on Unix UDS file (Windows pipes use ACLs).
140
+ if (!isWindows) {
141
+ try { fs.chmodSync(sockPath, 0o600); } catch (_e) { /* best-effort */ }
142
+ }
143
+ resolve(s);
144
+ });
145
+ });
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Connection handler — JSON-lines, fire-and-forget, dumb wire
150
+ // ---------------------------------------------------------------------------
151
+
152
+ function handleConnection(sock) {
153
+ log(`connection accepted`);
154
+ let buf = "";
155
+
156
+ sock.setEncoding("utf8");
157
+ sock.on("data", (chunk) => {
158
+ buf += chunk;
159
+ // Split on \n; keep the trailing partial in buf.
160
+ let nl;
161
+ // eslint-disable-next-line no-cond-assign
162
+ while ((nl = buf.indexOf("\n")) !== -1) {
163
+ const line = buf.slice(0, nl);
164
+ buf = buf.slice(nl + 1);
165
+ forwardLine(line);
166
+ }
167
+ });
168
+ sock.on("end", () => {
169
+ // Flush a trailing line without newline as a complete line. Pi's RPC
170
+ // reader expects newline-framed lines, so we must append \n anyway.
171
+ if (buf.length > 0) {
172
+ forwardLine(buf);
173
+ buf = "";
174
+ }
175
+ log(`connection ended`);
176
+ });
177
+ sock.on("error", (err) => {
178
+ log(`connection error: ${err && err.message}`);
179
+ });
180
+ }
181
+
182
+ function forwardLine(line) {
183
+ // No JSON parsing or content validation — keeper is a dumb wire.
184
+ if (!piChild || !piChild.stdin || piChild.stdin.destroyed) {
185
+ log(`drop line (pi stdin unavailable): ${line.slice(0, 80)}`);
186
+ return;
187
+ }
188
+ try {
189
+ piChild.stdin.write(line + "\n");
190
+ } catch (e) {
191
+ // pi.stdin EPIPE etc. Logged, but the actual EPIPE handler below will
192
+ // trigger shutdown via pi.stdin.on("error", ...) on the next event-loop tick.
193
+ log(`forwardLine error: ${e && e.message}`);
194
+ }
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Pi spawn + lifecycle
199
+ // ---------------------------------------------------------------------------
200
+
201
+ const CRASH_WINDOW_MS = 300;
202
+ let piSpawnedAt = 0;
203
+
204
+ function readPiArgs() {
205
+ // PI_KEEPER_PI_ARGS is a JSON-encoded string array of pi argv tokens.
206
+ // Set by KeeperManager to forward the dashboard's per-spawn flags
207
+ // (--session-file, --mode continue, --fork, etc.) so resume / fork
208
+ // round-trip correctly through the keeper. Default falls back to
209
+ // bare RPC mode for direct invocations and tests.
210
+ const raw = process.env.PI_KEEPER_PI_ARGS;
211
+ if (!raw) return ["--mode", "rpc"];
212
+ try {
213
+ const parsed = JSON.parse(raw);
214
+ if (Array.isArray(parsed) && parsed.every((s) => typeof s === "string")) {
215
+ return parsed;
216
+ }
217
+ log(`WARN: PI_KEEPER_PI_ARGS not a string[]; falling back to default`);
218
+ } catch (e) {
219
+ log(`WARN: PI_KEEPER_PI_ARGS parse failed (${e && e.message}); falling back to default`);
220
+ }
221
+ return ["--mode", "rpc"];
222
+ }
223
+
224
+ function spawnPi() {
225
+ const piArgs = readPiArgs();
226
+ log(`spawning pi ${piArgs.join(" ")}`);
227
+ // env is inherited from process.env (KeeperManager already set up the
228
+ // proper PATH and PI_DASHBOARD_SPAWNED). Defensively set the flag again
229
+ // here in case the keeper is invoked manually. Strip the keeper-internal
230
+ // PI_KEEPER_PI_ARGS so it doesn't leak into pi's env.
231
+ const env = Object.assign({}, process.env, { PI_DASHBOARD_SPAWNED: "1" });
232
+ delete env.PI_KEEPER_PI_ARGS;
233
+
234
+ piSpawnedAt = Date.now();
235
+ const c = child_process.spawn("pi", piArgs, {
236
+ stdio: ["pipe", logFd, logFd],
237
+ env,
238
+ cwd: process.cwd(),
239
+ windowsHide: true,
240
+ });
241
+
242
+ c.on("error", (err) => {
243
+ log(`pi spawn error: ${err && err.message}`);
244
+ shutdown(1, "pi-spawn-error");
245
+ });
246
+
247
+ c.on("exit", (code, signal) => {
248
+ const elapsed = Date.now() - piSpawnedAt;
249
+ log(`pi exited code=${code} signal=${signal} elapsed=${elapsed}ms`);
250
+ // If pi exited within the crash-detection window, surface a non-zero
251
+ // exit code so the parent (KeeperManager / process-manager) can preserve
252
+ // the existing dashboard PI_CRASHED semantic. Otherwise a graceful pi
253
+ // exit → keeper exit 0.
254
+ if (elapsed < CRASH_WINDOW_MS) {
255
+ shutdown(1, "pi-crashed-early");
256
+ } else {
257
+ shutdown(0, "pi-exit");
258
+ }
259
+ });
260
+
261
+ // Detect EPIPE / closed-stream errors on pi.stdin: per spec, treat as same
262
+ // as pi.exit (the pipe is gone; pi will follow shortly if not already).
263
+ if (c.stdin) {
264
+ c.stdin.on("error", (err) => {
265
+ log(`pi.stdin error: ${err && err.code}/${err && err.message}`);
266
+ // EPIPE is the canonical case; treat any stdin error as terminal.
267
+ shutdown(0, "pi-stdin-error");
268
+ });
269
+ }
270
+
271
+ return c;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Startup orchestration
276
+ // ---------------------------------------------------------------------------
277
+
278
+ async function main() {
279
+ // 1. Bind socket FIRST so the server can start retrying immediately.
280
+ server = await startServer(false);
281
+ if (!server) return; // shutdown already triggered
282
+
283
+ // 2. Write PID sidecar.
284
+ try {
285
+ fs.writeFileSync(pidPath, String(process.pid), "utf8");
286
+ } catch (e) {
287
+ log(`FATAL: cannot write PID sidecar ${pidPath}: ${e && e.message}`);
288
+ shutdown(2, "pid-sidecar-write");
289
+ return;
290
+ }
291
+
292
+ // 3. Spawn pi.
293
+ piChild = spawnPi();
294
+
295
+ // 4. Crash-detection window: emit the "keeper ready" marker once pi has
296
+ // survived the crash window. The crash-on-early-exit decision itself is
297
+ // made by the c.on("exit") handler comparing elapsed vs CRASH_WINDOW_MS
298
+ // — unifying the two paths so the early-exit code wins regardless of
299
+ // which fires first.
300
+ setTimeout(() => {
301
+ if (shuttingDown) return;
302
+ if (piChild && piChild.exitCode === null && piChild.signalCode === null) {
303
+ log(`keeper ready: ${sessionId}`);
304
+ }
305
+ // If pi already exited, c.on("exit") has already (or will imminently)
306
+ // call shutdown(1) via the elapsed-time check.
307
+ }, CRASH_WINDOW_MS);
308
+ }
309
+
310
+ main().catch((e) => {
311
+ log(`FATAL main: ${e && e.stack ? e.stack : e}`);
312
+ shutdown(2, "main-rejected");
313
+ });