@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
@@ -128,6 +128,102 @@ describe("publish.yml — electron job dependency-graph contract", () => {
128
128
  // single source of truth is the `prepare` job's computed `is_prerelease`
129
129
  // output. See change: eliminate-bash-on-windows-runners (D6).
130
130
 
131
+ // ── Lockfile-regen contract ──────────────────────────────────────────────
132
+ // The `prepare` job MUST regenerate package-lock.json with the bumped
133
+ // versions (between sync-versions.js and the git commit) so consumers'
134
+ // `npm ci` doesn't fall back to stale registry tarballs via strict
135
+ // prerelease semver. See change: fix-release-lockfile-drift.
136
+
137
+ /**
138
+ * Parse the `steps:` block of a single job into an array of `{ run }`
139
+ * entries. We only care about the `run:` field for this contract; the
140
+ * step delimiter is any ` - ` line (6-space indent + dash + space).
141
+ * Multi-line `run: |` blocks fold into a single `run` string.
142
+ */
143
+ function parseJobSteps(jobBlock: string): Array<{ run: string }> {
144
+ const lines = jobBlock.split("\n");
145
+ const steps: Array<{ run: string }> = [];
146
+ let i = 0;
147
+ // Find the ` steps:` line.
148
+ while (i < lines.length && !/^ steps:\s*$/.test(lines[i])) i++;
149
+ i++;
150
+ let current: { run: string } | null = null;
151
+ let inRunBlock = false;
152
+ let runBlockIndent = 0;
153
+ while (i < lines.length) {
154
+ const line = lines[i];
155
+ // New step delimiter: ` - ` at 6-space indent.
156
+ if (/^ - /.test(line)) {
157
+ if (current) steps.push(current);
158
+ current = { run: "" };
159
+ inRunBlock = false;
160
+ // Inline `- run: foo` form.
161
+ const inlineRun = line.match(/^ -\s+run:\s+(.*)$/);
162
+ if (inlineRun) current.run = inlineRun[1];
163
+ i++;
164
+ continue;
165
+ }
166
+ if (current) {
167
+ // Block scalar ` run: |`.
168
+ const blockStart = line.match(/^ run:\s*\|?\s*$/);
169
+ const inlineKey = line.match(/^ run:\s+(.+)$/);
170
+ if (blockStart) {
171
+ inRunBlock = true;
172
+ runBlockIndent = 10; // body lines start at ≥ 10-space indent
173
+ i++;
174
+ continue;
175
+ }
176
+ if (inlineKey) {
177
+ current.run += (current.run ? "\n" : "") + inlineKey[1];
178
+ i++;
179
+ continue;
180
+ }
181
+ if (inRunBlock) {
182
+ // Body line of a `run: |` block. Stop when we hit a less-indented
183
+ // line (next key at 8-space indent, or the next step at 6-space).
184
+ if (line.length === 0) {
185
+ current.run += "\n";
186
+ i++;
187
+ continue;
188
+ }
189
+ const indent = line.length - line.trimStart().length;
190
+ if (indent < runBlockIndent) {
191
+ inRunBlock = false;
192
+ continue; // re-process this line as a key
193
+ }
194
+ current.run += (current.run ? "\n" : "") + line.slice(runBlockIndent);
195
+ i++;
196
+ continue;
197
+ }
198
+ }
199
+ i++;
200
+ }
201
+ if (current) steps.push(current);
202
+ return steps;
203
+ }
204
+
205
+ describe("publish.yml — prepare job lockfile-regen contract", () => {
206
+ const yaml = fs.readFileSync(WORKFLOW_PATH, "utf8");
207
+ const prepareBlock = extractJobBlock(yaml, "prepare");
208
+ const prepareSteps = parseJobSteps(prepareBlock);
209
+
210
+ it("prepare job regenerates lockfile after version bump (fix-release-lockfile-drift)", () => {
211
+ const syncIdx = prepareSteps.findIndex((s) => /sync-versions\.js/.test(s.run || ""));
212
+ const regenIdx = prepareSteps.findIndex((s) =>
213
+ /npm install --package-lock-only/.test(s.run || ""),
214
+ );
215
+ const commitIdx = prepareSteps.findIndex((s) =>
216
+ /git commit -m "chore\(release\)/.test(s.run || ""),
217
+ );
218
+ expect(syncIdx, "sync-versions.js step missing").toBeGreaterThanOrEqual(0);
219
+ expect(
220
+ regenIdx,
221
+ "lockfile regen step missing — see change fix-release-lockfile-drift",
222
+ ).toBeGreaterThan(syncIdx);
223
+ expect(commitIdx, "git commit step missing").toBeGreaterThan(regenIdx);
224
+ });
225
+ });
226
+
131
227
  describe("publish.yml — prerelease safety contract", () => {
132
228
  const yaml = fs.readFileSync(WORKFLOW_PATH, "utf8");
133
229
  const prepareBlock = extractJobBlock(yaml, "prepare");
@@ -8,13 +8,14 @@ import {
8
8
  } from "../recommended-extensions.js";
9
9
 
10
10
  describe("RECOMMENDED_EXTENSIONS manifest", () => {
11
- it("contains exactly the five expected entries", () => {
11
+ it("contains exactly the six expected entries", () => {
12
12
  const ids = RECOMMENDED_EXTENSIONS.map((e) => e.id).sort();
13
13
  expect(ids).toEqual(
14
14
  [
15
15
  "pi-anthropic-messages",
16
16
  "pi-agent-browser",
17
17
  "pi-flows",
18
+ "pi-memory-honcho",
18
19
  "pi-web-access",
19
20
  "tintinweb-pi-subagents",
20
21
  ].sort(),
@@ -62,7 +63,12 @@ describe("RECOMMENDED_EXTENSIONS manifest", () => {
62
63
  it("npm-sourced entries use the npm: prefix", () => {
63
64
  const npmEntries = RECOMMENDED_EXTENSIONS.filter((e) => e.source.startsWith("npm:"));
64
65
  expect(npmEntries.map((e) => e.id).sort()).toEqual(
65
- ["pi-agent-browser", "pi-web-access", "tintinweb-pi-subagents"].sort(),
66
+ [
67
+ "pi-agent-browser",
68
+ "pi-memory-honcho",
69
+ "pi-web-access",
70
+ "tintinweb-pi-subagents",
71
+ ].sort(),
66
72
  );
67
73
  });
68
74
 
@@ -105,7 +111,9 @@ describe("getRecommendedByStatus", () => {
105
111
 
106
112
  it("filters by optional", () => {
107
113
  const optional = getRecommendedByStatus("optional");
108
- expect(optional.map((e) => e.id)).toEqual(["pi-agent-browser"]);
114
+ expect(optional.map((e) => e.id).sort()).toEqual(
115
+ ["pi-agent-browser", "pi-memory-honcho"].sort(),
116
+ );
109
117
  });
110
118
  });
111
119
 
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Unit tests for `launchDashboardServer`.
3
+ *
4
+ * Mocks the test seams (`_resolveJiti`, `_spawnNodeScript`,
5
+ * `_isDashboardRunning`, `_fs`, `_sleep`, `_now`) so the launcher's
6
+ * orchestration logic is exercised without spawning a real child or
7
+ * touching the filesystem.
8
+ */
9
+ import { describe, it, expect, vi } from "vitest";
10
+ import { EventEmitter } from "node:events";
11
+ import {
12
+ launchDashboardServer,
13
+ JitiNotFoundError,
14
+ PortConflictError,
15
+ EarlyExitError,
16
+ } from "../server-launcher.js";
17
+ import type { ChildProcess } from "node:child_process";
18
+ import type { spawnNodeScript } from "../platform/node-spawn.js";
19
+ import type { isDashboardRunning } from "../server-identity.js";
20
+
21
+ const spawnSpy = (impl: () => ChildProcess) =>
22
+ vi.fn<typeof spawnNodeScript>(impl as unknown as typeof spawnNodeScript);
23
+ const probeSpy = <T>(impl: () => Promise<T>) =>
24
+ vi.fn<typeof isDashboardRunning>(impl as unknown as typeof isDashboardRunning);
25
+
26
+ interface FakeChildOpts {
27
+ pid?: number | null;
28
+ exitCode?: number | null;
29
+ }
30
+
31
+ function makeFakeChild(opts: FakeChildOpts = {}): ChildProcess {
32
+ const ee = new EventEmitter() as unknown as ChildProcess & {
33
+ pid: number | undefined;
34
+ exitCode: number | null;
35
+ signalCode: NodeJS.Signals | null;
36
+ unref: () => void;
37
+ };
38
+ ee.pid = (opts.pid ?? 12345) as number | undefined;
39
+ ee.exitCode = opts.exitCode ?? null;
40
+ ee.signalCode = null;
41
+ ee.unref = vi.fn();
42
+ return ee;
43
+ }
44
+
45
+ function baseOpts(overrides: Partial<Parameters<typeof launchDashboardServer>[0]> = {}) {
46
+ return {
47
+ cliPath: "/srv/cli.ts",
48
+ stdio: "ignore" as const,
49
+ healthTimeoutMs: 5000,
50
+ port: 8000,
51
+ _resolveJiti: () => "file:///loader/jiti-register.mjs",
52
+ _spawnNodeScript: spawnSpy(() => makeFakeChild()),
53
+ _isDashboardRunning: probeSpy(async () => ({ running: true, pid: 99 })),
54
+ _sleep: () => Promise.resolve(),
55
+ _pollIntervalMs: 1,
56
+ ...overrides,
57
+ };
58
+ }
59
+
60
+ describe("launchDashboardServer — happy path", () => {
61
+ it("returns childPid + reportedPid + healthOk on first health-ok poll", async () => {
62
+ const result = await launchDashboardServer(baseOpts());
63
+ expect(result.childPid).toBe(12345);
64
+ expect(result.reportedPid).toBe(99);
65
+ expect(result.healthOk).toBe(true);
66
+ });
67
+
68
+ it("delegates argv to spawnNodeScript with loader + entry + args", async () => {
69
+ const spy = spawnSpy(() => makeFakeChild());
70
+ await launchDashboardServer(baseOpts({
71
+ _spawnNodeScript: spy,
72
+ extraArgs: ["--port", "8000", "--pi-port", "9999"],
73
+ }));
74
+ expect(spy).toHaveBeenCalledOnce();
75
+ const call = spy.mock.calls[0]![0]!;
76
+ expect(call.loader).toBe("file:///loader/jiti-register.mjs");
77
+ expect(call.entry).toBe("/srv/cli.ts");
78
+ expect(call.args).toEqual(["--port", "8000", "--pi-port", "9999"]);
79
+ expect(call.spawnOptions?.detached).toBe(true);
80
+ expect(call.spawnOptions?.windowsHide).toBe(true);
81
+ expect(call.spawnOptions?.stdio).toBe("ignore");
82
+ });
83
+ });
84
+
85
+ describe("launchDashboardServer — jiti resolution", () => {
86
+ it("throws JitiNotFoundError when resolveJiti returns null (no spawn)", async () => {
87
+ const spawn = spawnSpy(() => makeFakeChild());
88
+ await expect(launchDashboardServer(baseOpts({
89
+ _resolveJiti: () => null,
90
+ _spawnNodeScript: spawn,
91
+ }))).rejects.toBeInstanceOf(JitiNotFoundError);
92
+ expect(spawn).not.toHaveBeenCalled();
93
+ });
94
+ });
95
+
96
+ describe("launchDashboardServer — readiness termination", () => {
97
+ it("throws PortConflictError when probe reports portConflict", async () => {
98
+ await expect(launchDashboardServer(baseOpts({
99
+ _isDashboardRunning: async () => ({ running: false, portConflict: true }),
100
+ }))).rejects.toBeInstanceOf(PortConflictError);
101
+ });
102
+
103
+ it("throws EarlyExitError when child exits during poll", async () => {
104
+ const child = makeFakeChild();
105
+ let calls = 0;
106
+ const spawnFn = spawnSpy(() => child);
107
+ const probe = probeSpy(async () => {
108
+ calls++;
109
+ if (calls === 1) {
110
+ // Mid-poll, child crashes.
111
+ (child as unknown as { exitCode: number }).exitCode = 7;
112
+ }
113
+ return { running: false };
114
+ });
115
+ await expect(launchDashboardServer(baseOpts({
116
+ _spawnNodeScript: spawnFn,
117
+ _isDashboardRunning: probe,
118
+ }))).rejects.toBeInstanceOf(EarlyExitError);
119
+ });
120
+
121
+ it("throws readiness-timeout Error after healthTimeoutMs elapses", async () => {
122
+ let now = 1000;
123
+ await expect(launchDashboardServer(baseOpts({
124
+ healthTimeoutMs: 100,
125
+ _now: () => { now += 60; return now; }, // each poll advances 60ms — 2 polls past deadline
126
+ _isDashboardRunning: async () => ({ running: false }),
127
+ }))).rejects.toThrow(/readiness timeout/);
128
+ });
129
+
130
+ it("port-conflict beats timeout (probe order respected)", async () => {
131
+ let now = 1000;
132
+ await expect(launchDashboardServer(baseOpts({
133
+ healthTimeoutMs: 100,
134
+ _now: () => { now += 200; return now; },
135
+ _isDashboardRunning: async () => ({ running: false, portConflict: true }),
136
+ }))).rejects.toBeInstanceOf(PortConflictError);
137
+ });
138
+ });
139
+
140
+ describe("launchDashboardServer — log-file stdio", () => {
141
+ it("mkdirs parent, opens append fd, writes header, passes fd, closes parent's copy", async () => {
142
+ const calls: string[] = [];
143
+ const fsStub = {
144
+ mkdirSync: vi.fn((p: any) => { calls.push(`mkdir:${p}`); }),
145
+ openSync: vi.fn((p: any, mode: any) => { calls.push(`open:${p}:${mode}`); return 42; }),
146
+ writeSync: vi.fn((fd: number, s: any) => { calls.push(`write:${fd}:${String(s).slice(0, 20)}…`); return s.length; }),
147
+ closeSync: vi.fn((fd: number) => { calls.push(`close:${fd}`); }),
148
+ };
149
+ const spawn = spawnSpy(() => makeFakeChild());
150
+ await launchDashboardServer(baseOpts({
151
+ stdio: { logFile: "/var/log/dashboard/server.log" },
152
+ starter: "Standalone",
153
+ _fs: fsStub as any,
154
+ _spawnNodeScript: spawn,
155
+ }));
156
+ expect(fsStub.mkdirSync).toHaveBeenCalledWith("/var/log/dashboard", { recursive: true });
157
+ expect(fsStub.openSync).toHaveBeenCalledWith("/var/log/dashboard/server.log", "a");
158
+ expect(fsStub.writeSync).toHaveBeenCalledOnce();
159
+ expect(String(fsStub.writeSync.mock.calls[0]![1])).toContain("Standalone launch");
160
+ // Spawn received [ignore, fd, fd]:
161
+ const stdio = spawn.mock.calls[0]![0]!.spawnOptions!.stdio as Array<unknown>;
162
+ expect(stdio).toEqual(["ignore", 42, 42]);
163
+ // Parent fd closed AFTER spawn:
164
+ expect(fsStub.closeSync).toHaveBeenCalledWith(42);
165
+ const closeIdx = calls.findIndex((c) => c.startsWith("close:42"));
166
+ const writeIdx = calls.findIndex((c) => c.startsWith("write:42"));
167
+ expect(closeIdx).toBeGreaterThan(writeIdx);
168
+ });
169
+ });
170
+
171
+ describe("launchDashboardServer — env merge", () => {
172
+ it("caller env keys override buildSpawnEnv defaults", async () => {
173
+ const spawn = spawnSpy(() => makeFakeChild());
174
+ await launchDashboardServer(baseOpts({
175
+ _spawnNodeScript: spawn,
176
+ env: { DASHBOARD_STARTER: "Bridge", CUSTOM_KEY: "x" },
177
+ }));
178
+ const env = spawn.mock.calls[0]![0]!.spawnOptions!.env as Record<string, string>;
179
+ expect(env.DASHBOARD_STARTER).toBe("Bridge");
180
+ expect(env.CUSTOM_KEY).toBe("x");
181
+ });
182
+
183
+ it("starter option becomes DASHBOARD_STARTER when env does not supply it", async () => {
184
+ const spawn = spawnSpy(() => makeFakeChild());
185
+ await launchDashboardServer(baseOpts({
186
+ _spawnNodeScript: spawn,
187
+ starter: "Electron",
188
+ }));
189
+ const env = spawn.mock.calls[0]![0]!.spawnOptions!.env as Record<string, string>;
190
+ expect(env.DASHBOARD_STARTER).toBe("Electron");
191
+ });
192
+
193
+ it("explicit env.DASHBOARD_STARTER wins over starter option", async () => {
194
+ const spawn = spawnSpy(() => makeFakeChild());
195
+ await launchDashboardServer(baseOpts({
196
+ _spawnNodeScript: spawn,
197
+ starter: "Electron",
198
+ env: { DASHBOARD_STARTER: "Bridge" },
199
+ }));
200
+ const env = spawn.mock.calls[0]![0]!.spawnOptions!.env as Record<string, string>;
201
+ expect(env.DASHBOARD_STARTER).toBe("Bridge");
202
+ });
203
+ });
204
+
205
+ describe("launchDashboardServer — entry URL-wrapping", () => {
206
+ // The launcher delegates to spawnNodeScript, which uses
207
+ // `shouldUrlWrapEntry(loader, platform)`. We verify the launcher
208
+ // simply forwards the raw entry; the URL-wrap behaviour itself is
209
+ // pinned by node-spawn-jiti-contract.test.ts.
210
+ it("forwards `cliPath` verbatim to spawnNodeScript (URL-wrapping owned downstream)", async () => {
211
+ const spawn = spawnSpy(() => makeFakeChild());
212
+ await launchDashboardServer(baseOpts({
213
+ _spawnNodeScript: spawn,
214
+ cliPath: "/posix/cli.ts",
215
+ }));
216
+ expect(spawn.mock.calls[0]![0]!.entry).toBe("/posix/cli.ts");
217
+ });
218
+
219
+ it("forwards Windows-style `cliPath` verbatim too", async () => {
220
+ const spawn = spawnSpy(() => makeFakeChild());
221
+ await launchDashboardServer(baseOpts({
222
+ _spawnNodeScript: spawn,
223
+ cliPath: "C:\\srv\\cli.ts",
224
+ }));
225
+ expect(spawn.mock.calls[0]![0]!.entry).toBe("C:\\srv\\cli.ts");
226
+ });
227
+ });
@@ -213,7 +213,7 @@ export async function bootstrapInstallDefaults(
213
213
  progress?: ProgressCallback,
214
214
  ): Promise<BootstrapInstallResult> {
215
215
  return bootstrapInstall({
216
- packages: ["@earendil-works/pi-coding-agent", "@fission-ai/openspec", "tsx"],
216
+ packages: ["@earendil-works/pi-coding-agent", "@fission-ai/openspec"],
217
217
  progress,
218
218
  });
219
219
  }
@@ -9,6 +9,7 @@ import type {
9
9
  ImageContent,
10
10
  FileEntry,
11
11
  OpenSpecData,
12
+ OpenSpecGroup,
12
13
  ModelInfo,
13
14
  PiSessionInfo,
14
15
  ExtensionUiModule,
@@ -96,6 +97,19 @@ export interface BrowserOpenSpecUpdateMessage {
96
97
  data: OpenSpecData;
97
98
  }
98
99
 
100
+ /**
101
+ * Per-repo OpenSpec change-grouping update. Broadcast after every successful
102
+ * write to `<cwd>/openspec/groups/groups.json`, debounced 100 ms per cwd.
103
+ * Full payload (no incremental delta) so client logic stays simple.
104
+ * See change: add-openspec-change-grouping.
105
+ */
106
+ export interface BrowserOpenSpecGroupsUpdateMessage {
107
+ type: "openspec_groups_update";
108
+ cwd: string;
109
+ groups: OpenSpecGroup[];
110
+ assignments: Record<string, string>;
111
+ }
112
+
99
113
  export interface BrowserModelsListMessage {
100
114
  type: "models_list";
101
115
  sessionId: string;
@@ -386,6 +400,18 @@ export interface BootstrapStateSnapshot {
386
400
  upgradeDashboard?: boolean;
387
401
  };
388
402
  bridgeRegistrationError?: string;
403
+ /**
404
+ * Legacy `@mariozechner/pi-coding-agent` installs detected on disk.
405
+ * Surfaced by the client as a one-click cleanup banner. Empty array
406
+ * means no legacy installs found. Pi was renamed to
407
+ * `@earendil-works/pi-coding-agent` at v0.74 — the legacy scope can
408
+ * collide with the new scope's `bin/pi` symlink.
409
+ */
410
+ legacyPiInstalls?: Array<{
411
+ scope: "npm-global" | "npx-cache" | "managed";
412
+ path: string;
413
+ version: string | null;
414
+ }>;
389
415
  }
390
416
 
391
417
  /**
@@ -514,6 +540,7 @@ export type ServerToBrowserMessage =
514
540
  | BrowserUiDismissMessage
515
541
  | BrowserFilesListMessage
516
542
  | BrowserOpenSpecUpdateMessage
543
+ | BrowserOpenSpecGroupsUpdateMessage
517
544
  | BrowserModelsListMessage
518
545
  | SessionsListBrowserMessage
519
546
  | ResumeResultBrowserMessage