@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
@@ -5,7 +5,8 @@
5
5
  import { readFileSync, existsSync } from "node:fs";
6
6
  import { resolve, relative, isAbsolute, sep as pathSep } from "node:path";
7
7
  import * as git from "@blackbelt-technology/pi-dashboard-shared/platform/git.js";
8
- import type { DashboardEvent } from "@blackbelt-technology/pi-dashboard-shared/types.js";
8
+ import * as jj from "@blackbelt-technology/pi-dashboard-shared/platform/jj.js";
9
+ import type { DashboardEvent, JjState } from "@blackbelt-technology/pi-dashboard-shared/types.js";
9
10
  import type { FileChangeEvent, FileDiffEntry, EditOperation } from "@blackbelt-technology/pi-dashboard-shared/diff-types.js";
10
11
  import { isGitRepo } from "./git-operations.js";
11
12
 
@@ -176,3 +177,119 @@ export function enrichWithGitDiff(
176
177
 
177
178
  return { enrichedFiles: enriched, isGitRepo: true };
178
179
  }
180
+
181
+ // ── jj enrichment (regime-aware) ─────────────────────────────────────────
182
+
183
+ /**
184
+ * Pure helper: pick the right diff base for a given jj state.
185
+ * - default workspace → `@-` (equivalent to `git diff HEAD`)
186
+ * - non-default → `fork_point(@, trunk())`
187
+ *
188
+ * Exported for unit testing without spawning jj.
189
+ */
190
+ export function selectJjDiffBase(jjState: JjState | undefined): {
191
+ diffBase: string;
192
+ baseLabel: string;
193
+ } {
194
+ const workspace = jjState?.workspaceName;
195
+ if (!workspace || workspace === "default") {
196
+ return { diffBase: "@-", baseLabel: "@-" };
197
+ }
198
+ // Use the `..` range form (always-supported) instead of `fork_point()`
199
+ // (which changed signature across jj versions). `trunk()` returns the
200
+ // most-recent ancestor on main/master/trunk; the diff base is the
201
+ // single tip of trunk so that `--from <base> --to @` materializes the
202
+ // cumulative diff across every agent commit in this workspace.
203
+ return { diffBase: "trunk()", baseLabel: "trunk()" };
204
+ }
205
+
206
+ /**
207
+ * Enrich file entries with `jj diff` output, regime-aware. Runs
208
+ * `jj diff --from <baseRev> --to @ -- <path>` per file. Handles new
209
+ * files natively (no synthetic `/dev/null` fallback needed — jj
210
+ * reports new files in unified diff format directly).
211
+ */
212
+ export function enrichWithJjDiff(
213
+ cwd: string,
214
+ files: FileDiffEntry[],
215
+ jjState: JjState | undefined,
216
+ ): { enrichedFiles: FileDiffEntry[]; vcsKind: "jj"; diffBase: string; baseLabel: string } {
217
+ const { diffBase, baseLabel } = selectJjDiffBase(jjState);
218
+ const labelOverride = resolveBaseLabel(cwd, diffBase, baseLabel);
219
+ const enriched = files.map((file) => {
220
+ try {
221
+ const diff = jj.diffOr({
222
+ cwd,
223
+ fromRev: diffBase,
224
+ toRev: "@",
225
+ path: file.path,
226
+ }).trim();
227
+ if (diff) return { ...file, gitDiff: diff };
228
+ return file;
229
+ } catch {
230
+ return file;
231
+ }
232
+ });
233
+ return { enrichedFiles: enriched, vcsKind: "jj", diffBase, baseLabel: labelOverride };
234
+ }
235
+
236
+ /**
237
+ * Promote the abstract revset (e.g. `@-` or `fork_point(@, trunk())`) to
238
+ * a human-friendly bookmark name when one exists. Best effort — falls
239
+ * back to the abstract label if jj can't resolve it.
240
+ */
241
+ function resolveBaseLabel(cwd: string, diffBase: string, fallback: string): string {
242
+ const result = jj.logRevset({
243
+ cwd,
244
+ revset: diffBase,
245
+ template: 'bookmarks ++ "\\n"',
246
+ });
247
+ if (!result.ok) return fallback;
248
+ const first = result.value.trim().split("\n")[0]?.trim();
249
+ if (first && first.length > 0 && first.length < 100) return first;
250
+ return fallback;
251
+ }
252
+
253
+ // ── Unified dispatcher ──────────────────────────────────────────────────
254
+
255
+ export interface VcsEnrichmentResult {
256
+ enrichedFiles: FileDiffEntry[];
257
+ isGitRepo: boolean;
258
+ vcsKind?: "git" | "jj";
259
+ diffBase?: string;
260
+ baseLabel?: string;
261
+ }
262
+
263
+ /**
264
+ * Regime-aware dispatcher. When the session has `jjState.isJjRepo`,
265
+ * route through `enrichWithJjDiff` (which produces the cumulative diff
266
+ * for non-default workspaces). Otherwise fall back to the existing
267
+ * `enrichWithGitDiff` behavior unchanged — plain-git regime is byte-
268
+ * equivalent to the pre-change response shape (modulo the now-optional
269
+ * `vcsKind` field that older clients ignore).
270
+ *
271
+ * See change: add-jj-workspace-plugin.
272
+ */
273
+ export function enrichWithVcsDiff(
274
+ cwd: string,
275
+ files: FileDiffEntry[],
276
+ jjState: JjState | undefined,
277
+ ): VcsEnrichmentResult {
278
+ if (jjState?.isJjRepo) {
279
+ const result = enrichWithJjDiff(cwd, files, jjState);
280
+ return {
281
+ enrichedFiles: result.enrichedFiles,
282
+ isGitRepo: jjState.isColocated === true,
283
+ vcsKind: "jj",
284
+ diffBase: result.diffBase,
285
+ baseLabel: result.baseLabel,
286
+ };
287
+ }
288
+ const result = enrichWithGitDiff(cwd, files);
289
+ return {
290
+ ...result,
291
+ vcsKind: result.isGitRepo ? "git" : undefined,
292
+ diffBase: result.isGitRepo ? "HEAD" : undefined,
293
+ baseLabel: result.isGitRepo ? "HEAD" : undefined,
294
+ };
295
+ }
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import os from "node:os";
9
+ import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
9
10
 
10
11
  export interface DiscoveredSession {
11
12
  id: string;
@@ -53,15 +54,21 @@ function readSessionHeader(filePath: string): {
53
54
  if (entry.type === "session_info" && entry.name) {
54
55
  name = entry.name;
55
56
  }
56
- // Find first user message
57
+ // Find first user message. Skill invocations are stored as a
58
+ // `<skill name=...>...</skill>\n\nargs` envelope (~264 chars for typical
59
+ // absolute paths) which is longer than the 200-char firstMessage budget,
60
+ // so a naive .slice(0, 200) cuts the wrapper in half. condenseForFirstMessage
61
+ // returns the condensed slash form (`/skill:name args`) when the input
62
+ // matches the envelope, falling back to the raw slice otherwise.
63
+ // See change: render-skill-invocations-collapsibly.
57
64
  if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
58
65
  const msg = entry.message;
59
66
  if (typeof msg.content === "string") {
60
- firstMessage = msg.content.slice(0, 200);
67
+ firstMessage = condenseForFirstMessage(msg.content, 200);
61
68
  } else if (Array.isArray(msg.content)) {
62
69
  for (const part of msg.content) {
63
70
  if (part.type === "text" && part.text) {
64
- firstMessage = part.text.slice(0, 200);
71
+ firstMessage = condenseForFirstMessage(part.text, 200);
65
72
  break;
66
73
  }
67
74
  }
@@ -8,6 +8,7 @@ import { join } from "node:path";
8
8
  import os from "node:os";
9
9
  import type { DashboardSession, SessionSource } from "@blackbelt-technology/pi-dashboard-shared/types.js";
10
10
  import { type SessionMeta, metaPath, readSessionMeta, writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
11
+ import { condenseForFirstMessage } from "@blackbelt-technology/pi-dashboard-shared/skill-block-parser.js";
11
12
  import { extractSessionStats } from "./session-stats-reader.js";
12
13
 
13
14
  function getSessionsDir(): string {
@@ -236,14 +237,15 @@ function readJsonlHeaderSync(filePath: string): { id: string; cwd: string; name?
236
237
  const entry = JSON.parse(line);
237
238
  if (entry.type === "session" && entry.id) header = entry;
238
239
  if (entry.type === "session_info" && entry.name) name = entry.name;
240
+ // See change: render-skill-invocations-collapsibly.
239
241
  if (!firstMessage && entry.type === "message" && entry.message?.role === "user") {
240
242
  const msg = entry.message;
241
243
  if (typeof msg.content === "string") {
242
- firstMessage = msg.content.slice(0, 200);
244
+ firstMessage = condenseForFirstMessage(msg.content, 200);
243
245
  } else if (Array.isArray(msg.content)) {
244
246
  for (const part of msg.content) {
245
247
  if (part.type === "text" && part.text) {
246
- firstMessage = part.text.slice(0, 200);
248
+ firstMessage = condenseForFirstMessage(part.text, 200);
247
249
  break;
248
250
  }
249
251
  }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Rolling NDJSON log of failed pi session spawn attempts.
3
+ *
4
+ * Location: ~/.pi/dashboard/sessions/spawn-failures.log
5
+ * Rotation: single-shot at 10 MB (renames to .log.1, overwrites any prior .log.1).
6
+ * Format: one JSON object per line, terminated by \n.
7
+ *
8
+ * See change: spawn-failure-diagnostics.
9
+ */
10
+ import {
11
+ appendFileSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ renameSync,
16
+ statSync,
17
+ } from "node:fs";
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import type { PreflightReason } from "./spawn-preflight.js";
21
+
22
+ export interface SpawnFailureEntry {
23
+ /** ISO 8601 UTC timestamp. */
24
+ ts: string;
25
+ cwd: string;
26
+ strategy: string;
27
+ code: string;
28
+ message: string;
29
+ stderrTail?: string;
30
+ pid?: number;
31
+ reasons?: PreflightReason[];
32
+ }
33
+
34
+ const LOG_MAX_BYTES = 10 * 1024 * 1024; // 10 MB
35
+ const DEFAULT_LIMIT = 50;
36
+ const MAX_LIMIT = 500;
37
+
38
+ let _logDirOverride: string | null = null;
39
+
40
+ /** Override the log directory — for tests only. See change: spawn-failure-diagnostics. */
41
+ export function _setLogDirForTests(dir: string | null): void {
42
+ _logDirOverride = dir;
43
+ }
44
+
45
+ function logDir(): string {
46
+ return _logDirOverride ?? path.join(os.homedir(), ".pi", "dashboard", "sessions");
47
+ }
48
+
49
+ function logPath(): string {
50
+ return path.join(logDir(), "spawn-failures.log");
51
+ }
52
+
53
+ function logPath1(): string {
54
+ return path.join(logDir(), "spawn-failures.log.1");
55
+ }
56
+
57
+ /**
58
+ * Append a failure entry to the rolling log.
59
+ * Never throws — errors are caught and reported via `console.error`.
60
+ */
61
+ export function appendSpawnFailure(entry: SpawnFailureEntry): void {
62
+ try {
63
+ const dir = logDir();
64
+ mkdirSync(dir, { recursive: true });
65
+
66
+ const filePath = logPath();
67
+ const line = JSON.stringify(entry) + "\n";
68
+
69
+ // Rotate if file exceeds threshold.
70
+ if (existsSync(filePath)) {
71
+ try {
72
+ const { size } = statSync(filePath);
73
+ if (size > LOG_MAX_BYTES) {
74
+ renameSync(filePath, logPath1());
75
+ }
76
+ } catch {
77
+ // If stat/rename fails, just write anyway.
78
+ }
79
+ }
80
+
81
+ appendFileSync(filePath, line, "utf-8");
82
+ } catch (err) {
83
+ console.error("[spawn-failure-log] Failed to append entry:", err);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read the last `limit` entries from the rolling log (both .log.1 and .log).
89
+ * Skips malformed lines. Returns [] when no log exists.
90
+ */
91
+ export function readSpawnFailures(limit: number = DEFAULT_LIMIT): SpawnFailureEntry[] {
92
+ const effectiveLimit = Number.isNaN(limit) ? DEFAULT_LIMIT : Math.max(0, Math.min(limit, MAX_LIMIT));
93
+ if (effectiveLimit === 0) return [];
94
+
95
+ const lines: string[] = [];
96
+
97
+ // Read older log first, then newer.
98
+ for (const filePath of [logPath1(), logPath()]) {
99
+ if (!existsSync(filePath)) continue;
100
+ try {
101
+ const content = readFileSync(filePath, "utf-8");
102
+ lines.push(...content.split("\n").filter((l) => l.trim()));
103
+ } catch {
104
+ // Skip unreadable file.
105
+ }
106
+ }
107
+
108
+ // Parse, skipping malformed lines.
109
+ const entries: SpawnFailureEntry[] = [];
110
+ for (const line of lines) {
111
+ try {
112
+ const obj = JSON.parse(line) as Record<string, unknown>;
113
+ // Require the minimum fields.
114
+ if (
115
+ typeof obj.ts === "string" &&
116
+ typeof obj.cwd === "string" &&
117
+ typeof obj.strategy === "string" &&
118
+ typeof obj.code === "string" &&
119
+ typeof obj.message === "string"
120
+ ) {
121
+ entries.push(obj as unknown as SpawnFailureEntry);
122
+ }
123
+ } catch {
124
+ // Skip malformed line.
125
+ }
126
+ }
127
+
128
+ // Return last N in file order.
129
+ return entries.length <= effectiveLimit ? entries : entries.slice(entries.length - effectiveLimit);
130
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Synchronous spawn preflight check.
3
+ *
4
+ * Runs before every `spawnPiSession` invocation to catch fast-fail conditions
5
+ * (bad cwd, missing binaries) without racing the spawn itself. All checks run
6
+ * regardless of earlier failures so the caller gets all reasons in one pass.
7
+ *
8
+ * The ToolResolver passed in MUST have `useLoginShell: false` — preflight
9
+ * must never spawn a login shell on the spawn-click hot path. If a resolver
10
+ * with `useLoginShell: true` is passed, the check still runs but a one-time
11
+ * warning is emitted.
12
+ *
13
+ * See change: spawn-failure-diagnostics.
14
+ */
15
+ import { existsSync, accessSync, statSync, constants } from "node:fs";
16
+ import { ToolResolver } from "@blackbelt-technology/pi-dashboard-shared/platform/binary-lookup.js";
17
+
18
+ export interface PreflightReason {
19
+ code: string;
20
+ message: string;
21
+ }
22
+
23
+ export interface PreflightResult {
24
+ ok: boolean;
25
+ reasons: PreflightReason[];
26
+ }
27
+
28
+ /**
29
+ * Run all preflight checks for `cwd` and return the accumulated reasons.
30
+ * `ok` is `true` iff `reasons.length === 0`.
31
+ *
32
+ * @param deps.resolver - Must be constructed with `useLoginShell: false`.
33
+ * If omitted, a login-shell-disabled resolver is created automatically.
34
+ * Passing a resolver with `useLoginShell: true` violates the preflight
35
+ * contract; the function still runs but may call into the login shell.
36
+ */
37
+ export function preflightSpawn(
38
+ cwd: string,
39
+ deps?: { resolver?: ToolResolver },
40
+ ): PreflightResult {
41
+ const resolver = deps?.resolver ?? new ToolResolver({ processExecPath: process.execPath, useLoginShell: false });
42
+
43
+ const reasons: PreflightReason[] = [];
44
+
45
+ // 1. cwd exists
46
+ const cwdExists = existsSync(cwd);
47
+ if (!cwdExists) {
48
+ reasons.push({ code: "DIR_MISSING", message: `Directory does not exist: ${cwd}` });
49
+ // No point checking isDirectory / writable if it doesn't exist.
50
+ } else {
51
+ // 2. cwd is a directory
52
+ try {
53
+ const stat = statSync(cwd);
54
+ if (!stat.isDirectory()) {
55
+ reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Path is not a directory: ${cwd}` });
56
+ }
57
+ } catch (err: any) {
58
+ reasons.push({ code: "DIR_NOT_DIRECTORY", message: `Cannot stat path: ${err.message}` });
59
+ }
60
+
61
+ // 3. cwd is writable
62
+ try {
63
+ accessSync(cwd, constants.W_OK);
64
+ } catch {
65
+ reasons.push({ code: "DIR_NOT_WRITABLE", message: `Directory is not writable: ${cwd}` });
66
+ }
67
+ }
68
+
69
+ // 4. pi resolves
70
+ const piCmd = resolver.resolvePi();
71
+ if (piCmd === null) {
72
+ reasons.push({ code: "PI_NOT_FOUND", message: "pi binary not found via managed install or system PATH" });
73
+ }
74
+
75
+ // 5. node resolves
76
+ const nodeCmd = resolver.resolveNode();
77
+ if (nodeCmd === null) {
78
+ reasons.push({ code: "NODE_NOT_FOUND", message: "node binary not found via managed install or system PATH" });
79
+ }
80
+
81
+ return { ok: reasons.length === 0, reasons };
82
+ }
@@ -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.5",
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: