@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -2,8 +2,9 @@
2
2
 
3
3
  import crypto from 'crypto';
4
4
 
5
- // Preview lane registry: the in-memory set of dev servers published through the
6
- // dedicated preview listener, plus the config normalization and the startup
5
+ // Preview lane registry: the set of dev servers and folders published through
6
+ // the dedicated preview listener (persisted through an injected store, so it
7
+ // survives a restart), plus the config normalization and the startup
7
8
  // interlocks that keep that listener a separate origin from the relay.
8
9
  //
9
10
  // This module is deliberately http-free. It owns *what is allowed*; the listener
@@ -255,18 +256,155 @@ export function validatePreviewTarget({ host, port } = {}, {
255
256
  return { ok: true, host: normalizedHost, port: normalizedPort };
256
257
  }
257
258
 
259
+
260
+ export const PREVIEW_TOKEN_REDACTION = '[preview link removed]';
261
+ // The path prefix as typed, percent-encoded, or encoded more than once.
262
+ const PATH_PREFIX_SRC = 'test(?:_|%(?:25)*5f)';
263
+ // A preview path is recognised from this many hex characters on, whatever
264
+ // follows and however long the run is: a link that was cut short (a reply
265
+ // still streaming, a turn that failed inside it, a capped line) is then
266
+ // removed like a whole one, and at most 7 of a token's 32 characters can
267
+ // remain, which leaves 100 bits to guess. Shorter runs stay, so names such as
268
+ // `test_add` or `test_1` read as written.
269
+ export const PREVIEW_REDACT_MIN_HEX = 8;
270
+ const PREVIEW_PATH_TOKEN_GLOBAL_RE = new RegExp(`${PATH_PREFIX_SRC}[0-9a-f]{${PREVIEW_REDACT_MIN_HEX},}`, 'gi');
271
+
272
+ // `abcdefgh(?:i(?:j(?:k)?)?)?`: the first `min` characters, then any more.
273
+ function prefixSource(token, min) {
274
+ let tail = '';
275
+ for (let i = token.length - 1; i >= min; i -= 1) tail = `(?:${token[i]}${tail})?`;
276
+ return `${token.slice(0, min)}${tail}`;
277
+ }
278
+
279
+ // `(?:(?:(?:a)?b)?c)?defghijk`: the last `min` characters, with any more before.
280
+ function suffixSource(token, min) {
281
+ let head = '';
282
+ for (let i = 0; i < token.length - min; i += 1) head = `(?:${head}${token[i]})?`;
283
+ return `${head}${token.slice(token.length - min)}`;
284
+ }
285
+
286
+ /**
287
+ * Removes preview capabilities from every string of a JSON-like value. Returns
288
+ * a copy; the input is untouched. Redacted are:
289
+ * - each `test_<hex>` path segment with at least PREVIEW_REDACT_MIN_HEX hex
290
+ * characters, also with the `_` percent-encoded, wherever it stands: a
291
+ * whole link, one cut short, one glued to more hex;
292
+ * - each occurrence of one of `tokens` (the relay's known previews: the
293
+ * preview tool's list output quotes them bare), or of its first
294
+ * PREVIEW_REDACT_MIN_HEX characters or more, wherever it stands;
295
+ * - the end of one of `tokens`, of that length or more, at the start of a
296
+ * string (a link that runs across two strings).
297
+ *
298
+ * Ordinary text is hit only where `test_` is followed by eight or more hex
299
+ * digits (`test_deadbeef`), or where it holds eight characters of a known
300
+ * token by chance.
301
+ */
302
+ export function redactPreviewTokens(value, { tokens = [] } = {}) {
303
+ const bare = [...new Set((tokens || []).map(toText).filter((token) => isPreviewToken(token)))];
304
+ // Every pattern here does a bounded amount of work per character, so the
305
+ // pass is linear in the length of the text: it runs on a public route, on
306
+ // whatever a transcript holds. In particular nothing looks back over a run
307
+ // of whitespace; the start of a string is found by plain code.
308
+ const startRe = bare.length > 0
309
+ ? new RegExp(`(?:${bare.map((token) => suffixSource(token, PREVIEW_REDACT_MIN_HEX)).join('|')})`, 'iy')
310
+ : null;
311
+ const bareRe = bare.length > 0
312
+ ? new RegExp(`(?:${bare.map((token) => prefixSource(token, PREVIEW_REDACT_MIN_HEX)).join('|')})`, 'gi')
313
+ : null;
314
+ const redactText = (text) => {
315
+ let out = text.replace(PREVIEW_PATH_TOKEN_GLOBAL_RE, PREVIEW_TOKEN_REDACTION);
316
+ if (!bareRe) return out;
317
+ // The start of the string first: what is left of a link begun in another one.
318
+ const start = out.search(/\S/);
319
+ if (start !== -1) {
320
+ startRe.lastIndex = start;
321
+ const match = startRe.exec(out);
322
+ if (match) out = `${out.slice(0, start)}${PREVIEW_TOKEN_REDACTION}${out.slice(start + match[0].length)}`;
323
+ }
324
+ return out.replace(bareRe, PREVIEW_TOKEN_REDACTION);
325
+ };
326
+ const walk = (node, depth) => {
327
+ if (typeof node === 'string') return redactText(node);
328
+ if (!node || typeof node !== 'object' || depth > 64) return node;
329
+ if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
330
+ if (typeof node.toJSON === 'function') return walk(node.toJSON(), depth + 1);
331
+ const out = {};
332
+ for (const [key, item] of Object.entries(node)) out[key] = walk(item, depth + 1);
333
+ return out;
334
+ };
335
+ return walk(value, 0);
336
+ }
337
+
338
+ /**
339
+ * A short, stable name for a preview in logs: the token itself is the
340
+ * capability, so it never goes into a log line.
341
+ */
342
+ export function previewLogId(token) {
343
+ return crypto.createHash('sha256').update(toText(token)).digest('hex').slice(0, 8);
344
+ }
345
+
346
+ // A row a restore could not judge is checked again on the health probe's beat
347
+ // for this long, then dropped: ten minutes outlast a busy database or a mount
348
+ // that comes up late, and a link nobody can check does not stay for ever.
349
+ const DEFAULT_KEPT_ROW_MAX_AGE_MS = 10 * 60_000;
350
+ // The shutdown flush waits this long on a locked database, not the driver's
351
+ // default: the runtime's force-exit timer is armed only after it.
352
+ const FLUSH_BUSY_TIMEOUT_MS = 250;
353
+
354
+ // Writes of `last_hit_at` are coalesced to one per preview per minute: a page
355
+ // load is dozens of requests, and the column only feeds the "last opened" line.
356
+ const DEFAULT_HIT_PERSIST_INTERVAL_MS = 60_000;
357
+
358
+ function sameTargetKey({ conversationId, mode, targetHost, targetPort, rootDir }) {
359
+ const conv = toText(conversationId);
360
+ return mode === 'static'
361
+ ? JSON.stringify([conv, 'static', toText(rootDir)])
362
+ : JSON.stringify([conv, 'port', normalizeHost(targetHost), Number(targetPort)]);
363
+ }
364
+
365
+ function formatAge(ms) {
366
+ const minutes = Math.max(0, Math.round(ms / 60_000));
367
+ if (minutes < 1) return 'just now';
368
+ if (minutes < 60) return `${minutes} min ago`;
369
+ const hours = Math.round(minutes / 60);
370
+ if (hours < 48) return `${hours} h ago`;
371
+ return `${Math.round(hours / 24)} d ago`;
372
+ }
373
+
258
374
  /**
259
- * The live preview set. In-memory by design: a relay restart drops every
260
- * preview, which is the agreed lifecycle (nothing expires on its own, so a
261
- * restart is the only implicit cleanup).
375
+ * The live preview set. Kept in memory for the hot path and written through to
376
+ * an injected store (the `previews` table), so a restarted runtime restores
377
+ * every link it handed out. Nothing expires on its own: a preview ends when it
378
+ * is closed, when its conversation is deleted, or when a restore finds it no
379
+ * longer valid (folder gone or outside its workspace root, port preview from
380
+ * an earlier host boot, conversation gone).
381
+ *
382
+ * A row a restore could not judge (a check threw, or answered with an fs
383
+ * error rather than a "no") is kept apart: not served, but listed as
384
+ * `unverified`, counted towards the limit, closable, and checked again by
385
+ * recheckKeptRows until it is restored, refused, or too old.
262
386
  */
263
387
  export function createPreviewRegistry({
264
388
  config,
265
389
  now = () => Date.now(),
266
390
  randomBytes = crypto.randomBytes,
267
391
  onChange = null,
392
+ store = null,
393
+ hostBootId = '',
394
+ isSameHostBoot = (a, b) => !!toText(a) && toText(a) === toText(b),
395
+ // (conversationId) => boolean: does the conversation still exist (not deleted)?
396
+ checkConversation = null,
397
+ // (rootDir, conversationId) => { ok, rootDir } | { ok:false, error, transient? }:
398
+ // the registration-time jail, run again against the conversation's workspace
399
+ // root. `transient` (an fs error, not a "no") keeps the row to be checked again.
400
+ checkStaticRoot = null,
401
+ hitPersistIntervalMs = DEFAULT_HIT_PERSIST_INTERVAL_MS,
402
+ keptRowMaxAgeMs = DEFAULT_KEPT_ROW_MAX_AGE_MS,
403
+ logger = console,
268
404
  } = {}) {
269
405
  const entries = new Map();
406
+ // token -> { row, since, reason }: stored rows no check could judge yet.
407
+ const kept = new Map();
270
408
  const settings = config || normalizePreviewsConfig({});
271
409
 
272
410
  function toPublicRecord(entry) {
@@ -281,23 +419,158 @@ export function createPreviewRegistry({
281
419
  url: entry.url,
282
420
  basePath: entry.basePath,
283
421
  createdAt: entry.createdAt,
422
+ lastHitAt: entry.lastHitAt ?? null,
284
423
  online: entry.online,
285
424
  lastSeenOnline: entry.lastSeenOnline,
286
425
  hits: entry.hits,
287
426
  };
288
427
  }
289
428
 
290
- function emitChange(reason, entry) {
429
+ function keptRecord({ row }) {
430
+ const basePath = previewBasePath(row.token);
431
+ return {
432
+ token: row.token,
433
+ conversationId: toText(row.conversationId) || null,
434
+ label: toText(row.label).slice(0, 120) || 'preview',
435
+ mode: row.mode,
436
+ targetHost: row.targetHost ?? null,
437
+ targetPort: row.targetPort ?? null,
438
+ rootDir: row.rootDir ?? null,
439
+ url: `${settings.publicBaseUrl}${basePath}`,
440
+ basePath,
441
+ createdAt: Number.isFinite(row.createdAt) ? row.createdAt : 0,
442
+ lastHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
443
+ online: false,
444
+ lastSeenOnline: null,
445
+ hits: 0,
446
+ // Not served until a check passes.
447
+ unverified: true,
448
+ };
449
+ }
450
+
451
+ function storeCall(name, ...args) {
452
+ if (!store || typeof store[name] !== 'function') return undefined;
453
+ try {
454
+ return store[name](...args);
455
+ } catch (error) {
456
+ logger?.warn?.(`[preview] store ${name} failed: ${error?.message || error}`);
457
+ return undefined;
458
+ }
459
+ }
460
+
461
+ // Writes that threw (a locked or busy database) are kept here and retried by
462
+ // retryPendingWrites — on the health probe's beat and at shutdown — so the
463
+ // table converges with memory instead of bringing a closed link back, or
464
+ // losing a live one, at the next start. Keyed by the stored token:
465
+ // 'upsert' writes the live entry again, 'remove' deletes the row.
466
+ //
467
+ // One entry per token, and only for a token that is live or has a row: a
468
+ // preview whose insert never landed leaves nothing behind when it is closed,
469
+ // so cycles of create and close on a failing store do not grow the map.
470
+ const pendingWrites = new Map();
471
+ // Conversation ids whose rows are still to be deleted, each with the tokens
472
+ // that were closed with it: those rows are still stored.
473
+ const pendingConversationRemovals = new Map();
474
+
475
+ // Counts the writes that threw, for passes that must stop at the first one.
476
+ let storeWriteFailures = 0;
477
+
478
+ // true when the write went through, or there is nothing to write to; false
479
+ // when the store threw. Logs the first failure of a key only.
480
+ function storeWrite(key, name, ...args) {
481
+ if (!store || typeof store[name] !== 'function') return true;
482
+ try {
483
+ store[name](...args);
484
+ return true;
485
+ } catch (error) {
486
+ storeWriteFailures += 1;
487
+ if (!pendingWrites.has(key) && !pendingConversationRemovals.has(key)) {
488
+ logger?.warn?.(`[preview] store ${name} failed, retried later: ${error?.message || error}`);
489
+ }
490
+ return false;
491
+ }
492
+ }
493
+
494
+ // A failed write goes to the end of the line, so one that keeps failing
495
+ // does not hold the others back.
496
+ function setPending(token, op) {
497
+ pendingWrites.delete(token);
498
+ pendingWrites.set(token, op);
499
+ }
500
+
501
+ function upsertRow(entry) {
502
+ const ok = storeWrite(entry.token, 'insert', { ...toPublicRecord(entry), hostBootId });
503
+ if (ok) {
504
+ entry.stored = true;
505
+ pendingWrites.delete(entry.token);
506
+ } else {
507
+ setPending(entry.token, 'upsert');
508
+ }
509
+ return ok;
510
+ }
511
+
512
+ function removeRow(token) {
513
+ const ok = storeWrite(token, 'remove', token);
514
+ if (ok) pendingWrites.delete(token);
515
+ else setPending(token, 'remove');
516
+ return ok;
517
+ }
518
+
519
+ function liveTokensOfConversation(conversationId) {
520
+ const tokens = [];
521
+ for (const entry of entries.values()) {
522
+ if (entry.conversationId === conversationId) tokens.push(entry.token);
523
+ }
524
+ for (const [token, held] of kept) {
525
+ if (toText(held.row.conversationId) === conversationId) tokens.push(token);
526
+ }
527
+ return tokens;
528
+ }
529
+
530
+ // One pass over the failed writes, stopped at the first that fails again:
531
+ // on a locked database every attempt blocks for the driver's timeout, and
532
+ // the next one would only block again. -> how many are still pending.
533
+ function retryPendingWrites() {
534
+ const pendingCount = () => pendingWrites.size + pendingConversationRemovals.size;
535
+ for (const [token, op] of [...pendingWrites]) {
536
+ if (op === 'upsert') {
537
+ const entry = entries.get(token);
538
+ // Gone since without a close of its own: nothing is left to write.
539
+ if (!entry) pendingWrites.delete(token);
540
+ else if (!upsertRow(entry)) return pendingCount();
541
+ } else if (!removeRow(token)) {
542
+ return pendingCount();
543
+ }
544
+ }
545
+ for (const [conversationId, closedTokens] of [...pendingConversationRemovals]) {
546
+ // Only what was there when the delete was asked for: a preview
547
+ // registered under the same conversation id since then is in memory, and
548
+ // its row stays.
549
+ const ok = storeWrite(conversationId, 'removeForConversation', conversationId, {
550
+ exceptTokens: liveTokensOfConversation(conversationId),
551
+ });
552
+ if (!ok) {
553
+ pendingConversationRemovals.delete(conversationId);
554
+ pendingConversationRemovals.set(conversationId, closedTokens);
555
+ return pendingCount();
556
+ }
557
+ pendingConversationRemovals.delete(conversationId);
558
+ }
559
+ return pendingCount();
560
+ }
561
+
562
+ function emitChange(reason, entry, record = entry ? toPublicRecord(entry) : null) {
291
563
  if (typeof onChange !== 'function') return;
292
564
  try {
293
- onChange({ reason, preview: entry ? toPublicRecord(entry) : null, previews: list() });
565
+ onChange({ reason, preview: record, previews: list() });
294
566
  } catch {}
295
567
  }
296
568
 
297
569
  function list() {
298
- return [...entries.values()]
299
- .sort((a, b) => a.createdAt - b.createdAt)
300
- .map(toPublicRecord);
570
+ return [
571
+ ...[...entries.values()].map(toPublicRecord),
572
+ ...[...kept.values()].map(keptRecord),
573
+ ].sort((a, b) => a.createdAt - b.createdAt);
301
574
  }
302
575
 
303
576
  function listForConversation(conversationId) {
@@ -306,40 +579,142 @@ export function createPreviewRegistry({
306
579
  return list().filter((entry) => entry.conversationId === id);
307
580
  }
308
581
 
309
- function insertEntry(fields) {
310
- const token = createPreviewToken({ randomBytes });
582
+ function buildEntry(token, fields) {
311
583
  const basePath = previewBasePath(token);
312
- const entry = {
584
+ return {
313
585
  token,
314
586
  basePath,
587
+ // Rebuilt from the current settings on every boot, so a changed public
588
+ // address moves every restored link with it.
315
589
  url: `${settings.publicBaseUrl}${basePath}`,
316
590
  createdAt: now(),
591
+ lastHitAt: null,
592
+ persistedHitAt: null,
593
+ hitWriteAt: null,
317
594
  lastSeenOnline: null,
318
595
  hits: 0,
319
596
  ...fields,
320
597
  };
598
+ }
599
+
600
+ function insertEntry(fields) {
601
+ const token = createPreviewToken({ randomBytes });
602
+ const entry = buildEntry(token, fields);
321
603
  entries.set(token, entry);
604
+ upsertRow(entry);
322
605
  emitChange('created', entry);
323
606
  return { ok: true, preview: toPublicRecord(entry) };
324
607
  }
325
608
 
326
- function checkCreatable() {
609
+ function findSameTarget(fields) {
610
+ const key = sameTargetKey(fields);
611
+ for (const entry of entries.values()) {
612
+ if (sameTargetKey(entry) === key) return entry;
613
+ }
614
+ return null;
615
+ }
616
+
617
+ function findKeptSameTarget(fields) {
618
+ const key = sameTargetKey(fields);
619
+ for (const held of kept.values()) {
620
+ if (sameTargetKey(held.row) === key) return held;
621
+ }
622
+ return null;
623
+ }
624
+
625
+ // A registration of a kept row's target has just passed the target's own
626
+ // checks (the folder jail, the port rules), so the row comes back under its
627
+ // token, if its conversation still exists. Two rows do not come back and
628
+ // are dropped: one whose conversation is gone, and a port row from an
629
+ // earlier host boot, whose old link must not reach whatever listens on the
630
+ // port now. -> the result of create, or null (go on as without the row).
631
+ function reviveKept(held, fields) {
632
+ const { row } = held;
633
+ const conversationId = toText(row.conversationId);
634
+ if (conversationId && typeof checkConversation === 'function') {
635
+ let exists;
636
+ try {
637
+ exists = checkConversation(conversationId);
638
+ } catch (error) {
639
+ held.reason = `conversation check failed: ${errorText(error)}`;
640
+ return {
641
+ ok: false,
642
+ status: 503,
643
+ error: 'This preview is stored but could not be checked yet; try again in a moment.',
644
+ };
645
+ }
646
+ if (!exists) {
647
+ kept.delete(row.token);
648
+ dropRow(row, 'its conversation no longer exists');
649
+ return null;
650
+ }
651
+ }
652
+ kept.delete(row.token);
653
+ if (fields.mode === 'port' && !isSameHostBoot(row.hostBootId, hostBootId)) {
654
+ dropRow(row, 'port preview from an earlier host boot');
655
+ return null;
656
+ }
657
+ const entry = buildEntry(row.token, {
658
+ ...fields,
659
+ label: toText(row.label).slice(0, 120) || fields.label,
660
+ createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
661
+ lastHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
662
+ persistedHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
663
+ stored: true,
664
+ });
665
+ entries.set(entry.token, entry);
666
+ upsertRow(entry);
667
+ emitChange('restored', entry);
668
+ return { ok: true, existing: true, preview: toPublicRecord(entry) };
669
+ }
670
+
671
+ function describeTarget(entry) {
672
+ if (entry.mode === 'static') {
673
+ return `folder ${toText(entry.rootDir).split(/[\\/]/).filter(Boolean).pop() || entry.rootDir}`;
674
+ }
675
+ return `port ${entry.targetPort}`;
676
+ }
677
+
678
+ function tooManyError() {
679
+ const at = now();
680
+ const oldest = list()
681
+ .slice(0, 3)
682
+ .map((entry) => `"${entry.label}" (${describeTarget(entry)}, published ${formatAge(at - entry.createdAt)}, `
683
+ + `${entry.lastHitAt ? `last opened ${formatAge(at - entry.lastHitAt)}` : 'never opened'})`);
684
+ // More can be live than the limit (rows restored after it was lowered).
685
+ const toClose = Math.max(1, entries.size + kept.size - settings.maxLive + 1);
686
+ return {
687
+ ok: false,
688
+ status: 429,
689
+ error: `Too many live previews (max ${settings.maxLive}); previews stay until closed. `
690
+ + `Oldest: ${oldest.join('; ')}. Close ${toClose === 1 ? 'one' : toClose} in Settings → Previews, with the preview tool's `
691
+ + `close action (list shows the tokens), or with DELETE /api/previews/:token.`,
692
+ };
693
+ }
694
+
695
+ // A second create for the same conversation and the same target returns the
696
+ // live preview instead of minting another token, so every link handed out
697
+ // for that target stays one link.
698
+ function createOrReuse(fields) {
327
699
  if (!settings.enabled) {
328
700
  return { ok: false, status: 503, error: 'Preview lane is disabled' };
329
701
  }
330
- if (entries.size >= settings.maxLive) {
331
- return { ok: false, status: 429, error: `Too many live previews (max ${settings.maxLive})` };
332
- }
333
- return { ok: true };
702
+ const existing = findSameTarget(fields);
703
+ if (existing) return { ok: true, existing: true, preview: toPublicRecord(existing) };
704
+ const held = findKeptSameTarget(fields);
705
+ const revived = held ? reviveKept(held, fields) : null;
706
+ if (revived) return revived;
707
+ // Kept rows count: each of them may be a live link again at any moment.
708
+ if (entries.size + kept.size >= settings.maxLive) return tooManyError();
709
+ return insertEntry(fields);
334
710
  }
335
711
 
336
712
  function create({ conversationId, port, host, label } = {}) {
337
- const creatable = checkCreatable();
338
- if (!creatable.ok) return creatable;
713
+ if (!settings.enabled) return { ok: false, status: 503, error: 'Preview lane is disabled' };
339
714
  const target = validatePreviewTarget({ host, port }, settings);
340
715
  if (!target.ok) return { ok: false, status: 400, error: target.error };
341
716
 
342
- return insertEntry({
717
+ return createOrReuse({
343
718
  mode: 'port',
344
719
  conversationId: toText(conversationId) || null,
345
720
  label: toText(label).slice(0, 120) || `localhost:${target.port}`,
@@ -355,12 +730,11 @@ export function createPreviewRegistry({
355
730
  // workspace root) — the route owns that check because it has fs access and
356
731
  // the workspace resolver; the registry stays fs-free.
357
732
  function createStatic({ conversationId, rootDir, label } = {}) {
358
- const creatable = checkCreatable();
359
- if (!creatable.ok) return creatable;
733
+ if (!settings.enabled) return { ok: false, status: 503, error: 'Preview lane is disabled' };
360
734
  const root = toText(rootDir);
361
735
  if (!root) return { ok: false, status: 400, error: 'Missing rootDir' };
362
736
 
363
- return insertEntry({
737
+ return createOrReuse({
364
738
  mode: 'static',
365
739
  conversationId: toText(conversationId) || null,
366
740
  label: toText(label).slice(0, 120) || root.split(/[\\/]/).filter(Boolean).pop() || 'static preview',
@@ -391,11 +765,41 @@ export function createPreviewRegistry({
391
765
  return entry;
392
766
  }
393
767
 
768
+ // The throttle runs on the time of the last attempt, so a failing store is
769
+ // not hammered once per request; a hit that did not get written is written
770
+ // by the next attempt or by flushHits.
771
+ // -> false when the write failed.
772
+ function persistHit(entry, at = now()) {
773
+ if (!entry.lastHitAt || entry.persistedHitAt === entry.lastHitAt) return true;
774
+ entry.hitWriteAt = at;
775
+ const hitAt = entry.lastHitAt;
776
+ if (!storeWrite(`hit:${entry.token}`, 'touch', entry.token, hitAt)) return false;
777
+ entry.persistedHitAt = hitAt;
778
+ return true;
779
+ }
780
+
394
781
  function recordHit(token, at = now()) {
395
782
  const entry = entries.get(toText(token));
396
783
  if (!entry) return;
397
784
  entry.hits += 1;
398
785
  entry.lastHitAt = at;
786
+ if (entry.hitWriteAt === null || at - entry.hitWriteAt >= hitPersistIntervalMs) {
787
+ persistHit(entry, at);
788
+ }
789
+ }
790
+
791
+ // Writes the hits the throttle held back and retries failed writes; called
792
+ // on shutdown, which it must not hold up: it gives up at the first write
793
+ // that fails and waits only briefly on a locked database.
794
+ function flushHits() {
795
+ const flush = () => {
796
+ if (retryPendingWrites() > 0) return;
797
+ for (const entry of entries.values()) {
798
+ if (!persistHit(entry)) return;
799
+ }
800
+ };
801
+ if (typeof store?.withBusyTimeout === 'function') store.withBusyTimeout(FLUSH_BUSY_TIMEOUT_MS, flush);
802
+ else flush();
399
803
  }
400
804
 
401
805
  function markHealth(token, online, at = now()) {
@@ -411,8 +815,19 @@ export function createPreviewRegistry({
411
815
 
412
816
  function close(token) {
413
817
  const entry = entries.get(toText(token));
414
- if (!entry) return { ok: false, status: 404, error: 'Unknown preview' };
818
+ if (!entry) {
819
+ const held = kept.get(toText(token));
820
+ if (!held) return { ok: false, status: 404, error: 'Unknown preview' };
821
+ const record = keptRecord(held);
822
+ kept.delete(record.token);
823
+ removeRow(record.token);
824
+ emitChange('closed', null, record);
825
+ return { ok: true, preview: record };
826
+ }
415
827
  entries.delete(entry.token);
828
+ // A row that never reached the store needs no delete.
829
+ if (entry.stored) removeRow(entry.token);
830
+ else pendingWrites.delete(entry.token);
416
831
  emitChange('closed', entry);
417
832
  return { ok: true, preview: toPublicRecord(entry) };
418
833
  }
@@ -421,28 +836,263 @@ export function createPreviewRegistry({
421
836
  const id = toText(conversationId);
422
837
  if (!id) return 0;
423
838
  let closed = 0;
839
+ const closedTokens = new Set(pendingConversationRemovals.get(id) || []);
424
840
  for (const entry of [...entries.values()]) {
425
841
  if (entry.conversationId !== id) continue;
426
842
  entries.delete(entry.token);
843
+ // Covered by the conversation-wide delete below (or its retry).
844
+ pendingWrites.delete(entry.token);
845
+ closedTokens.add(entry.token);
846
+ closed += 1;
847
+ }
848
+ for (const [token, held] of [...kept]) {
849
+ if (toText(held.row.conversationId) !== id) continue;
850
+ kept.delete(token);
851
+ pendingWrites.delete(token);
852
+ closedTokens.add(token);
427
853
  closed += 1;
428
854
  }
855
+ // Also rows this runtime never loaded (lane off at boot).
856
+ if (storeWrite(id, 'removeForConversation', id)) pendingConversationRemovals.delete(id);
857
+ else pendingConversationRemovals.set(id, closedTokens);
429
858
  if (closed > 0) emitChange('closed', null);
430
859
  return closed;
431
860
  }
432
861
 
862
+ // Drops the in-memory set only; the stored rows stay. Not part of shutdown
863
+ // any more: a restart restores the set from the store.
433
864
  function clear() {
434
- const had = entries.size > 0;
865
+ const had = entries.size + kept.size > 0;
435
866
  entries.clear();
867
+ kept.clear();
436
868
  if (had) emitChange('cleared', null);
437
869
  }
438
870
 
871
+ function dropRow(row, reason) {
872
+ removeRow(row.token);
873
+ logger?.warn?.(`[preview] not restored ${previewLogId(row.token)} "${toText(row.label)}": ${reason}`);
874
+ return { outcome: 'dropped' };
875
+ }
876
+
877
+ // A check that threw, or answered with an fs error rather than a "no",
878
+ // proves nothing about the row: it stays, unserved, and is checked again.
879
+ // Logged once, not on every check.
880
+ function skipRow(row, reason) {
881
+ const held = kept.get(row?.token);
882
+ if (held) {
883
+ held.reason = reason;
884
+ return { outcome: 'skipped' };
885
+ }
886
+ logger?.warn?.(`[preview] not restored ${previewLogId(row?.token)} "${toText(row?.label)}", `
887
+ + `kept and checked again: ${reason}`);
888
+ if (typeof row?.token === 'string' && row.token === toText(row.token) && isPreviewToken(row.token)) {
889
+ kept.set(row.token, { row, since: now(), reason });
890
+ }
891
+ return { outcome: 'skipped' };
892
+ }
893
+
894
+ function errorText(error) {
895
+ return error?.message || String(error);
896
+ }
897
+
898
+ // -> { outcome: 'restored' | 'dropped' | 'skipped' | 'present' }
899
+ function restoreRow(row) {
900
+ // Only the canonical form is ever written; a row in another form (hand
901
+ // written, upper case) is deleted by the value it is stored under.
902
+ if (typeof row.token !== 'string' || row.token !== toText(row.token) || !isPreviewToken(row.token)) {
903
+ return dropRow(row, 'invalid token');
904
+ }
905
+ if (entries.has(row.token)) return { outcome: 'present' };
906
+ const conversationId = toText(row.conversationId) || null;
907
+ if (conversationId && typeof checkConversation === 'function') {
908
+ let exists;
909
+ try {
910
+ exists = checkConversation(conversationId);
911
+ } catch (error) {
912
+ return skipRow(row, `conversation check failed: ${errorText(error)}`);
913
+ }
914
+ if (!exists) return dropRow(row, 'its conversation no longer exists');
915
+ }
916
+ let fields;
917
+ let rewrite = false;
918
+ if (row.mode === 'static') {
919
+ let rootDir = toText(row.rootDir);
920
+ if (!rootDir) return dropRow(row, 'no folder');
921
+ if (typeof checkStaticRoot === 'function') {
922
+ let checked;
923
+ try {
924
+ checked = checkStaticRoot(rootDir, conversationId);
925
+ } catch (error) {
926
+ return skipRow(row, `folder check failed: ${errorText(error)}`);
927
+ }
928
+ if (!checked?.ok && checked?.transient) return skipRow(row, checked.error || 'folder check failed');
929
+ if (!checked?.ok) return dropRow(row, checked?.error || 'folder refused');
930
+ if (checked.rootDir && checked.rootDir !== rootDir) {
931
+ rootDir = checked.rootDir;
932
+ rewrite = true;
933
+ }
934
+ }
935
+ fields = { mode: 'static', targetHost: null, targetPort: null, rootDir, online: true };
936
+ } else if (row.mode === 'port') {
937
+ if (!isSameHostBoot(row.hostBootId, hostBootId)) {
938
+ return dropRow(row, 'port preview from an earlier host boot');
939
+ }
940
+ const target = validatePreviewTarget({ host: row.targetHost, port: row.targetPort }, settings);
941
+ if (!target.ok) return dropRow(row, target.error);
942
+ fields = { mode: 'port', targetHost: target.host, targetPort: target.port, rootDir: null, online: null };
943
+ // An uptime-based id matches within a tolerance; storing the current one
944
+ // keeps clock drift from adding up across restarts.
945
+ rewrite = toText(row.hostBootId) !== toText(hostBootId);
946
+ } else {
947
+ return dropRow(row, 'unknown mode');
948
+ }
949
+ const entry = buildEntry(row.token, {
950
+ ...fields,
951
+ conversationId,
952
+ label: toText(row.label).slice(0, 120) || (fields.mode === 'static' ? 'static preview' : `localhost:${fields.targetPort}`),
953
+ createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
954
+ lastHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
955
+ persistedHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
956
+ stored: true,
957
+ });
958
+ entries.set(entry.token, entry);
959
+ if (rewrite) upsertRow(entry);
960
+ return { outcome: 'restored' };
961
+ }
962
+
963
+ // Checks the kept rows again; called on the health probe's beat, so a
964
+ // failure that passes heals without a restart. A row is restored, dropped
965
+ // by the answer of its check, or, when it has stayed unjudgeable for
966
+ // keptRowMaxAgeMs, dropped and logged. Anything that may have blocked for
967
+ // a database timeout ends the pass: a row that still cannot be judged (it
968
+ // moves to the end of the line), or a write that failed. A row past the
969
+ // bound is not deleted here but left to the next retry of pending writes,
970
+ // so a pass waits once at most.
971
+ // -> how many rows are still kept.
972
+ function recheckKeptRows() {
973
+ if (!settings.enabled || kept.size === 0) return kept.size;
974
+ let changed = false;
975
+ const failuresBefore = storeWriteFailures;
976
+ for (const [token, held] of [...kept]) {
977
+ if (storeWriteFailures !== failuresBefore) break;
978
+ let result;
979
+ try {
980
+ result = restoreRow(held.row);
981
+ } catch (error) {
982
+ result = skipRow(held.row, `restore failed: ${errorText(error)}`);
983
+ }
984
+ if (result.outcome !== 'skipped') {
985
+ kept.delete(token);
986
+ changed = true;
987
+ if (result.outcome === 'restored') {
988
+ logger?.log?.(`[preview] restored ${previewLogId(token)} "${toText(held.row.label)}" on a later check`);
989
+ }
990
+ continue;
991
+ }
992
+ kept.delete(token);
993
+ if (now() - held.since >= keptRowMaxAgeMs) {
994
+ setPending(token, 'remove');
995
+ changed = true;
996
+ logger?.warn?.(`[preview] dropped ${previewLogId(token)} "${toText(held.row.label)}": `
997
+ + `could not be checked for ${Math.round(keptRowMaxAgeMs / 60_000)} min (${held.reason})`);
998
+ } else {
999
+ kept.set(token, held);
1000
+ }
1001
+ break;
1002
+ }
1003
+ if (changed) emitChange('rechecked', null);
1004
+ return kept.size;
1005
+ }
1006
+
1007
+ // The store's upkeep on the health probe's beat: failed writes first, and
1008
+ // the kept rows only when none of them failed again, so that a sweep on a
1009
+ // locked database waits for one timeout and not for one per step.
1010
+ function maintain() {
1011
+ const failuresBefore = storeWriteFailures;
1012
+ retryPendingWrites();
1013
+ if (storeWriteFailures === failuresBefore) recheckKeptRows();
1014
+ }
1015
+
1016
+ // The stored tokens as last read while the lane is off; null until a read
1017
+ // went through.
1018
+ let storedTokens = null;
1019
+ let storedTokensReadFailed = false;
1020
+
1021
+ // Every token that is or may again become a working link, for the redaction
1022
+ // of shared transcripts:
1023
+ // - live and kept previews;
1024
+ // - rows that are still stored because their delete has not landed yet (a
1025
+ // closed preview, a deleted conversation, a kept row past its bound): a
1026
+ // crash before the delete lands brings them back;
1027
+ // - with the lane off (nothing loaded), the stored rows. A read that fails
1028
+ // there is never taken for "no tokens": the last list that was read
1029
+ // stays in use, and without one this throws, so the caller shares nothing
1030
+ // rather than a transcript it could not redact.
1031
+ function knownTokens() {
1032
+ const tokens = new Set([...entries.keys(), ...kept.keys()]);
1033
+ for (const [token, op] of pendingWrites) {
1034
+ if (op === 'remove') tokens.add(token);
1035
+ }
1036
+ for (const closedTokens of pendingConversationRemovals.values()) {
1037
+ for (const token of closedTokens) tokens.add(token);
1038
+ }
1039
+ if (!settings.enabled && store && typeof store.list === 'function') {
1040
+ try {
1041
+ const rows = store.list() || [];
1042
+ storedTokens = rows.map((row) => toText(row?.token)).filter((token) => isPreviewToken(token));
1043
+ storedTokensReadFailed = false;
1044
+ } catch (error) {
1045
+ if (!storedTokensReadFailed) {
1046
+ logger?.warn?.(`[preview] stored previews could not be read for redaction, `
1047
+ + `${storedTokens ? 'the last list stays in use' : 'none was read yet'}: ${errorText(error)}`);
1048
+ }
1049
+ storedTokensReadFailed = true;
1050
+ if (!storedTokens) throw new Error('The stored previews could not be read');
1051
+ }
1052
+ for (const token of storedTokens) tokens.add(token);
1053
+ }
1054
+ return [...tokens];
1055
+ }
1056
+
1057
+ // Restores the stored set. With the lane off nothing is loaded or served and
1058
+ // the rows stay untouched for the next boot that has it on.
1059
+ function restore() {
1060
+ if (!settings.enabled || !store) return { restored: 0, dropped: 0, skipped: 0 };
1061
+ const rows = storeCall('list') || [];
1062
+ const counts = { restored: 0, dropped: 0, skipped: 0 };
1063
+ for (const row of rows) {
1064
+ let result;
1065
+ try {
1066
+ result = restoreRow(row);
1067
+ } catch (error) {
1068
+ result = skipRow(row, `restore failed: ${errorText(error)}`);
1069
+ }
1070
+ if (counts[result.outcome] !== undefined) counts[result.outcome] += 1;
1071
+ }
1072
+ if (rows.length > 0) {
1073
+ logger?.log?.(`[preview] restored ${counts.restored} preview(s)`
1074
+ + `${counts.dropped ? `, dropped ${counts.dropped}` : ''}`
1075
+ + `${counts.skipped ? `, kept ${counts.skipped} to check again` : ''}`);
1076
+ }
1077
+ return counts;
1078
+ }
1079
+
1080
+ restore();
1081
+
439
1082
  return {
440
1083
  settings,
441
1084
  get size() { return entries.size; },
1085
+ get keptCount() { return kept.size; },
442
1086
  create,
443
1087
  createStatic,
444
1088
  resolve,
445
1089
  recordHit,
1090
+ flushHits,
1091
+ retryPendingWrites,
1092
+ recheckKeptRows,
1093
+ maintain,
1094
+ knownTokens,
1095
+ get pendingWriteCount() { return pendingWrites.size + pendingConversationRemovals.size; },
446
1096
  markHealth,
447
1097
  close,
448
1098
  closeForConversation,