@blackbelt-technology/pi-agent-dashboard 0.4.4 → 0.4.5

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 (53) hide show
  1. package/AGENTS.md +38 -33
  2. package/README.md +1 -0
  3. package/docs/architecture.md +162 -4
  4. package/package.json +4 -4
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/connection-suppress-auto-start.test.ts +97 -0
  7. package/packages/extension/src/__tests__/session-sync.test.ts +81 -1
  8. package/packages/extension/src/bridge-context.ts +10 -0
  9. package/packages/extension/src/bridge.ts +22 -0
  10. package/packages/extension/src/connection.ts +29 -0
  11. package/packages/extension/src/server-auto-start.ts +16 -0
  12. package/packages/extension/src/session-sync.ts +14 -0
  13. package/packages/server/package.json +4 -4
  14. package/packages/server/src/__tests__/cli-restart.test.ts +78 -0
  15. package/packages/server/src/__tests__/config-api.test.ts +9 -0
  16. package/packages/server/src/__tests__/is-activity-event.test.ts +78 -0
  17. package/packages/server/src/__tests__/is-unread-trigger.test.ts +164 -0
  18. package/packages/server/src/__tests__/last-activity-broadcast.test.ts +190 -0
  19. package/packages/server/src/__tests__/reattach-placement.test.ts +231 -0
  20. package/packages/server/src/__tests__/restart-helper.test.ts +25 -0
  21. package/packages/server/src/__tests__/session-order-reboot.test.ts +117 -0
  22. package/packages/server/src/__tests__/session-scanner.test.ts +31 -0
  23. package/packages/server/src/__tests__/system-routes-restart.test.ts +128 -0
  24. package/packages/server/src/__tests__/unread-persistence.test.ts +55 -0
  25. package/packages/server/src/__tests__/unread-trigger-wiring.test.ts +210 -0
  26. package/packages/server/src/__tests__/viewed-session-tracker.test.ts +93 -0
  27. package/packages/server/src/browser-gateway.ts +36 -0
  28. package/packages/server/src/cli.ts +70 -2
  29. package/packages/server/src/event-status-extraction.ts +98 -1
  30. package/packages/server/src/event-wiring.ts +70 -1
  31. package/packages/server/src/memory-session-manager.ts +34 -3
  32. package/packages/server/src/pi-gateway.ts +4 -0
  33. package/packages/server/src/reattach-placement.ts +98 -0
  34. package/packages/server/src/restart-helper.ts +41 -2
  35. package/packages/server/src/routes/system-routes.ts +25 -1
  36. package/packages/server/src/server.ts +55 -3
  37. package/packages/server/src/session-scanner.ts +19 -0
  38. package/packages/server/src/viewed-session-tracker.ts +78 -0
  39. package/packages/shared/package.json +1 -1
  40. package/packages/shared/src/__tests__/config.test.ts +59 -0
  41. package/packages/shared/src/__tests__/mdns-discovery.test.ts +48 -1
  42. package/packages/shared/src/__tests__/no-bash-on-windows.test.ts +304 -0
  43. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +1 -1
  44. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +107 -0
  45. package/packages/shared/src/__tests__/protocol.test.ts +11 -0
  46. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +92 -0
  47. package/packages/shared/src/browser-protocol.ts +25 -0
  48. package/packages/shared/src/config.ts +41 -0
  49. package/packages/shared/src/mdns-discovery.ts +32 -1
  50. package/packages/shared/src/platform/node-spawn.ts +30 -0
  51. package/packages/shared/src/protocol.ts +30 -1
  52. package/packages/shared/src/session-meta.ts +6 -0
  53. package/packages/shared/src/types.ts +19 -0
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Repo-level invariant: no GitHub Actions step that can run on a
3
+ * Windows runner SHALL declare `shell: bash`. Bash on Windows runners
4
+ * is provided by Git for Windows' MSYS2 layer, which translates Win32
5
+ * paths to POSIX form (`D:\a\...` → `/d/a/...`) for any bash variable
6
+ * produced by `pwd`, `dirname`, etc. That POSIX-form string is invisible
7
+ * to native binaries (notably `node.exe`) when embedded in arguments,
8
+ * producing a recurring class of `MODULE_NOT_FOUND` / `ENOENT` bugs.
9
+ *
10
+ * Cross-OS build orchestration MUST be expressed in `.mjs` scripts
11
+ * invoked by `node`. POSIX-only steps MAY use `shell: bash` provided
12
+ * they are gated by an `if:` filter that excludes Windows. Windows-only
13
+ * steps MAY use `shell: pwsh`.
14
+ *
15
+ * If this test fails, port the offending step to `node` (cross-OS) or
16
+ * split it per-OS (`bash` for POSIX, `pwsh` for Windows) and gate each
17
+ * arm with an `if:` filter on `matrix.platform`.
18
+ *
19
+ * See change: eliminate-bash-on-windows-runners.
20
+ *
21
+ * Supported `if:` grammar (anything else fails closed → treated as
22
+ * Windows-reachable, forcing the contributor to write a recognised form
23
+ * or extend this evaluator):
24
+ *
25
+ * - bare boolean : `true` / `false`
26
+ * - matrix comparison : `matrix.platform == 'X'`, `matrix.platform != 'X'`
27
+ * `matrix.arch == 'X'`, `matrix.arch != 'X'`
28
+ * - conjunction : `<expr> && <expr>`
29
+ * - disjunction : `<expr> || <expr>`
30
+ * - negation : `!(<expr>)`
31
+ * - parens : `(<expr>)` are stripped
32
+ *
33
+ * The grammar is small because this repo's workflow YAML is small and
34
+ * stable; if a future workflow needs richer `if:` expressions, extend
35
+ * the evaluator (and the test grammar comment) rather than expand the
36
+ * lint's allowlist.
37
+ */
38
+ import { describe, it, expect } from "vitest";
39
+ import fs from "node:fs";
40
+ import path from "node:path";
41
+ import url from "node:url";
42
+
43
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
44
+ const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", "..");
45
+
46
+ /** Workflow files this lint scans. */
47
+ const WORKFLOW_FILES: readonly string[] = [
48
+ ".github/workflows/publish.yml",
49
+ ".github/workflows/ci.yml",
50
+ ];
51
+
52
+ /** A platform value present in `matrix.platform` of any job we care about. */
53
+ const WINDOWS_PLATFORMS = new Set(["win32"]);
54
+
55
+ /**
56
+ * Extract the YAML body of a top-level job by name. Returns the lines
57
+ * between ` <jobName>:` and the next sibling-indent (` `) job, or EOF.
58
+ *
59
+ * Same pattern as `publish-workflow-contract.test.ts` —
60
+ * regex-based extraction, no YAML library dep, tolerates the stable
61
+ * 2-space indented format used in this repo.
62
+ */
63
+ function extractJobBlock(yaml: string, jobName: string): string | null {
64
+ const lines = yaml.split("\n");
65
+ const headerRe = new RegExp(`^ ${jobName}:\\s*$`);
66
+ let start = -1;
67
+ for (let i = 0; i < lines.length; i++) {
68
+ if (headerRe.test(lines[i])) {
69
+ start = i;
70
+ break;
71
+ }
72
+ }
73
+ if (start === -1) return null;
74
+ const siblingRe = /^ [a-z][a-z0-9-]*:\s*$/;
75
+ let end = lines.length;
76
+ for (let i = start + 1; i < lines.length; i++) {
77
+ if (siblingRe.test(lines[i])) {
78
+ end = i;
79
+ break;
80
+ }
81
+ }
82
+ return lines.slice(start, end).join("\n");
83
+ }
84
+
85
+ /** Pull the matrix.include[*].platform values from a job block. */
86
+ function extractMatrixPlatforms(jobBlock: string): string[] {
87
+ // Each matrix entry starts with `- os:` at a deeper indent. Capture
88
+ // the `platform:` line that sibling-belongs to it (the next platform
89
+ // line after each `- os:`).
90
+ const out: string[] = [];
91
+ const lines = jobBlock.split("\n");
92
+ for (let i = 0; i < lines.length; i++) {
93
+ if (!/^\s+-\s*os:/.test(lines[i])) continue;
94
+ // Look at the next ~6 lines for a sibling `platform:` key.
95
+ for (let j = i + 1; j < Math.min(i + 8, lines.length); j++) {
96
+ if (/^\s+-\s*os:/.test(lines[j])) break; // next entry
97
+ const m = lines[j].match(/^\s+platform:\s*['"]?([\w-]+)['"]?\s*$/);
98
+ if (m) {
99
+ out.push(m[1]);
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Step-level extraction. Each step starts with ` - name:` (6-space
109
+ * indent under ` steps:`). For each step we capture the line number,
110
+ * the `name:` value, the `shell:` value (if any), and the `if:` value
111
+ * (if any).
112
+ */
113
+ interface Step {
114
+ line: number; // 1-indexed
115
+ name: string;
116
+ shell: string | null;
117
+ if_: string | null;
118
+ }
119
+
120
+ function extractSteps(jobBlock: string, baseLine: number): Step[] {
121
+ const lines = jobBlock.split("\n");
122
+ const steps: Step[] = [];
123
+ let cur: Step | null = null;
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const line = lines[i];
126
+ const stepHeader = line.match(/^ -\s*name:\s*(.+?)\s*$/);
127
+ if (stepHeader) {
128
+ if (cur) steps.push(cur);
129
+ cur = {
130
+ line: baseLine + i,
131
+ name: stepHeader[1].replace(/^['"]|['"]$/g, ""),
132
+ shell: null,
133
+ if_: null,
134
+ };
135
+ continue;
136
+ }
137
+ if (cur) {
138
+ // 8-space indented sibling keys of the step.
139
+ const sh = line.match(/^ shell:\s*(\S+)\s*$/);
140
+ if (sh) {
141
+ cur.shell = sh[1];
142
+ continue;
143
+ }
144
+ const ifM = line.match(/^ if:\s*(.+?)\s*$/);
145
+ if (ifM) {
146
+ // Strip a single layer of YAML-double-quote wrapping, e.g.
147
+ // if: "!(matrix.platform == 'win32' && matrix.arch == 'arm64')"
148
+ // Single quotes inside the expression must be preserved.
149
+ let v = ifM[1];
150
+ if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) {
151
+ v = v.slice(1, -1);
152
+ }
153
+ cur.if_ = v;
154
+ continue;
155
+ }
156
+ // A step body line at any non-step-header indent — keep accumulating.
157
+ }
158
+ }
159
+ if (cur) steps.push(cur);
160
+ return steps;
161
+ }
162
+
163
+ /**
164
+ * Pure evaluator: does an `if:` expression evaluate to `true` for ANY
165
+ * concrete (platform, arch) tuple in {windows} × {x64, arm64}? If yes,
166
+ * the step is reachable on Windows.
167
+ *
168
+ * Returns true if the expression is unrecognised (fail closed — force
169
+ * the contributor to write a recognisable form or extend the grammar).
170
+ */
171
+ function reachableOnWindows(ifExpr: string | null, archs: string[]): boolean {
172
+ if (ifExpr == null || ifExpr === "") return true;
173
+ for (const arch of archs) {
174
+ for (const plat of WINDOWS_PLATFORMS) {
175
+ try {
176
+ if (evaluate(ifExpr, { platform: plat, arch })) return true;
177
+ } catch {
178
+ // Unrecognised — fail closed
179
+ return true;
180
+ }
181
+ }
182
+ }
183
+ return false;
184
+ }
185
+
186
+ /**
187
+ * Evaluate a small grammar of `matrix.X op 'literal'` boolean expressions.
188
+ * Throws on unrecognised input.
189
+ */
190
+ function evaluate(
191
+ expr: string,
192
+ ctx: { platform: string; arch: string },
193
+ ): boolean {
194
+ const e = expr.trim();
195
+ if (e === "true") return true;
196
+ if (e === "false") return false;
197
+
198
+ // Negation: `!(...)` — must have matching outer parens
199
+ if (e.startsWith("!(") && e.endsWith(")")) {
200
+ return !evaluate(e.slice(2, -1), ctx);
201
+ }
202
+
203
+ // Strip outer parens
204
+ if (e.startsWith("(") && e.endsWith(")") && balanced(e.slice(1, -1))) {
205
+ return evaluate(e.slice(1, -1), ctx);
206
+ }
207
+
208
+ // Top-level `&&` or `||` — split at the first unparenthesized operator
209
+ const splitAt = (op: string): [string, string] | null => {
210
+ let depth = 0;
211
+ for (let i = 0; i < e.length - op.length + 1; i++) {
212
+ if (e[i] === "(") depth += 1;
213
+ else if (e[i] === ")") depth -= 1;
214
+ else if (depth === 0 && e.slice(i, i + op.length) === op) {
215
+ return [e.slice(0, i).trim(), e.slice(i + op.length).trim()];
216
+ }
217
+ }
218
+ return null;
219
+ };
220
+ const andSplit = splitAt("&&");
221
+ if (andSplit) return evaluate(andSplit[0], ctx) && evaluate(andSplit[1], ctx);
222
+ const orSplit = splitAt("||");
223
+ if (orSplit) return evaluate(orSplit[0], ctx) || evaluate(orSplit[1], ctx);
224
+
225
+ // Atomic: `matrix.<key> <op> '<literal>'`
226
+ const atom = e.match(
227
+ /^matrix\.(platform|arch)\s*(==|!=)\s*['"]([^'"]+)['"]$/,
228
+ );
229
+ if (atom) {
230
+ const [, key, op, lit] = atom;
231
+ const ctxVal = key === "platform" ? ctx.platform : ctx.arch;
232
+ return op === "==" ? ctxVal === lit : ctxVal !== lit;
233
+ }
234
+
235
+ throw new Error(`unrecognised if-expression: ${expr}`);
236
+ }
237
+
238
+ function balanced(s: string): boolean {
239
+ let depth = 0;
240
+ for (const c of s) {
241
+ if (c === "(") depth += 1;
242
+ else if (c === ")") depth -= 1;
243
+ if (depth < 0) return false;
244
+ }
245
+ return depth === 0;
246
+ }
247
+
248
+ describe("no `shell: bash` step is reachable on a Windows runner", () => {
249
+ for (const wf of WORKFLOW_FILES) {
250
+ const abs = path.join(REPO_ROOT, wf);
251
+ if (!fs.existsSync(abs)) continue;
252
+ const yaml = fs.readFileSync(abs, "utf8");
253
+
254
+ // Find every job that has a Windows-able matrix.
255
+ const jobMatches = [...yaml.matchAll(/^ ([a-z][a-z0-9-]*):\s*$/gm)];
256
+ const jobNames = jobMatches.map((m) => m[1]);
257
+
258
+ for (const jobName of jobNames) {
259
+ const block = extractJobBlock(yaml, jobName);
260
+ if (!block) continue;
261
+
262
+ const platforms = extractMatrixPlatforms(block);
263
+ const hasWindows = platforms.some((p) => WINDOWS_PLATFORMS.has(p));
264
+ // Also count jobs whose runs-on directly names windows-*
265
+ const directWindows = /runs-on:\s*['"]?windows-[\w.-]+['"]?/.test(block);
266
+ if (!hasWindows && !directWindows) continue;
267
+
268
+ // Compute the matrix archs available; default to ['x64','arm64'].
269
+ const archs = [
270
+ ...new Set(
271
+ [...block.matchAll(/arch:\s*['"]?([\w-]+)['"]?/g)].map((m) => m[1]),
272
+ ),
273
+ ];
274
+ const archList = archs.length > 0 ? archs : ["x64"];
275
+
276
+ const baseLine = yaml.slice(0, yaml.indexOf(block)).split("\n").length;
277
+ const steps = extractSteps(block, baseLine);
278
+
279
+ it(`${wf} — job '${jobName}': no shell:bash steps reachable on Windows`, () => {
280
+ const offenders: string[] = [];
281
+ for (const s of steps) {
282
+ if (s.shell !== "bash") continue;
283
+ if (reachableOnWindows(s.if_, archList)) {
284
+ offenders.push(
285
+ ` ${wf}:${s.line} step '${s.name}' uses shell: bash` +
286
+ ` and is reachable on Windows (if: ${s.if_ ?? "<none>"})`,
287
+ );
288
+ }
289
+ }
290
+ if (offenders.length > 0) {
291
+ throw new Error(
292
+ `Found ${offenders.length} bash-on-Windows step(s).\n` +
293
+ offenders.join("\n") +
294
+ `\n\nPort each offender to .mjs (cross-OS) OR split per-OS ` +
295
+ `(bash gated by 'matrix.platform != \\'win32\\'' for POSIX, ` +
296
+ `pwsh for Windows).\n` +
297
+ `See change: eliminate-bash-on-windows-runners.`,
298
+ );
299
+ }
300
+ expect(offenders).toEqual([]);
301
+ });
302
+ }
303
+ }
304
+ });
@@ -72,7 +72,7 @@ const ALLOWLIST: readonly string[] = [
72
72
  * The scope is intentionally narrow: only the build-time sites that the
73
73
  * `register-build-time-tools` change migrated, plus the postinstall
74
74
  * scripts that mirror the registry's `bare-import` semantics. Bundle /
75
- * Docker entrypoint scripts (`bundle-server.sh`, `docker-make.sh`,
75
+ * Docker entrypoint scripts (`bundle-server.mjs`, `docker-make.sh`,
76
76
  * `test-electron-install-inner.sh`, etc.) are NOT in scope: those
77
77
  * operate on a known WORKDIR with deterministic node_modules layout
78
78
  * inside the build image and are not affected by host-side hoisting.
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pin Defect 2's jiti version contract for `shouldUrlWrapEntry()`.
3
+ *
4
+ * The Windows-non-tsx arm in `platform/node-spawn.ts::shouldUrlWrapEntry`
5
+ * assumes the jiti loader is from `@mariozechner/pi-coding-agent@0.70.x`
6
+ * (jiti 2.x with the file:// URL handling fix). Newer pi versions ship
7
+ * a different jiti that breaks this contract.
8
+ *
9
+ * This test ensures:
10
+ * 1. The offline-cacache pin in `packages/electron/offline-packages.json`
11
+ * stays at `0.70.x` (the supported range). A bump elsewhere fires
12
+ * this test and forces the contributor to either:
13
+ * - re-verify the contract on Windows
14
+ * - add a per-jiti-version branch
15
+ * - switch the bundled loader to tsx
16
+ * 2. The `shouldUrlWrapEntry` header comment documents the contract
17
+ * so future contributors discover the constraint at the call site.
18
+ *
19
+ * See change: fix-electron-windows-installer-and-server-bootstrap (Defect 2).
20
+ */
21
+ import { describe, it, expect } from "vitest";
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import url from "node:url";
25
+
26
+ const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
27
+ const REPO_ROOT = path.resolve(__dirname, "..", "..", "..", "..");
28
+ const OFFLINE_PACKAGES_PATH = path.join(
29
+ REPO_ROOT,
30
+ "packages",
31
+ "electron",
32
+ "offline-packages.json",
33
+ );
34
+ const NODE_SPAWN_PATH = path.join(
35
+ REPO_ROOT,
36
+ "packages",
37
+ "shared",
38
+ "src",
39
+ "platform",
40
+ "node-spawn.ts",
41
+ );
42
+
43
+ describe("jiti version contract for shouldUrlWrapEntry", () => {
44
+ it("offline-packages.json pins @mariozechner/pi-coding-agent at a 0.70.x version", () => {
45
+ const raw = fs.readFileSync(OFFLINE_PACKAGES_PATH, "utf8");
46
+ const manifest = JSON.parse(raw) as {
47
+ packages: { name: string; version: string }[];
48
+ };
49
+ const piEntry = manifest.packages.find(
50
+ (p) => p.name === "@mariozechner/pi-coding-agent",
51
+ );
52
+ if (!piEntry) {
53
+ throw new Error(
54
+ "@mariozechner/pi-coding-agent not found in offline-packages.json. " +
55
+ "The offline cacache must include pi-coding-agent. " +
56
+ "See change: fix-electron-windows-installer-and-server-bootstrap (Defect 2).",
57
+ );
58
+ }
59
+ if (!piEntry.version.startsWith("0.70.")) {
60
+ throw new Error(
61
+ `pi-coding-agent pinned at ${piEntry.version}, but ` +
62
+ `shouldUrlWrapEntry()'s Windows-non-tsx arm only supports 0.70.x. ` +
63
+ `Newer jiti versions (e.g. 2.6.5 in pi 0.71.x) misnormalize ` +
64
+ `file:/// URL entries on Windows. Either re-verify the contract, ` +
65
+ `add a per-jiti-version branch in shouldUrlWrapEntry(), or switch ` +
66
+ `the bundled loader to tsx. See change: ` +
67
+ `fix-electron-windows-installer-and-server-bootstrap (Defect 2).`,
68
+ );
69
+ }
70
+ expect(piEntry.version).toMatch(/^0\.70\./);
71
+ });
72
+
73
+ it("node-spawn.ts source contains the documented JITI VERSION CONTRACT block", () => {
74
+ const source = fs.readFileSync(NODE_SPAWN_PATH, "utf8");
75
+
76
+ // Contract block markers
77
+ expect(source).toContain("JITI VERSION CONTRACT");
78
+ expect(source).toContain("0.70.x");
79
+
80
+ // Version drift markers (at least one of these identifies the broken jiti)
81
+ const hasVersionDriftMarker =
82
+ source.includes("0.71") || source.includes("2.6.5");
83
+ if (!hasVersionDriftMarker) {
84
+ throw new Error(
85
+ "shouldUrlWrapEntry() docstring is missing the version-drift marker. " +
86
+ "It must mention either '0.71' or '2.6.5' so contributors can " +
87
+ "identify the known-broken jiti versions. See change: " +
88
+ "fix-electron-windows-installer-and-server-bootstrap (Defect 2).",
89
+ );
90
+ }
91
+
92
+ // Remediation guidance markers (at least one)
93
+ const hasRemediationGuidance =
94
+ /re-verify/i.test(source) ||
95
+ /per-version branch/i.test(source) ||
96
+ /per-jiti-version/i.test(source) ||
97
+ /switch.*to tsx/i.test(source);
98
+ if (!hasRemediationGuidance) {
99
+ throw new Error(
100
+ "shouldUrlWrapEntry() docstring is missing remediation guidance. " +
101
+ "It must mention at least one of: re-verify, per-version branch, " +
102
+ "or switch to tsx. See change: " +
103
+ "fix-electron-windows-installer-and-server-bootstrap (Defect 2).",
104
+ );
105
+ }
106
+ });
107
+ });
@@ -100,6 +100,17 @@ describe("Protocol message serialization round-trip", () => {
100
100
  requestId: "req-2",
101
101
  cancelled: true,
102
102
  },
103
+ // See change: fix-restart-bridge-auto-start-race
104
+ {
105
+ type: "server_restarting",
106
+ reason: "restart",
107
+ quiesceMs: 5000,
108
+ },
109
+ {
110
+ type: "server_restarting",
111
+ reason: "shutdown",
112
+ quiesceMs: 60000,
113
+ },
103
114
  ];
104
115
 
105
116
  for (const msg of messages) {
@@ -121,3 +121,95 @@ describe("publish.yml — electron job dependency-graph contract", () => {
121
121
  expect(m[1]).toBe("false");
122
122
  });
123
123
  });
124
+
125
+ // ── Prerelease safety contract ───────────────────────────────────────────────────────
126
+ // Prerelease versions (e.g. `0.4.5-rc.1`) MUST publish to npm under the
127
+ // `next` dist-tag and surface as GitHub `prerelease: true` Releases. The
128
+ // single source of truth is the `prepare` job's computed `is_prerelease`
129
+ // output. See change: eliminate-bash-on-windows-runners (D6).
130
+
131
+ describe("publish.yml — prerelease safety contract", () => {
132
+ const yaml = fs.readFileSync(WORKFLOW_PATH, "utf8");
133
+ const prepareBlock = extractJobBlock(yaml, "prepare");
134
+ const publishBlock = extractJobBlock(yaml, "publish");
135
+ const ghReleaseBlock = extractJobBlock(yaml, "github-release");
136
+
137
+ it("prepare job's outputs block declares `is_prerelease`", () => {
138
+ // Match the `outputs:` block under `prepare`. Accept any whitespace
139
+ // alignment after the colon, but the key must be present and wired
140
+ // to a step output.
141
+ const m = prepareBlock.match(/^\s{4}outputs:\s*\n((?:\s{6}\S.*\n)+)/m);
142
+ if (!m) {
143
+ throw new Error(
144
+ "prepare job has no `outputs:` block. Required to expose\n" +
145
+ "`is_prerelease` to downstream jobs. See change:\n" +
146
+ "eliminate-bash-on-windows-runners (D6).\n" +
147
+ "prepare block:\n" +
148
+ prepareBlock,
149
+ );
150
+ }
151
+ const block = m[1];
152
+ if (!/is_prerelease:\s*\$\{\{\s*steps\.[A-Za-z_]+\.outputs\.is_prerelease\s*\}\}/.test(block)) {
153
+ throw new Error(
154
+ "prepare job's outputs block must declare `is_prerelease` wired to a\n" +
155
+ "step output (e.g. `is_prerelease: ${{ steps.resolve.outputs.is_prerelease }}`).\n" +
156
+ "Without this, downstream `publish` and `github-release` jobs cannot\n" +
157
+ "distinguish prereleases from stable versions. See change:\n" +
158
+ "eliminate-bash-on-windows-runners (D6).\n" +
159
+ "outputs block was:\n" +
160
+ block,
161
+ );
162
+ }
163
+ expect(block).toMatch(/is_prerelease:/);
164
+ });
165
+
166
+ it("publish job uses `--tag next` conditionally on is_prerelease", () => {
167
+ // Two requirements:
168
+ // 1. The literal string `--tag next` appears in the publish loop body.
169
+ // 2. There's a guard checking `is_prerelease == "true"` (or the bash
170
+ // equivalent `[ "$PRERELEASE" = "true" ]`).
171
+ if (!/--tag next/.test(publishBlock)) {
172
+ throw new Error(
173
+ "publish job is missing the `--tag next` literal. Prereleases must\n" +
174
+ "publish under the `next` dist-tag so consumers running plain\n" +
175
+ "`npm install <pkg>` keep getting the last stable release. See\n" +
176
+ "change: eliminate-bash-on-windows-runners (D6).",
177
+ );
178
+ }
179
+ const hasGuard =
180
+ /is_prerelease\s*==\s*['"]true['"]/.test(publishBlock) ||
181
+ /\[\s*"\$PRERELEASE"\s*=\s*"true"\s*\]/.test(publishBlock) ||
182
+ /PRERELEASE.*=.*"true"/.test(publishBlock);
183
+ if (!hasGuard) {
184
+ throw new Error(
185
+ "publish job uses `--tag next` but lacks the prerelease guard. The\n" +
186
+ "`--tag next` argument MUST be conditional on the `is_prerelease`\n" +
187
+ "output (e.g. `if [ \"$PRERELEASE\" = \"true\" ]; then ...`).\n" +
188
+ "Otherwise stable releases would also publish to `next`. See\n" +
189
+ "change: eliminate-bash-on-windows-runners (D6).",
190
+ );
191
+ }
192
+ expect(publishBlock).toContain("--tag next");
193
+ });
194
+
195
+ it("github-release job sets prerelease from is_prerelease", () => {
196
+ // softprops/action-gh-release accepts `prerelease: <bool>` in its
197
+ // `with:` block. The value MUST be derived from the prepare job's
198
+ // `is_prerelease` output (literal-string comparison required because
199
+ // GitHub Actions stringifies job outputs).
200
+ if (
201
+ !/prerelease:\s*\$\{\{\s*needs\.prepare\.outputs\.is_prerelease\s*==\s*['"]true['"]\s*\}\}/
202
+ .test(ghReleaseBlock)
203
+ ) {
204
+ throw new Error(
205
+ "github-release job's `softprops/action-gh-release` step must set\n" +
206
+ "`prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}`\n" +
207
+ "in its `with:` block. Otherwise rc tags surface as stable Releases.\n" +
208
+ "See change: eliminate-bash-on-windows-runners (D6).\n" +
209
+ "github-release block was:\n" +
210
+ ghReleaseBlock,
211
+ );
212
+ }
213
+ expect(ghReleaseBlock).toMatch(/prerelease:.*is_prerelease.*true/);
214
+ });
215
+ });
@@ -726,6 +726,29 @@ export interface RequestRolesBrowserMessage {
726
726
  * which re-emits as `pi.events.emit(event, { ...params, action, _reply })`.
727
727
  * See change: add-extension-ui-modal.
728
728
  */
729
+ /**
730
+ * Browser → server: declares which session a browser is currently displaying
731
+ * (typically when the URL is `/session/:id`). The server uses this to gate
732
+ * unread-trigger evaluation and to clear the unread bit when an unread session
733
+ * is opened. Browsers SHALL re-send `session_view` for the currently-displayed
734
+ * session on every WebSocket reconnect so server-side state stays coherent.
735
+ * See change: session-card-unread-stripes.
736
+ */
737
+ export interface SessionViewBrowserMessage {
738
+ type: "session_view";
739
+ sessionId: string;
740
+ }
741
+
742
+ /**
743
+ * Browser → server: declares the browser is no longer displaying the session
744
+ * (e.g. user navigated away from `/session/:id`).
745
+ * See change: session-card-unread-stripes.
746
+ */
747
+ export interface SessionUnviewBrowserMessage {
748
+ type: "session_unview";
749
+ sessionId: string;
750
+ }
751
+
729
752
  export interface UiManagementBrowserMessage {
730
753
  type: "ui_management";
731
754
  sessionId: string;
@@ -775,4 +798,6 @@ export type BrowserToServerMessage =
775
798
  | RolePresetDeleteBrowserMessage
776
799
  | RequestRolesBrowserMessage
777
800
  | UiManagementBrowserMessage
801
+ | SessionViewBrowserMessage
802
+ | SessionUnviewBrowserMessage
778
803
  | KillProcessBrowserMessage;
@@ -11,6 +11,39 @@ export const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
11
11
 
12
12
  export type SpawnStrategy = "tmux" | "headless";
13
13
 
14
+ /**
15
+ * Policy applied when a bridge re-registers a session after a dashboard
16
+ * restart (i.e. the `session_register` carries `registerReason: "reattach"`).
17
+ *
18
+ * - `"always"` (default) — unconditionally move the session to the front
19
+ * of `sessionOrder` for its cwd.
20
+ * - `"streaming-only"` — only move-to-front when the session's status is
21
+ * currently `"streaming"`.
22
+ * - `"preserve"` — leave `sessionOrder` untouched (legacy behavior).
23
+ *
24
+ * See change: reattach-move-to-front.
25
+ */
26
+ export type ReattachPlacement = "preserve" | "streaming-only" | "always";
27
+
28
+ const VALID_REATTACH_PLACEMENTS: ReattachPlacement[] = [
29
+ "preserve",
30
+ "streaming-only",
31
+ "always",
32
+ ];
33
+
34
+ export const DEFAULT_REATTACH_PLACEMENT: ReattachPlacement = "always";
35
+
36
+ /**
37
+ * Validate a raw value against the {@link ReattachPlacement} union.
38
+ * Anything outside the union (including `undefined`, numbers, objects)
39
+ * falls back to {@link DEFAULT_REATTACH_PLACEMENT}.
40
+ */
41
+ export function parseReattachPlacement(raw: unknown): ReattachPlacement {
42
+ return typeof raw === "string" && (VALID_REATTACH_PLACEMENTS as string[]).includes(raw)
43
+ ? (raw as ReattachPlacement)
44
+ : DEFAULT_REATTACH_PLACEMENT;
45
+ }
46
+
14
47
  export interface AuthProviderConfig {
15
48
  clientId: string;
16
49
  clientSecret: string;
@@ -111,6 +144,12 @@ export interface DashboardConfig {
111
144
  lastServer?: string;
112
145
  /** Whether the server was launched by the Electron app */
113
146
  electronMode: boolean;
147
+ /**
148
+ * Policy applied when the bridge reattaches after a dashboard restart.
149
+ * See {@link ReattachPlacement}. Default `"always"`.
150
+ * See change: reattach-move-to-front.
151
+ */
152
+ reattachPlacement: ReattachPlacement;
114
153
  /** Persisted list of known remote servers */
115
154
  knownServers: KnownServer[];
116
155
  /**
@@ -148,6 +187,7 @@ const DEFAULTS: DashboardConfig = {
148
187
  cors: { allowedOrigins: [] },
149
188
  electronMode: false,
150
189
  knownServers: [],
190
+ reattachPlacement: DEFAULT_REATTACH_PLACEMENT,
151
191
  };
152
192
 
153
193
  /**
@@ -343,6 +383,7 @@ export function loadConfig(): DashboardConfig {
343
383
  ...(typeof parsed.lastServer === "string" ? { lastServer: parsed.lastServer } : {}),
344
384
  electronMode: parsed.electronMode === true,
345
385
  knownServers: parseKnownServers(parsed.knownServers),
386
+ reattachPlacement: parseReattachPlacement(parsed.reattachPlacement),
346
387
  plugins: parsePluginsConfig(parsed.plugins),
347
388
  };
348
389
 
@@ -103,10 +103,41 @@ function getLocalAddresses(): Set<string> {
103
103
  return addresses;
104
104
  }
105
105
 
106
+ /**
107
+ * Pick the best host string for a discovered service.
108
+ *
109
+ * Bonjour can advertise `service.host` as the OS computer-name (e.g. macOS
110
+ * "MacBook 242") which contains characters that are not valid in a DNS
111
+ * hostname — so the browser cannot resolve it. When that happens we fall back
112
+ * to `service.addresses` (preferring IPv4 over IPv6) so the saved entry is
113
+ * actually reachable.
114
+ *
115
+ * Rule: a host is DNS-safe iff it matches `[A-Za-z0-9.-]+` and does not
116
+ * begin or end with a hyphen (RFC 1123, relaxed for the `.local` suffix).
117
+ */
118
+ export function pickBestHost(service: Pick<Service, "host" | "addresses">): string {
119
+ const host = service.host;
120
+ const isDnsSafe =
121
+ typeof host === "string" &&
122
+ host.length > 0 &&
123
+ /^[A-Za-z0-9.-]+$/.test(host) &&
124
+ !host.startsWith("-") &&
125
+ !host.endsWith("-");
126
+ if (isDnsSafe) return host;
127
+
128
+ const addresses = service.addresses ?? [];
129
+ const ipv4 = addresses.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a));
130
+ if (ipv4) return ipv4;
131
+ if (addresses.length > 0) return addresses[0];
132
+
133
+ // Last-resort: keep the original host so we never return undefined.
134
+ return host ?? "unknown";
135
+ }
136
+
106
137
  function serviceToServer(service: Service, isLocal: boolean): DiscoveredServer {
107
138
  const txt = service.txt as Record<string, string> | undefined;
108
139
  return {
109
- host: service.host ?? "unknown",
140
+ host: pickBestHost(service),
110
141
  port: service.port,
111
142
  piPort: parseInt(txt?.piPort ?? "9999", 10),
112
143
  version: txt?.version ?? "unknown",