@blackbelt-technology/pi-agent-dashboard 0.4.6 → 0.5.1

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 (181) hide show
  1. package/AGENTS.md +339 -190
  2. package/README.md +50 -7
  3. package/docs/architecture.md +238 -23
  4. package/package.json +23 -13
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/build-provider-catalogue.test.ts +176 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/markdown-image-inliner.test.ts +355 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/openspec-activity-detector.test.ts +68 -0
  13. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  14. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  15. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  16. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  17. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  18. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  19. package/packages/extension/src/ask-user-tool.ts +1 -1
  20. package/packages/extension/src/bridge-context.ts +1 -1
  21. package/packages/extension/src/bridge.ts +169 -4
  22. package/packages/extension/src/command-handler.ts +65 -2
  23. package/packages/extension/src/flow-event-wiring.ts +1 -1
  24. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  25. package/packages/extension/src/multiselect-list.ts +1 -1
  26. package/packages/extension/src/pi-env.d.ts +16 -9
  27. package/packages/extension/src/prompt-expander.ts +50 -2
  28. package/packages/extension/src/provider-register.ts +132 -8
  29. package/packages/extension/src/retry-tracker.ts +123 -0
  30. package/packages/extension/src/server-launcher.ts +18 -1
  31. package/packages/extension/src/session-sync.ts +15 -1
  32. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  33. package/packages/server/package.json +6 -6
  34. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  35. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  36. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  37. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  38. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  39. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  40. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  41. package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
  42. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  43. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  44. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  45. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  46. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  47. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  48. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +154 -0
  49. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  50. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  51. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  52. package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
  53. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  54. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  55. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  56. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  57. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  58. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  59. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  60. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  61. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  62. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  63. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +236 -0
  64. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  65. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  66. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  67. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  68. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  69. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  70. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  71. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +44 -0
  72. package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  74. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  75. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  76. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  77. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  78. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  79. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +250 -0
  80. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  81. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  82. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  83. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  84. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  85. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  86. package/packages/server/src/bootstrap-state.ts +18 -0
  87. package/packages/server/src/browser-gateway.ts +70 -24
  88. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  89. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  90. package/packages/server/src/browser-handlers/session-action-handler.ts +159 -18
  91. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  92. package/packages/server/src/changelog-fs.ts +167 -0
  93. package/packages/server/src/changelog-parser.ts +321 -0
  94. package/packages/server/src/changelog-remote.ts +134 -0
  95. package/packages/server/src/cli.ts +23 -2
  96. package/packages/server/src/directory-service.ts +31 -0
  97. package/packages/server/src/event-wiring.ts +105 -5
  98. package/packages/server/src/headless-pid-registry.ts +54 -5
  99. package/packages/server/src/home-lock.d.ts +124 -0
  100. package/packages/server/src/home-lock.js +330 -0
  101. package/packages/server/src/home-lock.js.map +1 -0
  102. package/packages/server/src/idle-timer.ts +15 -1
  103. package/packages/server/src/pending-client-correlations.ts +73 -0
  104. package/packages/server/src/pending-fork-registry.ts +24 -12
  105. package/packages/server/src/pi-core-checker.ts +77 -17
  106. package/packages/server/src/pi-core-updater.ts +81 -15
  107. package/packages/server/src/pi-dev-version-check.ts +145 -0
  108. package/packages/server/src/pi-gateway.ts +10 -0
  109. package/packages/server/src/pi-version-skew.ts +12 -4
  110. package/packages/server/src/process-manager.ts +115 -21
  111. package/packages/server/src/provider-auth-handlers.ts +9 -0
  112. package/packages/server/src/provider-auth-storage.ts +83 -51
  113. package/packages/server/src/provider-catalogue-cache.ts +47 -0
  114. package/packages/server/src/routes/doctor-routes.ts +140 -0
  115. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  116. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  117. package/packages/server/src/routes/provider-auth-routes.ts +14 -1
  118. package/packages/server/src/routes/provider-routes.ts +4 -4
  119. package/packages/server/src/routes/system-routes.ts +38 -1
  120. package/packages/server/src/server.ts +85 -66
  121. package/packages/server/src/session-api.ts +54 -3
  122. package/packages/server/src/session-bootstrap.ts +27 -12
  123. package/packages/server/src/session-discovery.ts +11 -4
  124. package/packages/server/src/session-file-reader.ts +1 -1
  125. package/packages/server/src/session-scanner.ts +4 -2
  126. package/packages/server/src/spawn-failure-log.ts +130 -0
  127. package/packages/server/src/spawn-preflight.ts +82 -0
  128. package/packages/server/src/spawn-register-watchdog.ts +291 -0
  129. package/packages/server/src/spawn-token.ts +20 -0
  130. package/packages/server/src/terminal-manager.ts +12 -1
  131. package/packages/shared/package.json +1 -1
  132. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +25 -17
  133. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  134. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  135. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  137. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  138. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +6 -3
  139. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  140. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  141. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  142. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  143. package/packages/shared/src/__tests__/config.test.ts +48 -0
  144. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  145. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  146. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  147. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  148. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  149. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  150. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  151. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  152. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  153. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  154. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  155. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  156. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  157. package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
  158. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  159. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  160. package/packages/shared/src/bootstrap-install.ts +197 -3
  161. package/packages/shared/src/browser-protocol.ts +155 -1
  162. package/packages/shared/src/changelog-types.ts +111 -0
  163. package/packages/shared/src/config.ts +15 -0
  164. package/packages/shared/src/dashboard-starter.ts +33 -0
  165. package/packages/shared/src/doctor-core.ts +821 -0
  166. package/packages/shared/src/index.ts +9 -0
  167. package/packages/shared/src/installable-list.ts +152 -0
  168. package/packages/shared/src/launch-source-flag.ts +14 -0
  169. package/packages/shared/src/launch-source-types.ts +18 -0
  170. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  171. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  172. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  173. package/packages/shared/src/platform/node-spawn.ts +29 -21
  174. package/packages/shared/src/protocol.ts +54 -2
  175. package/packages/shared/src/resolve-jiti.ts +62 -9
  176. package/packages/shared/src/rest-api.ts +4 -0
  177. package/packages/shared/src/skill-block-parser.ts +115 -0
  178. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  179. package/packages/shared/src/tool-registry/definitions.ts +33 -8
  180. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  181. package/packages/shared/src/types.ts +64 -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
+ }
@@ -118,29 +118,37 @@ export function toFileUrl(pathOrUrl: string): string {
118
118
  * host can exercise the Windows branch without mutating `process.platform`.
119
119
  *
120
120
  * !! JITI VERSION CONTRACT !!
121
- * The Windows-non-tsx arm assumes the jiti loader is from
122
- * `@mariozechner/pi-coding-agent@0.70.x` (jiti 2.x with the `file:///`
123
- * triple-slash URL handling fix). jiti 2.x correctly handles `file:///`
124
- * URL entries on Windows it was the version we carved this contract
125
- * around in change `fix-windows-entry-script-url`.
126
- *
127
- * Newer jiti versions (e.g. jiti 2.6.5 in `pi-coding-agent@0.71.x`)
128
- * MISBEHAVE on `file:///` entries: they normalize triple-slash to
129
- * single-slash and prepend cwd as if the entry were a relative
130
- * specifier, producing `<cwd>/file:/...` ENOENT errors.
131
- *
132
- * The Electron Windows codepath defends against this version drift by
133
- * resolving jiti from the managed dir's `pi-coding-agent@0.70.0` (the
134
- * version pinned in `packages/electron/offline-packages.json` and
135
- * extracted into `~/.pi-dashboard/` by `installStandalone()` on first
136
- * launch see Defect 1 of change
137
- * `fix-electron-windows-installer-and-server-bootstrap`). Since the
138
- * managed-dir tree is pinned, the contract holds regardless of what
139
- * jiti is on the user's PATH.
121
+ * The Windows-non-tsx arm relies on jiti's `file:///` triple-slash URL
122
+ * handling. Verified-good baselines (must be one of these in the
123
+ * offline cacache):
124
+ * `@earendil-works/pi-coding-agent@0.74.x` (jiti `^2.7.0`) current
125
+ * `@mariozechner/pi-coding-agent@0.70.x` (jiti 2.x) — legacy
126
+ *
127
+ * Both ship a jiti that correctly normalises `file:///` entries on
128
+ * Windows. The contract was originally carved around 0.70.x in change
129
+ * `fix-windows-entry-script-url` and re-anchored at 0.74.x in change
130
+ * `migrate-pi-fork-to-earendil` (E.7).
131
+ *
132
+ * Known-broken (do NOT pin): `pi-coding-agent@0.71.x` shipping
133
+ * `jiti@2.6.5`. That jiti version misnormalises triple-slash to
134
+ * single-slash and prepends cwd as if the entry were a relative
135
+ * specifier, producing `<cwd>/file:/...` ENOENT errors. Keep the
136
+ * 0.71.x / 2.6.5 mention here so contributors recognise the
137
+ * regression pattern if it recurs in a future jiti.
138
+ *
139
+ * The Electron Windows codepath defends against version drift by
140
+ * resolving jiti from the managed dir's pinned `pi-coding-agent`
141
+ * (currently `@earendil-works/pi-coding-agent@0.74.0`, pinned in
142
+ * `packages/electron/offline-packages.json` and extracted into
143
+ * `~/.pi-dashboard/` by `installStandalone()` on first launch — see
144
+ * Defect 1 of change `fix-electron-windows-installer-and-server-bootstrap`).
145
+ * Since the managed-dir tree is pinned, the contract holds regardless
146
+ * of what jiti is on the user's PATH.
140
147
  *
141
148
  * If a future change bumps the offline-cacache `pi-coding-agent` pin to
142
- * a version with a different jiti, RE-VERIFY this contract on Windows
143
- * manually (run a packaged Electron app on Win10 + Win11) and either:
149
+ * a version OUTSIDE the verified baselines, RE-VERIFY this contract on
150
+ * Windows manually (run a packaged Electron app on Win10 + Win11) and
151
+ * either:
144
152
  * 1. Update the contract (fix the file:// URL handling expectation), OR
145
153
  * 2. Add a per-jiti-version branch here, OR
146
154
  * 3. Switch the bundled loader to tsx (which has its own contract).
@@ -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
 
@@ -22,6 +22,14 @@ export interface SessionRegisterMessage {
22
22
  eventCount?: number;
23
23
  /** OS process ID of the pi agent — used for force-kill escalation */
24
24
  pid?: number;
25
+ /**
26
+ * Server-minted spawn correlation token. Bridge populates this from
27
+ * `process.env.PI_DASHBOARD_SPAWN_TOKEN` IFF this is the first register
28
+ * for the bridge process (`bc.hasRegisteredOnce === false`). Subsequent
29
+ * registers (reattach, in-process new/fork/resume) omit it.
30
+ * See change: spawn-correlation-token.
31
+ */
32
+ spawnToken?: string;
25
33
  /**
26
34
  * Why the bridge is registering this session. The bridge sets this to
27
35
  * `"spawn"` for the very first `session_register` after process boot
@@ -151,6 +159,17 @@ export interface ModelsListMessage {
151
159
  models: ModelInfo[];
152
160
  }
153
161
 
162
+ /**
163
+ * Bridge -> server: pi's live provider catalogue derived from
164
+ * `modelRegistry.authStorage` + `modelRegistry.getProviderDisplayName`.
165
+ * Sent alongside ModelsListMessage. See change: replace-hardcoded-provider-lists.
166
+ */
167
+ export interface ProvidersListMessage {
168
+ type: "providers_list";
169
+ sessionId: string;
170
+ providers: ProviderInfo[];
171
+ }
172
+
154
173
  export interface SessionNameUpdateMessage {
155
174
  type: "session_name_update";
156
175
  sessionId: string;
@@ -286,6 +305,27 @@ export interface ExtUiDecoratorMessage {
286
305
  removed?: boolean;
287
306
  }
288
307
 
308
+ // ── Markdown asset inlining (chat-markdown-local-images-and-math) ──
309
+ //
310
+ // Bridge → server: register a base64-encoded image asset under a content
311
+ // hash. Emitted by the bridge BEFORE the `message_update` / `message_end`
312
+ // event whose text references `pi-asset:<hash>`. Bytes ride exactly once
313
+ // per (session, hash) pair — subsequent references in later events emit
314
+ // no further `asset_register`. Persisted in `events.jsonl` alongside the
315
+ // referencing message events so reconnect/replay rebuilds the per-session
316
+ // asset registry deterministically. See change:
317
+ // chat-markdown-local-images-and-math.
318
+ export interface AssetRegisterMessage {
319
+ type: "asset_register";
320
+ sessionId: string;
321
+ /** Content hash (sha256 truncated to 16 hex chars). */
322
+ hash: string;
323
+ /** MIME type (one of the bridge's image allowlist). */
324
+ mimeType: string;
325
+ /** Base64-encoded file bytes. */
326
+ data: string;
327
+ }
328
+
289
329
  export type ExtensionToServerMessage =
290
330
  | SessionRegisterMessage
291
331
  | SessionUnregisterMessage
@@ -299,6 +339,7 @@ export type ExtensionToServerMessage =
299
339
  | JjStateUpdateMessage
300
340
  | SessionNameUpdateMessage
301
341
  | ModelsListMessage
342
+ | ProvidersListMessage
302
343
  | ModelUpdateMessage
303
344
  | SessionsListExtensionMessage
304
345
  | ExtensionUiDismissMessage
@@ -312,7 +353,8 @@ export type ExtensionToServerMessage =
312
353
  | ProcessListMessage
313
354
  | UiModulesListMessage
314
355
  | UiDataListMessage
315
- | ExtUiDecoratorMessage;
356
+ | ExtUiDecoratorMessage
357
+ | AssetRegisterMessage;
316
358
 
317
359
  // ── Server → Extension ──────────────────────────────────────────────
318
360
 
@@ -357,6 +399,15 @@ export interface RequestModelsMessage {
357
399
  sessionId: string;
358
400
  }
359
401
 
402
+ /**
403
+ * Server -> bridge: ask the bridge to push a fresh providers_list.
404
+ * See change: replace-hardcoded-provider-lists.
405
+ */
406
+ export interface RequestProvidersMessage {
407
+ type: "request_providers";
408
+ sessionId: string;
409
+ }
410
+
360
411
  export interface SetThinkingLevelMessage {
361
412
  type: "set_thinking_level";
362
413
  sessionId: string;
@@ -514,6 +565,7 @@ export type ServerToExtensionMessage =
514
565
  | ListFilesMessage
515
566
  | RenameSessionExtensionMessage
516
567
  | RequestModelsMessage
568
+ | RequestProvidersMessage
517
569
  | SetThinkingLevelMessage
518
570
  | ListSessionsExtensionMessage
519
571
  | SetModelMessage
@@ -5,6 +5,18 @@
5
5
  * points to pi's CLI entry (e.g., pi-coding-agent/dist/cli.js). Since
6
6
  * jiti is a dependency of pi-coding-agent, createRequire(process.argv[1])
7
7
  * can resolve it directly.
8
+ *
9
+ * Supported jiti providers, in lookup order:
10
+ * 1. `@mariozechner/jiti` — legacy fork shipped with pi ≤ 0.73.0.
11
+ * 2. `@oh-my-pi/jiti` — fork shipped with `@oh-my-pi/pi-coding-agent`.
12
+ * 3. `jiti` — upstream package. Pi 0.73.1+ dropped the
13
+ * fork in favour of upstream jiti 2.7,
14
+ * which ships the same `lib/jiti-register.mjs`
15
+ * layout the helpers below assume.
16
+ *
17
+ * Forks are tried first to preserve behaviour for users on older pi
18
+ * versions; upstream is the fallthrough for pi 0.73.1+. See change:
19
+ * support-upstream-jiti-resolution.
8
20
  */
9
21
 
10
22
  import { createRequire } from "node:module";
@@ -12,9 +24,11 @@ import { existsSync, realpathSync } from "node:fs";
12
24
  import path from "node:path";
13
25
  import { pathToFileURL } from "node:url";
14
26
 
15
- const JITI_PACKAGES = [
27
+ export const JITI_PACKAGES = [
28
+ // @earendil-works/pi-coding-agent depends on plain `jiti`. Try the
29
+ // bare name first; fall back to the legacy namespaced fork.
30
+ "jiti",
16
31
  "@mariozechner/jiti",
17
- "@oh-my-pi/jiti",
18
32
  ];
19
33
 
20
34
  /**
@@ -44,6 +58,29 @@ export function buildJitiRegisterUrl(pkgJsonPath: string): string {
44
58
  return pathToFileURL(registerPath).href;
45
59
  }
46
60
 
61
+ /**
62
+ * Test seam: a function that takes a package specifier (e.g.
63
+ * `"jiti/package.json"`) and returns the resolved path. Production
64
+ * supplies `createRequire(realpath(process.argv[1])).resolve`; tests
65
+ * supply a stub.
66
+ */
67
+ export type JitiResolver = (specifier: string) => string;
68
+
69
+ /**
70
+ * Internal: walk the JITI_PACKAGES list using the given resolver and
71
+ * return the first hit's register URL. Pure function once the
72
+ * resolver is supplied. Returns null when no provider resolves.
73
+ */
74
+ export function pickJitiRegisterUrl(resolver: JitiResolver): string | null {
75
+ for (const jiti of JITI_PACKAGES) {
76
+ try {
77
+ const pkgJson = resolver(`${jiti}/package.json`);
78
+ return buildJitiRegisterUrl(pkgJson);
79
+ } catch { /* next */ }
80
+ }
81
+ return null;
82
+ }
83
+
47
84
  /**
48
85
  * Returns jiti's register hook as a file:// URL suitable for `node --import`.
49
86
  * Uses process.argv[1] (pi's entry point) to anchor module resolution.
@@ -58,18 +95,14 @@ export function resolveJitiImport(): string {
58
95
  // Resolve symlinks — process.argv[1] may be a symlink (e.g., bin/pi → dist/cli.js)
59
96
  const resolved = realpathSync(anchor);
60
97
  const req = createRequire(resolved);
61
- for (const jiti of JITI_PACKAGES) {
62
- try {
63
- const pkgJson = req.resolve(`${jiti}/package.json`);
64
- return buildJitiRegisterUrl(pkgJson);
65
- } catch { /* next */ }
66
- }
98
+ const url = pickJitiRegisterUrl((spec) => req.resolve(spec));
99
+ if (url) return url;
67
100
  } catch { /* fall through */ }
68
101
  }
69
102
 
70
103
  throw new Error(
71
104
  "Cannot find pi's TypeScript loader (jiti). " +
72
- "Is @mariozechner/pi-coding-agent or @oh-my-pi/pi-coding-agent installed?"
105
+ "Is @earendil-works/pi-coding-agent or @mariozechner/pi-coding-agent installed?"
73
106
  );
74
107
  }
75
108
 
@@ -100,3 +133,23 @@ export function resolveJitiFromAnchor(anchorPath: string): string | null {
100
133
  } catch { /* ignore */ }
101
134
  return null;
102
135
  }
136
+
137
+ /**
138
+ * Test seam for `resolveJitiFromAnchor`. Pure function: given a resolver
139
+ * and a `pathExists` predicate, walk JITI_PACKAGES and return the first
140
+ * hit's register URL. Production wires `createRequire(anchor).resolve`
141
+ * and `existsSync`; tests inject stubs.
142
+ */
143
+ export function pickJitiFromAnchor(
144
+ resolver: JitiResolver,
145
+ pathExists: (p: string) => boolean,
146
+ ): string | null {
147
+ for (const jiti of JITI_PACKAGES) {
148
+ try {
149
+ const pkgJson = resolver(`${jiti}/package.json`);
150
+ const registerPath = path.join(path.dirname(pkgJson), "lib", "jiti-register.mjs");
151
+ if (pathExists(registerPath)) return pathToFileURL(registerPath).href;
152
+ } catch { /* next */ }
153
+ }
154
+ return null;
155
+ }
@@ -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 {