@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
@@ -17,6 +17,7 @@ import type { SessionOrderManager } from "./session-order-manager.js";
17
17
  import type { PreferencesStore } from "./preferences-store.js";
18
18
  import type { DirectoryService } from "./directory-service.js";
19
19
  import { createPendingResumeRegistry, type PendingResumeRegistry } from "./pending-resume-registry.js";
20
+ import { createViewedSessionTracker, type ViewedSessionTracker } from "./viewed-session-tracker.js";
20
21
  import type { TerminalManager } from "./terminal-manager.js";
21
22
  import type { BrowserHandlerContext } from "./browser-handlers/handler-context.js";
22
23
  import { handleSubscribe } from "./browser-handlers/subscription-handler.js";
@@ -53,6 +54,12 @@ export interface BrowserGateway {
53
54
  headlessPidRegistry: HeadlessPidRegistry;
54
55
  /** Registry for pending auto-resume prompts */
55
56
  pendingResumeRegistry: PendingResumeRegistry;
57
+ /**
58
+ * Tracker for which browser is currently viewing which session. Used by
59
+ * the unread-trigger evaluation in event-wiring.ts.
60
+ * See change: session-card-unread-stripes.
61
+ */
62
+ viewedSessionTracker: ViewedSessionTracker;
56
63
  /** Send a message to a specific WebSocket client */
57
64
  sendToClient(ws: WebSocket, msg: ServerToBrowserMessage): void;
58
65
  /** Callback invoked when a new browser client connects */
@@ -86,6 +93,10 @@ export function createBrowserGateway(
86
93
  // Track headless child processes with sessionId linkage
87
94
  const headlessPidRegistry = createHeadlessPidRegistry();
88
95
 
96
+ // Track which browser is viewing which session (for unread state machine).
97
+ // See change: session-card-unread-stripes.
98
+ const viewedSessionTracker = createViewedSessionTracker();
99
+
89
100
  // Track pending interactive UI requests per session for replay on reconnect
90
101
  const pendingUiRequests = new Map<string, Map<string, { requestId: string; method: string; params: Record<string, unknown> }>>();
91
102
 
@@ -454,6 +465,26 @@ export function createBrowserGateway(
454
465
  case "rename_terminal":
455
466
  handleRenameTerminal(msg, ctx);
456
467
  break;
468
+ case "session_view": {
469
+ // Browser declares it is currently displaying this session.
470
+ // Track the (sessionId, ws) pair AND clear `unread` if set.
471
+ // See change: session-card-unread-stripes.
472
+ viewedSessionTracker.view(msg.sessionId, ws);
473
+ const session = sessionManager.get(msg.sessionId);
474
+ if (session?.unread) {
475
+ sessionManager.update(msg.sessionId, { unread: false });
476
+ broadcast({
477
+ type: "session_updated",
478
+ sessionId: msg.sessionId,
479
+ updates: { unread: false },
480
+ });
481
+ }
482
+ break;
483
+ }
484
+ case "session_unview": {
485
+ viewedSessionTracker.unview(msg.sessionId, ws);
486
+ break;
487
+ }
457
488
  default:
458
489
  // Forward simple pi-gateway commands
459
490
  handlePiGatewayForward(msg, ctx);
@@ -473,6 +504,9 @@ export function createBrowserGateway(
473
504
  console.error(`[browser-gw] browser client disconnected (remaining: ${subscriptions.size - 1})`);
474
505
  subscriptions.delete(ws);
475
506
  replayingSessions.delete(ws);
507
+ // Drop this ws from every viewed-session entry so disconnected browsers
508
+ // don't hold sessions in the viewed state. See change: session-card-unread-stripes.
509
+ viewedSessionTracker.unviewAll(ws);
476
510
  });
477
511
  });
478
512
 
@@ -560,6 +594,8 @@ export function createBrowserGateway(
560
594
  headlessPidRegistry,
561
595
 
562
596
  pendingResumeRegistry,
597
+
598
+ viewedSessionTracker,
563
599
  };
564
600
 
565
601
  return gateway;
@@ -467,6 +467,75 @@ async function cmdStop(): Promise<void> {
467
467
  }
468
468
  }
469
469
 
470
+ /**
471
+ * `pi-dashboard restart` — restart the daemon.
472
+ *
473
+ * If a dashboard is currently running, POST to `/api/restart` so the proven
474
+ * `restart-helper.ts` orchestrator handles the stop/start atomically in a
475
+ * detached child. This avoids the bridge-auto-start race that occurs when
476
+ * `cmdStop()` kills the daemon in-process: every connected bridge sees its
477
+ * WS close and fires `server-auto-start.ts`, racing the subsequent
478
+ * `cmdStart()` to bind the port.
479
+ *
480
+ * If the dashboard is NOT running (or is unreachable), fall back to the
481
+ * existing `cmdStop()` + `cmdStart()` sequence.
482
+ *
483
+ * See change: fix-restart-bridge-auto-start-race.
484
+ */
485
+ export async function cmdRestart(
486
+ config: ServerConfig,
487
+ injected?: {
488
+ isDashboardRunning?: typeof isDashboardRunning;
489
+ fetchImpl?: typeof fetch;
490
+ cmdStopImpl?: () => Promise<void>;
491
+ cmdStartImpl?: (cfg: ServerConfig) => Promise<void>;
492
+ },
493
+ ): Promise<void> {
494
+ const probe = injected?.isDashboardRunning ?? isDashboardRunning;
495
+ const fetchFn = injected?.fetchImpl ?? fetch;
496
+ const stopFn = injected?.cmdStopImpl ?? cmdStop;
497
+ const startFn = injected?.cmdStartImpl ?? cmdStart;
498
+ return cmdRestartImpl(config, probe, fetchFn, stopFn, startFn);
499
+ }
500
+
501
+ async function cmdRestartImpl(
502
+ config: ServerConfig,
503
+ probe: typeof isDashboardRunning,
504
+ fetchFn: typeof fetch,
505
+ stopFn: () => Promise<void>,
506
+ startFn: (cfg: ServerConfig) => Promise<void>,
507
+ ): Promise<void> {
508
+ const status = await probe(config.port);
509
+ if (status.running) {
510
+ console.log(
511
+ `[restart] dashboard running at http://localhost:${config.port}, delegating to /api/restart`,
512
+ );
513
+ try {
514
+ const res = await fetchFn(`http://localhost:${config.port}/api/restart`, {
515
+ method: "POST",
516
+ headers: { "content-type": "application/json" },
517
+ body: JSON.stringify({ dev: !!config.dev }),
518
+ });
519
+ if (res.ok) {
520
+ console.log("[restart] orchestrator queued; CLI exits now.");
521
+ return;
522
+ }
523
+ const body = await res.text();
524
+ console.error(
525
+ `[restart] server rejected restart: HTTP ${res.status} ${body}; falling back to local stop/start`,
526
+ );
527
+ } catch (err) {
528
+ console.error(
529
+ `[restart] failed to reach server (${(err as Error).message ?? err}); falling back to local stop/start`,
530
+ );
531
+ }
532
+ // Fall through to local sequence on HTTP failure so the user is never
533
+ // left with a half-restarted server.
534
+ }
535
+ await stopFn();
536
+ await startFn(config);
537
+ }
538
+
470
539
  /**
471
540
  * Show server status.
472
541
  */
@@ -589,8 +658,7 @@ async function main() {
589
658
  await cmdStop();
590
659
  break;
591
660
  case "restart":
592
- await cmdStop();
593
- await cmdStart(config);
661
+ await cmdRestart(config);
594
662
  break;
595
663
  case "status":
596
664
  await cmdStatus(config.port);
@@ -2,7 +2,7 @@
2
2
  * Extract session status/tool updates from forwarded events.
3
3
  * Returns partial DashboardSession updates, or null if the event is not relevant.
4
4
  */
5
- import type { DashboardEvent, DashboardSession, FlowStatus } from "@blackbelt-technology/pi-dashboard-shared/types.js";
5
+ import type { DashboardEvent, DashboardSession, FlowStatus, SessionStatus } from "@blackbelt-technology/pi-dashboard-shared/types.js";
6
6
 
7
7
  // Use null (not undefined) for fields that must be cleared — undefined is
8
8
  // dropped during JSON serialisation so the browser would keep the stale value.
@@ -136,3 +136,100 @@ export function extractSessionUpdates(event: DashboardEvent): SessionUpdates | n
136
136
  return null;
137
137
  }
138
138
  }
139
+
140
+ /**
141
+ * Activity-event allowlist for `session.lastActivityAt` stamping.
142
+ *
143
+ * Returns `true` for event types that represent user-or-agent action
144
+ * (the kind of thing a human would call "this session did something"),
145
+ * and `false` for plumbing/heartbeat/UI-state noise.
146
+ *
147
+ * The allowlist is deliberately narrow. Adding a new pi event type that
148
+ * a user would consider "activity" requires adding it here.
149
+ *
150
+ * See change: session-card-last-activity-badge (design.md § "Activity-event allowlist").
151
+ */
152
+ const ACTIVITY_EVENT_TYPES: ReadonlySet<string> = new Set([
153
+ // User input
154
+ "prompt_send",
155
+ // Assistant message lifecycle
156
+ "message_start",
157
+ "message_end",
158
+ "turn_end",
159
+ // Tool execution
160
+ "tool_execution_start",
161
+ "tool_execution_end",
162
+ // Agent lifecycle
163
+ "agent_start",
164
+ "agent_end",
165
+ // Bash command output
166
+ "bash_output",
167
+ // Flow lifecycle / agent steps
168
+ "flow_started",
169
+ "flow_complete",
170
+ "flow_agent_started",
171
+ "flow_agent_complete",
172
+ // Architect (flow design) lifecycle
173
+ "architect_started",
174
+ "architect_complete",
175
+ "architect_cancelled",
176
+ ]);
177
+
178
+ export function isActivityEvent(eventType: string): boolean {
179
+ return ACTIVITY_EVENT_TYPES.has(eventType);
180
+ }
181
+
182
+ /**
183
+ * Snapshot of the session fields the unread classifier needs.
184
+ * Pulled out of `DashboardSession` to keep the helper testable without
185
+ * constructing a full session object.
186
+ */
187
+ export interface UnreadTriggerSnapshot {
188
+ status?: SessionStatus;
189
+ currentTool?: string | null;
190
+ }
191
+
192
+ /**
193
+ * Pure classifier: should the given event flip a session to `unread: true`?
194
+ *
195
+ * Triggers (per change: session-card-unread-stripes):
196
+ * 1. status transition `streaming` -> `idle` or `streaming` -> `active`
197
+ * (turn finished)
198
+ * 2. `currentTool` becomes `"ask_user"` (input requested)
199
+ * 3. `agent_end` event whose payload's `error` field is truthy
200
+ *
201
+ * Anything else (assistant message_end, tool_execution_*, model_select,
202
+ * git/process noise) returns false. This is intentionally narrower than
203
+ * `isActivityEvent` — unread is for moments that demand the user’s eyes,
204
+ * not every tick of work.
205
+ *
206
+ * The caller is responsible for the "not currently viewed" gate — this
207
+ * helper is concerned only with whether the event semantically qualifies.
208
+ */
209
+ export function isUnreadTrigger(
210
+ eventType: string,
211
+ before: UnreadTriggerSnapshot,
212
+ after: UnreadTriggerSnapshot,
213
+ payload?: unknown,
214
+ ): boolean {
215
+ // Trigger 1: streaming -> idle | active (turn fully finished)
216
+ if (
217
+ before.status === "streaming" &&
218
+ (after.status === "idle" || after.status === "active")
219
+ ) {
220
+ return true;
221
+ }
222
+
223
+ // Trigger 2: currentTool flips to "ask_user"
224
+ if (after.currentTool === "ask_user" && before.currentTool !== "ask_user") {
225
+ return true;
226
+ }
227
+
228
+ // Trigger 3: agent_end with error
229
+ if (eventType === "agent_end") {
230
+ const data = (payload as { error?: unknown } | undefined) ?? undefined;
231
+ if (data && data.error) return true;
232
+ }
233
+
234
+ return false;
235
+ }
@@ -9,7 +9,8 @@ import type { BrowserGateway } from "./browser-gateway.js";
9
9
  import type { SessionOrderManager } from "./session-order-manager.js";
10
10
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
11
11
  import type { DirectoryService } from "./directory-service.js";
12
- import { extractSessionUpdates } from "./event-status-extraction.js";
12
+ import { extractSessionUpdates, isActivityEvent, isUnreadTrigger } from "./event-status-extraction.js";
13
+ import type { ViewedSessionTracker } from "./viewed-session-tracker.js";
13
14
  import { spawnPiSession } from "./process-manager.js";
14
15
  import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
15
16
  import { writeSessionMeta } from "@blackbelt-technology/pi-dashboard-shared/session-meta.js";
@@ -34,6 +35,13 @@ export interface EventWiringDeps {
34
35
  * auto-rename. See change: add-folder-task-checker-and-spawn-attach.
35
36
  */
36
37
  pendingAttachRegistry?: import("./pending-attach-registry.js").PendingAttachRegistry;
38
+ /**
39
+ * Optional viewed-session tracker. When provided, the wiring evaluates
40
+ * `isUnreadTrigger(...)` on each forwarded event and stamps
41
+ * `session.unread = true` for sessions no browser is currently viewing.
42
+ * See change: session-card-unread-stripes.
43
+ */
44
+ viewedSessionTracker?: ViewedSessionTracker;
37
45
  }
38
46
 
39
47
  /**
@@ -52,6 +60,7 @@ export function wireEvents(deps: EventWiringDeps): void {
52
60
  knownSessionIds,
53
61
  pendingDashboardSpawns,
54
62
  pendingAttachRegistry,
63
+ viewedSessionTracker,
55
64
  } = deps;
56
65
 
57
66
  // Broadcast placeholder session to browsers when auto-created from early events
@@ -107,6 +116,13 @@ export function wireEvents(deps: EventWiringDeps): void {
107
116
  const skipReplayInsert = new Set<string>();
108
117
  // Debounce flows refresh to prevent infinite loop between sessions in same cwd
109
118
  const recentFlowsRefresh = new Set<string>();
119
+ // Per-session timestamp of the most recent `lastActivityAt` broadcast.
120
+ // In-memory state updates on every activity event; the WebSocket broadcast
121
+ // is throttled to at most one per `LAST_ACTIVITY_BROADCAST_INTERVAL_MS` per
122
+ // session. The client's local `now` ticker handles label refreshes between
123
+ // broadcasts. See change: session-card-last-activity-badge.
124
+ const lastActivityBroadcastAt = new Map<string, number>();
125
+ const LAST_ACTIVITY_BROADCAST_INTERVAL_MS = 30_000;
110
126
 
111
127
  piGateway.onEvent = (sessionId, msg) => {
112
128
  if (msg.type === "event_forward") {
@@ -134,6 +150,15 @@ export function wireEvents(deps: EventWiringDeps): void {
134
150
  browserGateway.broadcastEvent(sessionId, seq, storedEvent);
135
151
  }
136
152
 
153
+ // Snapshot pre-update fields used by `isUnreadTrigger`. Captured here
154
+ // so the trigger sees the before/after edges of `status` and
155
+ // `currentTool` cleanly. See change: session-card-unread-stripes.
156
+ const sessionBefore = sessionManager.get(sessionId);
157
+ const beforeSnapshot = {
158
+ status: sessionBefore?.status,
159
+ currentTool: sessionBefore?.currentTool,
160
+ };
161
+
137
162
  const updates = extractSessionUpdates(msg.event);
138
163
  if (updates) {
139
164
  if (updates.flowAgentsDone === -1) {
@@ -148,6 +173,47 @@ export function wireEvents(deps: EventWiringDeps): void {
148
173
  }
149
174
  }
150
175
 
176
+ // Unread-trigger evaluation. Only fires for live (non-replay) events
177
+ // and only stamps when no browser is currently viewing the session.
178
+ // The viewedSessionTracker dep is optional for backward compatibility
179
+ // (and to keep tests that don't need it lean).
180
+ // See change: session-card-unread-stripes.
181
+ if (!replayingSessions.has(sessionId) && viewedSessionTracker) {
182
+ const sessionAfter = sessionManager.get(sessionId);
183
+ const afterSnapshot = {
184
+ status: sessionAfter?.status,
185
+ currentTool: sessionAfter?.currentTool,
186
+ };
187
+ if (
188
+ isUnreadTrigger(
189
+ msg.event.eventType,
190
+ beforeSnapshot,
191
+ afterSnapshot,
192
+ msg.event.data,
193
+ ) &&
194
+ !viewedSessionTracker.isViewedByAnyone(sessionId)
195
+ ) {
196
+ if (sessionAfter && !sessionAfter.unread) {
197
+ sessionManager.update(sessionId, { unread: true });
198
+ browserGateway.broadcastSessionUpdated(sessionId, { unread: true });
199
+ }
200
+ }
201
+ }
202
+
203
+ // Stamp `session.lastActivityAt` on every live activity event.
204
+ // Skipped during replay — historical events should not retroactively
205
+ // bump the badge. In-memory updates always; broadcasts throttled per
206
+ // session. See change: session-card-last-activity-badge.
207
+ if (!replayingSessions.has(sessionId) && isActivityEvent(msg.event.eventType)) {
208
+ const now = Date.now();
209
+ sessionManager.update(sessionId, { lastActivityAt: now });
210
+ const lastBroadcast = lastActivityBroadcastAt.get(sessionId) ?? 0;
211
+ if (now - lastBroadcast >= LAST_ACTIVITY_BROADCAST_INTERVAL_MS) {
212
+ lastActivityBroadcastAt.set(sessionId, now);
213
+ browserGateway.broadcastSessionUpdated(sessionId, { lastActivityAt: now });
214
+ }
215
+ }
216
+
151
217
  // Server-side OpenSpec activity detection from forwarded events
152
218
  // Skip during replay — replayed events from a forked session would set stale phase/change
153
219
  if (msg.event.eventType === "tool_execution_start" && !replayingSessions.has(sessionId)) {
@@ -466,6 +532,9 @@ export function wireEvents(deps: EventWiringDeps): void {
466
532
  }
467
533
 
468
534
  if (msg.type === "session_unregister") {
535
+ // Drop the per-session debounce entry so a future re-register with the
536
+ // same id does not silently suppress its first activity broadcast.
537
+ lastActivityBroadcastAt.delete(sessionId);
469
538
  browserGateway.broadcastSessionRemoved(sessionId);
470
539
  }
471
540
 
@@ -16,6 +16,33 @@ export interface RegisterSessionParams {
16
16
  firstMessage?: string;
17
17
  startedAt?: number;
18
18
  pid?: number;
19
+ /**
20
+ * Why the bridge is registering this session. Forwarded from the
21
+ * `session_register` protocol message (see
22
+ * `SessionRegisterMessage.registerReason`). Used by `onChange` to
23
+ * decide whether to apply the configured `reattachPlacement` policy.
24
+ * See change: reattach-move-to-front.
25
+ */
26
+ registerReason?: "spawn" | "reattach";
27
+ }
28
+
29
+ export interface OnChangeContext {
30
+ /**
31
+ * Set when `onChange` is fired from `register(...)` and the inbound
32
+ * params carried a `registerReason`. Undefined for `update`/`unregister`
33
+ * paths and for legacy registers without the field.
34
+ * See change: reattach-move-to-front.
35
+ */
36
+ registerReason?: "spawn" | "reattach";
37
+ /**
38
+ * The session's status BEFORE `register(...)` overwrote it to `"active"`.
39
+ * Captured because `register()` unconditionally sets `status: "active"`,
40
+ * which would otherwise hide a `"streaming"` reattach from policies
41
+ * that gate on streaming. Undefined for first-ever registers and for
42
+ * `update`/`unregister` paths.
43
+ * See change: reattach-move-to-front.
44
+ */
45
+ priorStatus?: SessionStatus;
19
46
  }
20
47
 
21
48
  export interface SessionManager {
@@ -27,8 +54,8 @@ export interface SessionManager {
27
54
  get(sessionId: string): DashboardSession | undefined;
28
55
  listActive(): DashboardSession[];
29
56
  listAll(): DashboardSession[];
30
- /** Called after any mutation (register, unregister, update). Receives the affected session ID. */
31
- onChange?: (sessionId: string) => void;
57
+ /** Called after any mutation (register, unregister, update). Receives the affected session ID and optional context. */
58
+ onChange?: (sessionId: string, ctx?: OnChangeContext) => void;
32
59
  /** Called after a session is unregistered (status set to ended). */
33
60
  onUnregister?: (sessionId: string) => void;
34
61
  }
@@ -43,6 +70,7 @@ export function createMemorySessionManager(): SessionManager {
43
70
  // polled by the bridge extension shortly after reconnect, so they don't
44
71
  // need to be carried over.
45
72
  const existing = sessions.get(params.id);
73
+ const priorStatus = existing?.status;
46
74
 
47
75
  const session: DashboardSession = {
48
76
  // Carry over accumulated data from the existing session (e.g. restored after restart)
@@ -80,7 +108,10 @@ export function createMemorySessionManager(): SessionManager {
80
108
  pid: params.pid,
81
109
  };
82
110
  sessions.set(params.id, session);
83
- mgr.onChange?.(params.id);
111
+ mgr.onChange?.(params.id, {
112
+ registerReason: params.registerReason,
113
+ priorStatus,
114
+ });
84
115
  return session;
85
116
  },
86
117
 
@@ -297,6 +297,10 @@ export function createPiGateway(
297
297
  sessionDir: msg.sessionDir,
298
298
  firstMessage: msg.firstMessage,
299
299
  pid: msg.pid,
300
+ // Forward registerReason so server.ts onChange can apply
301
+ // the configured reattach placement policy.
302
+ // See change: reattach-move-to-front.
303
+ registerReason: msg.registerReason,
300
304
  });
301
305
  console.error(`[gateway] session registered: ${msg.sessionId} cwd=${msg.cwd}`);
302
306
 
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Reattach placement policy: when a bridge sends `session_register` with
3
+ * `registerReason: "reattach"` (i.e. the dashboard restarted while pi
4
+ * stayed alive), this module decides how the re-registered session id
5
+ * should be placed in the cwd's `sessionOrder`.
6
+ *
7
+ * Pure decision logic is extracted into `decideReattachAction` so it
8
+ * can be unit-tested without spinning up managers or browser gateways;
9
+ * `applyReattachPolicy` is the I/O-bearing entry point that calls it
10
+ * and performs the actual mutation + broadcast.
11
+ *
12
+ * See change: reattach-move-to-front.
13
+ */
14
+ import type { ReattachPlacement } from "@blackbelt-technology/pi-dashboard-shared/config.js";
15
+ import type { SessionStatus } from "@blackbelt-technology/pi-dashboard-shared/types.js";
16
+ import type { SessionManager } from "./memory-session-manager.js";
17
+ import type { SessionOrderManager } from "./session-order-manager.js";
18
+ import type { BrowserGateway } from "./browser-gateway.js";
19
+
20
+ export type ReattachAction = "moveToFront" | "preserve";
21
+
22
+ /**
23
+ * Pure helper: decide whether the policy demands a `moveToFront` for a
24
+ * reattaching session given the current configured placement and the
25
+ * session's current status.
26
+ *
27
+ * Mapping:
28
+ * - `"always"` → always `"moveToFront"`
29
+ * - `"streaming-only"` → `"moveToFront"` iff `status === "streaming"`
30
+ * - `"preserve"` → always `"preserve"`
31
+ */
32
+ export function decideReattachAction(
33
+ policy: ReattachPlacement,
34
+ status: SessionStatus | undefined,
35
+ ): ReattachAction {
36
+ switch (policy) {
37
+ case "always":
38
+ return "moveToFront";
39
+ case "streaming-only":
40
+ return status === "streaming" ? "moveToFront" : "preserve";
41
+ case "preserve":
42
+ default:
43
+ return "preserve";
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Apply the configured reattach placement policy to a session that just
49
+ * re-registered with `registerReason: "reattach"`.
50
+ *
51
+ * Calls `sessionOrderManager.moveToFront` and broadcasts
52
+ * `sessions_reordered` only when the policy demands it. No-op when the
53
+ * session no longer exists in the manager, when its status is
54
+ * `"ended"`, or when the policy resolves to `"preserve"`.
55
+ *
56
+ * `priorStatus` is the session's status BEFORE `register()` coerced it
57
+ * to `"active"`. It's the meaningful signal for `"streaming-only"`:
58
+ * a session mid-stream when the dashboard rebooted carries
59
+ * `priorStatus === "streaming"`, even though `session.status` is now
60
+ * `"active"`. Pass `undefined` when the prior status is unknown
61
+ * (first-ever register), in which case the helper falls back to
62
+ * `session.status`.
63
+ * See change: reattach-move-to-front.
64
+ */
65
+ export function applyReattachPolicy(
66
+ sessionId: string,
67
+ cwd: string,
68
+ policy: ReattachPlacement,
69
+ deps: {
70
+ sessionManager: SessionManager;
71
+ sessionOrderManager: SessionOrderManager;
72
+ browserGateway: BrowserGateway;
73
+ },
74
+ priorStatus?: SessionStatus,
75
+ ): ReattachAction {
76
+ const session = deps.sessionManager.get(sessionId);
77
+ if (!session) return "preserve";
78
+ // Defensive: if the session somehow ended between register and this
79
+ // hook firing, skip — the alive→ended branch in server.ts handles it.
80
+ if (session.status === "ended") return "preserve";
81
+
82
+ // Use prior status when known so `streaming-only` honors a session
83
+ // that was streaming when the dashboard went down. `register()`
84
+ // unconditionally sets `status: "active"`, so without this fallback
85
+ // `streaming-only` would silently behave as `preserve`.
86
+ const effectiveStatus = priorStatus ?? session.status;
87
+ const action = decideReattachAction(policy, effectiveStatus);
88
+ if (action === "moveToFront") {
89
+ deps.sessionOrderManager.moveToFront(cwd, sessionId);
90
+ const next = deps.sessionOrderManager.getOrder(cwd) ?? [];
91
+ deps.browserGateway.broadcastToAll({
92
+ type: "sessions_reordered",
93
+ cwd,
94
+ sessionIds: next,
95
+ });
96
+ }
97
+ return action;
98
+ }
@@ -36,6 +36,10 @@ export interface RestartParams {
36
36
  export function buildOrchestratorScript(params: RestartParams): string {
37
37
  const execPath = params.execPath ?? process.execPath;
38
38
  const logPath = path.join(os.homedir(), ".pi", "dashboard", "restart.log");
39
+ // Same convention as `server-pid.ts`. Embedded as a JSON-stringified literal
40
+ // so quoting/path-separator handling is correct on Windows.
41
+ // See change: fix-restart-bridge-auto-start-race.
42
+ const pidPath = path.join(os.homedir(), ".pi", "dashboard", "dashboard.pid");
39
43
  // Loader is always URL-wrapped (required on Windows for non-C: drives).
40
44
  // Entry is URL-wrapped only on Windows + non-tsx loader. POSIX + jiti MUST
41
45
  // pass raw path because jiti's resolver treats file:// URL entries as
@@ -66,6 +70,7 @@ const PORT = ${params.port};
66
70
  const EXEC = ${JSON.stringify(execPath)};
67
71
  const ARGS = ${JSON.stringify(spawnArgs)};
68
72
  const LOG_PATH = ${JSON.stringify(logPath)};
73
+ const PID_PATH = ${JSON.stringify(pidPath)};
69
74
 
70
75
  function log(msg) {
71
76
  try {
@@ -99,9 +104,43 @@ function healthOk() {
99
104
 
100
105
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
101
106
 
107
+ // The next three process.kill calls run inside the orchestrator's
108
+ // 'node -e' subprocess (NOT in-host server code), so they cannot use
109
+ // the platform/process.ts helpers — those modules are not bundled into
110
+ // the embedded script. The repo-lint opt-out marker at the end of each
111
+ // line keeps no-direct-process-kill.test.ts quiet.
112
+ // See change: fix-restart-bridge-auto-start-race.
113
+ function isAlive(pid) {
114
+ try { process.kill(pid, 0); return true; } catch (_) { return false; } // ban:process-kill-ok
115
+ }
116
+
117
+ // 0. Read PID file and terminate the previous daemon explicitly. Removes the
118
+ // "wait for self-exit" ambiguity that lets bridge auto-start race the
119
+ // orchestrator. See change: fix-restart-bridge-auto-start-race.
120
+ async function killPriorDaemon() {
121
+ let pid = 0;
122
+ try {
123
+ const raw = fs.readFileSync(PID_PATH, "utf-8").trim();
124
+ pid = parseInt(raw, 10);
125
+ } catch (_) { return; /* no PID file — nothing to do */ }
126
+ if (!Number.isFinite(pid) || pid <= 0) return;
127
+ if (!isAlive(pid)) return;
128
+ try { process.kill(pid, "SIGTERM"); } catch (_) { /* ignore */ } // ban:process-kill-ok
129
+ for (let i = 0; i < 30; i++) { // up to 3 s
130
+ await sleep(100);
131
+ if (!isAlive(pid)) return;
132
+ }
133
+ try { process.kill(pid, "SIGKILL"); } catch (_) { /* ignore */ } // ban:process-kill-ok
134
+ await sleep(200);
135
+ }
136
+
102
137
  (async () => {
103
- // 1. Wait for port to be free (up to 10s)
104
- for (let i = 0; i < 20; i++) {
138
+ // 0. Explicit kill of previous daemon (SIGTERM SIGKILL).
139
+ await killPriorDaemon();
140
+
141
+ // 1. Wait for port to be free (up to 5s — reduced from 10s because step 0
142
+ // already guarantees the previous server is dead).
143
+ for (let i = 0; i < 10; i++) {
105
144
  if (await portFree(PORT)) break;
106
145
  await sleep(500);
107
146
  }