hop2 0.11.21 → 0.11.22

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.
@@ -6,7 +6,7 @@
6
6
  <!-- Terminal prompt chevron on a dark tile — reads "terminal app" at 16px. -->
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317181d'/%3E%3Cpath d='M11 9.5 L20.5 16 L11 22.5' fill='none' stroke='%23a78bfa' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
8
8
  <title>Hay</title>
9
- <script type="module" crossorigin src="/assets/index-DjSB7l7z.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-COQpy7gK.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-B8TPcotl.css">
11
11
  </head>
12
12
  <body>
package/hop CHANGED
@@ -539,6 +539,26 @@ function enforceViewBudget() {
539
539
  } catch (e) { /* a failed sweep never blocks a publish */ }
540
540
  }
541
541
 
542
+ // Does the proxied session behind a Views server row still exist? Answered
543
+ // from the ports ledger on disk (plus the in-memory store, when this is the
544
+ // daemon), so the CLI and the daemon agree.
545
+ function portSessionIsKnown(target) {
546
+ const seg = String(target || '').match(/^\/s\/([^/]+)/);
547
+ const nm = seg ? decodeURIComponent(seg[1]) : '';
548
+ if (!nm) return false;
549
+ const matches = (cfg, key) => cfg?.type === 'port' && (key === nm || cfg.displayName === nm);
550
+ for (const [key, cfg] of Object.entries(sessionStore?.sessions || {})) {
551
+ if (matches(cfg, key)) return true;
552
+ }
553
+ try {
554
+ const raw = JSON.parse(fs.readFileSync(PORTS_FILE, 'utf8'));
555
+ for (const [key, cfg] of Object.entries(raw.ports || {})) {
556
+ if (matches(cfg, key)) return true;
557
+ }
558
+ } catch (e) { /* no ledger yet */ }
559
+ return false;
560
+ }
561
+
542
562
  // The virtual folder both `hop view` and `hop port --attached` publish into:
543
563
  // clients browse every view through one manifest, REBUILT from a disk scan
544
564
  // rather than read-modify-written, so two agents publishing at once cannot
@@ -578,6 +598,15 @@ function rebuildViewManifest() {
578
598
  kind: m.kind === 'server' ? 'server' : 'file',
579
599
  target: m.kind === 'server' ? (m.target || '') : undefined,
580
600
  port: m.kind === 'server' ? m.port : undefined,
601
+ // Whether the proxied session behind a server row still
602
+ // EXISTS. A row whose session evaporated used to open the
603
+ // hop wall (proxy miss → SPA fallback) with no hint why;
604
+ // clients can now say "server gone" instead.
605
+ // Read from the ports LEDGER, not sessionStore: the
606
+ // manifest is rebuilt inside the `hop view` CLI process
607
+ // as often as inside the daemon, and the CLI's store is
608
+ // empty — every server row read dead from there.
609
+ live: m.kind === 'server' ? portSessionIsKnown(m.target) : undefined,
581
610
  // The extensionless serving path — see the /view route for
582
611
  // why the extension must stay off the URL's end.
583
612
  path: `/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}/inline`,
@@ -601,6 +630,13 @@ const SESSION_SECRET_FILE = path.join(HOME_DIR, '.session_secret');
601
630
  // evaporated on every daemon restart. Session MEMBERSHIP is persisted per
602
631
  // session in its durable meta, so a folder and its contents survive together.
603
632
  const FOLDERS_FILE = path.join(HOME_DIR, 'folders.json');
633
+ // Port sessions (`hop port`, `hop session add --port`) outside a workspace.
634
+ // The daemon boots with NO active workspace, so saveSessionStore() is a
635
+ // no-op and every port definition lived in memory only — each restart
636
+ // forgot them all while their backends kept running, and every Views row
637
+ // pointing at one silently opened the hop wall instead (the proxy route no
638
+ // longer matched, the SPA fallback answered). Same shape as folders.json.
639
+ const PORTS_FILE = path.join(HOME_DIR, 'ports.json');
604
640
  const DEFAULT_SECRET_FILE = path.join(HOME_DIR, '.auth_secret');
605
641
  const DOMAIN_CONFIG_FILE = path.join(HOME_DIR, '.domain-config.json');
606
642
  const HOP_CONFIG_FILE = path.join(HOME_DIR, '.config.json');
@@ -833,6 +869,20 @@ function toInternalSessionName(displayName) {
833
869
  return displayName;
834
870
  }
835
871
 
872
+ // Invariant session id for NEW sessions: opaque, never derived from the
873
+ // display name. Sessions created before this simply keep their original
874
+ // name as their id (the state every rename already produced), so nothing
875
+ // migrates. The id is machine-facing (HOP_SESSION, room keys, record
876
+ // files) and must never be shown to the user; the display name is the
877
+ // only name a human sees, and renaming touches only that.
878
+ function mintSessionInternalName() {
879
+ let id;
880
+ do {
881
+ id = 's_' + crypto.randomBytes(5).toString('hex');
882
+ } while (hopSessionExists(id) || hasSavedSessionConfig(id));
883
+ return id;
884
+ }
885
+
836
886
  function toDisplaySessionName(internalName) {
837
887
  if (!internalName || typeof internalName !== 'string') return '';
838
888
  return stripLegacySessionPrefix(internalName);
@@ -1023,6 +1073,7 @@ let sessionStore = initialWorkspaceState.store;
1023
1073
  const runtimeSessionMetadata = new Map();
1024
1074
  // Folders are organization the user authored; they must be there on boot.
1025
1075
  loadFolders();
1076
+ loadPorts();
1026
1077
  // internalName -> the display-name claim we last rejected for it, so the
1027
1078
  // collision guard reports each rejection once instead of every reconcile.
1028
1079
  const reportedNameCollisions = new Map();
@@ -1142,10 +1193,13 @@ function setSessionConfig(name, config) {
1142
1193
  const folderId = config?.folderId || null;
1143
1194
  ensureSessionOrder(name, folderId);
1144
1195
  saveSessionStore();
1196
+ if (config?.type === 'port') savePorts();
1145
1197
  }
1146
1198
 
1147
1199
  function deleteSessionConfig(name) {
1200
+ const wasPort = sessionStore.sessions[name]?.type === 'port';
1148
1201
  delete sessionStore.sessions[name];
1202
+ if (wasPort) savePorts();
1149
1203
  removeSessionFromOrder(name);
1150
1204
  saveSessionStore();
1151
1205
  // A deleted session should not be resurrected by `hop restore`, so drop its
@@ -1430,6 +1484,29 @@ function saveFolders() {
1430
1484
  } catch (e) { /* best effort: folders are organization, not state */ }
1431
1485
  }
1432
1486
 
1487
+ function savePorts() {
1488
+ try {
1489
+ const ports = {};
1490
+ for (const [name, cfg] of Object.entries(sessionStore.sessions)) {
1491
+ if (cfg?.type === 'port') ports[name] = cfg;
1492
+ }
1493
+ writePrivateFile(PORTS_FILE, JSON.stringify({ ports }, null, 1));
1494
+ } catch (e) { /* the in-memory definition still serves this run */ }
1495
+ }
1496
+
1497
+ function loadPorts() {
1498
+ try {
1499
+ const raw = JSON.parse(fs.readFileSync(PORTS_FILE, 'utf8'));
1500
+ for (const [name, cfg] of Object.entries(raw.ports || {})) {
1501
+ if (!cfg || cfg.type !== 'port' || !Number.isInteger(cfg.port)) continue;
1502
+ // A workspace-defined port outranks the ledger's copy.
1503
+ if (sessionStore.sessions[name]) continue;
1504
+ sessionStore.sessions[name] = cfg;
1505
+ ensureSessionOrder(name, cfg.folderId || null);
1506
+ }
1507
+ } catch (e) { /* no ports yet */ }
1508
+ }
1509
+
1433
1510
  function loadFolders() {
1434
1511
  try {
1435
1512
  const raw = JSON.parse(fs.readFileSync(FOLDERS_FILE, 'utf8'));
@@ -5020,21 +5097,40 @@ function claudeConfigRootsForRestore(recConfigDir) {
5020
5097
  // resume; the caller falls back to a plain shell with a visible warning.
5021
5098
  function resolveClaudeTranscriptPath(rec) {
5022
5099
  if (!rec || !rec.sessionId || !rec.cwd) return null;
5100
+ const findIn = (base) => {
5101
+ const file = path.join(base, `${rec.sessionId}.jsonl`);
5102
+ try { fs.accessSync(file); return file; } catch (e) { /* next */ }
5103
+ // Newer layout: a per-session DIRECTORY. Only counts when it holds a
5104
+ // real transcript jsonl: a dir with only subagents/ metadata is a
5105
+ // conversation that never persisted (resume would fail on it).
5106
+ try {
5107
+ const dir = path.join(base, rec.sessionId);
5108
+ if (fs.statSync(dir).isDirectory()) {
5109
+ const jsonl = fs.readdirSync(dir).find((f) => f.endsWith('.jsonl'));
5110
+ if (jsonl) return path.join(dir, jsonl);
5111
+ }
5112
+ } catch (e) { /* next */ }
5113
+ return null;
5114
+ };
5023
5115
  for (const root of claudeConfigRootsForRestore(rec.configDir)) {
5024
5116
  for (const slug of claudeProjectDirCandidates(rec.cwd)) {
5025
- const base = path.join(root, 'projects', slug);
5026
- const file = path.join(base, `${rec.sessionId}.jsonl`);
5027
- try { fs.accessSync(file); return file; } catch (e) { /* next */ }
5028
- // Newer layout: a per-session DIRECTORY. Only counts when it holds a
5029
- // real transcript jsonl — a dir with only subagents/ metadata is a
5030
- // conversation that never persisted (resume would fail on it).
5031
- try {
5032
- const dir = path.join(base, rec.sessionId);
5033
- if (fs.statSync(dir).isDirectory()) {
5034
- const jsonl = fs.readdirSync(dir).find((f) => f.endsWith('.jsonl'));
5035
- if (jsonl) return path.join(dir, jsonl);
5036
- }
5037
- } catch (e) { /* next */ }
5117
+ const hit = findIn(path.join(root, 'projects', slug));
5118
+ if (hit) return hit;
5119
+ }
5120
+ }
5121
+ // The cwd-derived slug is a HINT, not the identity: the session id is
5122
+ // unique across the whole store, and claude itself resumes by id. A
5123
+ // record whose cwd was healed (Accessibility-fork: conversation written
5124
+ // from inside the transcript dir, so its file lives under a doubly-
5125
+ // encoded slug) would otherwise read as "no transcript on this host"
5126
+ // and restore to a bare shell, when the transcript is right there.
5127
+ for (const root of claudeConfigRootsForRestore(rec.configDir)) {
5128
+ const projects = path.join(root, 'projects');
5129
+ let slugs = [];
5130
+ try { slugs = fs.readdirSync(projects); } catch (e) { continue; }
5131
+ for (const slug of slugs) {
5132
+ const hit = findIn(path.join(projects, slug));
5133
+ if (hit) return hit;
5038
5134
  }
5039
5135
  }
5040
5136
  return null;
@@ -5216,7 +5312,19 @@ function restorePlanForSession(entry) {
5216
5312
  // arbiter: when the record disagrees with it, the record is not this
5217
5313
  // room's primary conversation. Reopen a plain shell in the RIGHT place
5218
5314
  // rather than resume the wrong history.
5219
- const liveCwd = readClaudeSessionMeta(entry.internalName)?.cwd || entry.cwd;
5315
+ // The transcript store is never a workspace (same rule as the hook and
5316
+ // fork): a meta cwd inside ~/.claude*/projects/* means the room's shell
5317
+ // was left standing in the transcript dir at some point (the
5318
+ // Accessibility-fork incident): a poisoned arbiter. Trusting it made
5319
+ // this guard fire against a CORRECT record and drop the room to a plain
5320
+ // shell in the transcript dir, which is how the fork came back as 'zsh
5321
+ // in ~/.claude/projects/-Users-jianzhou' with its conversation gone.
5322
+ const storePath = (c) => /[\\/]\.claude[^\\/]*[\\/]projects([\\/]|$)/.test(String(c || ''));
5323
+ const metaCwd = readClaudeSessionMeta(entry.internalName)?.cwd;
5324
+ const liveCwd = (metaCwd && !storePath(metaCwd) ? metaCwd : null)
5325
+ || (entry.cwd && !storePath(entry.cwd) ? entry.cwd : null)
5326
+ || (rec?.cwd && !storePath(rec.cwd) ? rec.cwd : null)
5327
+ || metaCwd || entry.cwd;
5220
5328
  if (rec && rec.sessionId && liveCwd && rec.cwd && rec.cwd !== liveCwd) {
5221
5329
  // The incumbent record disagrees with the room's real directory. The
5222
5330
  // hook parks a rival record at <session>.other.json when it refuses
@@ -8145,7 +8253,11 @@ async function reconcileExternalRuntimeSessionsUncached() {
8145
8253
  // workspaces is a different tree entirely. That is how a session
8146
8254
  // working in ~/Code/nebula-slides came back in the slides tool's
8147
8255
  // agent-home workspace and read as "my session died".
8148
- if (room.cwd && typeof room.cwd === 'string' && durableMeta && durableMeta.cwd !== room.cwd) {
8256
+ // ...but never persist a shell that wandered INTO the transcript
8257
+ // store (~/.claude*/projects/*): that is not a workspace, and a meta
8258
+ // holding it poisons restore's cwd arbiter (Accessibility-fork).
8259
+ const roomInStore = /[\\/]\.claude[^\\/]*[\\/]projects([\\/]|$)/.test(String(room.cwd || ''));
8260
+ if (room.cwd && typeof room.cwd === 'string' && durableMeta && durableMeta.cwd !== room.cwd && !roomInStore) {
8149
8261
  writeClaudeSessionMeta(room.id, { ...durableMeta, internalName: room.id, cwd: room.cwd });
8150
8262
  }
8151
8263
  rememberExternalRuntimeSession(room.id, room.hostPort);
@@ -9541,8 +9653,11 @@ happens until someone clicks it.
9541
9653
  return;
9542
9654
  }
9543
9655
  const meta = readViewMeta(sess);
9544
- if (meta[name]?.kind === 'server') {
9545
- respond(400, { ok: false, error: 'A live server row disappears when the server stops — nothing to delete' });
9656
+ // A LIVE server row is not a file to delete — it dies with its
9657
+ // server. A DEAD one (session gone, backend forgotten) is exactly
9658
+ // the stale entry a human wants to clear, so it goes.
9659
+ if (meta[name]?.kind === 'server' && portSessionIsKnown(meta[name]?.target)) {
9660
+ respond(400, { ok: false, error: 'This server is still running — its row disappears when it stops' });
9546
9661
  return;
9547
9662
  }
9548
9663
  try {
@@ -10417,36 +10532,34 @@ happens until someone clicks it.
10417
10532
  const actorCreatedBy = resolveActor(req) === 'agent' ? 'agent' : 'user';
10418
10533
  const createdVia = resolveVia(req) || (actorCreatedBy === 'agent' ? 'agent-api' : 'web');
10419
10534
  const displayName = name;
10420
- const internalName = toInternalSessionName(displayName);
10421
- if (!internalName || !displayName) {
10535
+ if (!displayName || !toInternalSessionName(displayName)) {
10422
10536
  res.writeHead(400, { 'Content-Type': 'application/json' });
10423
10537
  res.end(JSON.stringify({ error: 'Invalid session name' }));
10424
10538
  return;
10425
10539
  }
10426
- // Durable origin marker, mirroring the terminal-API path:
10427
- // runtime config dies with the daemon, so without this an
10428
- // agent session reads as human after the next restart.
10429
- if (actorCreatedBy === 'agent') {
10430
- const durableOrigin = readClaudeSessionMeta(internalName) || {};
10431
- writeClaudeSessionMeta(internalName, {
10432
- ...durableOrigin,
10433
- internalName,
10434
- createdBy: 'agent',
10435
- createdVia,
10436
- createdAt: durableOrigin.createdAt || new Date().toISOString()
10437
- });
10438
- }
10439
-
10440
10540
  if (isDisplayNameInUse(displayName)) {
10441
10541
  res.writeHead(409, { 'Content-Type': 'application/json' });
10442
10542
  res.end(JSON.stringify({ error: 'Session name already in use' }));
10443
10543
  return;
10444
10544
  }
10445
- if (hasSavedSessionConfig(internalName)) {
10446
- res.writeHead(409, { 'Content-Type': 'application/json' });
10447
- res.end(JSON.stringify({ error: 'Session internal name already in use' }));
10448
- return;
10449
- }
10545
+ // Identity is MINTED, not derived: reusing a display name
10546
+ // that some renamed-away session was BORN with used to
10547
+ // collide with that session's internal id.
10548
+ const internalName = mintSessionInternalName();
10549
+ // Durable meta is the naming AUTHORITY (docs/
10550
+ // session-identity.md), and with minted ids it is the
10551
+ // only place the display name survives a daemon restart:
10552
+ // runtime config dies with the process, and the id can no
10553
+ // longer stand in for the name. Written for EVERY create,
10554
+ // after the refusals so a 409 writes nothing.
10555
+ writeClaudeSessionMeta(internalName, {
10556
+ internalName,
10557
+ displayName,
10558
+ createdBy: actorCreatedBy,
10559
+ createdVia,
10560
+ folderId: folderId || undefined,
10561
+ createdAt: new Date().toISOString()
10562
+ });
10450
10563
  type = (type || 'terminal').toLowerCase();
10451
10564
  if (type !== 'terminal' && type !== 'port') {
10452
10565
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -10477,7 +10590,15 @@ happens until someone clicks it.
10477
10590
  res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
10478
10591
  return;
10479
10592
  }
10480
- const existingConfig = getEffectiveSessionConfig(internalName);
10593
+ // NOT getEffectiveSessionConfig: that synthesizes
10594
+ // type:'terminal' from durable meta when nothing else
10595
+ // exists — and this create already wrote its own meta
10596
+ // above, so a brand-new port session saw ITSELF and
10597
+ // refused with "Terminal session already exists".
10598
+ // Every `hop port` with a fresh name failed this way.
10599
+ // Only a saved or runtime TERMINAL config is a real
10600
+ // collision.
10601
+ const existingConfig = getSessionConfig(internalName) || getRuntimeSessionMetadata(internalName);
10481
10602
  if (existingConfig && existingConfig.type === 'terminal') {
10482
10603
  res.writeHead(409, { 'Content-Type': 'application/json' });
10483
10604
  res.end(JSON.stringify({ error: 'Terminal session already exists with this name' }));
@@ -10548,7 +10669,7 @@ happens until someone clicks it.
10548
10669
  }
10549
10670
  }
10550
10671
  res.writeHead(200, { 'Content-Type': 'application/json' });
10551
- res.end(JSON.stringify({ ok: true, name: displayName, type }));
10672
+ res.end(JSON.stringify({ ok: true, name: displayName, displayName, internalName, type }));
10552
10673
  } catch (e) {
10553
10674
  const status = Number.isInteger(e?.status) ? e.status : 500;
10554
10675
  const message = e?.message || 'Failed to create session';
@@ -10972,7 +11093,9 @@ happens until someone clicks it.
10972
11093
  res.end(JSON.stringify({ error: 'Session name already in use' }));
10973
11094
  return;
10974
11095
  }
10975
- const internalName = toInternalSessionName(displayName);
11096
+ // Minted, not derived from the display name (see
11097
+ // mintSessionInternalName): renames stay label-only.
11098
+ const internalName = mintSessionInternalName();
10976
11099
  if (!internalName) {
10977
11100
  res.writeHead(400, { 'Content-Type': 'application/json' });
10978
11101
  res.end(JSON.stringify({ error: 'Invalid session name' }));
@@ -11023,12 +11146,19 @@ happens until someone clicks it.
11023
11146
  folderId,
11024
11147
  workspaceName: targetWorkspaceName || null
11025
11148
  });
11026
- // Durable origin marker so `hop restore` (which may run after a
11027
- // reboot, long after runtime metadata is gone) can keep
11028
- // agent-created workers out of the default restore set.
11029
- if (actor === 'agent') {
11030
- writeClaudeSessionMeta(internalName, { createdBy: 'agent', createdAt: new Date().toISOString() });
11031
- }
11149
+ // Durable meta for EVERY create (naming authority; see
11150
+ // docs/session-identity.md): with minted ids the display
11151
+ // name survives restarts nowhere else. createdBy rides
11152
+ // along so `hop restore` keeps agent workers out of the
11153
+ // default restore set, as before.
11154
+ writeClaudeSessionMeta(internalName, {
11155
+ internalName,
11156
+ displayName,
11157
+ createdBy: actor,
11158
+ createdVia: resolveVia(req),
11159
+ folderId: folderId || undefined,
11160
+ createdAt: new Date().toISOString()
11161
+ });
11032
11162
 
11033
11163
  const entry = await attachTerminalApiClient(internalName, displayName, {
11034
11164
  actor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.21",
3
+ "version": "0.11.22",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -54,6 +54,31 @@ const FALLBACK_CWD = process.env.HAY_HOST_CWD || process.cwd();
54
54
  // Where we stash each room's tail buffer on shutdown so `hop restore` can replay
55
55
  // a plain-shell session's last screen. Mirrors the daemon's HOP_HOME (~/.hop2).
56
56
  const HOP_HOME = process.env.HOP_HOME || path.join(os.homedir(), '.hop2');
57
+
58
+ // EPHEMERAL homes die with their daemon. Integration tests spawn a daemon in
59
+ // a mkdtemp HOP_HOME, which spawns this host — and a test run killed
60
+ // mid-flight (a timeout, a crash) leaked the host forever: 18 of them were
61
+ // found idling with 242MB after a week of test runs. A host under the OS
62
+ // temp dir polls its daemon's pid from .tunnel-state and exits once it has
63
+ // been gone for two checks. A REAL home never does this: the production
64
+ // host deliberately outlives daemon restarts — that is what keeps sessions
65
+ // alive through deploys.
66
+ if (HOP_HOME.startsWith(os.tmpdir())) {
67
+ let daemonMissing = 0;
68
+ setInterval(() => {
69
+ let pid = null;
70
+ try { pid = Number(JSON.parse(fs.readFileSync(path.join(HOP_HOME, '.tunnel-state'), 'utf8')).pid); } catch (e) { /* not written yet */ }
71
+ let alive = false;
72
+ if (Number.isInteger(pid) && pid > 0) {
73
+ try { process.kill(pid, 0); alive = true; } catch (e) { /* dead */ }
74
+ }
75
+ daemonMissing = alive ? 0 : daemonMissing + 1;
76
+ if (daemonMissing >= 2) {
77
+ console.error('[hay-host] ephemeral home and the daemon is gone — exiting');
78
+ process.exit(0);
79
+ }
80
+ }, 30000).unref();
81
+ }
57
82
  const BUFFER_DIR = path.join(HOP_HOME, 'session-buffers');
58
83
  const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
59
84
 
@@ -382,7 +407,13 @@ async function main() {
382
407
  const room = watchRoomEnd(rooms.getRoom(roomId, { cols, rows }, existingCwdOr(cwd, FALLBACK_CWD)));
383
408
  if (Date.now() - __t0 > 100) console.log(`[hay-host] slow room create (ws) room=${roomId} ${Date.now() - __t0}ms`);
384
409
 
385
- const replayRaw = Number(wsUrl.searchParams.get('replay'));
410
+ // ABSENT is not ZERO: Number(null) === 0, and 0 is the explicit
411
+ // "no snapshot" contract for claim sockets — so every attach that
412
+ // simply omitted replay= (salvage, CLI reattach) silently got no
413
+ // screen: the 2026-08-17 host cycle salvaged 23 rooms and captured
414
+ // nothing, each waiting out a 20s timeout (the 8-minute freeze).
415
+ const replayParam = wsUrl.searchParams.get('replay');
416
+ const replayRaw = replayParam === null || replayParam === '' ? NaN : Number(replayParam);
386
417
  // The viewer's actual terminal colors (hex, no '#'). TUI apps pick a
387
418
  // light or dark theme from the background they're told — so a room
388
419
  // must report the background the USER is looking at, not a guess.
@@ -50,7 +50,12 @@ const stripAnsi = (raw) => String(raw)
50
50
 
51
51
  // Capture one room's snapshot (the ~1.5MB replay tail) over WS.
52
52
  const salvageRoom = (port, id) => new Promise((resolve) => {
53
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?room=${encodeURIComponent(id)}&name=salvage&cols=200&rows=50`);
53
+ // Explicit replay: an ABSENT param read as 0 ("no snapshot") on the host
54
+ // (Number(null)===0), and every room timed out. source=monitor + nudge=0:
55
+ // salvage is a spectator — no equal-size wiggle SIGWINCHed into 23 running
56
+ // claudes, no presence stamp, no seen-witness. Raw tail (above the
57
+ // serialized ceiling) so the salvaged text is scrollback, not one screen.
58
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?room=${encodeURIComponent(id)}&name=salvage&source=monitor&replay=1572864&nudge=0&cols=200&rows=50`);
54
59
  const timer = setTimeout(() => { try { ws.close(); } catch (e) {} resolve('timeout'); }, 20000);
55
60
  ws.on('message', (data) => {
56
61
  try {