@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +339 -190
- package/README.md +31 -0
- package/docs/architecture.md +238 -23
- package/package.json +14 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
- package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
- package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
- package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
- package/packages/extension/src/bridge.ts +110 -1
- package/packages/extension/src/command-handler.ts +6 -0
- package/packages/extension/src/markdown-image-inliner.ts +268 -0
- package/packages/extension/src/prompt-expander.ts +50 -2
- package/packages/extension/src/provider-register.ts +117 -0
- package/packages/extension/src/server-launcher.ts +18 -1
- package/packages/extension/src/session-sync.ts +5 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
- package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
- package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
- package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
- package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
- package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
- package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
- package/packages/server/src/__tests__/health-shape.test.ts +43 -0
- package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
- package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
- package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
- package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
- package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
- package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
- package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
- package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
- package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
- package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
- package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
- package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
- package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
- package/packages/server/src/bootstrap-install-from-list.ts +232 -0
- package/packages/server/src/bootstrap-state.ts +18 -0
- package/packages/server/src/browser-gateway.ts +58 -21
- package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
- package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
- package/packages/server/src/cli.ts +21 -0
- package/packages/server/src/directory-service.ts +31 -0
- package/packages/server/src/event-wiring.ts +48 -2
- package/packages/server/src/home-lock.d.ts +124 -0
- package/packages/server/src/home-lock.js +330 -0
- package/packages/server/src/home-lock.js.map +1 -0
- package/packages/server/src/idle-timer.ts +15 -1
- package/packages/server/src/pi-core-updater.ts +65 -9
- package/packages/server/src/pi-gateway.ts +6 -0
- package/packages/server/src/process-manager.ts +62 -11
- package/packages/server/src/provider-auth-handlers.ts +9 -0
- package/packages/server/src/provider-auth-storage.ts +83 -51
- package/packages/server/src/provider-catalogue-cache.ts +41 -0
- package/packages/server/src/routes/doctor-routes.ts +140 -0
- package/packages/server/src/routes/provider-auth-routes.ts +9 -0
- package/packages/server/src/routes/system-routes.ts +38 -1
- package/packages/server/src/server.ts +8 -7
- package/packages/server/src/session-bootstrap.ts +27 -12
- package/packages/server/src/session-discovery.ts +10 -3
- package/packages/server/src/session-scanner.ts +4 -2
- package/packages/server/src/spawn-failure-log.ts +130 -0
- package/packages/server/src/spawn-preflight.ts +82 -0
- package/packages/server/src/spawn-register-watchdog.ts +236 -0
- package/packages/server/src/terminal-manager.ts +12 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
- package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
- package/packages/shared/src/__tests__/config.test.ts +48 -0
- package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
- package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
- package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
- package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
- package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
- package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
- package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
- package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
- package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
- package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
- package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
- package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
- package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
- package/packages/shared/src/bootstrap-install.ts +196 -2
- package/packages/shared/src/browser-protocol.ts +112 -1
- package/packages/shared/src/config.ts +15 -0
- package/packages/shared/src/dashboard-starter.ts +33 -0
- package/packages/shared/src/doctor-core.ts +821 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/installable-list.ts +152 -0
- package/packages/shared/src/launch-source-flag.ts +14 -0
- package/packages/shared/src/launch-source-types.ts +18 -0
- package/packages/shared/src/openspec-activity-detector.ts +25 -7
- package/packages/shared/src/platform/detached-spawn.ts +13 -2
- package/packages/shared/src/platform/managed-node-path.ts +77 -0
- package/packages/shared/src/protocol.ts +46 -2
- package/packages/shared/src/rest-api.ts +4 -0
- package/packages/shared/src/skill-block-parser.ts +115 -0
- package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
- package/packages/shared/src/tool-registry/definitions.ts +18 -5
- package/packages/shared/src/tool-registry/strategies.ts +42 -0
- package/packages/shared/src/types.ts +57 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel re-exports for selected shared symbols. Most consumers import
|
|
3
|
+
* directly from per-file paths (`@blackbelt-technology/pi-dashboard-shared/<file>.js`)
|
|
4
|
+
* via the package's `exports` map. This barrel exists for symbols that
|
|
5
|
+
* would otherwise be cumbersome to wire — currently the doctor core.
|
|
6
|
+
*
|
|
7
|
+
* Added by change: doctor-rich-output.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./doctor-core.js";
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* installable-list.ts — types and helpers for ~/.pi/dashboard/installable.json.
|
|
3
|
+
*
|
|
4
|
+
* This file describes the set of packages the dashboard needs installed.
|
|
5
|
+
* Electron seeds the file on first run; Bridge / Standalone ignore it (file-absent
|
|
6
|
+
* path is a no-op in the server bootstrap). The server reads it during bootstrap.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
export type InstallableKind = "npm" | "pi-extension";
|
|
13
|
+
|
|
14
|
+
export interface InstallablePackage {
|
|
15
|
+
name: string;
|
|
16
|
+
/** Semver range or "*". */
|
|
17
|
+
version: string;
|
|
18
|
+
required: boolean;
|
|
19
|
+
kind: InstallableKind;
|
|
20
|
+
deprecated?: boolean;
|
|
21
|
+
defaultOff?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface InstallableList {
|
|
25
|
+
version: string;
|
|
26
|
+
packages: InstallablePackage[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MergeResult {
|
|
30
|
+
list: InstallableList;
|
|
31
|
+
warnings: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const VALID_KINDS: ReadonlySet<string> = new Set<InstallableKind>(["npm", "pi-extension"]);
|
|
35
|
+
|
|
36
|
+
function defaultConfigDir(): string {
|
|
37
|
+
return path.join(os.homedir(), ".pi", "dashboard");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function installablePath(configDir: string): string {
|
|
41
|
+
return path.join(configDir, "installable.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read `~/.pi/dashboard/installable.json` (or `configDir/installable.json`).
|
|
46
|
+
*
|
|
47
|
+
* Returns `null` when the file is absent. Logs a warning and drops entries
|
|
48
|
+
* with an invalid `kind` field. Does NOT create the file.
|
|
49
|
+
*/
|
|
50
|
+
export async function readInstallableList(
|
|
51
|
+
configDir?: string,
|
|
52
|
+
): Promise<InstallableList | null> {
|
|
53
|
+
const dir = configDir ?? defaultConfigDir();
|
|
54
|
+
const filePath = installablePath(dir);
|
|
55
|
+
|
|
56
|
+
let raw: string;
|
|
57
|
+
try {
|
|
58
|
+
raw = await fs.promises.readFile(filePath, "utf-8");
|
|
59
|
+
} catch (err: any) {
|
|
60
|
+
if (err.code === "ENOENT") return null;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parsed = JSON.parse(raw) as InstallableList;
|
|
65
|
+
|
|
66
|
+
const validPackages: InstallablePackage[] = [];
|
|
67
|
+
for (const pkg of parsed.packages ?? []) {
|
|
68
|
+
if (!VALID_KINDS.has(pkg.kind)) {
|
|
69
|
+
console.warn(
|
|
70
|
+
`[installable-list] Dropping entry "${pkg.name}" with unknown kind "${pkg.kind}".`,
|
|
71
|
+
);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
validPackages.push(pkg);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { version: parsed.version, packages: validPackages };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Atomically write `list` to `configDir/installable.json`.
|
|
82
|
+
* Writes a temp file then renames so readers never see a partial write.
|
|
83
|
+
*/
|
|
84
|
+
export async function writeInstallableList(
|
|
85
|
+
list: InstallableList,
|
|
86
|
+
configDir?: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const dir = configDir ?? defaultConfigDir();
|
|
89
|
+
const filePath = installablePath(dir);
|
|
90
|
+
const tmpPath = filePath + ".tmp." + process.pid;
|
|
91
|
+
|
|
92
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
93
|
+
await fs.promises.writeFile(tmpPath, JSON.stringify(list, null, 2), "utf-8");
|
|
94
|
+
await fs.promises.rename(tmpPath, filePath);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pure merge: reconcile a user's `existing` list against the `bundled` defaults.
|
|
99
|
+
*
|
|
100
|
+
* Rules:
|
|
101
|
+
* - Package in both: keep user's pinned version (user wins); emit a warning when
|
|
102
|
+
* the bundled default differs.
|
|
103
|
+
* - Package in existing but NOT in bundled: keep it, set `deprecated: true`, emit warning.
|
|
104
|
+
* - New `required: true` package in bundled: add it.
|
|
105
|
+
* - New `required: false` package in bundled: add it with `defaultOff: true`.
|
|
106
|
+
* - Result version marker comes from `bundled.version`.
|
|
107
|
+
*/
|
|
108
|
+
export function mergeInstallableList(
|
|
109
|
+
existing: InstallableList,
|
|
110
|
+
bundled: InstallableList,
|
|
111
|
+
): MergeResult {
|
|
112
|
+
const warnings: string[] = [];
|
|
113
|
+
const bundledMap = new Map(bundled.packages.map((p) => [p.name, p]));
|
|
114
|
+
const existingMap = new Map(existing.packages.map((p) => [p.name, p]));
|
|
115
|
+
|
|
116
|
+
const merged: InstallablePackage[] = [];
|
|
117
|
+
|
|
118
|
+
// Walk existing packages.
|
|
119
|
+
for (const pkg of existing.packages) {
|
|
120
|
+
const bundledPkg = bundledMap.get(pkg.name);
|
|
121
|
+
if (!bundledPkg) {
|
|
122
|
+
// Dropped in bundled → deprecate.
|
|
123
|
+
warnings.push(
|
|
124
|
+
`Package "${pkg.name}" is no longer in the bundled list and has been marked deprecated.`,
|
|
125
|
+
);
|
|
126
|
+
merged.push({ ...pkg, deprecated: true });
|
|
127
|
+
} else {
|
|
128
|
+
// Present in both → user version wins.
|
|
129
|
+
if (pkg.version !== bundledPkg.version) {
|
|
130
|
+
warnings.push(
|
|
131
|
+
`Package "${pkg.name}" is pinned at "${pkg.version}" (bundled default: "${bundledPkg.version}"). User pin preserved.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
merged.push({ ...pkg });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Walk bundled packages not yet in existing.
|
|
139
|
+
for (const bundledPkg of bundled.packages) {
|
|
140
|
+
if (existingMap.has(bundledPkg.name)) continue;
|
|
141
|
+
if (!bundledPkg.required) {
|
|
142
|
+
merged.push({ ...bundledPkg, defaultOff: true });
|
|
143
|
+
} else {
|
|
144
|
+
merged.push({ ...bundledPkg });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
list: { version: bundled.version, packages: merged },
|
|
150
|
+
warnings,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature flag for the LaunchSource V2 resolver.
|
|
3
|
+
* Default: true (Phase C cutover). Set LAUNCH_SOURCE_V2=false to revert to
|
|
4
|
+
* the legacy wizard + mode.json path for debugging.
|
|
5
|
+
*
|
|
6
|
+
* TODO: remove LAUNCH_SOURCE_V2 flag in follow-up change after Phase C ships
|
|
7
|
+
* without regressions for one release cycle. The flag and its CI matrix
|
|
8
|
+
* entry should be deleted at that point.
|
|
9
|
+
*/
|
|
10
|
+
export function isLaunchSourceV2Enabled(env: Record<string, string | undefined>): boolean {
|
|
11
|
+
const val = env["LAUNCH_SOURCE_V2"];
|
|
12
|
+
if (val === undefined) return true; // default ON in Phase C
|
|
13
|
+
return val === "true" || val === "1";
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LaunchSource — discriminated union describing how the dashboard server was (or should be) started.
|
|
3
|
+
*
|
|
4
|
+
* "attach" — a server is already running; Electron attaches to it.
|
|
5
|
+
* "devMonorepo" — running from a checked-out monorepo (dev workflow).
|
|
6
|
+
* "piExtension" — the pi bridge extension owns a server package in node_modules.
|
|
7
|
+
* "npmGlobal" — `pi-dashboard` is installed globally via npm.
|
|
8
|
+
* "extracted" — bundled Electron resources provide the server (managed install).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type SourceKind = "attach" | "devMonorepo" | "piExtension" | "npmGlobal" | "extracted";
|
|
12
|
+
|
|
13
|
+
export type LaunchSource =
|
|
14
|
+
| { kind: "attach"; url: string; starter: "Bridge" | "Standalone" | "Electron" }
|
|
15
|
+
| { kind: "devMonorepo"; cliPath: string; cwd: string }
|
|
16
|
+
| { kind: "piExtension"; cliPath: string; cwd: string }
|
|
17
|
+
| { kind: "npmGlobal"; cliPath: string; cwd: string }
|
|
18
|
+
| { kind: "extracted"; cliPath: string; cwd: string; didExtract?: boolean };
|
|
@@ -40,6 +40,22 @@ const CLI_ARCHIVE_RE = /openspec\s+archive\s+["']?([^\s"']+)["']?/;
|
|
|
40
40
|
/** Regex to match openspec new change "name" (positional arg) */
|
|
41
41
|
const CLI_NEW_CHANGE_RE = /openspec\s+new\s+change\s+["']?([^\s"']+)["']?/;
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* OpenSpec change-slug shape: lowercase kebab-case, must start with a letter,
|
|
45
|
+
* max 64 characters. Mirrors the validation enforced by `openspec new change`.
|
|
46
|
+
*
|
|
47
|
+
* Single source of truth for any code that needs to gate a captured token
|
|
48
|
+
* before treating it as an OpenSpec change name (detector + auto-attach
|
|
49
|
+
* defense-in-depth in event-wiring.ts).
|
|
50
|
+
*
|
|
51
|
+
* See change: fix-uuid-rename-bug.
|
|
52
|
+
*/
|
|
53
|
+
const OPENSPEC_CHANGE_SLUG_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
54
|
+
|
|
55
|
+
export function isValidOpenSpecChangeSlug(name: string): boolean {
|
|
56
|
+
return OPENSPEC_CHANGE_SLUG_RE.test(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
export function detectOpenSpecActivity(
|
|
44
60
|
toolName: string,
|
|
45
61
|
args: Record<string, unknown> | undefined,
|
|
@@ -63,7 +79,7 @@ export function detectOpenSpecActivity(
|
|
|
63
79
|
|
|
64
80
|
// Check for openspec change file read → change name detection (passive)
|
|
65
81
|
const changeMatch = path.match(CHANGE_PATH_RE);
|
|
66
|
-
if (changeMatch) {
|
|
82
|
+
if (changeMatch && isValidOpenSpecChangeSlug(changeMatch[1])) {
|
|
67
83
|
return { changeName: changeMatch[1], isActive: false };
|
|
68
84
|
}
|
|
69
85
|
|
|
@@ -75,7 +91,7 @@ export function detectOpenSpecActivity(
|
|
|
75
91
|
if (!path) return null;
|
|
76
92
|
|
|
77
93
|
const changeMatch = path.match(CHANGE_PATH_RE);
|
|
78
|
-
if (changeMatch) {
|
|
94
|
+
if (changeMatch && isValidOpenSpecChangeSlug(changeMatch[1])) {
|
|
79
95
|
return { changeName: changeMatch[1], isActive: true };
|
|
80
96
|
}
|
|
81
97
|
|
|
@@ -94,11 +110,13 @@ export function detectOpenSpecActivity(
|
|
|
94
110
|
if (!match) return null;
|
|
95
111
|
|
|
96
112
|
const name = match[1];
|
|
97
|
-
// Reject
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
113
|
+
// Reject any token that is not a valid OpenSpec change slug. Subsumes the
|
|
114
|
+
// earlier `-`-prefix guard (a leading `-` fails the `[a-z]` first-char
|
|
115
|
+
// class) and additionally rejects UUIDs, mixed-case, underscored, or
|
|
116
|
+
// overlong tokens that the CLI regexes' `[^\s"']+` capture group would
|
|
117
|
+
// otherwise pass through into auto-attach + auto-rename.
|
|
118
|
+
// See changes: fix-openspec-flag-rename-bug, fix-uuid-rename-bug.
|
|
119
|
+
if (!isValidOpenSpecChangeSlug(name)) return null;
|
|
102
120
|
|
|
103
121
|
return { changeName: name, isActive: true };
|
|
104
122
|
}
|
|
@@ -47,10 +47,18 @@ export interface SpawnDetachedOptions {
|
|
|
47
47
|
/** Environment for the child. Defaults to `process.env` via Node. */
|
|
48
48
|
env?: NodeJS.ProcessEnv;
|
|
49
49
|
/**
|
|
50
|
-
* Optional file descriptor for stderr. When omitted,
|
|
50
|
+
* Optional file descriptor for combined stdout + stderr. When omitted,
|
|
51
|
+
* BOTH stdout and stderr are "ignore".
|
|
52
|
+
*
|
|
51
53
|
* Caller is responsible for `fs.openSync(logPath, "a")` and closing the
|
|
52
54
|
* parent's copy after spawn (the child retains its dup via stdio
|
|
53
55
|
* inheritance). File fds survive parent death; pipes do not.
|
|
56
|
+
*
|
|
57
|
+
* Routing both streams to a single fd matches what the standalone CLI
|
|
58
|
+
* (`packages/server/src/cli.ts`) does directly with raw `spawn()`. Prior
|
|
59
|
+
* to change `fix-electron-extracted-jiti-and-stdio-capture`, only stderr
|
|
60
|
+
* was captured and clean-startup `console.log` output was silently
|
|
61
|
+
* dropped (resulting in 0-byte server.log files).
|
|
54
62
|
*/
|
|
55
63
|
logFd?: number;
|
|
56
64
|
/**
|
|
@@ -123,7 +131,10 @@ export interface SpawnDetachedResult {
|
|
|
123
131
|
*/
|
|
124
132
|
export async function spawnDetached(opts: SpawnDetachedOptions): Promise<SpawnDetachedResult> {
|
|
125
133
|
const stdioIn: "ignore" | "pipe" = opts.stdinMode ?? "ignore";
|
|
126
|
-
|
|
134
|
+
// logFd applies to BOTH stdout and stderr. See JSDoc on logFd above and
|
|
135
|
+
// change: fix-electron-extracted-jiti-and-stdio-capture.
|
|
136
|
+
const outFd: "ignore" | number = opts.logFd ?? "ignore";
|
|
137
|
+
const stdio: ("ignore" | "pipe" | number)[] = [stdioIn, outFd, outFd];
|
|
127
138
|
|
|
128
139
|
let child: ChildProcess;
|
|
129
140
|
let spawnError: string | null = null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prepend the managed Node runtime directory to a child-process `PATH`.
|
|
3
|
+
*
|
|
4
|
+
* The managed Node lives at `<managedDir>/node/` (Windows: binaries at
|
|
5
|
+
* the root; Unix: binaries under `bin/`). When present, every spawn the
|
|
6
|
+
* dashboard controls SHALL inherit that directory at the front of its
|
|
7
|
+
* `PATH` so plain `node` / `npm` invocations inside the child process
|
|
8
|
+
* resolve to the managed runtime.
|
|
9
|
+
*
|
|
10
|
+
* Pure helper: never mutates `process.env`, returns a distinct cloned
|
|
11
|
+
* env object, no-ops when the managed runtime is absent.
|
|
12
|
+
*
|
|
13
|
+
* See change: embed-managed-node-runtime (spec: managed-node-runtime,
|
|
14
|
+
* Requirement: Spawned children inherit managed Node on PATH).
|
|
15
|
+
*/
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { existsSync } from "node:fs";
|
|
18
|
+
import { getManagedDir, type ManagedPathsEnv } from "../managed-paths.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the bin directory inside the managed Node tree.
|
|
22
|
+
* Windows: `<managedDir>/node` (node.exe + npm.cmd live at root)
|
|
23
|
+
* Unix: `<managedDir>/node/bin` (node, npm, npx live under bin/)
|
|
24
|
+
*
|
|
25
|
+
* `platform` defaults to `process.platform`; tests inject `"win32"` from
|
|
26
|
+
* a Linux host to exercise the Windows layout.
|
|
27
|
+
*/
|
|
28
|
+
export function getManagedNodeBinDir(
|
|
29
|
+
env?: ManagedPathsEnv,
|
|
30
|
+
platform: NodeJS.Platform = process.platform,
|
|
31
|
+
): string {
|
|
32
|
+
const root = path.join(getManagedDir(env), "node");
|
|
33
|
+
return platform === "win32" ? root : path.join(root, "bin");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Path to the managed `node` / `node.exe` binary. */
|
|
37
|
+
export function getManagedNodeBinary(
|
|
38
|
+
env?: ManagedPathsEnv,
|
|
39
|
+
platform: NodeJS.Platform = process.platform,
|
|
40
|
+
): string {
|
|
41
|
+
const bin = getManagedNodeBinDir(env, platform);
|
|
42
|
+
return path.join(bin, platform === "win32" ? "node.exe" : "node");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True iff the managed Node runtime is installed (binary exists). */
|
|
46
|
+
export function isManagedNodePresent(
|
|
47
|
+
env?: ManagedPathsEnv,
|
|
48
|
+
platform: NodeJS.Platform = process.platform,
|
|
49
|
+
): boolean {
|
|
50
|
+
return existsSync(getManagedNodeBinary(env, platform));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return a shallow-cloned env with the managed Node bin directory
|
|
55
|
+
* prepended to `PATH`. No-op (still returns a clone) when the managed
|
|
56
|
+
* runtime is not installed or its directory is already on PATH.
|
|
57
|
+
*
|
|
58
|
+
* Never mutates the input env or `process.env`.
|
|
59
|
+
*/
|
|
60
|
+
export function prependManagedNodeToPath(
|
|
61
|
+
baseEnv: NodeJS.ProcessEnv = process.env,
|
|
62
|
+
managedPathsEnv?: ManagedPathsEnv,
|
|
63
|
+
): NodeJS.ProcessEnv {
|
|
64
|
+
const cloned: NodeJS.ProcessEnv = { ...baseEnv };
|
|
65
|
+
if (!isManagedNodePresent(managedPathsEnv)) return cloned;
|
|
66
|
+
|
|
67
|
+
const dir = getManagedNodeBinDir(managedPathsEnv);
|
|
68
|
+
const currentPath = cloned.PATH ?? "";
|
|
69
|
+
// Avoid duplicate prepends when the dir is already at the head; cheap
|
|
70
|
+
// string contains check matches `buildSpawnEnv` style.
|
|
71
|
+
if (currentPath.split(path.delimiter).includes(dir)) return cloned;
|
|
72
|
+
|
|
73
|
+
cloned.PATH = currentPath
|
|
74
|
+
? `${dir}${path.delimiter}${currentPath}`
|
|
75
|
+
: dir;
|
|
76
|
+
return cloned;
|
|
77
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extension ↔ Server WebSocket protocol messages.
|
|
3
3
|
*/
|
|
4
|
-
import type { DashboardEvent, CommandInfo, FlowInfo, SessionSource, ImageContent, FileEntry, TurnUsage, ContextUsage, ModelInfo, PiSessionInfo, OpenSpecPhase, RoleInfo, ExtensionUiModule, DecoratorDescriptor } from "./types.js";
|
|
4
|
+
import type { DashboardEvent, CommandInfo, FlowInfo, SessionSource, ImageContent, FileEntry, TurnUsage, ContextUsage, ModelInfo, ProviderInfo, PiSessionInfo, OpenSpecPhase, RoleInfo, ExtensionUiModule, DecoratorDescriptor } from "./types.js";
|
|
5
5
|
|
|
6
6
|
// ── Extension → Server ──────────────────────────────────────────────
|
|
7
7
|
|
|
@@ -151,6 +151,17 @@ export interface ModelsListMessage {
|
|
|
151
151
|
models: ModelInfo[];
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Bridge -> server: pi's live provider catalogue derived from
|
|
156
|
+
* `modelRegistry.authStorage` + `modelRegistry.getProviderDisplayName`.
|
|
157
|
+
* Sent alongside ModelsListMessage. See change: replace-hardcoded-provider-lists.
|
|
158
|
+
*/
|
|
159
|
+
export interface ProvidersListMessage {
|
|
160
|
+
type: "providers_list";
|
|
161
|
+
sessionId: string;
|
|
162
|
+
providers: ProviderInfo[];
|
|
163
|
+
}
|
|
164
|
+
|
|
154
165
|
export interface SessionNameUpdateMessage {
|
|
155
166
|
type: "session_name_update";
|
|
156
167
|
sessionId: string;
|
|
@@ -286,6 +297,27 @@ export interface ExtUiDecoratorMessage {
|
|
|
286
297
|
removed?: boolean;
|
|
287
298
|
}
|
|
288
299
|
|
|
300
|
+
// ── Markdown asset inlining (chat-markdown-local-images-and-math) ──
|
|
301
|
+
//
|
|
302
|
+
// Bridge → server: register a base64-encoded image asset under a content
|
|
303
|
+
// hash. Emitted by the bridge BEFORE the `message_update` / `message_end`
|
|
304
|
+
// event whose text references `pi-asset:<hash>`. Bytes ride exactly once
|
|
305
|
+
// per (session, hash) pair — subsequent references in later events emit
|
|
306
|
+
// no further `asset_register`. Persisted in `events.jsonl` alongside the
|
|
307
|
+
// referencing message events so reconnect/replay rebuilds the per-session
|
|
308
|
+
// asset registry deterministically. See change:
|
|
309
|
+
// chat-markdown-local-images-and-math.
|
|
310
|
+
export interface AssetRegisterMessage {
|
|
311
|
+
type: "asset_register";
|
|
312
|
+
sessionId: string;
|
|
313
|
+
/** Content hash (sha256 truncated to 16 hex chars). */
|
|
314
|
+
hash: string;
|
|
315
|
+
/** MIME type (one of the bridge's image allowlist). */
|
|
316
|
+
mimeType: string;
|
|
317
|
+
/** Base64-encoded file bytes. */
|
|
318
|
+
data: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
289
321
|
export type ExtensionToServerMessage =
|
|
290
322
|
| SessionRegisterMessage
|
|
291
323
|
| SessionUnregisterMessage
|
|
@@ -299,6 +331,7 @@ export type ExtensionToServerMessage =
|
|
|
299
331
|
| JjStateUpdateMessage
|
|
300
332
|
| SessionNameUpdateMessage
|
|
301
333
|
| ModelsListMessage
|
|
334
|
+
| ProvidersListMessage
|
|
302
335
|
| ModelUpdateMessage
|
|
303
336
|
| SessionsListExtensionMessage
|
|
304
337
|
| ExtensionUiDismissMessage
|
|
@@ -312,7 +345,8 @@ export type ExtensionToServerMessage =
|
|
|
312
345
|
| ProcessListMessage
|
|
313
346
|
| UiModulesListMessage
|
|
314
347
|
| UiDataListMessage
|
|
315
|
-
| ExtUiDecoratorMessage
|
|
348
|
+
| ExtUiDecoratorMessage
|
|
349
|
+
| AssetRegisterMessage;
|
|
316
350
|
|
|
317
351
|
// ── Server → Extension ──────────────────────────────────────────────
|
|
318
352
|
|
|
@@ -357,6 +391,15 @@ export interface RequestModelsMessage {
|
|
|
357
391
|
sessionId: string;
|
|
358
392
|
}
|
|
359
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Server -> bridge: ask the bridge to push a fresh providers_list.
|
|
396
|
+
* See change: replace-hardcoded-provider-lists.
|
|
397
|
+
*/
|
|
398
|
+
export interface RequestProvidersMessage {
|
|
399
|
+
type: "request_providers";
|
|
400
|
+
sessionId: string;
|
|
401
|
+
}
|
|
402
|
+
|
|
360
403
|
export interface SetThinkingLevelMessage {
|
|
361
404
|
type: "set_thinking_level";
|
|
362
405
|
sessionId: string;
|
|
@@ -514,6 +557,7 @@ export type ServerToExtensionMessage =
|
|
|
514
557
|
| ListFilesMessage
|
|
515
558
|
| RenameSessionExtensionMessage
|
|
516
559
|
| RequestModelsMessage
|
|
560
|
+
| RequestProvidersMessage
|
|
517
561
|
| SetThinkingLevelMessage
|
|
518
562
|
| ListSessionsExtensionMessage
|
|
519
563
|
| SetModelMessage
|
|
@@ -255,6 +255,10 @@ export interface ProviderAuthStatus {
|
|
|
255
255
|
authenticated: boolean;
|
|
256
256
|
expires?: number;
|
|
257
257
|
maskedKey?: string;
|
|
258
|
+
/** Name of the env var pi-ai consults for this provider (api-key rows only). */
|
|
259
|
+
envVar?: string;
|
|
260
|
+
/** True when configured via ambient credential chain (AWS profile / GCP ADC). */
|
|
261
|
+
ambient?: boolean;
|
|
258
262
|
}
|
|
259
263
|
|
|
260
264
|
export interface AuthorizeResponse {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill block parser & builder.
|
|
3
|
+
*
|
|
4
|
+
* Pi's `_expandSkillCommand` (in `@mariozechner/pi-coding-agent`) wraps skill
|
|
5
|
+
* expansions in a `<skill name="..." location="...">…</skill>\n\nargs` envelope.
|
|
6
|
+
* The dashboard's bridge expander (`packages/extension/src/prompt-expander.ts`)
|
|
7
|
+
* aligns to the same byte format. This module is the single source of truth for
|
|
8
|
+
* both producing and recovering that envelope.
|
|
9
|
+
*
|
|
10
|
+
* See change: render-skill-invocations-collapsibly.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface SkillBlock {
|
|
14
|
+
/** Bare skill name (no `skill:` prefix), e.g. `"openspec-explore"`. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** Absolute path to `SKILL.md`. */
|
|
17
|
+
location: string;
|
|
18
|
+
/**
|
|
19
|
+
* Skill body with the `References are relative to <baseDir>.\n\n` preamble
|
|
20
|
+
* stripped — what users see in the card. The preamble is bridge-internal
|
|
21
|
+
* orientation for the LLM and is not interesting to users.
|
|
22
|
+
*
|
|
23
|
+
* Pi's own `parseSkillBlock` returns the unstripped form (calls it `content`).
|
|
24
|
+
* We strip here so the renderer doesn't have to. If the preamble shape ever
|
|
25
|
+
* changes upstream and stripping fails, `body` falls back to the captured
|
|
26
|
+
* content verbatim.
|
|
27
|
+
*/
|
|
28
|
+
body: string;
|
|
29
|
+
/** User text after the skill name. `undefined` when no args were typed. */
|
|
30
|
+
args: string | undefined;
|
|
31
|
+
/** Slash-command form: `"/skill:" + name + (args ? " " + args : "")`. */
|
|
32
|
+
condensed: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Anchored, non-greedy match of a wrapped skill block.
|
|
37
|
+
*
|
|
38
|
+
* Why anchored: prevents a literal `<skill>` substring elsewhere in user text
|
|
39
|
+
* from matching. Why non-greedy + optional trailing args at end-of-string:
|
|
40
|
+
* forces the regex engine to extend the body to the last valid
|
|
41
|
+
* `\n</skill>(\n\nargs)?$` boundary, so SKILL.md bodies that document the
|
|
42
|
+
* `<skill>` tag (e.g. include the literal text in code samples) do not
|
|
43
|
+
* terminate the match prematurely.
|
|
44
|
+
*/
|
|
45
|
+
const SKILL_BLOCK_RE =
|
|
46
|
+
/^<skill name="([^"]+)" location="([^"]+)">\n([\s\S]*?)\n<\/skill>(?:\n\n([\s\S]+))?$/;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a skill block from message text. Returns `null` when the input is not
|
|
50
|
+
* a well-formed skill envelope.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* Strip the `References are relative to <baseDir>.\n\n` preamble from a captured
|
|
54
|
+
* `<skill>` content block. Returns the stripped body, or the input unchanged if
|
|
55
|
+
* the preamble shape doesn't match.
|
|
56
|
+
*/
|
|
57
|
+
function stripReferencesPreamble(content: string): string {
|
|
58
|
+
const m = content.match(/^References are relative to [^\n]+\.\n\n([\s\S]*)$/);
|
|
59
|
+
return m ? m[1] : content;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseSkillBlock(text: string): SkillBlock | null {
|
|
63
|
+
const m = text.match(SKILL_BLOCK_RE);
|
|
64
|
+
if (!m) return null;
|
|
65
|
+
const name = m[1];
|
|
66
|
+
const location = m[2];
|
|
67
|
+
const body = stripReferencesPreamble(m[3]);
|
|
68
|
+
const args = m[4];
|
|
69
|
+
const condensed = `/skill:${name}${args ? " " + args : ""}`;
|
|
70
|
+
return { name, location, body, args, condensed };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface BuildSkillBlockArgs {
|
|
74
|
+
/** Bare skill name (no `skill:` prefix). */
|
|
75
|
+
name: string;
|
|
76
|
+
/** Absolute path to `SKILL.md`. */
|
|
77
|
+
filePath: string;
|
|
78
|
+
/** Skill base directory — `dirname(filePath)`. */
|
|
79
|
+
baseDir: string;
|
|
80
|
+
/** Skill body, frontmatter already stripped. Should be `.trim()`-ed. */
|
|
81
|
+
body: string;
|
|
82
|
+
/** Optional user-typed args appended after the closing tag. */
|
|
83
|
+
userArgs?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a skill block in the exact byte format pi's `_expandSkillCommand`
|
|
88
|
+
* produces. The output is byte-identical to pi's output for the same inputs;
|
|
89
|
+
* `parseSkillBlock(buildSkillBlock(x))` round-trips for `name`, `body`, `args`.
|
|
90
|
+
*/
|
|
91
|
+
export function buildSkillBlock(input: BuildSkillBlockArgs): string {
|
|
92
|
+
const wrapper =
|
|
93
|
+
`<skill name="${input.name}" location="${input.filePath}">\n` +
|
|
94
|
+
`References are relative to ${input.baseDir}.\n\n` +
|
|
95
|
+
`${input.body}\n` +
|
|
96
|
+
`</skill>`;
|
|
97
|
+
return input.userArgs ? `${wrapper}\n\n${input.userArgs}` : wrapper;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Condense a user-message content string for `firstMessage` / display purposes.
|
|
102
|
+
*
|
|
103
|
+
* If `text` parses as a `<skill>` envelope, returns the slash-command form
|
|
104
|
+
* (`/skill:name args`) truncated to `maxLen` chars. Otherwise returns
|
|
105
|
+
* `text.slice(0, maxLen)`. Used by session-scanner / session-discovery so the
|
|
106
|
+
* 200-char `firstMessage` shows the recognisable slash form instead of the
|
|
107
|
+
* front of an opaque wrapper.
|
|
108
|
+
*
|
|
109
|
+
* See change: render-skill-invocations-collapsibly.
|
|
110
|
+
*/
|
|
111
|
+
export function condenseForFirstMessage(text: string, maxLen: number): string {
|
|
112
|
+
const block = parseSkillBlock(text);
|
|
113
|
+
if (block) return block.condensed.slice(0, maxLen);
|
|
114
|
+
return text.slice(0, maxLen);
|
|
115
|
+
}
|