@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
@@ -16,7 +16,11 @@
16
16
  import { describe, it, expect, beforeEach } from "vitest";
17
17
  import { createPendingResumeIntentRegistry } from "../pending-resume-intent-registry.js";
18
18
  import { createSessionOrderManager } from "../session-order-manager.js";
19
+ import { applyReattachPolicy } from "../reattach-placement.js";
20
+ import { createMemorySessionManager } from "../memory-session-manager.js";
21
+ import type { ReattachPlacement } from "@blackbelt-technology/pi-dashboard-shared/config.js";
19
22
  import type { PreferencesStore } from "../preferences-store.js";
23
+ import type { BrowserGateway } from "../browser-gateway.js";
20
24
 
21
25
  // In-memory PreferencesStore mock matching the slice of the interface
22
26
  // `sessionOrderManager` consumes.
@@ -207,6 +211,119 @@ describe("ended\u2192alive sessionOrder gate", () => {
207
211
  expect(sessionOrderManager.getOrder(cwd)).toEqual(["X", "A"]);
208
212
  });
209
213
 
214
+ // ── reattach-move-to-front ──
215
+
216
+ it("reattach with policy 'always' on persisted-active session lands at index 0 + broadcasts", () => {
217
+ // Repro: 6 alive sessions in cwd, none ended. The user's was at
218
+ // index 5. Dashboard restarts; bridge reconnects with
219
+ // registerReason: "reattach" for that session id.
220
+ sessionOrderManager.reorder(cwd, ["S0", "S1", "S2", "S3", "S4", "S5"]);
221
+
222
+ // No ended→alive transition fires (wasEnded=false, isEnded=false)
223
+ // for these reattaches; we exercise the new branch in server.ts
224
+ // directly via the helper.
225
+ const sm = createMemorySessionManager();
226
+ sm.register({ id: "S5", cwd, source: "tui" });
227
+
228
+ const broadcasts: any[] = [];
229
+ const gateway = { broadcastToAll: (m: any) => broadcasts.push(m) } as unknown as BrowserGateway;
230
+
231
+ const action = applyReattachPolicy("S5", cwd, "always", {
232
+ sessionManager: sm,
233
+ sessionOrderManager,
234
+ browserGateway: gateway,
235
+ });
236
+
237
+ expect(action).toBe("moveToFront");
238
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["S5", "S0", "S1", "S2", "S3", "S4"]);
239
+ expect(broadcasts).toHaveLength(1);
240
+ expect(broadcasts[0].sessionIds[0]).toBe("S5");
241
+ });
242
+
243
+ it("reattach with policy 'preserve' is a no-op (legacy behavior)", () => {
244
+ sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
245
+ const sm = createMemorySessionManager();
246
+ sm.register({ id: "S2", cwd, source: "tui" });
247
+
248
+ const broadcasts: any[] = [];
249
+ const gateway = { broadcastToAll: (m: any) => broadcasts.push(m) } as unknown as BrowserGateway;
250
+
251
+ const action = applyReattachPolicy("S2", cwd, "preserve", {
252
+ sessionManager: sm,
253
+ sessionOrderManager,
254
+ browserGateway: gateway,
255
+ });
256
+
257
+ expect(action).toBe("preserve");
258
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["S0", "S1", "S2"]);
259
+ expect(broadcasts).toEqual([]);
260
+ });
261
+
262
+ it("legacy bridge (no registerReason) does NOT trigger reattach policy", () => {
263
+ // Spec scenario: "Legacy bridge reattach without registerReason
264
+ // preserves layout". Pre-this-change bridges omit the field; the
265
+ // server treats omission as `"spawn"` and skips the policy entirely.
266
+ sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
267
+
268
+ // Simulate the alive→alive branch's gate condition with a missing
269
+ // registerReason (i.e. ctx?.registerReason === undefined). Per the
270
+ // server.ts onChange code, the branch should not fire.
271
+ const ctx = { registerReason: undefined } as { registerReason?: "spawn" | "reattach" };
272
+ const shouldFire = ctx.registerReason === "reattach";
273
+ expect(shouldFire).toBe(false);
274
+
275
+ // Order remains untouched.
276
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["S0", "S1", "S2"]);
277
+ });
278
+
279
+ it("registry intent wins over registerReason: reattach (defensive guarantee)", () => {
280
+ // Spec scenario: "Registry intent wins over reattach". A registry
281
+ // intent of "front" or "keep" must override any registerReason:
282
+ // "reattach" so user actions are never clobbered by the policy.
283
+ sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
284
+ const sm = createMemorySessionManager();
285
+ sm.register({ id: "S2", cwd, source: "tui" });
286
+
287
+ // Replicate the server.ts alive→alive branch logic verbatim:
288
+ // consume registry first, defer to it if non-null.
289
+ pendingResumeIntents.record("S2", "front");
290
+ const intent = pendingResumeIntents.consume("S2");
291
+ expect(intent).toBe("front");
292
+
293
+ // With intent === "front", the registry path moves to front;
294
+ // the reattach policy is NOT invoked.
295
+ sessionOrderManager.moveToFront(cwd, "S2");
296
+ expect(sessionOrderManager.getOrder(cwd)).toEqual(["S2", "S0", "S1"]);
297
+
298
+ // Verify policy.applyReattachPolicy would also have moved-to-front;
299
+ // the test guarantees registry-path wins by being executed first.
300
+ // (If both paths fired, S2 would still be at front, but only one
301
+ // sessions_reordered broadcast should be emitted in production.)
302
+ });
303
+
304
+ it("reattach respects 'streaming-only' policy: only moves streaming sessions", () => {
305
+ sessionOrderManager.reorder(cwd, ["S0", "S1", "S2"]);
306
+ const sm = createMemorySessionManager();
307
+ sm.register({ id: "S1", cwd, source: "tui" });
308
+ sm.register({ id: "S2", cwd, source: "tui" });
309
+ sm.update("S2", { status: "streaming" });
310
+
311
+ const policies: ReattachPlacement[] = ["streaming-only"];
312
+ for (const p of policies) {
313
+ // S1 is active, not streaming → preserve
314
+ const broadcastsA: any[] = [];
315
+ const gA = { broadcastToAll: (m: any) => broadcastsA.push(m) } as unknown as BrowserGateway;
316
+ expect(applyReattachPolicy("S1", cwd, p, { sessionManager: sm, sessionOrderManager, browserGateway: gA })).toBe("preserve");
317
+ expect(broadcastsA).toEqual([]);
318
+
319
+ // S2 is streaming → moveToFront
320
+ const broadcastsB: any[] = [];
321
+ const gB = { broadcastToAll: (m: any) => broadcastsB.push(m) } as unknown as BrowserGateway;
322
+ expect(applyReattachPolicy("S2", cwd, p, { sessionManager: sm, sessionOrderManager, browserGateway: gB })).toBe("moveToFront");
323
+ expect(broadcastsB).toHaveLength(1);
324
+ }
325
+ });
326
+
210
327
  it("end → resume → end → resume cycle always lands id at index 0", () => {
211
328
  // Regression for `top-of-tier-on-status-change`: pre-fix the
212
329
  // ended→alive branch used insert-if-absent, so on the second resume
@@ -108,6 +108,37 @@ describe("session-scanner", () => {
108
108
  expect(meta.cachedAt).toBeGreaterThan(0);
109
109
  });
110
110
 
111
+ it("should seed lastActivityAt from events.jsonl mtime (cold-start, cached meta path)", () => {
112
+ const dir = createSessionDir("--test-cwd--");
113
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_seed-id.jsonl", { id: "seed-id", cwd: "/seed" });
114
+ writeSessionMeta(sf, {
115
+ cwd: "/seed",
116
+ status: "ended",
117
+ startedAt: 1000,
118
+ cachedAt: Date.now() + 10_000, // fresh cache so we hit the cached-meta arm
119
+ });
120
+
121
+ // Force a known mtime on the .jsonl
122
+ const knownMtime = new Date("2026-04-15T10:00:00.000Z");
123
+ fs.utimesSync(sf, knownMtime, knownMtime);
124
+
125
+ const result = scanAllSessions(tmpDir);
126
+ expect(result.sessions).toHaveLength(1);
127
+ expect(result.sessions[0].lastActivityAt).toBe(knownMtime.getTime());
128
+ });
129
+
130
+ it("should seed lastActivityAt from events.jsonl mtime (fallback parse path)", () => {
131
+ const dir = createSessionDir("--test-cwd--");
132
+ const sf = createJsonl(dir, "2026-03-30T21-39-43-034Z_fallback-seed.jsonl", { id: "fallback-seed", cwd: "/seed2" });
133
+ // No .meta.json — forces the fallback-parse arm.
134
+ const knownMtime = new Date("2026-04-16T11:30:00.000Z");
135
+ fs.utimesSync(sf, knownMtime, knownMtime);
136
+
137
+ const result = scanAllSessions(tmpDir);
138
+ expect(result.sessions).toHaveLength(1);
139
+ expect(result.sessions[0].lastActivityAt).toBe(knownMtime.getTime());
140
+ });
141
+
111
142
  it("should ignore orphaned .meta.json without .jsonl", () => {
112
143
  const dir = createSessionDir("--test-cwd--");
113
144
  // Write .meta.json without a corresponding .jsonl
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Tests for `/api/restart` and `/api/shutdown` `server_restarting` broadcast.
3
+ * See change: fix-restart-bridge-auto-start-race.
4
+ */
5
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
6
+ import Fastify, { type FastifyInstance } from "fastify";
7
+ import { registerSystemRoutes } from "../routes/system-routes.js";
8
+ import type { PiGateway } from "../pi-gateway.js";
9
+ import type { ServerToExtensionMessage } from "@blackbelt-technology/pi-dashboard-shared/protocol.js";
10
+
11
+ function noGuard() {
12
+ return async () => { /* allow all */ };
13
+ }
14
+
15
+ function makeNoopDeps() {
16
+ return {
17
+ sessionManager: {} as never,
18
+ preferencesStore: { flush: () => {} } as never,
19
+ metaPersistence: { flushAll: () => {} } as never,
20
+ config: { port: 8000, piPort: 9999, dev: false } as never,
21
+ networkGuard: noGuard(),
22
+ };
23
+ }
24
+
25
+ function makeFakeGateway(): { gateway: PiGateway; broadcasts: ServerToExtensionMessage[] } {
26
+ const broadcasts: ServerToExtensionMessage[] = [];
27
+ const gateway: PiGateway = {
28
+ broadcast(msg) { broadcasts.push(msg); },
29
+ sendToSession() { return false; },
30
+ isSessionConnected() { return false; },
31
+ connectionCount() { return 0; },
32
+ onSessionRegistered() { /* no-op */ },
33
+ onConnectionClosed() { /* no-op */ },
34
+ close() { /* no-op */ },
35
+ } as unknown as PiGateway;
36
+ return { gateway, broadcasts };
37
+ }
38
+
39
+ describe("POST /api/restart broadcasts server_restarting", () => {
40
+ let fastify: FastifyInstance;
41
+ let exitSpy: ReturnType<typeof vi.spyOn>;
42
+ let broadcasts: ServerToExtensionMessage[];
43
+
44
+ beforeEach(() => {
45
+ fastify = Fastify();
46
+ const fake = makeFakeGateway();
47
+ broadcasts = fake.broadcasts;
48
+ // process.exit is deferred via setTimeout(...,200); silence it for the test
49
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
50
+ registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
51
+ });
52
+
53
+ afterEach(async () => {
54
+ // Wait long enough for the route's deferred setTimeout(process.exit, 200)
55
+ // to fire WHILE the spy is still active, so the mock absorbs it instead
56
+ // of leaking to the real process.exit after mockRestore.
57
+ await new Promise((r) => setTimeout(r, 300));
58
+ await fastify.close();
59
+ exitSpy.mockRestore();
60
+ });
61
+
62
+ it("sends server_restarting with reason=restart and quiesceMs=5000 to bridges before exit", async () => {
63
+ const res = await fastify.inject({ method: "POST", url: "/api/restart", payload: {} });
64
+ // The handler returns ok:true synchronously (orchestrator + exit are deferred).
65
+ expect(res.statusCode).toBe(200);
66
+ expect(res.json()).toEqual({ ok: true });
67
+ expect(broadcasts).toHaveLength(1);
68
+ expect(broadcasts[0]).toEqual({
69
+ type: "server_restarting",
70
+ reason: "restart",
71
+ quiesceMs: 5000,
72
+ });
73
+ });
74
+ });
75
+
76
+ describe("POST /api/shutdown broadcasts server_restarting", () => {
77
+ let fastify: FastifyInstance;
78
+ let exitSpy: ReturnType<typeof vi.spyOn>;
79
+ let broadcasts: ServerToExtensionMessage[];
80
+
81
+ beforeEach(() => {
82
+ fastify = Fastify();
83
+ const fake = makeFakeGateway();
84
+ broadcasts = fake.broadcasts;
85
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
86
+ registerSystemRoutes(fastify, { ...makeNoopDeps(), piGateway: fake.gateway });
87
+ });
88
+
89
+ afterEach(async () => {
90
+ await new Promise((r) => setTimeout(r, 300));
91
+ await fastify.close();
92
+ exitSpy.mockRestore();
93
+ });
94
+
95
+ it("sends server_restarting with reason=shutdown and quiesceMs=60000", async () => {
96
+ const res = await fastify.inject({ method: "POST", url: "/api/shutdown", payload: {} });
97
+ expect(res.statusCode).toBe(200);
98
+ expect(res.json()).toEqual({ ok: true });
99
+ expect(broadcasts).toHaveLength(1);
100
+ expect(broadcasts[0]).toEqual({
101
+ type: "server_restarting",
102
+ reason: "shutdown",
103
+ quiesceMs: 60000,
104
+ });
105
+ });
106
+ });
107
+
108
+ describe("/api/restart works without piGateway (no-op broadcast)", () => {
109
+ let fastify: FastifyInstance;
110
+ let exitSpy: ReturnType<typeof vi.spyOn>;
111
+
112
+ beforeEach(() => {
113
+ fastify = Fastify();
114
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((_code?: number) => undefined as never));
115
+ registerSystemRoutes(fastify, makeNoopDeps()); // no piGateway
116
+ });
117
+
118
+ afterEach(async () => {
119
+ await new Promise((r) => setTimeout(r, 300));
120
+ await fastify.close();
121
+ exitSpy.mockRestore();
122
+ });
123
+
124
+ it("does not throw when there is no gateway", async () => {
125
+ const res = await fastify.inject({ method: "POST", url: "/api/restart", payload: {} });
126
+ expect(res.statusCode).toBe(200);
127
+ });
128
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import {
6
+ writeSessionMeta,
7
+ readSessionMeta,
8
+ type SessionMeta,
9
+ } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
10
+
11
+ /**
12
+ * Persistence round-trip for `SessionMeta.unread`.
13
+ * See change: session-card-unread-stripes.
14
+ */
15
+ describe("unread persistence", () => {
16
+ it("round-trips unread=true through .meta.json", () => {
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "unread-meta-"));
18
+ const sessionFile = path.join(dir, "session-1.jsonl");
19
+ fs.writeFileSync(sessionFile, "");
20
+
21
+ const meta: SessionMeta = {
22
+ source: "tui",
23
+ cwd: "/tmp",
24
+ status: "idle",
25
+ unread: true,
26
+ };
27
+ writeSessionMeta(sessionFile, meta);
28
+
29
+ const restored = readSessionMeta(sessionFile);
30
+ expect(restored?.unread).toBe(true);
31
+ });
32
+
33
+ it("round-trips unread=false through .meta.json", () => {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "unread-meta-"));
35
+ const sessionFile = path.join(dir, "session-2.jsonl");
36
+ fs.writeFileSync(sessionFile, "");
37
+
38
+ writeSessionMeta(sessionFile, { source: "tui", unread: false });
39
+ const restored = readSessionMeta(sessionFile);
40
+ expect(restored?.unread).toBe(false);
41
+ });
42
+
43
+ it("absent unread field is undefined on read (back-compat)", () => {
44
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "unread-meta-"));
45
+ const sessionFile = path.join(dir, "session-3.jsonl");
46
+ fs.writeFileSync(sessionFile, "");
47
+ fs.writeFileSync(
48
+ path.join(dir, "session-3.meta.json"),
49
+ JSON.stringify({ source: "tui", cwd: "/tmp" }),
50
+ );
51
+
52
+ const restored = readSessionMeta(sessionFile);
53
+ expect(restored?.unread).toBeUndefined();
54
+ });
55
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { WebSocket } from "ws";
3
+ import { createServer, type DashboardServer } from "../server.js";
4
+
5
+ /**
6
+ * End-to-end wiring test for `session.unread`:
7
+ * - trigger fires & not viewed → unread broadcast
8
+ * - trigger fires & viewed → no unread
9
+ * - replay events do not trigger unread
10
+ *
11
+ * See change: session-card-unread-stripes.
12
+ */
13
+
14
+ async function connectSession(piPort: number, sessionId: string): Promise<WebSocket> {
15
+ const ws = new WebSocket(`ws://localhost:${piPort}`);
16
+ await new Promise<void>((resolve) => {
17
+ ws.on("open", () => {
18
+ ws.send(JSON.stringify({
19
+ type: "session_register",
20
+ sessionId,
21
+ cwd: "/tmp",
22
+ source: "cli",
23
+ }));
24
+ ws.send(JSON.stringify({ type: "replay_complete", sessionId }));
25
+ setTimeout(resolve, 60);
26
+ });
27
+ });
28
+ return ws;
29
+ }
30
+
31
+ async function connectBrowser(browserPort: number, sessionId: string): Promise<{
32
+ ws: WebSocket;
33
+ broadcasts: Array<Record<string, unknown>>;
34
+ }> {
35
+ const ws = new WebSocket(`ws://localhost:${browserPort}/ws`);
36
+ const broadcasts: Array<Record<string, unknown>> = [];
37
+ await new Promise<void>((resolve) => {
38
+ ws.on("open", () => {
39
+ ws.on("message", (raw) => {
40
+ try {
41
+ const msg = JSON.parse(raw.toString());
42
+ if (msg.type === "session_updated" && msg.sessionId === sessionId) {
43
+ broadcasts.push(msg);
44
+ }
45
+ } catch { /* ignore */ }
46
+ });
47
+ ws.send(JSON.stringify({ type: "subscribe", sessionId }));
48
+ setTimeout(resolve, 80);
49
+ });
50
+ });
51
+ return { ws, broadcasts };
52
+ }
53
+
54
+ function sendEvent(
55
+ ws: WebSocket,
56
+ sessionId: string,
57
+ eventType: string,
58
+ data: Record<string, unknown> = {},
59
+ ): void {
60
+ ws.send(JSON.stringify({
61
+ type: "event_forward",
62
+ sessionId,
63
+ event: { eventType, timestamp: Date.now(), data },
64
+ }));
65
+ }
66
+
67
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
68
+
69
+ describe("unread trigger — server wiring", () => {
70
+ let server: DashboardServer;
71
+ let piPort: number;
72
+ let browserPort: number;
73
+ let testPort = 19400;
74
+
75
+ beforeEach(async () => {
76
+ testPort += 2;
77
+ browserPort = testPort;
78
+ piPort = testPort + 1;
79
+ server = await createServer({
80
+ port: browserPort,
81
+ piPort,
82
+ dev: true,
83
+ autoShutdown: false,
84
+ shutdownIdleSeconds: 999,
85
+ tunnel: false,
86
+ editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
87
+ });
88
+ await server.start();
89
+ });
90
+
91
+ afterEach(async () => {
92
+ await server.stop();
93
+ });
94
+
95
+ it("streaming → idle while NOT viewed marks the session unread and broadcasts", async () => {
96
+ const piWs = await connectSession(piPort, "u1");
97
+ const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u1");
98
+
99
+ // Drive the session into streaming
100
+ sendEvent(piWs, "u1", "agent_start");
101
+ await wait(60);
102
+ expect(server.sessionManager.get("u1")?.status).toBe("streaming");
103
+
104
+ // Now finish the turn — agent_end transitions back to idle
105
+ sendEvent(piWs, "u1", "agent_end");
106
+ await wait(120);
107
+
108
+ const session = server.sessionManager.get("u1");
109
+ expect(session?.unread).toBe(true);
110
+
111
+ const unreadTrue = broadcasts.filter(
112
+ (b) => (b.updates as Record<string, unknown> | undefined)?.unread === true,
113
+ );
114
+ expect(unreadTrue.length).toBeGreaterThanOrEqual(1);
115
+
116
+ piWs.close();
117
+ browser.close();
118
+ });
119
+
120
+ it("trigger fires while a browser IS viewing → unread stays false", async () => {
121
+ const piWs = await connectSession(piPort, "u2");
122
+ const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u2");
123
+
124
+ // Browser declares it is viewing the session
125
+ browser.send(JSON.stringify({ type: "session_view", sessionId: "u2" }));
126
+ await wait(60);
127
+
128
+ sendEvent(piWs, "u2", "agent_start");
129
+ await wait(60);
130
+ sendEvent(piWs, "u2", "agent_end");
131
+ await wait(120);
132
+
133
+ const session = server.sessionManager.get("u2");
134
+ expect(session?.unread).toBeFalsy();
135
+
136
+ const unreadTrue = broadcasts.filter(
137
+ (b) => (b.updates as Record<string, unknown> | undefined)?.unread === true,
138
+ );
139
+ expect(unreadTrue.length).toBe(0);
140
+
141
+ piWs.close();
142
+ browser.close();
143
+ });
144
+
145
+ it("replay events do not flip unread", async () => {
146
+ const ws = new WebSocket(`ws://localhost:${piPort}`);
147
+ await new Promise<void>((resolve) => {
148
+ ws.on("open", () => {
149
+ ws.send(JSON.stringify({
150
+ type: "session_register",
151
+ sessionId: "u3",
152
+ cwd: "/tmp",
153
+ source: "cli",
154
+ }));
155
+ // Send a replayable agent_start/agent_end pair BEFORE replay_complete.
156
+ sendEvent(ws, "u3", "agent_start");
157
+ sendEvent(ws, "u3", "agent_end");
158
+ setTimeout(resolve, 150);
159
+ });
160
+ });
161
+
162
+ const session = server.sessionManager.get("u3");
163
+ expect(session?.unread).toBeFalsy();
164
+
165
+ ws.close();
166
+ });
167
+
168
+ it("session_view clears unread and broadcasts unread=false", async () => {
169
+ const piWs = await connectSession(piPort, "u4");
170
+ const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u4");
171
+
172
+ // Drive into unread (browser not viewing)
173
+ sendEvent(piWs, "u4", "agent_start");
174
+ await wait(50);
175
+ sendEvent(piWs, "u4", "agent_end");
176
+ await wait(120);
177
+ expect(server.sessionManager.get("u4")?.unread).toBe(true);
178
+
179
+ // Now declare view
180
+ browser.send(JSON.stringify({ type: "session_view", sessionId: "u4" }));
181
+ await wait(80);
182
+
183
+ expect(server.sessionManager.get("u4")?.unread).toBe(false);
184
+
185
+ const unreadFalseBroadcast = broadcasts.find(
186
+ (b) => (b.updates as Record<string, unknown> | undefined)?.unread === false,
187
+ );
188
+ expect(unreadFalseBroadcast).toBeDefined();
189
+
190
+ piWs.close();
191
+ browser.close();
192
+ });
193
+
194
+ it("session_view on an already-read session does not produce a redundant broadcast", async () => {
195
+ const piWs = await connectSession(piPort, "u5");
196
+ const { ws: browser, broadcasts } = await connectBrowser(browserPort, "u5");
197
+
198
+ browser.send(JSON.stringify({ type: "session_view", sessionId: "u5" }));
199
+ browser.send(JSON.stringify({ type: "session_view", sessionId: "u5" }));
200
+ await wait(80);
201
+
202
+ const unreadBroadcasts = broadcasts.filter(
203
+ (b) => (b.updates as Record<string, unknown> | undefined)?.unread !== undefined,
204
+ );
205
+ expect(unreadBroadcasts.length).toBe(0);
206
+
207
+ piWs.close();
208
+ browser.close();
209
+ });
210
+ });
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { WebSocket } from "ws";
3
+ import { createViewedSessionTracker } from "../viewed-session-tracker.js";
4
+
5
+ /**
6
+ * Server-side viewed-session tracker. Mirrors mail/Slack global read state.
7
+ * See change: session-card-unread-stripes.
8
+ */
9
+
10
+ // Lightweight stand-ins for `ws.WebSocket`. The tracker only uses the
11
+ // references for identity in `Set<WebSocket>`, so we don't need real sockets.
12
+ function fakeWs(label: string): WebSocket {
13
+ return { __label: label } as unknown as WebSocket;
14
+ }
15
+
16
+ describe("createViewedSessionTracker", () => {
17
+ it("starts empty: no session is viewed", () => {
18
+ const t = createViewedSessionTracker();
19
+ expect(t.isViewedByAnyone("abc")).toBe(false);
20
+ expect(t.viewerCount("abc")).toBe(0);
21
+ });
22
+
23
+ it("view() makes a session viewed", () => {
24
+ const t = createViewedSessionTracker();
25
+ const ws = fakeWs("a");
26
+ t.view("abc", ws);
27
+ expect(t.isViewedByAnyone("abc")).toBe(true);
28
+ expect(t.viewerCount("abc")).toBe(1);
29
+ });
30
+
31
+ it("view() is idempotent for the same ws", () => {
32
+ const t = createViewedSessionTracker();
33
+ const ws = fakeWs("a");
34
+ t.view("abc", ws);
35
+ t.view("abc", ws);
36
+ expect(t.viewerCount("abc")).toBe(1);
37
+ });
38
+
39
+ it("two viewers, one disconnects → still viewed", () => {
40
+ const t = createViewedSessionTracker();
41
+ const a = fakeWs("a");
42
+ const b = fakeWs("b");
43
+ t.view("abc", a);
44
+ t.view("abc", b);
45
+ expect(t.viewerCount("abc")).toBe(2);
46
+ t.unview("abc", a);
47
+ expect(t.isViewedByAnyone("abc")).toBe(true);
48
+ expect(t.viewerCount("abc")).toBe(1);
49
+ });
50
+
51
+ it("last viewer unviews → no longer viewed", () => {
52
+ const t = createViewedSessionTracker();
53
+ const ws = fakeWs("a");
54
+ t.view("abc", ws);
55
+ t.unview("abc", ws);
56
+ expect(t.isViewedByAnyone("abc")).toBe(false);
57
+ expect(t.viewerCount("abc")).toBe(0);
58
+ });
59
+
60
+ it("unview() of an unknown session is a no-op", () => {
61
+ const t = createViewedSessionTracker();
62
+ const ws = fakeWs("a");
63
+ expect(() => t.unview("never-seen", ws)).not.toThrow();
64
+ expect(t.isViewedByAnyone("never-seen")).toBe(false);
65
+ });
66
+
67
+ it("unviewAll() removes a ws from every session", () => {
68
+ const t = createViewedSessionTracker();
69
+ const a = fakeWs("a");
70
+ const b = fakeWs("b");
71
+ t.view("s1", a);
72
+ t.view("s2", a);
73
+ t.view("s2", b);
74
+ t.unviewAll(a);
75
+ expect(t.isViewedByAnyone("s1")).toBe(false);
76
+ expect(t.isViewedByAnyone("s2")).toBe(true); // b still views s2
77
+ expect(t.viewerCount("s2")).toBe(1);
78
+ });
79
+
80
+ it("unviewAll() with no viewing sessions is a no-op", () => {
81
+ const t = createViewedSessionTracker();
82
+ const ws = fakeWs("a");
83
+ expect(() => t.unviewAll(ws)).not.toThrow();
84
+ });
85
+
86
+ it("multiple sessions are tracked independently", () => {
87
+ const t = createViewedSessionTracker();
88
+ const ws = fakeWs("a");
89
+ t.view("s1", ws);
90
+ expect(t.isViewedByAnyone("s1")).toBe(true);
91
+ expect(t.isViewedByAnyone("s2")).toBe(false);
92
+ });
93
+ });