@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.
Files changed (112) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +31 -0
  3. package/docs/architecture.md +238 -23
  4. package/package.json +14 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  7. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  8. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  10. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  11. package/packages/extension/src/bridge.ts +110 -1
  12. package/packages/extension/src/command-handler.ts +6 -0
  13. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  14. package/packages/extension/src/prompt-expander.ts +50 -2
  15. package/packages/extension/src/provider-register.ts +117 -0
  16. package/packages/extension/src/server-launcher.ts +18 -1
  17. package/packages/extension/src/session-sync.ts +5 -0
  18. package/packages/server/package.json +4 -4
  19. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  20. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  21. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  22. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  23. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  24. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  25. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  26. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  27. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  28. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  29. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  30. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  31. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  32. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  33. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  34. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  35. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  36. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  37. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  38. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  39. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  40. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  41. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  42. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  43. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  44. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  45. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  46. package/packages/server/src/bootstrap-state.ts +18 -0
  47. package/packages/server/src/browser-gateway.ts +58 -21
  48. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  49. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  50. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  51. package/packages/server/src/cli.ts +21 -0
  52. package/packages/server/src/directory-service.ts +31 -0
  53. package/packages/server/src/event-wiring.ts +48 -2
  54. package/packages/server/src/home-lock.d.ts +124 -0
  55. package/packages/server/src/home-lock.js +330 -0
  56. package/packages/server/src/home-lock.js.map +1 -0
  57. package/packages/server/src/idle-timer.ts +15 -1
  58. package/packages/server/src/pi-core-updater.ts +65 -9
  59. package/packages/server/src/pi-gateway.ts +6 -0
  60. package/packages/server/src/process-manager.ts +62 -11
  61. package/packages/server/src/provider-auth-handlers.ts +9 -0
  62. package/packages/server/src/provider-auth-storage.ts +83 -51
  63. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  64. package/packages/server/src/routes/doctor-routes.ts +140 -0
  65. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  66. package/packages/server/src/routes/system-routes.ts +38 -1
  67. package/packages/server/src/server.ts +8 -7
  68. package/packages/server/src/session-bootstrap.ts +27 -12
  69. package/packages/server/src/session-discovery.ts +10 -3
  70. package/packages/server/src/session-scanner.ts +4 -2
  71. package/packages/server/src/spawn-failure-log.ts +130 -0
  72. package/packages/server/src/spawn-preflight.ts +82 -0
  73. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  74. package/packages/server/src/terminal-manager.ts +12 -1
  75. package/packages/shared/package.json +1 -1
  76. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  77. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  78. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  79. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  80. package/packages/shared/src/__tests__/config.test.ts +48 -0
  81. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  82. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  83. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  84. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  85. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  86. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  87. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  88. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  89. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  90. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  91. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  92. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  93. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  94. package/packages/shared/src/bootstrap-install.ts +196 -2
  95. package/packages/shared/src/browser-protocol.ts +112 -1
  96. package/packages/shared/src/config.ts +15 -0
  97. package/packages/shared/src/dashboard-starter.ts +33 -0
  98. package/packages/shared/src/doctor-core.ts +821 -0
  99. package/packages/shared/src/index.ts +9 -0
  100. package/packages/shared/src/installable-list.ts +152 -0
  101. package/packages/shared/src/launch-source-flag.ts +14 -0
  102. package/packages/shared/src/launch-source-types.ts +18 -0
  103. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  104. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  105. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  106. package/packages/shared/src/protocol.ts +46 -2
  107. package/packages/shared/src/rest-api.ts +4 -0
  108. package/packages/shared/src/skill-block-parser.ts +115 -0
  109. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  110. package/packages/shared/src/tool-registry/definitions.ts +18 -5
  111. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  112. 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 flag-shaped tokens (e.g. `--help`, `-h`). The CLI regex capture
98
- // groups use `[^\s"']+` which would otherwise treat `--help` as a change
99
- // name and trigger downstream auto-attach + auto-rename.
100
- // See change: fix-openspec-flag-rename-bug.
101
- if (name.startsWith("-")) return null;
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, stderr is "ignore".
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
- const stdio: ("ignore" | "pipe" | number)[] = [stdioIn, "ignore", opts.logFd ?? "ignore"];
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
+ }