@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/AGENTS.md +80 -32
  2. package/README.md +7 -3
  3. package/docs/architecture.md +361 -12
  4. package/package.json +7 -7
  5. package/packages/extension/package.json +7 -2
  6. package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
  7. package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
  8. package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
  9. package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
  10. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
  11. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
  12. package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
  13. package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
  14. package/packages/extension/src/ask-user-tool.ts +165 -57
  15. package/packages/extension/src/bridge.ts +97 -4
  16. package/packages/extension/src/multiselect-decode.ts +40 -0
  17. package/packages/extension/src/multiselect-polyfill.ts +38 -8
  18. package/packages/extension/src/ui-modules.ts +272 -0
  19. package/packages/server/package.json +9 -3
  20. package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
  21. package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
  22. package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
  23. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
  24. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
  25. package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
  26. package/packages/server/src/__tests__/directory-service.test.ts +174 -0
  27. package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
  28. package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
  29. package/packages/server/src/__tests__/package-routes.test.ts +136 -3
  30. package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
  31. package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
  32. package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
  33. package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
  34. package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
  35. package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
  36. package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
  37. package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
  38. package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
  39. package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
  40. package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
  41. package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
  42. package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
  43. package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
  44. package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
  45. package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
  46. package/packages/server/src/browse.ts +118 -13
  47. package/packages/server/src/browser-gateway.ts +19 -0
  48. package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
  49. package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
  50. package/packages/server/src/browser-handlers/handler-context.ts +15 -0
  51. package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
  52. package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
  53. package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
  54. package/packages/server/src/cli.ts +5 -6
  55. package/packages/server/src/directory-service.ts +156 -15
  56. package/packages/server/src/event-wiring.ts +111 -10
  57. package/packages/server/src/installed-package-enricher.ts +143 -0
  58. package/packages/server/src/package-manager-wrapper.ts +305 -8
  59. package/packages/server/src/package-source-helpers.ts +104 -0
  60. package/packages/server/src/pending-attach-registry.ts +112 -0
  61. package/packages/server/src/pending-resume-intent-registry.ts +107 -0
  62. package/packages/server/src/pi-core-checker.ts +9 -14
  63. package/packages/server/src/pi-gateway.ts +14 -0
  64. package/packages/server/src/proposal-attach-naming.ts +47 -0
  65. package/packages/server/src/routes/file-routes.ts +29 -3
  66. package/packages/server/src/routes/package-routes.ts +72 -3
  67. package/packages/server/src/routes/plugin-config-routes.ts +129 -0
  68. package/packages/server/src/routes/system-routes.ts +2 -0
  69. package/packages/server/src/server.ts +339 -10
  70. package/packages/server/src/session-api.ts +30 -5
  71. package/packages/server/src/session-order-manager.ts +22 -0
  72. package/packages/server/src/session-scanner.ts +10 -1
  73. package/packages/shared/package.json +9 -2
  74. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
  75. package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
  76. package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
  77. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
  78. package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
  79. package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
  80. package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
  81. package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
  82. package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
  83. package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
  84. package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
  85. package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
  86. package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
  87. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +123 -0
  88. package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
  89. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  90. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  91. package/packages/shared/src/browser-protocol.ts +110 -4
  92. package/packages/shared/src/config.ts +45 -0
  93. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  94. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  95. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  96. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  97. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  98. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  99. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  100. package/packages/shared/src/openspec-poller.ts +117 -3
  101. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  102. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  103. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  104. package/packages/shared/src/protocol.ts +56 -2
  105. package/packages/shared/src/recommended-extensions.ts +7 -1
  106. package/packages/shared/src/rest-api.ts +68 -3
  107. package/packages/shared/src/state-replay.ts +11 -1
  108. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  109. package/packages/shared/src/types.ts +160 -0
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ registerPluginBridge,
7
+ deregisterPluginBridge,
8
+ listManagedBridges,
9
+ } from "../plugin-bridge-register.js";
10
+
11
+ let tmpDir: string;
12
+ let homedir: string;
13
+
14
+ beforeEach(() => {
15
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-bridge-test-"));
16
+ homedir = tmpDir;
17
+ });
18
+
19
+ afterEach(() => {
20
+ fs.rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ function settingsPath() {
24
+ return path.join(homedir, ".pi", "agent", "settings.json");
25
+ }
26
+
27
+ function readSettings(): Record<string, unknown> {
28
+ return JSON.parse(fs.readFileSync(settingsPath(), "utf-8"));
29
+ }
30
+
31
+ describe("registerPluginBridge", () => {
32
+ it("writes dashboard-<id> entry under dashboardPluginBridges", () => {
33
+ const result = registerPluginBridge("demo", "/path/to/demo/bridge.js", { homedir });
34
+ expect(result.type).toBe("ok");
35
+ const s = readSettings();
36
+ const managed = s.dashboardPluginBridges as Record<string, string>;
37
+ expect(managed["dashboard-demo"]).toBe("/path/to/demo/bridge.js");
38
+ });
39
+
40
+ it("returns ok when entry already matches (idempotent)", () => {
41
+ registerPluginBridge("demo", "/path/to/bridge.js", { homedir });
42
+ const result = registerPluginBridge("demo", "/path/to/bridge.js", { homedir });
43
+ expect(result.type).toBe("ok");
44
+ });
45
+
46
+ it("returns conflict when entry exists with different path", () => {
47
+ registerPluginBridge("demo", "/old/bridge.js", { homedir });
48
+ const result = registerPluginBridge("demo", "/new/bridge.js", { homedir });
49
+ expect(result.type).toBe("conflict");
50
+ if (result.type === "conflict") {
51
+ expect(result.existingPath).toBe("/old/bridge.js");
52
+ expect(result.newPath).toBe("/new/bridge.js");
53
+ }
54
+ // Should not overwrite
55
+ const s = readSettings();
56
+ const managed = s.dashboardPluginBridges as Record<string, string>;
57
+ expect(managed["dashboard-demo"]).toBe("/old/bridge.js");
58
+ });
59
+
60
+ it("never touches user-owned packages array", () => {
61
+ // Pre-populate settings with user packages
62
+ fs.mkdirSync(path.join(homedir, ".pi", "agent"), { recursive: true });
63
+ fs.writeFileSync(
64
+ settingsPath(),
65
+ JSON.stringify({ packages: ["/user/extension1", "/user/extension2"] }),
66
+ );
67
+
68
+ registerPluginBridge("demo", "/demo/bridge.js", { homedir });
69
+ const s = readSettings();
70
+ const pkgs = s.packages as string[];
71
+ expect(pkgs).toEqual(["/user/extension1", "/user/extension2"]);
72
+ });
73
+ });
74
+
75
+ describe("deregisterPluginBridge", () => {
76
+ it("removes the managed entry", () => {
77
+ registerPluginBridge("demo", "/demo/bridge.js", { homedir });
78
+ deregisterPluginBridge("demo", { homedir });
79
+ const s = readSettings();
80
+ const managed = s.dashboardPluginBridges as Record<string, string>;
81
+ expect(managed["dashboard-demo"]).toBeUndefined();
82
+ });
83
+
84
+ it("is a no-op when entry does not exist", () => {
85
+ // Should not throw
86
+ expect(() => deregisterPluginBridge("nonexistent", { homedir })).not.toThrow();
87
+ });
88
+
89
+ it("does not remove other plugin entries", () => {
90
+ registerPluginBridge("a", "/a/bridge.js", { homedir });
91
+ registerPluginBridge("b", "/b/bridge.js", { homedir });
92
+ deregisterPluginBridge("a", { homedir });
93
+ const managed = listManagedBridges({ homedir });
94
+ expect(managed["dashboard-a"]).toBeUndefined();
95
+ expect(managed["dashboard-b"]).toBe("/b/bridge.js");
96
+ });
97
+ });
98
+
99
+ describe("listManagedBridges", () => {
100
+ it("returns all managed entries", () => {
101
+ registerPluginBridge("a", "/a/bridge.js", { homedir });
102
+ registerPluginBridge("b", "/b/bridge.js", { homedir });
103
+ const managed = listManagedBridges({ homedir });
104
+ expect(Object.keys(managed)).toHaveLength(2);
105
+ expect(managed["dashboard-a"]).toBe("/a/bridge.js");
106
+ expect(managed["dashboard-b"]).toBe("/b/bridge.js");
107
+ });
108
+
109
+ it("returns empty object when no plugins registered", () => {
110
+ const managed = listManagedBridges({ homedir });
111
+ expect(managed).toEqual({});
112
+ });
113
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Type-level test ensuring plugin_config_update is in ServerToBrowserMessage.
3
+ *
4
+ * Prevents the recurring esbuild-strips-as-any-cases regression where message
5
+ * types not in the union get dead-code eliminated in production builds.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+ import type { ServerToBrowserMessage, PluginConfigUpdateMessage } from "../browser-protocol.js";
9
+
10
+ // Type-level assertion: if PluginConfigUpdateMessage is NOT in the union, this fails to compile.
11
+ type AssertExtends<T, U> = T extends U ? true : never;
12
+ type _PluginConfigUpdateInUnion = AssertExtends<PluginConfigUpdateMessage, ServerToBrowserMessage>;
13
+
14
+ function extractPluginConfigId(msg: ServerToBrowserMessage): string | null {
15
+ switch (msg.type) {
16
+ case "plugin_config_update": return msg.id;
17
+ default: return null;
18
+ }
19
+ }
20
+
21
+ describe("ServerToBrowserMessage includes plugin_config_update", () => {
22
+ it("plugin_config_update is a valid discriminant", () => {
23
+ const msg: PluginConfigUpdateMessage = {
24
+ type: "plugin_config_update",
25
+ id: "demo",
26
+ config: { foo: 1 },
27
+ };
28
+ expect(extractPluginConfigId(msg)).toBe("demo");
29
+ });
30
+
31
+ it("config payload is only this plugin's namespace", () => {
32
+ const msg: PluginConfigUpdateMessage = {
33
+ type: "plugin_config_update",
34
+ id: "openspec",
35
+ config: { pollIntervalSeconds: 30 },
36
+ };
37
+ // The config is the plugin's namespace only — not the full config
38
+ expect((msg.config as any).pollIntervalSeconds).toBe(30);
39
+ expect((msg.config as any).plugins).toBeUndefined();
40
+ });
41
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Repo-level invariant: `.github/workflows/publish.yml`'s `electron` job
3
+ * MUST `needs: [prepare, publish]` and MUST set `strategy.fail-fast: false`.
4
+ *
5
+ * Why: the bundled-server step in the electron matrix runs `npm install`
6
+ * against the live npm registry, which depends on `@blackbelt-technology/*`
7
+ * sub-packages being uploaded by the `publish` job FIRST. Without this gate
8
+ * the electron job races publish and ETARGETs on the just-bumped version
9
+ * (release run #34 — macOS hit ETARGET 1m 45s before publish finished).
10
+ *
11
+ * Without `fail-fast: false`, a single OS failure cascades and cancels the
12
+ * other four matrix variants — losing diagnostic output and wasting runner
13
+ * minutes.
14
+ *
15
+ * If this test fails, restore the two lines in `publish.yml`:
16
+ * electron:
17
+ * needs: [prepare, publish]
18
+ * strategy:
19
+ * fail-fast: false
20
+ * matrix: ...
21
+ *
22
+ * See change: publish-fix-macos.
23
+ */
24
+ import { describe, it, expect } from "vitest";
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import url from "node:url";
28
+
29
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
30
+ const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", "..");
31
+ const WORKFLOW_PATH = path.join(REPO_ROOT, ".github", "workflows", "publish.yml");
32
+
33
+ /**
34
+ * Extract the YAML body of a top-level job by name. Returns the lines
35
+ * between ` <jobName>:` and the next sibling-indent (` `) job, or EOF.
36
+ *
37
+ * We avoid pulling in a YAML library — the test only needs to inspect two
38
+ * specific scalar/list keys on a known job, and the file format is stable
39
+ * (2-space indent, no tabs, no anchors). Same pattern as
40
+ * `no-direct-process-kill.test.ts` and `no-raw-node-import.test.ts`.
41
+ */
42
+ function extractJobBlock(yaml: string, jobName: string): string {
43
+ const lines = yaml.split("\n");
44
+ const headerRe = new RegExp(`^ ${jobName}:\\s*$`);
45
+ let start = -1;
46
+ for (let i = 0; i < lines.length; i++) {
47
+ if (headerRe.test(lines[i])) {
48
+ start = i;
49
+ break;
50
+ }
51
+ }
52
+ if (start === -1) {
53
+ throw new Error(`job '${jobName}' not found in publish.yml`);
54
+ }
55
+ // Walk forward until next line at the same 2-space indent that is a
56
+ // job header (`^ [a-z][a-z0-9-]*:\s*$`) or EOF.
57
+ const siblingRe = /^ [a-z][a-z0-9-]*:\s*$/;
58
+ let end = lines.length;
59
+ for (let i = start + 1; i < lines.length; i++) {
60
+ if (siblingRe.test(lines[i])) {
61
+ end = i;
62
+ break;
63
+ }
64
+ }
65
+ return lines.slice(start, end).join("\n");
66
+ }
67
+
68
+ describe("publish.yml — electron job dependency-graph contract", () => {
69
+ const yaml = fs.readFileSync(WORKFLOW_PATH, "utf8");
70
+ const electronBlock = extractJobBlock(yaml, "electron");
71
+
72
+ it("electron job's `needs:` includes both `prepare` and `publish`", () => {
73
+ // Accept either flow-list (`needs: [prepare, publish]`) or
74
+ // block-list:
75
+ // needs:
76
+ // - prepare
77
+ // - publish
78
+ // (Currently flow-list — but the test should not lock the surface
79
+ // syntax, only the dependency contract.)
80
+ const flowMatch = electronBlock.match(/^\s{4}needs:\s*\[([^\]]*)\]/m);
81
+ const blockMatch = electronBlock.match(
82
+ /^\s{4}needs:\s*\n((?:\s{6}-\s+\S+\s*\n)+)/m,
83
+ );
84
+
85
+ let names: string[] = [];
86
+ if (flowMatch) {
87
+ names = flowMatch[1]
88
+ .split(",")
89
+ .map((s) => s.trim())
90
+ .filter(Boolean);
91
+ } else if (blockMatch) {
92
+ names = blockMatch[1]
93
+ .split("\n")
94
+ .map((l) => l.replace(/^\s*-\s+/, "").trim())
95
+ .filter(Boolean);
96
+ } else {
97
+ throw new Error(
98
+ "electron job has no `needs:` key — must declare `needs: [prepare, publish]`. " +
99
+ "See change: publish-fix-macos. Job block was:\n" +
100
+ electronBlock,
101
+ );
102
+ }
103
+
104
+ expect(names).toContain("prepare");
105
+ expect(names).toContain("publish");
106
+ });
107
+
108
+ it("electron job's `strategy.fail-fast` is `false`", () => {
109
+ // Match `fail-fast: false` (any whitespace after the colon, but the
110
+ // value must be the literal `false` — not `False`, not absent).
111
+ const m = electronBlock.match(/^\s{6}fail-fast:\s*(\S+)\s*$/m);
112
+ if (!m) {
113
+ throw new Error(
114
+ "electron job's `strategy.fail-fast` is absent — the GitHub Actions " +
115
+ "default of `true` would re-introduce the run-#34 cascade. " +
116
+ "Set `fail-fast: false`. See change: publish-fix-macos.\n" +
117
+ "Job block was:\n" +
118
+ electronBlock,
119
+ );
120
+ }
121
+ expect(m[1]).toBe("false");
122
+ });
123
+ });
@@ -127,8 +127,12 @@ describe("RecommendedExtension type", () => {
127
127
 
128
128
  describe("BUNDLED_EXTENSION_IDS manifest", () => {
129
129
  it("contains exactly the v0.x initial bundled set", () => {
130
+ // pi-flows temporarily removed: upstream repo lacks SPDX license,
131
+ // blocking the bundle-recommended-extensions.sh license check.
132
+ // Re-add when https://github.com/BlackBeltTechnology/pi-flows has
133
+ // a license declared.
130
134
  expect([...BUNDLED_EXTENSION_IDS].sort()).toEqual(
131
- ["pi-anthropic-messages", "pi-flows"].sort(),
135
+ ["pi-anthropic-messages"].sort(),
132
136
  );
133
137
  });
134
138
 
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Type-level + structural tests for the `attachProposal` field on
3
+ * `SpawnSessionBrowserMessage`. See change:
4
+ * add-folder-task-checker-and-spawn-attach.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import type {
8
+ SpawnSessionBrowserMessage,
9
+ BrowserToServerMessage,
10
+ } from "../browser-protocol.js";
11
+
12
+ describe("SpawnSessionBrowserMessage.attachProposal", () => {
13
+ it("is optional — the bare-spawn payload still type-checks", () => {
14
+ // Compile-time: omitting attachProposal is allowed.
15
+ const bare: SpawnSessionBrowserMessage = { type: "spawn_session", cwd: "/x" };
16
+ expect(bare.attachProposal).toBeUndefined();
17
+ const _inUnion: BrowserToServerMessage = bare;
18
+ void _inUnion;
19
+ });
20
+
21
+ it("accepts a string attachProposal when set", () => {
22
+ const withAttach: SpawnSessionBrowserMessage = {
23
+ type: "spawn_session",
24
+ cwd: "/x",
25
+ attachProposal: "add-foo",
26
+ };
27
+ expect(withAttach.attachProposal).toBe("add-foo");
28
+ });
29
+
30
+ it("JSON round-trip preserves the field", () => {
31
+ const sent: SpawnSessionBrowserMessage = {
32
+ type: "spawn_session",
33
+ cwd: "/project/foo",
34
+ attachProposal: "add-auth",
35
+ };
36
+ const parsed = JSON.parse(JSON.stringify(sent)) as SpawnSessionBrowserMessage;
37
+ expect(parsed.type).toBe("spawn_session");
38
+ expect(parsed.cwd).toBe("/project/foo");
39
+ expect(parsed.attachProposal).toBe("add-auth");
40
+ });
41
+
42
+ it("JSON round-trip without the field omits it", () => {
43
+ const sent: SpawnSessionBrowserMessage = { type: "spawn_session", cwd: "/x" };
44
+ const parsed = JSON.parse(JSON.stringify(sent)) as SpawnSessionBrowserMessage;
45
+ expect("attachProposal" in parsed).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Tests for the AppImage self-hit guard inside `whereStrategy`. Per
3
+ * design D2 (change: fix-electron-appimage-cli-self-detection), the
4
+ * guard runs after `whichSync(name)` returns and demotes self-hits to
5
+ * `{ ok: false, reason: "appimage-self-hit: <path>" }` so the
6
+ * registry's diagnostic trail records the rejection.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import path from "node:path";
10
+ import os from "node:os";
11
+
12
+ import { whereStrategy } from "../tool-registry/strategies.js";
13
+ import {
14
+ ToolRegistry,
15
+ OverridesStore,
16
+ } from "../tool-registry/index.js";
17
+
18
+ function tmpStore(): OverridesStore {
19
+ return new OverridesStore({
20
+ filePath: path.join(
21
+ os.tmpdir(),
22
+ `where-strategy-appimage-test-${process.pid}-${Math.random().toString(36).slice(2)}.json`,
23
+ ),
24
+ warn: () => {},
25
+ });
26
+ }
27
+
28
+ describe("whereStrategy AppImage self-hit guard", () => {
29
+ it("rejects an APPDIR-mount candidate", () => {
30
+ const savedAppDir = process.env.APPDIR;
31
+ const fakeAppDir = "/tmp/.mount_PI-DAS-TEST";
32
+ process.env.APPDIR = fakeAppDir;
33
+ try {
34
+ const strat = whereStrategy("pi-dashboard", {
35
+ which: () => fakeAppDir + "/pi-dashboard",
36
+ });
37
+ const r = strat.run({
38
+ overrides: {},
39
+ platform: "linux",
40
+ });
41
+ expect(r.ok).toBe(false);
42
+ if (!r.ok) {
43
+ expect(r.reason).toContain("appimage-self-hit");
44
+ expect(r.reason).toContain(fakeAppDir + "/pi-dashboard");
45
+ }
46
+ } finally {
47
+ if (savedAppDir === undefined) delete process.env.APPDIR;
48
+ else process.env.APPDIR = savedAppDir;
49
+ }
50
+ });
51
+
52
+ it("rejects a process.execPath self-hit", () => {
53
+ // Use the running process.execPath — realpath-equality matches.
54
+ const exec = process.execPath;
55
+ const strat = whereStrategy("pi-dashboard", {
56
+ which: () => exec,
57
+ });
58
+ const r = strat.run({
59
+ overrides: {},
60
+ platform: process.platform,
61
+ });
62
+ expect(r.ok).toBe(false);
63
+ if (!r.ok) {
64
+ expect(r.reason).toContain("appimage-self-hit");
65
+ }
66
+ });
67
+
68
+ it("returns ok for unrelated paths when no AppImage env vars are set", () => {
69
+ const savedAppDir = process.env.APPDIR;
70
+ const savedAppImage = process.env.APPIMAGE;
71
+ delete process.env.APPDIR;
72
+ delete process.env.APPIMAGE;
73
+ try {
74
+ const candidate = "/usr/local/bin/git";
75
+ const strat = whereStrategy("git", { which: () => candidate });
76
+ const r = strat.run({ overrides: {}, platform: "linux" });
77
+ expect(r.ok).toBe(true);
78
+ if (r.ok) expect(r.path).toBe(candidate);
79
+ } finally {
80
+ if (savedAppDir !== undefined) process.env.APPDIR = savedAppDir;
81
+ if (savedAppImage !== undefined) process.env.APPIMAGE = savedAppImage;
82
+ }
83
+ });
84
+ });
85
+
86
+ describe("ToolRegistry diagnostic trail records appimage-self-hit", () => {
87
+ it("Resolution.tried includes a 'where' entry whose reason contains 'appimage-self-hit'", () => {
88
+ const savedAppDir = process.env.APPDIR;
89
+ const fakeAppDir = "/tmp/.mount_PI-REG-TEST";
90
+ process.env.APPDIR = fakeAppDir;
91
+ try {
92
+ const registry = new ToolRegistry({
93
+ overrides: tmpStore(),
94
+ platform: "linux",
95
+ });
96
+ registry.register({
97
+ name: "synthetic-tool",
98
+ kind: "binary",
99
+ strategies: [
100
+ // Final strategy in the chain is `where`, fed by an injected
101
+ // `which` that returns an APPDIR-mount candidate.
102
+ whereStrategy("synthetic-tool", {
103
+ which: () => fakeAppDir + "/synthetic-tool",
104
+ }),
105
+ ],
106
+ });
107
+
108
+ const res = registry.resolve("synthetic-tool");
109
+ expect(res.ok).toBe(false);
110
+ const whereEntry = res.tried.find((t) => t.strategy === "where");
111
+ expect(whereEntry).toBeDefined();
112
+ expect(String(whereEntry!.result)).toContain("appimage-self-hit");
113
+ } finally {
114
+ if (savedAppDir === undefined) delete process.env.APPDIR;
115
+ else process.env.APPDIR = savedAppDir;
116
+ }
117
+ });
118
+ });
@@ -11,6 +11,8 @@ import type {
11
11
  OpenSpecData,
12
12
  ModelInfo,
13
13
  PiSessionInfo,
14
+ ExtensionUiModule,
15
+ DecoratorDescriptor,
14
16
  } from "./types.js";
15
17
  import type { TerminalSession } from "./terminal-types.js";
16
18
  import type { EditorInstanceStatus } from "./editor-types.js";
@@ -214,13 +216,24 @@ export interface BrowserPromptCancelMessage {
214
216
  promptId: string;
215
217
  }
216
218
 
217
- /** Progress event streamed during a package install/remove/update operation. */
219
+ /** Progress event streamed during a package install/remove/update/move operation.
220
+ *
221
+ * `moveId` is set when this progress event is part of a move operation
222
+ * (which composes install + remove). Clients group events by `moveId`
223
+ * to display a single composite progress affordance instead of two
224
+ * separate operations. Consumers that ignore the field continue to
225
+ * render install + remove independently — graceful degradation.
226
+ *
227
+ * See change: unify-package-management-ui.
228
+ */
218
229
  export interface PackageProgressMessage {
219
230
  type: "package_progress";
220
231
  operationId: string;
232
+ /** Optional move grouping id when emitted as part of a move. */
233
+ moveId?: string;
221
234
  event: {
222
235
  type: "start" | "progress" | "complete" | "error";
223
- action: "install" | "remove" | "update" | "clone" | "pull";
236
+ action: "install" | "remove" | "update" | "clone" | "pull" | "move";
224
237
  source: string;
225
238
  message?: string;
226
239
  };
@@ -297,16 +310,72 @@ export interface BootstrapTicketCompleteMessage {
297
310
  export interface PackageOperationCompleteMessage {
298
311
  type: "package_operation_complete";
299
312
  operationId: string;
300
- action: "install" | "remove" | "update";
313
+ /** Optional move grouping id; set on every event of a composite move op. */
314
+ moveId?: string;
315
+ action: "install" | "remove" | "update" | "move";
301
316
  source: string;
302
317
  scope: "global" | "local";
303
318
  success: boolean;
304
319
  error?: string;
305
320
  /** Number of sessions reloaded (only on success). */
306
321
  sessionsReloaded?: number;
322
+ /** Set on a move op when install succeeded but remove failed.
323
+ * Indicates the package now exists in BOTH scopes; UI should surface
324
+ * a recovery action (POST /api/packages/remove against fromScope). */
325
+ partialSuccess?: {
326
+ installed: boolean;
327
+ removed: boolean;
328
+ removeError?: string;
329
+ };
330
+ }
331
+
332
+ // ── Extension UI System (Phase 1: management-modal slot) ───────────
333
+ // See change: add-extension-ui-modal.
334
+
335
+ /** Server → browser: cached extension-declared UI modules for a session. */
336
+ export interface BrowserUiModulesListMessage {
337
+ type: "ui_modules_list";
338
+ sessionId: string;
339
+ modules: ExtensionUiModule[];
340
+ }
341
+
342
+ /** Server → browser: row data for a `view.dataEvent`. */
343
+ export interface BrowserUiDataListMessage {
344
+ type: "ui_data_list";
345
+ sessionId: string;
346
+ event: string;
347
+ items: unknown[];
348
+ }
349
+
350
+ // ── Extension UI System (Phase 2: live in-page decorations) ──
351
+ // See change: add-extension-ui-decorations.
352
+
353
+ /**
354
+ * Server → browser: a live decorator descriptor (forwarded verbatim from the
355
+ * extension). `removed: true` instructs the client to unmount the matching
356
+ * descriptor.
357
+ */
358
+ export interface BrowserExtUiDecoratorMessage {
359
+ type: "ext_ui_decorator";
360
+ sessionId: string;
361
+ descriptor: DecoratorDescriptor;
362
+ removed?: boolean;
363
+ }
364
+
365
+ /** Sent when a plugin's config changes; carries only that plugin's namespace. */
366
+ export interface PluginConfigUpdateMessage {
367
+ type: "plugin_config_update";
368
+ /** Plugin id that was updated. */
369
+ id: string;
370
+ /**
371
+ * Only this plugin's namespace config (plugins.<id>.*).
372
+ * Never contains other plugins' configs.
373
+ */
374
+ config: unknown;
307
375
  }
308
376
 
309
377
  export type ServerToBrowserMessage =
378
+ | PluginConfigUpdateMessage
310
379
  | SessionAddedMessage
311
380
  | SessionUpdatedMessage
312
381
  | SessionRemovedMessage
@@ -344,7 +413,10 @@ export type ServerToBrowserMessage =
344
413
  | BrowserPromptCancelMessage
345
414
  | ModelsRefreshedMessage
346
415
  | BootstrapStatusUpdateMessage
347
- | BootstrapTicketCompleteMessage;
416
+ | BootstrapTicketCompleteMessage
417
+ | BrowserUiModulesListMessage
418
+ | BrowserUiDataListMessage
419
+ | BrowserExtUiDecoratorMessage;
348
420
 
349
421
  // ── Browser → Server ────────────────────────────────────────────────
350
422
 
@@ -483,6 +555,16 @@ export interface ResumeSessionBrowserMessage {
483
555
  mode: "continue" | "fork";
484
556
  /** When forking, optionally fork from a specific session entry instead of the latest */
485
557
  entryId?: string;
558
+ /**
559
+ * Placement intent for the resumed session in the cwd's sessionOrder:
560
+ * - "front" (default): move to top of alive tier (Resume button, REST,
561
+ * prompt-auto-resume).
562
+ * - "keep": leave order alone (drag-to-resume — drop position was already
563
+ * persisted by an earlier `reorder_sessions` message).
564
+ * Server defaults to "front" when omitted, preserving prior behavior.
565
+ * See change: differentiate-resume-intent-by-trigger.
566
+ */
567
+ placement?: "front" | "keep";
486
568
  }
487
569
 
488
570
  export interface HideSessionBrowserMessage {
@@ -498,6 +580,15 @@ export interface UnhideSessionBrowserMessage {
498
580
  export interface SpawnSessionBrowserMessage {
499
581
  type: "spawn_session";
500
582
  cwd: string;
583
+ /**
584
+ * Optional kebab-case OpenSpec change name to attach to the spawned session
585
+ * once it registers. The server queues the intent in `pendingAttachByCwd`
586
+ * and consumes it on the next matching `session_register`.
587
+ * Old servers that ignore unknown fields produce a bare spawn (degraded but
588
+ * recoverable: the user attaches manually). See change:
589
+ * add-folder-task-checker-and-spawn-attach.
590
+ */
591
+ attachProposal?: string;
501
592
  }
502
593
 
503
594
  export interface AttachProposalBrowserMessage {
@@ -629,6 +720,20 @@ export interface RequestRolesBrowserMessage {
629
720
  sessionId: string;
630
721
  }
631
722
 
723
+ /**
724
+ * Browser → server: the user invoked a Phase-1 module action / requested
725
+ * row data. Server forwards via `piGateway.sendToSession` to the bridge,
726
+ * which re-emits as `pi.events.emit(event, { ...params, action, _reply })`.
727
+ * See change: add-extension-ui-modal.
728
+ */
729
+ export interface UiManagementBrowserMessage {
730
+ type: "ui_management";
731
+ sessionId: string;
732
+ action: string;
733
+ event: string;
734
+ params?: Record<string, unknown>;
735
+ }
736
+
632
737
  export type BrowserToServerMessage =
633
738
  | SubscribeMessage
634
739
  | UnsubscribeMessage
@@ -669,4 +774,5 @@ export type BrowserToServerMessage =
669
774
  | RolePresetSaveBrowserMessage
670
775
  | RolePresetDeleteBrowserMessage
671
776
  | RequestRolesBrowserMessage
777
+ | UiManagementBrowserMessage
672
778
  | KillProcessBrowserMessage;