@blackbelt-technology/pi-agent-dashboard 0.4.5 → 0.5.0
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.
- package/AGENTS.md +342 -267
- package/README.md +51 -2
- package/docs/architecture.md +266 -25
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-bus.test.ts +44 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
- package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
- package/packages/extension/src/bridge-context.ts +7 -0
- package/packages/extension/src/bridge.ts +142 -4
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/model-tracker.ts +35 -1
- package/packages/extension/src/prompt-bus.ts +4 -3
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +6 -1
- package/packages/extension/src/vcs-info.ts +184 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
- package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +22 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +57 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/openspec-tasks.ts +50 -19
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/jj-routes.ts +386 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/session-routes.ts +12 -3
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +16 -9
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-diff.ts +118 -1
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +29 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/diff-types.ts +17 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/jj.ts +405 -0
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +60 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +19 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +91 -0
- package/packages/extension/src/git-info.ts +0 -55
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for POST /api/electron/reextract
|
|
3
|
+
* See change: simplify-electron-bootstrap-derived-state (task 6.4 / 6.9).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
7
|
+
import { registerSystemRoutes } from "../routes/system-routes.js";
|
|
8
|
+
import type { BootstrapStateStore, BootstrapState } from "../bootstrap-state.js";
|
|
9
|
+
|
|
10
|
+
function noGuard() {
|
|
11
|
+
return async () => { /* allow all */ };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeBootstrapState(starter: string): BootstrapStateStore {
|
|
15
|
+
return {
|
|
16
|
+
get: () => ({
|
|
17
|
+
status: "ready",
|
|
18
|
+
starter: starter as any,
|
|
19
|
+
installable: { total: 0, installed: 0, failed: [] },
|
|
20
|
+
} as BootstrapState),
|
|
21
|
+
set: () => {},
|
|
22
|
+
subscribe: () => () => {},
|
|
23
|
+
} as unknown as BootstrapStateStore;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeNoopDeps(bootstrapState?: BootstrapStateStore) {
|
|
27
|
+
return {
|
|
28
|
+
sessionManager: { listActive: () => [], listAll: () => [] } as never,
|
|
29
|
+
preferencesStore: { flush: () => {} } as never,
|
|
30
|
+
metaPersistence: { flushAll: () => {} } as never,
|
|
31
|
+
config: { port: 8000, piPort: 9999, dev: false } as never,
|
|
32
|
+
networkGuard: noGuard(),
|
|
33
|
+
bootstrapState,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("POST /api/electron/reextract", () => {
|
|
38
|
+
let fastify: FastifyInstance;
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
fastify = Fastify();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await fastify.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns 403 when starter is Bridge", async () => {
|
|
49
|
+
const deps = makeNoopDeps(makeBootstrapState("Bridge"));
|
|
50
|
+
registerSystemRoutes(fastify, deps);
|
|
51
|
+
await fastify.ready();
|
|
52
|
+
|
|
53
|
+
const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
|
|
54
|
+
expect(res.statusCode).toBe(403);
|
|
55
|
+
const body = res.json() as Record<string, unknown>;
|
|
56
|
+
expect(body.error).toBe("reextract_not_allowed");
|
|
57
|
+
expect(body.starter).toBe("Bridge");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns 403 when starter is Standalone", async () => {
|
|
61
|
+
const deps = makeNoopDeps(makeBootstrapState("Standalone"));
|
|
62
|
+
registerSystemRoutes(fastify, deps);
|
|
63
|
+
await fastify.ready();
|
|
64
|
+
|
|
65
|
+
const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
|
|
66
|
+
expect(res.statusCode).toBe(403);
|
|
67
|
+
const body = res.json() as Record<string, unknown>;
|
|
68
|
+
expect(body.error).toBe("reextract_not_allowed");
|
|
69
|
+
expect(body.starter).toBe("Standalone");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns 202 when starter is Electron", async () => {
|
|
73
|
+
const deps = makeNoopDeps(makeBootstrapState("Electron"));
|
|
74
|
+
registerSystemRoutes(fastify, deps);
|
|
75
|
+
await fastify.ready();
|
|
76
|
+
|
|
77
|
+
const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
|
|
78
|
+
expect(res.statusCode).toBe(202);
|
|
79
|
+
const body = res.json() as Record<string, unknown>;
|
|
80
|
+
expect(body.ok).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("returns 403 when no bootstrapState (defaults to Standalone)", async () => {
|
|
84
|
+
const deps = makeNoopDeps(undefined);
|
|
85
|
+
registerSystemRoutes(fastify, deps);
|
|
86
|
+
await fastify.ready();
|
|
87
|
+
|
|
88
|
+
const res = await fastify.inject({ method: "POST", url: "/api/electron/reextract" });
|
|
89
|
+
expect(res.statusCode).toBe(403);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -25,7 +25,7 @@ function makeNoopDeps() {
|
|
|
25
25
|
function makeFakeGateway(): { gateway: PiGateway; broadcasts: ServerToExtensionMessage[] } {
|
|
26
26
|
const broadcasts: ServerToExtensionMessage[] = [];
|
|
27
27
|
const gateway: PiGateway = {
|
|
28
|
-
broadcast(msg) { broadcasts.push(msg); },
|
|
28
|
+
broadcast(msg: ServerToExtensionMessage) { broadcasts.push(msg); },
|
|
29
29
|
sendToSession() { return false; },
|
|
30
30
|
isSessionConnected() { return false; },
|
|
31
31
|
connectionCount() { return 0; },
|
|
@@ -46,7 +46,7 @@ describe("POST /api/restart broadcasts server_restarting", () => {
|
|
|
46
46
|
const fake = makeFakeGateway();
|
|
47
47
|
broadcasts = fake.broadcasts;
|
|
48
48
|
// process.exit is deferred via setTimeout(...,200); silence it for the test
|
|
49
|
-
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
49
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
|
|
50
50
|
registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -82,7 +82,7 @@ describe("POST /api/shutdown broadcasts server_restarting", () => {
|
|
|
82
82
|
fastify = Fastify();
|
|
83
83
|
const fake = makeFakeGateway();
|
|
84
84
|
broadcasts = fake.broadcasts;
|
|
85
|
-
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
85
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
|
|
86
86
|
registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
|
|
87
87
|
});
|
|
88
88
|
|
|
@@ -111,7 +111,7 @@ describe("/api/restart works without piGateway (no-op broadcast)", () => {
|
|
|
111
111
|
|
|
112
112
|
beforeEach(() => {
|
|
113
113
|
fastify = Fastify();
|
|
114
|
-
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
|
|
114
|
+
exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: string | number | null) => undefined as never) as (code?: string | number | null | undefined) => never);
|
|
115
115
|
registerSystemRoutes(fastify, makeNoopDeps()); // no piGateway
|
|
116
116
|
});
|
|
117
117
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GET /api/spawn-failures endpoint.
|
|
3
|
+
* See change: spawn-failure-diagnostics.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
7
|
+
import { registerSystemRoutes } from "../routes/system-routes.js";
|
|
8
|
+
|
|
9
|
+
// Mock the spawn-failure-log module.
|
|
10
|
+
vi.mock("../spawn-failure-log.js", () => ({
|
|
11
|
+
readSpawnFailures: vi.fn().mockReturnValue([]),
|
|
12
|
+
appendSpawnFailure: vi.fn(),
|
|
13
|
+
SpawnFailureEntry: undefined,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { readSpawnFailures } from "../spawn-failure-log.js";
|
|
17
|
+
|
|
18
|
+
const mockReadSpawnFailures = vi.mocked(readSpawnFailures);
|
|
19
|
+
|
|
20
|
+
function makeNoopDeps() {
|
|
21
|
+
return {
|
|
22
|
+
sessionManager: {} as never,
|
|
23
|
+
preferencesStore: { flush: () => {} } as never,
|
|
24
|
+
metaPersistence: { flushAll: () => {} } as never,
|
|
25
|
+
config: { port: 8000, piPort: 9999, dev: false } as never,
|
|
26
|
+
directoryService: {} as never,
|
|
27
|
+
piGateway: {
|
|
28
|
+
broadcast: vi.fn(),
|
|
29
|
+
announceRestart: vi.fn(),
|
|
30
|
+
} as never,
|
|
31
|
+
idleTimer: {} as never,
|
|
32
|
+
serverVersion: "test",
|
|
33
|
+
localhostGuard: () => async () => {},
|
|
34
|
+
tunnelStatus: () => ({ active: false }),
|
|
35
|
+
serverConfig: { dev: false } as never,
|
|
36
|
+
pluginStatusStore: { getAll: () => [] } as never,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("GET /api/spawn-failures", () => {
|
|
41
|
+
let app: FastifyInstance;
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
vi.clearAllMocks();
|
|
45
|
+
app = Fastify({ logger: false });
|
|
46
|
+
registerSystemRoutes(app, makeNoopDeps() as never);
|
|
47
|
+
await app.ready();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await app.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns empty entries when no log exists", async () => {
|
|
55
|
+
mockReadSpawnFailures.mockReturnValue([]);
|
|
56
|
+
const res = await app.inject({ method: "GET", url: "/api/spawn-failures" });
|
|
57
|
+
expect(res.statusCode).toBe(200);
|
|
58
|
+
const body = JSON.parse(res.body);
|
|
59
|
+
expect(body).toHaveProperty("entries");
|
|
60
|
+
expect(body.entries).toEqual([]);
|
|
61
|
+
expect(mockReadSpawnFailures).toHaveBeenCalledWith(50);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("passes custom limit", async () => {
|
|
65
|
+
mockReadSpawnFailures.mockReturnValue([]);
|
|
66
|
+
await app.inject({ method: "GET", url: "/api/spawn-failures?limit=10" });
|
|
67
|
+
expect(mockReadSpawnFailures).toHaveBeenCalledWith(10);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("falls back to default limit on NaN", async () => {
|
|
71
|
+
mockReadSpawnFailures.mockReturnValue([]);
|
|
72
|
+
await app.inject({ method: "GET", url: "/api/spawn-failures?limit=abc" });
|
|
73
|
+
expect(mockReadSpawnFailures).toHaveBeenCalledWith(50);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns entries from the log", async () => {
|
|
77
|
+
const entry = { ts: "2026-01-01T00:00:00Z", cwd: "/p/x", strategy: "headless", code: "PI_CRASHED", message: "crashed" };
|
|
78
|
+
mockReadSpawnFailures.mockReturnValue([entry] as never);
|
|
79
|
+
const res = await app.inject({ method: "GET", url: "/api/spawn-failures" });
|
|
80
|
+
const body = JSON.parse(res.body);
|
|
81
|
+
expect(body.entries).toHaveLength(1);
|
|
82
|
+
expect(body.entries[0].code).toBe("PI_CRASHED");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -242,6 +242,51 @@ describe("TerminalManager", () => {
|
|
|
242
242
|
handlers.message(resizeMsg, false);
|
|
243
243
|
expect(mockPtyResize).toHaveBeenCalledWith(120, 40);
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
// Resize floor — see change: fix-terminal-half-height-dual-mount.
|
|
247
|
+
// PTYs at <2 cols/rows are non-functional for every supported shell
|
|
248
|
+
// and the most common cause is a transient display:none container
|
|
249
|
+
// measured by FitAddon during a route transition.
|
|
250
|
+
describe("resize floor", () => {
|
|
251
|
+
function attachAndSendResize(cols: number, rows: number) {
|
|
252
|
+
const session = manager.spawn("/tmp");
|
|
253
|
+
const handlers: Record<string, Function> = {};
|
|
254
|
+
const mockWs = {
|
|
255
|
+
send: vi.fn(),
|
|
256
|
+
on: vi.fn((event: string, cb: any) => { handlers[event] = cb; }),
|
|
257
|
+
readyState: 1,
|
|
258
|
+
OPEN: 1,
|
|
259
|
+
} as any;
|
|
260
|
+
manager.attach(session.id, mockWs);
|
|
261
|
+
const msg = Buffer.from(JSON.stringify({ type: "resize", cols, rows }));
|
|
262
|
+
handlers.message(msg, false);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
it("ignores resize with cols below floor (cols=1)", () => {
|
|
266
|
+
attachAndSendResize(1, 24);
|
|
267
|
+
expect(mockPtyResize).not.toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("ignores resize with rows below floor (rows=0)", () => {
|
|
271
|
+
attachAndSendResize(80, 0);
|
|
272
|
+
expect(mockPtyResize).not.toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("ignores resize with both dimensions below floor", () => {
|
|
276
|
+
attachAndSendResize(1, 1);
|
|
277
|
+
expect(mockPtyResize).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("accepts resize at the floor (cols=2, rows=2)", () => {
|
|
281
|
+
attachAndSendResize(2, 2);
|
|
282
|
+
expect(mockPtyResize).toHaveBeenCalledWith(2, 2);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("accepts a normal resize", () => {
|
|
286
|
+
attachAndSendResize(80, 24);
|
|
287
|
+
expect(mockPtyResize).toHaveBeenCalledWith(80, 24);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
245
290
|
});
|
|
246
291
|
|
|
247
292
|
describe("PTY exit", () => {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bootstrap install reconciler driven by ~/.pi/dashboard/installable.json.
|
|
3
|
+
* Invoked by cli.ts before app.listen.
|
|
4
|
+
*
|
|
5
|
+
* File-absent path is a deliberate no-op: Bridge and Standalone starters
|
|
6
|
+
* never write installable.json; only Electron seeds it on first run.
|
|
7
|
+
* When the file is absent, this function logs and returns immediately so
|
|
8
|
+
* bootstrap.status transitions to "ready" without delay.
|
|
9
|
+
*
|
|
10
|
+
* See change: simplify-electron-bootstrap-derived-state.
|
|
11
|
+
*/
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import { getManagedDir } from "@blackbelt-technology/pi-dashboard-shared/managed-paths.js";
|
|
16
|
+
import {
|
|
17
|
+
readInstallableList,
|
|
18
|
+
type InstallablePackage,
|
|
19
|
+
} from "@blackbelt-technology/pi-dashboard-shared/installable-list.js";
|
|
20
|
+
import { bootstrapInstall } from "@blackbelt-technology/pi-dashboard-shared/bootstrap-install.js";
|
|
21
|
+
import { getDefaultRegistry } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
|
|
22
|
+
import type { BootstrapStateStore } from "./bootstrap-state.js";
|
|
23
|
+
|
|
24
|
+
// ── Injectable helpers (overridable in tests) ──────────────────────────────
|
|
25
|
+
|
|
26
|
+
export type InstallProgressCallback = (line: string) => void;
|
|
27
|
+
export type PackageInstaller = (
|
|
28
|
+
pkg: InstallablePackage,
|
|
29
|
+
onOutput: InstallProgressCallback,
|
|
30
|
+
) => Promise<void>;
|
|
31
|
+
|
|
32
|
+
// ── Installed-check ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Return true if `pkgName` is resolvable from `managedDir/node_modules`.
|
|
36
|
+
* Version satisfies check is intentionally omitted (no semver dep) —
|
|
37
|
+
* we treat "resolves at all" as satisfied. This is sufficient for the
|
|
38
|
+
* Phase B bootstrap use case.
|
|
39
|
+
*/
|
|
40
|
+
export function isNpmPackageInstalled(pkgName: string, managedDir: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
// createRequire resolves from the given path; look in managedDir/node_modules.
|
|
43
|
+
const req = createRequire(path.join(managedDir, "package.json"));
|
|
44
|
+
req.resolve(pkgName + "/package.json");
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Default installers ─────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
async function defaultNpmInstall(
|
|
54
|
+
pkg: InstallablePackage,
|
|
55
|
+
managedDir: string,
|
|
56
|
+
onOutput: InstallProgressCallback,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const spec =
|
|
59
|
+
pkg.version && pkg.version !== "*"
|
|
60
|
+
? `${pkg.name}@${pkg.version}`
|
|
61
|
+
: pkg.name;
|
|
62
|
+
const res = await bootstrapInstall({
|
|
63
|
+
packages: [spec],
|
|
64
|
+
managedDir,
|
|
65
|
+
progress: (p) => {
|
|
66
|
+
if (p.output) onOutput(p.output);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw new Error(res.error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function defaultPiExtensionInstall(
|
|
75
|
+
pkg: InstallablePackage,
|
|
76
|
+
onOutput: InstallProgressCallback,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
const registry = getDefaultRegistry();
|
|
79
|
+
const { module: piModule } = await registry.resolveModule<{
|
|
80
|
+
DefaultPackageManager: any;
|
|
81
|
+
SettingsManager: any;
|
|
82
|
+
}>("pi-coding-agent");
|
|
83
|
+
const agentDir = path.join(os.homedir(), ".pi", "agent");
|
|
84
|
+
const settingsManager = piModule.SettingsManager.create(process.cwd(), agentDir);
|
|
85
|
+
const pm = new piModule.DefaultPackageManager({
|
|
86
|
+
cwd: process.cwd(),
|
|
87
|
+
agentDir,
|
|
88
|
+
settingsManager,
|
|
89
|
+
});
|
|
90
|
+
pm.setProgressCallback((event: { message?: string }) => {
|
|
91
|
+
if (event.message) onOutput(event.message);
|
|
92
|
+
});
|
|
93
|
+
await pm.installAndPersist(pkg.name, { local: false });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Options ────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export interface BootstrapInstallFromListOptions {
|
|
99
|
+
/** Override config dir for installable.json (default: ~/.pi/dashboard/). */
|
|
100
|
+
configDir?: string;
|
|
101
|
+
/** Override managed dir for npm installs (default: ~/.pi-dashboard/). */
|
|
102
|
+
managedDir?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Injectable npm installer. Defaults to bootstrapInstall.
|
|
105
|
+
* Receives the InstallablePackage and a streaming output callback.
|
|
106
|
+
*/
|
|
107
|
+
npmInstall?: PackageInstaller;
|
|
108
|
+
/**
|
|
109
|
+
* Injectable pi-extension installer. Defaults to pi DefaultPackageManager.
|
|
110
|
+
* Receives the InstallablePackage and a streaming output callback.
|
|
111
|
+
*/
|
|
112
|
+
piInstall?: PackageInstaller;
|
|
113
|
+
/**
|
|
114
|
+
* Injectable installed-check for npm packages.
|
|
115
|
+
* Defaults to isNpmPackageInstalled.
|
|
116
|
+
*/
|
|
117
|
+
isInstalled?: (pkg: InstallablePackage, managedDir: string) => boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Main reconciler ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reconcile packages from installable.json against the managed directory.
|
|
124
|
+
*
|
|
125
|
+
* - File absent: log and return immediately (not a failure).
|
|
126
|
+
* - Per package: check installed → skip or install.
|
|
127
|
+
* - Required failure: set bootstrap status=failed, throw (abort server start).
|
|
128
|
+
* - Optional failure: log, record in failed[], continue.
|
|
129
|
+
*/
|
|
130
|
+
export async function bootstrapInstallFromList(
|
|
131
|
+
bootstrapState: BootstrapStateStore,
|
|
132
|
+
opts?: BootstrapInstallFromListOptions,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
const configDir =
|
|
135
|
+
opts?.configDir ?? path.join(os.homedir(), ".pi", "dashboard");
|
|
136
|
+
const managedDir = opts?.managedDir ?? getManagedDir();
|
|
137
|
+
|
|
138
|
+
// Read installable.json; absent file is a deliberate no-op.
|
|
139
|
+
const list = await readInstallableList(configDir);
|
|
140
|
+
if (list === null) {
|
|
141
|
+
console.log(
|
|
142
|
+
"[bootstrap] bootstrap.installable.skipped reason=file-not-found",
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Only process packages that are active (not deprecated, not defaultOff).
|
|
148
|
+
const packages = list.packages.filter((p) => !p.deprecated && !p.defaultOff);
|
|
149
|
+
const total = packages.length;
|
|
150
|
+
let installedCount = 0;
|
|
151
|
+
const failed: string[] = [];
|
|
152
|
+
|
|
153
|
+
// Stamp initial installable progress into bootstrap state.
|
|
154
|
+
bootstrapState.set({ installable: { total, installed: 0, failed: [] } });
|
|
155
|
+
|
|
156
|
+
const checkInstalled =
|
|
157
|
+
opts?.isInstalled ?? ((p, dir) => isNpmPackageInstalled(p.name, dir));
|
|
158
|
+
const doNpmInstall: PackageInstaller =
|
|
159
|
+
opts?.npmInstall ??
|
|
160
|
+
((p, cb) => defaultNpmInstall(p, managedDir, cb));
|
|
161
|
+
const doPiInstall: PackageInstaller =
|
|
162
|
+
opts?.piInstall ?? defaultPiExtensionInstall;
|
|
163
|
+
|
|
164
|
+
for (const pkg of packages) {
|
|
165
|
+
// Fast path: already installed (npm packages only; pi-extension always attempts).
|
|
166
|
+
if (pkg.kind === "npm" && checkInstalled(pkg, managedDir)) {
|
|
167
|
+
console.log(
|
|
168
|
+
`[bootstrap] bootstrap.installable.package name=${pkg.name} status=satisfied`,
|
|
169
|
+
);
|
|
170
|
+
installedCount++;
|
|
171
|
+
bootstrapState.set({
|
|
172
|
+
installable: { total, installed: installedCount, failed },
|
|
173
|
+
});
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Emit installing progress.
|
|
178
|
+
bootstrapState.set({
|
|
179
|
+
progress: { step: pkg.name, output: "installing..." },
|
|
180
|
+
installable: { total, installed: installedCount, failed },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const onOutput = (line: string): void => {
|
|
185
|
+
bootstrapState.set({ progress: { step: pkg.name, output: line } });
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
if (pkg.kind === "npm") {
|
|
189
|
+
await doNpmInstall(pkg, onOutput);
|
|
190
|
+
} else {
|
|
191
|
+
await doPiInstall(pkg, onOutput);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
installedCount++;
|
|
195
|
+
bootstrapState.set({
|
|
196
|
+
progress: undefined,
|
|
197
|
+
installable: { total, installed: installedCount, failed },
|
|
198
|
+
});
|
|
199
|
+
console.log(
|
|
200
|
+
`[bootstrap] bootstrap.installable.package name=${pkg.name} status=done`,
|
|
201
|
+
);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
204
|
+
console.error(
|
|
205
|
+
`[bootstrap] bootstrap.installable.package name=${pkg.name} status=error error=${message}`,
|
|
206
|
+
);
|
|
207
|
+
failed.push(pkg.name);
|
|
208
|
+
bootstrapState.set({
|
|
209
|
+
progress: undefined,
|
|
210
|
+
installable: { total, installed: installedCount, failed: [...failed] },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (pkg.required) {
|
|
214
|
+
const errorMessage = `Required package "${pkg.name}" failed to install: ${message}`;
|
|
215
|
+
bootstrapState.set({
|
|
216
|
+
status: "failed",
|
|
217
|
+
error: { message: errorMessage },
|
|
218
|
+
});
|
|
219
|
+
throw new Error(errorMessage);
|
|
220
|
+
}
|
|
221
|
+
// Optional package failure: log, continue to next package.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Final state snapshot.
|
|
226
|
+
bootstrapState.set({
|
|
227
|
+
installable: { total, installed: installedCount, failed },
|
|
228
|
+
});
|
|
229
|
+
console.log(
|
|
230
|
+
`[bootstrap] bootstrap.installable.done total=${total} installed=${installedCount} failed=${failed.length}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { DashboardStarter } from "@blackbelt-technology/pi-dashboard-shared/dashboard-starter.js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* In-memory bootstrap state store for the dashboard server.
|
|
3
5
|
*
|
|
@@ -51,6 +53,22 @@ export interface BootstrapState {
|
|
|
51
53
|
compatibility?: BootstrapCompatibility;
|
|
52
54
|
/** Set when `registerBridgeExtension` fails after a successful install. */
|
|
53
55
|
bridgeRegistrationError?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Who started this server process. Defaults to "Standalone" (direct CLI).
|
|
58
|
+
* Set at boot time from `parseDashboardStarter(process.env)`.
|
|
59
|
+
*/
|
|
60
|
+
starter?: DashboardStarter;
|
|
61
|
+
/**
|
|
62
|
+
* Installable list reconciliation progress.
|
|
63
|
+
* Set by bootstrapInstallFromList during Phase B reconcile.
|
|
64
|
+
* See change: simplify-electron-bootstrap-derived-state.
|
|
65
|
+
*/
|
|
66
|
+
installable?: {
|
|
67
|
+
total: number;
|
|
68
|
+
installed: number;
|
|
69
|
+
/** Package names that failed to install. */
|
|
70
|
+
failed: string[];
|
|
71
|
+
};
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
export type BootstrapListener = (state: BootstrapState) => void;
|
|
@@ -15,7 +15,44 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
|
|
|
15
15
|
import type { PendingForkRegistry } from "./pending-fork-registry.js";
|
|
16
16
|
import type { SessionOrderManager } from "./session-order-manager.js";
|
|
17
17
|
import type { PreferencesStore } from "./preferences-store.js";
|
|
18
|
-
import type
|
|
18
|
+
import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pure helper: build the per-cwd `openspec_update` messages a freshly
|
|
22
|
+
* connecting browser should receive. One message per known cwd.
|
|
23
|
+
* Disambiguates three states:
|
|
24
|
+
* - cache populated → cached payload
|
|
25
|
+
* - openspec dir but cold → { initialized: false, pending: true }
|
|
26
|
+
* - no openspec dir → { initialized: false, pending: false }
|
|
27
|
+
*
|
|
28
|
+
* Exported so cold-boot snapshot semantics can be unit-tested without
|
|
29
|
+
* spinning up a WS server. See change: fix-cold-boot-openspec-protocol.
|
|
30
|
+
*/
|
|
31
|
+
export function buildOpenSpecConnectSnapshot(
|
|
32
|
+
directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
|
|
33
|
+
hasDir: (cwd: string) => boolean,
|
|
34
|
+
): Array<ServerToBrowserMessage> {
|
|
35
|
+
const out: Array<ServerToBrowserMessage> = [];
|
|
36
|
+
for (const cwd of directoryService.knownDirectories()) {
|
|
37
|
+
const cached = directoryService.getOpenSpecData(cwd);
|
|
38
|
+
if (cached && cached.initialized) {
|
|
39
|
+
out.push({ type: "openspec_update", cwd, data: cached });
|
|
40
|
+
} else if (hasDir(cwd)) {
|
|
41
|
+
out.push({
|
|
42
|
+
type: "openspec_update",
|
|
43
|
+
cwd,
|
|
44
|
+
data: { initialized: false, pending: true, changes: [] },
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
out.push({
|
|
48
|
+
type: "openspec_update",
|
|
49
|
+
cwd,
|
|
50
|
+
data: { initialized: false, pending: false, changes: [] },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
19
56
|
import { createPendingResumeRegistry, type PendingResumeRegistry } from "./pending-resume-registry.js";
|
|
20
57
|
import { createViewedSessionTracker, type ViewedSessionTracker } from "./viewed-session-tracker.js";
|
|
21
58
|
import type { TerminalManager } from "./terminal-manager.js";
|
|
@@ -208,10 +245,21 @@ export function createBrowserGateway(
|
|
|
208
245
|
const subs = new Set<string>();
|
|
209
246
|
subscriptions.set(ws, subs);
|
|
210
247
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
248
|
+
// Atomic snapshot of the full session registry + per-cwd orders.
|
|
249
|
+
// Replaces the legacy per-session `session_added` loop and per-cwd
|
|
250
|
+
// `sessions_reordered` loop. Client REPLACES (not merges) its
|
|
251
|
+
// `sessions` Map and `sessionOrderMap` on receipt so stale ids from a
|
|
252
|
+
// previous server lifetime are dropped atomically.
|
|
253
|
+
// See change: fix-stale-sessions-on-reconnect.
|
|
254
|
+
{
|
|
255
|
+
const sessionsSnapshot = sessionManager.listAll();
|
|
256
|
+
const orders: Record<string, string[]> = {};
|
|
257
|
+
if (sessionOrderManager) {
|
|
258
|
+
for (const [cwd, sessionIds] of Object.entries(sessionOrderManager.getAllOrders())) {
|
|
259
|
+
if (sessionIds.length > 0) orders[cwd] = sessionIds;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
sendTo(ws, { type: "sessions_snapshot", sessions: sessionsSnapshot, orders });
|
|
215
263
|
}
|
|
216
264
|
|
|
217
265
|
// Send pinned directories on connect
|
|
@@ -219,23 +267,12 @@ export function createBrowserGateway(
|
|
|
219
267
|
sendTo(ws, { type: "pinned_dirs_updated", paths: preferencesStore.getPinnedDirectories() });
|
|
220
268
|
}
|
|
221
269
|
|
|
222
|
-
// Send
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
for (const [cwd, sessionIds] of Object.entries(allOrders)) {
|
|
226
|
-
if (sessionIds.length > 0) {
|
|
227
|
-
sendTo(ws, { type: "sessions_reordered", cwd, sessionIds });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// Send cached OpenSpec data for all known directories
|
|
270
|
+
// Send OpenSpec data for every known directory — exactly one
|
|
271
|
+
// `openspec_update` per cwd, never silently omit.
|
|
272
|
+
// See change: fix-cold-boot-openspec-protocol.
|
|
233
273
|
if (directoryService) {
|
|
234
|
-
for (const
|
|
235
|
-
|
|
236
|
-
if (data && data.initialized) {
|
|
237
|
-
sendTo(ws, { type: "openspec_update", cwd, data });
|
|
238
|
-
}
|
|
274
|
+
for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
|
|
275
|
+
sendTo(ws, msg);
|
|
239
276
|
}
|
|
240
277
|
}
|
|
241
278
|
|
|
@@ -148,6 +148,10 @@ export function handlePiGatewayForward(
|
|
|
148
148
|
case "request_models":
|
|
149
149
|
piGateway.sendToSession(msg.sessionId, { type: "request_models", sessionId: msg.sessionId });
|
|
150
150
|
break;
|
|
151
|
+
case "request_providers":
|
|
152
|
+
// See change: replace-hardcoded-provider-lists.
|
|
153
|
+
piGateway.sendToSession(msg.sessionId, { type: "request_providers", sessionId: msg.sessionId });
|
|
154
|
+
break;
|
|
151
155
|
case "set_thinking_level":
|
|
152
156
|
piGateway.sendToSession(msg.sessionId, { type: "set_thinking_level", sessionId: msg.sessionId, level: msg.level });
|
|
153
157
|
break;
|