@blackbelt-technology/pi-agent-dashboard 0.2.0 → 0.2.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 (49) hide show
  1. package/AGENTS.md +3 -1
  2. package/docs/architecture.md +30 -23
  3. package/package.json +8 -1
  4. package/packages/extension/package.json +1 -1
  5. package/packages/extension/src/__tests__/dashboard-default-adapter.test.ts +77 -0
  6. package/packages/extension/src/__tests__/dev-build.test.ts +2 -2
  7. package/packages/extension/src/__tests__/prompt-bus-wiring.test.ts +791 -0
  8. package/packages/extension/src/__tests__/prompt-bus.test.ts +469 -0
  9. package/packages/extension/src/__tests__/server-launcher.test.ts +35 -34
  10. package/packages/extension/src/__tests__/tui-prompt-adapter.test.ts +207 -0
  11. package/packages/extension/src/ask-user-tool.ts +1 -1
  12. package/packages/extension/src/bridge-context.ts +1 -1
  13. package/packages/extension/src/bridge.ts +214 -59
  14. package/packages/extension/src/command-handler.ts +2 -2
  15. package/packages/extension/src/dashboard-default-adapter.ts +37 -0
  16. package/packages/extension/src/flow-event-wiring.ts +6 -23
  17. package/packages/extension/src/pi-env.d.ts +13 -0
  18. package/packages/extension/src/prompt-bus.ts +240 -0
  19. package/packages/extension/src/server-launcher.ts +2 -2
  20. package/packages/extension/src/session-sync.ts +2 -1
  21. package/packages/server/package.json +1 -1
  22. package/packages/server/src/__tests__/bridge-register-nondestructive.test.ts +108 -0
  23. package/packages/server/src/__tests__/extension-register-appimage.test.ts +39 -0
  24. package/packages/server/src/__tests__/extension-register.test.ts +26 -22
  25. package/packages/server/src/__tests__/process-manager.test.ts +4 -1
  26. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +5 -5
  27. package/packages/server/src/__tests__/tunnel.test.ts +2 -2
  28. package/packages/server/src/browser-gateway.ts +55 -16
  29. package/packages/server/src/cli.ts +1 -1
  30. package/packages/server/src/editor-manager.ts +1 -1
  31. package/packages/server/src/event-status-extraction.ts +7 -0
  32. package/packages/server/src/event-wiring.ts +16 -19
  33. package/packages/server/src/package-manager-wrapper.ts +1 -1
  34. package/packages/server/src/process-manager.ts +8 -69
  35. package/packages/server/src/routes/system-routes.ts +3 -1
  36. package/packages/server/src/server.ts +6 -4
  37. package/packages/shared/package.json +1 -1
  38. package/packages/shared/src/__tests__/bridge-register.test.ts +136 -0
  39. package/packages/shared/src/__tests__/tool-resolver.test.ts +164 -0
  40. package/packages/shared/src/bridge-register.ts +95 -0
  41. package/packages/shared/src/browser-protocol.ts +10 -0
  42. package/packages/shared/src/managed-paths.ts +15 -0
  43. package/packages/shared/src/mdns-discovery.ts +1 -1
  44. package/packages/shared/src/protocol.ts +46 -0
  45. package/packages/shared/src/tool-resolver.ts +201 -0
  46. package/packages/shared/src/types.ts +24 -0
  47. package/packages/extension/src/__tests__/ui-proxy.test.ts +0 -583
  48. package/packages/extension/src/ui-proxy.ts +0 -269
  49. package/packages/server/src/extension-register.ts +0 -92
@@ -5,12 +5,22 @@
5
5
  declare module "@mariozechner/pi-coding-agent" {
6
6
  export type ExtensionAPI = import("@oh-my-pi/pi-coding-agent").ExtensionAPI;
7
7
  }
8
+ declare module "@mariozechner/pi-ai" {
9
+ export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
10
+ }
11
+
8
12
  declare module "@oh-my-pi/pi-coding-agent" {
9
13
  export interface ModelRegistry {
10
14
  getAvailable(): Array<{ provider: string; id: string }>;
11
15
  refresh(): void;
12
16
  }
13
17
 
18
+ export interface EventBus {
19
+ on(event: string, handler: (...args: any[]) => any): void;
20
+ off(event: string, handler: (...args: any[]) => any): void;
21
+ emit(event: string, ...args: any[]): void;
22
+ }
23
+
14
24
  export interface ExtensionAPI {
15
25
  on(event: string, handler: (...args: any[]) => any): void;
16
26
  getCommands(): any[];
@@ -18,6 +28,9 @@ declare module "@oh-my-pi/pi-coding-agent" {
18
28
  setSessionName(name: string): void;
19
29
  getSessionName(): string | undefined;
20
30
  registerCommand(name: string, options: { description?: string; handler: (args: string, ctx: any) => Promise<void> }): void;
31
+ registerTool(tool: any): void;
32
+ registerProvider(name: string, config: any): void;
21
33
  exec(command: string, args: string[], options?: { timeout?: number }): Promise<{ stdout: string; stderr: string; exitCode: number }>;
34
+ events: EventBus;
22
35
  }
23
36
  }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * PromptBus — Unified prompt routing infrastructure.
3
+ *
4
+ * Routes prompt requests to registered adapters (TUI, dashboard, custom).
5
+ * Enforces first-response-wins semantics and cross-adapter dismissal.
6
+ * Replaces the ui-proxy race pattern and emitPromptAndAwait event system.
7
+ */
8
+
9
+ // ── Interfaces ──────────────────────────────────────────────────────
10
+
11
+ export interface PromptComponent {
12
+ /** Component type identifier — maps to a React component on the client */
13
+ type: string;
14
+ /** Serializable props for the component */
15
+ props: Record<string, unknown>;
16
+ }
17
+
18
+ export interface PromptClaim {
19
+ /** Optional custom dashboard UI component. If omitted, adapter handles externally (e.g. TUI). */
20
+ component?: PromptComponent;
21
+ /** Where to render the component on the dashboard client */
22
+ placement?: "widget-bar" | "inline" | "overlay";
23
+ }
24
+
25
+ export interface PromptRequest {
26
+ id: string;
27
+ pipeline: string;
28
+ type: "select" | "input" | "confirm" | "editor" | "multiselect";
29
+ question: string;
30
+ options?: string[];
31
+ defaultValue?: string;
32
+ metadata?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface PromptResponse {
36
+ id: string;
37
+ answer?: string;
38
+ cancelled?: boolean;
39
+ source: string;
40
+ }
41
+
42
+ export interface PromptAdapter {
43
+ name: string;
44
+ /**
45
+ * Called when a new prompt arrives.
46
+ * Return a PromptClaim to participate, or null/undefined to skip.
47
+ */
48
+ onRequest(prompt: PromptRequest): PromptClaim | null | undefined | void;
49
+ /** Called when any adapter answered — dismiss your UI if active. */
50
+ onResponse(response: PromptResponse): void;
51
+ /** Called on cancel/timeout — clean up your UI. */
52
+ onCancel(id: string): void;
53
+ }
54
+
55
+ // ── Internal types ──────────────────────────────────────────────────
56
+
57
+ interface PendingPrompt {
58
+ request: PromptRequest;
59
+ resolve: (response: PromptResponse) => void;
60
+ timer: ReturnType<typeof setTimeout>;
61
+ claims: Array<{ adapter: PromptAdapter; claim: PromptClaim }>;
62
+ /** Resolved component sent to dashboard at request time (for reconnect replay) */
63
+ resolvedComponent: PromptComponent | undefined;
64
+ /** Resolved placement sent to dashboard at request time (for reconnect replay) */
65
+ resolvedPlacement: string | undefined;
66
+ }
67
+
68
+ export interface PromptBusOptions {
69
+ /** Default timeout in milliseconds (default: 5 minutes) */
70
+ timeoutMs?: number;
71
+ /** Called when a prompt_request should be sent to the dashboard */
72
+ onDashboardRequest?: (prompt: PromptRequest, component: PromptComponent, placement: string) => void;
73
+ /** Called when a prompt_dismiss should be sent to the dashboard */
74
+ onDashboardDismiss?: (id: string) => void;
75
+ /** Called when a prompt_cancel should be sent to the dashboard */
76
+ onDashboardCancel?: (id: string) => void;
77
+ }
78
+
79
+ // ── PromptBus ───────────────────────────────────────────────────────
80
+
81
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
82
+
83
+ export class PromptBus {
84
+ private adapters: PromptAdapter[] = [];
85
+ private pending = new Map<string, PendingPrompt>();
86
+ private options: PromptBusOptions;
87
+
88
+ constructor(options: PromptBusOptions = {}) {
89
+ this.options = options;
90
+ }
91
+
92
+ /**
93
+ * Register an adapter. Returns an unsubscribe function.
94
+ * Re-registering with the same name replaces the previous adapter.
95
+ */
96
+ registerAdapter(adapter: PromptAdapter): () => void {
97
+ // Replace existing adapter with same name
98
+ this.adapters = this.adapters.filter(a => a.name !== adapter.name);
99
+ this.adapters.push(adapter);
100
+
101
+ return () => {
102
+ this.adapters = this.adapters.filter(a => a !== adapter);
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Submit a prompt. Returns a promise that resolves when any adapter answers.
108
+ */
109
+ request(options: Omit<PromptRequest, "id">): Promise<PromptResponse> {
110
+ const id = crypto.randomUUID();
111
+ const request: PromptRequest = { id, ...options };
112
+
113
+ return new Promise<PromptResponse>((resolve) => {
114
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
115
+ const timer = setTimeout(() => {
116
+ this.cancel(id);
117
+ }, timeoutMs);
118
+
119
+ // Distribute to all adapters and collect claims
120
+ const claims: PendingPrompt["claims"] = [];
121
+ for (const adapter of this.adapters) {
122
+ try {
123
+ const claim = adapter.onRequest(request);
124
+ if (claim) {
125
+ claims.push({ adapter, claim });
126
+ }
127
+ } catch {
128
+ // Adapter error — skip it
129
+ }
130
+ }
131
+
132
+ // Resolve dashboard rendering: first adapter with a component wins
133
+ const componentClaim = claims.find(c => c.claim.component);
134
+ let resolvedComponent: PromptComponent | undefined;
135
+ let resolvedPlacement: string | undefined;
136
+ if (componentClaim) {
137
+ resolvedComponent = componentClaim.claim.component!;
138
+ resolvedPlacement = componentClaim.claim.placement ?? "inline";
139
+ } else if (this.options.onDashboardRequest) {
140
+ resolvedComponent = {
141
+ type: "generic-dialog",
142
+ props: {
143
+ question: request.question,
144
+ type: request.type,
145
+ options: request.options,
146
+ defaultValue: request.defaultValue,
147
+ },
148
+ };
149
+ resolvedPlacement = "inline";
150
+ }
151
+
152
+ // Store pending state (with resolved component for reconnect replay)
153
+ this.pending.set(id, { request, resolve, timer, claims, resolvedComponent, resolvedPlacement });
154
+
155
+ // Send to dashboard
156
+ if (resolvedComponent && this.options.onDashboardRequest) {
157
+ this.options.onDashboardRequest(request, resolvedComponent, resolvedPlacement!);
158
+ }
159
+ });
160
+ }
161
+
162
+ /**
163
+ * An adapter calls this to answer a prompt. First response wins.
164
+ */
165
+ respond(response: PromptResponse): void {
166
+ const entry = this.pending.get(response.id);
167
+ if (!entry) return; // Already resolved or unknown
168
+
169
+ this.pending.delete(response.id);
170
+ clearTimeout(entry.timer);
171
+
172
+ // Notify ALL adapters so they can dismiss their UI
173
+ for (const adapter of this.adapters) {
174
+ try {
175
+ adapter.onResponse(response);
176
+ } catch {
177
+ // Adapter error — continue
178
+ }
179
+ }
180
+
181
+ // Send dismiss to dashboard if a non-dashboard source answered
182
+ if (this.options.onDashboardDismiss) {
183
+ this.options.onDashboardDismiss(response.id);
184
+ }
185
+
186
+ entry.resolve(response);
187
+ }
188
+
189
+ /**
190
+ * Cancel a pending prompt (e.g. on timeout or abort).
191
+ */
192
+ cancel(id: string): void {
193
+ const entry = this.pending.get(id);
194
+ if (!entry) return;
195
+
196
+ this.pending.delete(id);
197
+ clearTimeout(entry.timer);
198
+
199
+ // Notify adapters
200
+ for (const adapter of this.adapters) {
201
+ try {
202
+ adapter.onCancel(id);
203
+ } catch {
204
+ // Adapter error — continue
205
+ }
206
+ }
207
+
208
+ // Notify dashboard
209
+ if (this.options.onDashboardCancel) {
210
+ this.options.onDashboardCancel(id);
211
+ }
212
+
213
+ entry.resolve({ id, cancelled: true, source: "__bus__" });
214
+ }
215
+
216
+ /** Get pending requests with their resolved dashboard components (for reconnect replay). */
217
+ getPendingRequests(): Array<{ request: PromptRequest; component: PromptComponent; placement: string }> {
218
+ const result: Array<{ request: PromptRequest; component: PromptComponent; placement: string }> = [];
219
+ for (const entry of this.pending.values()) {
220
+ if (entry.resolvedComponent && entry.resolvedPlacement) {
221
+ result.push({
222
+ request: entry.request,
223
+ component: entry.resolvedComponent,
224
+ placement: entry.resolvedPlacement,
225
+ });
226
+ }
227
+ }
228
+ return result;
229
+ }
230
+
231
+ /** Get the number of pending prompts (for testing/diagnostics). */
232
+ get pendingCount(): number {
233
+ return this.pending.size;
234
+ }
235
+
236
+ /** Get registered adapter names (for testing/diagnostics). */
237
+ get adapterNames(): string[] {
238
+ return this.adapters.map(a => a.name);
239
+ }
240
+ }
@@ -18,10 +18,10 @@ export interface LaunchResult {
18
18
 
19
19
  /**
20
20
  * Resolve the dashboard server CLI script path relative to this extension file.
21
- * From src/extension/server-launcher.ts → src/server/cli.ts
21
+ * From packages/extension/src/server-launcher.ts → packages/server/src/cli.ts
22
22
  */
23
23
  export function resolveServerCliPath(): string {
24
- return path.resolve(__dirname, "..", "server", "cli.ts");
24
+ return path.resolve(__dirname, "..", "..", "server", "src", "cli.ts");
25
25
  }
26
26
 
27
27
  /**
@@ -81,7 +81,8 @@ export function replaySessionEntries(bc: BridgeContext): void {
81
81
  }
82
82
 
83
83
  /**
84
- * Handle session_switch or session_fork: unregister old, register new, replay, sync.
84
+ * Handle session change (new/fork/resume): unregister old, register new, replay, sync.
85
+ * Called from session_start when event.reason indicates a session switch.
85
86
  */
86
87
  export function handleSessionChange(
87
88
  bc: BridgeContext,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-server",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Dashboard server for monitoring and interacting with pi agent sessions",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for non-destructive bridge registration cleanup.
3
+ * Verifies that existing valid extension paths are preserved,
4
+ * while stale (non-existent) paths are removed.
5
+ */
6
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ import { registerBridgeExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
12
+
13
+ describe("non-destructive bridge registration", () => {
14
+ let tmpDir: string;
15
+ let settingsPath: string;
16
+ let origHome: string | undefined;
17
+ let fakeExtensionDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bridge-reg-test-"));
21
+ settingsPath = path.join(tmpDir, ".pi", "agent", "settings.json");
22
+ origHome = process.env.HOME;
23
+ process.env.HOME = tmpDir;
24
+
25
+ // Create a fake "existing valid" extension dir that looks like a dev install
26
+ fakeExtensionDir = path.join(tmpDir, "dev-project", "pi-agent-dashboard", "packages", "extension");
27
+ fs.mkdirSync(fakeExtensionDir, { recursive: true });
28
+ fs.writeFileSync(path.join(fakeExtensionDir, "package.json"), '{"name":"test"}');
29
+ });
30
+
31
+ afterEach(() => {
32
+ process.env.HOME = origHome;
33
+ fs.rmSync(tmpDir, { recursive: true, force: true });
34
+ });
35
+
36
+ function writeSettings(data: Record<string, unknown>) {
37
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
38
+ fs.writeFileSync(settingsPath, JSON.stringify(data, null, 2));
39
+ }
40
+
41
+ function readSettings(): Record<string, unknown> {
42
+ return JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
43
+ }
44
+
45
+ it("preserves existing valid dashboard extension path", () => {
46
+ writeSettings({ packages: [fakeExtensionDir] });
47
+
48
+ const newPath = path.join(tmpDir, "new-extension");
49
+ registerBridgeExtension(newPath);
50
+
51
+ const settings = readSettings();
52
+ const packages = settings.packages as string[];
53
+ expect(packages).toContain(fakeExtensionDir);
54
+ expect(packages).toContain(newPath);
55
+ });
56
+
57
+ it("removes stale (non-existent) dashboard extension path", () => {
58
+ const stalePath = "/old/nonexistent/pi-dashboard/extension";
59
+ writeSettings({ packages: [stalePath, fakeExtensionDir] });
60
+
61
+ const newPath = path.join(tmpDir, "new-extension");
62
+ registerBridgeExtension(newPath);
63
+
64
+ const settings = readSettings();
65
+ const packages = settings.packages as string[];
66
+ expect(packages).not.toContain(stalePath);
67
+ expect(packages).toContain(fakeExtensionDir);
68
+ expect(packages).toContain(newPath);
69
+ });
70
+
71
+ it("does not add duplicate entries", () => {
72
+ writeSettings({ packages: [fakeExtensionDir] });
73
+
74
+ registerBridgeExtension(fakeExtensionDir);
75
+
76
+ const settings = readSettings();
77
+ const packages = settings.packages as string[];
78
+ const count = packages.filter(p => p === fakeExtensionDir).length;
79
+ expect(count).toBe(1);
80
+ });
81
+
82
+ it("preserves non-dashboard extension paths", () => {
83
+ const otherExt = "/some/other/extension";
84
+ writeSettings({ packages: [otherExt, fakeExtensionDir] });
85
+
86
+ const newPath = path.join(tmpDir, "new-extension");
87
+ registerBridgeExtension(newPath);
88
+
89
+ const settings = readSettings();
90
+ const packages = settings.packages as string[];
91
+ expect(packages).toContain(otherExt);
92
+ });
93
+
94
+ it("removes path without package.json even if directory exists", () => {
95
+ const noPkgDir = path.join(tmpDir, "broken-pi-dashboard");
96
+ fs.mkdirSync(noPkgDir, { recursive: true });
97
+
98
+ writeSettings({ packages: [noPkgDir, fakeExtensionDir] });
99
+
100
+ const newPath = path.join(tmpDir, "new-extension");
101
+ registerBridgeExtension(newPath);
102
+
103
+ const settings = readSettings();
104
+ const packages = settings.packages as string[];
105
+ expect(packages).not.toContain(noPkgDir);
106
+ expect(packages).toContain(fakeExtensionDir);
107
+ });
108
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tests for AppImage guard in findBundledExtension().
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+
9
+ import { findBundledExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
10
+
11
+ describe("findBundledExtension - AppImage guard", () => {
12
+ let tmpDir: string;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "appimage-test-"));
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(tmpDir, { recursive: true, force: true });
20
+ });
21
+
22
+ it("returns extension path for stable install location", () => {
23
+ const extDir = path.join(tmpDir, "packages", "extension");
24
+ fs.mkdirSync(extDir, { recursive: true });
25
+ fs.writeFileSync(path.join(extDir, "package.json"), "{}");
26
+
27
+ const result = findBundledExtension(tmpDir);
28
+ expect(result).toBe(extDir);
29
+ expect(result).not.toContain("/tmp/.mount_");
30
+ });
31
+
32
+ it("returns null when extension does not exist", () => {
33
+ expect(findBundledExtension(tmpDir)).toBeNull();
34
+ });
35
+
36
+ // Note: We can't easily test the /tmp/.mount_ guard with real paths
37
+ // since we'd need to create dirs under /tmp/.mount_PIxxxx.
38
+ // The guard is verified by code inspection and the shared module tests.
39
+ });
@@ -1,12 +1,14 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
1
+ /**
2
+ * Tests for the shared bridge extension registration (server context).
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
5
  import fs from "node:fs";
3
6
  import path from "node:path";
4
7
  import os from "node:os";
5
8
 
6
- // We test the module's exported function
7
- import { ensureBridgeExtensionRegistered } from "../extension-register.js";
9
+ import { registerBridgeExtension, findBundledExtension } from "@blackbelt-technology/pi-dashboard-shared/bridge-register.js";
8
10
 
9
- describe("ensureBridgeExtensionRegistered", () => {
11
+ describe("bridge extension registration (server context)", () => {
10
12
  let tmpDir: string;
11
13
  let settingsPath: string;
12
14
  let origHome: string | undefined;
@@ -23,39 +25,41 @@ describe("ensureBridgeExtensionRegistered", () => {
23
25
  fs.rmSync(tmpDir, { recursive: true, force: true });
24
26
  });
25
27
 
26
- it("should be a no-op when no bundled extension exists", () => {
27
- // In dev mode, the extension directory relative to server/src is the real
28
- // packages/extension, but in test context __dirname doesn't point to a bundle.
29
- // The function should not crash and should not create settings.json
30
- ensureBridgeExtensionRegistered();
31
- // No assertion needed — just verify no crash
28
+ it("findBundledExtension returns null when extension dir does not exist", () => {
29
+ const result = findBundledExtension(tmpDir);
30
+ expect(result).toBeNull();
32
31
  });
33
32
 
34
- it("should add extension path to empty settings file", () => {
35
- // Create a fake bundled extension at the expected relative path
36
- // extension-register.ts resolves __dirname/../../../extension relative to server/src
37
- // We can't easily test the real path detection, so we test the settings write logic
38
- // by directly calling with a mocked path
33
+ it("findBundledExtension finds extension under base dir", () => {
34
+ const extDir = path.join(tmpDir, "packages", "extension");
35
+ fs.mkdirSync(extDir, { recursive: true });
36
+ fs.writeFileSync(path.join(extDir, "package.json"), "{}");
37
+ expect(findBundledExtension(tmpDir)).toBe(extDir);
38
+ });
39
39
 
40
- // Create settings dir
40
+ it("registerBridgeExtension adds extension to empty settings file", () => {
41
41
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
42
42
  fs.writeFileSync(settingsPath, "{}");
43
43
 
44
- // Since we can't mock __dirname easily, we test the settings logic directly
44
+ registerBridgeExtension("/test/extension");
45
45
  const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
46
- expect(settings.packages).toBeUndefined();
46
+ expect(settings.packages).toContain("/test/extension");
47
47
  });
48
48
 
49
49
  it("should not crash on malformed settings.json", () => {
50
50
  fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
51
51
  fs.writeFileSync(settingsPath, "not valid json{{{");
52
- ensureBridgeExtensionRegistered();
53
- // Should not throw
52
+ // Should not throw — starts fresh
53
+ registerBridgeExtension("/test/extension");
54
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
55
+ expect(settings.packages).toContain("/test/extension");
54
56
  });
55
57
 
56
58
  it("should not crash when settings directory does not exist", () => {
57
59
  // HOME points to tmpDir but .pi/agent/ doesn't exist
58
- ensureBridgeExtensionRegistered();
59
- // Should not throw
60
+ registerBridgeExtension("/test/extension");
61
+ // Should create the directory and write
62
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
63
+ expect(settings.packages).toContain("/test/extension");
60
64
  });
61
65
  });
@@ -168,7 +168,10 @@ describe("Process Manager", () => {
168
168
  it("should not duplicate managed bin if already present", () => {
169
169
  const managedBin = require("path").join(require("os").homedir(), ".pi-dashboard", "node_modules", ".bin");
170
170
  const env = buildSpawnEnv({ PATH: `${managedBin}:/usr/bin` });
171
- expect(env.PATH).toBe(`${managedBin}:/usr/bin`);
171
+ // Managed bin should appear exactly once
172
+ const parts = env.PATH!.split(":");
173
+ const managedCount = parts.filter(p => p === managedBin).length;
174
+ expect(managedCount).toBe(1);
172
175
  });
173
176
  });
174
177
 
@@ -43,7 +43,7 @@ describe("Session lifecycle logging", () => {
43
43
  }));
44
44
  await delay(100);
45
45
 
46
- const logs = errorSpy.mock.calls.map((c) => c[0]);
46
+ const logs = errorSpy.mock.calls.map((c: any) => c[0]);
47
47
  expect(logs).toContainEqual(expect.stringContaining("[gateway] session registered: log-reg cwd=/tmp/test"));
48
48
  ws.close();
49
49
  }, 10000);
@@ -64,7 +64,7 @@ describe("Session lifecycle logging", () => {
64
64
  ws.send(JSON.stringify({ type: "session_unregister", sessionId: "log-unreg" }));
65
65
  await delay(100);
66
66
 
67
- const logs = errorSpy.mock.calls.map((c) => c[0]);
67
+ const logs = errorSpy.mock.calls.map((c: any) => c[0]);
68
68
  expect(logs).toContainEqual(expect.stringContaining("[gateway] session unregistered: log-unreg (explicit)"));
69
69
  ws.close();
70
70
  }, 10000);
@@ -87,7 +87,7 @@ describe("Session lifecycle logging", () => {
87
87
  ws.close();
88
88
  await delay(SHORT_HB + 300);
89
89
 
90
- const logs = errorSpy.mock.calls.map((c) => c[0]);
90
+ const logs = errorSpy.mock.calls.map((c: any) => c[0]);
91
91
  expect(logs).toContainEqual(expect.stringContaining("[gateway] session timed out: log-timeout (no heartbeat for"));
92
92
  }, 10000);
93
93
 
@@ -107,7 +107,7 @@ describe("Session lifecycle logging", () => {
107
107
  ws.close();
108
108
  await delay(200);
109
109
 
110
- const logs = errorSpy.mock.calls.map((c) => c[0]);
110
+ const logs = errorSpy.mock.calls.map((c: any) => c[0]);
111
111
  expect(logs).toContainEqual(expect.stringContaining("[gateway] connection closed: log-close"));
112
112
  }, 10000);
113
113
 
@@ -132,7 +132,7 @@ describe("Session lifecycle logging", () => {
132
132
  (ws as any)._socket?.pause();
133
133
  await delay(200 * 4);
134
134
 
135
- const logs = errorSpy.mock.calls.map((c) => c[0]);
135
+ const logs = errorSpy.mock.calls.map((c: any) => c[0]);
136
136
  expect(logs).toContainEqual(expect.stringContaining("[gateway] connection dead (ping timeout, 2 misses): log-ping"));
137
137
  }, 10000);
138
138
  });
@@ -7,7 +7,7 @@ vi.mock("node:fs", async (importOriginal) => {
7
7
  return {
8
8
  ...actual,
9
9
  default: {
10
- ...actual.default,
10
+ ...(actual as any).default,
11
11
  existsSync: vi.fn(),
12
12
  readFileSync: vi.fn(),
13
13
  writeFileSync: vi.fn(),
@@ -25,7 +25,7 @@ vi.mock("node:os", async (importOriginal) => {
25
25
  const actual = await importOriginal<typeof import("node:os")>();
26
26
  return {
27
27
  ...actual,
28
- default: { ...actual.default, homedir: vi.fn(() => "/home/testuser") },
28
+ default: { ...(actual as any).default, homedir: vi.fn(() => "/home/testuser") },
29
29
  homedir: vi.fn(() => "/home/testuser"),
30
30
  };
31
31
  });