@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.
- package/AGENTS.md +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- package/packages/shared/src/resolve-jiti.ts +0 -155
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `packages/server/bin/pi-dashboard.mjs` — the published CLI
|
|
3
|
+
* bin entry. Spawns the wrapper as a child process to exercise the
|
|
4
|
+
* real jiti-resolution + re-exec behaviour.
|
|
5
|
+
*
|
|
6
|
+
* See change: replace-tsx-with-jiti.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import url from "node:url";
|
|
14
|
+
|
|
15
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
16
|
+
const wrapperPath = path.resolve(here, "..", "..", "bin", "pi-dashboard.mjs");
|
|
17
|
+
const repoNodeModules = path.resolve(here, "..", "..", "..", "..", "node_modules");
|
|
18
|
+
const repoJitiRegister = path.join(repoNodeModules, "jiti", "lib", "jiti-register.mjs");
|
|
19
|
+
|
|
20
|
+
describe("bin/pi-dashboard.mjs wrapper", () => {
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
if (!existsSync(wrapperPath)) {
|
|
23
|
+
throw new Error(`Wrapper missing at ${wrapperPath}`);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("exits 1 with install-hint when jiti cannot be resolved", () => {
|
|
28
|
+
// Build an isolated anchor with NO node_modules tree — createRequire on
|
|
29
|
+
// it will fail to resolve `jiti/package.json`, triggering the miss path.
|
|
30
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "pi-dashboard-bin-test-"));
|
|
31
|
+
try {
|
|
32
|
+
const fakeAnchor = path.join(tmp, "fake-anchor.js");
|
|
33
|
+
writeFileSync(fakeAnchor, "// no-op anchor with no node_modules\n");
|
|
34
|
+
|
|
35
|
+
// Spawn the wrapper. We override process.argv[1] indirectly by
|
|
36
|
+
// invoking node with `<wrapper>` then forcing argv[1] to the fake
|
|
37
|
+
// anchor via a tiny preamble — but the wrapper reads its OWN
|
|
38
|
+
// process.argv[1] which is the wrapper path itself when invoked
|
|
39
|
+
// directly. Strategy: copy the wrapper into the isolated tmp dir so
|
|
40
|
+
// its argv[1] resolves there with no jiti adjacency.
|
|
41
|
+
const isolatedWrapper = path.join(tmp, "pi-dashboard.mjs");
|
|
42
|
+
const wrapperSrc = require("node:fs").readFileSync(wrapperPath, "utf-8");
|
|
43
|
+
writeFileSync(isolatedWrapper, wrapperSrc);
|
|
44
|
+
|
|
45
|
+
const result = spawnSync(process.execPath, [isolatedWrapper, "--version"], {
|
|
46
|
+
encoding: "utf-8",
|
|
47
|
+
env: { ...process.env, NODE_PATH: "" },
|
|
48
|
+
timeout: 10_000,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(result.status).toBe(1);
|
|
52
|
+
expect(result.stderr).toContain("pi-dashboard: cannot find jiti");
|
|
53
|
+
expect(result.stderr).toContain("npm install -g @earendil-works/pi-coding-agent");
|
|
54
|
+
// No tsx mention — proposal mandates no-fallback wrapper.
|
|
55
|
+
expect(result.stderr).not.toMatch(/tsx/i);
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("resolves jiti from process.argv[1] anchor and re-execs cli.ts", () => {
|
|
62
|
+
// Repo root has jiti at node_modules/jiti — wrapper invoked with its
|
|
63
|
+
// real path SHOULD walk createRequire(realpath(argv[1])) up into the
|
|
64
|
+
// repo's node_modules and find jiti.
|
|
65
|
+
if (!existsSync(repoJitiRegister)) {
|
|
66
|
+
// CI / fresh clone without `npm install` — skip rather than fail.
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Use `status` — it doesn't bind ports and exits quickly regardless
|
|
71
|
+
// of whether a server is running. We don't care about exit code (0 if
|
|
72
|
+
// a dashboard is up, 1 if not — both are valid outcomes that prove
|
|
73
|
+
// the wrapper successfully resolved jiti and re-execed cli.ts). What
|
|
74
|
+
// we DO care about: (a) no jiti-miss error on stderr, (b) cli.ts
|
|
75
|
+
// produced its own "Dashboard server" output (running OR not running).
|
|
76
|
+
const result = spawnSync(process.execPath, [wrapperPath, "status"], {
|
|
77
|
+
encoding: "utf-8",
|
|
78
|
+
timeout: 30_000,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.stderr).not.toContain("pi-dashboard: cannot find jiti");
|
|
82
|
+
expect(result.stdout).toMatch(/Dashboard server/i);
|
|
83
|
+
}, 60_000);
|
|
84
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `useRpcKeeper: true` branch in `spawnHeadless` (Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* Drives `spawnPiSession({strategy: "headless"})` with the keeper-flag
|
|
5
|
+
* override on, an injected fake KeeperManager, and verifies:
|
|
6
|
+
* - keeper branch fires (KeeperManager.spawnKeeperFor called, NOT pi resolved)
|
|
7
|
+
* - returned SpawnResult.pid is the keeper PID
|
|
8
|
+
* - env passed to the keeper includes `PI_DASHBOARD_SPAWN_TOKEN`
|
|
9
|
+
* - keeper failure surfaces as `PI_CRASHED` or `SPAWN_ERRNO`
|
|
10
|
+
* - flag OFF (default) → keeper is NOT used
|
|
11
|
+
*/
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
import type {
|
|
17
|
+
KeeperManager,
|
|
18
|
+
KeeperSpawnResult,
|
|
19
|
+
} from "../rpc-keeper/keeper-manager.js";
|
|
20
|
+
import {
|
|
21
|
+
setKeeperManager,
|
|
22
|
+
_setUseRpcKeeperOverrideForTests,
|
|
23
|
+
spawnPiSession,
|
|
24
|
+
} from "../process-manager.js";
|
|
25
|
+
|
|
26
|
+
class FakeKeeperChild extends EventEmitter {
|
|
27
|
+
pid: number;
|
|
28
|
+
unref = vi.fn();
|
|
29
|
+
kill = vi.fn();
|
|
30
|
+
// Never emits "exit" → waitForNoCrash window completes cleanly.
|
|
31
|
+
constructor(pid: number) { super(); this.pid = pid; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface FakeKeeperManagerState {
|
|
35
|
+
spawnCalls: Array<{ sessionId: string; cwd: string; env: NodeJS.ProcessEnv; piArgs?: string[] }>;
|
|
36
|
+
writeCalls: Array<{ sessionId: string; line: string }>;
|
|
37
|
+
killCalls: string[];
|
|
38
|
+
spawnResult: KeeperSpawnResult;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeFakeKeeperManager(
|
|
42
|
+
state: Partial<FakeKeeperManagerState> & { spawnResult: KeeperSpawnResult },
|
|
43
|
+
): { km: KeeperManager; state: FakeKeeperManagerState } {
|
|
44
|
+
const full: FakeKeeperManagerState = {
|
|
45
|
+
spawnCalls: state.spawnCalls ?? [],
|
|
46
|
+
writeCalls: state.writeCalls ?? [],
|
|
47
|
+
killCalls: state.killCalls ?? [],
|
|
48
|
+
spawnResult: state.spawnResult,
|
|
49
|
+
};
|
|
50
|
+
const km: KeeperManager = {
|
|
51
|
+
sessionsDir: "/fake/sessions",
|
|
52
|
+
spawnKeeperFor: async (sessionId, cwd, env, piArgs) => {
|
|
53
|
+
full.spawnCalls.push({ sessionId, cwd, env, piArgs });
|
|
54
|
+
return full.spawnResult;
|
|
55
|
+
},
|
|
56
|
+
writeRpc: async (sessionId, line) => {
|
|
57
|
+
full.writeCalls.push({ sessionId, line });
|
|
58
|
+
return true;
|
|
59
|
+
},
|
|
60
|
+
writeRpcToSockPath: async (_sockPath, _line) => true,
|
|
61
|
+
killKeeper: (sessionId) => {
|
|
62
|
+
full.killCalls.push(sessionId);
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
discoverExistingKeepers: async () => [],
|
|
66
|
+
};
|
|
67
|
+
return { km, state: full };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let tmpCwd: string;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
tmpCwd = mkdtempSync(path.join("/tmp", "km-cwd-"));
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
setKeeperManager(null);
|
|
77
|
+
_setUseRpcKeeperOverrideForTests(null);
|
|
78
|
+
rmSync(tmpCwd, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("spawnHeadless (useRpcKeeper: true)", () => {
|
|
82
|
+
it("routes through KeeperManager when flag is on", async () => {
|
|
83
|
+
const fakeChild = new FakeKeeperChild(11111);
|
|
84
|
+
const { km, state } = makeFakeKeeperManager({
|
|
85
|
+
spawnResult: {
|
|
86
|
+
success: true,
|
|
87
|
+
pid: 11111,
|
|
88
|
+
sockPath: "/fake/sessions/sid.rpc.sock",
|
|
89
|
+
process: fakeChild as unknown as import("node:child_process").ChildProcess,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
setKeeperManager(km);
|
|
93
|
+
_setUseRpcKeeperOverrideForTests(true);
|
|
94
|
+
|
|
95
|
+
const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
|
|
96
|
+
|
|
97
|
+
expect(result.success).toBe(true);
|
|
98
|
+
expect(result.pid).toBe(11111);
|
|
99
|
+
expect(state.spawnCalls).toHaveLength(1);
|
|
100
|
+
expect(state.spawnCalls[0].cwd).toBe(tmpCwd);
|
|
101
|
+
|
|
102
|
+
// spawnToken contract (task 5.3): the env passed to the keeper carries
|
|
103
|
+
// PI_DASHBOARD_SPAWN_TOKEN, which the keeper forwards to pi via
|
|
104
|
+
// process.env inheritance.
|
|
105
|
+
expect(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN).toBeDefined();
|
|
106
|
+
expect(typeof state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN).toBe("string");
|
|
107
|
+
expect(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN!.length).toBeGreaterThan(0);
|
|
108
|
+
|
|
109
|
+
// The returned spawnToken matches what was injected into env.
|
|
110
|
+
expect(result.spawnToken).toBe(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN);
|
|
111
|
+
|
|
112
|
+
// Bare-spawn piArgs are at least `--mode rpc`.
|
|
113
|
+
expect(state.spawnCalls[0].piArgs).toBeDefined();
|
|
114
|
+
expect(state.spawnCalls[0].piArgs).toContain("--mode");
|
|
115
|
+
expect(state.spawnCalls[0].piArgs).toContain("rpc");
|
|
116
|
+
|
|
117
|
+
// SpawnResult.keeperSockPath populated so callers can pass it to
|
|
118
|
+
// `headlessPidRegistry.register(..., {keeperPid, keeperSockPath})`
|
|
119
|
+
// (Phase 6 contract). See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
120
|
+
expect(result.keeperSockPath).toBe("/fake/sessions/sid.rpc.sock");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("forwards resume flags (sessionFile / mode) to the keeper as piArgs", async () => {
|
|
124
|
+
const fakeChild = new FakeKeeperChild(33333);
|
|
125
|
+
const { km, state } = makeFakeKeeperManager({
|
|
126
|
+
spawnResult: {
|
|
127
|
+
success: true,
|
|
128
|
+
pid: 33333,
|
|
129
|
+
sockPath: "/fake/x.sock",
|
|
130
|
+
process: fakeChild as unknown as import("node:child_process").ChildProcess,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
setKeeperManager(km);
|
|
134
|
+
_setUseRpcKeeperOverrideForTests(true);
|
|
135
|
+
|
|
136
|
+
const sessionFile = "/tmp/sess-resume.jsonl";
|
|
137
|
+
const result = await spawnPiSession(tmpCwd, {
|
|
138
|
+
strategy: "headless",
|
|
139
|
+
sessionFile,
|
|
140
|
+
mode: "continue",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(result.success).toBe(true);
|
|
144
|
+
expect(state.spawnCalls).toHaveLength(1);
|
|
145
|
+
const piArgs = state.spawnCalls[0].piArgs ?? [];
|
|
146
|
+
// piArgs MUST carry the session-file flag so resume actually resumes
|
|
147
|
+
// (regression guard: in the first Phase-5 cut the keeper hardcoded
|
|
148
|
+
// ["--mode","rpc"] and resume created a fresh session instead).
|
|
149
|
+
expect(piArgs).toContain("--mode");
|
|
150
|
+
expect(piArgs).toContain("rpc");
|
|
151
|
+
// sessionFlagsToArgv emits the session-file path; the exact flag name
|
|
152
|
+
// (`--session-file`) is verified in spawn-mechanism unit tests; here
|
|
153
|
+
// we only assert the path token is present so we don't double-bind to
|
|
154
|
+
// upstream argv shape.
|
|
155
|
+
expect(piArgs).toContain(sessionFile);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("returns SPAWN_ERRNO when KeeperManager.spawnKeeperFor reports !success", async () => {
|
|
159
|
+
const { km } = makeFakeKeeperManager({
|
|
160
|
+
spawnResult: { success: false, error: "EACCES on socket bind" },
|
|
161
|
+
});
|
|
162
|
+
setKeeperManager(km);
|
|
163
|
+
_setUseRpcKeeperOverrideForTests(true);
|
|
164
|
+
|
|
165
|
+
const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
|
|
166
|
+
expect(result.success).toBe(false);
|
|
167
|
+
expect(result.code).toBe("SPAWN_ERRNO");
|
|
168
|
+
expect(result.message).toMatch(/RPC keeper/);
|
|
169
|
+
expect(result.message).toMatch(/EACCES/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("returns PI_CRASHED when keeper exits within the crash window", async () => {
|
|
173
|
+
// A child that emits "exit" inside 300 ms triggers the waitForNoCrash gate.
|
|
174
|
+
const fakeChild = new FakeKeeperChild(22222);
|
|
175
|
+
setTimeout(() => fakeChild.emit("exit", 1, null), 20);
|
|
176
|
+
|
|
177
|
+
const { km } = makeFakeKeeperManager({
|
|
178
|
+
spawnResult: {
|
|
179
|
+
success: true,
|
|
180
|
+
pid: 22222,
|
|
181
|
+
sockPath: "/fake/sessions/sid.rpc.sock",
|
|
182
|
+
process: fakeChild as unknown as import("node:child_process").ChildProcess,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
setKeeperManager(km);
|
|
186
|
+
_setUseRpcKeeperOverrideForTests(true);
|
|
187
|
+
|
|
188
|
+
const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
|
|
189
|
+
expect(result.success).toBe(false);
|
|
190
|
+
expect(result.code).toBe("PI_CRASHED");
|
|
191
|
+
expect(result.message).toMatch(/crash window/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("does NOT route through KeeperManager when flag is off (default)", async () => {
|
|
195
|
+
const { km, state } = makeFakeKeeperManager({
|
|
196
|
+
spawnResult: { success: true, pid: 99999, sockPath: "/fake/x.sock" },
|
|
197
|
+
});
|
|
198
|
+
setKeeperManager(km);
|
|
199
|
+
_setUseRpcKeeperOverrideForTests(false);
|
|
200
|
+
|
|
201
|
+
// We don't care about the actual headless spawn result here — only that
|
|
202
|
+
// it does NOT call the fake KeeperManager.
|
|
203
|
+
await spawnPiSession(tmpCwd, { strategy: "headless" });
|
|
204
|
+
expect(state.spawnCalls).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for recursion guard wired into PUT /api/providers (task 10.4).
|
|
3
|
+
*
|
|
4
|
+
* Verifies:
|
|
5
|
+
* - Self-pointing baseUrl → 400 with code RECURSIVE_PROXY
|
|
6
|
+
* - Valid external baseUrl → accepted (2xx, written to disk)
|
|
7
|
+
* - Existing providers untouched on validation failure
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import Fastify from "fastify";
|
|
11
|
+
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { registerProviderRoutes } from "../routes/provider-routes.js";
|
|
15
|
+
|
|
16
|
+
const PROVIDERS_PATH = join(homedir(), ".pi", "agent", "providers.json");
|
|
17
|
+
const PROVIDERS_DIR = join(homedir(), ".pi", "agent");
|
|
18
|
+
|
|
19
|
+
// Back up / restore providers.json around each test
|
|
20
|
+
let backup: string | null = null;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
try { backup = require("fs").readFileSync(PROVIDERS_PATH, "utf-8"); } catch { backup = null; }
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
try {
|
|
26
|
+
if (backup !== null) {
|
|
27
|
+
writeFileSync(PROVIDERS_PATH, backup);
|
|
28
|
+
} else {
|
|
29
|
+
rmSync(PROVIDERS_PATH, { force: true });
|
|
30
|
+
}
|
|
31
|
+
} catch {}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function buildApp(port = 8000) {
|
|
35
|
+
const app = Fastify({ logger: false });
|
|
36
|
+
const networkGuard = async () => {};
|
|
37
|
+
mkdirSync(PROVIDERS_DIR, { recursive: true });
|
|
38
|
+
registerProviderRoutes(app, { networkGuard, port });
|
|
39
|
+
await app.ready();
|
|
40
|
+
return app;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("recursion guard on PUT /api/providers (task 10.4)", () => {
|
|
44
|
+
it("localhost self-pointing baseUrl → 400 RECURSIVE_PROXY", async () => {
|
|
45
|
+
const app = await buildApp(8000);
|
|
46
|
+
|
|
47
|
+
const res = await app.inject({
|
|
48
|
+
method: "PUT",
|
|
49
|
+
url: "/api/providers",
|
|
50
|
+
headers: { "content-type": "application/json" },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
providers: {
|
|
53
|
+
self: { baseUrl: "http://localhost:8000/v1", apiKey: "" },
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(res.statusCode).toBe(400);
|
|
59
|
+
const body = JSON.parse(res.body);
|
|
60
|
+
expect(body.code).toBe("RECURSIVE_PROXY");
|
|
61
|
+
expect(body.offendingBaseUrl).toBe("http://localhost:8000/v1");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("127.0.0.1 variant also caught", async () => {
|
|
65
|
+
const app = await buildApp(8000);
|
|
66
|
+
|
|
67
|
+
const res = await app.inject({
|
|
68
|
+
method: "PUT",
|
|
69
|
+
url: "/api/providers",
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
providers: {
|
|
73
|
+
self: { baseUrl: "http://127.0.0.1:8000/v1", apiKey: "" },
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
expect(res.statusCode).toBe(400);
|
|
79
|
+
expect(JSON.parse(res.body).code).toBe("RECURSIVE_PROXY");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("external baseUrl passes validation", async () => {
|
|
83
|
+
const app = await buildApp(8000);
|
|
84
|
+
|
|
85
|
+
const res = await app.inject({
|
|
86
|
+
method: "PUT",
|
|
87
|
+
url: "/api/providers",
|
|
88
|
+
headers: { "content-type": "application/json" },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
providers: {
|
|
91
|
+
openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-test" },
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Should succeed (200/204) or return a non-400 error
|
|
97
|
+
expect(res.statusCode).not.toBe(400);
|
|
98
|
+
const body = JSON.parse(res.body);
|
|
99
|
+
expect(body.code).not.toBe("RECURSIVE_PROXY");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("validation failure leaves existing providers untouched", async () => {
|
|
103
|
+
// Pre-populate providers.json with a valid provider
|
|
104
|
+
mkdirSync(PROVIDERS_DIR, { recursive: true });
|
|
105
|
+
writeFileSync(PROVIDERS_PATH, JSON.stringify({
|
|
106
|
+
providers: { openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-existing" } },
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const app = await buildApp(8000);
|
|
110
|
+
|
|
111
|
+
// Attempt to add a recursive provider — should fail
|
|
112
|
+
const res = await app.inject({
|
|
113
|
+
method: "PUT",
|
|
114
|
+
url: "/api/providers",
|
|
115
|
+
headers: { "content-type": "application/json" },
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
providers: {
|
|
118
|
+
openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-existing" },
|
|
119
|
+
self: { baseUrl: "http://localhost:8000/v1", apiKey: "" },
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(res.statusCode).toBe(400);
|
|
125
|
+
|
|
126
|
+
// Read providers.json — existing provider should still be there
|
|
127
|
+
const stored = JSON.parse(require("fs").readFileSync(PROVIDERS_PATH, "utf-8"));
|
|
128
|
+
expect(stored.providers?.openai?.baseUrl).toBe("https://api.openai.com/v1");
|
|
129
|
+
expect(stored.providers?.self).toBeUndefined();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -195,7 +195,7 @@ describe("GET /api/packages/recommended", () => {
|
|
|
195
195
|
return fastify;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
it("returns the
|
|
198
|
+
it("returns the 6 manifest entries with default (offline) descriptions", async () => {
|
|
199
199
|
vi.mocked(fetchPackageMeta).mockResolvedValue(null);
|
|
200
200
|
vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
|
|
201
201
|
await setupRoute();
|
|
@@ -208,7 +208,7 @@ describe("GET /api/packages/recommended", () => {
|
|
|
208
208
|
const body = JSON.parse(res.payload);
|
|
209
209
|
expect(body.success).toBe(true);
|
|
210
210
|
const entries = body.data.recommended;
|
|
211
|
-
expect(entries).toHaveLength(
|
|
211
|
+
expect(entries).toHaveLength(6);
|
|
212
212
|
// Every entry falls back to fallbackDescription and has no version.
|
|
213
213
|
for (const e of entries) {
|
|
214
214
|
expect(typeof e.description).toBe("string");
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
probeTunnel,
|
|
4
|
+
startTunnelWatchdog,
|
|
5
|
+
stopTunnelWatchdog,
|
|
6
|
+
getTunnelWatchdogStatus,
|
|
7
|
+
_runTickForTest,
|
|
8
|
+
_resetForTest,
|
|
9
|
+
} from "../tunnel-watchdog.js";
|
|
10
|
+
|
|
11
|
+
const URL = "https://abc.share.zrok.io";
|
|
12
|
+
|
|
13
|
+
function makeFetch(responses: Array<Response | Error>): typeof fetch {
|
|
14
|
+
let i = 0;
|
|
15
|
+
return (async () => {
|
|
16
|
+
const r = responses[Math.min(i, responses.length - 1)];
|
|
17
|
+
i += 1;
|
|
18
|
+
if (r instanceof Error) throw r;
|
|
19
|
+
return r;
|
|
20
|
+
}) as unknown as typeof fetch;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("probeTunnel", () => {
|
|
24
|
+
it("returns ok on 2xx", async () => {
|
|
25
|
+
const f = makeFetch([new Response("{}", { status: 200 })]);
|
|
26
|
+
expect(await probeTunnel(URL, 1000, f)).toEqual({ ok: true, status: 200 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns ok on 4xx (auth gate proves edge↔local works)", async () => {
|
|
30
|
+
const f = makeFetch([new Response("", { status: 401 })]);
|
|
31
|
+
expect(await probeTunnel(URL, 1000, f)).toEqual({ ok: true, status: 401 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns NOT ok on 5xx", async () => {
|
|
35
|
+
const f = makeFetch([new Response("bad gateway", { status: 502 })]);
|
|
36
|
+
const r = await probeTunnel(URL, 1000, f);
|
|
37
|
+
expect(r.ok).toBe(false);
|
|
38
|
+
expect(r.status).toBe(502);
|
|
39
|
+
expect(r.reason).toMatch(/502/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("returns NOT ok on network error", async () => {
|
|
43
|
+
const f = makeFetch([new Error("ENOTFOUND")]);
|
|
44
|
+
const r = await probeTunnel(URL, 1000, f);
|
|
45
|
+
expect(r.ok).toBe(false);
|
|
46
|
+
expect(r.reason).toMatch(/ENOTFOUND/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("watchdog lifecycle", () => {
|
|
51
|
+
beforeEach(() => { _resetForTest(); });
|
|
52
|
+
afterEach(() => { _resetForTest(); });
|
|
53
|
+
|
|
54
|
+
it("does not start when disabled", () => {
|
|
55
|
+
startTunnelWatchdog(
|
|
56
|
+
{ getUrl: () => URL, recycle: vi.fn(async () => URL) },
|
|
57
|
+
{ enabled: false },
|
|
58
|
+
);
|
|
59
|
+
expect(getTunnelWatchdogStatus()).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("recycles after threshold consecutive 5xx", async () => {
|
|
63
|
+
const recycle = vi.fn(async () => URL);
|
|
64
|
+
const fetchFn = makeFetch([
|
|
65
|
+
new Response("", { status: 502 }),
|
|
66
|
+
new Response("", { status: 502 }),
|
|
67
|
+
]);
|
|
68
|
+
startTunnelWatchdog(
|
|
69
|
+
{ getUrl: () => URL, recycle, fetchFn, log: () => {} },
|
|
70
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
71
|
+
);
|
|
72
|
+
await _runTickForTest();
|
|
73
|
+
expect(recycle).not.toHaveBeenCalled();
|
|
74
|
+
expect(getTunnelWatchdogStatus()?.consecutiveFailures).toBe(1);
|
|
75
|
+
|
|
76
|
+
await _runTickForTest();
|
|
77
|
+
expect(recycle).toHaveBeenCalledTimes(1);
|
|
78
|
+
const s = getTunnelWatchdogStatus()!;
|
|
79
|
+
expect(s.consecutiveFailures).toBe(0);
|
|
80
|
+
expect(s.recycleCount).toBe(1);
|
|
81
|
+
expect(s.lastRecycleAt).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not recycle on a single failure surrounded by success", async () => {
|
|
85
|
+
const recycle = vi.fn(async () => URL);
|
|
86
|
+
const fetchFn = makeFetch([
|
|
87
|
+
new Response("", { status: 200 }),
|
|
88
|
+
new Response("", { status: 502 }),
|
|
89
|
+
new Response("", { status: 200 }),
|
|
90
|
+
]);
|
|
91
|
+
startTunnelWatchdog(
|
|
92
|
+
{ getUrl: () => URL, recycle, fetchFn, log: () => {} },
|
|
93
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
94
|
+
);
|
|
95
|
+
await _runTickForTest();
|
|
96
|
+
await _runTickForTest();
|
|
97
|
+
await _runTickForTest();
|
|
98
|
+
expect(recycle).not.toHaveBeenCalled();
|
|
99
|
+
expect(getTunnelWatchdogStatus()?.consecutiveFailures).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("treats recycle failure as a no-op for stats but flags it for backoff", async () => {
|
|
103
|
+
const recycle = vi.fn(async () => null); // recycle returned no URL
|
|
104
|
+
const fetchFn = makeFetch([
|
|
105
|
+
new Response("", { status: 502 }),
|
|
106
|
+
new Response("", { status: 502 }),
|
|
107
|
+
]);
|
|
108
|
+
startTunnelWatchdog(
|
|
109
|
+
{ getUrl: () => URL, recycle, fetchFn, log: () => {} },
|
|
110
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
111
|
+
);
|
|
112
|
+
await _runTickForTest();
|
|
113
|
+
await _runTickForTest();
|
|
114
|
+
expect(recycle).toHaveBeenCalledTimes(1);
|
|
115
|
+
expect(getTunnelWatchdogStatus()?.recycleCount).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("skips probing when no tunnel URL", async () => {
|
|
119
|
+
const recycle = vi.fn(async () => URL);
|
|
120
|
+
const fetchFn = vi.fn();
|
|
121
|
+
startTunnelWatchdog(
|
|
122
|
+
{ getUrl: () => null, recycle, fetchFn: fetchFn as any, log: () => {} },
|
|
123
|
+
{ intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
|
|
124
|
+
);
|
|
125
|
+
await _runTickForTest();
|
|
126
|
+
expect(fetchFn).not.toHaveBeenCalled();
|
|
127
|
+
expect(recycle).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("stop clears state", () => {
|
|
131
|
+
startTunnelWatchdog(
|
|
132
|
+
{ getUrl: () => URL, recycle: vi.fn(async () => URL), log: () => {} },
|
|
133
|
+
{ intervalMs: 1000 },
|
|
134
|
+
);
|
|
135
|
+
expect(getTunnelWatchdogStatus()).not.toBeNull();
|
|
136
|
+
stopTunnelWatchdog();
|
|
137
|
+
expect(getTunnelWatchdogStatus()).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -253,6 +253,9 @@ export async function registerAuthPlugin(
|
|
|
253
253
|
// Skip health endpoint
|
|
254
254
|
if (request.url === "/api/health") return;
|
|
255
255
|
|
|
256
|
+
// Skip /v1/* — proxy auth gate handles those
|
|
257
|
+
if (request.url.startsWith("/v1/")) return;
|
|
258
|
+
|
|
256
259
|
// Skip configured bypass URL prefixes
|
|
257
260
|
if (isBypassed(request.url, authState.bypassUrls)) return;
|
|
258
261
|
|
|
@@ -69,6 +69,16 @@ export interface BootstrapState {
|
|
|
69
69
|
/** Package names that failed to install. */
|
|
70
70
|
failed: string[];
|
|
71
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* Legacy `@mariozechner/pi-coding-agent` installs detected on disk.
|
|
74
|
+
* Populated at server start and after every cleanup POST. See
|
|
75
|
+
* `legacy-pi-cleanup.ts`.
|
|
76
|
+
*/
|
|
77
|
+
legacyPiInstalls?: Array<{
|
|
78
|
+
scope: "npm-global" | "npx-cache" | "managed";
|
|
79
|
+
path: string;
|
|
80
|
+
version: string | null;
|
|
81
|
+
}>;
|
|
72
82
|
}
|
|
73
83
|
|
|
74
84
|
export type BootstrapListener = (state: BootstrapState) => void;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { WebSocketServer, WebSocket } from "ws";
|
|
6
6
|
import type {
|
|
7
7
|
ServerToBrowserMessage,
|
|
8
|
+
BrowserOpenSpecUpdateMessage,
|
|
8
9
|
BrowserToServerMessage,
|
|
9
10
|
} from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
|
|
10
11
|
import type { SessionManager } from "./memory-session-manager.js";
|
|
@@ -15,7 +16,7 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
|
|
|
15
16
|
import type { PendingForkRegistry } from "./pending-fork-registry.js";
|
|
16
17
|
import type { SessionOrderManager } from "./session-order-manager.js";
|
|
17
18
|
import type { PreferencesStore } from "./preferences-store.js";
|
|
18
|
-
import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
|
|
19
|
+
import { hasOpenSpecDir, hasOpenSpecRoot, type DirectoryService } from "./directory-service.js";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Pure helper: build the per-cwd `openspec_update` messages a freshly
|
|
@@ -31,23 +32,30 @@ import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
|
|
|
31
32
|
export function buildOpenSpecConnectSnapshot(
|
|
32
33
|
directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
|
|
33
34
|
hasDir: (cwd: string) => boolean,
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
hasRoot: (cwd: string) => boolean = hasDir,
|
|
36
|
+
): Array<BrowserOpenSpecUpdateMessage> {
|
|
37
|
+
const out: Array<BrowserOpenSpecUpdateMessage> = [];
|
|
36
38
|
for (const cwd of directoryService.knownDirectories()) {
|
|
37
39
|
const cached = directoryService.getOpenSpecData(cwd);
|
|
40
|
+
const root = hasRoot(cwd);
|
|
38
41
|
if (cached && cached.initialized) {
|
|
39
|
-
|
|
42
|
+
// Cached payload already carries `hasOpenspecDir` set by `pollOne`; if
|
|
43
|
+
// an old cache entry predates that field, fill it from the live probe.
|
|
44
|
+
const data = cached.hasOpenspecDir === undefined
|
|
45
|
+
? { ...cached, hasOpenspecDir: root }
|
|
46
|
+
: cached;
|
|
47
|
+
out.push({ type: "openspec_update", cwd, data });
|
|
40
48
|
} else if (hasDir(cwd)) {
|
|
41
49
|
out.push({
|
|
42
50
|
type: "openspec_update",
|
|
43
51
|
cwd,
|
|
44
|
-
data: { initialized: false, pending: true, changes: [] },
|
|
52
|
+
data: { initialized: false, pending: true, changes: [], hasOpenspecDir: root },
|
|
45
53
|
});
|
|
46
54
|
} else {
|
|
47
55
|
out.push({
|
|
48
56
|
type: "openspec_update",
|
|
49
57
|
cwd,
|
|
50
|
-
data: { initialized: false, pending: false, changes: [] },
|
|
58
|
+
data: { initialized: false, pending: false, changes: [], hasOpenspecDir: root },
|
|
51
59
|
});
|
|
52
60
|
}
|
|
53
61
|
}
|
|
@@ -272,7 +280,7 @@ export function createBrowserGateway(
|
|
|
272
280
|
// `openspec_update` per cwd, never silently omit.
|
|
273
281
|
// See change: fix-cold-boot-openspec-protocol.
|
|
274
282
|
if (directoryService) {
|
|
275
|
-
for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
|
|
283
|
+
for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir, hasOpenSpecRoot)) {
|
|
276
284
|
sendTo(ws, msg);
|
|
277
285
|
}
|
|
278
286
|
}
|