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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -6
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Per-repo OpenSpec change-grouping store.
3
+ *
4
+ * On-disk shape: `<cwd>/openspec/groups/groups.json` containing
5
+ * `{ schemaVersion, groups, assignments }`. File is opt-in: absent → empty
6
+ * default. First write creates the directory atomically.
7
+ *
8
+ * Concurrency model:
9
+ * - Reads stat the file (microseconds, OS-cached) and short-circuit on a
10
+ * `(mtimeMs, size)` cache hit. Concurrent reads in the same tick share
11
+ * a single in-flight promise to avoid stampedes.
12
+ * - Writes serialize per-cwd via a FIFO promise chain. Inside the
13
+ * critical section the store re-stats before rename; on mtime drift
14
+ * it re-reads, re-applies the mutator, and retries once. A second
15
+ * drift surfaces as `ConcurrentEditError` (HTTP 409 at the route).
16
+ * - After every successful write a 100 ms trailing debounce schedules
17
+ * one `subscribe()` callback per cwd, regardless of write rate.
18
+ *
19
+ * See change: add-openspec-change-grouping (tasks 2.1–2.17).
20
+ */
21
+ import fs from "node:fs/promises";
22
+ import path from "node:path";
23
+ import {
24
+ OPENSPEC_GROUPS_SCHEMA_VERSION,
25
+ type OpenSpecData,
26
+ type OpenSpecGroup,
27
+ type OpenSpecGroupsFile,
28
+ } from "@blackbelt-technology/pi-dashboard-shared/types.js";
29
+
30
+ /**
31
+ * Pure helper. Returns a new `OpenSpecData` with every `OpenSpecChange.groupId`
32
+ * populated from the provided assignments map (`changeName → groupId`).
33
+ * Changes without an entry get `groupId: null` (Ungrouped). Used by
34
+ * `directory-service` after `buildOpenSpecData` and before broadcast so all
35
+ * clients see a single joined view.
36
+ *
37
+ * See change: add-openspec-change-grouping (tasks 4.1–4.2).
38
+ */
39
+ export function joinGroupIdsToOpenSpecData(
40
+ data: OpenSpecData,
41
+ assignments: Record<string, string>,
42
+ ): OpenSpecData {
43
+ return {
44
+ ...data,
45
+ changes: data.changes.map((c) => ({
46
+ ...c,
47
+ groupId: assignments[c.name] ?? null,
48
+ })),
49
+ };
50
+ }
51
+
52
+ // ── Errors ───────────────────────────────────────────────────────
53
+
54
+ export class ConcurrentEditError extends Error {
55
+ /** Current on-disk payload at the time the conflict was detected. */
56
+ readonly current: OpenSpecGroupsFile;
57
+ constructor(current: OpenSpecGroupsFile) {
58
+ super("Concurrent edit detected");
59
+ this.name = "ConcurrentEditError";
60
+ this.current = current;
61
+ }
62
+ }
63
+
64
+ export class UnsupportedSchemaVersionError extends Error {
65
+ readonly version: unknown;
66
+ constructor(version: unknown, message?: string) {
67
+ super(message ?? `unsupported schema version: ${String(version)}`);
68
+ this.name = "UnsupportedSchemaVersionError";
69
+ this.version = version;
70
+ }
71
+ }
72
+
73
+ export class GroupNotFoundError extends Error {
74
+ readonly id: string;
75
+ constructor(id: string) {
76
+ super(`Group not found: ${id}`);
77
+ this.name = "GroupNotFoundError";
78
+ this.id = id;
79
+ }
80
+ }
81
+
82
+ export class UnknownGroupIdError extends Error {
83
+ readonly id: string;
84
+ constructor(id: string) {
85
+ super(`Unknown groupId: ${id}`);
86
+ this.name = "UnknownGroupIdError";
87
+ this.id = id;
88
+ }
89
+ }
90
+
91
+ // ── Public surface ───────────────────────────────────────────────
92
+
93
+ export interface OpenSpecGroupStoreOptions {
94
+ /** Trailing-debounce window for subscriber callbacks in ms. Default 100. */
95
+ debounceMs?: number;
96
+ /**
97
+ * Test-only hook fired AFTER the temp file is staged, BEFORE the rename.
98
+ * Tests use this to simulate hand-edit / `git pull` races. Production
99
+ * MUST leave this undefined.
100
+ */
101
+ __testHookBeforeRename?: (cwd: string) => Promise<void> | void;
102
+ }
103
+
104
+ export interface OpenSpecGroupStore {
105
+ read(cwd: string): Promise<OpenSpecGroupsFile>;
106
+ createGroup(cwd: string, body: { name: string; color?: string }): Promise<OpenSpecGroup>;
107
+ updateGroup(
108
+ cwd: string,
109
+ id: string,
110
+ body: { name?: string; color?: string; order?: number },
111
+ ): Promise<OpenSpecGroup>;
112
+ deleteGroup(cwd: string, id: string): Promise<void>;
113
+ setAssignment(cwd: string, changeName: string, groupId: string | null): Promise<void>;
114
+ /**
115
+ * Subscribe to debounced post-write broadcasts. Returns an unsubscribe fn.
116
+ * The callback receives the cwd plus the latest `{ groups, assignments }`.
117
+ */
118
+ subscribe(
119
+ cb: (cwd: string, payload: { groups: OpenSpecGroup[]; assignments: Record<string, string> }) => void,
120
+ ): () => void;
121
+ /** Flushes pending broadcasts and clears caches. Tests + shutdown. */
122
+ dispose(): void;
123
+ }
124
+
125
+ // ── Internal cache shape ─────────────────────────────────────────
126
+
127
+ interface CacheEntry {
128
+ mtimeMs: number;
129
+ size: number;
130
+ data: OpenSpecGroupsFile | undefined;
131
+ inFlight?: Promise<OpenSpecGroupsFile>;
132
+ }
133
+
134
+ const DEFAULT_DEBOUNCE_MS = 100;
135
+
136
+ function emptyFile(): OpenSpecGroupsFile {
137
+ return { schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION, groups: [], assignments: {} };
138
+ }
139
+
140
+ function pathFor(cwd: string): string {
141
+ return path.join(cwd, "openspec", "groups", "groups.json");
142
+ }
143
+
144
+ function dirFor(cwd: string): string {
145
+ return path.join(cwd, "openspec", "groups");
146
+ }
147
+
148
+ function slugify(name: string): string {
149
+ const base = name
150
+ .toLowerCase()
151
+ .normalize("NFKD")
152
+ .replace(/[\u0300-\u036f]/g, "") // strip accents
153
+ .replace(/[^a-z0-9]+/g, "-")
154
+ .replace(/^-+|-+$/g, "");
155
+ return base.length > 0 ? base : "group";
156
+ }
157
+
158
+ function uniqueSlug(base: string, existing: ReadonlySet<string>): string {
159
+ if (!existing.has(base)) return base;
160
+ let n = 2;
161
+ while (existing.has(`${base}-${n}`)) n++;
162
+ return `${base}-${n}`;
163
+ }
164
+
165
+ function validateSchemaVersion(parsed: unknown): asserts parsed is OpenSpecGroupsFile {
166
+ if (typeof parsed !== "object" || parsed === null) {
167
+ throw new UnsupportedSchemaVersionError(undefined, "groups.json must be an object");
168
+ }
169
+ const v = (parsed as { schemaVersion?: unknown }).schemaVersion;
170
+ if (v === undefined) {
171
+ throw new UnsupportedSchemaVersionError(undefined, "missing schemaVersion field");
172
+ }
173
+ if (v !== OPENSPEC_GROUPS_SCHEMA_VERSION) {
174
+ throw new UnsupportedSchemaVersionError(v);
175
+ }
176
+ }
177
+
178
+ /** Re-pack `order` values to contiguous `0..N-1` while preserving sort order. */
179
+ function normalizeOrders(groups: OpenSpecGroup[]): OpenSpecGroup[] {
180
+ const sorted = [...groups].sort((a, b) => a.order - b.order);
181
+ return sorted.map((g, i) => ({ ...g, order: i }));
182
+ }
183
+
184
+ // ── Factory ──────────────────────────────────────────────────────
185
+
186
+ export function createOpenSpecGroupStore(
187
+ opts: OpenSpecGroupStoreOptions = {},
188
+ ): OpenSpecGroupStore {
189
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
190
+ const hook = opts.__testHookBeforeRename;
191
+
192
+ const cache = new Map<string, CacheEntry>();
193
+ const writeMutex = new Map<string, Promise<void>>();
194
+ const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
195
+ type Subscriber = (
196
+ cwd: string,
197
+ payload: { groups: OpenSpecGroup[]; assignments: Record<string, string> },
198
+ ) => void;
199
+ const subscribers = new Set<Subscriber>();
200
+
201
+ async function tryStat(filePath: string): Promise<{ mtimeMs: number; size: number } | null> {
202
+ try {
203
+ const s = await fs.stat(filePath);
204
+ return { mtimeMs: s.mtimeMs, size: s.size };
205
+ } catch (err: any) {
206
+ if (err?.code === "ENOENT") return null;
207
+ throw err;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Read the file via the mtime-gated cache. Returns the empty default when
213
+ * absent. Throws `UnsupportedSchemaVersionError` on bad version.
214
+ */
215
+ async function read(cwd: string): Promise<OpenSpecGroupsFile> {
216
+ const filePath = pathFor(cwd);
217
+
218
+ // Short-circuit a concurrent in-flight read.
219
+ const existing = cache.get(cwd);
220
+ if (existing?.inFlight) return existing.inFlight;
221
+
222
+ const inFlight = (async (): Promise<OpenSpecGroupsFile> => {
223
+ const stat = await tryStat(filePath);
224
+ if (!stat) {
225
+ cache.delete(cwd);
226
+ return emptyFile();
227
+ }
228
+ const cached = cache.get(cwd);
229
+ if (cached?.data && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
230
+ return cached.data;
231
+ }
232
+ const raw = await fs.readFile(filePath, "utf-8");
233
+ let parsed: unknown;
234
+ try {
235
+ parsed = JSON.parse(raw);
236
+ } catch (err) {
237
+ throw new UnsupportedSchemaVersionError(undefined, `groups.json parse error: ${(err as Error).message}`);
238
+ }
239
+ validateSchemaVersion(parsed);
240
+ const data = parsed as OpenSpecGroupsFile;
241
+ cache.set(cwd, { mtimeMs: stat.mtimeMs, size: stat.size, data });
242
+ return data;
243
+ })();
244
+
245
+ // Stash the in-flight promise so concurrent callers share it.
246
+ const slot: CacheEntry = existing ?? { mtimeMs: 0, size: 0, data: undefined };
247
+ slot.inFlight = inFlight;
248
+ cache.set(cwd, slot);
249
+
250
+ try {
251
+ return await inFlight;
252
+ } finally {
253
+ const e = cache.get(cwd);
254
+ if (e?.inFlight === inFlight) {
255
+ delete e.inFlight;
256
+ // If the read produced no data (e.g. file vanished mid-read), purge
257
+ // the placeholder slot rather than leak `mtimeMs: 0` forever.
258
+ if (!e.data) cache.delete(cwd);
259
+ }
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Run a per-cwd mutation under the FIFO mutex. The mutator receives the
265
+ * current file payload and returns a fresh payload. Implements the
266
+ * mtime-recheck-before-rename + 1-shot retry on race.
267
+ */
268
+ async function mutate<T>(
269
+ cwd: string,
270
+ mutator: (current: OpenSpecGroupsFile) => { next: OpenSpecGroupsFile; result: T },
271
+ ): Promise<T> {
272
+ const prev = writeMutex.get(cwd) ?? Promise.resolve();
273
+ let release!: () => void;
274
+ const slot = new Promise<void>((resolve) => {
275
+ release = resolve;
276
+ });
277
+ writeMutex.set(cwd, prev.then(() => slot));
278
+
279
+ try {
280
+ await prev;
281
+ // Try once, then retry once on race.
282
+ for (let attempt = 0; attempt < 2; attempt++) {
283
+ const filePath = pathFor(cwd);
284
+ const preStat = await tryStat(filePath);
285
+ const preMtime = preStat?.mtimeMs ?? null;
286
+ const preSize = preStat?.size ?? null;
287
+ const current = await read(cwd);
288
+ const { next, result } = mutator(current);
289
+ const tmpPath = filePath + ".tmp";
290
+ await fs.mkdir(dirFor(cwd), { recursive: true });
291
+ const serialized = JSON.stringify(next, null, 2) + "\n";
292
+ await fs.writeFile(tmpPath, serialized);
293
+ if (hook) {
294
+ await hook(cwd);
295
+ }
296
+ // Re-stat the original; if mtime/size changed since pre-read, race.
297
+ const postStat = await tryStat(filePath);
298
+ const postMtime = postStat?.mtimeMs ?? null;
299
+ const postSize = postStat?.size ?? null;
300
+ const raced = preMtime !== postMtime || preSize !== postSize;
301
+ if (raced) {
302
+ // Drop temp; retry once, else throw.
303
+ await fs.rm(tmpPath, { force: true });
304
+ if (attempt === 0) continue;
305
+ // Surface current payload for HTTP 409.
306
+ // Force a fresh read by invalidating the cache.
307
+ cache.delete(cwd);
308
+ const currentFile = await read(cwd);
309
+ throw new ConcurrentEditError(currentFile);
310
+ }
311
+ await fs.rename(tmpPath, filePath);
312
+ // Update cache directly with the new file's stat.
313
+ const finalStat = await fs.stat(filePath);
314
+ cache.set(cwd, {
315
+ mtimeMs: finalStat.mtimeMs,
316
+ size: finalStat.size,
317
+ data: next,
318
+ });
319
+ scheduleBroadcast(cwd, next);
320
+ return result;
321
+ }
322
+ // Unreachable.
323
+ throw new ConcurrentEditError(await read(cwd));
324
+ } finally {
325
+ release();
326
+ // Clean up exhausted mutex slots so the map doesn't leak per-cwd.
327
+ // Once the chain is fully drained, drop the entry.
328
+ // (No-op when newer writes are queued behind us.)
329
+ Promise.resolve(writeMutex.get(cwd)).then(() => {
330
+ // If still pointing at our slot's tail, drop.
331
+ if (writeMutex.get(cwd) === prev.then(() => slot)) writeMutex.delete(cwd);
332
+ }).catch(() => {});
333
+ }
334
+ }
335
+
336
+ function scheduleBroadcast(cwd: string, file: OpenSpecGroupsFile): void {
337
+ if (subscribers.size === 0) return;
338
+ const existing = debounceTimers.get(cwd);
339
+ if (existing) clearTimeout(existing);
340
+ const timer = setTimeout(() => {
341
+ debounceTimers.delete(cwd);
342
+ // Always emit the freshest cached payload for this cwd, not the file
343
+ // captured when the timer was scheduled — matters for coalesced bursts.
344
+ const latest = cache.get(cwd)?.data ?? file;
345
+ const payload = { groups: latest.groups, assignments: latest.assignments };
346
+ for (const cb of subscribers) {
347
+ try {
348
+ cb(cwd, payload);
349
+ } catch {
350
+ /* subscriber threw — swallow so other subs still fire */
351
+ }
352
+ }
353
+ }, debounceMs);
354
+ debounceTimers.set(cwd, timer);
355
+ }
356
+
357
+ // ── Public methods ───────────────────────────────────────────
358
+
359
+ async function createGroup(
360
+ cwd: string,
361
+ body: { name: string; color?: string },
362
+ ): Promise<OpenSpecGroup> {
363
+ return mutate(cwd, (current) => {
364
+ const existingIds = new Set(current.groups.map((g) => g.id));
365
+ const id = uniqueSlug(slugify(body.name), existingIds);
366
+ const newGroup: OpenSpecGroup = {
367
+ id,
368
+ name: body.name,
369
+ ...(body.color !== undefined ? { color: body.color } : {}),
370
+ order: current.groups.length,
371
+ };
372
+ const next: OpenSpecGroupsFile = {
373
+ ...current,
374
+ schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
375
+ groups: [...current.groups, newGroup],
376
+ };
377
+ return { next, result: newGroup };
378
+ });
379
+ }
380
+
381
+ async function updateGroup(
382
+ cwd: string,
383
+ id: string,
384
+ body: { name?: string; color?: string; order?: number },
385
+ ): Promise<OpenSpecGroup> {
386
+ return mutate(cwd, (current) => {
387
+ const target = current.groups.find((g) => g.id === id);
388
+ if (!target) throw new GroupNotFoundError(id);
389
+
390
+ // Apply scalar updates first (name, color).
391
+ const updatedTarget: OpenSpecGroup = {
392
+ ...target,
393
+ ...(body.name !== undefined ? { name: body.name } : {}),
394
+ ...(body.color !== undefined ? { color: body.color } : {}),
395
+ ...(body.order !== undefined ? { order: body.order } : {}),
396
+ };
397
+
398
+ const replaced = current.groups.map((g) => (g.id === id ? updatedTarget : g));
399
+
400
+ // If order was touched, normalize the whole set to contiguous 0..N-1.
401
+ // Keep `updatedTarget` at its requested slot, push others around it.
402
+ const finalGroups =
403
+ body.order === undefined
404
+ ? replaced
405
+ : (() => {
406
+ // Sort: target sits first at its requested order; others keep
407
+ // their relative ordering. Then re-pack indexes.
408
+ const others = replaced.filter((g) => g.id !== id).sort((a, b) => a.order - b.order);
409
+ // Insert target at clamped position.
410
+ const pos = Math.max(0, Math.min(body.order!, others.length));
411
+ const merged = [...others];
412
+ merged.splice(pos, 0, updatedTarget);
413
+ return merged.map((g, i) => ({ ...g, order: i }));
414
+ })();
415
+
416
+ const next: OpenSpecGroupsFile = {
417
+ ...current,
418
+ schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
419
+ groups: finalGroups,
420
+ };
421
+ const finalTarget = finalGroups.find((g) => g.id === id)!;
422
+ return { next, result: finalTarget };
423
+ });
424
+ }
425
+
426
+ async function deleteGroup(cwd: string, id: string): Promise<void> {
427
+ return mutate(cwd, (current) => {
428
+ const exists = current.groups.some((g) => g.id === id);
429
+ if (!exists) throw new GroupNotFoundError(id);
430
+ const remaining = normalizeOrders(current.groups.filter((g) => g.id !== id));
431
+ // Cascade: remove any assignment pointing at the deleted group.
432
+ const trimmed: Record<string, string> = {};
433
+ for (const [k, v] of Object.entries(current.assignments)) {
434
+ if (v !== id) trimmed[k] = v;
435
+ }
436
+ const next: OpenSpecGroupsFile = {
437
+ schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
438
+ groups: remaining,
439
+ assignments: trimmed,
440
+ };
441
+ return { next, result: undefined };
442
+ });
443
+ }
444
+
445
+ async function setAssignment(
446
+ cwd: string,
447
+ changeName: string,
448
+ groupId: string | null,
449
+ ): Promise<void> {
450
+ return mutate(cwd, (current) => {
451
+ if (groupId !== null && !current.groups.some((g) => g.id === groupId)) {
452
+ throw new UnknownGroupIdError(groupId);
453
+ }
454
+ const next: OpenSpecGroupsFile = {
455
+ ...current,
456
+ schemaVersion: OPENSPEC_GROUPS_SCHEMA_VERSION,
457
+ assignments: { ...current.assignments },
458
+ };
459
+ if (groupId === null) {
460
+ delete next.assignments[changeName];
461
+ } else {
462
+ next.assignments[changeName] = groupId;
463
+ }
464
+ return { next, result: undefined };
465
+ });
466
+ }
467
+
468
+ function subscribe(cb: Subscriber): () => void {
469
+ subscribers.add(cb);
470
+ return () => subscribers.delete(cb);
471
+ }
472
+
473
+ function dispose(): void {
474
+ for (const t of debounceTimers.values()) clearTimeout(t);
475
+ debounceTimers.clear();
476
+ subscribers.clear();
477
+ cache.clear();
478
+ writeMutex.clear();
479
+ }
480
+
481
+ return {
482
+ read,
483
+ createGroup,
484
+ updateGroup,
485
+ deleteGroup,
486
+ setAssignment,
487
+ subscribe,
488
+ dispose,
489
+ };
490
+ }
@@ -23,7 +23,13 @@ import type { SpawnStrategy } from "@blackbelt-technology/pi-dashboard-shared/co
23
23
  import { MANAGED_BIN } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
24
24
  import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
25
25
  import { prependManagedNodeToPath } from "@blackbelt-technology/pi-dashboard-shared/platform/managed-node-path.js";
26
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
26
27
  import { mintSpawnToken } from "./spawn-token.js";
28
+ import {
29
+ createKeeperManager,
30
+ type KeeperManager,
31
+ } from "./rpc-keeper/keeper-manager.js";
32
+ import { randomUUID } from "node:crypto";
27
33
  import { execSync, spawnSync, buildSafeArgv } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
28
34
  import {
29
35
  spawnDetached,
@@ -52,6 +58,41 @@ export function resetResolver(): void {
52
58
  resolver = new ToolResolver({ processExecPath: process.execPath });
53
59
  }
54
60
 
61
+ // ── KeeperManager seam (injectable for tests) ──────────────────────────
62
+
63
+ let keeperManager: KeeperManager | null = null;
64
+
65
+ /** Inject a KeeperManager — used by tests. Production code lazy-inits below. */
66
+ export function setKeeperManager(km: KeeperManager | null): void {
67
+ keeperManager = km;
68
+ }
69
+
70
+ /**
71
+ * Public lazy accessor for the singleton `KeeperManager`. Exposed so the
72
+ * server-side dispatch handler (`rpc-keeper/dispatch-router.ts`) and
73
+ * `headlessPidRegistry.setKeeperWriter` can share the same instance the
74
+ * spawn path uses. Tests still inject via `setKeeperManager`.
75
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6 + 8).
76
+ */
77
+ export function getKeeperManager(): KeeperManager {
78
+ if (!keeperManager) keeperManager = createKeeperManager();
79
+ return keeperManager;
80
+ }
81
+
82
+ /**
83
+ * Hook used by tests to override the `useRpcKeeper` flag read from config
84
+ * without mutating `~/.pi/dashboard/config.json`. Returns `null` to defer
85
+ * to the real config.
86
+ */
87
+ let useRpcKeeperOverride: boolean | null = null;
88
+ export function _setUseRpcKeeperOverrideForTests(v: boolean | null): void {
89
+ useRpcKeeperOverride = v;
90
+ }
91
+ function shouldUseRpcKeeper(): boolean {
92
+ if (useRpcKeeperOverride !== null) return useRpcKeeperOverride;
93
+ try { return loadConfig().useRpcKeeper === true; } catch { return false; }
94
+ }
95
+
55
96
  // ── Public API ─────────────────────────────────────────────────────────────
56
97
 
57
98
  export interface SessionOptions {
@@ -89,6 +130,15 @@ export interface SpawnResult {
89
130
  * See change: spawn-correlation-token.
90
131
  */
91
132
  spawnToken?: string;
133
+ /**
134
+ * RPC keeper UDS / named-pipe path. Set ONLY when the keeper-mediated
135
+ * spawn path was taken (`useRpcKeeper: true`). Callers pass this to
136
+ * `headlessPidRegistry.register(..., { keeperPid, keeperSockPath })` so
137
+ * later `writeRpc` / `killBySessionId` calls can locate the keeper.
138
+ * In keeper mode `pid` IS the keeper PID, so `keeperPid` is implicit.
139
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
140
+ */
141
+ keeperSockPath?: string;
92
142
  }
93
143
 
94
144
  /**
@@ -395,6 +445,15 @@ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResu
395
445
  async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
396
446
  const args = buildHeadlessArgs(options);
397
447
  const env = buildSpawnEnv(process.env, { spawnToken: options?.spawnToken });
448
+
449
+ // RPC keeper sidecar path (feature-flagged). When enabled, both Unix and
450
+ // Windows go through the keeper (uniform durability across OSes). The
451
+ // keeper spawns pi internally via its own PATH lookup, so we do NOT need
452
+ // to resolve pi here. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
453
+ if (shouldUseRpcKeeper()) {
454
+ return spawnHeadlessViaKeeper(cwd, env, args);
455
+ }
456
+
398
457
  const piCmd = resolvePiCommand();
399
458
  if (!piCmd) {
400
459
  return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
@@ -430,6 +489,75 @@ async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<Spa
430
489
  };
431
490
  }
432
491
 
492
+ /**
493
+ * RPC keeper sidecar headless spawn. Uniform across Unix + Windows.
494
+ *
495
+ * The keeper itself is a CJS-pure Node script (`rpc-keeper/keeper.cjs`).
496
+ * It binds a per-session UDS / named pipe BEFORE spawning pi, then owns
497
+ * pi's stdin pipe so it survives dashboard server restarts.
498
+ *
499
+ * Returned `pid` is the KEEPER PID (not pi's). Pi's PID is linked later
500
+ * via the existing `session_register` token correlation path.
501
+ *
502
+ * Crash-detection window applies to KEEPER spawn only — the keeper itself
503
+ * runs a separate 300 ms window on its pi child internally (and surfaces
504
+ * the failure by exiting non-zero, which will be picked up by
505
+ * `headless-pid-registry`'s PID-death tracking).
506
+ *
507
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 5).
508
+ */
509
+ async function spawnHeadlessViaKeeper(
510
+ cwd: string,
511
+ env: NodeJS.ProcessEnv,
512
+ piArgs: string[],
513
+ ): Promise<SpawnResult> {
514
+ // sessionId is what the keeper uses to derive its UDS / named-pipe path.
515
+ // This is a TRANSPORT-side identifier, distinct from pi's session UUID
516
+ // (which only exists once pi's RPC mode boots). We mint a fresh one per
517
+ // spawn so the keeper's socket path is unique.
518
+ const transportId = randomUUID();
519
+
520
+ // piArgs already includes `--mode rpc` plus any per-spawn flags from
521
+ // `buildHeadlessArgs(options)` (e.g. `--session-file <path>` for resume,
522
+ // `--fork` for fork). Forwarding them through the keeper preserves the
523
+ // existing resume / fork contract. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
524
+ const km = getKeeperManager();
525
+ const result = await km.spawnKeeperFor(transportId, cwd, env, piArgs);
526
+ if (!result.success || !result.pid || !result.process) {
527
+ return {
528
+ success: false,
529
+ code: "SPAWN_ERRNO",
530
+ message: `Failed to spawn RPC keeper: ${result.error ?? "unknown error"}`,
531
+ };
532
+ }
533
+
534
+ // Crash-detection window on the keeper process itself. Keeper applies
535
+ // its own 300 ms window to pi internally; this catches keeper-side
536
+ // failures (bind failure, pi-spawn-error, etc.) that exit the keeper
537
+ // within the window.
538
+ const gate = await waitForNoCrash({ child: result.process, windowMs: 300 });
539
+ if (!gate.ok) {
540
+ return {
541
+ success: false,
542
+ code: "PI_CRASHED",
543
+ message:
544
+ `RPC keeper exited within crash window (code ${gate.exitCode}). ` +
545
+ `Check ~/.pi/dashboard/sessions/keeper-${transportId}.log for details.`,
546
+ };
547
+ }
548
+
549
+ return {
550
+ success: true,
551
+ dashboardSpawned: true,
552
+ message: `Pi session spawned via RPC keeper (keeper pid ${result.pid}, transport ${transportId.slice(0, 8)})`,
553
+ pid: result.pid,
554
+ process: result.process,
555
+ keeperSockPath: result.sockPath,
556
+ // spawnToken propagated by the outer wrapper; keeper-spawn doesn't
557
+ // mint its own. The token already lives in `env.PI_DASHBOARD_SPAWN_TOKEN`.
558
+ };
559
+ }
560
+
433
561
  /**
434
562
  * Windows headless spawn using the detached-spawn primitive.
435
563
  *