@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
@@ -6,6 +6,7 @@ import type { SessionManager } from "../memory-session-manager.js";
6
6
  import type { PreferencesStore } from "../preferences-store.js";
7
7
  import type { MetaPersistence } from "../meta-persistence.js";
8
8
  import type { DirectoryService } from "../directory-service.js";
9
+ import type { PiGateway } from "../pi-gateway.js";
9
10
  import type { ServerConfig } from "../server.js";
10
11
  import type { ApiResponse } from "@blackbelt-technology/pi-dashboard-shared/types.js";
11
12
  import type { NetworkGuard } from "./route-deps.js";
@@ -31,9 +32,23 @@ export function registerSystemRoutes(
31
32
  networkGuard: NetworkGuard;
32
33
  version?: string;
33
34
  directoryService?: DirectoryService;
35
+ piGateway?: PiGateway;
34
36
  },
35
37
  ) {
36
- const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService } = deps;
38
+ const { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version, directoryService, piGateway } = deps;
39
+
40
+ // Quiesce windows for the bridge `server_restarting` broadcast. See change
41
+ // `fix-restart-bridge-auto-start-race`. Bridges that receive this message
42
+ // suppress only the spawn step in `server-auto-start.ts` for `quiesceMs`;
43
+ // discovery + reconnection still run.
44
+ const RESTART_QUIESCE_MS = 5000;
45
+ const SHUTDOWN_QUIESCE_MS = 60000;
46
+ const announceRestart = (reason: "restart" | "shutdown", quiesceMs: number) => {
47
+ if (!piGateway) return;
48
+ try {
49
+ piGateway.broadcast({ type: "server_restarting", reason, quiesceMs });
50
+ } catch { /* best-effort — never block exit on a flaky bridge socket */ }
51
+ };
37
52
  const serverStartTime = Date.now();
38
53
 
39
54
  // Editor detection endpoint
@@ -196,6 +211,10 @@ export function registerSystemRoutes(
196
211
  async () => {
197
212
  metaPersistence.flushAll();
198
213
  preferencesStore.flush();
214
+ // Tell every connected bridge that the server is going away deliberately
215
+ // BEFORE we start tearing down state, so bridges suppress auto-start.
216
+ // See change: fix-restart-bridge-auto-start-race.
217
+ announceRestart("shutdown", SHUTDOWN_QUIESCE_MS);
199
218
  // Tear down the zrok tunnel (and sweep orphans on our port) so restarts
200
219
  // don't leak reservations that leave stale URLs backed by nothing.
201
220
  try { await deleteTunnel(config.port); } catch { /* best-effort */ }
@@ -212,6 +231,11 @@ export function registerSystemRoutes(
212
231
  metaPersistence.flushAll();
213
232
  preferencesStore.flush();
214
233
 
234
+ // Announce restart to every bridge BEFORE spawning the replacement so
235
+ // bridges suppress their auto-start spawn step and don't race the
236
+ // orchestrator. See change: fix-restart-bridge-auto-start-race.
237
+ announceRestart("restart", RESTART_QUIESCE_MS);
238
+
215
239
  // Tear down tunnel before spawning the replacement process so the new
216
240
  // server doesn't race an orphan zrok agent on the same port.
217
241
  try { await deleteTunnel(config.port); } catch { /* best-effort */ }
@@ -20,6 +20,7 @@ import { createSessionOrderManager, type SessionOrderManager } from "./session-o
20
20
  import { createPendingForkRegistry, type PendingForkRegistry } from "./pending-fork-registry.js";
21
21
  import { createPendingAttachRegistry } from "./pending-attach-registry.js";
22
22
  import { createPendingResumeIntentRegistry } from "./pending-resume-intent-registry.js";
23
+ import { applyReattachPolicy } from "./reattach-placement.js";
23
24
 
24
25
  // pending-load-manager removed — server loads sessions directly via DirectoryService
25
26
  import { createDirectoryService, type DirectoryService } from "./directory-service.js";
@@ -282,7 +283,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
282
283
  }
283
284
 
284
285
  // Save per-session .meta.json on any change
285
- sessionManager.onChange = (sessionId: string) => {
286
+ sessionManager.onChange = (sessionId: string, ctx) => {
286
287
  const session = sessionManager.get(sessionId);
287
288
  if (!session?.sessionFile) return;
288
289
  metaPersistence.save(session.sessionFile, {
@@ -304,6 +305,9 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
304
305
  contextTokens: session.contextTokens ?? undefined,
305
306
  contextWindow: session.contextWindow,
306
307
  firstMessage: session.firstMessage,
308
+ // Persist unread bit so it survives server restart.
309
+ // See change: session-card-unread-stripes.
310
+ unread: session.unread,
307
311
  cachedAt: Date.now(),
308
312
  });
309
313
  // When a session ends, drop its id from the persisted drag-reorder list
@@ -352,17 +356,33 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
352
356
  endedSessionIds.delete(sessionId);
353
357
  const intent = pendingResumeIntents.consume(sessionId);
354
358
  if (intent === null) {
355
- // Bridge auto-reattach leave order alone.
359
+ // No user-driven resume intent. If this register carried
360
+ // `registerReason: "reattach"`, apply the configured
361
+ // `reattachPlacement` policy. Otherwise (legacy bridge or
362
+ // genuine null reattach with `"preserve"` semantics) leave
363
+ // order alone.
364
+ // See change: reattach-move-to-front.
365
+ if (ctx?.registerReason === "reattach") {
366
+ applyReattachPolicy(
367
+ sessionId,
368
+ session.cwd,
369
+ config.reattachPlacement,
370
+ { sessionManager, sessionOrderManager, browserGateway },
371
+ ctx.priorStatus,
372
+ );
373
+ }
356
374
  return;
357
375
  }
358
376
  if (intent === "keep") {
359
377
  // Drag-to-resume — dropped slot wins; the earlier reorder_sessions
360
378
  // already broadcast. Do NOT mutate sessionOrder, do NOT broadcast.
379
+ // Registry intent overrides any `registerReason: "reattach"`.
361
380
  return;
362
381
  }
363
382
  // intent === "front": move-to-front so the just-resumed card
364
383
  // surfaces at the top of the alive tier, even on repeated end →
365
384
  // resume cycles where the id might still be in the order.
385
+ // Registry intent overrides any `registerReason: "reattach"`.
366
386
  sessionOrderManager.moveToFront(session.cwd, sessionId);
367
387
  const next = sessionOrderManager.getOrder(session.cwd) ?? [];
368
388
  browserGateway.broadcastToAll({
@@ -370,6 +390,37 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
370
390
  cwd: session.cwd,
371
391
  sessionIds: next,
372
392
  });
393
+ } else if (!isEnded && !wasEnded && ctx?.registerReason === "reattach") {
394
+ // Reattach of a session that was persisted as alive (the common
395
+ // case after `pi-dashboard restart` while pi processes stay
396
+ // alive). Neither alive→ended nor ended→alive transition fires;
397
+ // we apply the reattach policy directly here.
398
+ //
399
+ // Defensive: a registry intent for an alive session should not
400
+ // happen in practice (handleResumeSession only tags intents for
401
+ // ended sessions), but per spec scenario "Registry intent wins
402
+ // over reattach" we honor it if present and skip the policy.
403
+ // See change: reattach-move-to-front.
404
+ const intent = pendingResumeIntents.consume(sessionId);
405
+ if (intent === "front") {
406
+ sessionOrderManager.moveToFront(session.cwd, sessionId);
407
+ const next = sessionOrderManager.getOrder(session.cwd) ?? [];
408
+ browserGateway.broadcastToAll({
409
+ type: "sessions_reordered",
410
+ cwd: session.cwd,
411
+ sessionIds: next,
412
+ });
413
+ } else if (intent === "keep") {
414
+ // Honor dropped slot; do nothing.
415
+ } else {
416
+ applyReattachPolicy(
417
+ sessionId,
418
+ session.cwd,
419
+ config.reattachPlacement,
420
+ { sessionManager, sessionOrderManager, browserGateway },
421
+ ctx.priorStatus,
422
+ );
423
+ }
373
424
  }
374
425
  };
375
426
  // Track which session ids we've seen as ended at least once, so the
@@ -503,6 +554,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
503
554
  knownSessionIds,
504
555
  pendingDashboardSpawns,
505
556
  pendingAttachRegistry,
557
+ viewedSessionTracker: browserGateway.viewedSessionTracker,
506
558
  });
507
559
 
508
560
  // Auto-shutdown idle timer
@@ -652,7 +704,7 @@ export async function createServer(config: ServerConfig): Promise<DashboardServe
652
704
  if (data) browserGateway.broadcastToAll({ type: "openspec_update", cwd, data });
653
705
  },
654
706
  });
655
- registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService });
707
+ registerSystemRoutes(fastify, { sessionManager, preferencesStore, metaPersistence, config, networkGuard, version: pkgVersion, directoryService, piGateway });
656
708
  registerToolRoutes(fastify, { registry: getDefaultRegistry(), networkGuard });
657
709
 
658
710
  // ── Bootstrap REST routes ────────────────────────────────────────
@@ -38,6 +38,19 @@ function extractTimestamp(filename: string): number {
38
38
  return isNaN(ts) ? Date.now() : ts;
39
39
  }
40
40
 
41
+ /**
42
+ * Read the events.jsonl mtime as a cold-start seed for `lastActivityAt`.
43
+ * Returns `undefined` on stat failure — callers fall back to `startedAt` at
44
+ * render time. See change: session-card-last-activity-badge.
45
+ */
46
+ function readJsonlMtime(sessionFile: string): number | undefined {
47
+ try {
48
+ return statSync(sessionFile).mtimeMs;
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
41
54
  /** Build a DashboardSession from cached `.meta.json` data */
42
55
  function sessionFromMeta(
43
56
  sessionId: string,
@@ -56,6 +69,9 @@ function sessionFromMeta(
56
69
  thinkingLevel: meta.thinkingLevel,
57
70
  startedAt: meta.startedAt ?? startedAt,
58
71
  endedAt: meta.endedAt,
72
+ // Seed last-activity from events.jsonl mtime so the session-card relative-time
73
+ // badge survives server restarts. See change: session-card-last-activity-badge.
74
+ lastActivityAt: readJsonlMtime(sessionFile),
59
75
  tokensIn: meta.tokensIn ?? 0,
60
76
  tokensOut: meta.tokensOut ?? 0,
61
77
  cacheRead: meta.cacheRead,
@@ -68,6 +84,9 @@ function sessionFromMeta(
68
84
  hidden: meta.hidden ?? false,
69
85
  firstMessage: meta.firstMessage,
70
86
  attachedProposal: meta.attachedProposal,
87
+ // Restore unread bit from .meta.json so it survives server restart.
88
+ // See change: session-card-unread-stripes.
89
+ unread: meta.unread,
71
90
  dataUnavailable: true,
72
91
  };
73
92
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Viewed-session tracker.
3
+ *
4
+ * Per-session set of WebSocket connections that are currently displaying
5
+ * the session's chat panel (typically the browser is on `/session/:id`).
6
+ * Used by:
7
+ * - `event-wiring.ts` to gate `isUnreadTrigger`-driven `unread = true`
8
+ * stamps so a session being actively watched never becomes unread.
9
+ * - the `session_view` handler in `browser-gateway.ts` to clear unread
10
+ * when any browser opens an unread session.
11
+ *
12
+ * Read state is GLOBAL across browsers: a session is "viewed" iff at
13
+ * least one connected client has it open. This mirrors mail/Slack:
14
+ * opening on phone clears unread for the laptop too.
15
+ *
16
+ * In-memory only — view state is intrinsically per-connection and has
17
+ * no value to persist.
18
+ *
19
+ * See change: session-card-unread-stripes.
20
+ */
21
+
22
+ import type { WebSocket } from "ws";
23
+
24
+ export interface ViewedSessionTracker {
25
+ /** Mark `ws` as viewing `sessionId`. Idempotent. */
26
+ view(sessionId: string, ws: WebSocket): void;
27
+ /** Mark `ws` as no longer viewing `sessionId`. Idempotent. */
28
+ unview(sessionId: string, ws: WebSocket): void;
29
+ /**
30
+ * Remove `ws` from every viewed session. Called from the WebSocket
31
+ * `close` handler so a disconnected browser cannot hold sessions in
32
+ * the viewed state forever.
33
+ */
34
+ unviewAll(ws: WebSocket): void;
35
+ /** Returns true if at least one connected browser views the session. */
36
+ isViewedByAnyone(sessionId: string): boolean;
37
+ /** Test/diagnostic accessor — number of viewers for a session. */
38
+ viewerCount(sessionId: string): number;
39
+ }
40
+
41
+ export function createViewedSessionTracker(): ViewedSessionTracker {
42
+ const viewers = new Map<string, Set<WebSocket>>();
43
+
44
+ return {
45
+ view(sessionId: string, ws: WebSocket): void {
46
+ let set = viewers.get(sessionId);
47
+ if (!set) {
48
+ set = new Set<WebSocket>();
49
+ viewers.set(sessionId, set);
50
+ }
51
+ set.add(ws);
52
+ },
53
+
54
+ unview(sessionId: string, ws: WebSocket): void {
55
+ const set = viewers.get(sessionId);
56
+ if (!set) return;
57
+ set.delete(ws);
58
+ if (set.size === 0) viewers.delete(sessionId);
59
+ },
60
+
61
+ unviewAll(ws: WebSocket): void {
62
+ for (const [sessionId, set] of viewers) {
63
+ if (set.delete(ws) && set.size === 0) {
64
+ viewers.delete(sessionId);
65
+ }
66
+ }
67
+ },
68
+
69
+ isViewedByAnyone(sessionId: string): boolean {
70
+ const set = viewers.get(sessionId);
71
+ return !!set && set.size > 0;
72
+ },
73
+
74
+ viewerCount(sessionId: string): number {
75
+ return viewers.get(sessionId)?.size ?? 0;
76
+ },
77
+ };
78
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blackbelt-technology/pi-dashboard-shared",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
4
4
  "description": "Shared types and utilities for pi-dashboard",
5
5
  "type": "module",
6
6
  "repository": {
@@ -412,3 +412,62 @@ describe("ensureConfig", () => {
412
412
  expect(content.piPort).toBeUndefined();
413
413
  });
414
414
  });
415
+
416
+ describe("loadConfig reattachPlacement", () => {
417
+ let testDir: string;
418
+ let configFile: string;
419
+ let origHome: string;
420
+
421
+ beforeEach(() => {
422
+ testDir = path.join(os.tmpdir(), `test-reattach-${Date.now()}-${Math.random()}`);
423
+ fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
424
+ configFile = path.join(testDir, ".pi", "dashboard", "config.json");
425
+ origHome = process.env.HOME!;
426
+ process.env.HOME = testDir;
427
+ });
428
+
429
+ afterEach(() => {
430
+ process.env.HOME = origHome;
431
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
432
+ });
433
+
434
+ it("defaults to 'always' when missing", () => {
435
+ fs.writeFileSync(configFile, JSON.stringify({}));
436
+ expect(loadConfig().reattachPlacement).toBe("always");
437
+ });
438
+
439
+ it("defaults to 'always' when config file does not exist", () => {
440
+ expect(loadConfig().reattachPlacement).toBe("always");
441
+ });
442
+
443
+ it("accepts 'preserve'", () => {
444
+ fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "preserve" }));
445
+ expect(loadConfig().reattachPlacement).toBe("preserve");
446
+ });
447
+
448
+ it("accepts 'streaming-only'", () => {
449
+ fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "streaming-only" }));
450
+ expect(loadConfig().reattachPlacement).toBe("streaming-only");
451
+ });
452
+
453
+ it("accepts 'always' explicitly", () => {
454
+ fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "always" }));
455
+ expect(loadConfig().reattachPlacement).toBe("always");
456
+ });
457
+
458
+ it("falls back to 'always' on invalid string", () => {
459
+ fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: "wibble" }));
460
+ expect(loadConfig().reattachPlacement).toBe("always");
461
+ });
462
+
463
+ it("falls back to 'always' on non-string", () => {
464
+ fs.writeFileSync(configFile, JSON.stringify({ reattachPlacement: 42 }));
465
+ expect(loadConfig().reattachPlacement).toBe("always");
466
+ });
467
+
468
+ it("ensureConfig does NOT write reattachPlacement to defaults", () => {
469
+ ensureConfig();
470
+ const content = JSON.parse(fs.readFileSync(configFile, "utf-8"));
471
+ expect(content.reattachPlacement).toBeUndefined();
472
+ });
473
+ });
@@ -1,6 +1,53 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import os from "node:os";
3
- import { isLocalService, type DiscoveredServer } from "../mdns-discovery.js";
3
+ import { isLocalService, pickBestHost, type DiscoveredServer } from "../mdns-discovery.js";
4
+
5
+ describe("pickBestHost", () => {
6
+ it("keeps a DNS-safe hostname unchanged", () => {
7
+ const service = { host: "my-mac.local", addresses: ["192.168.1.5"] } as any;
8
+ expect(pickBestHost(service)).toBe("my-mac.local");
9
+ });
10
+
11
+ it("keeps a bare DNS-safe hostname unchanged", () => {
12
+ const service = { host: "macbook", addresses: ["192.168.1.5"] } as any;
13
+ expect(pickBestHost(service)).toBe("macbook");
14
+ });
15
+
16
+ it("falls back to IPv4 address when host has a space", () => {
17
+ const service = { host: "MacBook 242", addresses: ["192.168.16.242", "fe80::1"] } as any;
18
+ expect(pickBestHost(service)).toBe("192.168.16.242");
19
+ });
20
+
21
+ it("falls back to IPv4 when host has invalid characters", () => {
22
+ const service = { host: "my_mac@home", addresses: ["10.0.0.5"] } as any;
23
+ expect(pickBestHost(service)).toBe("10.0.0.5");
24
+ });
25
+
26
+ it("prefers IPv4 over IPv6 when both are present", () => {
27
+ const service = { host: "bad host", addresses: ["fe80::1", "192.168.1.7"] } as any;
28
+ expect(pickBestHost(service)).toBe("192.168.1.7");
29
+ });
30
+
31
+ it("falls back to first address when only IPv6 is available", () => {
32
+ const service = { host: "bad host", addresses: ["fe80::1"] } as any;
33
+ expect(pickBestHost(service)).toBe("fe80::1");
34
+ });
35
+
36
+ it("returns the original host when no addresses available (best-effort)", () => {
37
+ const service = { host: "bad host", addresses: [] } as any;
38
+ expect(pickBestHost(service)).toBe("bad host");
39
+ });
40
+
41
+ it("returns 'unknown' when host is missing and no addresses", () => {
42
+ const service = { host: undefined, addresses: [] } as any;
43
+ expect(pickBestHost(service)).toBe("unknown");
44
+ });
45
+
46
+ it("rejects host starting with hyphen", () => {
47
+ const service = { host: "-bad", addresses: ["10.0.0.1"] } as any;
48
+ expect(pickBestHost(service)).toBe("10.0.0.1");
49
+ });
50
+ });
4
51
 
5
52
  // Test isLocalService with mock Service objects
6
53
  describe("isLocalService", () => {