@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,236 @@
1
+ /**
2
+ * Spawn-register watchdog.
3
+ *
4
+ * Arms a per-spawn timer after every successful `spawnPiSession`. If the
5
+ * spawned pi session never sends `session_register` within the timeout
6
+ * window, emits `spawn_register_timeout` to the originating WebSocket.
7
+ *
8
+ * Two index maps handle the two spawn families:
9
+ * - `byPid` — headless spawns where the dashboard owns the PID.
10
+ * - `byCwd` — tmux/wt/wsl-tmux spawns where any `session_register` from
11
+ * that directory clears the watch.
12
+ *
13
+ * Late registrations (pi finally registers after the watchdog fired) are
14
+ * detected via `recentlyFired` (60 s TTL) and cause a `spawn_register_recovered`
15
+ * message to auto-clear the timeout banner.
16
+ *
17
+ * See change: spawn-failure-diagnostics.
18
+ */
19
+ import WebSocket from "ws";
20
+ import { readFileSync } from "node:fs";
21
+ import type { SpawnMechanism } from "@blackbelt-technology/pi-dashboard-shared/platform/spawn-mechanism.js";
22
+ import type {
23
+ SpawnRegisterTimeoutMessage,
24
+ SpawnRegisterRecoveredMessage,
25
+ } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
26
+ import { clampSpawnRegisterTimeoutMs, loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
27
+ import { appendSpawnFailure } from "./spawn-failure-log.js";
28
+
29
+ export interface WatchdogArmOptions {
30
+ pid?: number;
31
+ cwd: string;
32
+ mechanism: SpawnMechanism;
33
+ logPath?: string;
34
+ ws: WebSocket;
35
+ }
36
+
37
+ interface Entry {
38
+ timer: ReturnType<typeof setTimeout>;
39
+ cwd: string;
40
+ pid?: number;
41
+ mechanism: SpawnMechanism;
42
+ logPath?: string;
43
+ ws: WebSocket;
44
+ timeoutMs: number;
45
+ }
46
+
47
+ interface RecentlyFiredEntry {
48
+ firedAt: number;
49
+ pid?: number;
50
+ ws: WebSocket;
51
+ }
52
+
53
+ const RECENTLY_FIRED_TTL_MS = 60_000;
54
+
55
+ export class SpawnRegisterWatchdog {
56
+ /** Default timeout used when arm() callers do not supply one. */
57
+ readonly timeoutMs: number;
58
+ private readonly byPid = new Map<number, Entry>();
59
+ private readonly byCwd = new Map<string, Entry>();
60
+ private readonly recentlyFired = new Map<string, RecentlyFiredEntry>();
61
+
62
+ constructor(timeoutMs: number) {
63
+ this.timeoutMs = clampSpawnRegisterTimeoutMs(timeoutMs);
64
+ }
65
+
66
+ arm(opts: WatchdogArmOptions & { timeoutMs?: number }): void {
67
+ // Read-on-arm: caller passes the current config value so a Settings change
68
+ // takes effect on the next spawn without a server restart.
69
+ // See change: spawn-failure-diagnostics (fix W1).
70
+ const effectiveTimeout = clampSpawnRegisterTimeoutMs(opts.timeoutMs ?? this.timeoutMs);
71
+ const { pid, cwd, mechanism, logPath, ws } = opts;
72
+ const entry: Entry = {
73
+ timer: null as unknown as ReturnType<typeof setTimeout>,
74
+ cwd, pid, mechanism, logPath, ws,
75
+ timeoutMs: effectiveTimeout,
76
+ };
77
+ entry.timer = setTimeout(() => this._fireEntry(entry), effectiveTimeout);
78
+ // Always index by cwd so a `session_register` clears the watchdog even
79
+ // when the bridge's reported pid differs from the spawner's pid (e.g.
80
+ // Unix headless wraps pi in `sh -c "tail -f /dev/null | pi …"`, so
81
+ // spawnResult.pid is the sh wrapper, not pi). Index by pid additionally
82
+ // for late-recovery lookup. Replace any prior entry for the same
83
+ // cwd/pid to avoid leaking timers.
84
+ const priorCwd = this.byCwd.get(cwd);
85
+ if (priorCwd) clearTimeout(priorCwd.timer);
86
+ this.byCwd.set(cwd, entry);
87
+ if (pid !== undefined) {
88
+ const priorPid = this.byPid.get(pid);
89
+ if (priorPid && priorPid !== priorCwd) clearTimeout(priorPid.timer);
90
+ this.byPid.set(pid, entry);
91
+ }
92
+ }
93
+
94
+ clearByPid(pid: number): void {
95
+ const entry = this.byPid.get(pid);
96
+ if (entry) {
97
+ clearTimeout(entry.timer);
98
+ this.byPid.delete(pid);
99
+ // Also clear cwd entry if it points at the same arm.
100
+ const cwdEntry = this.byCwd.get(entry.cwd);
101
+ if (cwdEntry === entry) this.byCwd.delete(entry.cwd);
102
+ return;
103
+ }
104
+ // Check for late recovery.
105
+ this._checkRecoveryByPid(pid);
106
+ }
107
+
108
+ clearByCwd(cwd: string): void {
109
+ const entry = this.byCwd.get(cwd);
110
+ if (entry) {
111
+ clearTimeout(entry.timer);
112
+ this.byCwd.delete(cwd);
113
+ // Also clear pid entry if it points at the same arm.
114
+ if (entry.pid !== undefined) {
115
+ const pidEntry = this.byPid.get(entry.pid);
116
+ if (pidEntry === entry) this.byPid.delete(entry.pid);
117
+ }
118
+ return;
119
+ }
120
+ // Check for late recovery.
121
+ this._checkRecoveryByCwd(cwd);
122
+ }
123
+
124
+ private _fireEntry(entry: Entry): void {
125
+ const { cwd, pid, logPath, ws, timeoutMs: entryTimeoutMs } = entry;
126
+ // Remove from active maps.
127
+ if (pid !== undefined) {
128
+ const pidEntry = this.byPid.get(pid);
129
+ if (pidEntry === entry) this.byPid.delete(pid);
130
+ }
131
+ const cwdEntry = this.byCwd.get(cwd);
132
+ if (cwdEntry === entry) this.byCwd.delete(cwd);
133
+
134
+ // Record in recentlyFired for late-recovery detection.
135
+ this.recentlyFired.set(cwd, { firedAt: Date.now(), pid, ws });
136
+
137
+ // Read stderr tail if logPath available.
138
+ let stderrTail: string | undefined;
139
+ if (logPath) {
140
+ stderrTail = readLogTail(logPath);
141
+ }
142
+
143
+ // Persist the timeout to the rolling failure log. See change: spawn-failure-diagnostics.
144
+ appendSpawnFailure({
145
+ ts: new Date().toISOString(),
146
+ cwd,
147
+ strategy: entry.mechanism,
148
+ code: "REGISTER_TIMEOUT",
149
+ message: `Pi session spawned but never registered (timeout ${this.timeoutMs}ms)`,
150
+ ...(pid !== undefined ? { pid } : {}),
151
+ ...(stderrTail ? { stderrTail } : {}),
152
+ });
153
+
154
+ if (ws.readyState !== WebSocket.OPEN) return;
155
+
156
+ const msg: SpawnRegisterTimeoutMessage = {
157
+ type: "spawn_register_timeout",
158
+ cwd,
159
+ timeoutMs: entryTimeoutMs,
160
+ ...(pid !== undefined ? { pid } : {}),
161
+ ...(stderrTail ? { stderrTail } : {}),
162
+ };
163
+ ws.send(JSON.stringify(msg));
164
+ }
165
+
166
+ private _checkRecoveryByPid(pid: number): void {
167
+ // recentlyFired is keyed by cwd; scan to find matching pid.
168
+ for (const [cwd, fired] of this.recentlyFired) {
169
+ if (fired.pid === pid) {
170
+ this._emitRecovery(cwd, fired);
171
+ return;
172
+ }
173
+ }
174
+ }
175
+
176
+ private _checkRecoveryByCwd(cwd: string): void {
177
+ const fired = this.recentlyFired.get(cwd);
178
+ if (!fired) return;
179
+ this._emitRecovery(cwd, fired);
180
+ }
181
+
182
+ private _emitRecovery(cwd: string, fired: RecentlyFiredEntry): void {
183
+ // TTL check.
184
+ if (Date.now() - fired.firedAt > RECENTLY_FIRED_TTL_MS) {
185
+ this.recentlyFired.delete(cwd);
186
+ return;
187
+ }
188
+
189
+ this.recentlyFired.delete(cwd);
190
+
191
+ if (fired.ws.readyState !== WebSocket.OPEN) return;
192
+
193
+ const msg: SpawnRegisterRecoveredMessage = {
194
+ type: "spawn_register_recovered",
195
+ cwd,
196
+ ...(fired.pid !== undefined ? { pid: fired.pid } : {}),
197
+ };
198
+ fired.ws.send(JSON.stringify(msg));
199
+ }
200
+ }
201
+
202
+ // ── Singleton ────────────────────────────────────────────────────────────────
203
+
204
+ let _instance: SpawnRegisterWatchdog | null = null;
205
+
206
+ /**
207
+ * Lazy singleton. On first call, reads `spawnRegisterTimeoutMs` from config.
208
+ * Tests can swap the instance via `_setSpawnRegisterWatchdogForTests`.
209
+ */
210
+ export function getSpawnRegisterWatchdog(): SpawnRegisterWatchdog {
211
+ if (!_instance) {
212
+ const config = loadConfig();
213
+ _instance = new SpawnRegisterWatchdog(config.spawnRegisterTimeoutMs);
214
+ }
215
+ return _instance;
216
+ }
217
+
218
+ /** Swap the singleton for tests. Pass `null` to reset. */
219
+ export function _setSpawnRegisterWatchdogForTests(w: SpawnRegisterWatchdog | null): void {
220
+ _instance = w;
221
+ }
222
+
223
+ // ── Helpers ──────────────────────────────────────────────────────────────────
224
+
225
+ function readLogTail(filePath: string, maxBytes = 4096): string | undefined {
226
+ try {
227
+ const buf = readFileSync(filePath);
228
+ if (!buf.length) return undefined;
229
+ const slice = buf.length <= maxBytes ? buf : buf.slice(buf.length - maxBytes);
230
+ let start = 0;
231
+ while (start < slice.length && (slice[start]! & 0xC0) === 0x80) start++;
232
+ return slice.slice(start).toString("utf-8");
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ }
@@ -185,7 +185,18 @@ export function createTerminalManager(options?: TerminalManagerOptions): Termina
185
185
  try {
186
186
  const msg: TerminalControlMessage = JSON.parse(str);
187
187
  if (msg.type === "resize") {
188
- entry.pty.resize(msg.cols, msg.rows);
188
+ // Defense in depth: reject degenerate resize messages.
189
+ // A PTY at <2 cols/rows is non-functional for every supported
190
+ // shell binding; no legitimate user intent maps there. xterm's
191
+ // FitAddon is supposed to guard against zero, but a transient
192
+ // display:none container measured during a route transition
193
+ // can leak a 1 through. See change:
194
+ // fix-terminal-half-height-dual-mount.
195
+ if (msg.cols < 2 || msg.rows < 2) {
196
+ // ignore — keep previous PTY dimensions
197
+ } else {
198
+ entry.pty.resize(msg.cols, msg.rows);
199
+ }
189
200
  } else if (msg.type === "title") {
190
201
  // title control message — handled elsewhere
191
202
  } else {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "repository": {
@@ -352,6 +352,7 @@ source: system
352
352
  path: C:/Program Files/nodejs/node.exe
353
353
  tried:
354
354
  override no override set
355
+ managed missing: <HOME>/.pi-dashboard/node/node.exe
355
356
  managed missing: <HOME>/.pi-dashboard/node_modules/.bin/node.cmd
356
357
  where ok
357
358
  argv:
@@ -39,6 +39,7 @@ source: system
39
39
  path: C:/Program Files/nodejs/node.exe
40
40
  tried:
41
41
  override no override set
42
+ managed missing: <HOME>/.pi-dashboard/node/node.exe
42
43
  managed missing: <HOME>/.pi-dashboard/node_modules/.bin/node.cmd
43
44
  where ok
44
45
  argv:
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Regression: `resolveNpmArgv` from `bootstrap-install.ts` MUST consult
3
+ * the injected `ToolRegistry` for `npm` before falling back to the
4
+ * platform `npm` / `npm.cmd` PATH binary.
5
+ *
6
+ * The change `embed-managed-node-runtime` adds a managedRuntime
7
+ * strategy to the npm chain in the registry. If a future refactor
8
+ * accidentally bypasses the registry inside `resolveNpmArgv`, the
9
+ * managed Node runtime would stop being preferred for shared bootstrap
10
+ * spawns \u2014 the user-visible regression class this whole change exists
11
+ * to prevent.
12
+ *
13
+ * See change: embed-managed-node-runtime (task 4.2).
14
+ */
15
+ import { describe, expect, it } from "vitest";
16
+ import { resolveNpmArgv } from "../bootstrap-install.js";
17
+ import type {
18
+ Resolution,
19
+ ToolRegistry,
20
+ } from "../tool-registry/index.js";
21
+
22
+ function fakeRegistry(opts: {
23
+ hasNpm?: boolean;
24
+ resolveResult?: Partial<Resolution>;
25
+ }): ToolRegistry {
26
+ // Only the methods `resolveNpmArgv` actually calls.
27
+ return {
28
+ has: (name: string) => name === "npm" && (opts.hasNpm ?? true),
29
+ resolve: () =>
30
+ ({
31
+ name: "npm",
32
+ ok: true,
33
+ path: "/managed/node/bin/npm",
34
+ source: "managed",
35
+ tried: [],
36
+ resolvedAt: Date.now(),
37
+ ...opts.resolveResult,
38
+ }) as Resolution,
39
+ } as unknown as ToolRegistry;
40
+ }
41
+
42
+ describe("resolveNpmArgv", () => {
43
+ it("explicit npmArgv wins over registry", () => {
44
+ const argv = resolveNpmArgv({
45
+ npmArgv: ["/explicit/node", "/explicit/npm-cli.js"],
46
+ registry: fakeRegistry({}),
47
+ });
48
+ expect(argv).toEqual(["/explicit/node", "/explicit/npm-cli.js"]);
49
+ });
50
+
51
+ it("uses ToolRegistry.resolve('npm') when no explicit argv", () => {
52
+ const argv = resolveNpmArgv({
53
+ registry: fakeRegistry({
54
+ resolveResult: { ok: true, path: "/managed/node/bin/npm" } as Partial<Resolution>,
55
+ }),
56
+ });
57
+ expect(argv).toEqual(["/managed/node/bin/npm"]);
58
+ });
59
+
60
+ it("falls back to npm/npm.cmd on PATH when registry has no entry", () => {
61
+ const argv = resolveNpmArgv({
62
+ registry: {
63
+ has: () => false,
64
+ resolve: () => {
65
+ throw new Error("should not be called");
66
+ },
67
+ } as unknown as ToolRegistry,
68
+ });
69
+ expect(argv).toHaveLength(1);
70
+ expect(argv[0]).toMatch(/^npm(\.cmd)?$/);
71
+ });
72
+ });
@@ -12,8 +12,13 @@ import type {
12
12
  BrowserPromptDismissMessage,
13
13
  BrowserPromptCancelMessage,
14
14
  BrowserExtUiDecoratorMessage,
15
+ BrowserAssetRegisterMessage,
15
16
  } from "../browser-protocol.js";
16
- import type { ExtensionToServerMessage, ExtUiDecoratorMessage } from "../protocol.js";
17
+ import type {
18
+ ExtensionToServerMessage,
19
+ ExtUiDecoratorMessage,
20
+ AssetRegisterMessage,
21
+ } from "../protocol.js";
17
22
  import type { DecoratorDescriptor } from "../types.js";
18
23
 
19
24
  // Type-level assertion: if these types are NOT in the union, this will fail to compile.
@@ -26,6 +31,11 @@ type _PromptCancelInUnion = AssertExtends<BrowserPromptCancelMessage, ServerToBr
26
31
  // esbuild strips the switch arms in production builds.
27
32
  type _ExtUiDecoratorInExtensionUnion = AssertExtends<ExtUiDecoratorMessage, ExtensionToServerMessage>;
28
33
  type _ExtUiDecoratorInBrowserUnion = AssertExtends<BrowserExtUiDecoratorMessage, ServerToBrowserMessage>;
34
+ // chat-markdown-local-images-and-math: asset_register must live in BOTH the
35
+ // extension→server union (so the server's switch arm survives esbuild) AND
36
+ // the server→browser union (so the client's reducer arm survives esbuild).
37
+ type _AssetRegisterInExtensionUnion = AssertExtends<AssetRegisterMessage, ExtensionToServerMessage>;
38
+ type _AssetRegisterInBrowserUnion = AssertExtends<BrowserAssetRegisterMessage, ServerToBrowserMessage>;
29
39
 
30
40
  // Runtime verification that the type discriminants are reachable in a switch
31
41
  function extractPromptType(msg: ServerToBrowserMessage): string | null {
@@ -119,3 +129,39 @@ describe("ext_ui_decorator is a member of both protocol unions", () => {
119
129
  expect(msg.descriptor.kind).toBe("footer-segment");
120
130
  });
121
131
  });
132
+
133
+ // chat-markdown-local-images-and-math: asset_register switch-arm reachability.
134
+ function extractAssetHash(msg: ServerToBrowserMessage): string | null {
135
+ switch (msg.type) {
136
+ case "asset_register":
137
+ return msg.hash;
138
+ default:
139
+ return null;
140
+ }
141
+ }
142
+
143
+ describe("asset_register is a member of both protocol unions", () => {
144
+ it("server→browser asset_register is a valid discriminant", () => {
145
+ const msg: BrowserAssetRegisterMessage = {
146
+ type: "asset_register",
147
+ sessionId: "s1",
148
+ hash: "abc1234567890123",
149
+ mimeType: "image/png",
150
+ data: "iVBORw0KGgo=",
151
+ };
152
+ expect(extractAssetHash(msg)).toBe("abc1234567890123");
153
+ });
154
+
155
+ it("extension→server asset_register carries the same shape", () => {
156
+ const msg: AssetRegisterMessage = {
157
+ type: "asset_register",
158
+ sessionId: "s1",
159
+ hash: "abc1234567890123",
160
+ mimeType: "image/svg+xml",
161
+ data: "PHN2Zy8+",
162
+ };
163
+ expect(msg.type).toBe("asset_register");
164
+ expect(msg.hash).toBe("abc1234567890123");
165
+ expect(msg.mimeType).toBe("image/svg+xml");
166
+ });
167
+ });
@@ -471,3 +471,51 @@ describe("loadConfig reattachPlacement", () => {
471
471
  expect(content.reattachPlacement).toBeUndefined();
472
472
  });
473
473
  });
474
+
475
+ describe("loadConfig spawnRegisterTimeoutMs", () => {
476
+ let testDir: string;
477
+ let configFile: string;
478
+ let origHome: string;
479
+
480
+ beforeEach(() => {
481
+ testDir = path.join(os.tmpdir(), `test-config-srt-${Date.now()}`);
482
+ fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
483
+ configFile = path.join(testDir, ".pi", "dashboard", "config.json");
484
+ origHome = process.env.HOME!;
485
+ process.env.HOME = testDir;
486
+ });
487
+
488
+ afterEach(() => {
489
+ process.env.HOME = origHome;
490
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
491
+ });
492
+
493
+ it("defaults to 30000 when field is omitted", () => {
494
+ expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
495
+ });
496
+
497
+ it("preserves in-range value", () => {
498
+ fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 45000 }));
499
+ expect(loadConfig().spawnRegisterTimeoutMs).toBe(45000);
500
+ });
501
+
502
+ it("clamps below-range value to 5000", () => {
503
+ fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 1000 }));
504
+ expect(loadConfig().spawnRegisterTimeoutMs).toBe(5000);
505
+ });
506
+
507
+ it("clamps above-range value to 120000", () => {
508
+ fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: 999999 }));
509
+ expect(loadConfig().spawnRegisterTimeoutMs).toBe(120000);
510
+ });
511
+
512
+ it("falls back to default for non-number string", () => {
513
+ fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: "thirty" }));
514
+ expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
515
+ });
516
+
517
+ it("falls back to default for null", () => {
518
+ fs.writeFileSync(configFile, JSON.stringify({ spawnRegisterTimeoutMs: null }));
519
+ expect(loadConfig().spawnRegisterTimeoutMs).toBe(30000);
520
+ });
521
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { parseDashboardStarter } from "../dashboard-starter.js";
3
+
4
+ describe("parseDashboardStarter", () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it("returns Standalone when env is empty object", () => {
10
+ expect(parseDashboardStarter({})).toBe("Standalone");
11
+ });
12
+
13
+ it("returns Standalone when DASHBOARD_STARTER is undefined", () => {
14
+ expect(parseDashboardStarter({ DASHBOARD_STARTER: undefined })).toBe("Standalone");
15
+ });
16
+
17
+ it("returns Standalone when DASHBOARD_STARTER is empty string", () => {
18
+ expect(parseDashboardStarter({ DASHBOARD_STARTER: "" })).toBe("Standalone");
19
+ });
20
+
21
+ it("returns Bridge for valid value", () => {
22
+ expect(parseDashboardStarter({ DASHBOARD_STARTER: "Bridge" })).toBe("Bridge");
23
+ });
24
+
25
+ it("returns Standalone for valid value", () => {
26
+ expect(parseDashboardStarter({ DASHBOARD_STARTER: "Standalone" })).toBe("Standalone");
27
+ });
28
+
29
+ it("returns Electron for valid value", () => {
30
+ expect(parseDashboardStarter({ DASHBOARD_STARTER: "Electron" })).toBe("Electron");
31
+ });
32
+
33
+ it("returns Standalone and warns on invalid value", () => {
34
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
35
+ const result = parseDashboardStarter({ DASHBOARD_STARTER: "bogus" });
36
+ expect(result).toBe("Standalone");
37
+ expect(warnSpy).toHaveBeenCalledOnce();
38
+ expect(warnSpy.mock.calls[0]![0]).toContain("bogus");
39
+ });
40
+ });
@@ -61,6 +61,30 @@ describe("spawnDetached", () => {
61
61
  rmSync(path.dirname(logPath), { recursive: true, force: true });
62
62
  });
63
63
 
64
+ // See change: fix-electron-extracted-jiti-and-stdio-capture.
65
+ it("redirects BOTH stdout and stderr to logFd when provided", async () => {
66
+ const logPath = tmpLog();
67
+ const fd = openSync(logPath, "a");
68
+ try {
69
+ const r = await spawnDetached({
70
+ cmd: process.execPath,
71
+ args: [
72
+ "-e",
73
+ "console.log('STDOUT-LINE'); process.stderr.write('STDERR-LINE'); setTimeout(() => process.exit(0), 100)",
74
+ ],
75
+ logFd: fd,
76
+ });
77
+ expect(r.ok).toBe(true);
78
+ await new Promise((res) => r.process!.once("exit", res));
79
+ } finally {
80
+ try { closeSync(fd); } catch { /* ignore */ }
81
+ }
82
+ const content = readFileSync(logPath, "utf-8");
83
+ expect(content).toContain("STDOUT-LINE");
84
+ expect(content).toContain("STDERR-LINE");
85
+ rmSync(path.dirname(logPath), { recursive: true, force: true });
86
+ });
87
+
64
88
  it("does not keep parent event loop alive (unref)", async () => {
65
89
  // Can only check behaviour indirectly: the returned pid/process exist
66
90
  // and the child is running detached. Lifecycle survival is covered by
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Doctor core — section assignment, suggestion taxonomy, and the
3
+ * Decision-8 lint (every non-ok check has non-empty
4
+ * message/detail/suggestion). See change: doctor-rich-output.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import {
8
+ SECTION_OF,
9
+ SUGGESTIONS,
10
+ stampSectionsAndSuggestions,
11
+ type DoctorCheck,
12
+ type DoctorStatus,
13
+ } from "../doctor-core.js";
14
+
15
+ const ALL_CHECK_NAMES = Object.keys(SECTION_OF);
16
+
17
+ describe("SECTION_OF", () => {
18
+ it("maps every canonical check name to one of the five sections", () => {
19
+ const allowed = new Set(["runtime", "pi-tooling", "server", "setup", "diagnostics"]);
20
+ for (const name of ALL_CHECK_NAMES) {
21
+ expect(allowed.has(SECTION_OF[name])).toBe(true);
22
+ }
23
+ });
24
+
25
+ it("covers all five sections (none empty)", () => {
26
+ const sections = new Set(Object.values(SECTION_OF));
27
+ for (const s of ["runtime", "pi-tooling", "server", "setup", "diagnostics"]) {
28
+ expect(sections.has(s as never)).toBe(true);
29
+ }
30
+ });
31
+ });
32
+
33
+ describe("SUGGESTIONS", () => {
34
+ it("returns undefined for status=ok across every check name", () => {
35
+ for (const name of ALL_CHECK_NAMES) {
36
+ const fn = SUGGESTIONS[name];
37
+ expect(fn).toBeDefined();
38
+ expect(fn?.("ok")).toBeUndefined();
39
+ }
40
+ });
41
+
42
+ it("returns a non-empty string for status=error or warning when defined", () => {
43
+ for (const name of ALL_CHECK_NAMES) {
44
+ const fn = SUGGESTIONS[name];
45
+ // Electron is the only one that returns undefined even for non-ok
46
+ // (because today it never fails). Skip it.
47
+ if (name === "Electron") continue;
48
+ const w = fn?.("warning");
49
+ const e = fn?.("error");
50
+ expect(typeof w === "string" && w.length > 0).toBe(true);
51
+ expect(typeof e === "string" && e.length > 0).toBe(true);
52
+ }
53
+ });
54
+
55
+ it("constrains suggestion text to the allowed Markdown subset", () => {
56
+ // Allowed: **bold**, single-backtick code, [text](url). Disallow: tables,
57
+ // headings, fenced blocks, raw HTML.
58
+ for (const name of ALL_CHECK_NAMES) {
59
+ const fn = SUGGESTIONS[name];
60
+ const candidates: (string | undefined)[] = [
61
+ fn?.("warning"),
62
+ fn?.("error"),
63
+ fn?.("error", undefined, "not-found"),
64
+ fn?.("error", undefined, "permission-denied"),
65
+ fn?.("error", undefined, "timeout"),
66
+ fn?.("error", undefined, "non-zero-exit"),
67
+ ];
68
+ for (const s of candidates) {
69
+ if (!s) continue;
70
+ // No fenced code blocks.
71
+ expect(/```/.test(s)).toBe(false);
72
+ // No headings at line start.
73
+ expect(/^#{1,6}\s/m.test(s)).toBe(false);
74
+ // No raw HTML tags (closing, self-closing, or with attributes).
75
+ // Plain `<placeholder>` text is allowed (used as prose).
76
+ expect(/<\/[a-zA-Z]|<[a-zA-Z][^>]*\s+[^>]+>|<[a-zA-Z][^>]*\/>/.test(s)).toBe(false);
77
+ // Triple-asterisk or underline for bold not allowed.
78
+ expect(/\*\*\*|___/.test(s)).toBe(false);
79
+ }
80
+ }
81
+ });
82
+ });
83
+
84
+ describe("stampSectionsAndSuggestions (Decision 8 lint)", () => {
85
+ it("stamps section + suggestion on non-ok rows by name", () => {
86
+ const checks: DoctorCheck[] = [
87
+ { name: "pi CLI", section: undefined as unknown as never, status: "error", message: "Not found", detail: "Searched PATH" },
88
+ { name: "System Node.js", section: undefined as unknown as never, status: "ok", message: "v22 at /usr/bin/node" },
89
+ ];
90
+ const out = stampSectionsAndSuggestions(checks);
91
+ expect(out[0].section).toBe("pi-tooling");
92
+ expect(out[0].suggestion).toBeDefined();
93
+ expect(out[1].section).toBe("runtime");
94
+ expect(out[1].suggestion).toBeUndefined();
95
+ });
96
+
97
+ it("every non-ok row produced through stamping has non-empty message + detail + suggestion", () => {
98
+ const statuses: DoctorStatus[] = ["warning", "error"];
99
+ for (const name of ALL_CHECK_NAMES) {
100
+ // Electron suggestion is always undefined (decision-by-design); skip.
101
+ if (name === "Electron") continue;
102
+ for (const status of statuses) {
103
+ const checks: DoctorCheck[] = [
104
+ {
105
+ name,
106
+ section: undefined as unknown as never,
107
+ status,
108
+ message: "synthetic message",
109
+ detail: "synthetic detail",
110
+ },
111
+ ];
112
+ const [stamped] = stampSectionsAndSuggestions(checks);
113
+ expect(stamped.message.length).toBeGreaterThan(0);
114
+ expect((stamped.detail ?? "").length).toBeGreaterThan(0);
115
+ expect((stamped.suggestion ?? "").length).toBeGreaterThan(0);
116
+ }
117
+ }
118
+ });
119
+
120
+ it("does not overwrite an existing suggestion", () => {
121
+ const checks: DoctorCheck[] = [
122
+ {
123
+ name: "pi CLI",
124
+ section: "pi-tooling",
125
+ status: "error",
126
+ message: "x",
127
+ detail: "y",
128
+ suggestion: "custom",
129
+ },
130
+ ];
131
+ const out = stampSectionsAndSuggestions(checks);
132
+ expect(out[0].suggestion).toBe("custom");
133
+ });
134
+ });