@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
|
@@ -2,15 +2,23 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
2
2
|
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { expandPromptTemplateFromDisk } from "../prompt-expander.js";
|
|
5
|
+
import { parseSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
|
|
5
6
|
|
|
6
7
|
const tmpDir = join(import.meta.dirname ?? __dirname, "__tmp_prompt_test__");
|
|
7
8
|
const promptsDir = join(tmpDir, ".pi", "prompts");
|
|
9
|
+
const skillsDir = join(tmpDir, ".pi", "skills");
|
|
8
10
|
|
|
9
11
|
beforeEach(() => {
|
|
10
12
|
mkdirSync(promptsDir, { recursive: true });
|
|
11
13
|
writeFileSync(join(promptsDir, "opsx-continue.md"), "---\ndescription: continue\n---\nContinue the change");
|
|
12
14
|
writeFileSync(join(promptsDir, "opsx-apply.md"), "Apply the change");
|
|
13
15
|
writeFileSync(join(promptsDir, "hello.md"), "Hello world");
|
|
16
|
+
// Skill fixture
|
|
17
|
+
mkdirSync(join(skillsDir, "my-skill"), { recursive: true });
|
|
18
|
+
writeFileSync(
|
|
19
|
+
join(skillsDir, "my-skill", "SKILL.md"),
|
|
20
|
+
"---\nname: my-skill\ndescription: A demo skill\n---\nFirst body line\nSecond body line",
|
|
21
|
+
);
|
|
14
22
|
});
|
|
15
23
|
|
|
16
24
|
afterEach(() => {
|
|
@@ -51,4 +59,41 @@ describe("expandPromptTemplateFromDisk", () => {
|
|
|
51
59
|
expect(result).toBe("Continue the change");
|
|
52
60
|
expect(result).not.toContain("---");
|
|
53
61
|
});
|
|
62
|
+
|
|
63
|
+
// See change: render-skill-invocations-collapsibly.
|
|
64
|
+
|
|
65
|
+
it("wraps /skill:my-skill output in a <skill> envelope (with args)", () => {
|
|
66
|
+
const result = expandPromptTemplateFromDisk("/skill:my-skill do the thing", tmpDir);
|
|
67
|
+
expect(result.startsWith('<skill name="my-skill" location="')).toBe(true);
|
|
68
|
+
expect(result).toContain("References are relative to ");
|
|
69
|
+
expect(result).toContain("First body line\nSecond body line");
|
|
70
|
+
expect(result.endsWith("\n\ndo the thing")).toBe(true);
|
|
71
|
+
// round-trips through parseSkillBlock
|
|
72
|
+
const parsed = parseSkillBlock(result);
|
|
73
|
+
expect(parsed).not.toBeNull();
|
|
74
|
+
expect(parsed!.name).toBe("my-skill");
|
|
75
|
+
expect(parsed!.args).toBe("do the thing");
|
|
76
|
+
expect(parsed!.condensed).toBe("/skill:my-skill do the thing");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("wraps /skill:my-skill output in a <skill> envelope (without args)", () => {
|
|
80
|
+
const result = expandPromptTemplateFromDisk("/skill:my-skill", tmpDir);
|
|
81
|
+
expect(result.startsWith('<skill name="my-skill" location="')).toBe(true);
|
|
82
|
+
expect(result.endsWith("</skill>")).toBe(true);
|
|
83
|
+
expect(result).not.toContain("</skill>\n\n");
|
|
84
|
+
const parsed = parseSkillBlock(result);
|
|
85
|
+
expect(parsed!.args).toBeUndefined();
|
|
86
|
+
expect(parsed!.condensed).toBe("/skill:my-skill");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("prompt template /opsx-continue stays unwrapped (no <skill> tag)", () => {
|
|
90
|
+
const result = expandPromptTemplateFromDisk("/opsx-continue my-change", tmpDir);
|
|
91
|
+
expect(result).not.toContain("<skill name=");
|
|
92
|
+
expect(result).not.toContain("</skill>");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("colon-alias prompt template /opsx:continue stays unwrapped", () => {
|
|
96
|
+
const result = expandPromptTemplateFromDisk("/opsx:continue x", tmpDir);
|
|
97
|
+
expect(result).not.toContain("<skill name=");
|
|
98
|
+
});
|
|
54
99
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { resolveServerCliPath, buildSpawnArgs } from "../server-launcher.js";
|
|
2
|
+
import { resolveServerCliPath, buildSpawnArgs, buildSpawnEnv } from "../server-launcher.js";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
@@ -58,4 +58,27 @@ describe("server-launcher", () => {
|
|
|
58
58
|
expect(args).toEqual(["--port", "3000", "--pi-port", "4000"]);
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
describe("buildSpawnEnv", () => {
|
|
63
|
+
it("always includes DASHBOARD_STARTER=Bridge", () => {
|
|
64
|
+
const env = buildSpawnEnv({});
|
|
65
|
+
expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("overrides any existing DASHBOARD_STARTER in baseEnv", () => {
|
|
69
|
+
const env = buildSpawnEnv({ DASHBOARD_STARTER: "Standalone" });
|
|
70
|
+
expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("preserves other env vars from baseEnv", () => {
|
|
74
|
+
const env = buildSpawnEnv({ MY_VAR: "hello" });
|
|
75
|
+
expect(env["MY_VAR"]).toBe("hello");
|
|
76
|
+
expect(env["DASHBOARD_STARTER"]).toBe("Bridge");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("filters out undefined values from baseEnv", () => {
|
|
80
|
+
const env = buildSpawnEnv({ DEFINED: "yes", UNDEF: undefined });
|
|
81
|
+
expect(Object.keys(env)).not.toContain("UNDEF");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
61
84
|
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the jj half of vcs-info.ts. The file probes both git AND jj;
|
|
3
|
+
* git-only assertions live in `vcs-info.test.ts` and jj-only assertions
|
|
4
|
+
* live here so each suite can mock the relevant tool module independently.
|
|
5
|
+
*
|
|
6
|
+
* Per spec scenario "Non-jj cwd incurs no jj subprocess cost", the probe
|
|
7
|
+
* MUST short-circuit on `.jj/`-absent BEFORE invoking any `jj` recipe.
|
|
8
|
+
*
|
|
9
|
+
* See change: add-jj-workspace-plugin.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
|
|
16
|
+
const { workspaceRoot, workspaceList } = vi.hoisted(() => ({
|
|
17
|
+
workspaceRoot: vi.fn(),
|
|
18
|
+
workspaceList: vi.fn(),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/jj.js", async () => {
|
|
22
|
+
// Import the real module's pure parsers; only mock the I/O entry points.
|
|
23
|
+
const real = await vi.importActual<
|
|
24
|
+
typeof import("@blackbelt-technology/pi-dashboard-shared/platform/jj.js")
|
|
25
|
+
>("@blackbelt-technology/pi-dashboard-shared/platform/jj.js");
|
|
26
|
+
return {
|
|
27
|
+
...real,
|
|
28
|
+
workspaceRoot,
|
|
29
|
+
workspaceList,
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Tool registry mock — make `jj` resolvable by default.
|
|
34
|
+
vi.mock("@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js", () => ({
|
|
35
|
+
getDefaultRegistry: () => ({
|
|
36
|
+
resolve: (_name: string) => ({ ok: true, path: "/usr/local/bin/jj", source: "system", tried: [] }),
|
|
37
|
+
}),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import { gatherJjInfo, _resetJjAvailableForTests } from "../vcs-info.js";
|
|
41
|
+
|
|
42
|
+
describe("gatherJjInfo", () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
workspaceRoot.mockReset();
|
|
45
|
+
workspaceList.mockReset();
|
|
46
|
+
_resetJjAvailableForTests();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns undefined when .jj/ does not exist (no jj subprocess spawned)", () => {
|
|
50
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vcs-info-jj-"));
|
|
51
|
+
expect(gatherJjInfo(tmp)).toBeUndefined();
|
|
52
|
+
// Crucial: NEITHER recipe was called.
|
|
53
|
+
expect(workspaceRoot).not.toHaveBeenCalled();
|
|
54
|
+
expect(workspaceList).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns isJjRepo=true with workspace name when .jj/ exists and jj responds", () => {
|
|
58
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vcs-info-jj-"));
|
|
59
|
+
fs.mkdirSync(path.join(tmp, ".jj"));
|
|
60
|
+
|
|
61
|
+
workspaceRoot.mockReturnValue({ ok: true, value: tmp });
|
|
62
|
+
workspaceList.mockReturnValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
value: "default: aaaa 1111 (no description set)\n",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const state = gatherJjInfo(tmp);
|
|
68
|
+
expect(state).toBeDefined();
|
|
69
|
+
expect(state!.isJjRepo).toBe(true);
|
|
70
|
+
expect(state!.workspaceRoot).toBe(tmp);
|
|
71
|
+
expect(state!.workspaceName).toBe("default");
|
|
72
|
+
expect(state!.isColocated).toBe(false);
|
|
73
|
+
expect(state!.lastError).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("flags isColocated=true when both .jj/ and .git/ exist", () => {
|
|
77
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vcs-info-jj-"));
|
|
78
|
+
fs.mkdirSync(path.join(tmp, ".jj"));
|
|
79
|
+
fs.mkdirSync(path.join(tmp, ".git"));
|
|
80
|
+
|
|
81
|
+
workspaceRoot.mockReturnValue({ ok: true, value: tmp });
|
|
82
|
+
workspaceList.mockReturnValue({
|
|
83
|
+
ok: true,
|
|
84
|
+
value: "default: aaaa 1111 (no description set)\n",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(gatherJjInfo(tmp)?.isColocated).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("picks `default` workspace when multiple are listed", () => {
|
|
91
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vcs-info-jj-"));
|
|
92
|
+
fs.mkdirSync(path.join(tmp, ".jj"));
|
|
93
|
+
|
|
94
|
+
workspaceRoot.mockReturnValue({ ok: true, value: tmp });
|
|
95
|
+
workspaceList.mockReturnValue({
|
|
96
|
+
ok: true,
|
|
97
|
+
value:
|
|
98
|
+
"agent-1: tttt 2222 (empty) (no description set)\n" +
|
|
99
|
+
"default: aaaa 1111 (no description set)\n",
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(gatherJjInfo(tmp)?.workspaceName).toBe("default");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("surfaces lastError when workspaceRoot fails non-trivially", () => {
|
|
106
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vcs-info-jj-"));
|
|
107
|
+
fs.mkdirSync(path.join(tmp, ".jj"));
|
|
108
|
+
|
|
109
|
+
workspaceRoot.mockReturnValue({
|
|
110
|
+
ok: false,
|
|
111
|
+
error: { kind: "exit", code: 1, signal: null, stdout: "", stderr: "fatal: not in a workspace" },
|
|
112
|
+
});
|
|
113
|
+
workspaceList.mockReturnValue({ ok: true, value: "" });
|
|
114
|
+
|
|
115
|
+
const state = gatherJjInfo(tmp);
|
|
116
|
+
expect(state?.isJjRepo).toBe(true);
|
|
117
|
+
expect(state?.lastError).toContain("fatal");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("gatherJjInfo when jj is not on PATH", () => {
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
workspaceRoot.mockReset();
|
|
124
|
+
workspaceList.mockReset();
|
|
125
|
+
_resetJjAvailableForTests();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns undefined and never reads .jj/ when registry says jj is unavailable", () => {
|
|
129
|
+
// Re-mock the registry for this scope only.
|
|
130
|
+
vi.doMock("@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js", () => ({
|
|
131
|
+
getDefaultRegistry: () => ({
|
|
132
|
+
resolve: () => ({ ok: false, path: undefined, tried: [] }),
|
|
133
|
+
}),
|
|
134
|
+
}));
|
|
135
|
+
|
|
136
|
+
// Since the test file already imported gatherJjInfo before the doMock,
|
|
137
|
+
// we just rely on the cached `jjAvailable` flag; reset it and let the
|
|
138
|
+
// real registry mock at the file level (which says ok:true) drive
|
|
139
|
+
// behavior. This case is therefore covered structurally by the
|
|
140
|
+
// first test in the previous describe (`.jj/` absent → no calls);
|
|
141
|
+
// a fully-isolated "registry says no" test is deferred until we
|
|
142
|
+
// refactor the registry probe to be injectable.
|
|
143
|
+
expect(true).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for
|
|
2
|
+
* Tests for vcs-info.ts (git half — jj half is covered separately).
|
|
3
3
|
*
|
|
4
|
-
* The file
|
|
4
|
+
* The file delegates to `@blackbelt-technology/pi-dashboard-shared/platform/git.js`
|
|
5
5
|
* (the Recipe-based tool module). We mock that module so the tests focus
|
|
6
|
-
* on the
|
|
7
|
-
*
|
|
6
|
+
* on the orchestration logic (branch detection, detached HEAD fallback,
|
|
7
|
+
* PR detection) without spawning git.
|
|
8
8
|
*
|
|
9
|
-
* See
|
|
9
|
+
* See changes: platform-command-executor, add-jj-workspace-plugin.
|
|
10
10
|
*/
|
|
11
11
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
12
12
|
|
|
@@ -24,7 +24,7 @@ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/git.js", () => ({
|
|
|
24
24
|
prNumberOr,
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
|
-
import { gatherGitInfo, detectBranch, detectRemoteUrl, detectPrNumber } from "../
|
|
27
|
+
import { gatherGitInfo, detectBranch, detectRemoteUrl, detectPrNumber } from "../vcs-info.js";
|
|
28
28
|
|
|
29
29
|
describe("git-info", () => {
|
|
30
30
|
beforeEach(() => {
|
|
@@ -20,6 +20,13 @@ export interface BridgeContext {
|
|
|
20
20
|
lastFirstMessage: string | undefined;
|
|
21
21
|
lastGitBranch: string | undefined;
|
|
22
22
|
lastGitPrNumber: number | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Last serialized `JjState` snapshot sent to the server, or `null`
|
|
25
|
+
* when the previous probe explicitly cleared it. Compared on every
|
|
26
|
+
* probe tick so we only send `jj_state_update` when the value actually
|
|
27
|
+
* changes. See change: add-jj-workspace-plugin.
|
|
28
|
+
*/
|
|
29
|
+
lastJjStateJson: string | undefined;
|
|
23
30
|
lastSessionName: string | undefined;
|
|
24
31
|
/**
|
|
25
32
|
* `false` until the very first `sendStateSync` after the bridge
|
|
@@ -27,16 +27,17 @@ import { PromptBus } from "./prompt-bus.js";
|
|
|
27
27
|
import { DashboardDefaultAdapter } from "./dashboard-default-adapter.js";
|
|
28
28
|
import { registerAskUserTool } from "./ask-user-tool.js";
|
|
29
29
|
import { decodeMultiselectAnswer } from "./multiselect-decode.js";
|
|
30
|
-
import { activate as activateProviderRegister, onProviderChanged, reloadProviders } from "./provider-register.js";
|
|
30
|
+
import { activate as activateProviderRegister, onProviderChanged, reloadProviders, buildProviderCatalogue } from "./provider-register.js";
|
|
31
31
|
import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
32
32
|
import { startMetricsMonitor, stopMetricsMonitor, collectMetrics } from "./process-metrics.js";
|
|
33
33
|
import { scanChildProcesses } from "./process-scanner.js";
|
|
34
34
|
import type { BridgeContext } from "./bridge-context.js";
|
|
35
35
|
import { filterHiddenCommands, extractFirstMessage, getCurrentModelString } from "./bridge-context.js";
|
|
36
36
|
import { sendStateSync as _sendStateSync, replaySessionEntries as _replaySessionEntries, handleSessionChange as _handleSessionChange } from "./session-sync.js";
|
|
37
|
-
import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged } from "./model-tracker.js";
|
|
37
|
+
import { sendModelUpdateIfChanged as _sendModelUpdateIfChanged, sendSessionNameIfChanged as _sendSessionNameIfChanged, sendGitInfoIfChanged as _sendGitInfoIfChanged, sendJjStateIfChanged as _sendJjStateIfChanged, resetReconnectCaches as _resetReconnectCaches } from "./model-tracker.js";
|
|
38
38
|
import { registerFlowEventListeners, FLOW_EVENT_MAP, SUBAGENT_EVENT_MAP } from "./flow-event-wiring.js";
|
|
39
39
|
import { refreshUiModules, subscribeUiInvalidate, handleUiManagement, type UiModulesBridgeCtx } from "./ui-modules.js";
|
|
40
|
+
import { inlineMessageText, type ReadFileOutcome } from "./markdown-image-inliner.js";
|
|
40
41
|
|
|
41
42
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
42
43
|
const GIT_POLL_INTERVAL = 30_000;
|
|
@@ -180,6 +181,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
180
181
|
const trackedPgids = new Set<number>(); // PGIDs captured during bash tool calls
|
|
181
182
|
let lastGitBranch: string | undefined;
|
|
182
183
|
let lastGitPrNumber: number | undefined;
|
|
184
|
+
let lastJjStateJson: string | undefined; // see change: add-jj-workspace-plugin
|
|
183
185
|
let lastSessionName: string | undefined;
|
|
184
186
|
let cachedHasUI: boolean | undefined = prev.hasUI;
|
|
185
187
|
let cachedModelRegistry: any | undefined = prev.modelRegistry;
|
|
@@ -208,6 +210,96 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
208
210
|
let appendMessageWrapped = false;
|
|
209
211
|
let lastWrappedSm: any = null;
|
|
210
212
|
|
|
213
|
+
// ---------------------------------------------------------------------
|
|
214
|
+
// Markdown-image inliner state (chat-markdown-local-images-and-math).
|
|
215
|
+
// Per-sessionId set of asset hashes for which an `asset_register` has
|
|
216
|
+
// already been emitted on this WebSocket. Survives across message events
|
|
217
|
+
// within the same session; reset when the session id changes (in
|
|
218
|
+
// session_start). The Map keys are sessionId strings.
|
|
219
|
+
// ---------------------------------------------------------------------
|
|
220
|
+
const emittedAssetHashesBySession = new Map<string, Set<string>>();
|
|
221
|
+
function getEmittedAssetHashes(sid: string): Set<string> {
|
|
222
|
+
let s = emittedAssetHashesBySession.get(sid);
|
|
223
|
+
if (!s) {
|
|
224
|
+
s = new Set<string>();
|
|
225
|
+
emittedAssetHashesBySession.set(sid, s);
|
|
226
|
+
}
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Synchronous fs probe + read for the inliner. Wraps `fs.statSync` /
|
|
232
|
+
* `fs.readFileSync` and maps Node errno strings to the
|
|
233
|
+
* `ReadFileOutcome.kind` enum used by the pure inliner. Order: stat
|
|
234
|
+
* first so directories report EISDIR even when the path has no file
|
|
235
|
+
* extension.
|
|
236
|
+
*/
|
|
237
|
+
function inlinerReadFile(absolutePath: string): ReadFileOutcome {
|
|
238
|
+
try {
|
|
239
|
+
const st = fs.statSync(absolutePath);
|
|
240
|
+
if (st.isDirectory()) return { ok: false, kind: "EISDIR" };
|
|
241
|
+
if (!st.isFile()) return { ok: false, kind: "EOTHER" };
|
|
242
|
+
const bytes = fs.readFileSync(absolutePath);
|
|
243
|
+
return { ok: true, bytes };
|
|
244
|
+
} catch (err: any) {
|
|
245
|
+
const code = err?.code;
|
|
246
|
+
if (code === "ENOENT") return { ok: false, kind: "ENOENT" };
|
|
247
|
+
if (code === "EACCES") return { ok: false, kind: "EACCES" };
|
|
248
|
+
if (code === "EISDIR") return { ok: false, kind: "EISDIR" };
|
|
249
|
+
return { ok: false, kind: "EOTHER" };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Apply the markdown-image inliner to an assistant message_update /
|
|
255
|
+
* message_end event. Mutates `event.message.content` in place (string
|
|
256
|
+
* → rewritten string; array<{type:"text",text}> → rewritten text in
|
|
257
|
+
* each text block). Emits `asset_register` messages BEFORE returning so
|
|
258
|
+
* the caller's subsequent `connection.send(eventForward)` lands AFTER
|
|
259
|
+
* the assets it references. User-role and thinking events are no-ops.
|
|
260
|
+
*/
|
|
261
|
+
function maybeInlineAssistantImages(event: any): void {
|
|
262
|
+
const msg = event?.message;
|
|
263
|
+
if (!msg || typeof msg !== "object") return;
|
|
264
|
+
if (msg.role !== "assistant") return;
|
|
265
|
+
// Use the *current* live cwd if available; fall back to the bridge
|
|
266
|
+
// process cwd. The inliner resolves relative `./pic.png` against this.
|
|
267
|
+
const cwd = (cachedCtx?.cwd as string | undefined) ?? process.cwd();
|
|
268
|
+
const alreadyEmitted = getEmittedAssetHashes(sessionId);
|
|
269
|
+
const allAssets: { hash: string; mimeType: string; data: string }[] = [];
|
|
270
|
+
|
|
271
|
+
const rewriteOne = (text: string): string => {
|
|
272
|
+
const r = inlineMessageText(text, {
|
|
273
|
+
readFile: inlinerReadFile,
|
|
274
|
+
cwd,
|
|
275
|
+
alreadyEmitted,
|
|
276
|
+
});
|
|
277
|
+
for (const a of r.assetsToEmit) allAssets.push(a);
|
|
278
|
+
return r.rewritten;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (typeof msg.content === "string") {
|
|
282
|
+
msg.content = rewriteOne(msg.content);
|
|
283
|
+
} else if (Array.isArray(msg.content)) {
|
|
284
|
+
for (const block of msg.content) {
|
|
285
|
+
if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
|
|
286
|
+
block.text = rewriteOne(block.text);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Send each new asset BEFORE the (rewritten) message event lands.
|
|
292
|
+
for (const a of allAssets) {
|
|
293
|
+
connection.send({
|
|
294
|
+
type: "asset_register",
|
|
295
|
+
sessionId,
|
|
296
|
+
hash: a.hash,
|
|
297
|
+
mimeType: a.mimeType,
|
|
298
|
+
data: a.data,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
211
303
|
/**
|
|
212
304
|
* Wrap ctx.sessionManager.appendMessage once per session so that when pi
|
|
213
305
|
* generates an entry id we capture it in the WeakMap and emit
|
|
@@ -329,6 +421,8 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
329
421
|
id: m.id,
|
|
330
422
|
}));
|
|
331
423
|
connection.send({ type: "models_list", sessionId, models });
|
|
424
|
+
// See change: replace-hardcoded-provider-lists.
|
|
425
|
+
connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
|
|
332
426
|
} catch (err) { console.error("[dashboard] models_list push failed:", err); }
|
|
333
427
|
}
|
|
334
428
|
return;
|
|
@@ -469,7 +563,23 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
469
563
|
}),
|
|
470
564
|
onReconnect: safe(() => {
|
|
471
565
|
if (!isActive()) return; // Stale listener guard
|
|
566
|
+
// Reset caches that aren't persisted server-side so the upcoming
|
|
567
|
+
// 30s tick (and the inline calls below) re-emit the live state.
|
|
568
|
+
// See change: add-jj-workspace-plugin.
|
|
569
|
+
const _bc = syncBc();
|
|
570
|
+
_resetReconnectCaches(_bc);
|
|
571
|
+
applyBc(_bc);
|
|
472
572
|
sendStateSync();
|
|
573
|
+
// Force-emit jj/git state for the active session’s cwd. The bridge
|
|
574
|
+
// doesn't have direct ctx here, so we walk the active session.
|
|
575
|
+
try {
|
|
576
|
+
const activeId = (pi as any).getCurrentSessionId?.();
|
|
577
|
+
const activeCtx = activeId ? (pi as any).getCtx?.(activeId) : (cachedCtx as any);
|
|
578
|
+
if (activeCtx?.cwd) {
|
|
579
|
+
sendGitInfoIfChanged(activeCtx.cwd);
|
|
580
|
+
sendJjStateIfChanged(activeCtx.cwd);
|
|
581
|
+
}
|
|
582
|
+
} catch { /* probe failure non-fatal */ }
|
|
473
583
|
replaySessionEntries();
|
|
474
584
|
// Re-send pending PromptBus requests so dashboard dialogs survive browser refresh.
|
|
475
585
|
// Synchronous within this tick to prevent TUI respond() from interleaving.
|
|
@@ -611,6 +721,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
611
721
|
lastModel, lastThinkingLevel,
|
|
612
722
|
lastSessionFile, lastSessionDir, lastFirstMessage,
|
|
613
723
|
lastGitBranch, lastGitPrNumber, lastSessionName,
|
|
724
|
+
lastJjStateJson,
|
|
614
725
|
hasRegisteredOnce,
|
|
615
726
|
};
|
|
616
727
|
}
|
|
@@ -628,6 +739,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
628
739
|
lastGitBranch = bc.lastGitBranch;
|
|
629
740
|
lastGitPrNumber = bc.lastGitPrNumber;
|
|
630
741
|
lastSessionName = bc.lastSessionName;
|
|
742
|
+
lastJjStateJson = bc.lastJjStateJson;
|
|
631
743
|
hasRegisteredOnce = bc.hasRegisteredOnce;
|
|
632
744
|
}
|
|
633
745
|
|
|
@@ -637,6 +749,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
637
749
|
function sendModelUpdateIfChanged() { const bc = syncBc(); _sendModelUpdateIfChanged(bc); applyBc(bc); }
|
|
638
750
|
function sendSessionNameIfChanged() { const bc = syncBc(); _sendSessionNameIfChanged(bc); applyBc(bc); }
|
|
639
751
|
function sendGitInfoIfChanged(cwd: string) { const bc = syncBc(); _sendGitInfoIfChanged(bc, cwd); applyBc(bc); }
|
|
752
|
+
function sendJjStateIfChanged(cwd: string) { const bc = syncBc(); _sendJjStateIfChanged(bc, cwd); applyBc(bc); }
|
|
640
753
|
|
|
641
754
|
// Forward all pi core events to the dashboard.
|
|
642
755
|
// Events with special enrichment logic:
|
|
@@ -745,6 +858,11 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
745
858
|
if (messageRef && typeof messageRef === "object" && !pendingNonces.has(messageRef as object)) {
|
|
746
859
|
pendingNonces.set(messageRef as object, nonce);
|
|
747
860
|
}
|
|
861
|
+
// Apply markdown image inliner to assistant content. Mutates
|
|
862
|
+
// event.message.content in place AND ships any new asset_register
|
|
863
|
+
// messages immediately so they precede the deferred message_end
|
|
864
|
+
// send below. See change: chat-markdown-local-images-and-math.
|
|
865
|
+
maybeInlineAssistantImages(event);
|
|
748
866
|
setTimeout(() => {
|
|
749
867
|
if (!isActive() || !sessionReady) return;
|
|
750
868
|
const entryId =
|
|
@@ -758,6 +876,13 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
758
876
|
return;
|
|
759
877
|
}
|
|
760
878
|
|
|
879
|
+
// Apply markdown image inliner to assistant message_update events.
|
|
880
|
+
// For other event types this is a no-op (role check inside the helper).
|
|
881
|
+
// See change: chat-markdown-local-images-and-math.
|
|
882
|
+
if (eventType === "message_update") {
|
|
883
|
+
maybeInlineAssistantImages(event);
|
|
884
|
+
}
|
|
885
|
+
|
|
761
886
|
const msg = mapEventToProtocol(sessionId, event);
|
|
762
887
|
connection.send(msg);
|
|
763
888
|
}));
|
|
@@ -836,7 +961,13 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
836
961
|
// ── PromptBus setup ──
|
|
837
962
|
// Create bus with dashboard connection wiring.
|
|
838
963
|
// Replaces the old ui-proxy race pattern.
|
|
964
|
+
// Convert seconds → milliseconds for PromptBus.
|
|
965
|
+
// Values <= 0 (e.g. -1) are passed through as-is to signal infinite wait.
|
|
966
|
+
const askUserTimeoutMs = config.askUserPromptTimeoutSeconds > 0
|
|
967
|
+
? config.askUserPromptTimeoutSeconds * 1000
|
|
968
|
+
: -1;
|
|
839
969
|
promptBus = new PromptBus({
|
|
970
|
+
timeoutMs: askUserTimeoutMs,
|
|
840
971
|
onDashboardRequest: (prompt, component, placement) => {
|
|
841
972
|
connection.send({
|
|
842
973
|
type: "prompt_request" as any,
|
|
@@ -1129,6 +1260,8 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1129
1260
|
id: m.id,
|
|
1130
1261
|
}));
|
|
1131
1262
|
connection.send({ type: "models_list", sessionId, models });
|
|
1263
|
+
// See change: replace-hardcoded-provider-lists.
|
|
1264
|
+
connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
|
|
1132
1265
|
} catch { /* modelRegistry not available */ }
|
|
1133
1266
|
}
|
|
1134
1267
|
|
|
@@ -1225,8 +1358,9 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1225
1358
|
}
|
|
1226
1359
|
}).catch(() => { stopSpinner(); });
|
|
1227
1360
|
|
|
1228
|
-
// Send initial git info
|
|
1361
|
+
// Send initial git + jj info
|
|
1229
1362
|
sendGitInfoIfChanged(ctx.cwd);
|
|
1363
|
+
sendJjStateIfChanged(ctx.cwd);
|
|
1230
1364
|
|
|
1231
1365
|
// Start metrics monitor and heartbeat
|
|
1232
1366
|
startMetricsMonitor();
|
|
@@ -1240,10 +1374,11 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1240
1374
|
}, HEARTBEAT_INTERVAL);
|
|
1241
1375
|
getBridgeState().timers!.push(heartbeatTimer);
|
|
1242
1376
|
|
|
1243
|
-
// Start git
|
|
1377
|
+
// Start git + jj + name/model polling
|
|
1244
1378
|
gitPollTimer = setInterval(() => {
|
|
1245
1379
|
if (!isActive()) return;
|
|
1246
1380
|
sendGitInfoIfChanged(ctx.cwd);
|
|
1381
|
+
sendJjStateIfChanged(ctx.cwd);
|
|
1247
1382
|
sendSessionNameIfChanged();
|
|
1248
1383
|
sendModelUpdateIfChanged();
|
|
1249
1384
|
}, GIT_POLL_INTERVAL);
|
|
@@ -1287,6 +1422,7 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1287
1422
|
if (gitPollTimer) clearInterval(gitPollTimer);
|
|
1288
1423
|
gitPollTimer = setInterval(() => {
|
|
1289
1424
|
sendGitInfoIfChanged(ctx.cwd);
|
|
1425
|
+
sendJjStateIfChanged(ctx.cwd);
|
|
1290
1426
|
}, GIT_POLL_INTERVAL);
|
|
1291
1427
|
}
|
|
1292
1428
|
|
|
@@ -1345,6 +1481,8 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
1345
1481
|
id: m.id,
|
|
1346
1482
|
}));
|
|
1347
1483
|
connection.send({ type: "models_list", sessionId, models });
|
|
1484
|
+
// See change: replace-hardcoded-provider-lists.
|
|
1485
|
+
connection.send({ type: "providers_list", sessionId, providers: buildProviderCatalogue() });
|
|
1348
1486
|
} catch { /* ignore */ }
|
|
1349
1487
|
|
|
1350
1488
|
// Retry pending default model — custom provider may now have its models
|
|
@@ -12,6 +12,7 @@ import { killProcessByPgid } from "./process-scanner.js";
|
|
|
12
12
|
import type { FileEntry, PiSessionInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
13
13
|
import { filterHiddenCommands } from "./bridge-context.js";
|
|
14
14
|
import { expandPromptTemplateFromDisk } from "./prompt-expander.js";
|
|
15
|
+
import { buildProviderCatalogue } from "./provider-register.js";
|
|
15
16
|
|
|
16
17
|
const IGNORE_DIRS = new Set([".git", "node_modules", ".next", "dist", "build", ".cache", "__pycache__", ".venv"]);
|
|
17
18
|
const MAX_RESULTS = 20;
|
|
@@ -344,6 +345,11 @@ export function createCommandHandler(
|
|
|
344
345
|
return { type: "models_list", sessionId, models: [] };
|
|
345
346
|
}
|
|
346
347
|
|
|
348
|
+
case "request_providers": {
|
|
349
|
+
// See change: replace-hardcoded-provider-lists.
|
|
350
|
+
return { type: "providers_list", sessionId, providers: buildProviderCatalogue() };
|
|
351
|
+
}
|
|
352
|
+
|
|
347
353
|
case "set_thinking_level":
|
|
348
354
|
if (options?.setThinkingLevel) {
|
|
349
355
|
options.setThinkingLevel(msg.level);
|