@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
@@ -20,7 +20,7 @@ import type { PiCorePackage } from "@blackbelt-technology/pi-dashboard-shared/re
20
20
 
21
21
  function makePkg(overrides: Partial<PiCorePackage> = {}): PiCorePackage {
22
22
  return {
23
- name: "@mariozechner/pi-coding-agent",
23
+ name: "@earendil-works/pi-coding-agent",
24
24
  displayName: "pi",
25
25
  currentVersion: "0.1.0",
26
26
  latestVersion: "0.2.0",
@@ -90,8 +90,11 @@ describe("defaultRunNpmUpdate — registry resolution + managed PATH", () => {
90
90
  expect(capturedCmd).toBe("C:\\node\\node.exe");
91
91
  expect(capturedArgs.slice(0, 2)).toEqual([
92
92
  "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
93
- "update",
93
+ "install",
94
94
  ]);
95
+ // Anchor the @latest suffix — the regression guard for
96
+ // fix-pi-core-update-cross-minor.
97
+ expect(capturedArgs).toContain("@earendil-works/pi-coding-agent@latest");
95
98
  });
96
99
 
97
100
  it("rejects with a clear 'npm' error when registry can't resolve", async () => {
@@ -152,7 +155,63 @@ describe("defaultRunNpmUpdate — registry resolution + managed PATH", () => {
152
155
  _envBuilder: () => ({}),
153
156
  },
154
157
  ),
155
- ).rejects.toThrow(/sudo npm update -g @example\/pkg/);
158
+ ).rejects.toThrow(/sudo npm install -g @example\/pkg@latest/);
159
+ });
160
+
161
+ it("spawns npm install with @latest suffix for managed install (regression guard)", async () => {
162
+ // fix-pi-core-update-cross-minor: managed updates must not run
163
+ // `npm update` (which respects the consuming package.json range)
164
+ // — they must run `npm install <pkg>@latest`.
165
+ let capturedArgs: readonly string[] = [];
166
+ const spawnFn = makeFakeSpawn({
167
+ exitCode: 0,
168
+ captureSpawn: (_c, args) => {
169
+ capturedArgs = args;
170
+ },
171
+ });
172
+ // Pre-create the managed dir so the existence check passes.
173
+ const managedDir = path.join(os.homedir(), ".pi-dashboard");
174
+ fs.mkdirSync(managedDir, { recursive: true });
175
+
176
+ await defaultRunNpmUpdate(
177
+ makePkg({ name: "@mariozechner/pi-coding-agent", installSource: "managed" }),
178
+ () => {},
179
+ {
180
+ _resolveNpm: () => ({ ok: true, argv: ["/usr/bin/npm"] }),
181
+ _spawn: spawnFn,
182
+ _envBuilder: () => ({}),
183
+ },
184
+ );
185
+
186
+ expect(capturedArgs[0]).toBe("install");
187
+ // NOT "-g" for managed installs.
188
+ expect(capturedArgs).not.toContain("-g");
189
+ // The hot bit: @latest suffix.
190
+ expect(capturedArgs.some((a) => a === "@mariozechner/pi-coding-agent@latest")).toBe(true);
191
+ });
192
+
193
+ it("spawns npm install -g with @latest suffix for global install (regression guard)", async () => {
194
+ let capturedArgs: readonly string[] = [];
195
+ const spawnFn = makeFakeSpawn({
196
+ exitCode: 0,
197
+ captureSpawn: (_c, args) => {
198
+ capturedArgs = args;
199
+ },
200
+ });
201
+
202
+ await defaultRunNpmUpdate(
203
+ makePkg({ name: "@mariozechner/pi-coding-agent", installSource: "global" }),
204
+ () => {},
205
+ {
206
+ _resolveNpm: () => ({ ok: true, argv: ["/usr/bin/npm"] }),
207
+ _spawn: spawnFn,
208
+ _envBuilder: () => ({}),
209
+ },
210
+ );
211
+
212
+ expect(capturedArgs[0]).toBe("install");
213
+ expect(capturedArgs).toContain("-g");
214
+ expect(capturedArgs.some((a) => a.endsWith("@latest"))).toBe(true);
156
215
  });
157
216
 
158
217
  it("rejects up front when managed install dir does not exist", async () => {
@@ -8,7 +8,7 @@ import type { PiCorePackage } from "@blackbelt-technology/pi-dashboard-shared/re
8
8
 
9
9
  // Pi PM is mocked in other tests via vi.mock; we don't need it here because
10
10
  // we never call the install/remove/update methods — only runExclusive().
11
- vi.mock("@mariozechner/pi-coding-agent", () => ({
11
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
12
12
  DefaultPackageManager: function () {
13
13
  return {};
14
14
  },
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for `packages/server/bin/pi-dashboard.mjs` — the published CLI
3
+ * bin entry. Spawns the wrapper as a child process to exercise the
4
+ * real jiti-resolution + re-exec behaviour.
5
+ *
6
+ * See change: replace-tsx-with-jiti.
7
+ */
8
+ import { describe, it, expect, beforeAll } from "vitest";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import url from "node:url";
14
+
15
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
16
+ const wrapperPath = path.resolve(here, "..", "..", "bin", "pi-dashboard.mjs");
17
+ const repoNodeModules = path.resolve(here, "..", "..", "..", "..", "node_modules");
18
+ const repoJitiRegister = path.join(repoNodeModules, "jiti", "lib", "jiti-register.mjs");
19
+
20
+ describe("bin/pi-dashboard.mjs wrapper", () => {
21
+ beforeAll(() => {
22
+ if (!existsSync(wrapperPath)) {
23
+ throw new Error(`Wrapper missing at ${wrapperPath}`);
24
+ }
25
+ });
26
+
27
+ it("exits 1 with install-hint when jiti cannot be resolved", () => {
28
+ // Build an isolated anchor with NO node_modules tree — createRequire on
29
+ // it will fail to resolve `jiti/package.json`, triggering the miss path.
30
+ const tmp = mkdtempSync(path.join(tmpdir(), "pi-dashboard-bin-test-"));
31
+ try {
32
+ const fakeAnchor = path.join(tmp, "fake-anchor.js");
33
+ writeFileSync(fakeAnchor, "// no-op anchor with no node_modules\n");
34
+
35
+ // Spawn the wrapper. We override process.argv[1] indirectly by
36
+ // invoking node with `<wrapper>` then forcing argv[1] to the fake
37
+ // anchor via a tiny preamble — but the wrapper reads its OWN
38
+ // process.argv[1] which is the wrapper path itself when invoked
39
+ // directly. Strategy: copy the wrapper into the isolated tmp dir so
40
+ // its argv[1] resolves there with no jiti adjacency.
41
+ const isolatedWrapper = path.join(tmp, "pi-dashboard.mjs");
42
+ const wrapperSrc = require("node:fs").readFileSync(wrapperPath, "utf-8");
43
+ writeFileSync(isolatedWrapper, wrapperSrc);
44
+
45
+ const result = spawnSync(process.execPath, [isolatedWrapper, "--version"], {
46
+ encoding: "utf-8",
47
+ env: { ...process.env, NODE_PATH: "" },
48
+ timeout: 10_000,
49
+ });
50
+
51
+ expect(result.status).toBe(1);
52
+ expect(result.stderr).toContain("pi-dashboard: cannot find jiti");
53
+ expect(result.stderr).toContain("npm install -g @earendil-works/pi-coding-agent");
54
+ // No tsx mention — proposal mandates no-fallback wrapper.
55
+ expect(result.stderr).not.toMatch(/tsx/i);
56
+ } finally {
57
+ rmSync(tmp, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ it("resolves jiti from process.argv[1] anchor and re-execs cli.ts", () => {
62
+ // Repo root has jiti at node_modules/jiti — wrapper invoked with its
63
+ // real path SHOULD walk createRequire(realpath(argv[1])) up into the
64
+ // repo's node_modules and find jiti.
65
+ if (!existsSync(repoJitiRegister)) {
66
+ // CI / fresh clone without `npm install` — skip rather than fail.
67
+ return;
68
+ }
69
+
70
+ // Use `status` — it doesn't bind ports and exits quickly regardless
71
+ // of whether a server is running. We don't care about exit code (0 if
72
+ // a dashboard is up, 1 if not — both are valid outcomes that prove
73
+ // the wrapper successfully resolved jiti and re-execed cli.ts). What
74
+ // we DO care about: (a) no jiti-miss error on stderr, (b) cli.ts
75
+ // produced its own "Dashboard server" output (running OR not running).
76
+ const result = spawnSync(process.execPath, [wrapperPath, "status"], {
77
+ encoding: "utf-8",
78
+ timeout: 30_000,
79
+ });
80
+
81
+ expect(result.stderr).not.toContain("pi-dashboard: cannot find jiti");
82
+ expect(result.stdout).toMatch(/Dashboard server/i);
83
+ }, 60_000);
84
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Tests for `pi-dev-version-check.ts`.
3
+ *
4
+ * See change: improve-pi-update-detection.
5
+ */
6
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
7
+ import {
8
+ parsePackageVersion,
9
+ comparePackageVersions,
10
+ isNewerPackageVersion,
11
+ getPiUserAgent,
12
+ getLatestPiRelease,
13
+ } from "../pi-dev-version-check.js";
14
+
15
+ describe("parsePackageVersion", () => {
16
+ it("parses plain semver", () => {
17
+ expect(parsePackageVersion("0.70.6")).toEqual({
18
+ major: 0,
19
+ minor: 70,
20
+ patch: 6,
21
+ prerelease: undefined,
22
+ });
23
+ });
24
+
25
+ it("parses semver with v prefix", () => {
26
+ expect(parsePackageVersion("v1.2.3")).toEqual({
27
+ major: 1,
28
+ minor: 2,
29
+ patch: 3,
30
+ prerelease: undefined,
31
+ });
32
+ });
33
+
34
+ it("parses semver with prerelease", () => {
35
+ expect(parsePackageVersion("0.71.0-rc.1")).toEqual({
36
+ major: 0,
37
+ minor: 71,
38
+ patch: 0,
39
+ prerelease: "rc.1",
40
+ });
41
+ });
42
+
43
+ it("returns undefined for unparseable", () => {
44
+ expect(parsePackageVersion("not-a-version")).toBeUndefined();
45
+ expect(parsePackageVersion("")).toBeUndefined();
46
+ });
47
+ });
48
+
49
+ describe("comparePackageVersions", () => {
50
+ it("orders by major then minor then patch", () => {
51
+ expect(comparePackageVersions("0.70.6", "0.74.0")).toBeLessThan(0);
52
+ expect(comparePackageVersions("1.0.0", "0.99.99")).toBeGreaterThan(0);
53
+ expect(comparePackageVersions("0.70.6", "0.70.6")).toBe(0);
54
+ });
55
+
56
+ it("treats prerelease as less than full release", () => {
57
+ expect(comparePackageVersions("0.71.0-rc.1", "0.71.0")).toBeLessThan(0);
58
+ expect(comparePackageVersions("0.71.0", "0.71.0-rc.1")).toBeGreaterThan(0);
59
+ });
60
+
61
+ it("returns undefined for unparseable", () => {
62
+ expect(comparePackageVersions("nope", "0.0.1")).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("isNewerPackageVersion", () => {
67
+ it("true when candidate is strictly newer", () => {
68
+ expect(isNewerPackageVersion("0.74.0", "0.70.6")).toBe(true);
69
+ });
70
+
71
+ it("false when candidate is same or older", () => {
72
+ expect(isNewerPackageVersion("0.70.6", "0.70.6")).toBe(false);
73
+ expect(isNewerPackageVersion("0.70.5", "0.70.6")).toBe(false);
74
+ });
75
+
76
+ it("falls back to string-inequality when unparseable", () => {
77
+ expect(isNewerPackageVersion("nightly-abc", "nightly-abc")).toBe(false);
78
+ expect(isNewerPackageVersion("nightly-xyz", "nightly-abc")).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("getPiUserAgent", () => {
83
+ it("formats as pi/<version> (<platform>; <runtime>; <arch>)", () => {
84
+ const ua = getPiUserAgent("0.70.6", "node/v22.20.0");
85
+ expect(ua).toMatch(/^pi\/0\.70\.6 \([a-z0-9]+; node\/v22\.20\.0; [a-z0-9]+\)$/);
86
+ });
87
+
88
+ it("auto-detects current Node runtime when not provided", () => {
89
+ const ua = getPiUserAgent("0.70.6");
90
+ expect(ua).toContain("pi/0.70.6");
91
+ // process.version is something like "v22.x.y"
92
+ expect(ua).toContain(`node/${process.version}`);
93
+ });
94
+ });
95
+
96
+ describe("getLatestPiRelease", () => {
97
+ let originalSkip: string | undefined;
98
+ let originalOffline: string | undefined;
99
+
100
+ beforeEach(() => {
101
+ originalSkip = process.env.PI_SKIP_VERSION_CHECK;
102
+ originalOffline = process.env.PI_OFFLINE;
103
+ delete process.env.PI_SKIP_VERSION_CHECK;
104
+ delete process.env.PI_OFFLINE;
105
+ });
106
+
107
+ afterEach(() => {
108
+ if (originalSkip !== undefined) process.env.PI_SKIP_VERSION_CHECK = originalSkip;
109
+ else delete process.env.PI_SKIP_VERSION_CHECK;
110
+ if (originalOffline !== undefined) process.env.PI_OFFLINE = originalOffline;
111
+ else delete process.env.PI_OFFLINE;
112
+ });
113
+
114
+ it("returns parsed { version, packageName } on success", async () => {
115
+ const fetchImpl = vi.fn().mockResolvedValue({
116
+ ok: true,
117
+ json: async () => ({ version: "0.74.0", packageName: "@earendil-works/pi-coding-agent" }),
118
+ });
119
+ const out = await getLatestPiRelease("0.70.6", { fetchImpl });
120
+ expect(out).toEqual({ version: "0.74.0", packageName: "@earendil-works/pi-coding-agent" });
121
+ });
122
+
123
+ it("returns undefined when packageName is absent", async () => {
124
+ const fetchImpl = vi.fn().mockResolvedValue({
125
+ ok: true,
126
+ json: async () => ({ version: "0.70.6" }),
127
+ });
128
+ const out = await getLatestPiRelease("0.70.6", { fetchImpl });
129
+ expect(out).toEqual({ version: "0.70.6", packageName: undefined });
130
+ });
131
+
132
+ it("sends User-Agent matching pi's format", async () => {
133
+ let capturedUA: string | undefined;
134
+ const fetchImpl = vi.fn().mockImplementation((_url: string, opts: any) => {
135
+ capturedUA = opts.headers?.["User-Agent"];
136
+ return Promise.resolve({
137
+ ok: true,
138
+ json: async () => ({ version: "0.74.0" }),
139
+ });
140
+ });
141
+ await getLatestPiRelease("0.70.6", { fetchImpl });
142
+ expect(capturedUA).toMatch(/^pi\/0\.70\.6 \([a-z0-9]+; node\/v[\d.]+; [a-z0-9]+\)$/);
143
+ });
144
+
145
+ it("returns undefined on non-2xx", async () => {
146
+ const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({}) });
147
+ expect(await getLatestPiRelease("0.70.6", { fetchImpl })).toBeUndefined();
148
+ });
149
+
150
+ it("returns undefined on network error", async () => {
151
+ const fetchImpl = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
152
+ expect(await getLatestPiRelease("0.70.6", { fetchImpl })).toBeUndefined();
153
+ });
154
+
155
+ it("returns undefined on missing version field", async () => {
156
+ const fetchImpl = vi.fn().mockResolvedValue({
157
+ ok: true,
158
+ json: async () => ({ packageName: "@x/y" }),
159
+ });
160
+ expect(await getLatestPiRelease("0.70.6", { fetchImpl })).toBeUndefined();
161
+ });
162
+
163
+ it("returns undefined on empty version field", async () => {
164
+ const fetchImpl = vi.fn().mockResolvedValue({
165
+ ok: true,
166
+ json: async () => ({ version: " " }),
167
+ });
168
+ expect(await getLatestPiRelease("0.70.6", { fetchImpl })).toBeUndefined();
169
+ });
170
+
171
+ it("skips request when PI_OFFLINE is set", async () => {
172
+ process.env.PI_OFFLINE = "1";
173
+ const fetchImpl = vi.fn();
174
+ expect(await getLatestPiRelease("0.70.6", { fetchImpl })).toBeUndefined();
175
+ expect(fetchImpl).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("skips request when PI_SKIP_VERSION_CHECK is set", async () => {
179
+ process.env.PI_SKIP_VERSION_CHECK = "1";
180
+ const fetchImpl = vi.fn();
181
+ expect(await getLatestPiRelease("0.70.6", { fetchImpl })).toBeUndefined();
182
+ expect(fetchImpl).not.toHaveBeenCalled();
183
+ });
184
+ });
@@ -187,17 +187,17 @@ describe("pi-version-skew", () => {
187
187
  }
188
188
 
189
189
  it("npm-global symlinked bin launcher resolves to the real package.json", () => {
190
- // Simulate ~/.nvm/.../bin/pi → ../lib/node_modules/@mariozechner/pi-coding-agent/dist/cli.js
190
+ // Simulate ~/.nvm/.../bin/pi → ../lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js
191
191
  const nodeRoot = path.join(tmpDir, "node-install");
192
192
  const binDir = path.join(nodeRoot, "bin");
193
- const pkgDir = path.join(nodeRoot, "lib", "node_modules", "@mariozechner", "pi-coding-agent");
193
+ const pkgDir = path.join(nodeRoot, "lib", "node_modules", "@earendil-works", "pi-coding-agent");
194
194
  const distDir = path.join(pkgDir, "dist");
195
195
  fs.mkdirSync(binDir, { recursive: true });
196
196
  fs.mkdirSync(distDir, { recursive: true });
197
197
  fs.writeFileSync(path.join(distDir, "cli.js"), "// stub");
198
198
  fs.writeFileSync(
199
199
  path.join(pkgDir, "package.json"),
200
- JSON.stringify({ name: "@mariozechner/pi-coding-agent", version: "0.70.0" }),
200
+ JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: "0.74.0" }),
201
201
  );
202
202
  // The bad path (what old code computed) must NOT exist.
203
203
  // That is: nodeRoot/package.json. We leave it absent.
@@ -210,7 +210,7 @@ describe("pi-version-skew", () => {
210
210
  );
211
211
 
212
212
  const registry = stubRegistry(binLink);
213
- expect(readCurrentPiVersion(registry)).toBe("0.70.0");
213
+ expect(readCurrentPiVersion(registry)).toBe("0.74.0");
214
214
  });
215
215
 
216
216
  it("non-symlinked path is a no-op under realpath", () => {
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Tests for the `useRpcKeeper: true` branch in `spawnHeadless` (Phase 5).
3
+ *
4
+ * Drives `spawnPiSession({strategy: "headless"})` with the keeper-flag
5
+ * override on, an injected fake KeeperManager, and verifies:
6
+ * - keeper branch fires (KeeperManager.spawnKeeperFor called, NOT pi resolved)
7
+ * - returned SpawnResult.pid is the keeper PID
8
+ * - env passed to the keeper includes `PI_DASHBOARD_SPAWN_TOKEN`
9
+ * - keeper failure surfaces as `PI_CRASHED` or `SPAWN_ERRNO`
10
+ * - flag OFF (default) → keeper is NOT used
11
+ */
12
+ import { EventEmitter } from "node:events";
13
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import type {
17
+ KeeperManager,
18
+ KeeperSpawnResult,
19
+ } from "../rpc-keeper/keeper-manager.js";
20
+ import {
21
+ setKeeperManager,
22
+ _setUseRpcKeeperOverrideForTests,
23
+ spawnPiSession,
24
+ } from "../process-manager.js";
25
+
26
+ class FakeKeeperChild extends EventEmitter {
27
+ pid: number;
28
+ unref = vi.fn();
29
+ kill = vi.fn();
30
+ // Never emits "exit" → waitForNoCrash window completes cleanly.
31
+ constructor(pid: number) { super(); this.pid = pid; }
32
+ }
33
+
34
+ interface FakeKeeperManagerState {
35
+ spawnCalls: Array<{ sessionId: string; cwd: string; env: NodeJS.ProcessEnv; piArgs?: string[] }>;
36
+ writeCalls: Array<{ sessionId: string; line: string }>;
37
+ killCalls: string[];
38
+ spawnResult: KeeperSpawnResult;
39
+ }
40
+
41
+ function makeFakeKeeperManager(
42
+ state: Partial<FakeKeeperManagerState> & { spawnResult: KeeperSpawnResult },
43
+ ): { km: KeeperManager; state: FakeKeeperManagerState } {
44
+ const full: FakeKeeperManagerState = {
45
+ spawnCalls: state.spawnCalls ?? [],
46
+ writeCalls: state.writeCalls ?? [],
47
+ killCalls: state.killCalls ?? [],
48
+ spawnResult: state.spawnResult,
49
+ };
50
+ const km: KeeperManager = {
51
+ sessionsDir: "/fake/sessions",
52
+ spawnKeeperFor: async (sessionId, cwd, env, piArgs) => {
53
+ full.spawnCalls.push({ sessionId, cwd, env, piArgs });
54
+ return full.spawnResult;
55
+ },
56
+ writeRpc: async (sessionId, line) => {
57
+ full.writeCalls.push({ sessionId, line });
58
+ return true;
59
+ },
60
+ writeRpcToSockPath: async (_sockPath, _line) => true,
61
+ killKeeper: (sessionId) => {
62
+ full.killCalls.push(sessionId);
63
+ return true;
64
+ },
65
+ discoverExistingKeepers: async () => [],
66
+ };
67
+ return { km, state: full };
68
+ }
69
+
70
+ let tmpCwd: string;
71
+
72
+ beforeEach(() => {
73
+ tmpCwd = mkdtempSync(path.join("/tmp", "km-cwd-"));
74
+ });
75
+ afterEach(() => {
76
+ setKeeperManager(null);
77
+ _setUseRpcKeeperOverrideForTests(null);
78
+ rmSync(tmpCwd, { recursive: true, force: true });
79
+ });
80
+
81
+ describe("spawnHeadless (useRpcKeeper: true)", () => {
82
+ it("routes through KeeperManager when flag is on", async () => {
83
+ const fakeChild = new FakeKeeperChild(11111);
84
+ const { km, state } = makeFakeKeeperManager({
85
+ spawnResult: {
86
+ success: true,
87
+ pid: 11111,
88
+ sockPath: "/fake/sessions/sid.rpc.sock",
89
+ process: fakeChild as unknown as import("node:child_process").ChildProcess,
90
+ },
91
+ });
92
+ setKeeperManager(km);
93
+ _setUseRpcKeeperOverrideForTests(true);
94
+
95
+ const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
96
+
97
+ expect(result.success).toBe(true);
98
+ expect(result.pid).toBe(11111);
99
+ expect(state.spawnCalls).toHaveLength(1);
100
+ expect(state.spawnCalls[0].cwd).toBe(tmpCwd);
101
+
102
+ // spawnToken contract (task 5.3): the env passed to the keeper carries
103
+ // PI_DASHBOARD_SPAWN_TOKEN, which the keeper forwards to pi via
104
+ // process.env inheritance.
105
+ expect(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN).toBeDefined();
106
+ expect(typeof state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN).toBe("string");
107
+ expect(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN!.length).toBeGreaterThan(0);
108
+
109
+ // The returned spawnToken matches what was injected into env.
110
+ expect(result.spawnToken).toBe(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN);
111
+
112
+ // Bare-spawn piArgs are at least `--mode rpc`.
113
+ expect(state.spawnCalls[0].piArgs).toBeDefined();
114
+ expect(state.spawnCalls[0].piArgs).toContain("--mode");
115
+ expect(state.spawnCalls[0].piArgs).toContain("rpc");
116
+
117
+ // SpawnResult.keeperSockPath populated so callers can pass it to
118
+ // `headlessPidRegistry.register(..., {keeperPid, keeperSockPath})`
119
+ // (Phase 6 contract). See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
120
+ expect(result.keeperSockPath).toBe("/fake/sessions/sid.rpc.sock");
121
+ });
122
+
123
+ it("forwards resume flags (sessionFile / mode) to the keeper as piArgs", async () => {
124
+ const fakeChild = new FakeKeeperChild(33333);
125
+ const { km, state } = makeFakeKeeperManager({
126
+ spawnResult: {
127
+ success: true,
128
+ pid: 33333,
129
+ sockPath: "/fake/x.sock",
130
+ process: fakeChild as unknown as import("node:child_process").ChildProcess,
131
+ },
132
+ });
133
+ setKeeperManager(km);
134
+ _setUseRpcKeeperOverrideForTests(true);
135
+
136
+ const sessionFile = "/tmp/sess-resume.jsonl";
137
+ const result = await spawnPiSession(tmpCwd, {
138
+ strategy: "headless",
139
+ sessionFile,
140
+ mode: "continue",
141
+ });
142
+
143
+ expect(result.success).toBe(true);
144
+ expect(state.spawnCalls).toHaveLength(1);
145
+ const piArgs = state.spawnCalls[0].piArgs ?? [];
146
+ // piArgs MUST carry the session-file flag so resume actually resumes
147
+ // (regression guard: in the first Phase-5 cut the keeper hardcoded
148
+ // ["--mode","rpc"] and resume created a fresh session instead).
149
+ expect(piArgs).toContain("--mode");
150
+ expect(piArgs).toContain("rpc");
151
+ // sessionFlagsToArgv emits the session-file path; the exact flag name
152
+ // (`--session-file`) is verified in spawn-mechanism unit tests; here
153
+ // we only assert the path token is present so we don't double-bind to
154
+ // upstream argv shape.
155
+ expect(piArgs).toContain(sessionFile);
156
+ });
157
+
158
+ it("returns SPAWN_ERRNO when KeeperManager.spawnKeeperFor reports !success", async () => {
159
+ const { km } = makeFakeKeeperManager({
160
+ spawnResult: { success: false, error: "EACCES on socket bind" },
161
+ });
162
+ setKeeperManager(km);
163
+ _setUseRpcKeeperOverrideForTests(true);
164
+
165
+ const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
166
+ expect(result.success).toBe(false);
167
+ expect(result.code).toBe("SPAWN_ERRNO");
168
+ expect(result.message).toMatch(/RPC keeper/);
169
+ expect(result.message).toMatch(/EACCES/);
170
+ });
171
+
172
+ it("returns PI_CRASHED when keeper exits within the crash window", async () => {
173
+ // A child that emits "exit" inside 300 ms triggers the waitForNoCrash gate.
174
+ const fakeChild = new FakeKeeperChild(22222);
175
+ setTimeout(() => fakeChild.emit("exit", 1, null), 20);
176
+
177
+ const { km } = makeFakeKeeperManager({
178
+ spawnResult: {
179
+ success: true,
180
+ pid: 22222,
181
+ sockPath: "/fake/sessions/sid.rpc.sock",
182
+ process: fakeChild as unknown as import("node:child_process").ChildProcess,
183
+ },
184
+ });
185
+ setKeeperManager(km);
186
+ _setUseRpcKeeperOverrideForTests(true);
187
+
188
+ const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
189
+ expect(result.success).toBe(false);
190
+ expect(result.code).toBe("PI_CRASHED");
191
+ expect(result.message).toMatch(/crash window/);
192
+ });
193
+
194
+ it("does NOT route through KeeperManager when flag is off (default)", async () => {
195
+ const { km, state } = makeFakeKeeperManager({
196
+ spawnResult: { success: true, pid: 99999, sockPath: "/fake/x.sock" },
197
+ });
198
+ setKeeperManager(km);
199
+ _setUseRpcKeeperOverrideForTests(false);
200
+
201
+ // We don't care about the actual headless spawn result here — only that
202
+ // it does NOT call the fake KeeperManager.
203
+ await spawnPiSession(tmpCwd, { strategy: "headless" });
204
+ expect(state.spawnCalls).toEqual([]);
205
+ });
206
+ });
@@ -114,7 +114,14 @@ describe("provider-auth-routes", () => {
114
114
 
115
115
  // /exchange endpoint removed — token exchange happens in the callback server's onCode
116
116
 
117
- it("PUT /api/provider-auth/api-key saves and notifies bridges and browsers", async () => {
117
+ // notifyBridges semantics changed: it now ONLY broadcasts
118
+ // `credentials_updated` to bridges. The previous `models_refreshed`
119
+ // broadcast to browsers was removed because the per-session
120
+ // `models_list` channel is self-healing: each bridge pushes a fresh
121
+ // models_list for its session on credentials_updated, and browsers
122
+ // update modelsMap[sid] incrementally without a global wipe. See
123
+ // change: simplify-model-selection-channels.
124
+ it("PUT /api/provider-auth/api-key saves and broadcasts credentials_updated to bridges", async () => {
118
125
  const { writeCredential } = await import("../provider-auth-storage.js");
119
126
  const res = await app.inject({
120
127
  method: "PUT",
@@ -125,10 +132,11 @@ describe("provider-auth-routes", () => {
125
132
  expect(JSON.parse(res.payload).ok).toBe(true);
126
133
  expect(writeCredential).toHaveBeenCalledWith("openai", { type: "api_key", key: "sk-test" });
127
134
  expect(piGateway.broadcast).toHaveBeenCalledWith({ type: "credentials_updated" });
128
- expect(browserGateway.broadcastToAll).toHaveBeenCalledWith({ type: "models_refreshed" });
135
+ // No models_refreshed broadcast — see simplify-model-selection-channels.
136
+ expect(browserGateway.broadcastToAll).not.toHaveBeenCalledWith({ type: "models_refreshed" });
129
137
  });
130
138
 
131
- it("DELETE /api/provider-auth/:provider removes and notifies bridges and browsers", async () => {
139
+ it("DELETE /api/provider-auth/:provider removes and broadcasts credentials_updated", async () => {
132
140
  const { removeCredential } = await import("../provider-auth-storage.js");
133
141
  const res = await app.inject({
134
142
  method: "DELETE",
@@ -137,7 +145,7 @@ describe("provider-auth-routes", () => {
137
145
  expect(res.statusCode).toBe(200);
138
146
  expect(removeCredential).toHaveBeenCalledWith("anthropic");
139
147
  expect(piGateway.broadcast).toHaveBeenCalledWith({ type: "credentials_updated" });
140
- expect(browserGateway.broadcastToAll).toHaveBeenCalledWith({ type: "models_refreshed" });
148
+ expect(browserGateway.broadcastToAll).not.toHaveBeenCalledWith({ type: "models_refreshed" });
141
149
  });
142
150
 
143
151
  // /callback/:provider route removed — temp callback server handles this directly