@ohmaseclaro/fleetwatch 0.1.0

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.
@@ -0,0 +1,275 @@
1
+ import { EventEmitter } from "node:events";
2
+ const HISTORY_BUFFER = 500; // keep the last N events per session in memory for replays
3
+ const IDLE_THRESHOLD_MS = 60_000;
4
+ const RUNNING_FRESHNESS_MS = 5_000;
5
+ export class SessionRegistry extends EventEmitter {
6
+ sessions = new Map();
7
+ upsertMeta(sessionId, init) {
8
+ let entry = this.sessions.get(sessionId);
9
+ if (!entry) {
10
+ const session = {
11
+ id: sessionId,
12
+ projectPath: init.projectPath,
13
+ projectLabel: init.projectLabel,
14
+ status: "idle",
15
+ lastUserMessageAt: 0,
16
+ lastEventAt: 0,
17
+ lastUserMessagePreview: "",
18
+ isSubagent: !!init.isSubagent,
19
+ parentSessionId: init.parentSessionId,
20
+ source: init.source,
21
+ gitBranch: init.gitBranch,
22
+ eventCount: 0,
23
+ };
24
+ entry = {
25
+ session,
26
+ events: [],
27
+ subscribed: false,
28
+ toolUseHasResult: new Set(),
29
+ filePath: init.filePath,
30
+ };
31
+ this.sessions.set(sessionId, entry);
32
+ this.emit("upsert", session);
33
+ }
34
+ else {
35
+ let changed = false;
36
+ if (entry.session.projectPath !== init.projectPath) {
37
+ entry.session.projectPath = init.projectPath;
38
+ changed = true;
39
+ }
40
+ if (entry.session.projectLabel !== init.projectLabel) {
41
+ entry.session.projectLabel = init.projectLabel;
42
+ changed = true;
43
+ }
44
+ if (init.gitBranch && entry.session.gitBranch !== init.gitBranch) {
45
+ entry.session.gitBranch = init.gitBranch;
46
+ changed = true;
47
+ }
48
+ entry.filePath = init.filePath;
49
+ if (changed)
50
+ this.emit("upsert", entry.session);
51
+ }
52
+ return entry;
53
+ }
54
+ setTitle(sessionId, patch) {
55
+ const entry = this.sessions.get(sessionId);
56
+ if (!entry)
57
+ return;
58
+ let changed = false;
59
+ if (patch.aiTitle && entry.session.aiTitle !== patch.aiTitle) {
60
+ entry.session.aiTitle = patch.aiTitle;
61
+ changed = true;
62
+ }
63
+ if (patch.customTitle && entry.session.customTitle !== patch.customTitle) {
64
+ entry.session.customTitle = patch.customTitle;
65
+ changed = true;
66
+ }
67
+ if (changed)
68
+ this.emit("upsert", entry.session);
69
+ }
70
+ /** Mark a session as actively subscribed by a client, enabling event buffering. */
71
+ subscribe(sessionId) {
72
+ const entry = this.sessions.get(sessionId);
73
+ if (entry)
74
+ entry.subscribed = true;
75
+ }
76
+ /** Unsubscribe: stop buffering events (clears the ring buffer to reclaim memory). */
77
+ unsubscribe(sessionId) {
78
+ const entry = this.sessions.get(sessionId);
79
+ if (entry) {
80
+ entry.subscribed = false;
81
+ entry.events = [];
82
+ }
83
+ }
84
+ isSubscribed(sessionId) {
85
+ return this.sessions.get(sessionId)?.subscribed ?? false;
86
+ }
87
+ appendEvent(sessionId, event) {
88
+ const entry = this.sessions.get(sessionId);
89
+ if (!entry)
90
+ return;
91
+ // Only buffer events for subscribed sessions to cap memory.
92
+ if (entry.subscribed) {
93
+ entry.events.push(event);
94
+ if (entry.events.length > HISTORY_BUFFER)
95
+ entry.events.splice(0, entry.events.length - HISTORY_BUFFER);
96
+ }
97
+ const s = entry.session;
98
+ s.eventCount += 1;
99
+ if (event.ts > s.lastEventAt)
100
+ s.lastEventAt = event.ts;
101
+ if (event.type === "user" && event.text) {
102
+ // Skip slash commands & internal hooks for the "last human message" notion.
103
+ const isHumanText = !event.text.startsWith("<command-name>") && !event.text.startsWith("<local-command-stdout>");
104
+ if (isHumanText) {
105
+ s.lastUserMessageAt = event.ts;
106
+ s.lastUserMessagePreview = previewOf(event.text);
107
+ }
108
+ }
109
+ // Status state updates — each event resets prior flags so the state always
110
+ // reflects the most recent meaningful turn.
111
+ if (event.type === "tool_use") {
112
+ entry.lastToolUseId = event.toolUseId;
113
+ entry.lastToolUseName = event.toolName;
114
+ entry.lastWasError = false;
115
+ entry.lastWasSummary = false;
116
+ entry.lastAssistantStop = undefined;
117
+ }
118
+ else if (event.type === "tool_result") {
119
+ if (event.toolUseRef)
120
+ entry.toolUseHasResult.add(event.toolUseRef);
121
+ entry.lastWasError = event.toolResultIsError === true;
122
+ }
123
+ else if (event.type === "assistant") {
124
+ entry.lastAssistantStop = "end_turn";
125
+ entry.lastWasError = false;
126
+ entry.lastWasSummary = false;
127
+ }
128
+ else if (event.type === "user") {
129
+ // New user prompt — reset error flag, assistant hasn't replied yet.
130
+ entry.lastWasError = false;
131
+ entry.lastWasSummary = false;
132
+ entry.lastAssistantStop = undefined;
133
+ }
134
+ else if (event.type === "summary") {
135
+ entry.lastWasSummary = true;
136
+ }
137
+ // We deliberately ignore system events for status derivation — they're
138
+ // mostly hooks / reminders / warnings, not errors.
139
+ this.recomputeStatus(entry);
140
+ this.emit("event", event);
141
+ this.emit("upsert", entry.session);
142
+ }
143
+ setUserMessageFromHistory(projectPath, sessionId, ts, preview) {
144
+ const entry = this.sessions.get(sessionId);
145
+ if (!entry)
146
+ return;
147
+ if (ts > entry.session.lastUserMessageAt) {
148
+ entry.session.lastUserMessageAt = ts;
149
+ entry.session.lastUserMessagePreview = preview;
150
+ this.emit("upsert", entry.session);
151
+ }
152
+ }
153
+ /**
154
+ * Sync per-session metrics from a non-event source (e.g. Cursor's
155
+ * `composerData` envelopes which carry timestamps but no events until the
156
+ * session is opened). Used to seed sort-order before subscription.
157
+ */
158
+ setActivity(sessionId, opts) {
159
+ const entry = this.sessions.get(sessionId);
160
+ if (!entry)
161
+ return;
162
+ let changed = false;
163
+ if (opts.lastEventAt !== undefined && opts.lastEventAt > entry.session.lastEventAt) {
164
+ entry.session.lastEventAt = opts.lastEventAt;
165
+ changed = true;
166
+ }
167
+ if (opts.eventCount !== undefined && opts.eventCount !== entry.session.eventCount) {
168
+ entry.session.eventCount = opts.eventCount;
169
+ changed = true;
170
+ }
171
+ if (opts.currentActivity !== undefined && entry.session.currentActivity !== opts.currentActivity) {
172
+ entry.session.currentActivity = opts.currentActivity;
173
+ changed = true;
174
+ }
175
+ if (changed) {
176
+ this.recomputeStatus(entry);
177
+ this.emit("upsert", entry.session);
178
+ }
179
+ }
180
+ recomputeAll() {
181
+ for (const entry of this.sessions.values()) {
182
+ this.recomputeStatus(entry);
183
+ }
184
+ }
185
+ recomputeStatus(entry) {
186
+ const prev = entry.session.status;
187
+ const next = deriveStatus(entry);
188
+ if (prev !== next.status || entry.session.currentActivity !== next.currentActivity) {
189
+ entry.session.status = next.status;
190
+ entry.session.currentActivity = next.currentActivity;
191
+ this.emit("upsert", entry.session);
192
+ }
193
+ }
194
+ list() {
195
+ return Array.from(this.sessions.values())
196
+ .map((e) => e.session)
197
+ .sort(sortSessions);
198
+ }
199
+ get(sessionId) {
200
+ return this.sessions.get(sessionId)?.session;
201
+ }
202
+ /**
203
+ * Where this session's transcript lives on disk. For JSONL providers
204
+ * (Claude Code, Cowork) it's an actual path. For Cursor we use a synthetic
205
+ * `cursor:<composerId>` token so the UI can show "lives in the Cursor
206
+ * SQLite DB" instead of a misleading non-existent file.
207
+ */
208
+ filePathOf(sessionId) {
209
+ return this.sessions.get(sessionId)?.filePath;
210
+ }
211
+ history(sessionId, limit) {
212
+ const entry = this.sessions.get(sessionId);
213
+ if (!entry)
214
+ return [];
215
+ if (limit && entry.events.length > limit) {
216
+ return entry.events.slice(entry.events.length - limit);
217
+ }
218
+ return entry.events.slice();
219
+ }
220
+ remove(sessionId) {
221
+ if (this.sessions.delete(sessionId))
222
+ this.emit("remove", sessionId);
223
+ }
224
+ }
225
+ const ERROR_FRESHNESS_MS = 5 * 60_000;
226
+ function deriveStatus(entry) {
227
+ const s = entry.session;
228
+ const now = Date.now();
229
+ const ageMs = now - (s.lastEventAt || now);
230
+ if (entry.lastToolUseId && !entry.toolUseHasResult.has(entry.lastToolUseId)) {
231
+ // Only show as running-tool if the call is fresh — otherwise treat as idle.
232
+ if (ageMs < IDLE_THRESHOLD_MS) {
233
+ return {
234
+ status: "running-tool",
235
+ currentActivity: `Running ${entry.lastToolUseName ?? "tool"}…`,
236
+ };
237
+ }
238
+ }
239
+ if (entry.lastWasSummary) {
240
+ return { status: "compacted", currentActivity: "Session compacted" };
241
+ }
242
+ // Errors are only sticky if recent — old sessions go to idle instead of staying red forever.
243
+ if (entry.lastWasError && ageMs < ERROR_FRESHNESS_MS) {
244
+ return { status: "errored", currentActivity: "Error" };
245
+ }
246
+ if (entry.lastAssistantStop === "end_turn") {
247
+ if (ageMs < RUNNING_FRESHNESS_MS) {
248
+ return { status: "running", currentActivity: "Finishing turn…" };
249
+ }
250
+ if (ageMs < IDLE_THRESHOLD_MS) {
251
+ return { status: "awaiting-user", currentActivity: "Waiting for you" };
252
+ }
253
+ return { status: "idle" };
254
+ }
255
+ if (s.lastEventAt > 0 && ageMs > IDLE_THRESHOLD_MS) {
256
+ return { status: "idle" };
257
+ }
258
+ return { status: "running", currentActivity: "Working…" };
259
+ }
260
+ function previewOf(text) {
261
+ const cleaned = text.replace(/\s+/g, " ").trim();
262
+ return cleaned.length > 120 ? cleaned.slice(0, 117) + "…" : cleaned;
263
+ }
264
+ export function sortSessions(a, b) {
265
+ // errored sessions in the last 5 min float to the top
266
+ const now = Date.now();
267
+ const aErrRecent = a.status === "errored" && now - a.lastEventAt < 5 * 60_000;
268
+ const bErrRecent = b.status === "errored" && now - b.lastEventAt < 5 * 60_000;
269
+ if (aErrRecent && !bErrRecent)
270
+ return -1;
271
+ if (bErrRecent && !aErrRecent)
272
+ return 1;
273
+ return (b.lastUserMessageAt || b.lastEventAt) - (a.lastUserMessageAt || a.lastEventAt);
274
+ }
275
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1,397 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { existsSync } from "node:fs";
4
+ import Fastify from "fastify";
5
+ import fastifyStatic from "@fastify/static";
6
+ import fastifyWebsocket from "@fastify/websocket";
7
+ import { attachmentStore } from "./attachmentStore.js";
8
+ import { buildPairingPayload, pickLanIp, rotateToken, saveConfig } from "./pairing.js";
9
+ import { stopTunnel, activeTunnel, lastTunnelError } from "./tunnel.js";
10
+ import { isAuthorized, isPasswordRequired, issueJwt, setPairingToken, setPassword, verifyPassword, } from "./auth.js";
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ export async function startServer(opts) {
13
+ const app = Fastify({ logger: false, trustProxy: true });
14
+ await app.register(fastifyWebsocket, {
15
+ options: {
16
+ maxPayload: 1024 * 1024,
17
+ },
18
+ });
19
+ // Block browser extensions (e.g. Claude Code's SES lockdown) from injecting
20
+ // scripts into the app. Inline styles are needed for Tailwind utility classes.
21
+ app.addHook("onSend", async (_req, reply, payload) => {
22
+ const ct = reply.getHeader("content-type");
23
+ if (ct && ct.startsWith("text/html")) {
24
+ reply.header("Content-Security-Policy", "default-src 'self'; connect-src 'self' ws: wss:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data: https:;");
25
+ }
26
+ return payload;
27
+ });
28
+ if (existsSync(opts.webRoot)) {
29
+ await app.register(fastifyStatic, {
30
+ root: opts.webRoot,
31
+ prefix: "/",
32
+ wildcard: false,
33
+ decorateReply: true,
34
+ });
35
+ }
36
+ // Auth helper: token is presented either as ?token= or Authorization: Bearer
37
+ // and may be either a JWT or the pairing token (when no password is set).
38
+ const requireToken = (token) => isAuthorized(token);
39
+ // --- HTTP routes ---
40
+ app.get("/api/health", async () => ({
41
+ ok: true,
42
+ host: opts.config.hostLabel,
43
+ platform: process.platform,
44
+ version: opts.agentVersion,
45
+ }));
46
+ /** Public — tells the client what auth method to use. */
47
+ app.get("/api/auth-info", async () => ({
48
+ passwordRequired: isPasswordRequired(),
49
+ hostLabel: opts.config.hostLabel,
50
+ }));
51
+ /**
52
+ * Exchange pairing token + optional password for a JWT.
53
+ * Rules:
54
+ * - No password configured: valid pairing token required.
55
+ * - Password configured: valid password required (pairing token optional).
56
+ */
57
+ app.post("/api/login", async (req, reply) => {
58
+ const body = req.body ?? {};
59
+ const passwordNeeded = isPasswordRequired();
60
+ if (passwordNeeded) {
61
+ if (typeof body.password !== "string" || body.password.length === 0) {
62
+ reply.code(400);
63
+ return { error: "password_required" };
64
+ }
65
+ const ok = await verifyPassword(body.password);
66
+ if (!ok) {
67
+ reply.code(401);
68
+ return { error: "invalid_password" };
69
+ }
70
+ }
71
+ else {
72
+ // No password — require valid pairing token (provided via QR URL).
73
+ if (typeof body.token !== "string" || body.token !== opts.config.token) {
74
+ reply.code(401);
75
+ return { error: "invalid_token" };
76
+ }
77
+ }
78
+ const { token: jwtToken, expiresAt } = issueJwt();
79
+ return { jwt: jwtToken, expiresAt, passwordRequired: passwordNeeded };
80
+ });
81
+ app.get("/api/pairing", async (req, reply) => {
82
+ // Only available from localhost.
83
+ const ip = req.ip;
84
+ const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
85
+ if (!isLocal) {
86
+ reply.code(403);
87
+ return { error: "Pairing info is only available from localhost." };
88
+ }
89
+ const lan = pickLanIp() ?? "127.0.0.1";
90
+ const tunnel = activeTunnel();
91
+ const payload = await buildPairingPayload(lan, opts.port, opts.config.token, tunnel?.url);
92
+ return {
93
+ url: payload.url,
94
+ qrSvg: payload.qrSvg,
95
+ hostLabel: opts.config.hostLabel,
96
+ lan,
97
+ port: opts.port,
98
+ ngrokUrl: tunnel?.url ?? null,
99
+ ngrokActive: !!tunnel,
100
+ ngrokConfigured: !!(opts.config.ngrokAuthtoken || process.env.NGROK_AUTHTOKEN),
101
+ ngrokDisabled: !!opts.config.ngrokDisabled,
102
+ passwordRequired: isPasswordRequired(),
103
+ };
104
+ });
105
+ /** Tunnel status — available to any authenticated client so the phone can show it. */
106
+ app.get("/api/tunnel", async (req, reply) => {
107
+ const token = extractToken(req.query, req.headers.authorization);
108
+ if (!requireToken(token)) {
109
+ reply.code(401);
110
+ return { error: "unauthorized" };
111
+ }
112
+ const tunnel = activeTunnel();
113
+ const err = lastTunnelError();
114
+ return {
115
+ active: !!tunnel,
116
+ url: tunnel?.url ?? null,
117
+ error: tunnel ? null : err,
118
+ };
119
+ });
120
+ app.post("/api/rotate-token", async (req, reply) => {
121
+ const ip = req.ip;
122
+ const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
123
+ if (!isLocal) {
124
+ reply.code(403);
125
+ return { error: "Local only." };
126
+ }
127
+ const next = await rotateToken(opts.config);
128
+ opts.config.token = next.token;
129
+ setPairingToken(next.token);
130
+ opts.onConfigChanged(next);
131
+ return { ok: true, token: next.token };
132
+ });
133
+ app.post("/api/settings", async (req, reply) => {
134
+ const ip = req.ip;
135
+ const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1";
136
+ if (!isLocal) {
137
+ reply.code(403);
138
+ return { error: "Local only." };
139
+ }
140
+ if (typeof req.body?.includeCowork === "boolean") {
141
+ opts.config.preferences.includeCowork = req.body.includeCowork;
142
+ }
143
+ if (typeof req.body?.hostLabel === "string" && req.body.hostLabel.length > 0) {
144
+ opts.config.hostLabel = req.body.hostLabel.slice(0, 80);
145
+ }
146
+ if (typeof req.body?.password === "string") {
147
+ setPassword(req.body.password.length > 0 ? req.body.password : null);
148
+ }
149
+ let tunnelRestarted = false;
150
+ if (typeof req.body?.ngrokDisabled === "boolean") {
151
+ opts.config.ngrokDisabled = req.body.ngrokDisabled;
152
+ if (req.body.ngrokDisabled) {
153
+ await stopTunnel().catch(() => { });
154
+ }
155
+ else if (opts.onTunnelAuthtoken && opts.config.ngrokAuthtoken) {
156
+ await opts.onTunnelAuthtoken(opts.config.ngrokAuthtoken).catch(() => { });
157
+ tunnelRestarted = true;
158
+ }
159
+ }
160
+ if (typeof req.body?.ngrokAuthtoken === "string") {
161
+ const token = req.body.ngrokAuthtoken.trim();
162
+ opts.config.ngrokAuthtoken = token || undefined;
163
+ if (opts.onTunnelAuthtoken && token && !opts.config.ngrokDisabled) {
164
+ await opts.onTunnelAuthtoken(token).catch(() => { });
165
+ tunnelRestarted = true;
166
+ }
167
+ else if (!token) {
168
+ await stopTunnel().catch(() => { });
169
+ }
170
+ }
171
+ await saveConfig(opts.config);
172
+ opts.onConfigChanged(opts.config);
173
+ const tunnel = activeTunnel();
174
+ return {
175
+ ok: true,
176
+ hostLabel: opts.config.hostLabel,
177
+ includeCowork: opts.config.preferences.includeCowork,
178
+ ngrokUrl: tunnel?.url ?? null,
179
+ ngrokActive: !!tunnel,
180
+ ngrokDisabled: !!opts.config.ngrokDisabled,
181
+ passwordRequired: isPasswordRequired(),
182
+ tunnelRestarted,
183
+ };
184
+ });
185
+ /**
186
+ * Serve a stored image attachment. Content-addressed — the hash comes from
187
+ * the SessionEvent's `images[].hash`. Requires JWT (or pairing token when
188
+ * no password is set).
189
+ */
190
+ app.get("/api/attachment/:hash", async (req, reply) => {
191
+ const token = extractToken(req.query, req.headers.authorization);
192
+ if (!requireToken(token)) {
193
+ reply.code(401);
194
+ return { error: "unauthorized" };
195
+ }
196
+ const { hash } = req.params;
197
+ // Defensive: only allow alphanumeric (our hashes are hex).
198
+ if (!/^[a-f0-9]{8,64}$/i.test(hash)) {
199
+ reply.code(400);
200
+ return { error: "bad_hash" };
201
+ }
202
+ const entry = attachmentStore.get(hash);
203
+ if (!entry) {
204
+ reply.code(404);
205
+ return { error: "not_found" };
206
+ }
207
+ reply.header("content-type", entry.mediaType);
208
+ // Long cache since the hash is content-addressed and immutable.
209
+ reply.header("cache-control", "private, max-age=2592000, immutable");
210
+ reply.header("content-length", entry.sizeBytes);
211
+ return reply.send(entry.buffer);
212
+ });
213
+ /**
214
+ * Per-session metadata: where the transcript lives, file size, event count,
215
+ * etc. Used by the (i) info modal in the SessionDetail screen.
216
+ */
217
+ app.get("/api/session/:id/info", async (req, reply) => {
218
+ const token = extractToken(req.query, req.headers.authorization);
219
+ if (!requireToken(token)) {
220
+ reply.code(401);
221
+ return { error: "unauthorized" };
222
+ }
223
+ const session = opts.registry.get(req.params.id);
224
+ if (!session) {
225
+ reply.code(404);
226
+ return { error: "not_found" };
227
+ }
228
+ const filePath = opts.registry.filePathOf(req.params.id);
229
+ // For real on-disk files, surface the size (helps the user judge if it's
230
+ // a "big" transcript). Synthetic paths (cursor:<id>) just return null.
231
+ let fileSize = null;
232
+ let exists = false;
233
+ if (filePath && !filePath.startsWith("cursor:")) {
234
+ try {
235
+ const stat = await import("node:fs").then((m) => m.promises.stat(filePath));
236
+ fileSize = stat.size;
237
+ exists = true;
238
+ }
239
+ catch {
240
+ exists = false;
241
+ }
242
+ }
243
+ return {
244
+ sessionId: session.id,
245
+ source: session.source,
246
+ filePath: filePath ?? null,
247
+ fileExists: exists,
248
+ fileSize,
249
+ projectPath: session.projectPath,
250
+ projectLabel: session.projectLabel,
251
+ gitBranch: session.gitBranch ?? null,
252
+ eventCount: session.eventCount,
253
+ lastEventAt: session.lastEventAt,
254
+ lastUserMessageAt: session.lastUserMessageAt,
255
+ status: session.status,
256
+ aiTitle: session.aiTitle ?? null,
257
+ customTitle: session.customTitle ?? null,
258
+ isSubagent: session.isSubagent,
259
+ parentSessionId: session.parentSessionId ?? null,
260
+ };
261
+ });
262
+ app.get("/api/session/:id/history", async (req, reply) => {
263
+ const token = extractToken(req.query, req.headers.authorization);
264
+ if (!requireToken(token)) {
265
+ reply.code(401);
266
+ return { error: "unauthorized" };
267
+ }
268
+ const { id } = req.params;
269
+ return { events: opts.registry.history(id) };
270
+ });
271
+ // SPA fallback — serve index.html for unknown routes so client routing works.
272
+ app.setNotFoundHandler(async (req, reply) => {
273
+ const indexPath = path.join(opts.webRoot, "index.html");
274
+ if (existsSync(indexPath) && (req.url || "").startsWith("/api") === false) {
275
+ return reply.type("text/html").sendFile("index.html");
276
+ }
277
+ reply.code(404).send({ error: "not found" });
278
+ });
279
+ // --- WebSocket ---
280
+ const clients = new Set();
281
+ app.get("/ws", { websocket: true }, (socket, req) => {
282
+ const token = extractToken(req.query, req.headers.authorization);
283
+ if (!requireToken(token)) {
284
+ socket.send(JSON.stringify({ kind: "error", message: "unauthorized" }));
285
+ socket.close(4001, "unauthorized");
286
+ return;
287
+ }
288
+ clients.add(socket);
289
+ const hello = {
290
+ kind: "hello",
291
+ agentVersion: opts.agentVersion,
292
+ hostname: opts.config.hostLabel,
293
+ platform: process.platform,
294
+ ts: Date.now(),
295
+ };
296
+ socket.send(JSON.stringify(hello));
297
+ const list = {
298
+ kind: "session_list",
299
+ sessions: opts.registry.list(),
300
+ ts: Date.now(),
301
+ };
302
+ socket.send(JSON.stringify(list));
303
+ const heartbeat = setInterval(() => {
304
+ try {
305
+ socket.send(JSON.stringify({ kind: "heartbeat", ts: Date.now() }));
306
+ }
307
+ catch { }
308
+ }, 15_000);
309
+ heartbeat.unref();
310
+ // Track which sessions this client has open so we can unsubscribe on disconnect.
311
+ const mySubscriptions = new Set();
312
+ socket.on("message", (raw) => {
313
+ let frame;
314
+ try {
315
+ frame = JSON.parse(raw.toString());
316
+ }
317
+ catch {
318
+ return;
319
+ }
320
+ if (frame.kind === "request_history") {
321
+ const sessionId = frame.sessionId;
322
+ // Mark subscribed → enables event buffering in the registry.
323
+ opts.registry.subscribe(sessionId);
324
+ mySubscriptions.add(sessionId);
325
+ // If the ring buffer is empty, kick off a full backfill from disk.
326
+ const existing = opts.registry.history(sessionId, frame.limit ?? 500);
327
+ if (existing.length > 0) {
328
+ const response = { kind: "session_events", sessionId, events: existing, replay: true };
329
+ socket.send(JSON.stringify(response));
330
+ }
331
+ else {
332
+ // Backfill async — events will come via session_event broadcasts.
333
+ opts.providers.backfillSession(sessionId).then(() => {
334
+ const events = opts.registry.history(sessionId, frame.limit ?? 500);
335
+ const response = { kind: "session_events", sessionId, events, replay: true };
336
+ socket.send(JSON.stringify(response));
337
+ });
338
+ }
339
+ }
340
+ else if (frame.kind === "subscribe") {
341
+ for (const id of frame.sessionIds) {
342
+ opts.registry.subscribe(id);
343
+ mySubscriptions.add(id);
344
+ }
345
+ }
346
+ else if (frame.kind === "unsubscribe") {
347
+ for (const id of frame.sessionIds) {
348
+ opts.registry.unsubscribe(id);
349
+ mySubscriptions.delete(id);
350
+ }
351
+ }
352
+ else if (frame.kind === "heartbeat") {
353
+ // echo
354
+ }
355
+ });
356
+ socket.on("close", () => {
357
+ clearInterval(heartbeat);
358
+ clients.delete(socket);
359
+ // Unsubscribe all sessions this client had open.
360
+ for (const id of mySubscriptions)
361
+ opts.registry.unsubscribe(id);
362
+ mySubscriptions.clear();
363
+ });
364
+ socket.on("error", () => {
365
+ clearInterval(heartbeat);
366
+ clients.delete(socket);
367
+ for (const id of mySubscriptions)
368
+ opts.registry.unsubscribe(id);
369
+ mySubscriptions.clear();
370
+ });
371
+ });
372
+ // Bridge registry events to all clients (broadcast model: tiny payloads).
373
+ opts.registry.on("upsert", (session) => broadcast(clients, { kind: "session_upsert", session }));
374
+ opts.registry.on("event", (event) => broadcast(clients, { kind: "session_event", event }));
375
+ opts.registry.on("remove", (sessionId) => broadcast(clients, { kind: "session_remove", sessionId }));
376
+ await app.listen({ port: opts.port, host: opts.host });
377
+ return app;
378
+ }
379
+ function broadcast(clients, frame) {
380
+ const payload = JSON.stringify(frame);
381
+ for (const sock of clients) {
382
+ if (sock.readyState === 1) {
383
+ try {
384
+ sock.send(payload);
385
+ }
386
+ catch { }
387
+ }
388
+ }
389
+ }
390
+ function extractToken(query, auth) {
391
+ if (query && typeof query.token === "string" && query.token.length > 0)
392
+ return query.token;
393
+ if (auth && auth.startsWith("Bearer "))
394
+ return auth.slice(7);
395
+ return undefined;
396
+ }
397
+ //# sourceMappingURL=server.js.map