@wrongstack/cli 0.296.2 → 0.296.4

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 (61) hide show
  1. package/dist/boot/dispatch-webui.d.ts +1 -0
  2. package/dist/boot/dispatch-webui.d.ts.map +1 -1
  3. package/dist/boot/tui-session-resume.d.ts.map +1 -1
  4. package/dist/{chunk-IN4GXGYW.js → chunk-BQCAJ7FI.js} +2 -2
  5. package/dist/{chunk-IN4GXGYW.js.map → chunk-BQCAJ7FI.js.map} +2 -2
  6. package/dist/{chunk-X5DQTPXT.js → chunk-OI7GWJDS.js} +1048 -804
  7. package/dist/chunk-OI7GWJDS.js.map +7 -0
  8. package/dist/{cli-main-6AU6E4XZ.js → cli-main-TOAWVJDY.js} +197 -60
  9. package/dist/cli-main-TOAWVJDY.js.map +7 -0
  10. package/dist/cli-main-helpers.d.ts +1 -1
  11. package/dist/cli-main-helpers.d.ts.map +1 -1
  12. package/dist/cli-main.d.ts.map +1 -1
  13. package/dist/execute-deps.d.ts +1 -0
  14. package/dist/execute-deps.d.ts.map +1 -1
  15. package/dist/{execution-V26SE52F.js → execution-O7I5DYE6.js} +34 -8
  16. package/dist/execution-O7I5DYE6.js.map +7 -0
  17. package/dist/execution-storage-observability.d.ts.map +1 -1
  18. package/dist/execution.d.ts.map +1 -1
  19. package/dist/fleet/host-subagent-factory.d.ts.map +1 -1
  20. package/dist/{hq-BECGICEZ.js → hq-JUYKUNJX.js} +2 -2
  21. package/dist/hq-server/auth.d.ts +15 -4
  22. package/dist/hq-server/auth.d.ts.map +1 -1
  23. package/dist/hq-server/routes/auth-handlers.d.ts +6 -13
  24. package/dist/hq-server/routes/auth-handlers.d.ts.map +1 -1
  25. package/dist/hq-server/routes.d.ts +6 -4
  26. package/dist/hq-server/routes.d.ts.map +1 -1
  27. package/dist/hq-server/snapshot.d.ts +44 -0
  28. package/dist/hq-server/snapshot.d.ts.map +1 -1
  29. package/dist/hq-server/types.d.ts +22 -4
  30. package/dist/hq-server/types.d.ts.map +1 -1
  31. package/dist/hq-server/utils.d.ts +8 -0
  32. package/dist/hq-server/utils.d.ts.map +1 -1
  33. package/dist/hq-server/ws.d.ts +1 -1
  34. package/dist/hq-server/ws.d.ts.map +1 -1
  35. package/dist/{hq-server-FJOVOERO.js → hq-server-6AQ3BXZA.js} +2 -2
  36. package/dist/hq-server.d.ts +3 -3
  37. package/dist/hq-server.d.ts.map +1 -1
  38. package/dist/{hq-tunnel-YZLRONPV.js → hq-tunnel-RFKGC4HI.js} +8 -5
  39. package/dist/{hq-tunnel-YZLRONPV.js.map → hq-tunnel-RFKGC4HI.js.map} +2 -2
  40. package/dist/hq-tunnel.d.ts +1 -1
  41. package/dist/hq-tunnel.d.ts.map +1 -1
  42. package/dist/index.js +5 -5
  43. package/dist/{sdd-runtime-IKPROVV2.js → sdd-runtime-JG4EEKL6.js} +2 -2
  44. package/dist/services/sdd/task-manager.d.ts.map +1 -1
  45. package/dist/slash-commands/settings.d.ts.map +1 -1
  46. package/dist/{webui-server-G3TISUIY.js → webui-server-ZF5KVGC5.js} +3 -2
  47. package/dist/webui-server-ZF5KVGC5.js.map +7 -0
  48. package/dist/webui-server-options.d.ts +2 -0
  49. package/dist/webui-server-options.d.ts.map +1 -1
  50. package/dist/webui-server.d.ts.map +1 -1
  51. package/dist/wiring/sage.d.ts.map +1 -1
  52. package/dist/wiring/session.d.ts +3 -1
  53. package/dist/wiring/session.d.ts.map +1 -1
  54. package/package.json +22 -22
  55. package/dist/chunk-X5DQTPXT.js.map +0 -7
  56. package/dist/cli-main-6AU6E4XZ.js.map +0 -7
  57. package/dist/execution-V26SE52F.js.map +0 -7
  58. package/dist/webui-server-G3TISUIY.js.map +0 -7
  59. /package/dist/{hq-BECGICEZ.js.map → hq-JUYKUNJX.js.map} +0 -0
  60. /package/dist/{hq-server-FJOVOERO.js.map → hq-server-6AQ3BXZA.js.map} +0 -0
  61. /package/dist/{sdd-runtime-IKPROVV2.js.map → sdd-runtime-JG4EEKL6.js.map} +0 -0
@@ -7,17 +7,26 @@ import { randomUUID as randomUUID4 } from "node:crypto";
7
7
  import * as fs4 from "node:fs/promises";
8
8
  import * as http from "node:http";
9
9
  import * as path6 from "node:path";
10
- import { WebSocket as WebSocket3, WebSocketServer } from "ws";
11
- import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
12
- import { createHqPersistence, toAlertMessage, watchHqAuthFile } from "@wrongstack/core/hq";
13
- import { createMailboxHttpRouter, MAILBOX_HTTP_DEFAULT_MAX_AGE_MS } from "@wrongstack/core/coordination";
14
- import { HqAlertEngine, HqCommandAuditLog } from "@wrongstack/core/hq";
15
10
  import {
11
+ createMailboxHttpRouter,
16
12
  createProjectMailbox,
17
13
  getSharedProjectMailbox,
14
+ MAILBOX_HTTP_DEFAULT_MAX_AGE_MS,
18
15
  MailboxEventEmitter,
19
16
  MailboxHttpRateLimiter
20
17
  } from "@wrongstack/core/coordination";
18
+ import {
19
+ createHqPersistence,
20
+ HqAlertEngine,
21
+ HqCommandAuditLog,
22
+ HqBootstrapCodeStore,
23
+ mintHqCookieSecret as mintHqCookieSecret2,
24
+ mutateHqAuthFile as mutateHqAuthFile2,
25
+ toAlertMessage,
26
+ watchHqAuthFile
27
+ } from "@wrongstack/core/hq";
28
+ import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
29
+ import { WebSocket as WebSocket3, WebSocketServer } from "ws";
21
30
 
22
31
  // src/hq-recovery-html.ts
23
32
  var HQ_RECOVERY_HTML = `<!doctype html>
@@ -172,7 +181,10 @@ function clearHqSessionCookie(res, secure) {
172
181
  res.setHeader("Set-Cookie", parts.join("; "));
173
182
  }
174
183
  function isTokenAuth(auth) {
175
- return auth !== void 0 && auth !== "cookie";
184
+ return auth !== void 0 && auth.kind === "token";
185
+ }
186
+ function isCookieAuth(auth) {
187
+ return auth !== void 0 && auth.kind === "cookie";
176
188
  }
177
189
  function extractBrowserToken(req, url) {
178
190
  const queryToken = url.searchParams.get("token");
@@ -217,7 +229,7 @@ function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
217
229
  return ctx;
218
230
  }
219
231
  }
220
- if (mutableAuth.passwordHash && mutableAuth.cookieSecret) {
232
+ if (mutableAuth.cookieSecret) {
221
233
  const cookies = parseCookieHeader(req.headers.cookie);
222
234
  const raw = cookies[HQ_SESSION_COOKIE];
223
235
  if (raw) {
@@ -225,7 +237,19 @@ function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
225
237
  if (sessionId) {
226
238
  const session = sessions.get(sessionId);
227
239
  if (session && Date.now() - session.createdAt < HQ_SESSION_MAX_AGE_MS) {
228
- return "cookie";
240
+ if (session.kind === "token" && session.tokenId !== void 0) {
241
+ const liveToken = [...mutableAuth.browserTokenObjs.values()].find(
242
+ (obj) => obj.id === session.tokenId
243
+ );
244
+ if (!liveToken) {
245
+ sessions.delete(sessionId);
246
+ return void 0;
247
+ }
248
+ const ctx = { kind: "cookie", tokenId: session.tokenId };
249
+ if (liveToken.capabilities !== void 0) ctx.capabilities = liveToken.capabilities;
250
+ return ctx;
251
+ }
252
+ return { kind: "cookie" };
229
253
  }
230
254
  if (session) sessions.delete(sessionId);
231
255
  }
@@ -234,14 +258,260 @@ function authenticateBrowserRequest(req, url, mutableAuth, sessions) {
234
258
  return void 0;
235
259
  }
236
260
 
261
+ // src/hq-server/auth-state.ts
262
+ import { DEFAULT_HQ_REDACTION_POLICY, hqAuthContentHash, isTokenExpired, logHqAuthAudit } from "@wrongstack/core/hq";
263
+ var TOKEN_EXPIRY_WARNING_MS = 24 * 60 * 60 * 1e3;
264
+ function liveTokens(list) {
265
+ return (list ?? []).filter((token) => !isTokenExpired(token));
266
+ }
267
+ function createHqAuthState(authFile, dataDir) {
268
+ let rawBrowserTokens = authFile.browserTokens ?? [];
269
+ let rawClientTokens = authFile.clientTokens ?? [];
270
+ const mutableAuth = {
271
+ operatorPolicy: { ...DEFAULT_HQ_REDACTION_POLICY, ...authFile.redactionPolicy ?? {} },
272
+ operatorPolicyOverride: authFile.redactionPolicy,
273
+ browserTokens: new Set(liveTokens(authFile.browserTokens).map(({ token }) => token)),
274
+ clientTokens: new Set(liveTokens(authFile.clientTokens).map(({ token }) => token)),
275
+ browserTokenObjs: browserTokenMap(authFile.browserTokens),
276
+ clientTokenObjs: new Map(liveTokens(authFile.clientTokens).map((token) => [token.token, token])),
277
+ passwordHash: authFile.passwordHash,
278
+ cookieSecret: authFile.cookieSecret,
279
+ alertRules: authFile.alertRules
280
+ };
281
+ return {
282
+ mutableAuth,
283
+ tokenStats: () => {
284
+ const now = Date.now();
285
+ const all = [...rawBrowserTokens, ...rawClientTokens];
286
+ const live = all.filter((token) => !isTokenExpired(token, now));
287
+ return {
288
+ browserTotal: mutableAuth.browserTokens.size,
289
+ clientTotal: mutableAuth.clientTokens.size,
290
+ expired: all.length - live.length,
291
+ expiringSoon: live.filter((token) => {
292
+ if (token.expiresAt === void 0) return false;
293
+ const expiry = Date.parse(token.expiresAt);
294
+ return Number.isFinite(expiry) && expiry - now <= TOKEN_EXPIRY_WARNING_MS;
295
+ }).length
296
+ };
297
+ },
298
+ apply: (next) => {
299
+ const newBrowser = next.browserTokens ?? [];
300
+ const newClient = next.clientTokens ?? [];
301
+ auditPrunedTokens("browser", rawBrowserTokens, newBrowser, next, dataDir);
302
+ auditPrunedTokens("client", rawClientTokens, newClient, next, dataDir);
303
+ rawBrowserTokens = newBrowser;
304
+ rawClientTokens = newClient;
305
+ mutableAuth.operatorPolicy = {
306
+ ...DEFAULT_HQ_REDACTION_POLICY,
307
+ ...next.redactionPolicy ?? {}
308
+ };
309
+ mutableAuth.operatorPolicyOverride = next.redactionPolicy;
310
+ mutableAuth.browserTokens = new Set(liveTokens(next.browserTokens).map(({ token }) => token));
311
+ mutableAuth.clientTokens = new Set(liveTokens(next.clientTokens).map(({ token }) => token));
312
+ mutableAuth.browserTokenObjs = browserTokenMap(next.browserTokens);
313
+ mutableAuth.clientTokenObjs = new Map(
314
+ liveTokens(next.clientTokens).map((token) => [token.token, token])
315
+ );
316
+ mutableAuth.passwordHash = next.passwordHash;
317
+ mutableAuth.cookieSecret = next.cookieSecret;
318
+ mutableAuth.alertRules = next.alertRules;
319
+ }
320
+ };
321
+ }
322
+ function browserTokenMap(tokens) {
323
+ return new Map(
324
+ liveTokens(tokens).map((token) => [
325
+ token.token,
326
+ {
327
+ id: token.id,
328
+ ...token.capabilities !== void 0 ? { capabilities: token.capabilities } : {},
329
+ ...token.expiresAt !== void 0 ? { expiresAt: token.expiresAt } : {}
330
+ }
331
+ ])
332
+ );
333
+ }
334
+ function auditPrunedTokens(scope, previous, next, authFile, dataDir) {
335
+ const previouslyLive = new Set(previous.filter((token) => !isTokenExpired(token)).map(({ id }) => id));
336
+ const prunedCount = next.filter((token) => isTokenExpired(token) && previouslyLive.has(token.id)).length;
337
+ if (prunedCount === 0) return;
338
+ const contentHash = hqAuthContentHash(authFile);
339
+ logHqAuthAudit(dataDir, {
340
+ kind: "expired-prune",
341
+ scope,
342
+ tokenId: "(aggregate)",
343
+ prunedCount,
344
+ ...contentHash !== void 0 ? { contentHash } : {}
345
+ });
346
+ }
347
+
348
+ // src/hq-server/preflight.ts
349
+ import { assessHqExposure, ensureHqFirstRunAuthFile, resolveHqDataDir } from "@wrongstack/core/hq";
350
+ import { HqInsecureExposureError } from "@wrongstack/core/hq";
351
+ async function prepareHqServerStart(options, defaults) {
352
+ const host = options.host ?? defaults.host;
353
+ const port = options.port ?? defaults.port;
354
+ const dataDir = resolveHqDataDir(options.dataDir);
355
+ const firstRunAuth = await ensureHqFirstRunAuthFile(dataDir, {
356
+ warn: (message) => console.warn(
357
+ JSON.stringify({
358
+ level: "warn",
359
+ event: "hq.auth_load_failed",
360
+ message,
361
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
362
+ })
363
+ ),
364
+ actor: resolveAuditActor(),
365
+ ...options.password !== void 0 ? { password: options.password } : {},
366
+ ...options.tokenTtlMs !== void 0 ? { tokenTtlMs: options.tokenTtlMs } : {}
367
+ });
368
+ const { authFile } = firstRunAuth;
369
+ if (options.requireBrowserAuth && (authFile.browserTokens ?? []).length === 0 && authFile.passwordHash === void 0) {
370
+ throw new HqInsecureExposureError(
371
+ "HQ public relay requires browser authentication. Set --password or create a browser token first."
372
+ );
373
+ }
374
+ const exposure = assessHqExposure({
375
+ host,
376
+ hasBrowserTokens: (authFile.browserTokens ?? []).length > 0,
377
+ hasPassword: authFile.passwordHash !== void 0,
378
+ allowInsecure: options.allowInsecureOpen
379
+ });
380
+ if (exposure.kind === "refuse") throw new HqInsecureExposureError(exposure.message);
381
+ if (exposure.kind === "warn") {
382
+ console.warn(
383
+ JSON.stringify({
384
+ level: "warn",
385
+ event: "hq.insecure_exposure",
386
+ message: exposure.message,
387
+ host,
388
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
389
+ })
390
+ );
391
+ }
392
+ return { host, port, dataDir, firstRunAuth };
393
+ }
394
+
395
+ // src/hq-server/routes.ts
396
+ import { randomUUID as randomUUID2 } from "node:crypto";
397
+ import * as path4 from "node:path";
398
+ import { tokenHasCapability, validateHqCommand } from "@wrongstack/core/hq";
399
+ import {
400
+ resolveProjectDir
401
+ } from "@wrongstack/core/coordination";
402
+
403
+ // src/hq-static-serve.ts
404
+ import * as fs from "node:fs";
405
+ import { createRequire } from "node:module";
406
+ import * as path from "node:path";
407
+ var requireFromHere = createRequire(import.meta.url);
408
+ var MIME = {
409
+ ".html": "text/html; charset=utf-8",
410
+ ".js": "application/javascript; charset=utf-8",
411
+ ".mjs": "application/javascript; charset=utf-8",
412
+ ".css": "text/css; charset=utf-8",
413
+ ".json": "application/json; charset=utf-8",
414
+ ".svg": "image/svg+xml",
415
+ ".png": "image/png",
416
+ ".jpg": "image/jpeg",
417
+ ".woff": "font/woff",
418
+ ".woff2": "font/woff2",
419
+ ".ico": "image/x-icon"
420
+ };
421
+ var cachedDistDir;
422
+ function resolveHqDistDir() {
423
+ if (cachedDistDir !== void 0) return cachedDistDir;
424
+ try {
425
+ const pkgJson = requireFromHere.resolve("@wrongstack/webui-hq/package.json");
426
+ const dist = path.join(path.dirname(pkgJson), "dist");
427
+ if (fs.existsSync(path.join(dist, "index.html"))) {
428
+ cachedDistDir = dist;
429
+ return dist;
430
+ }
431
+ } catch {
432
+ }
433
+ try {
434
+ let dir = path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
435
+ for (let i = 0; i < 8; i++) {
436
+ const candidate = path.join(dir, "node_modules", "@wrongstack", "webui-hq", "dist");
437
+ if (fs.existsSync(path.join(candidate, "index.html"))) {
438
+ cachedDistDir = candidate;
439
+ return candidate;
440
+ }
441
+ const parent = path.dirname(dir);
442
+ if (parent === dir) break;
443
+ dir = parent;
444
+ }
445
+ } catch {
446
+ }
447
+ cachedDistDir = null;
448
+ return null;
449
+ }
450
+ function isInsideDist(filePath, distDir) {
451
+ const rel = path.relative(distDir, filePath);
452
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
453
+ }
454
+ function isMissingPath(error) {
455
+ const code = error.code;
456
+ return code === "ENOENT" || code === "ENOTDIR";
457
+ }
458
+ async function readRegularFile(filePath) {
459
+ try {
460
+ const info = await fs.promises.stat(filePath);
461
+ if (!info.isFile()) return null;
462
+ return await fs.promises.readFile(filePath);
463
+ } catch (error) {
464
+ if (isMissingPath(error)) return null;
465
+ throw error;
466
+ }
467
+ }
468
+ async function serveHqStatic(_req, res, urlPath, distDir) {
469
+ const relPath = urlPath === "/" || urlPath === "" ? "/index.html" : urlPath;
470
+ let decodedPath;
471
+ try {
472
+ decodedPath = decodeURIComponent(relPath);
473
+ } catch {
474
+ return { handled: false };
475
+ }
476
+ const filePath = path.join(distDir, decodedPath);
477
+ if (!isInsideDist(filePath, distDir)) {
478
+ return { handled: false };
479
+ }
480
+ const data = await readRegularFile(filePath);
481
+ if (data === null) {
482
+ const indexPath = path.join(distDir, "index.html");
483
+ const html = await readRegularFile(indexPath);
484
+ if (html !== null) {
485
+ res.writeHead(200, {
486
+ "Content-Type": MIME[".html"],
487
+ "Cache-Control": "no-cache",
488
+ "Content-Length": html.byteLength
489
+ });
490
+ res.end(html);
491
+ return { handled: true };
492
+ }
493
+ return { handled: false };
494
+ }
495
+ const ext = path.extname(filePath).toLowerCase();
496
+ const mime = MIME[ext] ?? "application/octet-stream";
497
+ const cacheControl = decodedPath.startsWith("/assets/") ? "public, max-age=31536000, immutable" : ext === ".html" ? "no-cache" : "public, max-age=3600";
498
+ res.writeHead(200, {
499
+ "Content-Type": mime,
500
+ "Cache-Control": cacheControl,
501
+ "Content-Length": data.byteLength
502
+ });
503
+ res.end(data);
504
+ return { handled: true };
505
+ }
506
+
237
507
  // src/hq-server/snapshot.ts
238
508
  import { WebSocket } from "ws";
239
509
 
240
510
  // src/hq-server/utils.ts
241
- import * as fs from "node:fs/promises";
511
+ import * as fs2 from "node:fs/promises";
242
512
  import * as os from "node:os";
243
- import * as path from "node:path";
244
- import { resolveHqDataDir } from "@wrongstack/core/hq";
513
+ import * as path2 from "node:path";
514
+ import { resolveHqDataDir as resolveHqDataDir2 } from "@wrongstack/core/hq";
245
515
  var TRANSCRIPT_RING_MAX = 4e3;
246
516
  var MAX_TRANSCRIPT_SESSIONS = 400;
247
517
  var MAX_AGENT_RINGS = 800;
@@ -270,14 +540,14 @@ async function readLocalSubagentTranscript(sessionId, subagentId) {
270
540
  try {
271
541
  const { SessionRegistry } = await import("@wrongstack/core/storage");
272
542
  const { resolveWstackPaths, sessionScopedPath } = await import("@wrongstack/core/utils");
273
- const globalRoot = path.dirname(resolveHqDataDir());
543
+ const globalRoot = path2.dirname(resolveHqDataDir2());
274
544
  const registry = new SessionRegistry(globalRoot);
275
545
  const entry = await registry.get(sessionId).catch(() => null);
276
546
  if (!entry) return null;
277
547
  const paths = resolveWstackPaths({ projectRoot: entry.projectRoot, globalRoot });
278
548
  const sessionDir = sessionScopedPath(paths.projectSessions, sessionId, "");
279
- const file = path.join(sessionDir, "subagents", "transcripts", subagentId, "transcript.jsonl");
280
- const raw = await fs.readFile(file, "utf8").catch(() => null);
549
+ const file = path2.join(sessionDir, "subagents", "transcripts", subagentId, "transcript.jsonl");
550
+ const raw = await fs2.readFile(file, "utf8").catch(() => null);
281
551
  if (raw === null) return null;
282
552
  const out = [];
283
553
  for (const line of raw.split("\n")) {
@@ -339,13 +609,18 @@ function buildHttpUrl(host, port, token) {
339
609
  if (token) url.searchParams.set("token", token);
340
610
  return url.toString();
341
611
  }
612
+ function buildBootstrapHttpUrl(host, port, bootstrapCode) {
613
+ const url = new URL(`http://${displayHost(host)}:${port}/`);
614
+ url.hash = `bootstrap=${bootstrapCode}`;
615
+ return url.toString();
616
+ }
342
617
  function buildClientWsUrl(host, port, token) {
343
618
  const url = new URL(`ws://${displayHost(host)}:${port}/ws/client`);
344
619
  if (token) url.searchParams.set("token", token);
345
620
  return url.toString();
346
621
  }
347
622
  function hqRuntimeMarkerPath(dataDir) {
348
- return path.join(dataDir, "runtime.json");
623
+ return path2.join(dataDir, "runtime.json");
349
624
  }
350
625
  function lanIPv4Addresses() {
351
626
  const out = [];
@@ -410,22 +685,59 @@ function sanitizeApiError(err) {
410
685
  // src/hq-server/snapshot.ts
411
686
  var HQ_SNAPSHOT_BROADCAST_DEBOUNCE_MS = 100;
412
687
  var HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS = 3e3;
413
- function buildSnapshot(clients, options) {
414
- const now = (/* @__PURE__ */ new Date()).toISOString();
415
- const clientRecordById = /* @__PURE__ */ new Map();
416
- const projectMap = /* @__PURE__ */ new Map();
417
- const mailboxSummaries = [];
418
- const sessionById = /* @__PURE__ */ new Map();
419
- const fleetByRunId = /* @__PURE__ */ new Map();
420
- for (const client of clients.values()) {
421
- const machineId = client.machineId || client.project.machineId || "";
422
- if (!clientRecordById.has(client.clientId)) {
423
- clientRecordById.set(client.clientId, {
424
- clientId: client.clientId,
425
- kind: client.kind,
426
- machineId,
427
- ...client.hostname ? { hostname: client.hostname } : {},
428
- ...client.pid ? { pid: client.pid } : {},
688
+ var HQ_BROWSER_MAX_BUFFERED_BYTES = 32 * 1024 * 1024;
689
+ function sendGuarded(ws, data) {
690
+ if (ws.readyState !== WebSocket.OPEN) return false;
691
+ const buffered = Number.isFinite(ws.bufferedAmount) ? ws.bufferedAmount : 0;
692
+ if (buffered + Buffer.byteLength(data, "utf8") > HQ_BROWSER_MAX_BUFFERED_BYTES) {
693
+ try {
694
+ ws.terminate();
695
+ } catch {
696
+ try {
697
+ ws.close(1013, "client cannot keep up");
698
+ } catch {
699
+ }
700
+ }
701
+ return false;
702
+ }
703
+ try {
704
+ ws.send(data);
705
+ return true;
706
+ } catch {
707
+ return false;
708
+ }
709
+ }
710
+ var HQ_STALE_SNAPSHOT_MS = 5 * 6e4;
711
+ function reapStaleClientState(clients, nowMs = Date.now()) {
712
+ for (const client of clients.values()) {
713
+ for (const [sessionId, tracked] of client.sessions) {
714
+ if (nowMs - tracked.receivedAt > HQ_STALE_SNAPSHOT_MS) client.sessions.delete(sessionId);
715
+ }
716
+ for (const [runId, fleet] of client.fleets) {
717
+ if (nowMs - fleet.receivedAt > HQ_STALE_SNAPSHOT_MS) client.fleets.delete(runId);
718
+ }
719
+ for (const [sessionId, tracked] of client.mcpSnapshots) {
720
+ if (nowMs - tracked.receivedAt > HQ_STALE_SNAPSHOT_MS) client.mcpSnapshots.delete(sessionId);
721
+ }
722
+ }
723
+ }
724
+ function buildSnapshot(clients, options) {
725
+ reapStaleClientState(clients);
726
+ const now = (/* @__PURE__ */ new Date()).toISOString();
727
+ const clientRecordById = /* @__PURE__ */ new Map();
728
+ const projectMap = /* @__PURE__ */ new Map();
729
+ const mailboxSummaries = [];
730
+ const sessionById = /* @__PURE__ */ new Map();
731
+ const fleetByRunId = /* @__PURE__ */ new Map();
732
+ for (const client of clients.values()) {
733
+ const machineId = client.machineId || client.project.machineId || "";
734
+ if (!clientRecordById.has(client.clientId)) {
735
+ clientRecordById.set(client.clientId, {
736
+ clientId: client.clientId,
737
+ kind: client.kind,
738
+ machineId,
739
+ ...client.hostname ? { hostname: client.hostname } : {},
740
+ ...client.pid ? { pid: client.pid } : {},
429
741
  ...client.version ? { version: client.version } : {},
430
742
  connected: true,
431
743
  connectedAt: client.connectedAt,
@@ -630,8 +942,8 @@ function buildSnapshot(clients, options) {
630
942
  const mcpServers = [];
631
943
  const seenMcp = /* @__PURE__ */ new Set();
632
944
  for (const client of clients.values()) {
633
- for (const [sessionId, servers] of client.mcpSnapshots.entries()) {
634
- for (const server of servers) {
945
+ for (const [sessionId, tracked] of client.mcpSnapshots.entries()) {
946
+ for (const server of tracked.servers) {
635
947
  const key = `${client.clientId}:${sessionId}:${server.name}`;
636
948
  if (seenMcp.has(key)) continue;
637
949
  seenMcp.add(key);
@@ -657,619 +969,121 @@ function buildSnapshot(clients, options) {
657
969
  unreadMailboxMessages: unread,
658
970
  incompleteMailboxMessages: incomplete,
659
971
  totalCostUsd,
660
- activeMachines: machines.length,
661
- activeAgents: totalAgents,
662
- ...options?.tokenStats !== void 0 ? { tokenStats: options.tokenStats } : {}
663
- }
664
- };
665
- }
666
- function createSnapshotBroadcaster(clients, browsers, persistence) {
667
- let cached = "";
668
- let dirty = true;
669
- let timer = null;
670
- let closed = false;
671
- let lastPersistAt = 0;
672
- const persistCheckpoint = (snapshot) => {
673
- if (persistence === void 0) return;
674
- const now = Date.now();
675
- if (now - lastPersistAt < HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) return;
676
- lastPersistAt = now;
677
- persistence.snapshotStore.save(snapshot);
678
- };
679
- const serialize = () => {
680
- if (!dirty && cached.length > 0) return cached;
681
- const snapshot = buildSnapshot(clients);
682
- const msg = { type: "hq.snapshot", snapshot };
683
- cached = JSON.stringify(msg);
684
- dirty = false;
685
- persistCheckpoint(snapshot);
686
- return cached;
687
- };
688
- const flush = () => {
689
- timer = null;
690
- if (browsers.size === 0) {
691
- if (persistence !== void 0 && Date.now() - lastPersistAt >= HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) {
692
- persistCheckpoint(buildSnapshot(clients));
693
- }
694
- return;
695
- }
696
- const data = serialize();
697
- for (const ws of browsers) {
698
- if (ws.readyState === WebSocket.OPEN) ws.send(data);
699
- }
700
- };
701
- return {
702
- currentSerialized: serialize,
703
- broadcast: () => {
704
- if (closed) return;
705
- dirty = true;
706
- if (timer !== null) return;
707
- timer = setTimeout(flush, HQ_SNAPSHOT_BROADCAST_DEBOUNCE_MS);
708
- timer.unref?.();
709
- },
710
- close: () => {
711
- closed = true;
712
- if (timer !== null) {
713
- clearTimeout(timer);
714
- timer = null;
715
- }
716
- }
717
- };
718
- }
719
- function buildProjectDetail(clients, projectId) {
720
- const projectClients = [];
721
- for (const c of clients.values()) {
722
- if (c.projectId === projectId) projectClients.push(c);
723
- }
724
- if (projectClients.length === 0) return null;
725
- const now = (/* @__PURE__ */ new Date()).toISOString();
726
- const clientRecords = projectClients.map((c) => ({
727
- clientId: c.clientId,
728
- kind: c.kind,
729
- machineId: "",
730
- ...c.hostname ? { hostname: c.hostname } : {},
731
- ...c.pid ? { pid: c.pid } : {},
732
- ...c.version ? { version: c.version } : {},
733
- connected: true,
734
- connectedAt: c.connectedAt,
735
- lastSeenAt: c.lastSeenAt,
736
- projectId: c.projectId,
737
- capabilities: c.capabilities
738
- }));
739
- const mailboxPayloads = [];
740
- let latestActivity = now;
741
- for (const c of projectClients) {
742
- for (const snap of c.mailboxes.values()) {
743
- mailboxPayloads.push(snap);
744
- if (snap.totals.messages > 0) latestActivity = now;
745
- }
746
- }
747
- const primaryProject = projectClients[0].project;
748
- const machineIds = Array.from(new Set(projectClients.map((client) => client.project.machineId)));
749
- const project = {
750
- projectId,
751
- projectName: primaryProject.projectName || projectId,
752
- projectRootDisplay: primaryProject.projectRoot,
753
- machineIds,
754
- ...primaryProject.gitBranch ? { gitBranch: primaryProject.gitBranch } : {},
755
- activeClients: projectClients.length,
756
- activeSessions: 0,
757
- activeSubagents: 0,
758
- totalCostUsd: 0,
759
- lastActivityAt: latestActivity,
760
- status: "active"
761
- };
762
- return {
763
- generatedAt: now,
764
- project,
765
- clients: clientRecords,
766
- mailboxes: mailboxPayloads
767
- };
768
- }
769
- function broadcastEvent(event, browsers) {
770
- const msg = { type: "hq.event", event };
771
- const data = JSON.stringify(msg);
772
- for (const ws of browsers) {
773
- if (ws.readyState === WebSocket.OPEN) ws.send(data);
774
- }
775
- }
776
- function broadcastCommandStatus(command, browsers) {
777
- const data = JSON.stringify({ type: "hq.command_status", command });
778
- for (const ws of browsers) {
779
- if (ws.readyState === WebSocket.OPEN) ws.send(data);
780
- }
781
- }
782
-
783
- // src/hq-server/ws.ts
784
- import { randomUUID } from "node:crypto";
785
- import { WebSocket as WebSocket2 } from "ws";
786
- import { HQ_PROTOCOL_VERSION, HQ_TRANSCRIPT_TEXT_CAP, parseHqEventPayload, parseHqFrame, redactHqEvent, resolveHqRedactionPolicy, tightenHqRedactionPolicy, tokenHasCapability } from "@wrongstack/core/hq";
787
- var MAX_EVENT_LOG = 5e3;
788
- function handleBrowser(ws, snapshotBroadcaster, browsers) {
789
- browsers.add(ws);
790
- ws.on("error", (err) => {
791
- console.warn(
792
- JSON.stringify({
793
- level: "warn",
794
- event: "hq.browser_socket_error",
795
- message: err instanceof Error ? err.message : String(err),
796
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
797
- })
798
- );
799
- });
800
- ws.send(snapshotBroadcaster.currentSerialized());
801
- ws.on("close", () => {
802
- browsers.delete(ws);
803
- });
804
- }
805
- function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster, transcripts, agentMessages, persistence, auditLog) {
806
- let registered = false;
807
- function persistEvent(event) {
808
- eventLog.push(event);
809
- if (eventLog.length > MAX_EVENT_LOG) eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
810
- if (persistence !== void 0) {
811
- persistence.eventLog.append(event);
812
- recordTimeseriesSignal(persistence, event);
813
- }
814
- }
815
- ws.on("error", (err) => {
816
- console.warn(
817
- JSON.stringify({
818
- level: "warn",
819
- event: "hq.client_socket_error",
820
- message: err instanceof Error ? err.message : String(err),
821
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
822
- })
823
- );
824
- });
825
- ws.on("message", (data) => {
826
- const raw = typeof data === "string" ? data : Buffer.isBuffer(data) ? data : new TextDecoder().decode(data);
827
- const parsed = parseHqFrame(raw);
828
- if (!parsed.ok) {
829
- const code = parsed.reason === "invalid-json" ? 1003 : 1008;
830
- ws.close(code, `invalid frame: ${parsed.reason}`);
831
- return;
832
- }
833
- const frame = parsed.frame;
834
- if (frame.type === "client.hello") {
835
- if (registered) {
836
- ws.close(1008, "duplicate client.hello");
837
- return;
838
- }
839
- const payload = frame.payload;
840
- if (payload.protocolVersion !== HQ_PROTOCOL_VERSION) {
841
- ws.close(1008, "protocol version mismatch");
842
- return;
843
- }
844
- const canPublishTelemetry = auth.token === void 0 || tokenHasCapability(auth.token, "telemetry.publish");
845
- const acceptedCapabilities = payload.capabilities.filter(
846
- (capability) => capability === "control.receive" || canPublishTelemetry
847
- );
848
- const declaredRedactionPolicy = resolveHqRedactionPolicy(payload.redactionPolicy);
849
- for (const [otherWs, otherClient] of clients) {
850
- const samePublisher = otherClient.projectId === payload.project.projectId && otherClient.kind === payload.client.kind && otherClient.pid !== void 0 && payload.client.pid !== void 0 && otherClient.pid === payload.client.pid && (otherClient.machineId || otherClient.project.machineId) === (payload.client.machineId || payload.project.machineId);
851
- if (otherWs !== ws && (otherClient.clientId === payload.client.clientId || samePublisher && otherClient.sessions.size === 0)) {
852
- clients.delete(otherWs);
853
- otherWs.close(4001, "superseded by a newer HQ connection");
854
- }
855
- }
856
- const client = {
857
- ws,
858
- clientId: payload.client.clientId,
859
- projectId: payload.project.projectId,
860
- project: payload.project,
861
- kind: payload.client.kind,
862
- connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
863
- lastSeenAt: (/* @__PURE__ */ new Date()).toISOString(),
864
- ...payload.client.hostname ? { hostname: payload.client.hostname } : {},
865
- ...payload.client.pid ? { pid: payload.client.pid } : {},
866
- ...payload.client.version ? { version: payload.client.version } : {},
867
- capabilities: acceptedCapabilities,
868
- ...auth.token !== void 0 ? { authToken: auth.token } : {},
869
- declaredRedactionPolicy,
870
- lastEventSeq: 0,
871
- mailboxes: /* @__PURE__ */ new Map(),
872
- machineId: payload.client.machineId || payload.project.machineId,
873
- sessions: /* @__PURE__ */ new Map(),
874
- fleets: /* @__PURE__ */ new Map(),
875
- mcpSnapshots: /* @__PURE__ */ new Map(),
876
- commandQueue: []
877
- };
878
- clients.set(ws, client);
879
- registered = true;
880
- const welcome = {
881
- type: "hq.welcome",
882
- protocolVersion: HQ_PROTOCOL_VERSION,
883
- serverTime: (/* @__PURE__ */ new Date()).toISOString(),
884
- acceptedCapabilities,
885
- // The operator-configured override (from <dataDir>/auth.json) wins
886
- // over the default. The client learns the *effective* policy.
887
- redactionPolicy: tightenHqRedactionPolicy(
888
- declaredRedactionPolicy,
889
- auth.getOperatorPolicy()
890
- )
891
- };
892
- ws.send(JSON.stringify(welcome));
893
- if (persistence !== void 0) {
894
- void persistence.kanban.load(client.projectId).then((payload2) => {
895
- if (ws.readyState === WebSocket2.OPEN) {
896
- ws.send(JSON.stringify({ type: "hq.kanban_snapshot", payload: payload2 }));
897
- }
898
- }).catch(() => void 0);
899
- }
900
- const event = {
901
- id: randomUUID(),
902
- type: "client.hello",
903
- schemaVersion: HQ_PROTOCOL_VERSION,
904
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
905
- clientId: payload.client.clientId,
906
- projectId: payload.project.projectId,
907
- seq: 0,
908
- payload: { client: payload.client, project: payload.project }
909
- };
910
- persistEvent(event);
911
- snapshotBroadcaster.broadcast();
912
- broadcastEvent(event, browsers);
913
- return;
914
- }
915
- if (!registered) return;
916
- if (frame.type === "client.command_poll") {
917
- const client = clients.get(ws);
918
- if (client) {
919
- if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
920
- ws.close(1008, "command poll identity mismatch");
921
- return;
922
- }
923
- client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
924
- const afterId = frame.afterCommandId;
925
- const limit = frame.limit ?? 25;
926
- let toSend;
927
- if (afterId === void 0) {
928
- toSend = client.commandQueue.slice(0, limit);
929
- } else {
930
- const idx = client.commandQueue.findIndex((c) => c.commandId === afterId);
931
- toSend = (idx >= 0 ? client.commandQueue.slice(idx + 1) : client.commandQueue).slice(
932
- 0,
933
- limit
934
- );
935
- }
936
- if (toSend.length > 0) {
937
- const batch = JSON.stringify({ type: "hq.command_batch", commands: toSend });
938
- if (ws.readyState === WebSocket2.OPEN) ws.send(batch);
939
- for (const cmd of toSend) {
940
- auditLog?.update(cmd.commandId, { status: "delivered" });
941
- const updated = auditLog?.get(cmd.commandId);
942
- if (updated !== void 0) broadcastCommandStatus(updated, browsers);
943
- }
944
- }
945
- }
946
- return;
947
- }
948
- if (frame.type === "client.command_ack") {
949
- const client = clients.get(ws);
950
- if (client) {
951
- if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
952
- ws.close(1008, "command ack identity mismatch");
953
- return;
954
- }
955
- client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
956
- }
957
- if (client) {
958
- const updated = auditLog?.updateForClient(frame.commandId, client.clientId, {
959
- status: "acked",
960
- ackStatus: frame.status,
961
- ...frame.message !== void 0 ? { ackMessage: frame.message } : {},
962
- ackedAt: (/* @__PURE__ */ new Date()).toISOString()
963
- });
964
- if (updated === true) {
965
- const entry = auditLog?.get(frame.commandId);
966
- if (entry !== void 0) broadcastCommandStatus(entry, browsers);
967
- }
968
- }
969
- return;
970
- }
971
- if (frame.type === "client.event") {
972
- const client = clients.get(ws);
973
- if (!client) return;
974
- const incomingEvent = frame.event;
975
- if (incomingEvent.clientId !== client.clientId || incomingEvent.projectId !== client.projectId) {
976
- ws.close(1008, "event identity mismatch");
977
- return;
978
- }
979
- if (!tokenHasCapability(client.authToken, "telemetry.publish") && auth.token !== void 0) {
980
- ws.close(1008, "client token lacks telemetry.publish capability");
981
- return;
982
- }
983
- if (incomingEvent.seq <= client.lastEventSeq) {
984
- ws.close(1008, "event sequence must increase monotonically");
985
- return;
986
- }
987
- const parsedPayload = parseHqEventPayload(incomingEvent.type, incomingEvent.payload);
988
- if (!parsedPayload.ok) {
989
- return;
990
- }
991
- client.lastEventSeq = incomingEvent.seq;
992
- client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
993
- const event = redactHqEvent(
994
- { ...incomingEvent, payload: parsedPayload.payload },
995
- {
996
- policy: tightenHqRedactionPolicy(
997
- client.declaredRedactionPolicy,
998
- auth.getOperatorPolicy()
999
- ),
1000
- projectRoot: client.project.projectRoot,
1001
- // Chat-transcript events carry full turns — the generic 500-char
1002
- // summary cap would truncate them a second time after the
1003
- // publisher already applied the transcript cap.
1004
- ...incomingEvent.type === "session.transcript" || incomingEvent.type === "agent.message" ? { maxSummaryLength: HQ_TRANSCRIPT_TEXT_CAP } : {}
1005
- }
1006
- ).value;
1007
- if (event.type === "client.heartbeat") {
1008
- return;
1009
- }
1010
- if (event.type === "kanban.snapshot") {
1011
- const payload = event.payload;
1012
- if (payload.projectId !== client.projectId || persistence === void 0) return;
1013
- void persistence.kanban.merge(payload).then((merged) => {
1014
- const touched = /* @__PURE__ */ new Set();
1015
- for (const record of payload.boards) touched.add(record.boardId);
1016
- for (const record of payload.tombstones) touched.add(record.boardId);
1017
- const delta = {
1018
- projectId: merged.projectId,
1019
- generatedAt: merged.generatedAt,
1020
- boards: merged.boards.filter((record) => touched.has(record.boardId)),
1021
- tombstones: merged.tombstones.filter((record) => touched.has(record.boardId))
1022
- };
1023
- const message = JSON.stringify({ type: "hq.kanban_snapshot", payload: delta });
1024
- for (const peer of clients.values()) {
1025
- if (peer.projectId === client.projectId && peer.ws.readyState === WebSocket2.OPEN) {
1026
- peer.ws.send(message);
1027
- }
1028
- }
1029
- }).catch((error) => {
1030
- console.warn(
1031
- JSON.stringify({
1032
- level: "warn",
1033
- event: "hq.kanban_merge_failed",
1034
- message: error instanceof Error ? error.message : String(error),
1035
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
1036
- })
1037
- );
1038
- });
1039
- persistEvent(event);
1040
- broadcastEvent(event, browsers);
1041
- return;
1042
- }
1043
- if (event.type === "mailbox.snapshot" && client !== void 0) {
1044
- const payloadResult = parseHqEventPayload(event.type, event.payload);
1045
- if (payloadResult.ok) {
1046
- const payload = payloadResult.payload;
1047
- client.mailboxes.set(client.projectId + ":" + payload.mailboxId, payload);
1048
- persistEvent(event);
1049
- snapshotBroadcaster.broadcast();
1050
- broadcastEvent(event, browsers);
1051
- return;
1052
- }
1053
- return;
1054
- }
1055
- if (event.type === "mailbox.event") {
1056
- const payloadResult = parseHqEventPayload(event.type, event.payload);
1057
- if (!payloadResult.ok) {
1058
- return;
1059
- }
1060
- const payload = payloadResult.payload;
1061
- const sanitizedSummary = truncateHqSummary(payload.summary, 280);
1062
- const sanitizedEvent = sanitizedSummary === void 0 ? event : { ...event, payload: { ...payload, summary: sanitizedSummary } };
1063
- persistEvent(sanitizedEvent);
1064
- broadcastEvent(sanitizedEvent, browsers);
1065
- return;
1066
- }
1067
- if (event.type === "session.snapshot" && client !== void 0) {
1068
- const result = parseHqEventPayload(event.type, event.payload);
1069
- if (result.ok) {
1070
- const payload = result.payload;
1071
- client.sessions.set(payload.sessionId, { payload, receivedAt: Date.now() });
1072
- snapshotBroadcaster.broadcast();
1073
- }
1074
- return;
1075
- }
1076
- if (event.type === "session.ended" && client !== void 0) {
1077
- const result = parseHqEventPayload(event.type, event.payload);
1078
- if (result.ok) {
1079
- const payload = result.payload;
1080
- client.sessions.delete(payload.sessionId);
1081
- client.mcpSnapshots.delete(payload.sessionId);
1082
- for (const [runId, fleet] of client.fleets) {
1083
- if (fleet.sessionId === payload.sessionId) client.fleets.delete(runId);
1084
- }
1085
- snapshotBroadcaster.broadcast();
1086
- }
1087
- return;
1088
- }
1089
- if (event.type === "fleet.snapshot" && client !== void 0) {
1090
- const result = parseHqEventPayload(event.type, event.payload);
1091
- if (result.ok) {
1092
- const payload = result.payload;
1093
- client.fleets.set(payload.runId, {
1094
- payload,
1095
- ...event.sessionId !== void 0 ? { sessionId: event.sessionId } : {},
1096
- receivedAt: Date.now()
1097
- });
1098
- snapshotBroadcaster.broadcast();
1099
- }
1100
- return;
1101
- }
1102
- if (event.type === "mcp.health.snapshot" && client !== void 0) {
1103
- const result = parseHqEventPayload(event.type, event.payload);
1104
- if (result.ok) {
1105
- const payload = result.payload;
1106
- const sessionId = event.sessionId || "unknown";
1107
- const stamped = payload.servers.map((s) => ({
1108
- ...s,
1109
- projectId: client.projectId,
1110
- clientId: client.clientId
1111
- }));
1112
- client.mcpSnapshots.set(sessionId, stamped);
1113
- snapshotBroadcaster.broadcast();
1114
- }
1115
- return;
1116
- }
1117
- if (event.type === "session.transcript" && client !== void 0) {
1118
- const result = parseHqEventPayload(event.type, event.payload);
1119
- if (result.ok) {
1120
- const payload = result.payload;
1121
- let ring = transcripts.get(payload.sessionId);
1122
- if (!ring) {
1123
- ring = { entries: [], ...client.machineId ? { machineId: client.machineId } : {} };
1124
- }
1125
- for (const entry of payload.entries) ring.entries.push(entry);
1126
- if (ring.entries.length > TRANSCRIPT_RING_MAX) {
1127
- ring.entries.splice(0, ring.entries.length - TRANSCRIPT_RING_MAX);
1128
- }
1129
- transcripts.delete(payload.sessionId);
1130
- transcripts.set(payload.sessionId, ring);
1131
- evictOldest(transcripts, MAX_TRANSCRIPT_SESSIONS);
1132
- broadcastEvent(event, browsers);
1133
- }
1134
- return;
1135
- }
1136
- if (event.type === "agent.message") {
1137
- const p = event.payload;
1138
- const subId = p && typeof p["subagentId"] === "string" ? p["subagentId"] : void 0;
1139
- if (subId) {
1140
- const key = agentRingKey(event.sessionId, subId);
1141
- let ring = agentMessages.get(key);
1142
- if (!ring) ring = [];
1143
- ring.push(agentMessageToEntry(p));
1144
- if (ring.length > TRANSCRIPT_RING_MAX) ring.splice(0, ring.length - TRANSCRIPT_RING_MAX);
1145
- agentMessages.delete(key);
1146
- agentMessages.set(key, ring);
1147
- evictOldest(agentMessages, MAX_AGENT_RINGS);
1148
- }
1149
- persistEvent(event);
1150
- broadcastEvent(event, browsers);
1151
- return;
1152
- }
1153
- persistEvent(event);
1154
- broadcastEvent(event, browsers);
972
+ activeMachines: machines.length,
973
+ activeAgents: totalAgents,
974
+ ...options?.tokenStats !== void 0 ? { tokenStats: options.tokenStats } : {}
1155
975
  }
1156
- });
1157
- ws.on("close", () => {
1158
- clients.delete(ws);
1159
- snapshotBroadcaster.broadcast();
1160
- });
976
+ };
1161
977
  }
1162
-
1163
- // src/hq-server/routes.ts
1164
- import { randomUUID as randomUUID3 } from "node:crypto";
1165
- import * as path4 from "node:path";
1166
- import { tokenHasCapability as tokenHasCapability2, validateHqCommand } from "@wrongstack/core/hq";
1167
- import {
1168
- resolveProjectDir
1169
- } from "@wrongstack/core/coordination";
1170
-
1171
- // src/hq-static-serve.ts
1172
- import * as fs2 from "node:fs";
1173
- import { createRequire } from "node:module";
1174
- import * as path2 from "node:path";
1175
- var requireFromHere = createRequire(import.meta.url);
1176
- var MIME = {
1177
- ".html": "text/html; charset=utf-8",
1178
- ".js": "application/javascript; charset=utf-8",
1179
- ".mjs": "application/javascript; charset=utf-8",
1180
- ".css": "text/css; charset=utf-8",
1181
- ".json": "application/json; charset=utf-8",
1182
- ".svg": "image/svg+xml",
1183
- ".png": "image/png",
1184
- ".jpg": "image/jpeg",
1185
- ".woff": "font/woff",
1186
- ".woff2": "font/woff2",
1187
- ".ico": "image/x-icon"
1188
- };
1189
- var cachedDistDir;
1190
- function resolveHqDistDir() {
1191
- if (cachedDistDir !== void 0) return cachedDistDir;
1192
- try {
1193
- const pkgJson = requireFromHere.resolve("@wrongstack/webui-hq/package.json");
1194
- const dist = path2.join(path2.dirname(pkgJson), "dist");
1195
- if (fs2.existsSync(path2.join(dist, "index.html"))) {
1196
- cachedDistDir = dist;
1197
- return dist;
978
+ function createSnapshotBroadcaster(clients, browsers, persistence) {
979
+ let cached = "";
980
+ let dirty = true;
981
+ let timer = null;
982
+ let closed = false;
983
+ let lastPersistAt = 0;
984
+ const persistCheckpoint = (snapshot) => {
985
+ if (persistence === void 0) return;
986
+ const now = Date.now();
987
+ if (now - lastPersistAt < HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) return;
988
+ lastPersistAt = now;
989
+ persistence.snapshotStore.save(snapshot);
990
+ };
991
+ const serialize = () => {
992
+ if (!dirty && cached.length > 0) return cached;
993
+ const snapshot = buildSnapshot(clients);
994
+ const msg = { type: "hq.snapshot", snapshot };
995
+ cached = JSON.stringify(msg);
996
+ dirty = false;
997
+ persistCheckpoint(snapshot);
998
+ return cached;
999
+ };
1000
+ const flush = () => {
1001
+ timer = null;
1002
+ if (browsers.size === 0) {
1003
+ if (persistence !== void 0 && Date.now() - lastPersistAt >= HQ_SNAPSHOT_PERSIST_MIN_INTERVAL_MS) {
1004
+ persistCheckpoint(buildSnapshot(clients));
1005
+ }
1006
+ return;
1198
1007
  }
1199
- } catch {
1200
- }
1201
- try {
1202
- let dir = path2.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, "$1"));
1203
- for (let i = 0; i < 8; i++) {
1204
- const candidate = path2.join(dir, "node_modules", "@wrongstack", "webui-hq", "dist");
1205
- if (fs2.existsSync(path2.join(candidate, "index.html"))) {
1206
- cachedDistDir = candidate;
1207
- return candidate;
1008
+ const data = serialize();
1009
+ for (const ws of browsers) sendGuarded(ws, data);
1010
+ };
1011
+ return {
1012
+ currentSerialized: serialize,
1013
+ broadcast: () => {
1014
+ if (closed) return;
1015
+ dirty = true;
1016
+ if (timer !== null) return;
1017
+ timer = setTimeout(flush, HQ_SNAPSHOT_BROADCAST_DEBOUNCE_MS);
1018
+ timer.unref?.();
1019
+ },
1020
+ close: () => {
1021
+ closed = true;
1022
+ if (timer !== null) {
1023
+ clearTimeout(timer);
1024
+ timer = null;
1208
1025
  }
1209
- const parent = path2.dirname(dir);
1210
- if (parent === dir) break;
1211
- dir = parent;
1212
1026
  }
1213
- } catch {
1214
- }
1215
- cachedDistDir = null;
1216
- return null;
1217
- }
1218
- function isInsideDist(filePath, distDir) {
1219
- const rel = path2.relative(distDir, filePath);
1220
- return rel !== "" && !rel.startsWith("..") && !path2.isAbsolute(rel);
1221
- }
1222
- function isMissingPath(error) {
1223
- const code = error.code;
1224
- return code === "ENOENT" || code === "ENOTDIR";
1225
- }
1226
- async function readRegularFile(filePath) {
1227
- try {
1228
- const info = await fs2.promises.stat(filePath);
1229
- if (!info.isFile()) return null;
1230
- return await fs2.promises.readFile(filePath);
1231
- } catch (error) {
1232
- if (isMissingPath(error)) return null;
1233
- throw error;
1234
- }
1027
+ };
1235
1028
  }
1236
- async function serveHqStatic(_req, res, urlPath, distDir) {
1237
- const relPath = urlPath === "/" || urlPath === "" ? "/index.html" : urlPath;
1238
- let decodedPath;
1239
- try {
1240
- decodedPath = decodeURIComponent(relPath);
1241
- } catch {
1242
- return { handled: false };
1243
- }
1244
- const filePath = path2.join(distDir, decodedPath);
1245
- if (!isInsideDist(filePath, distDir)) {
1246
- return { handled: false };
1029
+ function buildProjectDetail(clients, projectId) {
1030
+ const projectClients = [];
1031
+ for (const c of clients.values()) {
1032
+ if (c.projectId === projectId) projectClients.push(c);
1247
1033
  }
1248
- const data = await readRegularFile(filePath);
1249
- if (data === null) {
1250
- const indexPath = path2.join(distDir, "index.html");
1251
- const html = await readRegularFile(indexPath);
1252
- if (html !== null) {
1253
- res.writeHead(200, {
1254
- "Content-Type": MIME[".html"],
1255
- "Cache-Control": "no-cache",
1256
- "Content-Length": html.byteLength
1257
- });
1258
- res.end(html);
1259
- return { handled: true };
1034
+ if (projectClients.length === 0) return null;
1035
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1036
+ const clientRecords = projectClients.map((c) => ({
1037
+ clientId: c.clientId,
1038
+ kind: c.kind,
1039
+ machineId: "",
1040
+ ...c.hostname ? { hostname: c.hostname } : {},
1041
+ ...c.pid ? { pid: c.pid } : {},
1042
+ ...c.version ? { version: c.version } : {},
1043
+ connected: true,
1044
+ connectedAt: c.connectedAt,
1045
+ lastSeenAt: c.lastSeenAt,
1046
+ projectId: c.projectId,
1047
+ capabilities: c.capabilities
1048
+ }));
1049
+ const mailboxPayloads = [];
1050
+ let latestActivity = now;
1051
+ for (const c of projectClients) {
1052
+ for (const snap of c.mailboxes.values()) {
1053
+ mailboxPayloads.push(snap);
1054
+ if (snap.totals.messages > 0) latestActivity = now;
1260
1055
  }
1261
- return { handled: false };
1262
1056
  }
1263
- const ext = path2.extname(filePath).toLowerCase();
1264
- const mime = MIME[ext] ?? "application/octet-stream";
1265
- const cacheControl = decodedPath.startsWith("/assets/") ? "public, max-age=31536000, immutable" : ext === ".html" ? "no-cache" : "public, max-age=3600";
1266
- res.writeHead(200, {
1267
- "Content-Type": mime,
1268
- "Cache-Control": cacheControl,
1269
- "Content-Length": data.byteLength
1270
- });
1271
- res.end(data);
1272
- return { handled: true };
1057
+ const primaryProject = projectClients[0].project;
1058
+ const machineIds = Array.from(new Set(projectClients.map((client) => client.project.machineId)));
1059
+ const project = {
1060
+ projectId,
1061
+ projectName: primaryProject.projectName || projectId,
1062
+ projectRootDisplay: primaryProject.projectRoot,
1063
+ machineIds,
1064
+ ...primaryProject.gitBranch ? { gitBranch: primaryProject.gitBranch } : {},
1065
+ activeClients: projectClients.length,
1066
+ activeSessions: 0,
1067
+ activeSubagents: 0,
1068
+ totalCostUsd: 0,
1069
+ lastActivityAt: latestActivity,
1070
+ status: "active"
1071
+ };
1072
+ return {
1073
+ generatedAt: now,
1074
+ project,
1075
+ clients: clientRecords,
1076
+ mailboxes: mailboxPayloads
1077
+ };
1078
+ }
1079
+ function broadcastEvent(event, browsers) {
1080
+ const msg = { type: "hq.event", event };
1081
+ const data = JSON.stringify(msg);
1082
+ for (const ws of browsers) sendGuarded(ws, data);
1083
+ }
1084
+ function broadcastCommandStatus(command, browsers) {
1085
+ const data = JSON.stringify({ type: "hq.command_status", command });
1086
+ for (const ws of browsers) sendGuarded(ws, data);
1273
1087
  }
1274
1088
 
1275
1089
  // src/hq-server/trust-boundary.ts
@@ -1319,9 +1133,9 @@ async function authorizeHqCommand(input) {
1319
1133
  }
1320
1134
 
1321
1135
  // src/hq-server/routes/auth-handlers.ts
1322
- import { randomUUID as randomUUID2 } from "node:crypto";
1136
+ import { randomUUID } from "node:crypto";
1323
1137
  import {
1324
- DEFAULT_HQ_REDACTION_POLICY,
1138
+ DEFAULT_HQ_REDACTION_POLICY as DEFAULT_HQ_REDACTION_POLICY2,
1325
1139
  hashHqPassword,
1326
1140
  isLoopbackHost,
1327
1141
  mintHqCookieSecret,
@@ -1346,7 +1160,7 @@ async function handleApiAuthStatus(req, res, url, mutableAuth, sessions, require
1346
1160
  ...publicOrigin !== void 0 ? { publicOrigin } : {},
1347
1161
  secureCookies: secureCookies === true,
1348
1162
  loggedIn: auth !== void 0 || localOpenMode,
1349
- authKind: auth === "cookie" ? "password" : isTokenAuth(auth) ? "token" : localOpenMode ? "open" : void 0
1163
+ authKind: isCookieAuth(auth) ? "password" : isTokenAuth(auth) ? "token" : localOpenMode ? "open" : void 0
1350
1164
  })
1351
1165
  );
1352
1166
  }
@@ -1412,8 +1226,8 @@ async function handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, se
1412
1226
  return;
1413
1227
  }
1414
1228
  loginAttempts.delete(clientIp);
1415
- const sessionId = randomUUID2();
1416
- sessions.set(sessionId, { createdAt: Date.now() });
1229
+ const sessionId = randomUUID();
1230
+ sessions.set(sessionId, { createdAt: Date.now(), kind: "password" });
1417
1231
  setHqSessionCookie(
1418
1232
  res,
1419
1233
  serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
@@ -1429,7 +1243,7 @@ async function handleApiLogout(req, res, mutableAuth, sessions, secureCookies) {
1429
1243
  mutableAuth,
1430
1244
  sessions
1431
1245
  );
1432
- if (auth === "cookie") {
1246
+ if (isCookieAuth(auth)) {
1433
1247
  const cookies = parseCookieHeader(req.headers.cookie);
1434
1248
  const raw = cookies[HQ_SESSION_COOKIE];
1435
1249
  if (raw) {
@@ -1471,7 +1285,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1471
1285
  );
1472
1286
  return;
1473
1287
  }
1474
- if (auth === "cookie" && mutableAuth.passwordHash !== void 0) {
1288
+ if (isCookieAuth(auth) && mutableAuth.passwordHash !== void 0) {
1475
1289
  const currentPassword = typeof body.currentPassword === "string" ? body.currentPassword : "";
1476
1290
  if (!currentPassword || !await verifyHqPassword(currentPassword, mutableAuth.passwordHash)) {
1477
1291
  res.writeHead(403, { "Content-Type": "application/json" });
@@ -1510,7 +1324,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1510
1324
  JSON.stringify({
1511
1325
  passwordMode: false,
1512
1326
  tokenMode: mutableAuth.browserTokens.size > 0,
1513
- loggedIn: auth !== "cookie" && isTokenAuth(auth)
1327
+ loggedIn: !isCookieAuth(auth) && isTokenAuth(auth)
1514
1328
  })
1515
1329
  );
1516
1330
  return;
@@ -1534,9 +1348,9 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1534
1348
  }));
1535
1349
  applyAuthFile(mutableAuth, next);
1536
1350
  sessions.clear();
1537
- if (auth === "cookie" || localOpenBootstrap) {
1538
- const sessionId = randomUUID2();
1539
- sessions.set(sessionId, { createdAt: Date.now() });
1351
+ if (isCookieAuth(auth) || localOpenBootstrap) {
1352
+ const sessionId = randomUUID();
1353
+ sessions.set(sessionId, { createdAt: Date.now(), kind: "password" });
1540
1354
  setHqSessionCookie(
1541
1355
  res,
1542
1356
  serializeHqSessionCookie(sessionId, cookieSecret),
@@ -1554,7 +1368,7 @@ async function handleApiPassword(req, res, mutableAuth, sessions, dataDir, secur
1554
1368
  }
1555
1369
  function applyAuthFile(mutableAuth, next) {
1556
1370
  mutableAuth.operatorPolicy = {
1557
- ...DEFAULT_HQ_REDACTION_POLICY,
1371
+ ...DEFAULT_HQ_REDACTION_POLICY2,
1558
1372
  ...next.redactionPolicy ?? {}
1559
1373
  };
1560
1374
  mutableAuth.operatorPolicyOverride = next.redactionPolicy;
@@ -1573,6 +1387,76 @@ function applyAuthFile(mutableAuth, next) {
1573
1387
  mutableAuth.cookieSecret = next.cookieSecret;
1574
1388
  mutableAuth.alertRules = next.alertRules;
1575
1389
  }
1390
+ async function handleApiBootstrap(req, res, mutableAuth, sessions, secureCookies, bootstrapStore) {
1391
+ if (!mutableAuth.cookieSecret) {
1392
+ res.writeHead(503, { "Content-Type": "application/json" });
1393
+ res.end(
1394
+ JSON.stringify({
1395
+ error: {
1396
+ code: "BOOTSTRAP_UNAVAILABLE",
1397
+ message: "Cookie signing is not configured on this HQ server."
1398
+ }
1399
+ })
1400
+ );
1401
+ return;
1402
+ }
1403
+ let body;
1404
+ try {
1405
+ body = JSON.parse(await readRequestBody(req));
1406
+ } catch (error) {
1407
+ writeInvalidBody(res, error);
1408
+ return;
1409
+ }
1410
+ if (typeof body.code !== "string" || body.code.length === 0) {
1411
+ res.writeHead(400, { "Content-Type": "application/json" });
1412
+ res.end(
1413
+ JSON.stringify({ error: { code: "BAD_REQUEST", message: "code is required" } })
1414
+ );
1415
+ return;
1416
+ }
1417
+ const entry = bootstrapStore.consume(body.code);
1418
+ if (!entry) {
1419
+ res.writeHead(401, { "Content-Type": "application/json" });
1420
+ res.end(
1421
+ JSON.stringify({
1422
+ error: {
1423
+ code: "INVALID_OR_EXPIRED_CODE",
1424
+ message: "The bootstrap code is invalid, already used, or expired."
1425
+ }
1426
+ })
1427
+ );
1428
+ return;
1429
+ }
1430
+ const tokenLive = [...mutableAuth.browserTokenObjs.values()].some(
1431
+ (obj) => obj.id === entry.tokenId
1432
+ );
1433
+ if (!tokenLive) {
1434
+ res.writeHead(401, { "Content-Type": "application/json" });
1435
+ res.end(
1436
+ JSON.stringify({
1437
+ error: {
1438
+ code: "TOKEN_REVOKED",
1439
+ message: "The token that issued this bootstrap code has been revoked."
1440
+ }
1441
+ })
1442
+ );
1443
+ return;
1444
+ }
1445
+ const sessionId = randomUUID();
1446
+ sessions.set(sessionId, {
1447
+ createdAt: Date.now(),
1448
+ kind: "token",
1449
+ tokenId: entry.tokenId,
1450
+ ...entry.capabilities !== void 0 ? { capabilities: entry.capabilities } : {}
1451
+ });
1452
+ setHqSessionCookie(
1453
+ res,
1454
+ serializeHqSessionCookie(sessionId, mutableAuth.cookieSecret),
1455
+ secureCookies
1456
+ );
1457
+ res.writeHead(200, { "Content-Type": "application/json" });
1458
+ res.end(JSON.stringify({ loggedIn: true }));
1459
+ }
1576
1460
 
1577
1461
  // src/hq-server/routes/system-handlers.ts
1578
1462
  async function handleApiSystemUpdate(res) {
@@ -1665,11 +1549,11 @@ async function handleApiAlerts(req, res, alertEngine) {
1665
1549
  import * as path3 from "node:path";
1666
1550
  import {
1667
1551
  buildTranscriptFromEvents,
1668
- resolveHqDataDir as resolveHqDataDir2
1552
+ resolveHqDataDir as resolveHqDataDir3
1669
1553
  } from "@wrongstack/core/hq";
1670
1554
  async function handleApiSessions(res) {
1671
1555
  const { SessionRegistry } = await import("@wrongstack/core/storage");
1672
- const globalRoot = path3.dirname(resolveHqDataDir2());
1556
+ const globalRoot = path3.dirname(resolveHqDataDir3());
1673
1557
  try {
1674
1558
  const registry = new SessionRegistry(globalRoot);
1675
1559
  const sessions = await registry.list();
@@ -1714,7 +1598,7 @@ async function handleApiSessionEvents(req, res, match, transcripts) {
1714
1598
  res.end(JSON.stringify({ error: "invalid sessionId encoding" }));
1715
1599
  return;
1716
1600
  }
1717
- const globalRoot = path3.dirname(resolveHqDataDir2());
1601
+ const globalRoot = path3.dirname(resolveHqDataDir3());
1718
1602
  try {
1719
1603
  const registry = new SessionRegistry(globalRoot);
1720
1604
  const entry = await registry.get(sessionId).catch(() => null);
@@ -1849,6 +1733,7 @@ var setHqSecurityHeaders2 = setHqSecurityHeaders;
1849
1733
  var hasTrustedBrowserOrigin2 = hasTrustedBrowserOrigin;
1850
1734
  var authenticateBrowserRequest2 = authenticateBrowserRequest;
1851
1735
  var isTokenAuth2 = isTokenAuth;
1736
+ var isCookieAuth2 = isCookieAuth;
1852
1737
  var parseHqSessionCookie2 = parseHqSessionCookie;
1853
1738
  var parseCookieHeader2 = parseCookieHeader;
1854
1739
  var HQ_SESSION_COOKIE2 = HQ_SESSION_COOKIE;
@@ -1857,6 +1742,7 @@ var readRequestBody2 = readRequestBody;
1857
1742
  var writeInvalidBody2 = writeInvalidBody;
1858
1743
  var sanitizeApiError2 = sanitizeApiError;
1859
1744
  var buildHttpUrl2 = buildHttpUrl;
1745
+ var buildBootstrapHttpUrl2 = buildBootstrapHttpUrl;
1860
1746
  var buildClientWsUrl2 = buildClientWsUrl;
1861
1747
  var agentRingKey2 = agentRingKey;
1862
1748
  var agentMessageToEntry2 = agentMessageToEntry;
@@ -1884,7 +1770,8 @@ function createHqRouter(deps) {
1884
1770
  authorizeMailboxGateway,
1885
1771
  getMailboxGateway,
1886
1772
  getTokenStats,
1887
- trustBoundary
1773
+ trustBoundary,
1774
+ bootstrapStore
1888
1775
  } = deps;
1889
1776
  return async (req, res) => {
1890
1777
  try {
@@ -1895,7 +1782,7 @@ function createHqRouter(deps) {
1895
1782
  res.end(JSON.stringify({ error: "forbidden: untrusted request origin" }));
1896
1783
  return;
1897
1784
  }
1898
- if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/status" && url.pathname !== "/api/login" && (requireBrowserAuth || mutableAuth.browserTokens.size > 0 || mutableAuth.passwordHash)) {
1785
+ if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/status" && url.pathname !== "/api/login" && url.pathname !== "/api/auth/bootstrap" && (requireBrowserAuth || mutableAuth.browserTokens.size > 0 || mutableAuth.passwordHash)) {
1899
1786
  const auth = authenticateBrowserRequest2(req, url, mutableAuth, sessions);
1900
1787
  if (!auth) {
1901
1788
  const tokenOnly = mutableAuth.browserTokens.size > 0 && !mutableAuth.passwordHash;
@@ -1930,6 +1817,22 @@ function createHqRouter(deps) {
1930
1817
  await handleApiLogin(req, res, mutableAuth, sessions, loginAttempts, secureCookies);
1931
1818
  return;
1932
1819
  }
1820
+ if (url.pathname === "/api/auth/bootstrap" && req.method === "POST") {
1821
+ if (bootstrapStore) {
1822
+ await handleApiBootstrap(req, res, mutableAuth, sessions, secureCookies, bootstrapStore);
1823
+ } else {
1824
+ res.writeHead(503, { "Content-Type": "application/json" });
1825
+ res.end(
1826
+ JSON.stringify({
1827
+ error: {
1828
+ code: "BOOTSTRAP_UNAVAILABLE",
1829
+ message: "Bootstrap exchange is not enabled on this HQ server."
1830
+ }
1831
+ })
1832
+ );
1833
+ }
1834
+ return;
1835
+ }
1933
1836
  if (url.pathname === "/api/logout" && req.method === "POST") {
1934
1837
  await handleApiLogout(req, res, mutableAuth, sessions, secureCookies);
1935
1838
  return;
@@ -2148,8 +2051,7 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
2148
2051
  res.end(JSON.stringify({ error: "unauthorized" }));
2149
2052
  return;
2150
2053
  }
2151
- const tokenObj = isTokenAuth2(auth) ? mutableAuth.browserTokenObjs.get(auth.token) : void 0;
2152
- const canEnqueue = auth === "cookie" || !inBrowserTokenMode || tokenObj?.capabilities === void 0 || tokenObj.capabilities.includes("control.enqueue");
2054
+ const canEnqueue = isCookieAuth2(auth) ? auth.tokenId === void 0 || auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : isTokenAuth2(auth) ? auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : !inBrowserTokenMode;
2153
2055
  if (!canEnqueue) {
2154
2056
  res.writeHead(403, { "Content-Type": "application/json" });
2155
2057
  res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
@@ -2189,7 +2091,7 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
2189
2091
  );
2190
2092
  return;
2191
2093
  }
2192
- const commandId = randomUUID3();
2094
+ const commandId = randomUUID2();
2193
2095
  const queued = {
2194
2096
  commandId,
2195
2097
  type: body.type,
@@ -2205,7 +2107,7 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
2205
2107
  );
2206
2108
  return;
2207
2109
  }
2208
- if (validated.type === "run-command" && !tokenHasCapability2(target.authToken, "control.execute")) {
2110
+ if (validated.type === "run-command" && !tokenHasCapability(target.authToken, "control.execute")) {
2209
2111
  res.writeHead(403, { "Content-Type": "application/json" });
2210
2112
  res.end(
2211
2113
  JSON.stringify({
@@ -2214,13 +2116,13 @@ async function handleApiCommand(req, res, url, mutableAuth, sessions, clients, b
2214
2116
  );
2215
2117
  return;
2216
2118
  }
2217
- const enqueuedBy = auth === "cookie" ? "password-session" : tokenObj?.id ?? "open-mode";
2119
+ const enqueuedBy = isCookieAuth2(auth) ? auth.tokenId ?? "password-session" : isTokenAuth2(auth) ? auth.id : "open-mode";
2218
2120
  const authorization = await authorizeHqCommand({
2219
2121
  boundary: trustBoundary,
2220
2122
  command: validated,
2221
2123
  commandId,
2222
2124
  enqueuedBy,
2223
- authMethod: auth === "cookie" ? "session" : "bearer-token",
2125
+ authMethod: isCookieAuth2(auth) ? "session" : "bearer-token",
2224
2126
  target
2225
2127
  });
2226
2128
  if (!authorization.allowed) {
@@ -2251,8 +2153,7 @@ async function handleApiMailboxSend(_req, res, mutableAuth, sessions, dataDir, g
2251
2153
  mutableAuth,
2252
2154
  sessions
2253
2155
  );
2254
- const token = isTokenAuth2(auth) ? mutableAuth.browserTokenObjs.get(auth.token) : void 0;
2255
- const canEnqueue = auth === "cookie" || mutableAuth.browserTokens.size === 0 || token?.capabilities === void 0 || token.capabilities.includes("control.enqueue");
2156
+ const canEnqueue = isCookieAuth2(auth) ? auth.tokenId === void 0 || auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : isTokenAuth2(auth) ? auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : mutableAuth.browserTokens.size === 0;
2256
2157
  if (!canEnqueue) {
2257
2158
  res.writeHead(403, { "Content-Type": "application/json" });
2258
2159
  res.end(JSON.stringify({ error: "forbidden: token lacks control.enqueue capability" }));
@@ -2487,138 +2388,389 @@ function writeHqLanEndpoints(write, handle, browserToken) {
2487
2388
  `);
2488
2389
  }
2489
2390
 
2490
- // src/hq-server/auth-state.ts
2491
- import { DEFAULT_HQ_REDACTION_POLICY as DEFAULT_HQ_REDACTION_POLICY2, hqAuthContentHash, isTokenExpired, logHqAuthAudit } from "@wrongstack/core/hq";
2492
- var TOKEN_EXPIRY_WARNING_MS = 24 * 60 * 60 * 1e3;
2493
- function liveTokens(list) {
2494
- return (list ?? []).filter((token) => !isTokenExpired(token));
2391
+ // src/hq-server/ws.ts
2392
+ import { randomUUID as randomUUID3 } from "node:crypto";
2393
+ import {
2394
+ HQ_PROTOCOL_VERSION,
2395
+ HQ_TRANSCRIPT_TEXT_CAP,
2396
+ parseHqEventPayload,
2397
+ parseHqFrame,
2398
+ redactHqEvent,
2399
+ resolveHqRedactionPolicy,
2400
+ tightenHqRedactionPolicy,
2401
+ tokenHasCapability as tokenHasCapability2
2402
+ } from "@wrongstack/core/hq";
2403
+ import { WebSocket as WebSocket2 } from "ws";
2404
+ var MAX_EVENT_LOG = 5e3;
2405
+ function handleBrowser(ws, snapshotBroadcaster, browsers) {
2406
+ browsers.add(ws);
2407
+ ws.on("error", (err) => {
2408
+ console.warn(
2409
+ JSON.stringify({
2410
+ level: "warn",
2411
+ event: "hq.browser_socket_error",
2412
+ message: err instanceof Error ? err.message : String(err),
2413
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2414
+ })
2415
+ );
2416
+ });
2417
+ sendGuarded(ws, snapshotBroadcaster.currentSerialized());
2418
+ ws.on("close", () => {
2419
+ browsers.delete(ws);
2420
+ });
2495
2421
  }
2496
- function createHqAuthState(authFile, dataDir) {
2497
- let rawBrowserTokens = authFile.browserTokens ?? [];
2498
- let rawClientTokens = authFile.clientTokens ?? [];
2499
- const mutableAuth = {
2500
- operatorPolicy: { ...DEFAULT_HQ_REDACTION_POLICY2, ...authFile.redactionPolicy ?? {} },
2501
- operatorPolicyOverride: authFile.redactionPolicy,
2502
- browserTokens: new Set(liveTokens(authFile.browserTokens).map(({ token }) => token)),
2503
- clientTokens: new Set(liveTokens(authFile.clientTokens).map(({ token }) => token)),
2504
- browserTokenObjs: browserTokenMap(authFile.browserTokens),
2505
- clientTokenObjs: new Map(liveTokens(authFile.clientTokens).map((token) => [token.token, token])),
2506
- passwordHash: authFile.passwordHash,
2507
- cookieSecret: authFile.cookieSecret,
2508
- alertRules: authFile.alertRules
2509
- };
2510
- return {
2511
- mutableAuth,
2512
- tokenStats: () => {
2513
- const now = Date.now();
2514
- const all = [...rawBrowserTokens, ...rawClientTokens];
2515
- const live = all.filter((token) => !isTokenExpired(token, now));
2516
- return {
2517
- browserTotal: mutableAuth.browserTokens.size,
2518
- clientTotal: mutableAuth.clientTokens.size,
2519
- expired: all.length - live.length,
2520
- expiringSoon: live.filter((token) => {
2521
- if (token.expiresAt === void 0) return false;
2522
- const expiry = Date.parse(token.expiresAt);
2523
- return Number.isFinite(expiry) && expiry - now <= TOKEN_EXPIRY_WARNING_MS;
2524
- }).length
2422
+ function handleClient(ws, clients, browsers, eventLog, auth, snapshotBroadcaster, transcripts, agentMessages, persistence, auditLog) {
2423
+ let registered = false;
2424
+ function persistEvent(event) {
2425
+ eventLog.push(event);
2426
+ if (eventLog.length > MAX_EVENT_LOG) eventLog.splice(0, eventLog.length - MAX_EVENT_LOG);
2427
+ if (persistence !== void 0) {
2428
+ persistence.eventLog.append(event);
2429
+ recordTimeseriesSignal(persistence, event);
2430
+ }
2431
+ }
2432
+ ws.on("error", (err) => {
2433
+ console.warn(
2434
+ JSON.stringify({
2435
+ level: "warn",
2436
+ event: "hq.client_socket_error",
2437
+ message: err instanceof Error ? err.message : String(err),
2438
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2439
+ })
2440
+ );
2441
+ });
2442
+ ws.on("message", (data) => {
2443
+ const raw = typeof data === "string" ? data : Buffer.isBuffer(data) ? data : new TextDecoder().decode(data);
2444
+ const parsed = parseHqFrame(raw);
2445
+ if (!parsed.ok) {
2446
+ const code = parsed.reason === "invalid-json" ? 1003 : 1008;
2447
+ ws.close(code, `invalid frame: ${parsed.reason}`);
2448
+ return;
2449
+ }
2450
+ const frame = parsed.frame;
2451
+ if (frame.type === "client.hello") {
2452
+ if (registered) {
2453
+ ws.close(1008, "duplicate client.hello");
2454
+ return;
2455
+ }
2456
+ const payload = frame.payload;
2457
+ if (payload.protocolVersion !== HQ_PROTOCOL_VERSION) {
2458
+ ws.close(1008, "protocol version mismatch");
2459
+ return;
2460
+ }
2461
+ const canPublishTelemetry = auth.token === void 0 || tokenHasCapability2(auth.token, "telemetry.publish");
2462
+ const acceptedCapabilities = payload.capabilities.filter(
2463
+ (capability) => capability === "control.receive" || canPublishTelemetry
2464
+ );
2465
+ const declaredRedactionPolicy = resolveHqRedactionPolicy(payload.redactionPolicy);
2466
+ for (const [otherWs, otherClient] of clients) {
2467
+ const samePublisher = otherClient.projectId === payload.project.projectId && otherClient.kind === payload.client.kind && otherClient.pid !== void 0 && payload.client.pid !== void 0 && otherClient.pid === payload.client.pid && (otherClient.machineId || otherClient.project.machineId) === (payload.client.machineId || payload.project.machineId);
2468
+ if (otherWs !== ws && (otherClient.clientId === payload.client.clientId || samePublisher && otherClient.sessions.size === 0)) {
2469
+ clients.delete(otherWs);
2470
+ otherWs.close(4001, "superseded by a newer HQ connection");
2471
+ }
2472
+ }
2473
+ const client = {
2474
+ ws,
2475
+ clientId: payload.client.clientId,
2476
+ projectId: payload.project.projectId,
2477
+ project: payload.project,
2478
+ kind: payload.client.kind,
2479
+ connectedAt: (/* @__PURE__ */ new Date()).toISOString(),
2480
+ lastSeenAt: (/* @__PURE__ */ new Date()).toISOString(),
2481
+ ...payload.client.hostname ? { hostname: payload.client.hostname } : {},
2482
+ ...payload.client.pid ? { pid: payload.client.pid } : {},
2483
+ ...payload.client.version ? { version: payload.client.version } : {},
2484
+ capabilities: acceptedCapabilities,
2485
+ ...auth.token !== void 0 ? { authToken: auth.token } : {},
2486
+ declaredRedactionPolicy,
2487
+ lastEventSeq: 0,
2488
+ mailboxes: /* @__PURE__ */ new Map(),
2489
+ machineId: payload.client.machineId || payload.project.machineId,
2490
+ sessions: /* @__PURE__ */ new Map(),
2491
+ fleets: /* @__PURE__ */ new Map(),
2492
+ mcpSnapshots: /* @__PURE__ */ new Map(),
2493
+ commandQueue: []
2525
2494
  };
2526
- },
2527
- apply: (next) => {
2528
- const newBrowser = next.browserTokens ?? [];
2529
- const newClient = next.clientTokens ?? [];
2530
- auditPrunedTokens("browser", rawBrowserTokens, newBrowser, next, dataDir);
2531
- auditPrunedTokens("client", rawClientTokens, newClient, next, dataDir);
2532
- rawBrowserTokens = newBrowser;
2533
- rawClientTokens = newClient;
2534
- mutableAuth.operatorPolicy = {
2535
- ...DEFAULT_HQ_REDACTION_POLICY2,
2536
- ...next.redactionPolicy ?? {}
2495
+ clients.set(ws, client);
2496
+ registered = true;
2497
+ const welcome = {
2498
+ type: "hq.welcome",
2499
+ protocolVersion: HQ_PROTOCOL_VERSION,
2500
+ serverTime: (/* @__PURE__ */ new Date()).toISOString(),
2501
+ acceptedCapabilities,
2502
+ // The operator-configured override (from <dataDir>/auth.json) wins
2503
+ // over the default. The client learns the *effective* policy.
2504
+ redactionPolicy: tightenHqRedactionPolicy(
2505
+ declaredRedactionPolicy,
2506
+ auth.getOperatorPolicy()
2507
+ )
2537
2508
  };
2538
- mutableAuth.operatorPolicyOverride = next.redactionPolicy;
2539
- mutableAuth.browserTokens = new Set(liveTokens(next.browserTokens).map(({ token }) => token));
2540
- mutableAuth.clientTokens = new Set(liveTokens(next.clientTokens).map(({ token }) => token));
2541
- mutableAuth.browserTokenObjs = browserTokenMap(next.browserTokens);
2542
- mutableAuth.clientTokenObjs = new Map(
2543
- liveTokens(next.clientTokens).map((token) => [token.token, token])
2544
- );
2545
- mutableAuth.passwordHash = next.passwordHash;
2546
- mutableAuth.cookieSecret = next.cookieSecret;
2547
- mutableAuth.alertRules = next.alertRules;
2509
+ ws.send(JSON.stringify(welcome));
2510
+ if (persistence !== void 0) {
2511
+ void persistence.kanban.load(client.projectId).then((payload2) => {
2512
+ sendGuarded(ws, JSON.stringify({ type: "hq.kanban_snapshot", payload: payload2 }));
2513
+ }).catch(() => void 0);
2514
+ }
2515
+ const event = {
2516
+ id: randomUUID3(),
2517
+ type: "client.hello",
2518
+ schemaVersion: HQ_PROTOCOL_VERSION,
2519
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2520
+ clientId: payload.client.clientId,
2521
+ projectId: payload.project.projectId,
2522
+ seq: 0,
2523
+ payload: { client: payload.client, project: payload.project }
2524
+ };
2525
+ persistEvent(event);
2526
+ snapshotBroadcaster.broadcast();
2527
+ broadcastEvent(event, browsers);
2528
+ return;
2548
2529
  }
2549
- };
2550
- }
2551
- function browserTokenMap(tokens) {
2552
- return new Map(
2553
- liveTokens(tokens).map((token) => [
2554
- token.token,
2555
- {
2556
- id: token.id,
2557
- ...token.capabilities !== void 0 ? { capabilities: token.capabilities } : {},
2558
- ...token.expiresAt !== void 0 ? { expiresAt: token.expiresAt } : {}
2530
+ if (!registered) return;
2531
+ if (frame.type === "client.command_poll") {
2532
+ const client = clients.get(ws);
2533
+ if (client) {
2534
+ if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
2535
+ ws.close(1008, "command poll identity mismatch");
2536
+ return;
2537
+ }
2538
+ client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
2539
+ const afterId = frame.afterCommandId;
2540
+ const limit = frame.limit ?? 25;
2541
+ let toSend;
2542
+ if (afterId === void 0) {
2543
+ toSend = client.commandQueue.slice(0, limit);
2544
+ } else {
2545
+ const idx = client.commandQueue.findIndex((c) => c.commandId === afterId);
2546
+ toSend = (idx >= 0 ? client.commandQueue.slice(idx + 1) : client.commandQueue).slice(
2547
+ 0,
2548
+ limit
2549
+ );
2550
+ }
2551
+ if (toSend.length > 0) {
2552
+ const batch = JSON.stringify({ type: "hq.command_batch", commands: toSend });
2553
+ if (ws.readyState === WebSocket2.OPEN) ws.send(batch);
2554
+ for (const cmd of toSend) {
2555
+ auditLog?.update(cmd.commandId, { status: "delivered" });
2556
+ const updated = auditLog?.get(cmd.commandId);
2557
+ if (updated !== void 0) broadcastCommandStatus(updated, browsers);
2558
+ }
2559
+ }
2560
+ }
2561
+ return;
2562
+ }
2563
+ if (frame.type === "client.command_ack") {
2564
+ const client = clients.get(ws);
2565
+ if (client) {
2566
+ if (frame.clientId !== client.clientId || frame.projectId !== client.projectId) {
2567
+ ws.close(1008, "command ack identity mismatch");
2568
+ return;
2569
+ }
2570
+ client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
2571
+ }
2572
+ if (client) {
2573
+ const updated = auditLog?.updateForClient(frame.commandId, client.clientId, {
2574
+ status: "acked",
2575
+ ackStatus: frame.status,
2576
+ ...frame.message !== void 0 ? { ackMessage: frame.message } : {},
2577
+ ackedAt: (/* @__PURE__ */ new Date()).toISOString()
2578
+ });
2579
+ if (updated === true) {
2580
+ const entry = auditLog?.get(frame.commandId);
2581
+ if (entry !== void 0) broadcastCommandStatus(entry, browsers);
2582
+ }
2583
+ }
2584
+ return;
2585
+ }
2586
+ if (frame.type === "client.event") {
2587
+ const client = clients.get(ws);
2588
+ if (!client) return;
2589
+ const incomingEvent = frame.event;
2590
+ if (incomingEvent.clientId !== client.clientId || incomingEvent.projectId !== client.projectId) {
2591
+ ws.close(1008, "event identity mismatch");
2592
+ return;
2593
+ }
2594
+ if (!tokenHasCapability2(client.authToken, "telemetry.publish") && auth.token !== void 0) {
2595
+ ws.close(1008, "client token lacks telemetry.publish capability");
2596
+ return;
2597
+ }
2598
+ if (incomingEvent.seq <= client.lastEventSeq) {
2599
+ ws.close(1008, "event sequence must increase monotonically");
2600
+ return;
2559
2601
  }
2560
- ])
2561
- );
2562
- }
2563
- function auditPrunedTokens(scope, previous, next, authFile, dataDir) {
2564
- const previouslyLive = new Set(previous.filter((token) => !isTokenExpired(token)).map(({ id }) => id));
2565
- const prunedCount = next.filter((token) => isTokenExpired(token) && previouslyLive.has(token.id)).length;
2566
- if (prunedCount === 0) return;
2567
- const contentHash = hqAuthContentHash(authFile);
2568
- logHqAuthAudit(dataDir, {
2569
- kind: "expired-prune",
2570
- scope,
2571
- tokenId: "(aggregate)",
2572
- prunedCount,
2573
- ...contentHash !== void 0 ? { contentHash } : {}
2574
- });
2575
- }
2576
-
2577
- // src/hq-server/preflight.ts
2578
- import { assessHqExposure, ensureHqFirstRunAuthFile, resolveHqDataDir as resolveHqDataDir3 } from "@wrongstack/core/hq";
2579
- import { HqInsecureExposureError } from "@wrongstack/core/hq";
2580
- async function prepareHqServerStart(options, defaults) {
2581
- const host = options.host ?? defaults.host;
2582
- const port = options.port ?? defaults.port;
2583
- const dataDir = resolveHqDataDir3(options.dataDir);
2584
- const firstRunAuth = await ensureHqFirstRunAuthFile(dataDir, {
2585
- warn: (message) => console.warn(
2586
- JSON.stringify({
2587
- level: "warn",
2588
- event: "hq.auth_load_failed",
2589
- message,
2590
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2591
- })
2592
- ),
2593
- actor: resolveAuditActor(),
2594
- ...options.password !== void 0 ? { password: options.password } : {},
2595
- ...options.tokenTtlMs !== void 0 ? { tokenTtlMs: options.tokenTtlMs } : {}
2602
+ const parsedPayload = parseHqEventPayload(incomingEvent.type, incomingEvent.payload);
2603
+ if (!parsedPayload.ok) {
2604
+ return;
2605
+ }
2606
+ client.lastEventSeq = incomingEvent.seq;
2607
+ client.lastSeenAt = (/* @__PURE__ */ new Date()).toISOString();
2608
+ const event = redactHqEvent(
2609
+ { ...incomingEvent, payload: parsedPayload.payload },
2610
+ {
2611
+ policy: tightenHqRedactionPolicy(
2612
+ client.declaredRedactionPolicy,
2613
+ auth.getOperatorPolicy()
2614
+ ),
2615
+ projectRoot: client.project.projectRoot,
2616
+ // Chat-transcript events carry full turns — the generic 500-char
2617
+ // summary cap would truncate them a second time after the
2618
+ // publisher already applied the transcript cap.
2619
+ ...incomingEvent.type === "session.transcript" || incomingEvent.type === "agent.message" ? { maxSummaryLength: HQ_TRANSCRIPT_TEXT_CAP } : {}
2620
+ }
2621
+ ).value;
2622
+ if (event.type === "client.heartbeat") {
2623
+ return;
2624
+ }
2625
+ if (event.type === "kanban.snapshot") {
2626
+ const payload = event.payload;
2627
+ if (payload.projectId !== client.projectId || persistence === void 0) return;
2628
+ void persistence.kanban.merge(payload).then((merged) => {
2629
+ const touched = /* @__PURE__ */ new Set();
2630
+ for (const record of payload.boards) touched.add(record.boardId);
2631
+ for (const record of payload.tombstones) touched.add(record.boardId);
2632
+ const delta = {
2633
+ projectId: merged.projectId,
2634
+ generatedAt: merged.generatedAt,
2635
+ boards: merged.boards.filter((record) => touched.has(record.boardId)),
2636
+ tombstones: merged.tombstones.filter((record) => touched.has(record.boardId))
2637
+ };
2638
+ const message = JSON.stringify({ type: "hq.kanban_snapshot", payload: delta });
2639
+ for (const peer of clients.values()) {
2640
+ if (peer.projectId === client.projectId) sendGuarded(peer.ws, message);
2641
+ }
2642
+ }).catch((error) => {
2643
+ console.warn(
2644
+ JSON.stringify({
2645
+ level: "warn",
2646
+ event: "hq.kanban_merge_failed",
2647
+ message: error instanceof Error ? error.message : String(error),
2648
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2649
+ })
2650
+ );
2651
+ });
2652
+ persistEvent(event);
2653
+ broadcastEvent(event, browsers);
2654
+ return;
2655
+ }
2656
+ if (event.type === "mailbox.snapshot" && client !== void 0) {
2657
+ const payloadResult = parseHqEventPayload(event.type, event.payload);
2658
+ if (payloadResult.ok) {
2659
+ const payload = payloadResult.payload;
2660
+ client.mailboxes.set(client.projectId + ":" + payload.mailboxId, payload);
2661
+ persistEvent(event);
2662
+ snapshotBroadcaster.broadcast();
2663
+ broadcastEvent(event, browsers);
2664
+ return;
2665
+ }
2666
+ return;
2667
+ }
2668
+ if (event.type === "mailbox.event") {
2669
+ const payloadResult = parseHqEventPayload(event.type, event.payload);
2670
+ if (!payloadResult.ok) {
2671
+ return;
2672
+ }
2673
+ const payload = payloadResult.payload;
2674
+ const sanitizedSummary = truncateHqSummary(payload.summary, 280);
2675
+ const sanitizedEvent = sanitizedSummary === void 0 ? event : { ...event, payload: { ...payload, summary: sanitizedSummary } };
2676
+ persistEvent(sanitizedEvent);
2677
+ broadcastEvent(sanitizedEvent, browsers);
2678
+ return;
2679
+ }
2680
+ if (event.type === "session.snapshot" && client !== void 0) {
2681
+ const result = parseHqEventPayload(event.type, event.payload);
2682
+ if (result.ok) {
2683
+ const payload = result.payload;
2684
+ client.sessions.set(payload.sessionId, { payload, receivedAt: Date.now() });
2685
+ snapshotBroadcaster.broadcast();
2686
+ }
2687
+ return;
2688
+ }
2689
+ if (event.type === "session.ended" && client !== void 0) {
2690
+ const result = parseHqEventPayload(event.type, event.payload);
2691
+ if (result.ok) {
2692
+ const payload = result.payload;
2693
+ client.sessions.delete(payload.sessionId);
2694
+ client.mcpSnapshots.delete(payload.sessionId);
2695
+ for (const [runId, fleet] of client.fleets) {
2696
+ if (fleet.sessionId === payload.sessionId) client.fleets.delete(runId);
2697
+ }
2698
+ snapshotBroadcaster.broadcast();
2699
+ }
2700
+ return;
2701
+ }
2702
+ if (event.type === "fleet.snapshot" && client !== void 0) {
2703
+ const result = parseHqEventPayload(event.type, event.payload);
2704
+ if (result.ok) {
2705
+ const payload = result.payload;
2706
+ client.fleets.set(payload.runId, {
2707
+ payload,
2708
+ ...event.sessionId !== void 0 ? { sessionId: event.sessionId } : {},
2709
+ receivedAt: Date.now()
2710
+ });
2711
+ snapshotBroadcaster.broadcast();
2712
+ }
2713
+ return;
2714
+ }
2715
+ if (event.type === "mcp.health.snapshot" && client !== void 0) {
2716
+ const result = parseHqEventPayload(event.type, event.payload);
2717
+ if (result.ok) {
2718
+ const payload = result.payload;
2719
+ const sessionId = event.sessionId || "unknown";
2720
+ const stamped = payload.servers.map((s) => ({
2721
+ ...s,
2722
+ projectId: client.projectId,
2723
+ clientId: client.clientId
2724
+ }));
2725
+ client.mcpSnapshots.set(sessionId, { servers: stamped, receivedAt: Date.now() });
2726
+ snapshotBroadcaster.broadcast();
2727
+ }
2728
+ return;
2729
+ }
2730
+ if (event.type === "session.transcript" && client !== void 0) {
2731
+ const result = parseHqEventPayload(event.type, event.payload);
2732
+ if (result.ok) {
2733
+ const payload = result.payload;
2734
+ let ring = transcripts.get(payload.sessionId);
2735
+ if (!ring) {
2736
+ ring = { entries: [], ...client.machineId ? { machineId: client.machineId } : {} };
2737
+ }
2738
+ for (const entry of payload.entries) ring.entries.push(entry);
2739
+ if (ring.entries.length > TRANSCRIPT_RING_MAX) {
2740
+ ring.entries.splice(0, ring.entries.length - TRANSCRIPT_RING_MAX);
2741
+ }
2742
+ transcripts.delete(payload.sessionId);
2743
+ transcripts.set(payload.sessionId, ring);
2744
+ evictOldest(transcripts, MAX_TRANSCRIPT_SESSIONS);
2745
+ broadcastEvent(event, browsers);
2746
+ }
2747
+ return;
2748
+ }
2749
+ if (event.type === "agent.message") {
2750
+ const p = event.payload;
2751
+ const subId = p && typeof p["subagentId"] === "string" ? p["subagentId"] : void 0;
2752
+ if (subId) {
2753
+ const key = agentRingKey(event.sessionId, subId);
2754
+ let ring = agentMessages.get(key);
2755
+ if (!ring) ring = [];
2756
+ ring.push(agentMessageToEntry(p));
2757
+ if (ring.length > TRANSCRIPT_RING_MAX) ring.splice(0, ring.length - TRANSCRIPT_RING_MAX);
2758
+ agentMessages.delete(key);
2759
+ agentMessages.set(key, ring);
2760
+ evictOldest(agentMessages, MAX_AGENT_RINGS);
2761
+ }
2762
+ persistEvent(event);
2763
+ broadcastEvent(event, browsers);
2764
+ return;
2765
+ }
2766
+ persistEvent(event);
2767
+ broadcastEvent(event, browsers);
2768
+ }
2596
2769
  });
2597
- const { authFile } = firstRunAuth;
2598
- if (options.requireBrowserAuth && (authFile.browserTokens ?? []).length === 0 && authFile.passwordHash === void 0) {
2599
- throw new HqInsecureExposureError(
2600
- "HQ public relay requires browser authentication. Set --password or create a browser token first."
2601
- );
2602
- }
2603
- const exposure = assessHqExposure({
2604
- host,
2605
- hasBrowserTokens: (authFile.browserTokens ?? []).length > 0,
2606
- hasPassword: authFile.passwordHash !== void 0,
2607
- allowInsecure: options.allowInsecureOpen
2770
+ ws.on("close", () => {
2771
+ clients.delete(ws);
2772
+ snapshotBroadcaster.broadcast();
2608
2773
  });
2609
- if (exposure.kind === "refuse") throw new HqInsecureExposureError(exposure.message);
2610
- if (exposure.kind === "warn") {
2611
- console.warn(
2612
- JSON.stringify({
2613
- level: "warn",
2614
- event: "hq.insecure_exposure",
2615
- message: exposure.message,
2616
- host,
2617
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
2618
- })
2619
- );
2620
- }
2621
- return { host, port, dataDir, firstRunAuth };
2622
2774
  }
2623
2775
 
2624
2776
  // src/hq-server.ts
@@ -2638,11 +2790,28 @@ async function startHqServer(options = {}) {
2638
2790
  });
2639
2791
  return startHqServerWithAuth(options, host, port, dataDir, firstRunAuth);
2640
2792
  }
2641
- function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2793
+ async function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2642
2794
  const trustBoundary = options.trustBoundary ?? createCompatibilityTrustBoundary({ policyId: "hq-trusted-host-compat-v1" });
2643
2795
  const authFile = firstRunAuth.authFile;
2644
2796
  const authState = createHqAuthState(authFile, dataDir);
2645
2797
  const { mutableAuth } = authState;
2798
+ if (!mutableAuth.cookieSecret && mutableAuth.browserTokens.size > 0) {
2799
+ const secret = mintHqCookieSecret2();
2800
+ const next = await mutateHqAuthFile2(dataDir, (current) => ({
2801
+ ...current,
2802
+ cookieSecret: secret
2803
+ }));
2804
+ mutableAuth.cookieSecret = next.cookieSecret;
2805
+ console.warn(
2806
+ JSON.stringify({
2807
+ level: "info",
2808
+ event: "hq.cookie_secret_provisioned",
2809
+ message: "Cookie signing secret provisioned for token-mode bootstrap exchange.",
2810
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2811
+ })
2812
+ );
2813
+ }
2814
+ const bootstrapStore = new HqBootstrapCodeStore();
2646
2815
  console.warn(
2647
2816
  JSON.stringify({
2648
2817
  level: "info",
@@ -2696,8 +2865,29 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2696
2865
  const entries = await fs4.readdir(projectsDir, { withFileTypes: true }).catch(() => []);
2697
2866
  await Promise.all(
2698
2867
  entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
2699
- const mailbox = getSharedProjectMailbox(path6.join(projectsDir, entry.name));
2700
- await Promise.all([mailbox.getAgentStatuses(), mailbox.purgeClients()]);
2868
+ const projectDir = path6.join(projectsDir, entry.name);
2869
+ const metadataPath = path6.join(projectDir, ".mailbox-server.json");
2870
+ const ownerPid = await fs4.readFile(metadataPath, "utf8").then((raw) => JSON.parse(raw).pid).catch(() => void 0);
2871
+ if (ownerPid === void 0) return;
2872
+ let ownerAlive = false;
2873
+ try {
2874
+ process.kill(ownerPid, 0);
2875
+ ownerAlive = true;
2876
+ } catch {
2877
+ ownerAlive = false;
2878
+ }
2879
+ if (!ownerAlive) {
2880
+ await fs4.rm(metadataPath, { force: true }).catch(() => {
2881
+ });
2882
+ return;
2883
+ }
2884
+ const mailbox = getSharedProjectMailbox(projectDir);
2885
+ try {
2886
+ await Promise.all([mailbox.getAgentStatuses(), mailbox.purgeClients()]);
2887
+ } finally {
2888
+ await mailbox.close().catch(() => {
2889
+ });
2890
+ }
2701
2891
  })
2702
2892
  );
2703
2893
  })().catch(() => {
@@ -2709,14 +2899,27 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2709
2899
  const tokenMode = mutableAuth.browserTokens.size > 0;
2710
2900
  const passwordMode = mutableAuth.passwordHash !== void 0;
2711
2901
  if ((tokenMode || passwordMode) && auth === void 0) {
2712
- return { allowed: false, status: 401, body: { error: { code: "UNAUTHORIZED", message: "unauthorized" } } };
2902
+ return {
2903
+ allowed: false,
2904
+ status: 401,
2905
+ body: { error: { code: "UNAUTHORIZED", message: "unauthorized" } }
2906
+ };
2713
2907
  }
2714
2908
  const token = isTokenAuth2(auth) ? mutableAuth.browserTokenObjs.get(auth.token) : void 0;
2715
- const canUseMailbox = auth === "cookie" || !tokenMode || token?.capabilities === void 0 || token.capabilities.includes("control.enqueue");
2909
+ const canUseMailbox = isCookieAuth2(auth) ? auth.capabilities === void 0 || auth.capabilities.includes("control.enqueue") : isTokenAuth2(auth) ? token?.capabilities === void 0 || !!token?.capabilities.includes("control.enqueue") : !tokenMode;
2716
2910
  if (!canUseMailbox) {
2717
- return { allowed: false, status: 403, body: { error: { code: "FORBIDDEN", message: "forbidden: token lacks control.enqueue capability" } } };
2911
+ return {
2912
+ allowed: false,
2913
+ status: 403,
2914
+ body: {
2915
+ error: {
2916
+ code: "FORBIDDEN",
2917
+ message: "forbidden: token lacks control.enqueue capability"
2918
+ }
2919
+ }
2920
+ };
2718
2921
  }
2719
- const identity = isTokenAuth2(auth) ? auth.id : auth === "cookie" ? "cookie" : "open";
2922
+ const identity = isTokenAuth2(auth) ? auth.id : isCookieAuth2(auth) ? "cookie" : "open";
2720
2923
  return { allowed: true, rateLimitKey: `hq:${identity}:${projectDir}` };
2721
2924
  };
2722
2925
  const getMailboxGateway = (projectDir) => {
@@ -2768,7 +2971,11 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2768
2971
  persistence.timeseries.flush();
2769
2972
  }, 6e4);
2770
2973
  timeseriesFlushTimer.unref?.();
2771
- const snapshotBroadcaster = createSnapshotBroadcaster(clients, browsers, persistence);
2974
+ const snapshotBroadcaster = createSnapshotBroadcaster(
2975
+ clients,
2976
+ browsers,
2977
+ persistence
2978
+ );
2772
2979
  snapshotBroadcaster.currentSerialized();
2773
2980
  const stopAlertEngine = alertEngine.startPeriodic(
2774
2981
  () => buildSnapshot(clients, { tokenStats: authState.tokenStats() }),
@@ -2835,7 +3042,8 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2835
3042
  secureCookies: options.secureCookies,
2836
3043
  authorizeMailboxGateway,
2837
3044
  getMailboxGateway,
2838
- getTokenStats: () => authState.tokenStats()
3045
+ getTokenStats: () => authState.tokenStats(),
3046
+ bootstrapStore
2839
3047
  };
2840
3048
  const handleRequest = createHqRouter(routerDeps);
2841
3049
  const httpServer = http.createServer((req, res) => {
@@ -2843,7 +3051,10 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2843
3051
  });
2844
3052
  const wss = new WebSocketServer({ noServer: true, maxPayload: 1 * 1024 * 1024 });
2845
3053
  const browserHeartbeatTimer = setInterval(() => {
2846
- const heartbeat = JSON.stringify({ type: "hq.heartbeat", serverTime: (/* @__PURE__ */ new Date()).toISOString() });
3054
+ const heartbeat = JSON.stringify({
3055
+ type: "hq.heartbeat",
3056
+ serverTime: (/* @__PURE__ */ new Date()).toISOString()
3057
+ });
2847
3058
  for (const browser of browsers) {
2848
3059
  if (browser.readyState === WebSocket3.OPEN) browser.send(heartbeat);
2849
3060
  }
@@ -2858,7 +3069,12 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2858
3069
  }
2859
3070
  if (!hasTrustedBrowserOrigin2(req, host, listeningPort, trustedPublicOrigins)) {
2860
3071
  socket.write(
2861
- "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({ error: { code: "INVALID_ORIGIN", message: "Cross-origin WebSocket upgrade rejected." } })
3072
+ "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n" + JSON.stringify({
3073
+ error: {
3074
+ code: "INVALID_ORIGIN",
3075
+ message: "Cross-origin WebSocket upgrade rejected."
3076
+ }
3077
+ })
2862
3078
  );
2863
3079
  socket.destroy();
2864
3080
  return;
@@ -2868,12 +3084,21 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2868
3084
  if (needsAuth) {
2869
3085
  const supplied = url.searchParams.get("token") ?? "";
2870
3086
  const tokenValid = timingSafeTokenMatch(tokenSet, supplied) !== void 0;
2871
- const cookieValid = pathname === "/ws/browser" && mutableAuth.passwordHash !== void 0 && mutableAuth.cookieSecret !== void 0 && (() => {
3087
+ const cookieValid = pathname === "/ws/browser" && mutableAuth.cookieSecret !== void 0 && (() => {
2872
3088
  const cookies = parseCookieHeader2(req.headers.cookie);
2873
3089
  const raw = cookies[HQ_SESSION_COOKIE2];
2874
3090
  if (!raw) return false;
2875
3091
  const sessionId = parseHqSessionCookie2(raw, mutableAuth.cookieSecret);
2876
- return sessionId !== void 0 && sessions.has(sessionId);
3092
+ if (sessionId === void 0) return false;
3093
+ const session = sessions.get(sessionId);
3094
+ if (!session || Date.now() - session.createdAt >= HQ_SESSION_MAX_AGE_MS)
3095
+ return false;
3096
+ if (session.kind === "token" && session.tokenId !== void 0) {
3097
+ return [...mutableAuth.browserTokenObjs.values()].some(
3098
+ (obj) => obj.id === session.tokenId
3099
+ );
3100
+ }
3101
+ return true;
2877
3102
  })();
2878
3103
  if (!tokenValid && !cookieValid) {
2879
3104
  socket.write(
@@ -2937,7 +3162,12 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2937
3162
  },
2938
3163
  {
2939
3164
  warn: (msg) => console.warn(
2940
- JSON.stringify({ level: "warn", event: "hq.auth.reload_failed", message: msg, timestamp: (/* @__PURE__ */ new Date()).toISOString() })
3165
+ JSON.stringify({
3166
+ level: "warn",
3167
+ event: "hq.auth.reload_failed",
3168
+ message: msg,
3169
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3170
+ })
2941
3171
  )
2942
3172
  }
2943
3173
  );
@@ -2969,9 +3199,20 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2969
3199
  const hqUrl = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${actualPort}`;
2970
3200
  await writeHqRuntimeMarker(dataDir, hqUrl).catch(() => {
2971
3201
  });
3202
+ const browserTokenObj = mutableAuth.browserTokenObjs.get(browserToken ?? "");
3203
+ let browserUrl;
3204
+ if (browserToken && browserTokenObj) {
3205
+ const code = bootstrapStore.issue({
3206
+ tokenId: browserTokenObj.id,
3207
+ ...browserTokenObj.capabilities !== void 0 ? { capabilities: browserTokenObj.capabilities } : {}
3208
+ });
3209
+ browserUrl = buildBootstrapHttpUrl2(host, actualPort, code);
3210
+ } else {
3211
+ browserUrl = buildHttpUrl2(host, actualPort);
3212
+ }
2972
3213
  const startupInfo = {
2973
3214
  dataDir,
2974
- browserUrl: buildHttpUrl2(host, actualPort, browserToken),
3215
+ browserUrl,
2975
3216
  clientUrl: buildClientWsUrl2(host, actualPort, clientToken),
2976
3217
  clientEnv: {
2977
3218
  WRONGSTACK_HQ_URL: hqUrl,
@@ -2989,7 +3230,9 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
2989
3230
  trustPublicOrigin: (origin) => {
2990
3231
  const parsed = new URL(origin);
2991
3232
  if (parsed.protocol !== "https:" || parsed.origin !== origin) {
2992
- throw new TypeError("Trusted HQ public origin must be an exact HTTPS origin without a path.");
3233
+ throw new TypeError(
3234
+ "Trusted HQ public origin must be an exact HTTPS origin without a path."
3235
+ );
2993
3236
  }
2994
3237
  trustedPublicOrigins.add(parsed.origin.toLowerCase());
2995
3238
  },
@@ -3007,6 +3250,7 @@ function startHqServerWithAuth(options, host, port, dataDir, firstRunAuth) {
3007
3250
  stopAlertEngine();
3008
3251
  authWatcher.close();
3009
3252
  snapshotBroadcaster.close();
3253
+ bootstrapStore.clear();
3010
3254
  persistence.timeseries.flush();
3011
3255
  for (const ws of browsers) ws.close(1001, "HQ shutting down");
3012
3256
  for (const ws of clients.keys()) ws.close(1001, "HQ shutting down");
@@ -3056,4 +3300,4 @@ export {
3056
3300
  startHqServer,
3057
3301
  HqInsecureExposureError2 as HqInsecureExposureError
3058
3302
  };
3059
- //# sourceMappingURL=chunk-X5DQTPXT.js.map
3303
+ //# sourceMappingURL=chunk-OI7GWJDS.js.map