@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
@@ -22,6 +22,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
22
22
  import path from "node:path";
23
23
  import os from "node:os";
24
24
  import { fetchPackageMeta } from "./npm-search-proxy.js";
25
+ import { invalidateChangelogCache } from "./changelog-parser.js";
26
+ import { getLatestPiRelease, type PiDevReleaseInfo } from "./pi-dev-version-check.js";
25
27
 
26
28
  const execFileAsync = promisify(execFile);
27
29
 
@@ -34,16 +36,16 @@ const MANAGED_NODE_MODULES = path.join(MANAGED_DIR, "node_modules");
34
36
 
35
37
  /** Known core packages (not extensions). Order matters for display. */
36
38
  export const CORE_PACKAGE_NAMES: readonly string[] = [
39
+ "@earendil-works/pi-coding-agent",
37
40
  "@mariozechner/pi-coding-agent",
38
- "@oh-my-pi/pi-coding-agent",
39
41
  "@blackbelt-technology/pi-agent-dashboard",
40
42
  "@blackbelt-technology/pi-model-proxy",
41
43
  ];
42
44
 
43
45
  /** Display name mapping for known packages. Falls back to package name. */
44
46
  const DISPLAY_NAMES: Readonly<Record<string, string>> = {
45
- "@mariozechner/pi-coding-agent": "pi (core agent)",
46
- "@oh-my-pi/pi-coding-agent": "pi (core agent — fork)",
47
+ "@earendil-works/pi-coding-agent": "pi (core agent)",
48
+ "@mariozechner/pi-coding-agent": "pi (core agent — legacy fork)",
47
49
  "@blackbelt-technology/pi-agent-dashboard": "pi-dashboard",
48
50
  "@blackbelt-technology/pi-model-proxy": "pi-model-proxy",
49
51
  };
@@ -68,20 +70,40 @@ function resolveDisplayName(name: string): string {
68
70
  return DISPLAY_NAMES[name] ?? name;
69
71
  }
70
72
 
73
+ /**
74
+ * Dynamically-discovered package-name aliases for `@mariozechner/pi-coding-agent`.
75
+ * Populated from pi.dev's `latest-version` response, which returns the
76
+ * authoritative package name for fresh installs (used for the upcoming
77
+ * `@mariozechner` → `@earendil-works` scope migration). The dashboard
78
+ * accepts whatever name pi.dev declares as a trusted alias — this avoids
79
+ * having to ship a release every time the canonical scope changes.
80
+ *
81
+ * See change: improve-pi-update-detection.
82
+ */
83
+ let dynamicPiAliases: Set<string> = new Set();
84
+
85
+ /** Test seam: clear runtime aliases between tests. */
86
+ export function _resetDynamicPiAliases(): void {
87
+ dynamicPiAliases = new Set();
88
+ }
89
+
71
90
  /**
72
91
  * Strict whitelist check: a package is part of the pi-ecosystem CORE
73
- * tooling if and only if its name is in `CORE_PACKAGE_NAMES`. The
74
- * previous `pi-*` name-prefix heuristic was removed because it caused
75
- * recommended-extension packages (e.g. `pi-agent-browser`,
76
- * `@tintinweb/pi-subagents`) to appear in BOTH the Core ecosystem panel
77
- * and the Installed Packages panel. Recommended extensions are now
78
- * surfaced exclusively through `/api/packages/installed`. See change:
79
- * consolidate-packages-settings-ui.
92
+ * tooling if and only if its name is in `CORE_PACKAGE_NAMES` OR a
93
+ * pi.dev-declared alias. The previous `pi-*` name-prefix heuristic was
94
+ * removed because it caused recommended-extension packages (e.g.
95
+ * `pi-agent-browser`, `@tintinweb/pi-subagents`) to appear in BOTH the
96
+ * Core ecosystem panel and the Installed Packages panel. Recommended
97
+ * extensions are now surfaced exclusively through
98
+ * `/api/packages/installed`. See change: consolidate-packages-settings-ui.
80
99
  */
81
100
  function looksLikePiEcosystem(name: string): boolean {
82
- return CORE_PACKAGE_NAMES.includes(name);
101
+ return CORE_PACKAGE_NAMES.includes(name) || dynamicPiAliases.has(name);
83
102
  }
84
103
 
104
+ /** Pi packages whose latestVersion comes from pi.dev (not npm registry). */
105
+ const PI_DEV_PACKAGE = "@mariozechner/pi-coding-agent";
106
+
85
107
  export interface NpmListRunner {
86
108
  /** Run `npm list -g --depth=0 --json` and return stdout. */
87
109
  (): Promise<string>;
@@ -92,6 +114,13 @@ export interface PiCoreCheckerOptions {
92
114
  npmList?: NpmListRunner;
93
115
  /** Inject version fetcher (for tests). */
94
116
  fetchLatest?: (packageName: string) => Promise<string | null>;
117
+ /**
118
+ * Inject pi.dev release fetcher (for tests). Production uses
119
+ * `getLatestPiRelease` which honours PI_OFFLINE / PI_SKIP_VERSION_CHECK
120
+ * envs and falls back to `undefined` on any failure. See change:
121
+ * improve-pi-update-detection.
122
+ */
123
+ fetchPiDevRelease?: (currentVersion: string) => Promise<PiDevReleaseInfo | undefined>;
95
124
  /** Override managed directory (for tests). */
96
125
  managedDir?: string;
97
126
  }
@@ -114,19 +143,28 @@ export class PiCoreChecker {
114
143
  private cache: { at: number; data: PiCoreStatus } | null = null;
115
144
  private readonly npmList: NpmListRunner;
116
145
  private readonly fetchLatest: (packageName: string) => Promise<string | null>;
146
+ private readonly fetchPiDevRelease: (currentVersion: string) => Promise<PiDevReleaseInfo | undefined>;
117
147
  private readonly managedNodeModules: string;
118
148
 
119
149
  constructor(opts: PiCoreCheckerOptions = {}) {
120
150
  this.npmList = opts.npmList ?? defaultNpmList;
121
151
  this.fetchLatest = opts.fetchLatest ?? defaultFetchLatest;
152
+ this.fetchPiDevRelease = opts.fetchPiDevRelease ?? getLatestPiRelease;
122
153
  this.managedNodeModules = opts.managedDir
123
154
  ? path.join(opts.managedDir, "node_modules")
124
155
  : MANAGED_NODE_MODULES;
125
156
  }
126
157
 
127
- /** Invalidate the cache (e.g. after an update completes). */
158
+ /**
159
+ * Invalidate the cache (e.g. after an update completes).
160
+ *
161
+ * Also clears the changelog parser cache so the next
162
+ * `GET /api/pi-core/changelog` request reads the freshly-installed
163
+ * file from disk. See change: pi-update-whats-new-panel.
164
+ */
128
165
  invalidate(): void {
129
166
  this.cache = null;
167
+ invalidateChangelogCache();
130
168
  }
131
169
 
132
170
  /** Get version status. Returns cached data within 5 min unless `refresh`. */
@@ -144,15 +182,37 @@ export class PiCoreChecker {
144
182
  for (const entry of global) byName.set(entry.name, { version: entry.version, source: "global" });
145
183
  for (const entry of managed) byName.set(entry.name, { version: entry.version, source: "managed" });
146
184
 
147
- // Fetch latest versions in parallel.
185
+ // Fetch latest versions in parallel. For pi-coding-agent (and its
186
+ // declared scope-rename aliases), prefer pi.dev's authoritative
187
+ // version-check endpoint over the npm registry; fall back to npm
188
+ // registry on any failure so the dashboard never reports "unknown
189
+ // version" just because pi.dev had a hiccup. See change:
190
+ // improve-pi-update-detection.
148
191
  const entries = Array.from(byName.entries());
149
192
  const withLatest = await Promise.all(
150
193
  entries.map(async ([name, info]) => {
151
194
  let latest: string | null = null;
152
- try {
153
- latest = await this.fetchLatest(name);
154
- } catch {
155
- latest = null;
195
+ const isPi = name === PI_DEV_PACKAGE || dynamicPiAliases.has(name);
196
+ if (isPi) {
197
+ try {
198
+ const piDev = await this.fetchPiDevRelease(info.version);
199
+ if (piDev) {
200
+ latest = piDev.version;
201
+ // Record any new alias for next-time discovery.
202
+ if (piDev.packageName && !CORE_PACKAGE_NAMES.includes(piDev.packageName)) {
203
+ dynamicPiAliases.add(piDev.packageName);
204
+ }
205
+ }
206
+ } catch {
207
+ /* fall through to npm registry */
208
+ }
209
+ }
210
+ if (latest === null) {
211
+ try {
212
+ latest = await this.fetchLatest(name);
213
+ } catch {
214
+ latest = null;
215
+ }
156
216
  }
157
217
  const updateAvailable = latest !== null && latest !== info.version;
158
218
  const pkg: PiCorePackage = {
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Pi core package updater.
3
3
  *
4
- * Runs `npm update -g <pkg>` for globally-installed packages or
5
- * `npm update <pkg>` in `~/.pi-dashboard/` for managed installs.
4
+ * Runs `npm install -g <pkg>@latest` for globally-installed packages or
5
+ * `npm install <pkg>@latest` in `~/.pi-dashboard/` for managed installs.
6
+ * The `@latest` suffix is required because the consuming `package.json`
7
+ * dependency range (e.g. `^0.70.0`) would otherwise pin updates to the
8
+ * same minor — breaking cross-minor upgrades that pi now ships routinely
9
+ * (0.71+ minors carry breaking changes per its CHANGELOG).
6
10
  * Coordinates with PackageManagerWrapper's busy-lock so extension
7
11
  * operations and core updates can't run concurrently.
12
+ *
13
+ * See change: fix-pi-core-update-cross-minor.
8
14
  */
9
15
  import { spawn } from "node:child_process"; // ban:child_process-ok npm-update streams stdout/stderr via pipe for progress events; refactor to platform/spawn Recipe is tracked tech debt
10
16
  import path from "node:path";
@@ -75,10 +81,14 @@ export function defaultRunNpmUpdate(
75
81
  seams: DefaultRunNpmUpdateSeams = {},
76
82
  ): Promise<void> {
77
83
  return new Promise((resolve, reject) => {
84
+ // Always target the npm `latest` dist-tag — bypasses the
85
+ // consuming package.json range so cross-minor jumps work. See
86
+ // change: fix-pi-core-update-cross-minor.
87
+ const spec = `${pkg.name}@latest`;
78
88
  const args =
79
89
  pkg.installSource === "global"
80
- ? ["update", "-g", pkg.name]
81
- : ["update", pkg.name];
90
+ ? ["install", "-g", spec]
91
+ : ["install", spec];
82
92
  const cwd = pkg.installSource === "managed" ? MANAGED_DIR : process.cwd();
83
93
 
84
94
  if (pkg.installSource === "managed" && !existsSync(MANAGED_DIR)) {
@@ -149,9 +159,9 @@ export function defaultRunNpmUpdate(
149
159
  } else {
150
160
  const hint =
151
161
  pkg.installSource === "global" && /permission|EACCES|EPERM|EROFS/i.test(stderrBuf)
152
- ? ` (permission error — try: sudo npm update -g ${pkg.name})`
162
+ ? ` (permission error — try: sudo npm install -g ${pkg.name}@latest)`
153
163
  : "";
154
- reject(new Error(`npm update exited with code ${code}${hint}`));
164
+ reject(new Error(`npm install exited with code ${code}${hint}`));
155
165
  }
156
166
  });
157
167
  });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * pi.dev version-check client. Mirrors the implementation pi itself
3
+ * uses for self-update checks (see `@mariozechner/pi-coding-agent/dist/
4
+ * utils/version-check.js`). Returns `{ version, packageName? }` so the
5
+ * dashboard can:
6
+ * 1. Detect the genuinely-newest pi without npm-registry lag.
7
+ * 2. Pick up pi's npm-scope migration dynamically (the response's
8
+ * `packageName` field is the upstream's authoritative answer to
9
+ * "which package should be installed for the latest pi?").
10
+ *
11
+ * Falls back to `undefined` on any error so callers can degrade to
12
+ * the npm-registry path. Honours `PI_OFFLINE` and `PI_SKIP_VERSION_CHECK`
13
+ * envs identically to pi.
14
+ *
15
+ * See change: improve-pi-update-detection.
16
+ */
17
+
18
+ const LATEST_VERSION_URL = "https://pi.dev/api/latest-version";
19
+ const DEFAULT_TIMEOUT_MS = 10_000;
20
+
21
+ export interface PiDevReleaseInfo {
22
+ /** Latest version string as published by pi.dev (e.g. `"0.74.0"`). */
23
+ version: string;
24
+ /**
25
+ * Authoritative package name for fresh installs. Pi 0.73.1+ returns
26
+ * this so consumers can follow npm-scope migrations without code
27
+ * changes. Absent for older pi.dev responses.
28
+ */
29
+ packageName?: string;
30
+ }
31
+
32
+ export interface PiDevVersionCheckOptions {
33
+ /** Override fetch timeout. Default 10 s, matching pi. */
34
+ timeoutMs?: number;
35
+ /** Test seam: override fetch implementation. */
36
+ fetchImpl?: typeof fetch;
37
+ }
38
+
39
+ interface ParsedSemver {
40
+ major: number;
41
+ minor: number;
42
+ patch: number;
43
+ prerelease?: string;
44
+ }
45
+
46
+ /**
47
+ * Parse a semver-ish version string. Mirrors pi's `parsePackageVersion`.
48
+ * Returns `undefined` for unparseable input so callers fall back to a
49
+ * conservative comparison.
50
+ */
51
+ export function parsePackageVersion(version: string): ParsedSemver | undefined {
52
+ const match = version
53
+ .trim()
54
+ .match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
55
+ if (!match) return undefined;
56
+ return {
57
+ major: Number.parseInt(match[1], 10),
58
+ minor: Number.parseInt(match[2], 10),
59
+ patch: Number.parseInt(match[3], 10),
60
+ prerelease: match[4],
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Compare two semver strings: -ve when left < right, 0 when equal,
66
+ * +ve when left > right. Returns `undefined` when either side is
67
+ * unparseable.
68
+ */
69
+ export function comparePackageVersions(
70
+ leftVersion: string,
71
+ rightVersion: string,
72
+ ): number | undefined {
73
+ const left = parsePackageVersion(leftVersion);
74
+ const right = parsePackageVersion(rightVersion);
75
+ if (!left || !right) return undefined;
76
+ if (left.major !== right.major) return left.major - right.major;
77
+ if (left.minor !== right.minor) return left.minor - right.minor;
78
+ if (left.patch !== right.patch) return left.patch - right.patch;
79
+ if (left.prerelease === right.prerelease) return 0;
80
+ if (!left.prerelease) return 1;
81
+ if (!right.prerelease) return -1;
82
+ return left.prerelease.localeCompare(right.prerelease);
83
+ }
84
+
85
+ /** True when `candidateVersion` is strictly newer than `currentVersion`. */
86
+ export function isNewerPackageVersion(
87
+ candidateVersion: string,
88
+ currentVersion: string,
89
+ ): boolean {
90
+ const cmp = comparePackageVersions(candidateVersion, currentVersion);
91
+ if (cmp !== undefined) return cmp > 0;
92
+ return candidateVersion.trim() !== currentVersion.trim();
93
+ }
94
+
95
+ /**
96
+ * Build the User-Agent string pi sends on its self-update calls.
97
+ * Format: `pi/<version> (<platform>; <runtime>; <arch>)`.
98
+ *
99
+ * `<runtime>` is `bun/<version>` when running under Bun, otherwise
100
+ * `node/<process.version>`. We don't identify the dashboard separately
101
+ * so pi.dev treats the request the same way as a self-update from pi.
102
+ */
103
+ export function getPiUserAgent(version: string, runtime?: string): string {
104
+ const rt =
105
+ runtime ??
106
+ ((globalThis as { Bun?: { version: string } }).Bun?.version
107
+ ? `bun/${(globalThis as { Bun?: { version: string } }).Bun!.version}`
108
+ : `node/${process.version}`);
109
+ return `pi/${version} (${process.platform}; ${rt}; ${process.arch})`;
110
+ }
111
+
112
+ /**
113
+ * Query pi.dev for the latest release info. Returns `undefined` on
114
+ * any of: env-skipped, network error, non-2xx response, malformed
115
+ * JSON, missing `version` field, timeout.
116
+ */
117
+ export async function getLatestPiRelease(
118
+ currentVersion: string,
119
+ opts: PiDevVersionCheckOptions = {},
120
+ ): Promise<PiDevReleaseInfo | undefined> {
121
+ if (process.env.PI_SKIP_VERSION_CHECK || process.env.PI_OFFLINE) {
122
+ return undefined;
123
+ }
124
+ const fetchFn = opts.fetchImpl ?? fetch;
125
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
126
+ try {
127
+ const response = await fetchFn(LATEST_VERSION_URL, {
128
+ headers: {
129
+ "User-Agent": getPiUserAgent(currentVersion),
130
+ accept: "application/json",
131
+ },
132
+ signal: AbortSignal.timeout(timeoutMs),
133
+ });
134
+ if (!response.ok) return undefined;
135
+ const data = (await response.json()) as { version?: unknown; packageName?: unknown };
136
+ if (typeof data.version !== "string" || !data.version.trim()) return undefined;
137
+ const packageName =
138
+ typeof data.packageName === "string" && data.packageName.trim()
139
+ ? data.packageName.trim()
140
+ : undefined;
141
+ return { version: data.version.trim(), packageName };
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
@@ -275,7 +275,11 @@ export function createPiGateway(
275
275
 
276
276
  if (msg.type === "session_register") {
277
277
  // Clear spawn-register watchdog BEFORE any throwing logic. See change: spawn-failure-diagnostics.
278
+ // Priority: token > pid > cwd. Token is the strongest identity
279
+ // (spawn-correlation-token); pid catches headless without token;
280
+ // cwd is the legacy fallback for tmux/wt with neither.
278
281
  const watchdog = getSpawnRegisterWatchdog();
282
+ if (msg.spawnToken) watchdog.clearByToken(msg.spawnToken);
279
283
  if (msg.pid !== undefined) watchdog.clearByPid(msg.pid);
280
284
  watchdog.clearByCwd(msg.cwd);
281
285
 
@@ -98,10 +98,18 @@ export function readPiCompatibility(serverPkgJsonPath: string): Pick<
98
98
  export function readCurrentPiVersion(registry: ToolRegistry = getDefaultRegistry()): string | undefined {
99
99
  try {
100
100
  const req = createRequire(import.meta.url);
101
- const pkgJson = req.resolve("@mariozechner/pi-coding-agent/package.json");
102
- const raw = fs.readFileSync(pkgJson, "utf8");
103
- const parsed = JSON.parse(raw) as { version?: string };
104
- if (typeof parsed.version === "string") return parsed.version;
101
+ let pkgJson: string | undefined;
102
+ for (const name of ["@earendil-works/pi-coding-agent", "@mariozechner/pi-coding-agent"]) {
103
+ try {
104
+ pkgJson = req.resolve(`${name}/package.json`);
105
+ break;
106
+ } catch { /* try next alias */ }
107
+ }
108
+ if (pkgJson) {
109
+ const raw = fs.readFileSync(pkgJson, "utf8");
110
+ const parsed = JSON.parse(raw) as { version?: string };
111
+ if (typeof parsed.version === "string") return parsed.version;
112
+ }
105
113
  } catch {
106
114
  /* not resolvable yet */
107
115
  }
@@ -23,6 +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";
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";
26
33
  import { execSync, spawnSync, buildSafeArgv } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
27
34
  import {
28
35
  spawnDetached,
@@ -51,12 +58,56 @@ export function resetResolver(): void {
51
58
  resolver = new ToolResolver({ processExecPath: process.execPath });
52
59
  }
53
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
+
54
96
  // ── Public API ─────────────────────────────────────────────────────────────
55
97
 
56
98
  export interface SessionOptions {
57
99
  sessionFile?: string;
58
100
  mode?: "continue" | "fork";
59
101
  strategy?: SpawnStrategy;
102
+ /**
103
+ * Server-minted spawn correlation token. When provided, injected into
104
+ * the spawned process env as `PI_DASHBOARD_SPAWN_TOKEN`. The bridge
105
+ * echoes it back in the first `session_register` so the server can
106
+ * resolve identity precisely (linkByToken). When omitted, callers
107
+ * fall through to pid-link or cwd-FIFO matching.
108
+ * See change: spawn-correlation-token.
109
+ */
110
+ spawnToken?: string;
60
111
  }
61
112
 
62
113
  export interface SpawnResult {
@@ -72,6 +123,22 @@ export interface SpawnResult {
72
123
  stderr?: string;
73
124
  /** Path to the per-session stderr log (Windows headless). Forwarded to watchdog. See change: spawn-failure-diagnostics. */
74
125
  logPath?: string;
126
+ /**
127
+ * Token minted by `spawnPiSession` and injected into the spawned process's
128
+ * env as `PI_DASHBOARD_SPAWN_TOKEN`. Returned so callers can register it
129
+ * with the headless-pid registry, watchdog, and pending-* registries.
130
+ * See change: spawn-correlation-token.
131
+ */
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;
75
142
  }
76
143
 
77
144
  /**
@@ -87,8 +154,18 @@ export interface SpawnResult {
87
154
  * lands at the very head of `PATH` — spawned children invoking plain
88
155
  * `node` / `npm` resolve to the managed runtime first.
89
156
  */
90
- export function buildSpawnEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
91
- return prependManagedNodeToPath(resolver.buildSpawnEnv(baseEnv));
157
+ export function buildSpawnEnv(
158
+ baseEnv: NodeJS.ProcessEnv = process.env,
159
+ opts?: { spawnToken?: string },
160
+ ): NodeJS.ProcessEnv {
161
+ const env = prependManagedNodeToPath(resolver.buildSpawnEnv(baseEnv));
162
+ if (opts?.spawnToken) {
163
+ // Inject the correlation token so the bridge inside the spawned pi
164
+ // process can read it and echo back in `session_register`.
165
+ // See change: spawn-correlation-token.
166
+ return { ...env, PI_DASHBOARD_SPAWN_TOKEN: opts.spawnToken };
167
+ }
168
+ return env;
92
169
  }
93
170
 
94
171
  /**
@@ -279,14 +356,25 @@ export async function spawnPiSession(
279
356
  return { success: false, code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` };
280
357
  }
281
358
 
282
- const mechanism = chooseMechanism(options, options?.electronMode ?? false);
359
+ // Mint a spawn token if the caller didn't provide one. Token is injected
360
+ // into the spawned process's env (via buildSpawnEnv) and surfaced on
361
+ // SpawnResult so callers can register it with the registries.
362
+ // See change: spawn-correlation-token.
363
+ const spawnToken = options?.spawnToken ?? mintSpawnToken();
364
+ const opts: SessionOptions & { electronMode?: boolean } = { ...(options ?? {}), spawnToken };
283
365
 
366
+ const mechanism = chooseMechanism(opts, opts?.electronMode ?? false);
367
+
368
+ let result: SpawnResult;
284
369
  switch (mechanism) {
285
- case "tmux": return spawnTmux(cwd, options);
286
- case "wt": return spawnWt(cwd, options);
287
- case "wsl-tmux": return spawnWslTmux(cwd, options);
288
- case "headless": return spawnHeadless(cwd, options);
370
+ case "tmux": result = spawnTmux(cwd, opts); break;
371
+ case "wt": result = await spawnWt(cwd, opts); break;
372
+ case "wsl-tmux": result = spawnWslTmux(cwd, opts); break;
373
+ case "headless": result = await spawnHeadless(cwd, opts); break;
289
374
  }
375
+ // Surface the token on every result (success or failure) so callers
376
+ // can clean up registries deterministically.
377
+ return { ...result, spawnToken };
290
378
  }
291
379
 
292
380
  // ── Per-mechanism spawn ────────────────────────────────────────────────────
@@ -294,8 +382,12 @@ export async function spawnPiSession(
294
382
  function spawnTmux(cwd: string, options?: SessionOptions): SpawnResult {
295
383
  const exists = dashboardSessionExists();
296
384
  const cmd = buildTmuxCommand(cwd, exists, options);
385
+ // Pass env explicitly so PI_DASHBOARD_SPAWN_TOKEN reaches the tmux pane's
386
+ // pi process (tmux inherits the caller's env into new windows/sessions).
387
+ // See change: spawn-correlation-token.
388
+ const env = buildSpawnEnv(process.env, { spawnToken: options?.spawnToken });
297
389
  try {
298
- execSync(cmd, { stdio: "ignore" });
390
+ execSync(cmd, { stdio: "ignore", env });
299
391
  return {
300
392
  success: true,
301
393
  dashboardSpawned: true,
@@ -309,7 +401,8 @@ function spawnTmux(cwd: string, options?: SessionOptions): SpawnResult {
309
401
  function spawnWslTmux(cwd: string, options?: SessionOptions): SpawnResult {
310
402
  try {
311
403
  const cmd = `wsl ${buildTmuxCommand(cwd, false, options)}`;
312
- execSync(cmd, { stdio: "ignore" });
404
+ const env = buildSpawnEnv(process.env, { spawnToken: options?.spawnToken });
405
+ execSync(cmd, { stdio: "ignore", env });
313
406
  return { success: true, dashboardSpawned: true, message: "Pi session spawned via WSL tmux" };
314
407
  } catch (err: any) {
315
408
  return { success: false, code: "TMUX_MISSING", message: `Failed to spawn via WSL tmux (wsl-tmux mechanism): ${err.message}` };
@@ -333,7 +426,7 @@ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResu
333
426
  cmd: wt,
334
427
  args,
335
428
  cwd,
336
- env: buildSpawnEnv(),
429
+ env: buildSpawnEnv(process.env, { spawnToken: options?.spawnToken }),
337
430
  });
338
431
 
339
432
  if (!r.ok) {
@@ -351,7 +444,16 @@ async function spawnWt(cwd: string, options?: SessionOptions): Promise<SpawnResu
351
444
 
352
445
  async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<SpawnResult> {
353
446
  const args = buildHeadlessArgs(options);
354
- const env = buildSpawnEnv();
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
+
355
457
  const piCmd = resolvePiCommand();
356
458
  if (!piCmd) {
357
459
  return { success: false, code: "PI_NOT_FOUND", message: `pi binary not found. Checked: ${MANAGED_BIN} and system PATH.` };
@@ -387,6 +489,75 @@ async function spawnHeadless(cwd: string, options?: SessionOptions): Promise<Spa
387
489
  };
388
490
  }
389
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
+
390
561
  /**
391
562
  * Windows headless spawn using the detached-spawn primitive.
392
563
  *