@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
@@ -11,6 +11,18 @@ import path from "node:path";
11
11
  import os from "node:os";
12
12
  import { isUnsafeTestHomeScan } from "./test-env-guard.js";
13
13
 
14
+ /**
15
+ * Minimal interface the registry depends on for keeper-mediated writes
16
+ * and orphan reconciliation. Implemented by `KeeperManager` in
17
+ * `rpc-keeper/keeper-manager.ts`. Injected via
18
+ * `HeadlessPidRegistryOptions.keeperManager` to avoid a circular dep at
19
+ * module load. See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
20
+ */
21
+ export interface KeeperWriter {
22
+ writeRpcToSockPath(sockPath: string, line: string): Promise<boolean>;
23
+ discoverExistingKeepers(): Promise<Array<{ sessionId: string; keeperPid: number; sockPath: string }>>;
24
+ }
25
+
14
26
  /** Default PID file path */
15
27
  const DEFAULT_PID_FILE = path.join(os.homedir(), ".pi", "dashboard", "headless-pids.json");
16
28
 
@@ -23,27 +35,134 @@ export interface HeadlessEntry {
23
35
  process: ChildProcess;
24
36
  sessionId?: string;
25
37
  spawnedAt: number;
38
+ /**
39
+ * Server-minted spawn correlation token. Stored at `register` time.
40
+ * Used by `linkByToken` (tier 1) to resolve sessionId↔pid mapping
41
+ * deterministically, replacing the racy cwd-FIFO `linkSession`.
42
+ * See change: spawn-correlation-token.
43
+ */
44
+ spawnToken?: string;
45
+ /**
46
+ * Pi process PID, distinct from the spawn-time PID when an RPC keeper
47
+ * sidecar owns pi's stdin (see `rpc-keeper-sidecar`). Set by
48
+ * `linkByToken` from `session_register.pid` once the bridge connects.
49
+ * In non-keeper mode it remains undefined; consumers fall back to
50
+ * `entry.pid`. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
51
+ */
52
+ piPid?: number;
53
+ /**
54
+ * RPC keeper sidecar PID. Set at register-time when the entry was
55
+ * spawned through `spawnHeadlessViaKeeper`. In keeper mode this equals
56
+ * `entry.pid` (the keeper IS the spawned child); the explicit field
57
+ * makes the keeper-vs-non-keeper branch unambiguous in `killBySessionId`
58
+ * and `writeRpc`. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
59
+ */
60
+ keeperPid?: number;
61
+ /**
62
+ * Absolute UDS / named-pipe path the keeper listens on. Set at
63
+ * register-time alongside `keeperPid`. Used by `writeRpc` to forward
64
+ * `dispatch_extension_command` lines without re-deriving the path.
65
+ */
66
+ keeperSockPath?: string;
26
67
  }
27
68
 
28
- /** Serialized format for disk persistence */
69
+ /**
70
+ * Serialized format for disk persistence. ALL identity-bearing fields
71
+ * must round-trip across server restart so `linkByToken` / `linkByPid`
72
+ * keep their precise matching after `cleanupOrphans` reclaims entries.
73
+ *
74
+ * Without this, after a dashboard restart the rebuilt entry only has
75
+ * (pid, cwd, spawnedAt). The bridge then re-registers with
76
+ * `{pid: piPid, spawnToken: <omitted-on-reattach>}` and the registry
77
+ * falls all the way through to the cwd-FIFO tier, which mis-maps
78
+ * sessionIds to keeper entries when two sessions share a cwd. Symptom:
79
+ * `/ctx-stats` in session A dispatches to pi-B's keeper; killing A
80
+ * SIGTERMs B's pi. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
81
+ */
29
82
  interface PersistedEntry {
30
83
  pid: number;
31
84
  cwd: string;
32
85
  spawnedAt: string;
86
+ spawnToken?: string;
87
+ piPid?: number;
88
+ keeperPid?: number;
89
+ keeperSockPath?: string;
33
90
  }
34
91
 
35
92
  interface PidFileData {
36
93
  entries: PersistedEntry[];
37
94
  }
38
95
 
96
+ export interface KeeperRegisterOptions {
97
+ keeperPid: number;
98
+ keeperSockPath: string;
99
+ }
100
+
101
+ /**
102
+ * Pure helper: derive the optional `KeeperRegisterOptions` from a
103
+ * SpawnResult-shaped object. Returns `undefined` when the spawn was not
104
+ * keeper-mediated (no `keeperSockPath`). Lets registration call sites
105
+ * stay one-liners across all spawn paths.
106
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
107
+ */
108
+ export function keeperOptsFromSpawnResult(
109
+ result: { pid?: number; keeperSockPath?: string },
110
+ ): KeeperRegisterOptions | undefined {
111
+ if (typeof result.pid !== "number" || !result.keeperSockPath) return undefined;
112
+ return { keeperPid: result.pid, keeperSockPath: result.keeperSockPath };
113
+ }
114
+
39
115
  export interface HeadlessPidRegistry {
40
- /** Register a newly spawned headless process. */
41
- register(pid: number, cwd: string, proc: ChildProcess): void;
42
- /** Link a session ID to a tracked PID by matching cwd (FIFO). */
116
+ /**
117
+ * Register a newly spawned headless process. The optional `spawnToken`
118
+ * is the server-minted UUID injected into the spawned process's env;
119
+ * storing it lets `linkByToken` resolve identity precisely later.
120
+ * The optional `keeperOpts` marks this entry as keeper-mediated and
121
+ * stores the keeper PID + socket path for `writeRpc` / `killBySessionId`.
122
+ * See change: spawn-correlation-token, add-rpc-stdin-dispatch-with-keeper-sidecar.
123
+ */
124
+ register(
125
+ pid: number,
126
+ cwd: string,
127
+ proc: ChildProcess,
128
+ spawnToken?: string,
129
+ keeperOpts?: KeeperRegisterOptions,
130
+ ): void;
131
+ /**
132
+ * Tier 1 link: find entry by `spawnToken`, set its `sessionId`. Returns
133
+ * `true` on match. The strongest identity — used when the bridge sent
134
+ * `session_register.spawnToken`. When `pid` is provided AND the entry
135
+ * is keeper-mediated, `entry.piPid` is set so `killBySessionId` can
136
+ * SIGTERM pi directly (the keeper auto-exits on pi exit). See change:
137
+ * spawn-correlation-token, add-rpc-stdin-dispatch-with-keeper-sidecar.
138
+ */
139
+ linkByToken(spawnToken: string, sessionId: string, pid?: number): boolean;
140
+ /**
141
+ * Tier 2 link: find entry by `pid` (where `!sessionId`), set its
142
+ * `sessionId`. Returns `true` on match. Used when the bridge sent
143
+ * `session_register.pid` but no token. See change: spawn-correlation-token.
144
+ */
145
+ linkByPid(sessionId: string, pid: number): boolean;
146
+ /**
147
+ * Tier 3 (legacy) link: find first entry by `cwd` where `!sessionId`,
148
+ * set its `sessionId`. Returns `true` on match. Cwd-FIFO fallback for
149
+ * old bridges that send neither token nor pid. Race-prone for
150
+ * concurrent same-cwd spawns; tiers 1–2 should pre-empt this.
151
+ */
43
152
  linkSession(sessionId: string, cwd: string): boolean;
44
- /** Get the PID linked to a session ID. */
153
+ /**
154
+ * Get the PID linked to a session ID. In keeper mode returns the pi
155
+ * PID once linked (`entry.piPid`), falling back to `entry.pid` (= keeper
156
+ * PID at spawn) if the bridge hasn't connected yet. In non-keeper mode
157
+ * returns `entry.pid` unchanged.
158
+ */
45
159
  getPid(sessionId: string): number | undefined;
46
- /** Send SIGTERM to the process linked to a session ID. Returns true if killed. */
160
+ /**
161
+ * Send SIGTERM to the process linked to a session ID. Returns true if
162
+ * killed. In keeper mode kills pi first (so the keeper's auto-exit-on-
163
+ * pi-exit fires) and falls back to killing the keeper after a brief
164
+ * delay if it survives. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
165
+ */
47
166
  killBySessionId(sessionId: string): boolean;
48
167
  /** Remove a tracked process by PID. */
49
168
  remove(pid: number): void;
@@ -53,23 +172,69 @@ export interface HeadlessPidRegistry {
53
172
  size(): number;
54
173
  /** Clean up orphan processes from a previous server instance. */
55
174
  cleanupOrphans(): void;
175
+ /**
176
+ * Connect to the keeper UDS for `sessionId` and write `line + \n`.
177
+ * Returns false if no entry, no keeper for this session, or if the
178
+ * 3-attempt write to the socket fails. Never throws. Used by the
179
+ * server's dispatch handler to forward extension slash commands to pi.
180
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6/8).
181
+ */
182
+ writeRpc(sessionId: string, line: string): Promise<boolean>;
183
+ /**
184
+ * Async startup pass: scan the sessions dir for live keeper sidecars
185
+ * (via the injected `KeeperManager.discoverExistingKeepers`) and
186
+ * reconcile them with the in-memory registry. Live keepers whose
187
+ * registry entry doesn't yet exist are skipped (the bridge will
188
+ * register them on connect). No-op when no `keeperManager` was injected.
189
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
190
+ */
191
+ cleanupKeeperOrphans(): Promise<void>;
192
+ /**
193
+ * Inject the keeper writer / discoverer after construction. Necessary
194
+ * because `browser-gateway.ts` constructs the registry before the
195
+ * server creates the `KeeperManager`. Pass `null` to clear (used by tests).
196
+ */
197
+ setKeeperWriter(writer: KeeperWriter | null): void;
56
198
  }
57
199
 
58
200
  export interface HeadlessPidRegistryOptions {
59
201
  pidFilePath?: string;
202
+ /**
203
+ * Optional `KeeperWriter` (typically a `KeeperManager`) wired so the
204
+ * registry can delegate UDS writes and orphan reconciliation. May be
205
+ * supplied after construction via `setKeeperWriter` instead.
206
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6).
207
+ */
208
+ keeperManager?: KeeperWriter;
60
209
  }
61
210
 
62
211
  export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions): HeadlessPidRegistry {
63
212
  const entries = new Map<number, HeadlessEntry>();
64
213
  const pidFilePath = options?.pidFilePath ?? DEFAULT_PID_FILE;
214
+ let keeperWriter: KeeperWriter | null = options?.keeperManager ?? null;
215
+
216
+ /** Internal: locate entry by sessionId. */
217
+ function findBySessionId(sessionId: string): HeadlessEntry | undefined {
218
+ for (const entry of entries.values()) {
219
+ if (entry.sessionId === sessionId) return entry;
220
+ }
221
+ return undefined;
222
+ }
65
223
 
66
224
  function persist() {
67
225
  const data: PidFileData = {
68
- entries: [...entries.values()].map((e) => ({
69
- pid: e.pid,
70
- cwd: e.cwd,
71
- spawnedAt: new Date(e.spawnedAt).toISOString(),
72
- })),
226
+ entries: [...entries.values()].map((e) => {
227
+ const out: PersistedEntry = {
228
+ pid: e.pid,
229
+ cwd: e.cwd,
230
+ spawnedAt: new Date(e.spawnedAt).toISOString(),
231
+ };
232
+ if (e.spawnToken) out.spawnToken = e.spawnToken;
233
+ if (e.piPid !== undefined) out.piPid = e.piPid;
234
+ if (e.keeperPid !== undefined) out.keeperPid = e.keeperPid;
235
+ if (e.keeperSockPath) out.keeperSockPath = e.keeperSockPath;
236
+ return out;
237
+ }),
73
238
  };
74
239
  try {
75
240
  writeJsonFile(pidFilePath, data);
@@ -84,8 +249,25 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
84
249
  }
85
250
 
86
251
  return {
87
- register(pid: number, cwd: string, proc: ChildProcess) {
88
- entries.set(pid, { pid, cwd, process: proc, spawnedAt: Date.now() });
252
+ register(
253
+ pid: number,
254
+ cwd: string,
255
+ proc: ChildProcess,
256
+ spawnToken?: string,
257
+ keeperOpts?: KeeperRegisterOptions,
258
+ ) {
259
+ const entry: HeadlessEntry = {
260
+ pid,
261
+ cwd,
262
+ process: proc,
263
+ spawnedAt: Date.now(),
264
+ spawnToken,
265
+ };
266
+ if (keeperOpts) {
267
+ entry.keeperPid = keeperOpts.keeperPid;
268
+ entry.keeperSockPath = keeperOpts.keeperSockPath;
269
+ }
270
+ entries.set(pid, entry);
89
271
  proc.on("exit", () => {
90
272
  entries.delete(pid);
91
273
  persist();
@@ -93,43 +275,120 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
93
275
  persist();
94
276
  },
95
277
 
96
- linkSession(sessionId: string, cwd: string): boolean {
278
+ linkByToken(spawnToken: string, sessionId: string, pid?: number): boolean {
279
+ if (!spawnToken) return false;
97
280
  for (const entry of entries.values()) {
98
- if (entry.cwd === cwd && !entry.sessionId) {
281
+ if (entry.spawnToken === spawnToken && !entry.sessionId) {
99
282
  entry.sessionId = sessionId;
283
+ // Keeper-mode: store pi's PID separately so killBySessionId can
284
+ // SIGTERM pi directly (the keeper auto-exits on pi exit).
285
+ // Non-keeper mode leaves piPid undefined; getPid falls back to
286
+ // entry.pid. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
287
+ if (entry.keeperPid !== undefined && pid !== undefined && pid !== entry.pid) {
288
+ entry.piPid = pid;
289
+ // Persist immediately so a future cleanupOrphans reclaim has
290
+ // piPid available for linkByPid to match against (the token
291
+ // is omitted by the bridge on reattach).
292
+ persist();
293
+ }
100
294
  return true;
101
295
  }
102
296
  }
103
297
  return false;
104
298
  },
105
299
 
106
- getPid(sessionId: string): number | undefined {
300
+ linkByPid(sessionId: string, pid: number): boolean {
301
+ // Tier 2a: match by Map key (= spawn-time pid). Always tried first
302
+ // because it's O(1) and exact for non-keeper mode.
303
+ const direct = entries.get(pid);
304
+ if (direct && !direct.sessionId) {
305
+ direct.sessionId = sessionId;
306
+ return true;
307
+ }
308
+ // Tier 2b: keeper-mode reattach. After server restart, the Map is
309
+ // keyed by `keeperPid` (the spawn-time pid in keeper mode); pi's
310
+ // actual PID lives in `entry.piPid`. The bridge's session_register
311
+ // sends pi's PID, so direct Map lookup misses. Iterate to find a
312
+ // matching piPid — critical for correct sessionId↔pi mapping when
313
+ // multiple sessions share a cwd (otherwise the cwd-FIFO fallback
314
+ // mis-maps and `/ctx-stats` dispatches to the wrong keeper).
315
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
107
316
  for (const entry of entries.values()) {
108
- if (entry.sessionId === sessionId) {
109
- return entry.pid;
317
+ if (entry.piPid === pid && !entry.sessionId) {
318
+ entry.sessionId = sessionId;
319
+ return true;
110
320
  }
111
321
  }
112
- return undefined;
322
+ return false;
113
323
  },
114
324
 
115
- killBySessionId(sessionId: string): boolean {
325
+ linkSession(sessionId: string, cwd: string): boolean {
116
326
  for (const entry of entries.values()) {
117
- if (entry.sessionId === sessionId) {
327
+ if (entry.cwd === cwd && !entry.sessionId) {
328
+ entry.sessionId = sessionId;
329
+ return true;
330
+ }
331
+ }
332
+ return false;
333
+ },
334
+
335
+ getPid(sessionId: string): number | undefined {
336
+ const entry = findBySessionId(sessionId);
337
+ if (!entry) return undefined;
338
+ // Keeper mode: prefer the linked pi PID; fall back to entry.pid (=
339
+ // keeper PID) when the bridge hasn't connected yet. Non-keeper mode
340
+ // returns entry.pid unchanged.
341
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
342
+ return entry.piPid ?? entry.pid;
343
+ },
344
+
345
+ killBySessionId(sessionId: string): boolean {
346
+ const entry = findBySessionId(sessionId);
347
+ if (!entry) return false;
348
+
349
+ // Keeper-mediated entry: kill pi first so the keeper's
350
+ // auto-exit-on-pi-exit handler fires; schedule a fallback SIGTERM
351
+ // to the keeper if it survives the brief grace window.
352
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar (task 6.4).
353
+ if (entry.keeperPid !== undefined) {
354
+ const piPid = entry.piPid;
355
+ const keeperPid = entry.keeperPid;
356
+ let killedSomething = false;
357
+ if (piPid !== undefined) {
118
358
  try {
119
- // Delegate platform-specific pid-vs-group-pid handling to the
120
- // shared primitive. See change: consolidate-platform-handlers.
121
- killPidWithGroup(entry.pid, "SIGTERM");
122
- entries.delete(entry.pid);
123
- persist();
124
- return true;
125
- } catch {
126
- entries.delete(entry.pid);
127
- persist();
128
- return false;
359
+ killPidWithGroup(piPid, "SIGTERM");
360
+ killedSomething = true;
361
+ } catch { /* pi may already be dead */ }
362
+ }
363
+ // Fallback: 200 ms grace for the keeper's auto-exit; SIGTERM if it
364
+ // survives. Fire-and-forget to keep killBySessionId synchronous.
365
+ setTimeout(() => {
366
+ if (isProcessAlive(keeperPid)) {
367
+ try { killPidWithGroup(keeperPid, "SIGTERM"); } catch { /* ignore */ }
129
368
  }
369
+ }, 200).unref?.();
370
+ // If pi was unknown (bridge never connected), fall through to
371
+ // killing the keeper directly so the spawn cleanup completes.
372
+ if (!killedSomething) {
373
+ try { killPidWithGroup(keeperPid, "SIGTERM"); killedSomething = true; }
374
+ catch { /* ignore */ }
130
375
  }
376
+ entries.delete(entry.pid);
377
+ persist();
378
+ return killedSomething;
379
+ }
380
+
381
+ // Non-keeper path (legacy): kill the spawn-time PID directly.
382
+ try {
383
+ killPidWithGroup(entry.pid, "SIGTERM");
384
+ entries.delete(entry.pid);
385
+ persist();
386
+ return true;
387
+ } catch {
388
+ entries.delete(entry.pid);
389
+ persist();
390
+ return false;
131
391
  }
132
- return false;
133
392
  },
134
393
 
135
394
  remove(pid: number) {
@@ -158,6 +417,46 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
158
417
  return entries.size;
159
418
  },
160
419
 
420
+ setKeeperWriter(writer: KeeperWriter | null) {
421
+ keeperWriter = writer;
422
+ },
423
+
424
+ async writeRpc(sessionId: string, line: string): Promise<boolean> {
425
+ const entry = findBySessionId(sessionId);
426
+ if (!entry || !entry.keeperSockPath || !keeperWriter) return false;
427
+ return keeperWriter.writeRpcToSockPath(entry.keeperSockPath, line);
428
+ },
429
+
430
+ async cleanupKeeperOrphans(): Promise<void> {
431
+ if (isUnsafeTestHomeScan()) {
432
+ console.warn("[headless-pid-registry] cleanupKeeperOrphans() blocked: running under vitest with real HOME");
433
+ return;
434
+ }
435
+ if (!keeperWriter) return;
436
+ // KeeperManager.discoverExistingKeepers does the heavy lifting:
437
+ // unlinks stale sockets, SIGTERMs orphans whose pi child is dead.
438
+ // The registry only needs to know about live pairs so it can
439
+ // attach keeper info to existing entries (matched by spawn-time
440
+ // pid via the persisted sidecar PID).
441
+ try {
442
+ const live = await keeperWriter.discoverExistingKeepers();
443
+ for (const k of live) {
444
+ // Reattach to any existing entry whose spawn-time PID matches
445
+ // the keeper PID (set when the previous server instance ran
446
+ // spawnHeadlessViaKeeper). Defensive: do not blow away entries
447
+ // that already have keeperPid set.
448
+ const existing = entries.get(k.keeperPid);
449
+ if (existing && existing.keeperPid === undefined) {
450
+ existing.keeperPid = k.keeperPid;
451
+ existing.keeperSockPath = k.sockPath;
452
+ persist();
453
+ }
454
+ }
455
+ } catch (err) {
456
+ console.warn("[headless-pid-registry] cleanupKeeperOrphans failed", err);
457
+ }
458
+ },
459
+
161
460
  cleanupOrphans() {
162
461
  if (isUnsafeTestHomeScan()) {
163
462
  console.warn("[headless-pid-registry] cleanupOrphans() blocked: running under vitest with real HOME");
@@ -185,16 +484,24 @@ export function createHeadlessPidRegistry(options?: HeadlessPidRegistryOptions):
185
484
  continue;
186
485
  }
187
486
 
188
- // Alive and not too old — reclaim into registry
189
- // Create a dummy ChildProcess-like emitter for the entry
190
- // EventEmitter imported at top level
487
+ // Alive and not too old — reclaim into registry.
488
+ // ALL identity fields are restored so the post-restart three-tier
489
+ // link (token pid → cwd-FIFO) keeps its precision; without piPid
490
+ // / spawnToken, keeper-mode sessions mis-map under cwd-FIFO when
491
+ // two sessions share a cwd. See change:
492
+ // add-rpc-stdin-dispatch-with-keeper-sidecar.
191
493
  const dummyProc = new EventEmitter() as ChildProcess;
192
- entries.set(entry.pid, {
494
+ const reclaimed: HeadlessEntry = {
193
495
  pid: entry.pid,
194
496
  cwd: entry.cwd,
195
497
  process: dummyProc,
196
498
  spawnedAt,
197
- });
499
+ };
500
+ if (entry.spawnToken) reclaimed.spawnToken = entry.spawnToken;
501
+ if (entry.piPid !== undefined) reclaimed.piPid = entry.piPid;
502
+ if (entry.keeperPid !== undefined) reclaimed.keeperPid = entry.keeperPid;
503
+ if (entry.keeperSockPath) reclaimed.keeperSockPath = entry.keeperSockPath;
504
+ entries.set(entry.pid, reclaimed);
198
505
  }
199
506
 
200
507
  persist();
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Detects and removes legacy `@mariozechner/pi-coding-agent` installs.
3
+ *
4
+ * Pi was renamed to `@earendil-works/pi-coding-agent` at v0.74. The legacy
5
+ * scope is published only up to v0.73.x and conflicts with the new scope's
6
+ * `bin/pi` symlink in npm-global (EEXIST). This module surfaces legacy
7
+ * installs so the UI can offer a one-click cleanup.
8
+ *
9
+ * Detection is read-only and cheap (3 fs.stat calls + optional `npm root
10
+ * -g`). Cleanup is gated behind a POST endpoint — never silent.
11
+ *
12
+ * Scopes scanned:
13
+ * - npm-global: `$(npm root -g)/@mariozechner/pi-coding-agent`
14
+ * - npx-cache: `~/.npm/_npx/<hash>/node_modules/@mariozechner/pi-coding-agent`
15
+ * - managed: `~/.pi-dashboard/node_modules/@mariozechner/pi-coding-agent`
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import { execSync } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
21
+
22
+ export const LEGACY_PI_PACKAGE = "@mariozechner/pi-coding-agent";
23
+
24
+ export type LegacyPiScope = "npm-global" | "npx-cache" | "managed";
25
+
26
+ export interface LegacyPiInstall {
27
+ scope: LegacyPiScope;
28
+ path: string;
29
+ version: string | null;
30
+ }
31
+
32
+ export interface LegacyPiCleanupResult {
33
+ scope: LegacyPiScope;
34
+ path: string;
35
+ removed: boolean;
36
+ error?: string;
37
+ }
38
+
39
+ // ── Pure helpers (no I/O) ──────────────────────────────────────────
40
+
41
+ /** Build the legacy package path under a given node_modules root. */
42
+ export function legacyPathUnder(nodeModulesDir: string): string {
43
+ return path.join(nodeModulesDir, ...LEGACY_PI_PACKAGE.split("/"));
44
+ }
45
+
46
+ /** Read `version` from a package.json blob; returns null on any parse failure. */
47
+ export function parseVersion(packageJsonRaw: string): string | null {
48
+ try {
49
+ const parsed = JSON.parse(packageJsonRaw);
50
+ return typeof parsed.version === "string" ? parsed.version : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // ── Detection ──────────────────────────────────────────────────────
57
+
58
+ function safeStatDir(p: string): boolean {
59
+ try { return fs.statSync(p).isDirectory(); } catch { return false; }
60
+ }
61
+
62
+ function readVersionOf(packageDir: string): string | null {
63
+ try {
64
+ const raw = fs.readFileSync(path.join(packageDir, "package.json"), "utf-8");
65
+ return parseVersion(raw);
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function detectNpmGlobal(): LegacyPiInstall | null {
72
+ let globalRoot: string;
73
+ try {
74
+ globalRoot = execSync("npm root -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
75
+ } catch {
76
+ return null; // npm not available, or call failed — treat as no install
77
+ }
78
+ if (!globalRoot) return null;
79
+ const pkgDir = legacyPathUnder(globalRoot);
80
+ if (!safeStatDir(pkgDir)) return null;
81
+ return { scope: "npm-global", path: pkgDir, version: readVersionOf(pkgDir) };
82
+ }
83
+
84
+ function detectNpxCache(): LegacyPiInstall[] {
85
+ const root = path.join(os.homedir(), ".npm", "_npx");
86
+ let entries: string[];
87
+ try { entries = fs.readdirSync(root); } catch { return []; }
88
+ const found: LegacyPiInstall[] = [];
89
+ for (const hash of entries) {
90
+ const pkgDir = legacyPathUnder(path.join(root, hash, "node_modules"));
91
+ if (safeStatDir(pkgDir)) {
92
+ found.push({ scope: "npx-cache", path: pkgDir, version: readVersionOf(pkgDir) });
93
+ }
94
+ }
95
+ return found;
96
+ }
97
+
98
+ function detectManaged(): LegacyPiInstall | null {
99
+ const pkgDir = legacyPathUnder(path.join(os.homedir(), ".pi-dashboard", "node_modules"));
100
+ if (!safeStatDir(pkgDir)) return null;
101
+ return { scope: "managed", path: pkgDir, version: readVersionOf(pkgDir) };
102
+ }
103
+
104
+ /**
105
+ * Scan all three locations for legacy pi installs. Synchronous because
106
+ * the cost is dominated by one `npm root -g` invocation (~50ms once);
107
+ * everything else is fs.statSync. Called at startup and on POST refresh.
108
+ */
109
+ export function detectLegacyPiInstalls(): LegacyPiInstall[] {
110
+ const found: LegacyPiInstall[] = [];
111
+ const g = detectNpmGlobal();
112
+ if (g) found.push(g);
113
+ found.push(...detectNpxCache());
114
+ const m = detectManaged();
115
+ if (m) found.push(m);
116
+ return found;
117
+ }
118
+
119
+ // ── Cleanup ────────────────────────────────────────────────────────
120
+
121
+ function rmrf(target: string): void {
122
+ fs.rmSync(target, { recursive: true, force: true });
123
+ }
124
+
125
+ function removeOne(install: LegacyPiInstall): LegacyPiCleanupResult {
126
+ const base: Pick<LegacyPiCleanupResult, "scope" | "path"> = { scope: install.scope, path: install.path };
127
+ try {
128
+ if (install.scope === "npm-global") {
129
+ // npm-global needs the package-manager call so any bin symlinks are
130
+ // cleaned up too. Using `--no-fund --no-audit` to keep output quiet.
131
+ execSync(`npm uninstall -g --no-fund --no-audit ${LEGACY_PI_PACKAGE}`, {
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ encoding: "utf-8",
134
+ });
135
+ } else {
136
+ // npx-cache and managed are plain node_modules subtrees we can rm.
137
+ rmrf(install.path);
138
+ }
139
+ return { ...base, removed: true };
140
+ } catch (err: any) {
141
+ return { ...base, removed: false, error: err?.message ?? String(err) };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Remove all detected legacy installs. Each scope is attempted
147
+ * independently; one failure does not abort the others.
148
+ */
149
+ export function uninstallLegacyPi(installs: readonly LegacyPiInstall[]): LegacyPiCleanupResult[] {
150
+ return installs.map(removeOne);
151
+ }