@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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared extension-slash-command dispatch branch used by both bridge.ts
|
|
3
|
+
* (sessionPrompt callback) and command-handler.ts (slash else-arm fallback).
|
|
4
|
+
*
|
|
5
|
+
* Routing-step 9 from `command-routing` spec — three-way decision:
|
|
6
|
+
* - Path B: when `pi.dispatchCommand` is a function → call it directly.
|
|
7
|
+
* - Path C: when `pi.dispatchCommand` is absent AND the bridge runs inside a
|
|
8
|
+
* dashboard-spawned headless `pi --mode rpc` AND a `connection` is wired
|
|
9
|
+
* → emit `dispatch_extension_command` to the server (server forwards to
|
|
10
|
+
* the per-session RPC keeper UDS and emits the terminal command_feedback).
|
|
11
|
+
* - Path D (stopgap, last resort): `pi.dispatchCommand` absent AND the bridge
|
|
12
|
+
* is NOT headless (tmux / wt / unrecognized spawn shape) OR no `connection`
|
|
13
|
+
* was supplied → emit `command_feedback {status:"error"}` with a pi-version
|
|
14
|
+
* reminder.
|
|
15
|
+
*
|
|
16
|
+
* If `text` is NOT an extension command, return `false` so the caller can
|
|
17
|
+
* fall through to its existing template-expansion / sendUserMessage path.
|
|
18
|
+
*
|
|
19
|
+
* Guarantees: EXACTLY ONE `started` event AND EXACTLY ONE terminal event
|
|
20
|
+
* (`completed` xor `error`) per dispatch, across all three paths combined.
|
|
21
|
+
* Path C does NOT emit a terminal event — the server emits it.
|
|
22
|
+
*
|
|
23
|
+
* See change: fix-extension-slash-commands-in-dashboard,
|
|
24
|
+
* add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
25
|
+
*/
|
|
26
|
+
import crypto from "node:crypto";
|
|
27
|
+
import type { ExtensionToServerMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
|
|
28
|
+
import { hasDispatchCommand, isExtensionSlashCommand, isHeadlessRpcSession } from "./bridge-context.js";
|
|
29
|
+
|
|
30
|
+
export type FeedbackSink = (msg: ExtensionToServerMessage) => void;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Minimal connection surface used by Path C. Concrete implementation is
|
|
34
|
+
* `ConnectionManager` (`connection.ts`) but a structural type keeps this
|
|
35
|
+
* helper unit-testable without a real WebSocket.
|
|
36
|
+
*/
|
|
37
|
+
export interface DispatchConnection {
|
|
38
|
+
send(msg: ExtensionToServerMessage): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PI_071_REQUIRED =
|
|
42
|
+
"Extension slash commands cannot be dispatched from the dashboard yet — requires pi 0.71+ (`pi.dispatchCommand`). Invoke from the pi TUI, or use the extension's tools directly.";
|
|
43
|
+
|
|
44
|
+
function emitFeedback(
|
|
45
|
+
sink: FeedbackSink | undefined,
|
|
46
|
+
sessionId: string,
|
|
47
|
+
command: string,
|
|
48
|
+
status: "started" | "completed" | "error",
|
|
49
|
+
message?: string,
|
|
50
|
+
): void {
|
|
51
|
+
if (!sink) return;
|
|
52
|
+
sink({
|
|
53
|
+
type: "event_forward",
|
|
54
|
+
sessionId,
|
|
55
|
+
event: {
|
|
56
|
+
eventType: "command_feedback",
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
data: message === undefined ? { command, status } : { command, status, message },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Try to dispatch a slash command as an extension command.
|
|
65
|
+
*
|
|
66
|
+
* @returns `true` if the helper handled the text (extension command detected;
|
|
67
|
+
* dispatch attempted or stopgap emitted). The caller MUST NOT fall
|
|
68
|
+
* through to template expansion or `sendUserMessage`.
|
|
69
|
+
* @returns `false` if `text` is not an extension slash command. The caller
|
|
70
|
+
* SHOULD continue with its existing fallback path.
|
|
71
|
+
*/
|
|
72
|
+
export async function tryDispatchExtensionCommand(
|
|
73
|
+
pi: unknown,
|
|
74
|
+
text: string,
|
|
75
|
+
sessionId: string,
|
|
76
|
+
sink: FeedbackSink | undefined,
|
|
77
|
+
connection?: DispatchConnection,
|
|
78
|
+
): Promise<boolean> {
|
|
79
|
+
// Defensive: pi.getCommands() can throw on a stale ctx during dispose.
|
|
80
|
+
let commands: Array<{ name: string; source?: string }> = [];
|
|
81
|
+
try {
|
|
82
|
+
const got = (pi as any)?.getCommands?.();
|
|
83
|
+
if (Array.isArray(got)) commands = got;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.warn("[dashboard] getCommands stale on slash-dispatch", err);
|
|
86
|
+
return false; // fall through to existing path; preserve today's behavior
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!isExtensionSlashCommand(text, commands)) return false;
|
|
90
|
+
|
|
91
|
+
emitFeedback(sink, sessionId, text, "started");
|
|
92
|
+
|
|
93
|
+
// Path B (preferred when available): pi 0.71+ exposes dispatchCommand.
|
|
94
|
+
if (hasDispatchCommand(pi)) {
|
|
95
|
+
try {
|
|
96
|
+
await (pi as any).dispatchCommand(text, { streamingBehavior: "followUp" });
|
|
97
|
+
emitFeedback(sink, sessionId, text, "completed");
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
100
|
+
emitFeedback(sink, sessionId, text, "error", message);
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Path C: headless RPC session, dispatchCommand absent. Hand off to the
|
|
106
|
+
// server, which writes the line to the session's RPC keeper UDS and
|
|
107
|
+
// emits the terminal command_feedback. The bridge does NOT emit a
|
|
108
|
+
// terminal event for this path — that would duplicate the reducer's
|
|
109
|
+
// started→terminal upsert. See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
|
|
110
|
+
if (connection && isHeadlessRpcSession()) {
|
|
111
|
+
connection.send({
|
|
112
|
+
type: "dispatch_extension_command",
|
|
113
|
+
sessionId,
|
|
114
|
+
command: text,
|
|
115
|
+
requestId: crypto.randomUUID(),
|
|
116
|
+
});
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Path D (stopgap): no dispatchCommand and not headless (tmux / wt / unrecognized).
|
|
121
|
+
emitFeedback(sink, sessionId, text, "error", PI_071_REQUIRED);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pi-dashboard CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* The actual CLI is `../src/cli.ts`. This wrapper exists because a
|
|
6
|
+
* `#!/usr/bin/env` shebang cannot interpolate a dynamic `--import`
|
|
7
|
+
* loader path. The wrapper resolves jiti from pi's tree at runtime
|
|
8
|
+
* and re-execs Node with `--import <jiti-url> cli.ts <args>`.
|
|
9
|
+
*
|
|
10
|
+
* No tsx fallback: if jiti cannot be resolved, the wrapper exits 1
|
|
11
|
+
* with an install-hint pointing at pi. Mirrors the resolution shape
|
|
12
|
+
* in `packages/shared/src/resolve-jiti.ts` (cannot import the .ts
|
|
13
|
+
* module before a TS loader is registered, so the lookup is inlined).
|
|
14
|
+
*
|
|
15
|
+
* See change: replace-tsx-with-jiti.
|
|
16
|
+
*/
|
|
17
|
+
import { createRequire } from "node:module";
|
|
18
|
+
import { realpathSync } from "node:fs";
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const cliPath = resolve(here, "..", "src", "cli.ts");
|
|
25
|
+
|
|
26
|
+
// Mirrors packages/shared/src/resolve-jiti.ts JITI_PACKAGES.
|
|
27
|
+
const JITI_PACKAGES = ["jiti", "@mariozechner/jiti"];
|
|
28
|
+
|
|
29
|
+
/** Resolve pi's jiti register hook as a file:// URL. Returns null on miss. */
|
|
30
|
+
function resolveJitiUrl() {
|
|
31
|
+
const anchor = process.argv[1];
|
|
32
|
+
if (!anchor) return null;
|
|
33
|
+
let resolved;
|
|
34
|
+
try {
|
|
35
|
+
resolved = realpathSync(anchor);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const req = createRequire(resolved);
|
|
40
|
+
for (const pkg of JITI_PACKAGES) {
|
|
41
|
+
try {
|
|
42
|
+
const pkgJson = req.resolve(`${pkg}/package.json`);
|
|
43
|
+
const registerPath = join(dirname(pkgJson), "lib", "jiti-register.mjs");
|
|
44
|
+
return pathToFileURL(registerPath).href;
|
|
45
|
+
} catch {
|
|
46
|
+
/* try next */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const loader = resolveJitiUrl();
|
|
53
|
+
if (!loader) {
|
|
54
|
+
process.stderr.write(
|
|
55
|
+
"pi-dashboard: cannot find jiti. " +
|
|
56
|
+
"Install pi: 'npm install -g @earendil-works/pi-coding-agent'\n",
|
|
57
|
+
);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Mirrors shouldUrlWrapEntry() in packages/shared/src/platform/node-spawn.ts:
|
|
62
|
+
// jiti needs the entry URL-wrapped on Windows (Node rejects raw drive-letter
|
|
63
|
+
// paths for --import). POSIX takes the raw path.
|
|
64
|
+
const entry = process.platform === "win32" ? pathToFileURL(cliPath).href : cliPath;
|
|
65
|
+
|
|
66
|
+
const child = spawn(
|
|
67
|
+
process.execPath,
|
|
68
|
+
["--import", loader, entry, ...process.argv.slice(2)],
|
|
69
|
+
{ stdio: "inherit", windowsHide: true },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
child.on("exit", (code, signal) => {
|
|
73
|
+
if (signal) {
|
|
74
|
+
// Re-raise the signal so the parent shell sees the same exit reason.
|
|
75
|
+
process.kill(process.pid, signal);
|
|
76
|
+
} else {
|
|
77
|
+
process.exit(code ?? 0);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
child.on("error", (err) => {
|
|
82
|
+
process.stderr.write(`pi-dashboard: failed to spawn Node: ${err.message}\n`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blackbelt-technology/pi-dashboard-server",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Dashboard server for monitoring and interacting with pi agent sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
},
|
|
22
22
|
"main": "src/cli.ts",
|
|
23
23
|
"bin": {
|
|
24
|
-
"pi-dashboard": "
|
|
24
|
+
"pi-dashboard": "bin/pi-dashboard.mjs"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
+
"bin/",
|
|
27
28
|
"src/",
|
|
28
29
|
"scripts/"
|
|
29
30
|
],
|
|
@@ -31,9 +32,9 @@
|
|
|
31
32
|
"postinstall": "node scripts/fix-pty-permissions.cjs"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@blackbelt-technology/dashboard-plugin-runtime": "^0.5.
|
|
35
|
-
"@blackbelt-technology/pi-dashboard-extension": "^0.5.
|
|
36
|
-
"@blackbelt-technology/pi-dashboard-shared": "^0.5.
|
|
35
|
+
"@blackbelt-technology/dashboard-plugin-runtime": "^0.5.2",
|
|
36
|
+
"@blackbelt-technology/pi-dashboard-extension": "^0.5.2",
|
|
37
|
+
"@blackbelt-technology/pi-dashboard-shared": "^0.5.2",
|
|
37
38
|
"@fastify/compress": "^8.3.1",
|
|
38
39
|
"@fastify/cookie": "^11.0.2",
|
|
39
40
|
"@fastify/cors": "^11.0.0",
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix node-pty native prebuild permissions after npm install.
|
|
3
|
+
*
|
|
4
|
+
* The prebuilt `spawn-helper` (and occasionally `pty.node`) may be unpacked
|
|
5
|
+
* without the execute bit, which causes `posix_spawnp failed` errors when
|
|
6
|
+
* calling `pty.spawn(...)` on macOS/Linux.
|
|
7
|
+
*
|
|
8
|
+
* This script is hoist-aware: it locates `node-pty` via `require.resolve`
|
|
9
|
+
* rather than a hardcoded relative path, so it works whether the dependency
|
|
10
|
+
* is nested under a workspace package's node_modules or hoisted to the
|
|
11
|
+
* workspace root.
|
|
12
|
+
*/
|
|
13
|
+
const fs = require("fs");
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
|
|
17
|
+
if (os.platform() === "win32") process.exit(0);
|
|
18
|
+
|
|
19
|
+
let prebuildsDir;
|
|
20
|
+
try {
|
|
21
|
+
const ptyPkg = require.resolve("node-pty/package.json");
|
|
22
|
+
prebuildsDir = path.join(path.dirname(ptyPkg), "prebuilds");
|
|
23
|
+
} catch {
|
|
24
|
+
// node-pty not installed (e.g. running from a workspace that doesn't depend
|
|
25
|
+
// on it, or the package hasn't been installed yet). Silent no-op.
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let prebuildDirs;
|
|
30
|
+
try {
|
|
31
|
+
prebuildDirs = fs.readdirSync(prebuildsDir);
|
|
32
|
+
} catch {
|
|
33
|
+
// prebuilds dir missing — unusual, but not fatal. Silent no-op.
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const dir of prebuildDirs) {
|
|
38
|
+
for (const name of ["spawn-helper", "pty.node"]) {
|
|
39
|
+
const target = path.join(prebuildsDir, dir, name);
|
|
40
|
+
try {
|
|
41
|
+
fs.chmodSync(target, 0o755);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// Individual chmod failures are logged to stderr (not swallowed) so
|
|
44
|
+
// real problems become visible, but we still try remaining files.
|
|
45
|
+
if (err && err.code !== "ENOENT") {
|
|
46
|
+
process.stderr.write(
|
|
47
|
+
`[fix-pty-permissions] chmod ${target} failed: ${err.message}\n`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -76,27 +76,21 @@ describe("parseArgs", () => {
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
describe("daemon spawn jiti resolution", () => {
|
|
79
|
-
it("
|
|
80
|
-
// After change `
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// resolution
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
const {
|
|
87
|
-
"@blackbelt-technology/pi-dashboard-shared/
|
|
79
|
+
it("ToolResolver.resolveJiti either returns a file:// URL or null", async () => {
|
|
80
|
+
// After change `unify-server-launch-ts-loader`, jiti resolution
|
|
81
|
+
// is owned by `ToolResolver.resolveJiti()` which walks managed pi
|
|
82
|
+
// → system pi → anchor → argv. Vitest's transitive `jiti` dep
|
|
83
|
+
// makes resolution likely succeed under the test runner; either
|
|
84
|
+
// outcome is valid — we just assert the contract: success returns
|
|
85
|
+
// a `file://` URL, miss returns null (no throw).
|
|
86
|
+
const { ToolResolver } = await import(
|
|
87
|
+
"@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js"
|
|
88
88
|
);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
url = resolveJitiImport();
|
|
93
|
-
} catch (e) {
|
|
94
|
-
err = e as Error;
|
|
95
|
-
}
|
|
96
|
-
if (url !== undefined) {
|
|
89
|
+
const url = new ToolResolver().resolveJiti();
|
|
90
|
+
if (url !== null) {
|
|
97
91
|
expect(url.startsWith("file://")).toBe(true);
|
|
98
92
|
} else {
|
|
99
|
-
expect(
|
|
93
|
+
expect(url).toBeNull();
|
|
100
94
|
}
|
|
101
95
|
});
|
|
102
96
|
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `openspec.enabled` gating in DirectoryService.
|
|
3
|
+
*
|
|
4
|
+
* Confirms:
|
|
5
|
+
* - `refreshOpenSpec` short-circuits (no CLI spawn, returns cleared shape).
|
|
6
|
+
* - `pollDirectoryGated` short-circuits.
|
|
7
|
+
* - `scheduleOpenSpecTick` short-circuits.
|
|
8
|
+
* - `reconfigurePolling({ enabled: false })` clears every cached cwd and
|
|
9
|
+
* broadcasts `openspec_update` via the onChange callback.
|
|
10
|
+
*
|
|
11
|
+
* See change: auto-hide-empty-session-subcards.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
14
|
+
import { createDirectoryService, type DirectoryService } from "../directory-service.js";
|
|
15
|
+
import type { PreferencesStore } from "../preferences-store.js";
|
|
16
|
+
import type { SessionManager } from "../memory-session-manager.js";
|
|
17
|
+
import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
18
|
+
import { DEFAULT_OPENSPEC_POLL } from "@blackbelt-technology/pi-dashboard-shared/config.js";
|
|
19
|
+
|
|
20
|
+
// Mock CLI entry points so we can spy on whether they get called.
|
|
21
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js", async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal<
|
|
23
|
+
typeof import("@blackbelt-technology/pi-dashboard-shared/openspec-poller.js")
|
|
24
|
+
>();
|
|
25
|
+
return {
|
|
26
|
+
...actual,
|
|
27
|
+
pollOpenSpecAsync: vi.fn(async () => ({ initialized: true, changes: [] })),
|
|
28
|
+
runOpenSpecList: vi.fn(async () => null),
|
|
29
|
+
runOpenSpecStatus: vi.fn(async () => null),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
vi.mock("../pi-resource-scanner.js", () => ({
|
|
34
|
+
scanPiResources: vi.fn(async () => ({
|
|
35
|
+
local: { extensions: [], skills: [], prompts: [] },
|
|
36
|
+
global: { extensions: [], skills: [], prompts: [] },
|
|
37
|
+
packages: [],
|
|
38
|
+
})),
|
|
39
|
+
}));
|
|
40
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/state-replay.js", () => ({
|
|
41
|
+
replayEntriesAsEvents: vi.fn(() => []),
|
|
42
|
+
}));
|
|
43
|
+
vi.mock("../session-discovery.js", () => ({
|
|
44
|
+
discoverSessionsForCwd: vi.fn(() => []),
|
|
45
|
+
}));
|
|
46
|
+
vi.mock("../session-file-reader.js", () => ({
|
|
47
|
+
loadSessionEntries: vi.fn(() => []),
|
|
48
|
+
}));
|
|
49
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
50
|
+
SessionManager: {
|
|
51
|
+
list: vi.fn(async () => []),
|
|
52
|
+
open: vi.fn(() => ({ getBranch: vi.fn(() => []) })),
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
function makePrefs(pinnedDirs: string[] = []): PreferencesStore {
|
|
57
|
+
return {
|
|
58
|
+
getPinnedDirectories: () => pinnedDirs,
|
|
59
|
+
getSessionOrder: () => ({}),
|
|
60
|
+
setSessionOrder: vi.fn(),
|
|
61
|
+
setPinnedDirectories: vi.fn(),
|
|
62
|
+
pinDirectory: vi.fn(),
|
|
63
|
+
unpinDirectory: vi.fn(),
|
|
64
|
+
reorderPinnedDirs: vi.fn(),
|
|
65
|
+
flush: vi.fn(),
|
|
66
|
+
dispose: vi.fn(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function makeSessionMgr(sessions: DashboardSession[] = []): SessionManager {
|
|
70
|
+
const map = new Map<string, DashboardSession>();
|
|
71
|
+
for (const s of sessions) map.set(s.id, s);
|
|
72
|
+
return {
|
|
73
|
+
register: vi.fn(),
|
|
74
|
+
restore: vi.fn(),
|
|
75
|
+
unregister: vi.fn(),
|
|
76
|
+
update: vi.fn(),
|
|
77
|
+
get: (id: string) => map.get(id),
|
|
78
|
+
listActive: () => Array.from(map.values()).filter(s => s.status !== "ended"),
|
|
79
|
+
listAll: () => Array.from(map.values()),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("DirectoryService — openspec.enabled gate", () => {
|
|
84
|
+
let service: DirectoryService;
|
|
85
|
+
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
vi.clearAllMocks();
|
|
88
|
+
});
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
service?.stopPolling();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refreshOpenSpec returns cleared shape and spawns no CLI when disabled", async () => {
|
|
94
|
+
const prefs = makePrefs(["/repo"]);
|
|
95
|
+
const sessMgr = makeSessionMgr();
|
|
96
|
+
service = createDirectoryService(prefs, sessMgr, { ...DEFAULT_OPENSPEC_POLL, enabled: false });
|
|
97
|
+
|
|
98
|
+
const { pollOpenSpecAsync, runOpenSpecList, runOpenSpecStatus } = await import(
|
|
99
|
+
"@blackbelt-technology/pi-dashboard-shared/openspec-poller.js"
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const data = await service.refreshOpenSpec("/repo");
|
|
103
|
+
expect(data).toEqual({
|
|
104
|
+
initialized: false,
|
|
105
|
+
pending: false,
|
|
106
|
+
changes: [],
|
|
107
|
+
hasOpenspecDir: false,
|
|
108
|
+
});
|
|
109
|
+
expect(pollOpenSpecAsync).not.toHaveBeenCalled();
|
|
110
|
+
expect(runOpenSpecList).not.toHaveBeenCalled();
|
|
111
|
+
expect(runOpenSpecStatus).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("pollDirectoryGated returns cleared shape and spawns no CLI when disabled", async () => {
|
|
115
|
+
const prefs = makePrefs();
|
|
116
|
+
const sessMgr = makeSessionMgr();
|
|
117
|
+
service = createDirectoryService(prefs, sessMgr, { ...DEFAULT_OPENSPEC_POLL, enabled: false });
|
|
118
|
+
|
|
119
|
+
const { runOpenSpecList } = await import(
|
|
120
|
+
"@blackbelt-technology/pi-dashboard-shared/openspec-poller.js"
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const data = await service.pollDirectoryGated("/repo");
|
|
124
|
+
expect(data).toEqual({
|
|
125
|
+
initialized: false,
|
|
126
|
+
pending: false,
|
|
127
|
+
changes: [],
|
|
128
|
+
hasOpenspecDir: false,
|
|
129
|
+
});
|
|
130
|
+
expect(runOpenSpecList).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("reconfigurePolling({ enabled: false }) broadcasts cleared payload for every cached cwd", async () => {
|
|
134
|
+
const prefs = makePrefs(["/a", "/b"]);
|
|
135
|
+
const sessMgr = makeSessionMgr();
|
|
136
|
+
service = createDirectoryService(prefs, sessMgr); // starts enabled
|
|
137
|
+
|
|
138
|
+
// Seed the cache by calling refresh while enabled.
|
|
139
|
+
const { runOpenSpecList, runOpenSpecStatus } = await import(
|
|
140
|
+
"@blackbelt-technology/pi-dashboard-shared/openspec-poller.js"
|
|
141
|
+
);
|
|
142
|
+
(runOpenSpecList as any).mockResolvedValue({
|
|
143
|
+
mtimeMs: 1,
|
|
144
|
+
result: { changes: [], specs: [] },
|
|
145
|
+
});
|
|
146
|
+
(runOpenSpecStatus as any).mockResolvedValue(null);
|
|
147
|
+
await service.refreshOpenSpec("/a");
|
|
148
|
+
await service.refreshOpenSpec("/b");
|
|
149
|
+
|
|
150
|
+
expect(service.getOpenSpecData("/a")).toBeDefined();
|
|
151
|
+
expect(service.getOpenSpecData("/b")).toBeDefined();
|
|
152
|
+
|
|
153
|
+
// Wire the broadcast callback then flip the master gate.
|
|
154
|
+
const broadcasts: Array<{ cwd: string; data: unknown }> = [];
|
|
155
|
+
service.startPolling((cwd, data) => broadcasts.push({ cwd, data }));
|
|
156
|
+
|
|
157
|
+
service.reconfigurePolling({ ...DEFAULT_OPENSPEC_POLL, enabled: false });
|
|
158
|
+
|
|
159
|
+
const cleared = { initialized: false, pending: false, changes: [], hasOpenspecDir: false };
|
|
160
|
+
const cwds = new Set(broadcasts.map(b => b.cwd));
|
|
161
|
+
expect(cwds.has("/a")).toBe(true);
|
|
162
|
+
expect(cwds.has("/b")).toBe(true);
|
|
163
|
+
for (const b of broadcasts) {
|
|
164
|
+
expect(b.data).toEqual(cleared);
|
|
165
|
+
}
|
|
166
|
+
expect(service.getOpenSpecData("/a")).toEqual(cleared);
|
|
167
|
+
expect(service.getOpenSpecData("/b")).toEqual(cleared);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("no broadcast on disabled→disabled or enabled→enabled reconfiguration", async () => {
|
|
171
|
+
const prefs = makePrefs(["/a"]);
|
|
172
|
+
const sessMgr = makeSessionMgr();
|
|
173
|
+
service = createDirectoryService(prefs, sessMgr, { ...DEFAULT_OPENSPEC_POLL, enabled: false });
|
|
174
|
+
|
|
175
|
+
const broadcasts: Array<{ cwd: string }> = [];
|
|
176
|
+
service.startPolling((cwd) => broadcasts.push({ cwd }));
|
|
177
|
+
|
|
178
|
+
// disabled → disabled with new interval — should not trigger the
|
|
179
|
+
// disable-broadcast path.
|
|
180
|
+
service.reconfigurePolling({
|
|
181
|
+
...DEFAULT_OPENSPEC_POLL,
|
|
182
|
+
enabled: false,
|
|
183
|
+
pollIntervalSeconds: 90,
|
|
184
|
+
});
|
|
185
|
+
expect(broadcasts).toHaveLength(0);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -633,7 +633,7 @@ describe("DirectoryService", () => {
|
|
|
633
633
|
const sessionManager = createMockSessionManager();
|
|
634
634
|
service = createDirectoryService(stateStore, sessionManager);
|
|
635
635
|
await service.refreshOpenSpec("/x");
|
|
636
|
-
service.reconfigurePolling({ pollIntervalSeconds: 60, maxConcurrentSpawns: 5, changeDetection: "mtime", jitterSeconds: 0 });
|
|
636
|
+
service.reconfigurePolling({ enabled: true, pollIntervalSeconds: 60, maxConcurrentSpawns: 5, changeDetection: "mtime", jitterSeconds: 0 });
|
|
637
637
|
expect(service.getOpenSpecData("/x")).toBeDefined();
|
|
638
638
|
});
|
|
639
639
|
});
|