hop2 0.11.0 → 0.11.2

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 (82) hide show
  1. package/.github/workflows/ci.yml +25 -0
  2. package/.github/workflows/publish.yml +130 -0
  3. package/DESIGN-input-acks.md +349 -0
  4. package/MULTI-AGENT-CHECKLIST.md +5 -5
  5. package/README-MCP.md +84 -5
  6. package/README.md +55 -4
  7. package/demo/README.md +52 -0
  8. package/demo/capture/capture-env.mjs +108 -0
  9. package/demo/capture/capture.mjs +634 -0
  10. package/demo/capture/dispatch-task.mjs +44 -0
  11. package/demo/capture/mcp-client.mjs +61 -0
  12. package/demo/capture/native-css.mjs +49 -0
  13. package/demo/capture/setup-04.mjs +67 -0
  14. package/demo/capture/setup-sessions.mjs +40 -0
  15. package/demo/capture/spawn-aurora.mjs +53 -0
  16. package/demo/capture/tools/agent.sh +30 -0
  17. package/demo/capture/tools/build.sh +18 -0
  18. package/demo/capture/tools/metrics.sh +17 -0
  19. package/docs/hero-overview.svg +119 -0
  20. package/docs/hero.svg +118 -0
  21. package/docs/restore-reliability.md +139 -0
  22. package/docs/session-identity.md +76 -0
  23. package/docs/switcher-first-ux.md +103 -0
  24. package/hay/CHANGELOG.md +59 -1
  25. package/hay/apps/cli/package.json +3 -2
  26. package/hay/apps/cli/src/index.ts +705 -28
  27. package/hay/apps/server/src/pty.ts +36 -2
  28. package/hay/apps/server/src/rooms.ts +525 -34
  29. package/hay/apps/server/test/rooms.test.ts +411 -1
  30. package/hay/apps/web/index.html +2 -0
  31. package/hay/apps/web/package.json +14 -12
  32. package/hay/apps/web/scripts/run-e2e.mjs +42 -5
  33. package/hay/apps/web/src/App.tsx +2492 -281
  34. package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
  35. package/hay/apps/web/src/components/MobileKeyboard.tsx +135 -14
  36. package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
  37. package/hay/apps/web/src/components/SessionSwitcher.tsx +2525 -0
  38. package/hay/apps/web/src/setupTests.ts +23 -0
  39. package/hay/apps/web/src/styles.css +2460 -180
  40. package/hay/apps/web/src/utils/fileDrop.ts +15 -0
  41. package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
  42. package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
  43. package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
  44. package/hay/apps/web/src/utils/switcherModel.ts +314 -0
  45. package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
  46. package/hay/apps/web/src/vite-env.d.ts +3 -0
  47. package/hay/apps/web/test/SessionSwitcher.test.tsx +324 -0
  48. package/hay/apps/web/test/fileDrop.test.ts +22 -0
  49. package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
  50. package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
  51. package/hay/apps/web/test/switcherModel.test.ts +377 -0
  52. package/hay/apps/web/test/terminalSearch.test.ts +37 -0
  53. package/hay/apps/web/vite.config.ts +20 -0
  54. package/hay/package-lock.json +1696 -1171
  55. package/hay/packages/shared/src/protocol.ts +25 -2
  56. package/hay-web/assets/index-BohAWpcL.css +1 -0
  57. package/hay-web/assets/index-DfGT3DuO.js +130 -0
  58. package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
  59. package/hay-web/index.html +4 -2
  60. package/hop +4348 -302
  61. package/hopa +465 -0
  62. package/mcp/hop-mcp.js +2878 -122
  63. package/package.json +7 -4
  64. package/scripts/claude-session-hook.js +152 -0
  65. package/scripts/hay-host.js +227 -4
  66. package/scripts/hop-math.js +316 -0
  67. package/scripts/salvage-and-restart-host.js +217 -0
  68. package/skills/hop-manager/SKILL.md +62 -0
  69. package/skills/hop-session/SKILL.md +42 -0
  70. package/skills/hopa/SKILL.md +75 -0
  71. package/tests/agent-skill.unit.test.cjs +60 -0
  72. package/tests/composer-state.unit.test.cjs +102 -0
  73. package/tests/hop-mcp.integration.test.cjs +476 -94
  74. package/tests/hopa-parity.unit.test.cjs +188 -0
  75. package/tests/readable-echo.unit.test.cjs +94 -0
  76. package/tests/readable-flush.unit.test.cjs +93 -0
  77. package/tests/submit-verification.unit.test.cjs +331 -0
  78. package/tests/terminal-api.integration.test.cjs +301 -13
  79. package/hay-web/assets/index-BzJB9r_I.js +0 -64
  80. package/hay-web/assets/index-BzJB9r_I.js.map +0 -1
  81. package/hay-web/assets/index-DG8Ntznc.css +0 -32
  82. package/sessions.html +0 -726
@@ -18,48 +18,108 @@ export type SocketAdapter = {
18
18
  export type ClientInfo = {
19
19
  id: string;
20
20
  name: string;
21
+ // "local-cli" = hop CLI attach; "monitor" = read-only live tile (session
22
+ // switcher wall): excluded from presence and client counts, and given a
23
+ // small snapshot so a wall of tiles doesn't pull megabytes per open.
21
24
  source?: string;
22
25
  colorIndex: number;
23
26
  cols: number;
24
27
  rows: number;
28
+ // Optional per-connection cap for the join snapshot (clamped to the
29
+ // room-level bound). Monitors request ~64KB.
30
+ replayBytes?: number;
31
+ // False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
32
+ // nudge). Wall tiles decline it: they arrive already showing the current
33
+ // grid (the daemon's headless screen, complete — not a raw byte tail), so
34
+ // the nudge's two SIGWINCHes only produce a visible reflow of the running
35
+ // app at the exact moment the tile goes live — the "twitch".
36
+ nudge?: boolean;
25
37
  };
26
38
 
27
39
  export type RoomCreateOptions = {
28
40
  cwd: string;
29
41
  env?: Record<string, string>;
30
42
  shell?: string;
43
+ // Raw terminal output to pre-load into the room's buffer before the PTY
44
+ // starts producing data. Used by `hop restore` to replay a plain-shell
45
+ // session's last screen (so reopening it picks up where it left off) ahead of
46
+ // the fresh shell's first prompt. Clamped to the buffer cap like live output.
47
+ seedOutput?: string;
48
+ // Launch the room ON this command (passed to the shell as argv) instead of
49
+ // typing it in afterwards — see PtyFactoryOptions.command.
50
+ command?: string;
31
51
  };
32
52
 
33
53
  export type RoomSummary = {
34
54
  id: string;
35
55
  cwd: string;
36
56
  liveCwd: string;
57
+ foregroundProcess: string;
58
+ lastActivityAt: number;
59
+ bellSeq: number;
60
+ lastBellAt: number;
37
61
  clientCount: number;
38
62
  localCliCount: number;
39
63
  };
40
64
 
41
- // Raw output retained for reattach snapshots. ~2MB of raw stream reconstructs
42
- // roughly a full client scrollback (2000-5000 lines) of plain output; TUI
43
- // streams are escape-dense and reconstruct less. Override with
65
+ // Raw output retained for reattach snapshots. ~20MB of raw stream reconstructs
66
+ // roughly a full client scrollback (tens of thousands of lines) of plain
67
+ // output; TUI streams are escape-dense and reconstruct less. This is the whole
68
+ // buffer replayed (and parsed by the client) on reattach, so very large values
69
+ // trade memory + reattach-parse time for deeper restored history. Override with
44
70
  // HAY_SNAPSHOT_BUFFER_BYTES (bytes of raw stream kept per room).
45
71
  const MAX_BUFFER_SIZE = (() => {
46
72
  const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
47
- return Number.isFinite(env) && env > 0 ? env : 2_000_000;
73
+ return Number.isFinite(env) && env > 0 ? env : 20_000_000;
48
74
  })();
49
75
  // Keep rooms alive indefinitely; only explicit kill/remove should end a session.
50
76
  const CLEANUP_DELAY_MS = 0;
77
+ // Join-time replay bound: the room keeps up to MAX_BUFFER_SIZE of history,
78
+ // but replaying tens of MB through the client's parser on every open/switch
79
+ // is what made sessions feel slow to load (a phone on a tunnel downloads and
80
+ // parses all of it before first paint). Send a bounded tail instead; the cut
81
+ // avoids starting mid-line so a partial escape sequence can't corrupt the
82
+ // replay. Alt-screen apps redraw fully, and mode flags (alternateScreen,
83
+ // cursorHidden, keyboardEnhanced) travel separately, so a tail is safe.
84
+ const SNAPSHOT_REPLAY_BYTES = (() => {
85
+ const env = Number(process.env.HAY_SNAPSHOT_REPLAY_BYTES);
86
+ return Number.isFinite(env) && env > 0 ? env : 1_500_000;
87
+ })();
88
+
89
+ const boundSnapshotReplay = (buffer: string): string => {
90
+ if (buffer.length <= SNAPSHOT_REPLAY_BYTES) return buffer;
91
+ let tail = buffer.slice(buffer.length - SNAPSHOT_REPLAY_BYTES);
92
+ const nl = tail.indexOf("\n");
93
+ if (nl !== -1 && nl < 8192) {
94
+ tail = tail.slice(nl + 1);
95
+ }
96
+ return tail;
97
+ };
98
+
99
+ // Autofit-on-attach: a resize may claim the shared size when every OTHER
100
+ // client has been input-idle at least this long. Someone actively typing keeps
101
+ // the size they set; a viewer opening the session after everyone went idle
102
+ // takes it over immediately instead of waiting to type first.
103
+ const RESIZE_CLAIM_IDLE_MS = (() => {
104
+ const env = Number(process.env.HAY_RESIZE_CLAIM_IDLE_MS);
105
+ return Number.isFinite(env) && env >= 0 ? env : 60_000;
106
+ })();
107
+ // A claim:"attach" resize (a fresh client's first autofit) is deliberate:
108
+ // someone just opened the session there. It takes the shared size unless a
109
+ // peer typed within this window — only an actively-typing peer holds it.
110
+ // 2.5s (was 5s): an actively typing peer keeps refreshing its own window, so
111
+ // this only decides how long a PAUSED typist blocks a deliberate new attach —
112
+ // measured 5s made wall-tile focus feel stuck (converged at ~5s through the
113
+ // tunnel; ~3s with this).
114
+ const ATTACH_CLAIM_IDLE_MS = (() => {
115
+ const env = Number(process.env.HAY_ATTACH_CLAIM_IDLE_MS);
116
+ return Number.isFinite(env) && env >= 0 ? env : 2_500;
117
+ })();
51
118
  const CONTROL_SEQUENCE_TAIL = 32;
52
119
  const DEBUG_STATE = process.env.HAY_DEBUG === "1";
53
120
 
54
121
  const now = () => Date.now();
55
122
 
56
- const clampBuffer = (value: string) => {
57
- if (value.length <= MAX_BUFFER_SIZE) {
58
- return value;
59
- }
60
- return value.slice(value.length - MAX_BUFFER_SIZE);
61
- };
62
-
63
123
  // Build a useful error for a payload that failed schema validation: name the
64
124
  // message type and the offending field instead of a bare "Invalid message".
65
125
  const describeInvalidMessage = (payload: string): string => {
@@ -124,14 +184,48 @@ export class Room extends EventEmitter {
124
184
  private pty: IPty;
125
185
  private readonly initialCwd: string;
126
186
  private liveCwd: string;
187
+ private foregroundProcess = "";
188
+ private lastActivityAt = now(); // ms of the most recent PTY output
189
+ // Terminal bells (BEL outside escape sequences) rung by the app — agents and
190
+ // long jobs ring one to ask for attention. Monotonic counter + timestamp so
191
+ // clients can diff against a last-seen value; never reset for a room's life.
192
+ private bellSeq = 0;
193
+ private lastBellAt = 0;
194
+ // Whether the previous output chunk ended inside an unterminated OSC (whose
195
+ // BEL terminator must not count as a bell) — see trackBells.
196
+ private inOscForBells = false;
197
+ private bellEscCarry = false;
127
198
  private clients = new Map<string, ClientState>();
128
199
  private collabMode = true;
129
200
  private controllerId: string | null = null;
130
201
  private activeClientId: string | null = null;
131
202
  private activeCols: number;
132
203
  private activeRows: number;
133
- private outputBuffer = "";
204
+ // Retained raw output as a chunk ring: append is O(1) and trimming drops
205
+ // whole chunks from the head. NEVER joined in full on the hot path — a flat
206
+ // string here meant every chunk past the 20MB cap re-copied the entire
207
+ // buffer (≈3ms of blocked event loop per 2KB chunk, measured), which froze
208
+ // the host for minutes under a busy fleet and got it killed as unresponsive.
209
+ private outputChunks: string[] = [];
210
+ private outputBytes = 0;
211
+ // Absolute stream offset (UTF-16 units) of the FIRST retained char — i.e.
212
+ // how much has been trimmed off the head over the room's lifetime. Lets
213
+ // getOutputSince hand out stable cursors into an ever-trimming ring.
214
+ private outputStart = 0;
215
+ // Last viewer's terminal colors (6-hex, no '#'), used to answer OSC 10/11
216
+ // while headless. Defaults to the web client's dark surface.
217
+ private clientBg = "0d1117";
218
+ private clientFg = "e6edf3";
134
219
  private alternateScreen = false;
220
+ // Enhanced keyboard reporting requested by the remote app (kitty keyboard protocol
221
+ // or xterm modifyOtherKeys). Tracked like alternateScreen so a reattaching client
222
+ // can restore it on its real terminal (see snapshot below).
223
+ private keyboardEnhanced = false;
224
+ // Mouse tracking requested by the remote app (any of ?1000/1002/1003) and
225
+ // whether it asked for SGR encoding (?1006). Snapshotted so a reattaching
226
+ // client can scroll the app with wheel events instead of Page keys.
227
+ private mouseReporting = false;
228
+ private mouseSgr = false;
135
229
  private cursorHidden = false;
136
230
  private controlSequenceTail = "";
137
231
  private oscBuffer = "";
@@ -151,15 +245,29 @@ export class Room extends EventEmitter {
151
245
  cols: initialSize.cols,
152
246
  rows: initialSize.rows,
153
247
  cwd: options.cwd,
154
- env: options.env,
155
- shell: options.shell
248
+ // Tag every session's environment with its room id so a Claude SessionStart
249
+ // hook can map a running claude conversation back to this hop session (used
250
+ // by `hop restore` to resume the exact conversation per session).
251
+ env: { ...(options.env || {}), HOP_SESSION: id },
252
+ shell: options.shell,
253
+ command: options.command
156
254
  });
157
255
  this.activeCols = initialSize.cols;
158
256
  this.activeRows = initialSize.rows;
159
257
 
258
+ // Restore replay: pre-load the previous session's tail output so the first
259
+ // client to attach sees the restored screen (via the snapshot) before the
260
+ // fresh shell repaints. This is raw bytes that already happened, so we only
261
+ // stage it into the buffer — we don't re-broadcast or re-parse it.
262
+ if (options.seedOutput) {
263
+ this.appendOutput(options.seedOutput);
264
+ }
265
+
160
266
  this.pty.onData((data: string) => {
161
- this.outputBuffer = clampBuffer(this.outputBuffer + data);
267
+ this.lastActivityAt = now();
268
+ this.appendOutput(data);
162
269
  this.updateTerminalState(data);
270
+ this.answerTerminalQueriesIfHeadless(data);
163
271
  this.broadcast({ type: "output", data });
164
272
  this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
165
273
  });
@@ -170,24 +278,44 @@ export class Room extends EventEmitter {
170
278
  console.log(
171
279
  `[hay] PTY exit room=${this.id} code=${normalizedExit ?? "null"} signal=${normalizedSignal ?? "null"}`
172
280
  );
173
- this.endSession({
281
+ // Spurious-exit guard: an exit event with the shell still alive must not
282
+ // end the session — a wrongly `ended` room keeps serving output but its
283
+ // cwd poll and cleanup are permanently dead (observed live: a restored
284
+ // session whose cwd froze for hours while the shell ran fine). If the
285
+ // pid still exists shortly after the event, ignore it.
286
+ const pid = (this.pty as unknown as { pid?: number }).pid;
287
+ const finish = () => this.endSession({
174
288
  exitCode: normalizedExit,
175
289
  signal: normalizedSignal,
176
290
  message: "Session ended"
177
291
  });
292
+ if (!pid) {
293
+ finish();
294
+ return;
295
+ }
296
+ setTimeout(() => {
297
+ try {
298
+ process.kill(pid, 0); // throws when the process is gone
299
+ console.log(`[hay] room=${this.id} ignoring spurious exit event (pid ${pid} still alive)`);
300
+ } catch {
301
+ finish();
302
+ }
303
+ }, 150);
178
304
  };
179
305
 
180
306
  if (typeof this.pty.onExit === "function") {
181
307
  this.pty.onExit((event) => {
182
308
  handleExit(event.exitCode, event.signal);
183
309
  });
184
- }
185
-
186
- const legacyOn = (this.pty as unknown as { on?: (event: string, handler: (...args: any[]) => void) => void }).on;
187
- if (typeof legacyOn === "function") {
188
- legacyOn.call(this.pty, "exit", (exitCode: number, signal?: number) => {
189
- handleExit(exitCode, signal);
190
- });
310
+ } else {
311
+ // Legacy fallback ONLY when onExit is unavailable — registering both
312
+ // fired every real exit twice.
313
+ const legacyOn = (this.pty as unknown as { on?: (event: string, handler: (...args: any[]) => void) => void }).on;
314
+ if (typeof legacyOn === "function") {
315
+ legacyOn.call(this.pty, "exit", (exitCode: number, signal?: number) => {
316
+ handleExit(exitCode, signal);
317
+ });
318
+ }
191
319
  }
192
320
 
193
321
  // Start PID-based cwd polling as fallback for shells without OSC 7
@@ -226,15 +354,47 @@ export class Room extends EventEmitter {
226
354
  } satisfies ServerMessage)
227
355
  );
228
356
 
229
- if (this.outputBuffer) {
357
+ if (this.outputBytes > 0) {
230
358
  socket.send(JSON.stringify({
231
359
  type: "snapshot",
232
- data: this.outputBuffer,
360
+ data: boundSnapshotReplay(this.tailOutput(
361
+ Number.isFinite(info.replayBytes) && (info.replayBytes as number) > 0
362
+ ? Math.min(SNAPSHOT_REPLAY_BYTES, Math.floor(info.replayBytes as number))
363
+ : SNAPSHOT_REPLAY_BYTES
364
+ )),
233
365
  alternateScreen: this.alternateScreen,
234
- cursorHidden: this.cursorHidden
366
+ cursorHidden: this.cursorHidden,
367
+ keyboardEnhanced: this.keyboardEnhanced,
368
+ mouseReporting: this.mouseReporting,
369
+ mouseSgr: this.mouseSgr
235
370
  } satisfies ServerMessage));
236
371
  }
237
372
 
373
+ // The snapshot is a raw byte TAIL, and incremental TUIs (Claude Code/Ink)
374
+ // never re-emit rows that don't change — e.g. the input box's horizontal
375
+ // border lines — so the replay can be missing them. When this client's
376
+ // size differs from the PTY's, the attach resize triggers a full repaint
377
+ // anyway; when sizes are EQUAL nothing would repaint, so nudge: a one-
378
+ // column wiggle (tmux's redraw trick) makes full-screen apps repaint.
379
+ if (
380
+ info.nudge !== false &&
381
+ client.cols === this.activeCols &&
382
+ client.rows === this.activeRows &&
383
+ this.activeCols > 4 &&
384
+ !this.ended
385
+ ) {
386
+ const cols = this.activeCols;
387
+ const rows = this.activeRows;
388
+ try { this.pty.resize(cols - 1, rows); } catch { /* pty may be gone */ }
389
+ setTimeout(() => {
390
+ if (this.ended) return;
391
+ // Only bounce back if nobody claimed a different size meanwhile.
392
+ if (this.activeCols === cols && this.activeRows === rows) {
393
+ try { this.pty.resize(cols, rows); } catch { /* pty may be gone */ }
394
+ }
395
+ }, 60);
396
+ }
397
+
238
398
  // Send current cwd to the new client
239
399
  socket.send(JSON.stringify({
240
400
  type: "cwd_changed",
@@ -272,7 +432,7 @@ export class Room extends EventEmitter {
272
432
  this.handleInput(client, message.data);
273
433
  break;
274
434
  case "resize":
275
- this.handleResize(client, message.cols, message.rows);
435
+ this.handleResize(client, message.cols, message.rows, message.claim, message.user === true);
276
436
  break;
277
437
  case "typing":
278
438
  client.typing = message.active;
@@ -316,6 +476,8 @@ export class Room extends EventEmitter {
316
476
  let nextAlternate = this.alternateScreen;
317
477
  let nextCursorHidden = this.cursorHidden;
318
478
 
479
+ let nextMouseReporting = this.mouseReporting;
480
+ let nextMouseSgr = this.mouseSgr;
319
481
  while ((match = regex.exec(combined)) !== null) {
320
482
  const params = match[1].split(";").filter(Boolean);
321
483
  const mode = match[2];
@@ -324,11 +486,36 @@ export class Room extends EventEmitter {
324
486
  nextCursorHidden = mode === "l";
325
487
  } else if (param === "47" || param === "1047" || param === "1049") {
326
488
  nextAlternate = mode === "h";
489
+ } else if (param === "1000" || param === "1002" || param === "1003") {
490
+ // Mouse tracking (any flavor) — lets a reattaching client know it can
491
+ // scroll the app with wheel events instead of coarse Page keys.
492
+ nextMouseReporting = mode === "h";
493
+ } else if (param === "1006") {
494
+ nextMouseSgr = mode === "h";
327
495
  }
328
496
  }
329
497
  }
330
498
 
499
+ // Enhanced keyboard reporting: kitty keyboard protocol (CSI >flags u push /
500
+ // CSI <n u pop / CSI =flags u set) and xterm modifyOtherKeys (CSI >4;n m).
501
+ let nextKeyboardEnhanced = this.keyboardEnhanced;
502
+ const kbdRegex = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
503
+ let kbdMatch: RegExpExecArray | null;
504
+ while ((kbdMatch = kbdRegex.exec(combined)) !== null) {
505
+ if (kbdMatch[1] !== undefined) {
506
+ nextKeyboardEnhanced = kbdMatch[1] !== "<"; // push/set = on, pop = off
507
+ } else {
508
+ const parts = (kbdMatch[2] ?? "").split(";");
509
+ if (parts[0] === "4") nextKeyboardEnhanced = (parts[1] ?? "0") !== "0";
510
+ }
511
+ }
512
+
331
513
  let stateChanged = false;
514
+ if (nextMouseReporting !== this.mouseReporting || nextMouseSgr !== this.mouseSgr) {
515
+ this.mouseReporting = nextMouseReporting;
516
+ this.mouseSgr = nextMouseSgr;
517
+ stateChanged = true;
518
+ }
332
519
  if (nextAlternate !== this.alternateScreen) {
333
520
  if (DEBUG_STATE) {
334
521
  console.log(`[hay] room=${this.id} alternateScreen=${nextAlternate}`);
@@ -336,6 +523,13 @@ export class Room extends EventEmitter {
336
523
  this.alternateScreen = nextAlternate;
337
524
  stateChanged = true;
338
525
  }
526
+ if (nextKeyboardEnhanced !== this.keyboardEnhanced) {
527
+ if (DEBUG_STATE) {
528
+ console.log(`[hay] room=${this.id} keyboardEnhanced=${nextKeyboardEnhanced}`);
529
+ }
530
+ this.keyboardEnhanced = nextKeyboardEnhanced;
531
+ stateChanged = true;
532
+ }
339
533
  if (nextCursorHidden !== this.cursorHidden) {
340
534
  if (DEBUG_STATE) {
341
535
  console.log(`[hay] room=${this.id} cursorHidden=${nextCursorHidden}`);
@@ -358,9 +552,70 @@ export class Room extends EventEmitter {
358
552
  this.parseOsc7(data);
359
553
  }
360
554
 
555
+ this.trackBells(data);
556
+
361
557
  this.controlSequenceTail = combined.slice(-CONTROL_SEQUENCE_TAIL);
362
558
  }
363
559
 
560
+ // Count attention bells: BEL bytes that are NOT the terminator of an OSC
561
+ // sequence (title updates, OSC 7 cwd reports, clipboard writes all end in
562
+ // BEL and must not register). Tracks OSC state across chunk boundaries; a
563
+ // trailing lone ESC is carried so a split "\x1b]" still opens an OSC.
564
+ private trackBells(data: string) {
565
+ let chunk = data;
566
+ if (this.bellEscCarry) {
567
+ chunk = "\x1b" + chunk;
568
+ this.bellEscCarry = false;
569
+ }
570
+ if (!this.inOscForBells && chunk.indexOf("\x07") === -1) {
571
+ // Fast path: no bell to classify; just keep the cross-chunk OSC state.
572
+ const lastOsc = chunk.lastIndexOf("\x1b]");
573
+ if (lastOsc !== -1) {
574
+ const tail = chunk.slice(lastOsc);
575
+ this.inOscForBells = tail.indexOf("\x07") === -1 && tail.indexOf("\x1b\\") === -1;
576
+ }
577
+ if (chunk.endsWith("\x1b")) this.bellEscCarry = true;
578
+ return;
579
+ }
580
+ let bells = 0;
581
+ let i = 0;
582
+ const len = chunk.length;
583
+ while (i < len) {
584
+ if (this.inOscForBells) {
585
+ const bel = chunk.indexOf("\x07", i);
586
+ const st = chunk.indexOf("\x1b\\", i);
587
+ if (bel === -1 && st === -1) {
588
+ i = len;
589
+ break;
590
+ }
591
+ if (bel !== -1 && (st === -1 || bel < st)) {
592
+ this.inOscForBells = false;
593
+ i = bel + 1;
594
+ } else {
595
+ this.inOscForBells = false;
596
+ i = st + 2;
597
+ }
598
+ } else {
599
+ const bel = chunk.indexOf("\x07", i);
600
+ const osc = chunk.indexOf("\x1b]", i);
601
+ if (bel === -1 && osc === -1) break;
602
+ if (bel !== -1 && (osc === -1 || bel < osc)) {
603
+ bells += 1;
604
+ i = bel + 1;
605
+ } else {
606
+ this.inOscForBells = true;
607
+ i = osc + 2;
608
+ }
609
+ }
610
+ }
611
+ if (!this.inOscForBells && chunk.endsWith("\x1b")) this.bellEscCarry = true;
612
+ if (bells > 0) {
613
+ this.bellSeq += bells;
614
+ this.lastBellAt = now();
615
+ this.emit("bell", { roomId: this.id, bellSeq: this.bellSeq, timestamp: this.lastBellAt });
616
+ }
617
+ }
618
+
364
619
  // Regex: matches OSC 7 with BEL (\x07) or ST (\x1b\\) terminator
365
620
  private static readonly OSC7_RE = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g;
366
621
 
@@ -438,6 +693,12 @@ export class Room extends EventEmitter {
438
693
 
439
694
  const poll = () => {
440
695
  if (this.ended) return;
696
+ // node-pty exposes the foreground process name (already short, e.g. "vim",
697
+ // "node", "claude"); cheap getter, sampled on the same cadence as cwd.
698
+ const fg = (this.pty as unknown as { process?: string }).process;
699
+ if (typeof fg === "string" && fg) {
700
+ this.foregroundProcess = fg.replace(/^-/, ""); // strip login-shell "-zsh" dash
701
+ }
441
702
  if (process.platform === "darwin") {
442
703
  execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { timeout: 5000 }, (err, stdout) => {
443
704
  if (err || !stdout) return;
@@ -512,7 +773,7 @@ export class Room extends EventEmitter {
512
773
  this.emit("pty_input", { roomId: this.id, clientId: client.id, data, timestamp: now() });
513
774
  }
514
775
 
515
- private handleResize(client: ClientState, cols: number, rows: number) {
776
+ private handleResize(client: ClientState, cols: number, rows: number, claim?: "attach", userClaim = false) {
516
777
  if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
517
778
  if (cols < 1 || cols > 500 || rows < 1 || rows > 200) return;
518
779
 
@@ -527,15 +788,47 @@ export class Room extends EventEmitter {
527
788
  // 0) only wins when no one else has typed either — so the first/sole client
528
789
  // can still size the PTY before typing.
529
790
  const maxInputAt = Math.max(...[...this.clients.values()].map((c) => c.lastInputAt));
530
- const isActive = client.lastInputAt >= maxInputAt;
791
+ const othersMaxInputAt = Math.max(
792
+ 0,
793
+ ...[...this.clients.values()].filter((c) => c.id !== client.id).map((c) => c.lastInputAt)
794
+ );
795
+ const claimIdleMs = claim === "attach" ? ATTACH_CLAIM_IDLE_MS : RESIZE_CLAIM_IDLE_MS;
796
+ // A user-flagged claim is a deliberate human act (clicking a wall tile,
797
+ // switching to the session): it wins outright. Anyone actively typing
798
+ // elsewhere reclaims with their next keystroke, so misfires self-heal.
799
+ const isActive = userClaim ||
800
+ client.lastInputAt >= maxInputAt || now() - othersMaxInputAt > claimIdleMs;
531
801
  if (isActive) {
532
802
  this.pty.resize(cols, rows);
533
803
  this.activeCols = cols;
534
804
  this.activeRows = rows;
535
805
  this.activeClientId = client.id;
536
- // Broadcast the new active size to other clients so they can adjust
806
+ // Broadcast the new active size to other clients so they can adjust —
807
+ // and CONFIRM to the winner: a claimant otherwise learns nothing on
808
+ // success and can only distinguish accept from reject by timeout.
537
809
  this.broadcastActiveSize(client);
810
+ client.socket.send(
811
+ JSON.stringify({
812
+ type: "active_size",
813
+ clientId: client.id,
814
+ cols,
815
+ rows
816
+ } satisfies ServerMessage)
817
+ );
538
818
  this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
819
+ } else if (cols !== this.activeCols || rows !== this.activeRows) {
820
+ // The resize lost the election (a passive viewer auto-fitted itself).
821
+ // Snap that client back to the shared size — otherwise its local
822
+ // terminal re-wraps the buffer at a size the PTY isn't, and it has no
823
+ // way to learn the real size until the next winning resize.
824
+ client.socket.send(
825
+ JSON.stringify({
826
+ type: "active_size",
827
+ clientId: this.activeClientId ?? client.id,
828
+ cols: this.activeCols,
829
+ rows: this.activeRows
830
+ } satisfies ServerMessage)
831
+ );
539
832
  }
540
833
  }
541
834
 
@@ -564,7 +857,9 @@ export class Room extends EventEmitter {
564
857
  }
565
858
 
566
859
  private broadcastPresence() {
567
- const clients = [...this.clients.values()].map(toPresence);
860
+ // Monitor tiles are spectators of the UI, not participants — eight live
861
+ // tiles must not read as eight viewers in every session.
862
+ const clients = [...this.clients.values()].filter((c) => c.source !== "monitor").map(toPresence);
568
863
  this.broadcast({ type: "presence", clients });
569
864
  }
570
865
 
@@ -599,6 +894,97 @@ export class Room extends EventEmitter {
599
894
  }, CLEANUP_DELAY_MS);
600
895
  }
601
896
 
897
+ /**
898
+ * Headless terminal-identity: hop rooms usually start with NO client
899
+ * attached, so an app's startup probes — "what's your background color?"
900
+ * (OSC 10/11), "who are you?" (DA1), "do you support truecolor?"
901
+ * (XTGETTCAP RGB/Tc) — go unanswered, and the app falls back to its most
902
+ * conservative palette. Measured live: Claude Code emits only 256-color
903
+ * SGR in hop while emitting truecolor in iTerm2, purely because iTerm
904
+ * answers these and nobody here did. When a room is headless, the ROOM is
905
+ * the terminal — answer as one (dark background, truecolor-capable).
906
+ * With a client attached, the real terminal answers and we stay silent.
907
+ */
908
+ private answerTerminalQueriesIfHeadless(data: string) {
909
+ if (this.clients.size > 0 || this.ended) return;
910
+ if (data.indexOf("\x1b") === -1) return;
911
+ let reply = "";
912
+ // OSC 10/11: foreground / background color queries — answered with the
913
+ // LAST VIEWER'S actual colors, so an app that themes itself from the
914
+ // background (Claude Code picks light vs dark this way) matches what the
915
+ // user is really looking at. Falls back to the dark default before any
916
+ // client has ever attached.
917
+ const osc = (n: number, hex: string) => {
918
+ const p = (i: number) => hex.slice(i, i + 2).repeat(2);
919
+ return `\x1b]${n};rgb:${p(0)}/${p(2)}/${p(4)}\x1b\\`;
920
+ };
921
+ if (/\x1b\]10;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(10, this.clientFg);
922
+ if (/\x1b\]11;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(11, this.clientBg);
923
+ // DA1: identify as a VT220-class terminal (matches xterm.js's own reply).
924
+ if (/\x1b\[0?c/.test(data)) reply += "\x1b[?62;22c";
925
+ // DSR 5 (status): "OK".
926
+ if (/\x1b\[5n/.test(data)) reply += "\x1b[0n";
927
+ // XTGETTCAP: confirm truecolor for the two conventional capability names
928
+ // (RGB / Tc, hex-encoded); other capability requests get a valid "not
929
+ // recognized" so the app never hangs waiting.
930
+ const tcap = data.match(/\x1bP\+q([0-9a-fA-F;]+)\x1b\\/g);
931
+ if (tcap) {
932
+ for (const q of tcap) {
933
+ const names = (q.slice(4, -2) || "").split(";"); // strip ESC P + q … ESC \
934
+ for (const hex of names) {
935
+ const name = Buffer.from(hex, "hex").toString("utf8");
936
+ if (name === "RGB" || name === "Tc") reply += `\x1bP1+r${hex}\x1b\\`;
937
+ else reply += `\x1bP0+r${hex}\x1b\\`;
938
+ }
939
+ }
940
+ }
941
+ if (reply) {
942
+ try {
943
+ this.pty.write(reply);
944
+ } catch {
945
+ /* pty gone — nothing to answer */
946
+ }
947
+ }
948
+ }
949
+
950
+ /** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
951
+ private appendOutput(data: string) {
952
+ if (!data) return;
953
+ this.outputChunks.push(data);
954
+ this.outputBytes += data.length;
955
+ while (this.outputChunks.length > 1 && this.outputBytes - this.outputChunks[0].length >= MAX_BUFFER_SIZE) {
956
+ this.outputBytes -= this.outputChunks[0].length;
957
+ this.outputStart += this.outputChunks[0].length;
958
+ this.outputChunks.shift();
959
+ }
960
+ // A single chunk larger than the whole cap (giant seed/paste): slice once.
961
+ if (this.outputChunks.length === 1 && this.outputBytes > MAX_BUFFER_SIZE) {
962
+ const only = this.outputChunks[0].slice(this.outputChunks[0].length - MAX_BUFFER_SIZE);
963
+ this.outputStart += this.outputChunks[0].length - only.length;
964
+ this.outputChunks[0] = only;
965
+ this.outputBytes = only.length;
966
+ }
967
+ }
968
+
969
+ /**
970
+ * Join at most the last maxBytes of retained output. Every consumer
971
+ * (snapshot replay, preview, persistence) needs only a bounded tail, so the
972
+ * full ring is never flattened.
973
+ */
974
+ private tailOutput(maxBytes: number): string {
975
+ if (this.outputBytes === 0 || maxBytes <= 0) return "";
976
+ const cap = Math.min(maxBytes, this.outputBytes);
977
+ const parts: string[] = [];
978
+ let collected = 0;
979
+ for (let i = this.outputChunks.length - 1; i >= 0 && collected < cap; i--) {
980
+ parts.push(this.outputChunks[i]);
981
+ collected += this.outputChunks[i].length;
982
+ }
983
+ parts.reverse();
984
+ const joined = parts.join("");
985
+ return joined.length > cap ? joined.slice(joined.length - cap) : joined;
986
+ }
987
+
602
988
  private endSession(payload: { exitCode: number | null; signal: string | null; message: string; by?: string }) {
603
989
  if (this.ended) return;
604
990
  this.ended = true;
@@ -623,13 +1009,87 @@ export class Room extends EventEmitter {
623
1009
  this.emit("empty");
624
1010
  }
625
1011
 
1012
+ /**
1013
+ * Bounded source for an on-demand screen preview: the terminal's size plus a
1014
+ * tail of the raw output (enough to reconstruct the visible screen for the
1015
+ * common case of full-repaint TUIs). The actual rendering happens in the
1016
+ * daemon, on demand, only when someone is looking — so idle rooms cost nothing.
1017
+ */
1018
+ getPreviewSource(maxBytes = 65536): { cols: number; rows: number; output: string } {
1019
+ return { cols: this.activeCols, rows: this.activeRows, output: this.tailOutput(maxBytes) };
1020
+ }
1021
+
1022
+ /**
1023
+ * Incremental output for a persistent consumer-side screen parser (the
1024
+ * daemon's preview grids). `since` is an absolute cursor from a previous
1025
+ * call's `offset`; the response is exactly the chars appended after it, so
1026
+ * parser state carries forward with no arbitrary cut points. A cursor
1027
+ * that's unknown, ahead of the stream, fallen off the retained ring, or too
1028
+ * far behind to be worth replaying comes back as `reset: true` with a
1029
+ * bounded tail and a fresh cursor — the caller re-seeds.
1030
+ */
1031
+ /** Remember the viewer's terminal colors for headless OSC 10/11 answers. */
1032
+ setClientColors(bg?: string | null, fg?: string | null) {
1033
+ if (bg && /^[0-9a-f]{6}$/i.test(bg)) this.clientBg = bg.toLowerCase();
1034
+ if (fg && /^[0-9a-f]{6}$/i.test(fg)) this.clientFg = fg.toLowerCase();
1035
+ }
1036
+
1037
+ getOutputSince(
1038
+ since?: number,
1039
+ maxBytes = 1_048_576
1040
+ ): { offset: number; data: string; reset: boolean; cols: number; rows: number } {
1041
+ const end = this.outputStart + this.outputBytes;
1042
+ const dims = { cols: this.activeCols, rows: this.activeRows };
1043
+ if (
1044
+ typeof since !== "number" ||
1045
+ !Number.isFinite(since) ||
1046
+ since < this.outputStart ||
1047
+ since > end ||
1048
+ end - since > maxBytes
1049
+ ) {
1050
+ return { offset: end, data: this.tailOutput(maxBytes), reset: true, ...dims };
1051
+ }
1052
+ // The delta from `since` to the end IS the tail of that length, and
1053
+ // since >= outputStart guarantees every char of it is still retained.
1054
+ const deltaLen = end - since;
1055
+ return { offset: end, data: deltaLen === 0 ? "" : this.tailOutput(deltaLen), reset: false, ...dims };
1056
+ }
1057
+
1058
+ /**
1059
+ * A bounded tail of the raw output, for persisting across a host restart so
1060
+ * `hop restore` can replay the last screen of a plain-shell session. Returns
1061
+ * an empty string for an empty buffer so callers can skip writing a file.
1062
+ */
1063
+ getPersistableBuffer(maxBytes = 65536): string {
1064
+ return this.tailOutput(maxBytes);
1065
+ }
1066
+
1067
+ hasEnded() {
1068
+ return this.ended;
1069
+ }
1070
+
626
1071
  getSummary(): RoomSummary {
627
1072
  const localCliCount = [...this.clients.values()].filter((client) => client.source === "local-cli").length;
1073
+ // Read the foreground process name fresh (cheap getter) so the session
1074
+ // manager reflects what's running now; fall back to the last polled sample.
1075
+ let foregroundProcess = this.foregroundProcess;
1076
+ try {
1077
+ const live = (this.pty as unknown as { process?: string }).process;
1078
+ if (typeof live === "string" && live) {
1079
+ foregroundProcess = live.replace(/^-/, "");
1080
+ }
1081
+ } catch {
1082
+ /* keep the polled fallback */
1083
+ }
628
1084
  return {
629
1085
  id: this.id,
630
1086
  cwd: this.initialCwd,
631
1087
  liveCwd: this.liveCwd,
632
- clientCount: this.clients.size,
1088
+ foregroundProcess,
1089
+ lastActivityAt: this.lastActivityAt,
1090
+ bellSeq: this.bellSeq,
1091
+ lastBellAt: this.lastBellAt,
1092
+ clientCount: [...this.clients.values()].filter((c) => c.source !== "monitor").length,
633
1093
  localCliCount
634
1094
  };
635
1095
  }
@@ -645,7 +1105,9 @@ export class RoomManager {
645
1105
 
646
1106
  getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwdOrOptions: string | RoomCreateOptions) {
647
1107
  const existing = this.rooms.get(roomId);
648
- if (existing) {
1108
+ // Never hand out a room that already ended — its poll/cleanup are dead and
1109
+ // its pty is (supposed to be) gone. Replace it with a fresh generation.
1110
+ if (existing && !existing.hasEnded()) {
649
1111
  return existing;
650
1112
  }
651
1113
  const options = typeof cwdOrOptions === "string"
@@ -656,7 +1118,12 @@ export class RoomManager {
656
1118
  }
657
1119
  const room = new Room(roomId, this.ptyFactory, initialSize, options);
658
1120
  room.on("empty", () => {
659
- this.rooms.delete(roomId);
1121
+ // Generation-safe delete: only remove the mapping if it still points at
1122
+ // THIS room. A late 'empty' from a dead generation must not evict a
1123
+ // freshly created successor under the same id.
1124
+ if (this.rooms.get(roomId) === room) {
1125
+ this.rooms.delete(roomId);
1126
+ }
660
1127
  });
661
1128
  this.rooms.set(roomId, room);
662
1129
  return room;
@@ -683,6 +1150,30 @@ export class RoomManager {
683
1150
  }
684
1151
  }
685
1152
 
1153
+ getRoomPreviewSource(id: string, maxBytes?: number) {
1154
+ const room = this.rooms.get(id);
1155
+ return room ? room.getPreviewSource(maxBytes) : null;
1156
+ }
1157
+
1158
+ getRoomOutputSince(id: string, since?: number, maxBytes?: number) {
1159
+ const room = this.rooms.get(id);
1160
+ return room ? room.getOutputSince(since, maxBytes) : null;
1161
+ }
1162
+
1163
+ /**
1164
+ * Snapshot every live room's tail buffer for persistence across a host
1165
+ * restart. Rooms with no output are omitted. The host script writes these to
1166
+ * disk on shutdown; `hop restore` replays them as `seedOutput`.
1167
+ */
1168
+ getPersistableBuffers(maxBytes?: number): Array<{ id: string; output: string }> {
1169
+ const out: Array<{ id: string; output: string }> = [];
1170
+ for (const room of this.rooms.values()) {
1171
+ const output = room.getPersistableBuffer(maxBytes);
1172
+ if (output) out.push({ id: room.id, output });
1173
+ }
1174
+ return out;
1175
+ }
1176
+
686
1177
  listRooms(): RoomSummary[] {
687
1178
  return Array.from(this.rooms.values()).map((room) => room.getSummary());
688
1179
  }