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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -6
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Tests for `ToolResolver.resolveJiti` — ported from the prior
3
+ * `resolve-jiti.test.ts`. Exercises every anchor in the resolution
4
+ * chain (managed-pi upstream/legacy, system-pi, anchor walk-up,
5
+ * argv fallback, all-miss) plus the URL-shape invariants
6
+ * (`file://` URL output, Windows drive-letter wrapping, upstream
7
+ * jiti chosen before legacy fork).
8
+ *
9
+ * Test seams (`_pathExists`, `_realpath`, `_whichPi`, `_argv1`,
10
+ * `_managedDir`, `resolver`) keep the test pure — no fs / process
11
+ * mutation, no managed-dir on disk.
12
+ */
13
+ import { describe, it, expect } from "vitest";
14
+ import path from "node:path";
15
+ import { ToolResolver, MANAGED_PI_PACKAGES, JITI_PACKAGES } from "../platform/binary-lookup.js";
16
+
17
+ const MANAGED_DIR = "/fake/.pi-dashboard";
18
+
19
+ function makeResolver(installed: Record<string, string>) {
20
+ return (spec: string): string => {
21
+ if (spec in installed) return installed[spec]!;
22
+ throw new Error(`Cannot find module '${spec}'`);
23
+ };
24
+ }
25
+
26
+ describe("MANAGED_PI_PACKAGES + JITI_PACKAGES contract", () => {
27
+ it("upstream pi pkg first, legacy fork fallback", () => {
28
+ expect(MANAGED_PI_PACKAGES).toEqual([
29
+ "@earendil-works/pi-coding-agent",
30
+ "@mariozechner/pi-coding-agent",
31
+ ]);
32
+ });
33
+
34
+ it("upstream jiti first, legacy fork fallback", () => {
35
+ expect(JITI_PACKAGES).toEqual(["jiti", "@mariozechner/jiti"]);
36
+ });
37
+ });
38
+
39
+ describe("ToolResolver.resolveJiti — managed pi", () => {
40
+ it("hits upstream managed pi (@earendil-works) when only it is present", () => {
41
+ const upstreamPkgJson = path.join(
42
+ MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json",
43
+ );
44
+ const jitiPkgJson = "/managed/upstream/node_modules/jiti/package.json";
45
+ const url = new ToolResolver().resolveJiti({
46
+ _managedDir: MANAGED_DIR,
47
+ _pathExists: (p) => p === upstreamPkgJson || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
48
+ _whichPi: () => null,
49
+ _argv1: undefined,
50
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
51
+ });
52
+ expect(url).not.toBeNull();
53
+ expect(url!.startsWith("file://")).toBe(true);
54
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
55
+ expect(url!).not.toContain("@mariozechner");
56
+ });
57
+
58
+ it("falls through to legacy managed pi (@mariozechner) when upstream is absent", () => {
59
+ const legacyPkgJson = path.join(
60
+ MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json",
61
+ );
62
+ const jitiPkgJson = "/managed/legacy/node_modules/@mariozechner/jiti/package.json";
63
+ const url = new ToolResolver().resolveJiti({
64
+ _managedDir: MANAGED_DIR,
65
+ _pathExists: (p) =>
66
+ p === legacyPkgJson ||
67
+ p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
68
+ _whichPi: () => null,
69
+ _argv1: undefined,
70
+ resolver: makeResolver({
71
+ "@mariozechner/jiti/package.json": jitiPkgJson,
72
+ }),
73
+ });
74
+ expect(url).not.toBeNull();
75
+ expect(url!).toContain("@mariozechner/jiti");
76
+ });
77
+
78
+ it("prefers upstream pi over legacy when BOTH managed pkgs are present", () => {
79
+ const upstream = path.join(MANAGED_DIR, "node_modules", "@earendil-works", "pi-coding-agent", "package.json");
80
+ const legacy = path.join(MANAGED_DIR, "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
81
+ const upstreamJiti = "/managed/upstream/jiti/package.json";
82
+ const legacyJiti = "/managed/legacy/@mariozechner/jiti/package.json";
83
+ const calls: string[] = [];
84
+ const resolver = (spec: string): string => {
85
+ calls.push(spec);
86
+ if (spec === "jiti/package.json") return upstreamJiti;
87
+ if (spec === "@mariozechner/jiti/package.json") return legacyJiti;
88
+ throw new Error(`nope ${spec}`);
89
+ };
90
+ const url = new ToolResolver().resolveJiti({
91
+ _managedDir: MANAGED_DIR,
92
+ _pathExists: (p) =>
93
+ p === upstream || p === legacy ||
94
+ p === path.join(path.dirname(upstreamJiti), "lib", "jiti-register.mjs"),
95
+ _whichPi: () => null,
96
+ _argv1: undefined,
97
+ resolver,
98
+ });
99
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
100
+ expect(url!).not.toContain("@mariozechner");
101
+ // Upstream pi anchor produced upstream jiti — legacy pi anchor never tried.
102
+ expect(calls).toEqual(["jiti/package.json"]);
103
+ });
104
+ });
105
+
106
+ describe("ToolResolver.resolveJiti — system pi", () => {
107
+ it("uses which(\"pi\") when managed pi absent", () => {
108
+ const piBin = "/usr/local/bin/pi";
109
+ const piReal = "/usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js";
110
+ const jitiPkgJson = "/usr/local/lib/node_modules/jiti/package.json";
111
+ const url = new ToolResolver().resolveJiti({
112
+ _managedDir: MANAGED_DIR,
113
+ _pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
114
+ _whichPi: () => piBin,
115
+ _realpath: (p) => (p === piBin ? piReal : p),
116
+ _argv1: undefined,
117
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
118
+ });
119
+ expect(url!.startsWith("file://")).toBe(true);
120
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
121
+ });
122
+
123
+ it("realpaths a symlinked pi binary before resolving", () => {
124
+ const piSymlink = "/usr/local/bin/pi";
125
+ const piTarget = "/opt/pi/dist/cli.js";
126
+ const jitiPkgJson = "/opt/pi/node_modules/jiti/package.json";
127
+ let realpathArg: string | null = null;
128
+ const url = new ToolResolver().resolveJiti({
129
+ _managedDir: MANAGED_DIR,
130
+ // Managed-pi miss; only the symlinked register file exists.
131
+ _pathExists: (p) => p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
132
+ _whichPi: () => piSymlink,
133
+ _realpath: (p) => { realpathArg = p; return piTarget; },
134
+ _argv1: undefined,
135
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
136
+ });
137
+ expect(realpathArg).toBe(piSymlink);
138
+ expect(url).not.toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("ToolResolver.resolveJiti — anchor walk-up + argv fallback", () => {
143
+ it("uses caller-supplied anchor when prior layers miss", () => {
144
+ const anchor = "/custom/cli/path.js";
145
+ const jitiPkgJson = "/custom/node_modules/jiti/package.json";
146
+ const url = new ToolResolver().resolveJiti({
147
+ anchor,
148
+ _managedDir: MANAGED_DIR,
149
+ _pathExists: (p) => p === anchor || p === path.join(path.dirname(jitiPkgJson), "lib", "jiti-register.mjs"),
150
+ _whichPi: () => null,
151
+ _argv1: undefined,
152
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
153
+ });
154
+ expect(url).not.toBeNull();
155
+ expect(url!).toMatch(/\/jiti\/lib\/jiti-register\.mjs$/);
156
+ });
157
+
158
+ it("returns null when caller-supplied anchor does not exist on disk", () => {
159
+ const url = new ToolResolver().resolveJiti({
160
+ anchor: "/missing/path.js",
161
+ _managedDir: MANAGED_DIR,
162
+ _pathExists: () => false,
163
+ _whichPi: () => null,
164
+ _argv1: undefined,
165
+ resolver: () => "/whatever/jiti/package.json",
166
+ });
167
+ expect(url).toBeNull();
168
+ });
169
+
170
+ it("falls back to process.argv[1] (test seam) when all earlier anchors miss", () => {
171
+ const argv = "/runtime/argv1/cli.js";
172
+ const jitiPkgJson = "/runtime/node_modules/jiti/package.json";
173
+ const url = new ToolResolver().resolveJiti({
174
+ _managedDir: MANAGED_DIR,
175
+ _pathExists: () => true,
176
+ _whichPi: () => null,
177
+ _realpath: (p) => p,
178
+ _argv1: argv,
179
+ resolver: makeResolver({ "jiti/package.json": jitiPkgJson }),
180
+ });
181
+ expect(url).not.toBeNull();
182
+ });
183
+
184
+ it("returns null when every anchor misses", () => {
185
+ const url = new ToolResolver().resolveJiti({
186
+ _managedDir: MANAGED_DIR,
187
+ _pathExists: () => false,
188
+ _whichPi: () => null,
189
+ _argv1: undefined,
190
+ resolver: () => { throw new Error("nope"); },
191
+ });
192
+ expect(url).toBeNull();
193
+ });
194
+ });
195
+
196
+ describe("ToolResolver.resolveJiti — URL contract", () => {
197
+ it("returns a file:// URL parseable by new URL()", () => {
198
+ const url = new ToolResolver().resolveJiti({
199
+ _managedDir: MANAGED_DIR,
200
+ _pathExists: () => true,
201
+ _whichPi: () => null,
202
+ _argv1: "/runtime/argv1/cli.js",
203
+ _realpath: (p) => p,
204
+ resolver: makeResolver({ "jiti/package.json": "/r/node_modules/jiti/package.json" }),
205
+ });
206
+ expect(url!.startsWith("file://")).toBe(true);
207
+ expect(() => new URL(url!)).not.toThrow();
208
+ expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
209
+ });
210
+
211
+ it("URL-wraps Windows drive-letter pkg.json paths (regression for ERR_UNSUPPORTED_ESM_URL_SCHEME)", () => {
212
+ const winPkgJson = "B:\\Dev\\Nodejs\\global\\node_modules\\@mariozechner\\jiti\\package.json";
213
+ const url = new ToolResolver().resolveJiti({
214
+ _managedDir: MANAGED_DIR,
215
+ _pathExists: () => true,
216
+ _whichPi: () => null,
217
+ _argv1: "C:\\runtime\\cli.js",
218
+ _realpath: (p) => p,
219
+ resolver: makeResolver({ "@mariozechner/jiti/package.json": winPkgJson }),
220
+ });
221
+ expect(url).not.toBeNull();
222
+ expect(url!.startsWith("file:///")).toBe(true);
223
+ expect(() => new URL(url!)).not.toThrow();
224
+ expect(new URL(url!).protocol).toBe("file:");
225
+ expect(url!.toLowerCase()).toContain("/b:/");
226
+ expect(url!.endsWith("/lib/jiti-register.mjs")).toBe(true);
227
+ });
228
+ });
@@ -104,3 +104,77 @@ describe("loadConfig — openspec poll block", () => {
104
104
  expect(second.openspec).toEqual(first.openspec);
105
105
  });
106
106
  });
107
+
108
+ describe("loadConfig — openspec.enabled (auto-hide-empty-session-subcards)", () => {
109
+ let testDir: string;
110
+ let configFile: string;
111
+ let origHome: string;
112
+
113
+ beforeEach(() => {
114
+ testDir = path.join(
115
+ os.tmpdir(),
116
+ `test-config-openspec-enabled-${Date.now()}-${Math.random().toString(36).slice(2)}`,
117
+ );
118
+ fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
119
+ configFile = path.join(testDir, ".pi", "dashboard", "config.json");
120
+ origHome = process.env.HOME!;
121
+ process.env.HOME = testDir;
122
+ });
123
+
124
+ afterEach(() => {
125
+ process.env.HOME = origHome;
126
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
127
+ });
128
+
129
+ it("defaults to true when openspec block is absent", () => {
130
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
131
+ expect(loadConfig().openspec.enabled).toBe(true);
132
+ });
133
+
134
+ it("defaults to true when openspec block has other fields but no `enabled`", () => {
135
+ fs.writeFileSync(
136
+ configFile,
137
+ JSON.stringify({ openspec: { pollIntervalSeconds: 60 } }),
138
+ );
139
+ expect(loadConfig().openspec.enabled).toBe(true);
140
+ });
141
+
142
+ it("preserves explicit `false`", () => {
143
+ fs.writeFileSync(
144
+ configFile,
145
+ JSON.stringify({ openspec: { enabled: false } }),
146
+ );
147
+ const cfg = loadConfig();
148
+ expect(cfg.openspec.enabled).toBe(false);
149
+ // sibling fields keep their defaults
150
+ expect(cfg.openspec.pollIntervalSeconds).toBe(30);
151
+ });
152
+
153
+ it("preserves explicit `true`", () => {
154
+ fs.writeFileSync(
155
+ configFile,
156
+ JSON.stringify({ openspec: { enabled: true } }),
157
+ );
158
+ expect(loadConfig().openspec.enabled).toBe(true);
159
+ });
160
+
161
+ it("falls back to default true on non-boolean", () => {
162
+ fs.writeFileSync(
163
+ configFile,
164
+ JSON.stringify({ openspec: { enabled: "yes" } }),
165
+ );
166
+ expect(loadConfig().openspec.enabled).toBe(true);
167
+ });
168
+
169
+ it("round-trips through load → stringify → load", () => {
170
+ fs.writeFileSync(
171
+ configFile,
172
+ JSON.stringify({ openspec: { enabled: false, pollIntervalSeconds: 90 } }),
173
+ );
174
+ const first = loadConfig();
175
+ fs.writeFileSync(configFile, JSON.stringify(first));
176
+ const second = loadConfig();
177
+ expect(second.openspec.enabled).toBe(false);
178
+ expect(second.openspec.pollIntervalSeconds).toBe(90);
179
+ });
180
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ parseModelProxyConfig,
4
+ DEFAULT_MODEL_PROXY,
5
+ type ModelProxyConfig,
6
+ } from "../config.js";
7
+
8
+ describe("parseModelProxyConfig", () => {
9
+ it("returns defaults when input is missing", () => {
10
+ expect(parseModelProxyConfig(undefined)).toEqual(DEFAULT_MODEL_PROXY);
11
+ expect(parseModelProxyConfig(null)).toEqual(DEFAULT_MODEL_PROXY);
12
+ expect(parseModelProxyConfig("string")).toEqual(DEFAULT_MODEL_PROXY);
13
+ });
14
+
15
+ it("returns defaults when input is empty object", () => {
16
+ const result = parseModelProxyConfig({});
17
+ expect(result.enabled).toBe(true);
18
+ expect(result.maxConcurrentStreams).toBe(16);
19
+ expect(result.perKeyConcurrentStreams).toBe(4);
20
+ expect(result.logRequests).toBe(false);
21
+ expect(result.apiKeys).toEqual([]);
22
+ });
23
+
24
+ it("preserves valid enabled flag", () => {
25
+ expect(parseModelProxyConfig({ enabled: false }).enabled).toBe(false);
26
+ expect(parseModelProxyConfig({ enabled: true }).enabled).toBe(true);
27
+ });
28
+
29
+ it("clamps maxConcurrentStreams to [1, 256]", () => {
30
+ expect(parseModelProxyConfig({ maxConcurrentStreams: 0 }).maxConcurrentStreams).toBe(1);
31
+ expect(parseModelProxyConfig({ maxConcurrentStreams: -5 }).maxConcurrentStreams).toBe(1);
32
+ expect(parseModelProxyConfig({ maxConcurrentStreams: 500 }).maxConcurrentStreams).toBe(256);
33
+ expect(parseModelProxyConfig({ maxConcurrentStreams: 32 }).maxConcurrentStreams).toBe(32);
34
+ // Non-number falls back to default
35
+ expect(parseModelProxyConfig({ maxConcurrentStreams: "ten" }).maxConcurrentStreams).toBe(16);
36
+ });
37
+
38
+ it("clamps perKeyConcurrentStreams to [1, 64]", () => {
39
+ expect(parseModelProxyConfig({ perKeyConcurrentStreams: 0 }).perKeyConcurrentStreams).toBe(1);
40
+ expect(parseModelProxyConfig({ perKeyConcurrentStreams: 100 }).perKeyConcurrentStreams).toBe(64);
41
+ expect(parseModelProxyConfig({ perKeyConcurrentStreams: 8 }).perKeyConcurrentStreams).toBe(8);
42
+ });
43
+
44
+ it("accepts arbitrary string keys in perProviderCaps", () => {
45
+ const result = parseModelProxyConfig({
46
+ perProviderCaps: { anthropic: 5, google: 10, "custom-provider": 2 },
47
+ });
48
+ expect(result.perProviderCaps).toEqual({ anthropic: 5, google: 10, "custom-provider": 2 });
49
+ });
50
+
51
+ it("filters invalid perProviderCaps entries", () => {
52
+ const result = parseModelProxyConfig({
53
+ perProviderCaps: { valid: 5, bad: "nope", zero: 0, negative: -1 },
54
+ });
55
+ expect(result.perProviderCaps).toEqual({ valid: 5 });
56
+ });
57
+
58
+ it("clamps perProviderCaps values to max 256", () => {
59
+ const result = parseModelProxyConfig({
60
+ perProviderCaps: { huge: 999 },
61
+ });
62
+ expect(result.perProviderCaps).toEqual({ huge: 256 });
63
+ });
64
+
65
+ it("omits perProviderCaps when not an object", () => {
66
+ expect(parseModelProxyConfig({ perProviderCaps: "bad" }).perProviderCaps).toBeUndefined();
67
+ expect(parseModelProxyConfig({ perProviderCaps: [1] }).perProviderCaps).toBeUndefined();
68
+ });
69
+
70
+ it("validates apiKeys entries — rejects missing hash", () => {
71
+ const result = parseModelProxyConfig({
72
+ apiKeys: [
73
+ { id: "a", label: "test", createdAt: 1000 }, // missing hash
74
+ { id: "b", label: "ok", hash: "abc123", createdAt: 2000 },
75
+ ],
76
+ });
77
+ expect(result.apiKeys).toHaveLength(1);
78
+ expect(result.apiKeys[0].id).toBe("b");
79
+ });
80
+
81
+ it("validates apiKeys entries — rejects missing id/label/createdAt", () => {
82
+ const result = parseModelProxyConfig({
83
+ apiKeys: [
84
+ { label: "no-id", hash: "h", createdAt: 1 },
85
+ { id: "no-label", hash: "h", createdAt: 1 },
86
+ { id: "no-time", label: "x", hash: "h" },
87
+ ],
88
+ });
89
+ expect(result.apiKeys).toHaveLength(0);
90
+ });
91
+
92
+ it("preserves optional apiKey fields", () => {
93
+ const result = parseModelProxyConfig({
94
+ apiKeys: [
95
+ {
96
+ id: "k1",
97
+ label: "full",
98
+ hash: "sha",
99
+ createdAt: 1000,
100
+ createdBy: "alice@x",
101
+ scopes: ["all"],
102
+ lastUsedAt: 2000,
103
+ expiresAt: 9999,
104
+ revokedAt: 3000,
105
+ },
106
+ ],
107
+ });
108
+ expect(result.apiKeys[0]).toEqual({
109
+ id: "k1",
110
+ label: "full",
111
+ hash: "sha",
112
+ createdAt: 1000,
113
+ createdBy: "alice@x",
114
+ scopes: ["all"],
115
+ lastUsedAt: 2000,
116
+ expiresAt: 9999,
117
+ revokedAt: 3000,
118
+ });
119
+ });
120
+
121
+ it("filters non-string scopes in apiKeys", () => {
122
+ const result = parseModelProxyConfig({
123
+ apiKeys: [
124
+ { id: "k1", label: "t", hash: "h", createdAt: 1, scopes: ["chat", 42, null, "messages"] },
125
+ ],
126
+ });
127
+ expect(result.apiKeys[0].scopes).toEqual(["chat", "messages"]);
128
+ });
129
+
130
+ it("validates secondPort range [1024, 65535]", () => {
131
+ expect(parseModelProxyConfig({ secondPort: 9876 }).secondPort).toBe(9876);
132
+ expect(parseModelProxyConfig({ secondPort: 80 }).secondPort).toBeUndefined();
133
+ expect(parseModelProxyConfig({ secondPort: 70000 }).secondPort).toBeUndefined();
134
+ expect(parseModelProxyConfig({ secondPort: "bad" }).secondPort).toBeUndefined();
135
+ });
136
+
137
+ it("preserves defaultModel string", () => {
138
+ expect(parseModelProxyConfig({ defaultModel: "gpt-4" }).defaultModel).toBe("gpt-4");
139
+ expect(parseModelProxyConfig({ defaultModel: 42 }).defaultModel).toBeUndefined();
140
+ });
141
+
142
+ it("preserves logRequests boolean", () => {
143
+ expect(parseModelProxyConfig({ logRequests: true }).logRequests).toBe(true);
144
+ expect(parseModelProxyConfig({ logRequests: "yes" }).logRequests).toBe(false); // falls back
145
+ });
146
+ });
@@ -20,10 +20,12 @@ import url from "node:url";
20
20
  /** Files allowed to reference --import / --loader with raw identifiers. */
21
21
  const ALLOWLIST: readonly string[] = [
22
22
  "packages/shared/src/platform/node-spawn.ts",
23
- // resolve-jiti.ts returns a file:// URL to callers; it does not itself
24
- // build a `["--import", X, Y]` argv. Allowlisted as the documented
25
- // source of loader URLs referenced in server spawn call sites.
26
- "packages/shared/src/resolve-jiti.ts",
23
+ // server-launcher.ts is the single shared spawn primitive for the
24
+ // dashboard server (Bridge / Standalone CLI / Electron). It mentions
25
+ // "--import" in commentary; argv construction itself is delegated to
26
+ // node-spawn.ts via `spawnNodeScript` / `buildNodeImportArgvParts`.
27
+ // See change: unify-server-launch-ts-loader.
28
+ "packages/shared/src/server-launcher.ts",
27
29
  ];
28
30
 
29
31
  /** Per-line opt-out for intentional usages (e.g. comment examples). */
@@ -49,7 +51,7 @@ const IMPORT_ARGV_RE =
49
51
  /["']--(?:import|loader)["']\s*,\s*([^,\]]+?)\s*,\s*([^,\]]+?)(?:\s*,|\s*\])/g;
50
52
 
51
53
  const URL_LOOKING_RE =
52
- /^(?:["']file:|toFileUrl\s*\(|pathToFileURL\s*\([^)]*\)\s*\.href|resolveJitiImport\s*\(|resolveJitiFromAnchor\s*\()/;
54
+ /^(?:["']file:|toFileUrl\s*\(|pathToFileURL\s*\([^)]*\)\s*\.href)/;
53
55
 
54
56
  /** Recursively walk a directory, yielding .ts / .tsx files. */
55
57
  async function* walk(dir: string): AsyncGenerator<string> {
@@ -180,6 +180,57 @@ describe("spawnNodeScript", () => {
180
180
  });
181
181
  });
182
182
 
183
+ describe("buildNodeImportArgvParts", () => {
184
+ // Pure helper shared by spawnNodeScript and restart-helper.ts so the
185
+ // `--import` argv shape lives in exactly one place.
186
+ it("POSIX + jiti: entry passed RAW (jiti rejects file:// URL entries)", async () => {
187
+ const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
188
+ const parts = buildNodeImportArgvParts({
189
+ loader: "/usr/lib/jiti/lib/jiti-register.mjs",
190
+ entry: "/srv/cli.ts",
191
+ args: ["start", "--port", "8000"],
192
+ platform: "linux",
193
+ });
194
+ expect(parts[0]).toBe("--import");
195
+ expect(parts[1]).toMatch(/^file:\/\//);
196
+ expect(parts[2]).toBe("/srv/cli.ts"); // RAW
197
+ expect(parts.slice(3)).toEqual(["start", "--port", "8000"]);
198
+ });
199
+
200
+ it("Windows + jiti: entry URL-wrapped", async () => {
201
+ const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
202
+ const parts = buildNodeImportArgvParts({
203
+ loader: "B:\\Dev\\jiti\\lib\\jiti-register.mjs",
204
+ entry: "B:\\srv\\cli.ts",
205
+ args: ["start"],
206
+ platform: "win32",
207
+ });
208
+ expect(parts[1]).toBe("file:///B:/Dev/jiti/lib/jiti-register.mjs");
209
+ expect(parts[2]).toBe("file:///B:/srv/cli.ts");
210
+ });
211
+
212
+ it("tsx loader: entry RAW on any platform", async () => {
213
+ const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
214
+ const parts = buildNodeImportArgvParts({
215
+ loader: "/x/tsx/dist/esm/index.mjs",
216
+ entry: "C:\\srv\\cli.ts",
217
+ args: [],
218
+ platform: "win32",
219
+ });
220
+ expect(parts[2]).toBe("C:\\srv\\cli.ts"); // RAW (tsx rejects file:// entries)
221
+ });
222
+
223
+ it("omits args when none supplied", async () => {
224
+ const { buildNodeImportArgvParts } = await import("../platform/node-spawn.js");
225
+ const parts = buildNodeImportArgvParts({
226
+ loader: "/x/jiti/lib/jiti-register.mjs",
227
+ entry: "/srv/cli.ts",
228
+ platform: "linux",
229
+ });
230
+ expect(parts).toEqual(["--import", `file://${"/x/jiti/lib/jiti-register.mjs"}`, "/srv/cli.ts"]);
231
+ });
232
+ });
233
+
183
234
  describe("shouldUrlWrapEntry", () => {
184
235
  it("returns false for tsx loader on any platform", () => {
185
236
  const tsxLoader = "file:///home/u/node_modules/tsx/dist/esm/index.mjs";
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Type-level tests for OpenSpec change-grouping shared types.
3
+ *
4
+ * Asserts:
5
+ * - `OpenSpecGroup` shape compiles.
6
+ * - `OpenSpecChange.groupId?: string | null` is optional.
7
+ * - `OpenSpecGroupsFile` shape compiles.
8
+ * - `OPENSPEC_GROUPS_SCHEMA_VERSION` is the literal `1`.
9
+ * - `BrowserOpenSpecGroupsUpdateMessage` is a member of `ServerToBrowserMessage`
10
+ * (otherwise esbuild would dead-code-eliminate the consumer switch arm).
11
+ * - REST request/response shapes for the five `/api/openspec/groups*` routes compile.
12
+ *
13
+ * See change: add-openspec-change-grouping (tasks 1.1–1.7).
14
+ */
15
+ import { describe, it, expect } from "vitest";
16
+ import type {
17
+ OpenSpecGroup,
18
+ OpenSpecChange,
19
+ OpenSpecGroupsFile,
20
+ } from "../types.js";
21
+ import { OPENSPEC_GROUPS_SCHEMA_VERSION } from "../types.js";
22
+ import type {
23
+ ServerToBrowserMessage,
24
+ BrowserOpenSpecGroupsUpdateMessage,
25
+ } from "../browser-protocol.js";
26
+ import type {
27
+ GetOpenSpecGroupsResponse,
28
+ CreateOpenSpecGroupRequest,
29
+ CreateOpenSpecGroupResponse,
30
+ UpdateOpenSpecGroupRequest,
31
+ UpdateOpenSpecGroupResponse,
32
+ DeleteOpenSpecGroupResponse,
33
+ SetOpenSpecGroupAssignmentRequest,
34
+ SetOpenSpecGroupAssignmentResponse,
35
+ } from "../rest-api.js";
36
+
37
+ // Type-level assertion: if the type does NOT extend the union, this will fail to compile.
38
+ type AssertExtends<T, U> = T extends U ? true : never;
39
+
40
+ // 1.5 — broadcast variant lives in the union.
41
+ type _GroupsUpdateInBrowserUnion = AssertExtends<
42
+ BrowserOpenSpecGroupsUpdateMessage,
43
+ ServerToBrowserMessage
44
+ >;
45
+
46
+ // 1.1 — group shape.
47
+ type _GroupShape = AssertExtends<
48
+ { id: string; name: string; color?: string; order: number },
49
+ OpenSpecGroup
50
+ >;
51
+
52
+ // 1.2 — `groupId?` is optional on `OpenSpecChange`.
53
+ type _ChangeGroupIdOptional = AssertExtends<
54
+ { name: string; status: "in-progress"; completedTasks: 0; totalTasks: 0; artifacts: [] },
55
+ OpenSpecChange
56
+ >;
57
+ // And it accepts a string when present.
58
+ type _ChangeWithGroupId = AssertExtends<
59
+ {
60
+ name: string;
61
+ status: "in-progress";
62
+ completedTasks: 0;
63
+ totalTasks: 0;
64
+ artifacts: [];
65
+ groupId: "ui";
66
+ },
67
+ OpenSpecChange
68
+ >;
69
+
70
+ // 1.4 — file shape.
71
+ type _FileShape = AssertExtends<
72
+ {
73
+ schemaVersion: 1;
74
+ groups: OpenSpecGroup[];
75
+ assignments: Record<string, string>;
76
+ },
77
+ OpenSpecGroupsFile
78
+ >;
79
+
80
+ // 1.6 — REST shapes (compile-time only).
81
+ const _getResp: GetOpenSpecGroupsResponse = {
82
+ success: true,
83
+ data: { schemaVersion: 1, groups: [], assignments: {} },
84
+ };
85
+ const _createReq: CreateOpenSpecGroupRequest = { name: "UI", color: "#3b82f6" };
86
+ const _createResp: CreateOpenSpecGroupResponse = {
87
+ success: true,
88
+ data: { id: "ui", name: "UI", color: "#3b82f6", order: 0 },
89
+ };
90
+ const _updateReq: UpdateOpenSpecGroupRequest = { name: "Frontend" };
91
+ const _updateResp: UpdateOpenSpecGroupResponse = {
92
+ success: true,
93
+ data: { id: "ui", name: "Frontend", order: 0 },
94
+ };
95
+ const _deleteResp: DeleteOpenSpecGroupResponse = { success: true };
96
+ const _putReq: SetOpenSpecGroupAssignmentRequest = { changeName: "add-foo", groupId: "ui" };
97
+ const _putReqNull: SetOpenSpecGroupAssignmentRequest = { changeName: "add-foo", groupId: null };
98
+ const _putResp: SetOpenSpecGroupAssignmentResponse = { success: true };
99
+
100
+ // Suppress unused-locals for compile-time-only declarations.
101
+ void _getResp;
102
+ void _createReq;
103
+ void _createResp;
104
+ void _updateReq;
105
+ void _updateResp;
106
+ void _deleteResp;
107
+ void _putReq;
108
+ void _putReqNull;
109
+ void _putResp;
110
+
111
+ describe("OpenSpec change-grouping shared types", () => {
112
+ it("OPENSPEC_GROUPS_SCHEMA_VERSION is the literal 1", () => {
113
+ expect(OPENSPEC_GROUPS_SCHEMA_VERSION).toBe(1);
114
+ });
115
+
116
+ it("openspec_groups_update is reachable in a runtime switch over ServerToBrowserMessage", () => {
117
+ // Runtime check that the discriminant survives type-narrowing — mirrors
118
+ // the prompt-message regression guard in browser-protocol-types.test.ts.
119
+ const sample: ServerToBrowserMessage = {
120
+ type: "openspec_groups_update",
121
+ cwd: "/tmp/foo",
122
+ groups: [],
123
+ assignments: {},
124
+ };
125
+ let hit = false;
126
+ switch (sample.type) {
127
+ case "openspec_groups_update":
128
+ hit = true;
129
+ break;
130
+ default:
131
+ break;
132
+ }
133
+ expect(hit).toBe(true);
134
+ });
135
+ });