@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,107 @@
1
+ /**
2
+ * In-memory tracker for user-initiated session-resume intents.
3
+ *
4
+ * Purpose: distinguish ended→alive transitions caused by a deliberate user
5
+ * action (Resume click, drag-to-resume, REST resume, prompt-auto-resume)
6
+ * from those caused by a bridge auto-reattach on dashboard reboot, AND
7
+ * differentiate between "surface this card at the top" (front) and
8
+ * "respect the slot the user just chose" (keep) for user-driven resumes.
9
+ *
10
+ * The `sessionManager.onChange` hook in `server.ts` consults this registry
11
+ * in its ended→alive branch:
12
+ *
13
+ * - if `consume(sessionId) === "front"` → moveToFront + broadcast
14
+ * - if `consume(sessionId) === "keep"` → no-op (drop position already
15
+ * persisted via reorder_sessions)
16
+ * - if `consume(sessionId) === null` → bridge reattach → leave order alone
17
+ *
18
+ * Tagging happens in `handleResumeSession` (WS), the `/api/session/:id/resume`
19
+ * handler (REST), and `handleSendPrompt`'s ended-branch (prompt-auto-resume),
20
+ * immediately before `spawnPiSession`. The intent value is supplied by the
21
+ * caller — drag-to-resume tags `"keep"`; everyone else tags `"front"`.
22
+ *
23
+ * In-memory only. NOT persisted across server restarts. Stale entries (older
24
+ * than `ttlMs`, default 60 s) are silently dropped on read so a failed spawn
25
+ * cannot poison a later legitimate reattach.
26
+ *
27
+ * See changes: preserve-session-order-on-reboot, top-of-tier-on-status-change,
28
+ * differentiate-resume-intent-by-trigger.
29
+ */
30
+
31
+ export const PENDING_RESUME_INTENT_TTL_MS = 60_000;
32
+
33
+ /** The two user-driven placement intents. */
34
+ export type ResumeIntent = "front" | "keep";
35
+
36
+ interface IntentEntry {
37
+ intent: ResumeIntent;
38
+ timestamp: number;
39
+ }
40
+
41
+ export interface PendingResumeIntentRegistry {
42
+ /**
43
+ * Tag a session id with a placement intent. Idempotent — re-recording the
44
+ * same id refreshes both timestamp and intent (last-write-wins).
45
+ */
46
+ record(sessionId: string, intent: ResumeIntent): void;
47
+ /**
48
+ * Returns the recorded intent and clears the entry iff the session was
49
+ * tagged within the TTL window. Stale entries are dropped silently and
50
+ * `null` is returned. `null` is also returned for never-tagged ids.
51
+ */
52
+ consume(sessionId: string): ResumeIntent | null;
53
+ /** Test helper — number of live (non-expired) entries. */
54
+ size(): number;
55
+ }
56
+
57
+ export interface PendingResumeIntentRegistryOptions {
58
+ /** Override the TTL in milliseconds. Defaults to 60_000. */
59
+ ttlMs?: number;
60
+ /** Override `Date.now` for tests. */
61
+ now?: () => number;
62
+ }
63
+
64
+ export function createPendingResumeIntentRegistry(
65
+ opts: PendingResumeIntentRegistryOptions = {},
66
+ ): PendingResumeIntentRegistry {
67
+ const ttl = opts.ttlMs ?? PENDING_RESUME_INTENT_TTL_MS;
68
+ const now = opts.now ?? (() => Date.now());
69
+
70
+ // sessionId -> { intent, timestamp } of most recent record() call.
71
+ const store = new Map<string, IntentEntry>();
72
+
73
+ function pruneStale(): void {
74
+ const cutoff = now() - ttl;
75
+ for (const [id, entry] of store) {
76
+ if (entry.timestamp < cutoff) store.delete(id);
77
+ }
78
+ }
79
+
80
+ return {
81
+ record(sessionId: string, intent: ResumeIntent): void {
82
+ if (!sessionId) return;
83
+ // Last-write-wins on re-record: a second user action for the same
84
+ // session (e.g. drag-then-button-click) should reflect the most
85
+ // recent intent. Also refreshes the timestamp so a slow bridge
86
+ // round-trip doesn't expire mid-resume.
87
+ store.set(sessionId, { intent, timestamp: now() });
88
+ },
89
+
90
+ consume(sessionId: string): ResumeIntent | null {
91
+ if (!sessionId) return null;
92
+ const entry = store.get(sessionId);
93
+ if (entry === undefined) return null;
94
+ if (entry.timestamp < now() - ttl) {
95
+ store.delete(sessionId);
96
+ return null;
97
+ }
98
+ store.delete(sessionId);
99
+ return entry.intent;
100
+ },
101
+
102
+ size(): number {
103
+ pruneStale();
104
+ return store.size;
105
+ },
106
+ };
107
+ }
@@ -69,22 +69,17 @@ function resolveDisplayName(name: string): string {
69
69
  }
70
70
 
71
71
  /**
72
- * Heuristic to decide if a package is part of the pi ecosystem but NOT in
73
- * the known-names list above. Matches bare-name pi packages on npm:
74
- * - bare `pi-<name>`
75
- * - scoped `@<scope>/pi-<name>`
76
- * Note: extensions already managed by PackageManagerWrapper (via
77
- * `settings.json packages[]`) are deliberately included if they are ALSO
78
- * installed globally the PiCoreChecker's discovery is a superset, and
79
- * the UI layer decides which surface to show a package in.
72
+ * Strict whitelist check: a package is part of the pi-ecosystem CORE
73
+ * tooling if and only if its name is in `CORE_PACKAGE_NAMES`. The
74
+ * previous `pi-*` name-prefix heuristic was removed because it caused
75
+ * recommended-extension packages (e.g. `pi-agent-browser`,
76
+ * `@tintinweb/pi-subagents`) to appear in BOTH the Core ecosystem panel
77
+ * and the Installed Packages panel. Recommended extensions are now
78
+ * surfaced exclusively through `/api/packages/installed`. See change:
79
+ * consolidate-packages-settings-ui.
80
80
  */
81
81
  function looksLikePiEcosystem(name: string): boolean {
82
- if (CORE_PACKAGE_NAMES.includes(name)) return true;
83
- // `pi-foo` or `pi` bare-scoped
84
- if (/^pi-[a-z0-9-]+$/i.test(name)) return true;
85
- // scoped variant: `@scope/pi-foo`
86
- if (/^@[^/]+\/pi-[a-z0-9-]+$/i.test(name)) return true;
87
- return false;
82
+ return CORE_PACKAGE_NAMES.includes(name);
88
83
  }
89
84
 
90
85
  export interface NpmListRunner {
@@ -32,6 +32,14 @@ export interface PiGateway {
32
32
  onConnection?: () => void;
33
33
  onDisconnect?: (sessionId: string) => void;
34
34
  onSessionCreated?: (sessionId: string) => void;
35
+ /**
36
+ * Fired after a `session_register` message has been processed and the
37
+ * session is in the manager. Receives the registered sessionId and its
38
+ * cwd. Wired by the dashboard server to consume any pending
39
+ * spawn-with-attach intent. See change:
40
+ * add-folder-task-checker-and-spawn-attach.
41
+ */
42
+ onSessionRegistered?: (sessionId: string, cwd: string) => void;
35
43
  }
36
44
 
37
45
  export function createPiGateway(
@@ -57,6 +65,7 @@ export function createPiGateway(
57
65
  let onConnection: (() => void) | undefined;
58
66
  let onDisconnect: ((sessionId: string) => void) | undefined;
59
67
  let onSessionCreated: ((sessionId: string) => void) | undefined;
68
+ let onSessionRegistered: ((sessionId: string, cwd: string) => void) | undefined;
60
69
 
61
70
  function checkEmpty() {
62
71
  if (connections.size === 0) {
@@ -174,6 +183,10 @@ export function createPiGateway(
174
183
  onSessionCreated = handler;
175
184
  },
176
185
 
186
+ set onSessionRegistered(handler: ((sessionId: string, cwd: string) => void) | undefined) {
187
+ onSessionRegistered = handler;
188
+ },
189
+
177
190
  address() {
178
191
  const addr = wss?.address();
179
192
  if (addr && typeof addr === "object") return addr.port;
@@ -289,6 +302,7 @@ export function createPiGateway(
289
302
 
290
303
  resetHeartbeat(msg.sessionId);
291
304
  onConnection?.();
305
+ onSessionRegistered?.(msg.sessionId, msg.cwd);
292
306
  }
293
307
 
294
308
  if (msg.type === "session_heartbeat" && msg.sessionId) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Pure helpers for the idempotent attach/detach auto-rename rule.
3
+ *
4
+ * See change: fix-mobile-attach-proposal-display (design.md decision matrix).
5
+ *
6
+ * Auto-rename on attach when EITHER (a) name is empty/whitespace, OR
7
+ * (b) name === current attachedProposal (auto-set on a prior attach;
8
+ * user has not customised, so re-track the new attachment).
9
+ *
10
+ * Auto-revert on detach only when name === attachedProposal (the witness
11
+ * that the name was auto-set on a previous attach).
12
+ */
13
+ import type { DashboardSession } from "@blackbelt-technology/pi-dashboard-shared/types.js";
14
+
15
+ /** True when the session's current name is the equality witness for "auto-set". */
16
+ export function isNameAutoSetFromAttachment(session: Pick<DashboardSession, "name" | "attachedProposal"> | undefined): boolean {
17
+ if (!session) return false;
18
+ const trimmed = session.name?.trim();
19
+ if (!trimmed) return false;
20
+ if (!session.attachedProposal) return false;
21
+ return trimmed === session.attachedProposal;
22
+ }
23
+
24
+ /**
25
+ * Decide whether attaching `changeName` to `session` should also rename it.
26
+ * Returns the new name to apply, or `undefined` if name should not change.
27
+ */
28
+ export function attachRenameTarget(
29
+ session: Pick<DashboardSession, "name" | "attachedProposal"> | undefined,
30
+ changeName: string,
31
+ ): string | undefined {
32
+ if (!session) return undefined;
33
+ const trimmed = session.name?.trim();
34
+ if (!trimmed) return changeName;
35
+ if (isNameAutoSetFromAttachment(session)) return changeName;
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * Decide whether detaching from `session` should clear the name.
41
+ * Returns true when the name should be cleared (set to undefined).
42
+ */
43
+ export function detachShouldClearName(
44
+ session: Pick<DashboardSession, "name" | "attachedProposal"> | undefined,
45
+ ): boolean {
46
+ return isNameAutoSetFromAttachment(session);
47
+ }
@@ -6,7 +6,7 @@ import type { SessionManager } from "../memory-session-manager.js";
6
6
  import type { PreferencesStore } from "../preferences-store.js";
7
7
  import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
8
8
  import type { NetworkGuard } from "./route-deps.js";
9
- import { listDirectories, createDirectory } from "../browse.js";
9
+ import { listDirectories, createDirectory, classifyPaths, parseFlagsQuery } from "../browse.js";
10
10
  import path from "node:path";
11
11
  import fs from "node:fs/promises";
12
12
 
@@ -20,8 +20,13 @@ export function registerFileRoutes(
20
20
  ) {
21
21
  const { sessionManager, preferencesStore, networkGuard } = deps;
22
22
 
23
- // Directory browse endpoint
24
- fastify.get<{ Querystring: { path?: string; q?: string } }>(
23
+ // Directory browse endpoint.
24
+ // `detect=1` opts into eager `.git` / `.pi` classification on every entry
25
+ // (anything other than the literal string `"1"` is treated as falsy).
26
+ // Without `detect`, this is a single-readdir enumeration with no filesystem
27
+ // probes — use `GET /api/browse/flags` to classify lazily.
28
+ // See change: split-browse-flags.
29
+ fastify.get<{ Querystring: { path?: string; q?: string; detect?: string } }>(
25
30
  "/api/browse",
26
31
  { preHandler: networkGuard },
27
32
  async (request) => {
@@ -29,6 +34,7 @@ export function registerFileRoutes(
29
34
  const result = await listDirectories(
30
35
  request.query.path || undefined,
31
36
  request.query.q || undefined,
37
+ { detect: request.query.detect === "1" },
32
38
  );
33
39
  return { success: true, data: result } satisfies ApiResponse;
34
40
  } catch {
@@ -37,6 +43,26 @@ export function registerFileRoutes(
37
43
  },
38
44
  );
39
45
 
46
+ // Bulk directory flag classifier. Accepts `paths=<json-array>` (URL-encoded
47
+ // JSON array of absolute path strings, length ≤ 100). Returns
48
+ // `{ flags: { [path]: { isGit, isPi } } }`. Per-path probe failures map to
49
+ // `{ isGit: false, isPi: false }` — only malformed input or over-cap
50
+ // requests produce a top-level error (HTTP 400).
51
+ // See change: split-browse-flags.
52
+ fastify.get<{ Querystring: { paths?: string } }>(
53
+ "/api/browse/flags",
54
+ { preHandler: networkGuard },
55
+ async (request, reply) => {
56
+ const parsed = parseFlagsQuery(request.query.paths);
57
+ if (!parsed.ok) {
58
+ reply.code(400);
59
+ return { success: false, error: parsed.error } satisfies ApiResponse;
60
+ }
61
+ const flags = await classifyPaths(parsed.paths);
62
+ return { success: true, data: { flags } } satisfies ApiResponse;
63
+ },
64
+ );
65
+
40
66
  // Directory create endpoint
41
67
  fastify.post<{ Body: { parent?: unknown; name?: unknown } }>(
42
68
  "/api/browse/mkdir",
@@ -3,9 +3,16 @@
3
3
  */
4
4
  import type { FastifyInstance } from "fastify";
5
5
  import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
6
- import type { PackageManagerWrapper } from "../package-manager-wrapper.js";
7
- import { PackageOperationBusyError } from "../package-manager-wrapper.js";
6
+ import type { PackageManagerWrapper, PackageEntry } from "../package-manager-wrapper.js";
7
+ import {
8
+ PackageOperationBusyError,
9
+ AlreadyAtDestinationError,
10
+ InvalidMoveRequestError,
11
+ UnsupportedSourceForDestinationError,
12
+ } from "../package-manager-wrapper.js";
13
+ import { parseSourceKind } from "../package-source-helpers.js";
8
14
  import { searchPackages, fetchReadme, PackageNotFoundError } from "../npm-search-proxy.js";
15
+ import { enrichInstalledRows } from "../installed-package-enricher.js";
9
16
 
10
17
  export function registerPackageRoutes(
11
18
  fastify: FastifyInstance,
@@ -62,7 +69,8 @@ export function registerPackageRoutes(
62
69
  const cwd = request.query.cwd;
63
70
  try {
64
71
  const packages = await packageManagerWrapper.listInstalled(scope, cwd);
65
- return { success: true, data: packages } satisfies ApiResponse;
72
+ const enriched = enrichInstalledRows(packages as any);
73
+ return { success: true, data: enriched } satisfies ApiResponse;
66
74
  } catch (err: any) {
67
75
  return { success: false, error: err.message } satisfies ApiResponse;
68
76
  }
@@ -157,6 +165,67 @@ export function registerPackageRoutes(
157
165
 
158
166
  // ── Check for updates ───────────────────────────────────────────
159
167
 
168
+ // Move package between scopes (see change: unify-package-management-ui)
169
+ fastify.post<{
170
+ Body: {
171
+ entry?: PackageEntry;
172
+ fromScope?: string;
173
+ fromCwd?: string;
174
+ toScope?: string;
175
+ toCwd?: string;
176
+ };
177
+ }>("/api/packages/move", async (request, reply) => {
178
+ const body = request.body ?? {};
179
+ const { entry, fromCwd, toCwd } = body;
180
+ const fromScope = body.fromScope === "local" ? "local" : body.fromScope === "global" ? "global" : null;
181
+ const toScope = body.toScope === "local" ? "local" : body.toScope === "global" ? "global" : null;
182
+
183
+ if (!entry || (typeof entry !== "string" && (typeof entry !== "object" || !entry.source))) {
184
+ reply.code(400);
185
+ return { success: false, error: "entry is required (string or { source, ...filters })" } satisfies ApiResponse;
186
+ }
187
+ if (!fromScope || !toScope) {
188
+ reply.code(400);
189
+ return { success: false, error: "fromScope and toScope are required ('global' or 'local')" } satisfies ApiResponse;
190
+ }
191
+
192
+ try {
193
+ const moveId = await packageManagerWrapper.move({
194
+ entry: entry as PackageEntry,
195
+ fromScope,
196
+ fromCwd,
197
+ toScope,
198
+ toCwd,
199
+ });
200
+ const sourceStr = typeof entry === "string" ? entry : entry.source;
201
+ const kind = parseSourceKind(sourceStr);
202
+ const phases = kind === "abs-path" || kind === "rel-path"
203
+ ? ["settings-edit" as const]
204
+ : ["install" as const, "remove" as const];
205
+ reply.code(202);
206
+ return { success: true, data: { moveId, phases } } satisfies ApiResponse;
207
+ } catch (err: any) {
208
+ if (err instanceof InvalidMoveRequestError) {
209
+ reply.code(400);
210
+ return { success: false, error: err.message, code: "invalid_request" } as any;
211
+ }
212
+ if (err instanceof UnsupportedSourceForDestinationError) {
213
+ reply.code(400);
214
+ return { success: false, error: err.message, code: "unsupported_source_for_destination" } as any;
215
+ }
216
+ if (err instanceof AlreadyAtDestinationError) {
217
+ reply.code(409);
218
+ return { success: false, error: err.message, code: "already_at_destination" } as any;
219
+ }
220
+ if (err instanceof PackageOperationBusyError) {
221
+ reply.code(409);
222
+ return { success: false, error: err.message, code: "operation_in_flight" } as any;
223
+ }
224
+ reply.code(500);
225
+ return { success: false, error: err?.message ?? String(err) } satisfies ApiResponse;
226
+ }
227
+ });
228
+
160
229
  fastify.post<{ Body: { cwd?: string } }>(
161
230
  "/api/packages/check-updates",
162
231
  async (request) => {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Plugin config REST routes.
3
+ *
4
+ * POST /api/config/plugins/:id — write a partial plugin config.
5
+ * Validates against the plugin's configSchema (if declared).
6
+ * Broadcasts plugin_config_update to all subscribed browsers.
7
+ */
8
+ import type { FastifyInstance } from "fastify";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import os from "node:os";
12
+ import {
13
+ getPluginStatusStore,
14
+ discoverPlugins,
15
+ } from "@blackbelt-technology/dashboard-plugin-runtime/server";
16
+ import {
17
+ validatePluginConfig,
18
+ applySchemaDefaults,
19
+ } from "@blackbelt-technology/dashboard-plugin-runtime/server";
20
+ import type { NetworkGuard } from "./route-deps.js";
21
+ import type { ServerToBrowserMessage } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
22
+
23
+ const CONFIG_DIR = path.join(os.homedir(), ".pi", "dashboard");
24
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
25
+
26
+ function readRawConfig(): Record<string, unknown> {
27
+ try {
28
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
29
+ return JSON.parse(raw);
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ function writeRawConfig(merged: Record<string, unknown>): void {
36
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
37
+ const tmpFile = CONFIG_FILE + ".tmp." + process.pid;
38
+ fs.writeFileSync(tmpFile, JSON.stringify(merged, null, 2) + "\n");
39
+ fs.renameSync(tmpFile, CONFIG_FILE);
40
+ }
41
+
42
+ function loadSchemaForPlugin(
43
+ pluginId: string,
44
+ repoRoot?: string,
45
+ ): Record<string, unknown> | null {
46
+ const plugins = discoverPlugins(repoRoot);
47
+ const plugin = plugins.find(p => p.manifest.id === pluginId);
48
+ if (!plugin?.manifest.configSchema) return null;
49
+ const schemaPath = path.resolve(plugin.packageDir, plugin.manifest.configSchema);
50
+ try {
51
+ return JSON.parse(fs.readFileSync(schemaPath, "utf-8"));
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ export function registerPluginConfigRoutes(
58
+ fastify: FastifyInstance,
59
+ deps: {
60
+ networkGuard: NetworkGuard;
61
+ broadcast: (msg: ServerToBrowserMessage) => void;
62
+ repoRoot?: string;
63
+ },
64
+ ) {
65
+ const { networkGuard, broadcast, repoRoot } = deps;
66
+
67
+ fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
68
+ "/api/config/plugins/:id",
69
+ { preHandler: networkGuard },
70
+ async (request, reply) => {
71
+ const { id } = request.params;
72
+
73
+ const store = getPluginStatusStore();
74
+ const status = store.getStatus(id);
75
+
76
+ if (!status) {
77
+ return reply.status(404).send({ success: false, error: `Plugin "${id}" not found` });
78
+ }
79
+
80
+ if (!status.enabled) {
81
+ return reply.status(409).send({
82
+ success: false,
83
+ error: `Plugin "${id}" is disabled. Enable it before writing config.`,
84
+ });
85
+ }
86
+
87
+ const body = request.body ?? {};
88
+
89
+ // Validate against schema if the plugin has one
90
+ const schema = loadSchemaForPlugin(id, repoRoot);
91
+ if (schema) {
92
+ try {
93
+ validatePluginConfig(id, body as Record<string, unknown>, schema);
94
+ } catch (e: unknown) {
95
+ return reply.status(400).send({
96
+ success: false,
97
+ error: e instanceof Error ? e.message : String(e),
98
+ });
99
+ }
100
+ }
101
+
102
+ // Read existing config, merge, write
103
+ const existing = readRawConfig();
104
+ const existingPlugins = (existing.plugins as Record<string, unknown> | undefined) ?? {};
105
+ const existingPluginConfig =
106
+ (existingPlugins[id] as Record<string, unknown> | undefined) ?? {};
107
+
108
+ let merged = { ...existingPluginConfig, ...body };
109
+
110
+ // Apply schema defaults to merged result
111
+ if (schema) {
112
+ merged = applySchemaDefaults(merged, schema);
113
+ }
114
+
115
+ const updatedPlugins = { ...existingPlugins, [id]: merged };
116
+ const updatedConfig = { ...existing, plugins: updatedPlugins };
117
+ writeRawConfig(updatedConfig);
118
+
119
+ // Broadcast to all subscribed browsers
120
+ broadcast({
121
+ type: "plugin_config_update",
122
+ id,
123
+ config: merged,
124
+ });
125
+
126
+ return reply.status(200).send({ success: true, config: merged });
127
+ },
128
+ );
129
+ }
@@ -18,6 +18,7 @@ import { spawn } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.j
18
18
  import path from "node:path";
19
19
  import os from "node:os";
20
20
  import { localhostGuard, netmaskToCidrBits, networkAddress } from "../localhost-guard.js";
21
+ import { getPluginStatusStore } from "@blackbelt-technology/dashboard-plugin-runtime/server";
21
22
  import type { NetworkInterface } from "@blackbelt-technology/pi-dashboard-shared/rest-api.js";
22
23
 
23
24
  export function registerSystemRoutes(
@@ -184,6 +185,7 @@ export function registerSystemRoutes(
184
185
  totalSessions: sessionManager.listAll().length,
185
186
  },
186
187
  agents: agentMetrics,
188
+ plugins: getPluginStatusStore().listAll(),
187
189
  };
188
190
  });
189
191