@blackbelt-technology/pi-agent-dashboard 0.4.5 → 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 (133) hide show
  1. package/AGENTS.md +342 -267
  2. package/README.md +51 -2
  3. package/docs/architecture.md +266 -25
  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-bus.test.ts +44 -0
  10. package/packages/extension/src/__tests__/prompt-expander.test.ts +45 -0
  11. package/packages/extension/src/__tests__/server-launcher.test.ts +24 -1
  12. package/packages/extension/src/__tests__/vcs-info-jj.test.ts +145 -0
  13. package/packages/extension/src/__tests__/{git-info.test.ts → vcs-info.test.ts} +6 -6
  14. package/packages/extension/src/bridge-context.ts +7 -0
  15. package/packages/extension/src/bridge.ts +142 -4
  16. package/packages/extension/src/command-handler.ts +6 -0
  17. package/packages/extension/src/markdown-image-inliner.ts +268 -0
  18. package/packages/extension/src/model-tracker.ts +35 -1
  19. package/packages/extension/src/prompt-bus.ts +4 -3
  20. package/packages/extension/src/prompt-expander.ts +50 -2
  21. package/packages/extension/src/provider-register.ts +117 -0
  22. package/packages/extension/src/server-launcher.ts +18 -1
  23. package/packages/extension/src/session-sync.ts +6 -1
  24. package/packages/extension/src/vcs-info.ts +184 -0
  25. package/packages/server/package.json +4 -4
  26. package/packages/server/src/__tests__/auto-attach-slug-defense.test.ts +104 -0
  27. package/packages/server/src/__tests__/bootstrap-install-from-list.test.ts +263 -0
  28. package/packages/server/src/__tests__/browser-gateway-snapshot-on-connect.test.ts +143 -0
  29. package/packages/server/src/__tests__/build-auth-status.test.ts +190 -0
  30. package/packages/server/src/__tests__/cold-boot-openspec-broadcast.test.ts +161 -0
  31. package/packages/server/src/__tests__/doctor-route.test.ts +132 -0
  32. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +87 -0
  33. package/packages/server/src/__tests__/has-openspec-dir.test.ts +64 -0
  34. package/packages/server/src/__tests__/health-shape.test.ts +43 -0
  35. package/packages/server/src/__tests__/idle-timer-respects-terminals.test.ts +115 -0
  36. package/packages/server/src/__tests__/is-unread-trigger.test.ts +4 -2
  37. package/packages/server/src/__tests__/jj-routes.test.ts +93 -0
  38. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +92 -0
  39. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +114 -0
  40. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +177 -0
  41. package/packages/server/src/__tests__/process-manager-codes.test.ts +80 -0
  42. package/packages/server/src/__tests__/process-manager-managed-path.test.ts +73 -0
  43. package/packages/server/src/__tests__/provider-auth-storage.test.ts +42 -11
  44. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +54 -0
  45. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +17 -2
  46. package/packages/server/src/__tests__/session-action-handler-spawn.test.ts +150 -0
  47. package/packages/server/src/__tests__/session-diff-vcs.test.ts +61 -0
  48. package/packages/server/src/__tests__/session-discovery-skill-firstmessage.test.ts +95 -0
  49. package/packages/server/src/__tests__/spawn-failure-log.test.ts +118 -0
  50. package/packages/server/src/__tests__/spawn-preflight.test.ts +91 -0
  51. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +166 -0
  52. package/packages/server/src/__tests__/subscription-handler.test.ts +98 -6
  53. package/packages/server/src/__tests__/system-routes-reextract.test.ts +91 -0
  54. package/packages/server/src/__tests__/system-routes-restart.test.ts +4 -4
  55. package/packages/server/src/__tests__/system-routes-spawn-failures.test.ts +84 -0
  56. package/packages/server/src/__tests__/terminal-manager.test.ts +45 -0
  57. package/packages/server/src/bootstrap-install-from-list.ts +232 -0
  58. package/packages/server/src/bootstrap-state.ts +18 -0
  59. package/packages/server/src/browser-gateway.ts +58 -21
  60. package/packages/server/src/browser-handlers/directory-handler.ts +4 -0
  61. package/packages/server/src/browser-handlers/session-action-handler.ts +60 -2
  62. package/packages/server/src/browser-handlers/subscription-handler.ts +50 -3
  63. package/packages/server/src/cli.ts +22 -0
  64. package/packages/server/src/directory-service.ts +31 -0
  65. package/packages/server/src/event-wiring.ts +57 -2
  66. package/packages/server/src/home-lock.d.ts +124 -0
  67. package/packages/server/src/home-lock.js +330 -0
  68. package/packages/server/src/home-lock.js.map +1 -0
  69. package/packages/server/src/idle-timer.ts +15 -1
  70. package/packages/server/src/openspec-tasks.ts +50 -19
  71. package/packages/server/src/pi-core-updater.ts +65 -9
  72. package/packages/server/src/pi-gateway.ts +6 -0
  73. package/packages/server/src/process-manager.ts +62 -11
  74. package/packages/server/src/provider-auth-handlers.ts +9 -0
  75. package/packages/server/src/provider-auth-storage.ts +83 -51
  76. package/packages/server/src/provider-catalogue-cache.ts +41 -0
  77. package/packages/server/src/routes/doctor-routes.ts +140 -0
  78. package/packages/server/src/routes/jj-routes.ts +386 -0
  79. package/packages/server/src/routes/provider-auth-routes.ts +9 -0
  80. package/packages/server/src/routes/session-routes.ts +12 -3
  81. package/packages/server/src/routes/system-routes.ts +38 -1
  82. package/packages/server/src/server.ts +16 -9
  83. package/packages/server/src/session-bootstrap.ts +27 -12
  84. package/packages/server/src/session-diff.ts +118 -1
  85. package/packages/server/src/session-discovery.ts +10 -3
  86. package/packages/server/src/session-scanner.ts +4 -2
  87. package/packages/server/src/spawn-failure-log.ts +130 -0
  88. package/packages/server/src/spawn-preflight.ts +82 -0
  89. package/packages/server/src/spawn-register-watchdog.ts +236 -0
  90. package/packages/server/src/terminal-manager.ts +12 -1
  91. package/packages/shared/package.json +1 -1
  92. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +1 -0
  93. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +1 -0
  94. package/packages/shared/src/__tests__/bootstrap-install-resolve-npm.test.ts +72 -0
  95. package/packages/shared/src/__tests__/browser-protocol-types.test.ts +47 -1
  96. package/packages/shared/src/__tests__/config.test.ts +48 -0
  97. package/packages/shared/src/__tests__/dashboard-starter.test.ts +40 -0
  98. package/packages/shared/src/__tests__/detached-spawn.test.ts +24 -0
  99. package/packages/shared/src/__tests__/doctor-core.test.ts +134 -0
  100. package/packages/shared/src/__tests__/doctor-fault-tolerance.test.ts +218 -0
  101. package/packages/shared/src/__tests__/doctor-format.test.ts +121 -0
  102. package/packages/shared/src/__tests__/install-managed-node-bootstrap-order.test.ts +68 -0
  103. package/packages/shared/src/__tests__/install-managed-node.test.ts +192 -0
  104. package/packages/shared/src/__tests__/installable-list.test.ts +130 -0
  105. package/packages/shared/src/__tests__/managed-node-path.test.ts +122 -0
  106. package/packages/shared/src/__tests__/managed-runtime-strategy.test.ts +74 -0
  107. package/packages/shared/src/__tests__/no-installable-list-in-bridge.test.ts +52 -0
  108. package/packages/shared/src/__tests__/no-raw-openspec-status-in-skills.test.ts +6 -1
  109. package/packages/shared/src/__tests__/platform-jj.test.ts +339 -0
  110. package/packages/shared/src/__tests__/skill-block-parser.test.ts +153 -0
  111. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +18 -2
  112. package/packages/shared/src/bootstrap-install.ts +196 -2
  113. package/packages/shared/src/browser-protocol.ts +112 -1
  114. package/packages/shared/src/config.ts +29 -0
  115. package/packages/shared/src/dashboard-starter.ts +33 -0
  116. package/packages/shared/src/diff-types.ts +17 -0
  117. package/packages/shared/src/doctor-core.ts +821 -0
  118. package/packages/shared/src/index.ts +9 -0
  119. package/packages/shared/src/installable-list.ts +152 -0
  120. package/packages/shared/src/launch-source-flag.ts +14 -0
  121. package/packages/shared/src/launch-source-types.ts +18 -0
  122. package/packages/shared/src/openspec-activity-detector.ts +25 -7
  123. package/packages/shared/src/platform/detached-spawn.ts +13 -2
  124. package/packages/shared/src/platform/jj.ts +405 -0
  125. package/packages/shared/src/platform/managed-node-path.ts +77 -0
  126. package/packages/shared/src/protocol.ts +60 -2
  127. package/packages/shared/src/rest-api.ts +4 -0
  128. package/packages/shared/src/skill-block-parser.ts +115 -0
  129. package/packages/shared/src/tool-registry/__tests__/managed-runtime-strategy.test.ts +166 -0
  130. package/packages/shared/src/tool-registry/definitions.ts +19 -5
  131. package/packages/shared/src/tool-registry/strategies.ts +42 -0
  132. package/packages/shared/src/types.ts +91 -0
  133. package/packages/extension/src/git-info.ts +0 -55
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Markdown image inliner for the bridge.
3
+ *
4
+ * Scans assistant message text for fully-closed `![alt](src)` markdown image
5
+ * tokens, reads any local-path file references, hashes the bytes, and
6
+ * rewrites the token to `![alt](pi-asset:<hash>)`. Bytes ride out of band
7
+ * via `asset_register` events (one per unique hash per session). The text
8
+ * itself only ever carries the short `pi-asset:<hash>` token, keeping
9
+ * streaming `message_update` events bandwidth-bounded.
10
+ *
11
+ * The core helper `inlineMessageText` is **pure** — all I/O is delegated to
12
+ * the injected `readFile` callback so tests can drive every branch with
13
+ * memory fixtures. The bridge wires `readFile = node:fs.readFileSync` plus
14
+ * a per-session `alreadyEmitted: Set<string>` of hashes already shipped.
15
+ *
16
+ * See change: chat-markdown-local-images-and-math.
17
+ */
18
+ import { createHash } from "node:crypto";
19
+ import path from "node:path";
20
+
21
+ /** Per-image hard cap (decision D8). */
22
+ export const MAX_PER_IMAGE_BYTES = 5 * 1024 * 1024;
23
+ /** Per-message cumulative cap on **newly-inlined** asset bytes (decision D8). */
24
+ export const MAX_PER_MESSAGE_BYTES = 20 * 1024 * 1024;
25
+
26
+ /** MIME allowlist keyed by lowercased extension. Decision D8. */
27
+ const MIME_BY_EXT: Record<string, string> = {
28
+ ".png": "image/png",
29
+ ".jpg": "image/jpeg",
30
+ ".jpeg": "image/jpeg",
31
+ ".gif": "image/gif",
32
+ ".webp": "image/webp",
33
+ ".svg": "image/svg+xml",
34
+ ".avif": "image/avif",
35
+ ".bmp": "image/bmp",
36
+ };
37
+
38
+ /** Matches a fully-closed `![alt](src)` markdown image token. */
39
+ const IMAGE_TOKEN_RE = /!\[([^\]\n]*)\]\(([^)\n\s]+)\)/g;
40
+
41
+ export interface ParsedImageToken {
42
+ /** The full original `![alt](src)` substring. */
43
+ token: string;
44
+ alt: string;
45
+ src: string;
46
+ /** Start offset within the input text. */
47
+ index: number;
48
+ length: number;
49
+ }
50
+
51
+ /**
52
+ * Find every fully-closed `![alt](src)` token in `text`. Partial tokens
53
+ * (e.g. `![alt](/path/x` without closing `)`) are NOT returned — the
54
+ * regex requires the closing paren. Tokens spanning newlines are NOT
55
+ * returned (markdown doesn't allow newlines inside the URL portion).
56
+ */
57
+ export function parseImageTokens(text: string): ParsedImageToken[] {
58
+ const out: ParsedImageToken[] = [];
59
+ let match: RegExpExecArray | null;
60
+ IMAGE_TOKEN_RE.lastIndex = 0;
61
+ while ((match = IMAGE_TOKEN_RE.exec(text)) !== null) {
62
+ out.push({
63
+ token: match[0],
64
+ alt: match[1] ?? "",
65
+ src: match[2] ?? "",
66
+ index: match.index,
67
+ length: match[0].length,
68
+ });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Returns true iff `src` looks like a local filesystem path (i.e. NOT an
75
+ * already-resolved web URL, data URL, blob URL, fragment, or pre-rewritten
76
+ * `pi-asset:<hash>` token). Idempotency hinges on `pi-asset:` returning
77
+ * `false` here.
78
+ */
79
+ export function isLocalSrc(src: string): boolean {
80
+ if (!src) return false;
81
+ if (src.startsWith("data:")) return false;
82
+ if (src.startsWith("blob:")) return false;
83
+ if (src.startsWith("http://")) return false;
84
+ if (src.startsWith("https://")) return false;
85
+ if (src.startsWith("pi-asset:")) return false;
86
+ if (src.startsWith("#")) return false;
87
+ // `file://` prefix — treat the rest as a local path.
88
+ return true;
89
+ }
90
+
91
+ /**
92
+ * Resolve `src` to an absolute path against `cwd`. `file://` prefix is
93
+ * stripped. Absolute paths pass through. Relative paths resolve against
94
+ * `cwd`.
95
+ */
96
+ export function resolveLocalPath(src: string, cwd: string): string {
97
+ let raw = src;
98
+ if (raw.startsWith("file://")) raw = raw.slice("file://".length);
99
+ if (path.isAbsolute(raw)) return raw;
100
+ return path.resolve(cwd, raw);
101
+ }
102
+
103
+ /**
104
+ * Detect MIME from the file extension (case-insensitive). Returns null
105
+ * if the extension is not in the image allowlist.
106
+ */
107
+ export function mimeFromExtension(filePath: string): string | null {
108
+ const ext = path.extname(filePath).toLowerCase();
109
+ return MIME_BY_EXT[ext] ?? null;
110
+ }
111
+
112
+ /**
113
+ * Hash file bytes to a 16-hex-char identifier (sha256 truncated). Decision D4.
114
+ */
115
+ export function hashBytes(buf: Buffer): string {
116
+ return createHash("sha256").update(buf).digest("hex").slice(0, 16);
117
+ }
118
+
119
+ /** Format a byte count to a one-decimal MB string for placeholder text. */
120
+ function formatMB(bytes: number): string {
121
+ return (bytes / (1024 * 1024)).toFixed(1);
122
+ }
123
+
124
+ /** Bridge-side per-file read result; thin enough to mock in tests. */
125
+ export interface ReadFileResult {
126
+ ok: true;
127
+ bytes: Buffer;
128
+ }
129
+ export interface ReadFileError {
130
+ ok: false;
131
+ /** Use ENOENT/EACCES/EISDIR/EOTHER. EACCES is folded into ENOENT in placeholder text. */
132
+ kind: "ENOENT" | "EACCES" | "EISDIR" | "EOTHER";
133
+ }
134
+ export type ReadFileOutcome = ReadFileResult | ReadFileError;
135
+
136
+ export interface InlineOptions {
137
+ /** Synchronous file-read callback. Bridge wires this to `node:fs.readFileSync` + `fs.statSync`. */
138
+ readFile: (absolutePath: string) => ReadFileOutcome;
139
+ /** Working directory used to resolve relative srcs. */
140
+ cwd: string;
141
+ /**
142
+ * Per-session set of hashes for which an `asset_register` has already been
143
+ * emitted. The inliner checks-then-adds so dedup is automatic across
144
+ * multiple message events within the same session.
145
+ */
146
+ alreadyEmitted: Set<string>;
147
+ /** Override the per-image cap. Default `MAX_PER_IMAGE_BYTES`. */
148
+ maxPerImageBytes?: number;
149
+ /** Override the per-message cap. Default `MAX_PER_MESSAGE_BYTES`. */
150
+ maxPerMessageBytes?: number;
151
+ }
152
+
153
+ export interface AssetToEmit {
154
+ hash: string;
155
+ mimeType: string;
156
+ /** Base64-encoded bytes ready for the `asset_register` message. */
157
+ data: string;
158
+ }
159
+
160
+ export interface InlineResult {
161
+ /** The rewritten text with every applicable token replaced. */
162
+ rewritten: string;
163
+ /** Newly-discovered assets to emit BEFORE the message_update / message_end. */
164
+ assetsToEmit: AssetToEmit[];
165
+ }
166
+
167
+ /**
168
+ * Pure inliner. Scans `text` for image tokens; rewrites local-path tokens
169
+ * either to `![alt](pi-asset:<hash>)` (success) or to a visible placeholder
170
+ * text (file too large / unsupported MIME / read error / message budget
171
+ * exhausted). Tokens with web/data/blob/pi-asset/# srcs pass through
172
+ * unchanged. Idempotent — re-running on already-rewritten text yields the
173
+ * same output (because `pi-asset:` returns `false` from `isLocalSrc`).
174
+ */
175
+ export function inlineMessageText(text: string, opts: InlineOptions): InlineResult {
176
+ const tokens = parseImageTokens(text);
177
+ if (tokens.length === 0) {
178
+ return { rewritten: text, assetsToEmit: [] };
179
+ }
180
+
181
+ const maxPerImage = opts.maxPerImageBytes ?? MAX_PER_IMAGE_BYTES;
182
+ const maxPerMessage = opts.maxPerMessageBytes ?? MAX_PER_MESSAGE_BYTES;
183
+
184
+ const assetsToEmit: AssetToEmit[] = [];
185
+ let bytesInThisMessage = 0;
186
+
187
+ // Build the rewritten string by stitching segments separated by token
188
+ // replacements. Walk tokens left-to-right; tokens that pass through
189
+ // unchanged keep their original substring.
190
+ const out: string[] = [];
191
+ let cursor = 0;
192
+
193
+ for (const tok of tokens) {
194
+ // Append the segment before this token verbatim.
195
+ out.push(text.slice(cursor, tok.index));
196
+ cursor = tok.index + tok.length;
197
+
198
+ if (!isLocalSrc(tok.src)) {
199
+ // External / data: / pi-asset: / fragment — pass through unchanged.
200
+ out.push(tok.token);
201
+ continue;
202
+ }
203
+
204
+ const absPath = resolveLocalPath(tok.src, opts.cwd);
205
+
206
+ // Order matters here:
207
+ // 1. readFile FIRST so EISDIR / ENOENT / EACCES are reported with
208
+ // their proper placeholders even when the path has no extension
209
+ // (e.g. `/home/me` resolving to a directory).
210
+ // 2. mimeFromExtension after a successful read so an existing file
211
+ // with a non-image extension reports "unsupported image type"
212
+ // rather than a generic read failure.
213
+ // 3. hashBytes so we can consult `alreadyEmitted` BEFORE the
214
+ // per-image and per-message caps. Already-registered assets bypass
215
+ // caps because their bytes were paid for on the previous emission.
216
+ // 4. Per-image cap and per-message budget gate ONLY new emissions.
217
+ const outcome = opts.readFile(absPath);
218
+ if (!outcome.ok) {
219
+ // EACCES is folded into ENOENT placeholder to avoid leaking permission
220
+ // existence. EISDIR / EOTHER use the generic "read failed" wording.
221
+ if (outcome.kind === "ENOENT" || outcome.kind === "EACCES") {
222
+ out.push(`[image not found: ${tok.src}]`);
223
+ } else {
224
+ out.push(`[image read failed: ${tok.src}]`);
225
+ }
226
+ continue;
227
+ }
228
+
229
+ const mime = mimeFromExtension(absPath);
230
+ if (!mime) {
231
+ out.push(`[unsupported image type: ${tok.src}]`);
232
+ continue;
233
+ }
234
+
235
+ const hash = hashBytes(outcome.bytes);
236
+
237
+ if (opts.alreadyEmitted.has(hash)) {
238
+ // Bytes already shipped earlier in the session — only the token rewrites.
239
+ // Caps are bypassed: dedup means no new bytes go on the wire.
240
+ out.push(`![${tok.alt}](pi-asset:${hash})`);
241
+ continue;
242
+ }
243
+
244
+ const size = outcome.bytes.length;
245
+ if (size > maxPerImage) {
246
+ out.push(`[image too large: ${tok.src} (${formatMB(size)} MB)]`);
247
+ continue;
248
+ }
249
+
250
+ // New asset. Check the per-message budget before committing.
251
+ if (bytesInThisMessage + size > maxPerMessage) {
252
+ out.push(`[message asset budget exhausted: ${tok.src}]`);
253
+ continue;
254
+ }
255
+
256
+ bytesInThisMessage += size;
257
+ opts.alreadyEmitted.add(hash);
258
+ assetsToEmit.push({
259
+ hash,
260
+ mimeType: mime,
261
+ data: outcome.bytes.toString("base64"),
262
+ });
263
+ out.push(`![${tok.alt}](pi-asset:${hash})`);
264
+ }
265
+
266
+ out.push(text.slice(cursor));
267
+ return { rewritten: out.join(""), assetsToEmit };
268
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import type { BridgeContext } from "./bridge-context.js";
6
6
  import { getCurrentModelString } from "./bridge-context.js";
7
- import { gatherGitInfo } from "./git-info.js";
7
+ import { gatherGitInfo, gatherJjInfo } from "./vcs-info.js";
8
8
 
9
9
  /**
10
10
  * Send model_update if model or thinking level has changed since last send.
@@ -54,3 +54,37 @@ export function sendGitInfoIfChanged(bc: BridgeContext, cwd: string): void {
54
54
  ...info,
55
55
  });
56
56
  }
57
+
58
+ /**
59
+ * Reset the change-detection caches that aren't persisted on the server
60
+ * side, so a server-restart-driven reconnect re-sends them. `gitBranch`
61
+ * is already persisted to `.meta.json` so it's tolerable for a tick of
62
+ * staleness; `jjState` is intentionally NOT persisted (live tool state)
63
+ * and must be re-emitted on every reconnect.
64
+ * See change: add-jj-workspace-plugin.
65
+ */
66
+ export function resetReconnectCaches(bc: BridgeContext): void {
67
+ bc.lastJjStateJson = undefined;
68
+ // Defensive: also reset git so a reconnect through a stale state cache
69
+ // doesn't surface stale branch info if .meta.json wasn't persisted yet.
70
+ bc.lastGitBranch = undefined;
71
+ bc.lastGitPrNumber = undefined;
72
+ }
73
+
74
+ /**
75
+ * Send jj_state_update if the cwd's jj state has changed since last send.
76
+ * Sends `null` to clear when the session leaves a jj repo (cwd switch).
77
+ * No-op when there's nothing to clear and nothing to send.
78
+ * See change: add-jj-workspace-plugin.
79
+ */
80
+ export function sendJjStateIfChanged(bc: BridgeContext, cwd: string): void {
81
+ const state = gatherJjInfo(cwd);
82
+ const nextJson = state ? JSON.stringify(state) : "";
83
+ if (nextJson === (bc.lastJjStateJson ?? "")) return;
84
+ bc.lastJjStateJson = nextJson;
85
+ bc.connection.send({
86
+ type: "jj_state_update",
87
+ sessionId: bc.sessionId,
88
+ jjState: state ?? null,
89
+ });
90
+ }
@@ -112,9 +112,10 @@ export class PromptBus {
112
112
 
113
113
  return new Promise<PromptResponse>((resolve) => {
114
114
  const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
115
- const timer = setTimeout(() => {
116
- this.cancel(id);
117
- }, timeoutMs);
115
+ // timeoutMs <= 0 means infinite — never fire a cancellation timer.
116
+ const timer = timeoutMs > 0
117
+ ? setTimeout(() => { this.cancel(id); }, timeoutMs)
118
+ : (null as unknown as ReturnType<typeof setTimeout>);
118
119
 
119
120
  // Distribute to all adapters and collect claims
120
121
  const claims: PendingPrompt["claims"] = [];
@@ -6,8 +6,9 @@
6
6
  * by reading template/skill files directly and expanding them.
7
7
  */
8
8
  import { readFileSync, existsSync } from "node:fs";
9
- import { join, resolve } from "node:path";
9
+ import { dirname, join, resolve } from "node:path";
10
10
  import { readdirSync, statSync } from "node:fs";
11
+ import { buildSkillBlock } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
11
12
 
12
13
  /** Scan directories for .md prompt template files */
13
14
  function findPromptTemplates(cwd: string): Map<string, string> {
@@ -97,7 +98,26 @@ export function expandPromptTemplateFromDisk(text: string, cwd: string, pi?: any
97
98
 
98
99
  try {
99
100
  const content = readTemplate(filePath);
100
- // Simple arg substitution: replace $1, $2, etc. or just append args
101
+
102
+ // Skill detection: either the local-scan key starts with `skill:` or the
103
+ // pi.getCommands() fallback resolved a command whose `source === "skill"`
104
+ // (we re-check below). Skill expansions wrap in pi's `<skill>` envelope so
105
+ // the dashboard ingress path is byte-identical to pi's own _expandSkillCommand,
106
+ // which lets the client and server recover the slash-command form.
107
+ // See change: render-skill-invocations-collapsibly.
108
+ const isSkill = isSkillResolution(templateName, filePath, pi);
109
+ if (isSkill) {
110
+ const bareName = templateName.replace(/^skill:/, "");
111
+ return buildSkillBlock({
112
+ name: bareName,
113
+ filePath,
114
+ baseDir: dirname(filePath),
115
+ body: content,
116
+ userArgs: argsString || undefined,
117
+ });
118
+ }
119
+
120
+ // Plain prompt templates: append args after a blank line, no wrapper.
101
121
  if (argsString) {
102
122
  return `${content}\n\n${argsString}`;
103
123
  }
@@ -106,3 +126,31 @@ export function expandPromptTemplateFromDisk(text: string, cwd: string, pi?: any
106
126
  return text;
107
127
  }
108
128
  }
129
+
130
+ /**
131
+ * Detect whether the resolved `filePath` came from a skill source.
132
+ *
133
+ * The local-scan key tells us directly when it starts with `skill:`. For the
134
+ * pi.getCommands() fallback we re-query and check `source === "skill"` against
135
+ * the same templateName.
136
+ */
137
+ function isSkillResolution(
138
+ templateName: string,
139
+ filePath: string,
140
+ pi: any | undefined,
141
+ ): boolean {
142
+ if (templateName.startsWith("skill:")) return true;
143
+ // The colon-alias path (e.g. /opsx:continue) maps to a hyphen filename and is
144
+ // a prompt template, not a skill. Skills always use the `skill:` prefix in
145
+ // both the local scan and pi.getCommands().
146
+ if (!pi?.getCommands) return false;
147
+ try {
148
+ const commands = pi.getCommands();
149
+ const match = commands.find(
150
+ (c: any) => c.name === templateName && c.source === "skill" && c.path === filePath,
151
+ );
152
+ return !!match;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
@@ -15,6 +15,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
15
  import { existsSync, readFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
17
  import { join } from "node:path";
18
+ import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
18
19
 
19
20
  // -- Types ----------------------------------------------------------------
20
21
 
@@ -288,6 +289,122 @@ export function getSessionInfo(): { provider: string; modelId: string } {
288
289
  return { provider: currentSessionProvider, modelId: currentSessionModelId };
289
290
  }
290
291
 
292
+ // -- Provider catalogue (for dashboard /api/provider-auth/status) -------
293
+ //
294
+ // Pure derivation: given a captured `ModelRegistry` and the pi-ai
295
+ // helpers (`findEnvKeys`, `getEnvApiKey`), build a flat ProviderInfo[]
296
+ // covering every OAuth provider plus every distinct provider id from
297
+ // `getAll()`. The bridge pushes this to the server alongside
298
+ // `models_list`. See change: replace-hardcoded-provider-lists.
299
+
300
+ type PiAiHelpers = {
301
+ findEnvKeys?: (id: string) => string[] | undefined;
302
+ getEnvApiKey?: (id: string) => string | undefined;
303
+ };
304
+
305
+ export function _buildProviderCatalogue(
306
+ modelRegistry: any,
307
+ piAi: PiAiHelpers,
308
+ customIds: ReadonlySet<string> = new Set(),
309
+ ): ProviderInfo[] {
310
+ if (!modelRegistry) return [];
311
+ const oauthIds = new Set<string>(
312
+ (modelRegistry.authStorage?.getOAuthProviders?.() ?? []).map((p: any) => p.id),
313
+ );
314
+ // The catalogue is the complete picture of what pi knows about —
315
+ // built-in providers, OAuth providers, AND custom providers registered
316
+ // by the dashboard via pi.registerProvider() from ~/.pi/agent/providers.json.
317
+ // Custom providers carry `custom: true` so consumers can decide what
318
+ // to surface where (e.g. the auth UI suppresses their API-key rows
319
+ // because they're managed by the LLM Providers settings section).
320
+ // Filtering decisions belong to consumers, not to this function.
321
+ // See change: replace-hardcoded-provider-lists.
322
+ const allIds = new Set<string>(oauthIds);
323
+ for (const m of (modelRegistry.getAll?.() ?? []) as Array<{ provider?: string }>) {
324
+ if (m.provider) allIds.add(m.provider);
325
+ }
326
+ return [...allIds].map((id) => {
327
+ let displayName = id;
328
+ try {
329
+ displayName = modelRegistry.getProviderDisplayName?.(id) ?? id;
330
+ } catch { /* fallback to id */ }
331
+ let configured = false;
332
+ let source: ProviderInfo["source"];
333
+ try {
334
+ const status = modelRegistry.authStorage?.getAuthStatus?.(id);
335
+ if (status) {
336
+ configured = !!status.configured;
337
+ source = status.source;
338
+ }
339
+ } catch { /* ignore */ }
340
+ let expires: number | undefined;
341
+ try {
342
+ const cred = modelRegistry.authStorage?.get?.(id);
343
+ if (cred?.type === "oauth" && typeof cred.expires === "number") {
344
+ expires = cred.expires;
345
+ }
346
+ } catch { /* ignore */ }
347
+ let envVar: string | undefined;
348
+ let ambient: boolean | undefined;
349
+ try {
350
+ const keys = piAi.findEnvKeys?.(id);
351
+ if (keys && keys.length > 0) envVar = keys[0];
352
+ if (piAi.getEnvApiKey?.(id) === "<authenticated>") ambient = true;
353
+ } catch { /* ignore */ }
354
+ return {
355
+ id,
356
+ displayName,
357
+ hasOAuth: oauthIds.has(id),
358
+ configured,
359
+ source,
360
+ envVar,
361
+ ambient,
362
+ expires,
363
+ custom: customIds.has(id) || undefined,
364
+ };
365
+ });
366
+ }
367
+
368
+ // Lazy-cached pi-ai module (in scope inside pi's process).
369
+ let _piAiModule: PiAiHelpers | null = null;
370
+ let _piAiLoadAttempted = false;
371
+ async function loadPiAi(): Promise<PiAiHelpers> {
372
+ if (_piAiModule) return _piAiModule;
373
+ if (_piAiLoadAttempted) return {};
374
+ _piAiLoadAttempted = true;
375
+ try {
376
+ const mod: any = await import("@mariozechner/pi-ai");
377
+ _piAiModule = { findEnvKeys: mod.findEnvKeys, getEnvApiKey: mod.getEnvApiKey };
378
+ return _piAiModule;
379
+ } catch {
380
+ return {};
381
+ }
382
+ }
383
+
384
+ // Eagerly kick off pi-ai load at module import time so env-var hints
385
+ // are populated by the time the first session_register fires. Failure
386
+ // is silent; `buildProviderCatalogue` falls back to {} which still
387
+ // produces a valid catalogue minus envVar/ambient hints.
388
+ void loadPiAi();
389
+
390
+ /**
391
+ * Public wrapper: returns the current provider catalogue, or [] when
392
+ * the model registry has not been captured yet. Marks providers the
393
+ * bridge itself registered (from `~/.pi/agent/providers.json` via
394
+ * `pi.registerProvider()`) with `custom: true` so consumers can
395
+ * suppress their API-key auth rows (those are managed by the LLM
396
+ * Providers settings section). The catalogue itself is complete —
397
+ * including custom providers — so other consumers (e.g. diagnostics)
398
+ * see the full picture.
399
+ */
400
+ export function buildProviderCatalogue(): ProviderInfo[] {
401
+ const mr = getModelRegistry();
402
+ if (!mr) return [];
403
+ const piAi = _piAiModule ?? {};
404
+ const customIds = new Set<string>(lastRegistered.keys());
405
+ return _buildProviderCatalogue(mr, piAi, customIds);
406
+ }
407
+
291
408
  export function getModelDisplayName(modelId: string): string {
292
409
  if (piRef) {
293
410
  const data: any = {};
@@ -45,6 +45,23 @@ export function resolveServerCliPath(): string {
45
45
  }
46
46
  }
47
47
 
48
+ /**
49
+ * Build the environment object passed to the spawned server process.
50
+ * Always stamps DASHBOARD_STARTER=Bridge so the server knows it was
51
+ * launched by the pi bridge extension.
52
+ */
53
+ export function buildSpawnEnv(
54
+ baseEnv: NodeJS.ProcessEnv = process.env,
55
+ ): Record<string, string> {
56
+ // Spread process.env (may contain undefined values); filter them out.
57
+ const out: Record<string, string> = {};
58
+ for (const [k, v] of Object.entries(baseEnv)) {
59
+ if (v !== undefined) out[k] = v;
60
+ }
61
+ out["DASHBOARD_STARTER"] = "Bridge";
62
+ return out;
63
+ }
64
+
48
65
  /**
49
66
  * Build the spawn arguments from config.
50
67
  */
@@ -94,7 +111,7 @@ export async function launchServer(config: DashboardConfig): Promise<LaunchResul
94
111
  const r = await spawnDetached({
95
112
  cmd: process.execPath,
96
113
  args: ["--import", loader, entry, ...args], // ban:raw-node-import-ok: entry gated by shouldUrlWrapEntry
97
- env: { ...process.env },
114
+ env: { ...process.env, DASHBOARD_STARTER: "Bridge" },
98
115
  logFd,
99
116
  });
100
117
 
@@ -6,8 +6,9 @@ import type { BridgeContext } from "./bridge-context.js";
6
6
  import { getCurrentModelString, extractFirstMessage, filterHiddenCommands } from "./bridge-context.js";
7
7
  import { detectSessionSource } from "./source-detector.js";
8
8
  import { replayEntriesAsEvents } from "@blackbelt-technology/pi-dashboard-shared/state-replay.js";
9
- import { gatherGitInfo } from "./git-info.js";
9
+ import { gatherGitInfo, gatherJjInfo } from "./vcs-info.js";
10
10
  import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
11
+ import { buildProviderCatalogue } from "./provider-register.js";
11
12
 
12
13
  /**
13
14
  * Send full state sync to the server (session_register, commands, flows, models).
@@ -72,6 +73,8 @@ export function sendStateSync(
72
73
  id: m.id,
73
74
  }));
74
75
  bc.connection.send({ type: "models_list", sessionId: bc.sessionId, models });
76
+ // See change: replace-hardcoded-provider-lists.
77
+ bc.connection.send({ type: "providers_list", sessionId: bc.sessionId, providers: buildProviderCatalogue() });
75
78
  } catch { /* ignore */ }
76
79
  }
77
80
  }
@@ -164,6 +167,8 @@ export function handleSessionChange(
164
167
  id: m.id,
165
168
  }));
166
169
  bc.connection.send({ type: "models_list", sessionId: bc.sessionId, models });
170
+ // See change: replace-hardcoded-provider-lists.
171
+ bc.connection.send({ type: "providers_list", sessionId: bc.sessionId, providers: buildProviderCatalogue() });
167
172
  } catch { /* ignore */ }
168
173
  }
169
174
  }