@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.
- package/AGENTS.md +79 -32
- package/README.md +7 -3
- package/docs/architecture.md +361 -12
- package/package.json +7 -7
- package/packages/extension/package.json +7 -2
- package/packages/extension/src/__tests__/ask-user-schema-discriminator.test.ts +141 -0
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +51 -7
- package/packages/extension/src/__tests__/multiselect-dashboard-routing.test.ts +203 -0
- package/packages/extension/src/__tests__/multiselect-polyfill.test.ts +92 -0
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +81 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +37 -0
- package/packages/extension/src/__tests__/ui-decorators.test.ts +309 -0
- package/packages/extension/src/__tests__/ui-modules.test.ts +293 -0
- package/packages/extension/src/ask-user-tool.ts +165 -57
- package/packages/extension/src/bridge.ts +97 -4
- package/packages/extension/src/multiselect-decode.ts +40 -0
- package/packages/extension/src/multiselect-polyfill.ts +38 -8
- package/packages/extension/src/ui-modules.ts +272 -0
- package/packages/server/package.json +9 -3
- package/packages/server/src/__tests__/auto-attach.test.ts +61 -8
- package/packages/server/src/__tests__/browse-endpoint.test.ts +295 -19
- package/packages/server/src/__tests__/cli-bootstrap.test.ts +36 -0
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +163 -0
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +315 -0
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +303 -0
- package/packages/server/src/__tests__/directory-service.test.ts +174 -0
- package/packages/server/src/__tests__/installed-package-enricher.test.ts +225 -0
- package/packages/server/src/__tests__/package-manager-wrapper-move.test.ts +414 -0
- package/packages/server/src/__tests__/package-routes.test.ts +136 -3
- package/packages/server/src/__tests__/package-source-helpers.test.ts +101 -0
- package/packages/server/src/__tests__/pending-attach-registry.test.ts +123 -0
- package/packages/server/src/__tests__/pending-resume-intent-registry.test.ts +138 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +73 -30
- package/packages/server/src/__tests__/pi-gateway-consume-pending-attach.test.ts +112 -0
- package/packages/server/src/__tests__/post-install-openspec-refresh.test.ts +180 -0
- package/packages/server/src/__tests__/post-install-rescan.test.ts +134 -0
- package/packages/server/src/__tests__/proposal-attach-naming.test.ts +79 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-with-attach.test.ts +108 -0
- package/packages/server/src/__tests__/session-order-manager.test.ts +55 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +242 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +44 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +40 -0
- package/packages/server/src/__tests__/translate-path-source.test.ts +77 -0
- package/packages/server/src/__tests__/ui-decorators-replay.test.ts +209 -0
- package/packages/server/src/__tests__/ui-modules-replay.test.ts +221 -0
- package/packages/server/src/browse.ts +118 -13
- package/packages/server/src/browser-gateway.ts +19 -0
- package/packages/server/src/browser-handlers/__tests__/session-meta-handler.test.ts +183 -0
- package/packages/server/src/browser-handlers/directory-handler.ts +7 -1
- package/packages/server/src/browser-handlers/handler-context.ts +15 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +29 -3
- package/packages/server/src/browser-handlers/session-meta-handler.ts +46 -12
- package/packages/server/src/browser-handlers/subscription-handler.ts +46 -1
- package/packages/server/src/cli.ts +5 -6
- package/packages/server/src/directory-service.ts +156 -15
- package/packages/server/src/event-wiring.ts +111 -10
- package/packages/server/src/installed-package-enricher.ts +143 -0
- package/packages/server/src/package-manager-wrapper.ts +305 -8
- package/packages/server/src/package-source-helpers.ts +104 -0
- package/packages/server/src/pending-attach-registry.ts +112 -0
- package/packages/server/src/pending-resume-intent-registry.ts +107 -0
- package/packages/server/src/pi-core-checker.ts +9 -14
- package/packages/server/src/pi-gateway.ts +14 -0
- package/packages/server/src/proposal-attach-naming.ts +47 -0
- package/packages/server/src/routes/file-routes.ts +29 -3
- package/packages/server/src/routes/package-routes.ts +72 -3
- package/packages/server/src/routes/plugin-config-routes.ts +129 -0
- package/packages/server/src/routes/system-routes.ts +2 -0
- package/packages/server/src/server.ts +339 -10
- package/packages/server/src/session-api.ts +30 -5
- package/packages/server/src/session-order-manager.ts +22 -0
- package/packages/server/src/session-scanner.ts +10 -1
- package/packages/shared/package.json +9 -2
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +59 -0
- package/packages/shared/src/__tests__/config-plugins.test.ts +68 -0
- package/packages/shared/src/__tests__/extension-ui-module-shape.test.ts +265 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +81 -0
- package/packages/shared/src/__tests__/openspec-design-evidence.test.ts +288 -0
- package/packages/shared/src/__tests__/openspec-effective-status-script.test.ts +174 -0
- package/packages/shared/src/__tests__/openspec-poller-design-override.test.ts +225 -0
- package/packages/shared/src/__tests__/openspec-poller-specs-override.test.ts +284 -0
- package/packages/shared/src/__tests__/openspec-specs-evidence.test.ts +144 -0
- package/packages/shared/src/__tests__/platform/is-appimage-self-hit.test.ts +164 -0
- package/packages/shared/src/__tests__/plugin-bridge-register-extended.test.ts +72 -0
- package/packages/shared/src/__tests__/plugin-bridge-register.test.ts +113 -0
- package/packages/shared/src/__tests__/plugin-config-update-protocol.test.ts +41 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +5 -1
- package/packages/shared/src/__tests__/spawn-session-attach-proposal.test.ts +47 -0
- package/packages/shared/src/__tests__/tool-registry-strategies-appimage.test.ts +118 -0
- package/packages/shared/src/browser-protocol.ts +110 -4
- package/packages/shared/src/config.ts +45 -0
- package/packages/shared/src/dashboard-plugin/index.ts +11 -0
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +58 -0
- package/packages/shared/src/dashboard-plugin/plugin-status.ts +26 -0
- package/packages/shared/src/dashboard-plugin/slot-props.ts +92 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +151 -0
- package/packages/shared/src/openspec-activity-detector.ts +18 -22
- package/packages/shared/src/openspec-design-evidence.ts +109 -0
- package/packages/shared/src/openspec-poller.ts +117 -3
- package/packages/shared/src/openspec-specs-evidence.ts +79 -0
- package/packages/shared/src/platform/binary-lookup.ts +96 -1
- package/packages/shared/src/plugin-bridge-register.ts +139 -0
- package/packages/shared/src/protocol.ts +56 -2
- package/packages/shared/src/recommended-extensions.ts +7 -1
- package/packages/shared/src/rest-api.ts +68 -3
- package/packages/shared/src/state-replay.ts +11 -1
- package/packages/shared/src/tool-registry/strategies.ts +17 -3
- 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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|