@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.
- package/AGENTS.md +38 -33
- package/README.md +1 -0
- package/docs/architecture.md +162 -4
- package/package.json +4 -4
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/connection-suppress-auto-start.test.ts +97 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +81 -1
- package/packages/extension/src/bridge-context.ts +10 -0
- package/packages/extension/src/bridge.ts +22 -0
- package/packages/extension/src/connection.ts +29 -0
- package/packages/extension/src/server-auto-start.ts +16 -0
- package/packages/extension/src/session-sync.ts +14 -0
- package/packages/server/package.json +4 -4
- package/packages/server/src/__tests__/cli-restart.test.ts +78 -0
- package/packages/server/src/__tests__/config-api.test.ts +9 -0
- package/packages/server/src/__tests__/is-activity-event.test.ts +78 -0
- package/packages/server/src/__tests__/is-unread-trigger.test.ts +164 -0
- package/packages/server/src/__tests__/last-activity-broadcast.test.ts +190 -0
- package/packages/server/src/__tests__/reattach-placement.test.ts +231 -0
- package/packages/server/src/__tests__/restart-helper.test.ts +25 -0
- package/packages/server/src/__tests__/session-order-reboot.test.ts +117 -0
- package/packages/server/src/__tests__/session-scanner.test.ts +31 -0
- package/packages/server/src/__tests__/system-routes-restart.test.ts +128 -0
- package/packages/server/src/__tests__/unread-persistence.test.ts +55 -0
- package/packages/server/src/__tests__/unread-trigger-wiring.test.ts +210 -0
- package/packages/server/src/__tests__/viewed-session-tracker.test.ts +93 -0
- package/packages/server/src/browser-gateway.ts +36 -0
- package/packages/server/src/cli.ts +70 -2
- package/packages/server/src/event-status-extraction.ts +98 -1
- package/packages/server/src/event-wiring.ts +70 -1
- package/packages/server/src/memory-session-manager.ts +34 -3
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/reattach-placement.ts +98 -0
- package/packages/server/src/restart-helper.ts +41 -2
- package/packages/server/src/routes/system-routes.ts +25 -1
- package/packages/server/src/server.ts +55 -3
- package/packages/server/src/session-scanner.ts +19 -0
- package/packages/server/src/viewed-session-tracker.ts +78 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/config.test.ts +59 -0
- package/packages/shared/src/__tests__/mdns-discovery.test.ts +48 -1
- package/packages/shared/src/__tests__/no-bash-on-windows.test.ts +304 -0
- package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +1 -1
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +107 -0
- package/packages/shared/src/__tests__/protocol.test.ts +11 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +92 -0
- package/packages/shared/src/browser-protocol.ts +25 -0
- package/packages/shared/src/config.ts +41 -0
- package/packages/shared/src/mdns-discovery.ts +32 -1
- package/packages/shared/src/platform/node-spawn.ts +30 -0
- package/packages/shared/src/protocol.ts +30 -1
- package/packages/shared/src/session-meta.ts +6 -0
- 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
|
|
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
|
-
//
|
|
104
|
-
|
|
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
|
}
|