@blackbelt-technology/pi-agent-dashboard 0.4.1 → 0.4.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.
Files changed (108) hide show
  1. package/AGENTS.md +79 -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__/recommended-extensions.test.ts +5 -1
  88. package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
  89. package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
  90. package/packages/shared/src/browser-protocol.ts +110 -4
  91. package/packages/shared/src/config.ts +45 -0
  92. package/packages/shared/src/dashboard-plugin/index.ts +11 -0
  93. package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
  94. package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
  95. package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
  96. package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
  97. package/packages/shared/src/openspec-activity-detector.ts +18 -22
  98. package/packages/shared/src/openspec-design-evidence.ts +109 -0
  99. package/packages/shared/src/openspec-poller.ts +117 -3
  100. package/packages/shared/src/openspec-specs-evidence.ts +79 -0
  101. package/packages/shared/src/platform/binary-lookup.ts +96 -1
  102. package/packages/shared/src/plugin-bridge-register.ts +139 -0
  103. package/packages/shared/src/protocol.ts +56 -2
  104. package/packages/shared/src/recommended-extensions.ts +7 -1
  105. package/packages/shared/src/rest-api.ts +68 -3
  106. package/packages/shared/src/state-replay.ts +11 -1
  107. package/packages/shared/src/tool-registry/strategies.ts +17 -3
  108. 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
+ });
@@ -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;
@@ -80,6 +80,12 @@ export interface KnownServer {
80
80
  addedAt: string; // ISO timestamp
81
81
  }
82
82
 
83
+ /**
84
+ * Plugin-specific config namespace.
85
+ * Lives at ~/.pi/dashboard/config.json#plugins.<id>.*
86
+ */
87
+ export type PluginsConfig = Record<string, Record<string, unknown>>;
88
+
83
89
  export interface DashboardConfig {
84
90
  port: number;
85
91
  piPort: number;
@@ -107,6 +113,13 @@ export interface DashboardConfig {
107
113
  electronMode: boolean;
108
114
  /** Persisted list of known remote servers */
109
115
  knownServers: KnownServer[];
116
+ /**
117
+ * Per-plugin config namespaces. Reserved top-level key.
118
+ * Each plugin's config lives at plugins.<id>.*
119
+ * Plugin-shaped legacy top-level keys (e.g. openspec.*) stay at top-level
120
+ * until each extract-*-as-plugin change migrates them.
121
+ */
122
+ plugins: PluginsConfig;
110
123
  }
111
124
 
112
125
  export interface CorsConfig {
@@ -117,6 +130,7 @@ export interface CorsConfig {
117
130
  const VALID_SPAWN_STRATEGIES: SpawnStrategy[] = ["tmux", "headless"];
118
131
 
119
132
  const DEFAULTS: DashboardConfig = {
133
+ plugins: {},
120
134
  port: 8000,
121
135
  piPort: 9999,
122
136
  autoStart: true,
@@ -237,6 +251,36 @@ function parseMemoryLimits(raw: any): MemoryLimitsConfig {
237
251
  };
238
252
  }
239
253
 
254
+ function parsePluginsConfig(raw: unknown): PluginsConfig {
255
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
256
+ const result: PluginsConfig = {};
257
+ for (const [id, val] of Object.entries(raw as Record<string, unknown>)) {
258
+ if (val && typeof val === "object" && !Array.isArray(val)) {
259
+ result[id] = val as Record<string, unknown>;
260
+ }
261
+ }
262
+ return result;
263
+ }
264
+
265
+ /**
266
+ * Get the plugins config block from a loaded DashboardConfig.
267
+ * Provides typed access to plugins.<id>.* namespaces.
268
+ */
269
+ export function getPluginsConfig(config: DashboardConfig): PluginsConfig {
270
+ return config.plugins ?? {};
271
+ }
272
+
273
+ /**
274
+ * Get a single plugin's config from a loaded DashboardConfig.
275
+ * Returns {} if the plugin has no stored config.
276
+ */
277
+ export function getPluginConfig(
278
+ config: DashboardConfig,
279
+ pluginId: string,
280
+ ): Record<string, unknown> {
281
+ return config.plugins?.[pluginId] ?? {};
282
+ }
283
+
240
284
  function parseKnownServers(raw: any): KnownServer[] {
241
285
  if (!Array.isArray(raw)) return [];
242
286
  return raw
@@ -299,6 +343,7 @@ export function loadConfig(): DashboardConfig {
299
343
  ...(typeof parsed.lastServer === "string" ? { lastServer: parsed.lastServer } : {}),
300
344
  electronMode: parsed.electronMode === true,
301
345
  knownServers: parseKnownServers(parsed.knownServers),
346
+ plugins: parsePluginsConfig(parsed.plugins),
302
347
  };
303
348
 
304
349
  // Compute resolvedTrustedNetworks: merge trustedNetworks + auth.bypassHosts
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Barrel export for dashboard plugin system shared types.
3
+ * Import from:
4
+ * @blackbelt-technology/pi-dashboard-shared/dashboard-plugin/index.js
5
+ * @blackbelt-technology/pi-dashboard-shared/dashboard-plugin/slot-types.js
6
+ * etc.
7
+ */
8
+ export * from "./slot-types.js";
9
+ export * from "./manifest-types.js";
10
+ export * from "./slot-props.js";
11
+ export * from "./plugin-status.js";
@@ -0,0 +1,58 @@
1
+ import type { SlotId, SettingsTab } from "./slot-types.js";
2
+
3
+ /**
4
+ * A single slot claim in a plugin manifest.
5
+ */
6
+ export interface PluginClaim {
7
+ /** The slot this claim targets. */
8
+ slot: SlotId;
9
+ /** Exported component name from the plugin's client entry (for React slots). */
10
+ component?: string;
11
+ /** Route command for "command-route" slot (e.g. "/specs"). */
12
+ command?: string;
13
+ /** Trigger id for "anchored-popover" slot. */
14
+ trigger?: string;
15
+ /** toolName for "tool-renderer" slot. */
16
+ toolName?: string;
17
+ /**
18
+ * For "settings-section" slot: which SettingsPanel tab to render in.
19
+ * Defaults to "general" if omitted.
20
+ */
21
+ tab?: SettingsTab;
22
+ /** Slot-specific extra config. */
23
+ config?: Record<string, unknown>;
24
+ /** Optional exported predicate function name for filtering contributions. */
25
+ predicate?: string;
26
+ }
27
+
28
+ /**
29
+ * The pi-dashboard-plugin manifest.
30
+ * Declared as the `pi-dashboard-plugin` field in a package.json,
31
+ * or as a top-level `dashboard-plugin.json` adjacent to package.json.
32
+ */
33
+ export interface PluginManifest {
34
+ /** Globally unique kebab-case plugin id. */
35
+ id: string;
36
+ /** Human-readable display name. */
37
+ displayName: string;
38
+ /**
39
+ * Lower number = rendered earlier in multi-contribution slots.
40
+ * Default 1000. First-party plugins use 100.
41
+ */
42
+ priority?: number;
43
+ /** Relative path to the bundled client entry (from package root). */
44
+ client?: string;
45
+ /** Optional relative path to the server entry. */
46
+ server?: string;
47
+ /** Optional relative path to a pi-extension/bridge entry. */
48
+ bridge?: string;
49
+ /** Optional relative path to a JSON Schema 7 file for plugin config validation. */
50
+ configSchema?: string;
51
+ /** Slot claims. */
52
+ claims: PluginClaim[];
53
+ /**
54
+ * When true, the plugin is a test fixture and SHALL be excluded from
55
+ * production bundles (NODE_ENV=production).
56
+ */
57
+ fixture?: boolean;
58
+ }