hop2 0.11.8 → 0.11.10

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.
@@ -1,6 +1,6 @@
1
1
  // Sanitized live-footage capture driver for the hop product video (dark theme).
2
2
  // Usage: node demo/capture/capture.mjs <clip> where clip in:
3
- // 01-sessions | 02-agent-live | 02b-desktop-terminal | 03-phone-live |
3
+ // 00-wall | 01-sessions | 02-agent-live | 02b-desktop-terminal | 03-phone-live |
4
4
  // 04-phone-switcher | 05-presence | 06-math | 07-theme
5
5
  //
6
6
  // Run demo/capture/setup-sessions.mjs and demo/capture/spawn-aurora.mjs first
@@ -112,7 +112,12 @@ async function sanitizeContext(context) {
112
112
  const resp = await route.fetch();
113
113
  let json;
114
114
  try { json = await resp.json(); } catch { return await route.fulfill({ response: resp }); }
115
- json.sessions = (json.sessions || []).filter((s) => ALLOWED.includes(s.name));
115
+ json.sessions = (json.sessions || []).filter((s) => ALLOWED.includes(s.name))
116
+ // The rig creates its sessions through the terminal API, which the
117
+ // origin classifier (correctly) marks agent-created — but the wall
118
+ // defaults to the USER scope, so the whole demo cast would be
119
+ // hidden behind the Agent toggle. They PLAY user sessions.
120
+ .map((s) => ({ ...s, createdBy: "user" }));
116
121
  json.active = (json.active || []).filter((n) => ALLOWED.includes(n));
117
122
  json.starting = (json.starting || []).filter((n) => ALLOWED.includes(n));
118
123
  json.aliases = {};
@@ -598,6 +603,48 @@ try {
598
603
  await sleep(2400);
599
604
  await saveVideo(page, "07-theme", trimFor(t0, marker));
600
605
 
606
+ } else if (clip === "00-wall") {
607
+ // THE signature shot of the switcher-first era: the wall is the product.
608
+ // Live tiles ticking, folders, the briefing card up top, filter-to-find,
609
+ // click = engage in place, ⌘⏎ = the one deliberate door into full screen.
610
+ // The briefing is staged by ROUTE INTERCEPTION — no file ever touches
611
+ // hay-web/assets, so nothing sanitization-sensitive can leak or linger.
612
+ const { ctx, page, t0 } = await newRecordedPage(browser, {
613
+ css: "try{localStorage.setItem('hay_tile_zoom','6');localStorage.setItem('hay_theme','dark');}catch(e){}"
614
+ });
615
+ await ctx.route("**/assets/digest.json", (route) =>
616
+ route.fulfill({
617
+ contentType: "application/json",
618
+ body: JSON.stringify({
619
+ generated_at: new Date().toISOString(),
620
+ summary: "Nebula finished the ingest refactor; Polaris is mid-benchmark; Aurora wants a review.",
621
+ items: [
622
+ { session: "Nebula", headline: "Ingest refactor landed — 41s → 9s on the sample day", urgency: "info" },
623
+ { session: "Polaris", headline: "Benchmark sweep 60% done, no regressions so far", urgency: "info" },
624
+ { session: "Aurora", headline: "Wants review: error-handling change in the exporter", urgency: "attention" }
625
+ ]
626
+ })
627
+ })
628
+ );
629
+ await page.goto(`${state.localUrl}/s/Lyra2/?view=wall`, { waitUntil: "networkidle", timeout: 30000 });
630
+ await sleep(3500);
631
+ await assertDark(page, "00-wall");
632
+ const marker = Date.now();
633
+ await sleep(2600); // the wall breathing: tiles tick, briefing reads
634
+ // Find-by-typing: the filter narrows the wall live.
635
+ const filterBox = page.locator(".switcher-filter").first();
636
+ await filterBox.click();
637
+ await page.keyboard.type("neb", { delay: 160 });
638
+ await sleep(1600);
639
+ for (let i = 0; i < 3; i++) { await page.keyboard.press("Backspace"); await sleep(120); }
640
+ await sleep(900);
641
+ // Click a session: it engages IN PLACE — the wall never yanks you away.
642
+ await page.locator(".switcher-card", { hasText: "Nebula" }).first().click();
643
+ await sleep(3200);
644
+ // ⌘⏎ — the one deliberate door into full screen.
645
+ await page.keyboard.press("Meta+Enter");
646
+ await sleep(3400);
647
+ await saveVideo(page, "00-wall", trimFor(t0, marker));
601
648
  } else if (clip === "01-sessions") {
602
649
  const { page, t0 } = await newRecordedPage(browser, {});
603
650
  await page.goto(`${state.localUrl}/sessions`, { waitUntil: "networkidle", timeout: 30000 });
@@ -0,0 +1,64 @@
1
+ # Product video v2 — the switcher-first cut
2
+
3
+ The March cut opened on a session *list* and treated full screen as the
4
+ product. That is backwards now: **the wall is the product**, full screen is a
5
+ deliberate mode, and three new stories exist — the briefing, hop-ios, and
6
+ HopBoard dictation. Target: ~60s, captioned, no voiceover.
7
+
8
+ ## Shot list
9
+
10
+ Captured with the sanitized rig (`demo/capture/`) unless marked PHONE.
11
+ Dark theme throughout; captions succinct, not salesy.
12
+
13
+ 1. **00-wall** (10–13s) — CAPTURED, `demo-output/footage/live/00-wall.webm`
14
+ The wall breathing: live tiles ticking, briefing card up top, filter
15
+ narrows live, click engages a session in place, ⌘⏎ enters full screen.
16
+ Caption: `Every terminal, one wall. Sessions are the home screen.`
17
+ 2. **01-briefing** (5s) — crop/zoom of the briefing card reading itself.
18
+ Caption: `A briefing, not a backlog — what happened while you were away.`
19
+ (Staged via route interception in the 00-wall clip; can reuse frames.)
20
+ 3. **02-agent-live** (8s) — existing rig clip, still valid: an agent working
21
+ in a session while you watch and type into the same terminal.
22
+ Caption: `Agents work in real terminals. Watch, steer, take over.`
23
+ 4. **03-phone-live** (6s) — existing rig clip (mobile web).
24
+ Caption: `The same fleet from your phone.`
25
+ 5. **04-mobile** (6s) — `docs/hero-mobile.svg` as a Ken-Burns still
26
+ (slow push-in, left phone then right phone). Covers BOTH hop-ios and
27
+ HopBoard in the house illustration style (same canvas/typography as
28
+ hero-overview.svg). Caption: none needed — the annotations are in
29
+ the frame. UPGRADE PATH: real device footage replaces this still
30
+ whenever it gets filmed; the storyboard slot and timing stay.
31
+ Why not simulator footage: simctl has no tap injection (driving the
32
+ app means running Orion's XCUITest rig inside their repo), system
33
+ dialogs (Apple ID sign-in) land on camera, and HopBoard specifically
34
+ cannot be real there — keyboard-extension + mic pipeline + Whisper on
35
+ the Neural Engine don't function in the simulator, so the 1–3s
36
+ transcription moment would have to be staged. The dictation demo's
37
+ honesty is its point; an illustration is honest, a fake capture isn't.
38
+ 7. **06-close** (3s) — wall again, one tile ringing its attention bell,
39
+ cursor moves to it. Caption: `hop — github.com/jzthree/hop`.
40
+
41
+ ## Rig notes (what changed since March)
42
+
43
+ - New clip `00-wall` in `capture.mjs`; briefing is staged by intercepting
44
+ `/assets/digest.json` — nothing written to disk, nothing to leak.
45
+ - The rig's sessions are created through the terminal API, so the origin
46
+ classifier marks them agent-created and the wall's default USER scope
47
+ would hide the entire cast — the network filter now presents them as
48
+ user sessions (`createdBy: "user"` rewrite).
49
+ - The legacy `01-sessions` clip targets the old `/sessions` hub page.
50
+ Superseded by `00-wall`; keep the code until the v2 edit is locked.
51
+
52
+ ## Phone footage checklist (only you can film these)
53
+
54
+ - iPhone, dark mode, notifications off, screen-record at 60fps.
55
+ - hop-ios: open on the wall, scroll it once, tap a tile, type one short
56
+ line (keyboard feel is the shot), open the briefing.
57
+ - HopBoard: in Notes or a claude session, tap mic key, speak one sentence
58
+ ("route the exporter errors into the retry queue"), stop, let the text
59
+ land. Keep the orange mic indicator in frame — it is the privacy story.
60
+
61
+ ## Assembly
62
+
63
+ `stitch-demo-video.mjs` concatenates WebM → MP4 as before. Captions get
64
+ burned in at edit time (same as March cut) — keep each on screen ≥2.5s.
@@ -0,0 +1,105 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="980" height="620" viewBox="0 0 980 620" role="img" aria-label="hop on the phone: the native iOS app showing the session wall with a morning briefing and live terminal tiles, and HopBoard, a system-wide keyboard with on-device Whisper dictation — audio never leaves the phone.">
2
+ <defs>
3
+ <linearGradient id="canvasM" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#16171c"/>
5
+ <stop offset="1" stop-color="#101116"/>
6
+ </linearGradient>
7
+ <filter id="shadowM" x="-20%" y="-20%" width="140%" height="160%">
8
+ <feDropShadow dx="0" dy="6" stdDeviation="14" flood-color="#000000" flood-opacity="0.45"/>
9
+ </filter>
10
+ <clipPath id="phoneA"><rect x="120" y="96" width="250" height="470" rx="28"/></clipPath>
11
+ <clipPath id="phoneB"><rect x="612" y="96" width="250" height="470" rx="28"/></clipPath>
12
+ </defs>
13
+
14
+ <!-- canvas -->
15
+ <rect x="0.5" y="0.5" width="979" height="619" rx="18" fill="url(#canvasM)" stroke="#272831"/>
16
+
17
+ <!-- kicker -->
18
+ <text x="490" y="44" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="12.5" letter-spacing="0.14em" fill="#8d909b">THE FLEET IN YOUR POCKET — NATIVE APP, AND A KEYBOARD THAT LISTENS ON-DEVICE</text>
19
+
20
+ <!-- ═══════════ ① hop-ios: the wall, native ═══════════ -->
21
+ <g filter="url(#shadowM)">
22
+ <rect x="120" y="96" width="250" height="470" rx="28" fill="#0b0b0e" stroke="#33343c" stroke-width="2"/>
23
+ </g>
24
+ <g clip-path="url(#phoneA)" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
25
+ <!-- dynamic island -->
26
+ <rect x="205" y="106" width="80" height="16" rx="8" fill="#000000"/>
27
+ <!-- app header -->
28
+ <text x="136" y="146" fill="#e6e6eb" font-weight="700" font-size="13">Sessions</text>
29
+ <text x="354" y="146" text-anchor="end" fill="#8d909b" font-size="10">4 live</text>
30
+ <!-- briefing card -->
31
+ <rect x="132" y="158" width="226" height="86" rx="10" fill="#17181f" stroke="#2c2d36"/>
32
+ <text x="142" y="176" font-size="9.5"><tspan fill="#a78bfa" font-weight="700">✦ Briefing</tspan><tspan fill="#8d909b">&#160;&#160;7:30am</tspan></text>
33
+ <text x="142" y="192" fill="#c9cad3" font-size="9">Nebula landed the ingest refactor</text>
34
+ <text x="142" y="205" fill="#c9cad3" font-size="9">overnight — 41s → 9s. Polaris is</text>
35
+ <text x="142" y="218" fill="#c9cad3" font-size="9">mid-benchmark, no regressions.</text>
36
+ <text x="142" y="235" font-size="9"><tspan fill="#f0883e">●</tspan><tspan fill="#e6e6eb">&#160;Aurora wants a review</tspan></text>
37
+ <!-- tile: Nebula -->
38
+ <rect x="132" y="254" width="226" height="92" rx="10" fill="#101116" stroke="#2c2d36"/>
39
+ <text x="142" y="270" font-size="9.5"><tspan fill="#28c840">●</tspan><tspan fill="#e6e6eb" font-weight="700">&#160;Nebula</tspan></text>
40
+ <text x="142" y="288" fill="#8d909b" font-size="8.5">✓ tests: 47 passed</text>
41
+ <text x="142" y="301" fill="#8d909b" font-size="8.5">transform src/hooks/usePresence.ts</text>
42
+ <text x="142" y="314" fill="#8d909b" font-size="8.5">128 modules in 296ms</text>
43
+ <text x="142" y="334" font-size="8.5"><tspan fill="#6e7079">❯</tspan><tspan fill="#c9cad3">&#160;</tspan></text>
44
+ <rect x="152" y="326" width="5" height="10" fill="#a78bfa" opacity="0.85"/>
45
+ <!-- tile: Polaris with bell -->
46
+ <rect x="132" y="356" width="226" height="70" rx="10" fill="#101116" stroke="#7c3aed"/>
47
+ <text x="142" y="372" font-size="9.5"><tspan fill="#a78bfa">◉</tspan><tspan fill="#e6e6eb" font-weight="700">&#160;Polaris</tspan><tspan fill="#f0883e">&#160;· needs you</tspan></text>
48
+ <text x="142" y="390" fill="#8d909b" font-size="8.5">rollout wave 3/4 complete</text>
49
+ <text x="142" y="403" fill="#8d909b" font-size="8.5">continue to wave 4/4? (y/n)</text>
50
+ <!-- keyboard hint strip -->
51
+ <rect x="132" y="436" width="226" height="30" rx="8" fill="#17181f" stroke="#2c2d36"/>
52
+ <text x="245" y="455" text-anchor="middle" fill="#8d909b" font-size="9">esc · tab · ctrl · ↑ ↓ · ⌘K</text>
53
+ <!-- home indicator -->
54
+ <rect x="205" y="546" width="80" height="4" rx="2" fill="#33343c"/>
55
+ </g>
56
+ <text x="245" y="592" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" fill="#c9cad3"><tspan fill="#a78bfa" font-weight="700">①</tspan> native app — the wall, the briefing, push when a session rings</text>
57
+
58
+ <!-- connective arrow -->
59
+ <g stroke="#3d3e49" stroke-width="1.5" fill="none">
60
+ <path d="M 386 330 C 450 330 530 330 596 330" stroke-dasharray="4 5"/>
61
+ </g>
62
+ <text x="491" y="318" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="10" fill="#8d909b">same fleet · same daemon</text>
63
+
64
+ <!-- ═══════════ ② HopBoard: dictation keyboard ═══════════ -->
65
+ <g filter="url(#shadowM)">
66
+ <rect x="612" y="96" width="250" height="470" rx="28" fill="#0b0b0e" stroke="#33343c" stroke-width="2"/>
67
+ </g>
68
+ <g clip-path="url(#phoneB)" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
69
+ <rect x="697" y="106" width="80" height="16" rx="8" fill="#000000"/>
70
+ <!-- orange mic indicator -->
71
+ <circle cx="789" cy="114" r="3.5" fill="#ff9f0a"/>
72
+ <!-- a claude session being dictated into -->
73
+ <text x="628" y="150" font-size="9.5"><tspan fill="#28c840">●</tspan><tspan fill="#e6e6eb" font-weight="700">&#160;Aurora</tspan><tspan fill="#8d909b">&#160;· full screen</tspan></text>
74
+ <text x="628" y="172" fill="#8d909b" font-size="8.5">⏺ Exporter errors now surface in</text>
75
+ <text x="628" y="185" fill="#8d909b" font-size="8.5"> the retry queue with backoff.</text>
76
+ <rect x="624" y="200" width="226" height="42" rx="8" fill="#17181f" stroke="#2c2d36"/>
77
+ <text x="634" y="217" font-size="9"><tspan fill="#6e7079">❯</tspan><tspan fill="#e6e6eb">&#160;route the exporter errors into</tspan></text>
78
+ <text x="634" y="231" font-size="9"><tspan fill="#e6e6eb"> the retry queue</tspan></text>
79
+ <rect x="762" y="222" width="5" height="11" fill="#a78bfa" opacity="0.85"/>
80
+ <!-- transcribing pill -->
81
+ <rect x="624" y="252" width="226" height="24" rx="12" fill="#1c1420" stroke="#7c3aed"/>
82
+ <text x="737" y="268" text-anchor="middle" font-size="9"><tspan fill="#a78bfa">●</tspan><tspan fill="#c9cad3">&#160;whisper large-v3-turbo · on-device · 1.4s</tspan></text>
83
+ <!-- keyboard -->
84
+ <rect x="612" y="290" width="250" height="276" fill="#141419"/>
85
+ <g fill="#232430">
86
+ <rect x="624" y="302" width="20" height="30" rx="5"/><rect x="648" y="302" width="20" height="30" rx="5"/><rect x="672" y="302" width="20" height="30" rx="5"/><rect x="696" y="302" width="20" height="30" rx="5"/><rect x="720" y="302" width="20" height="30" rx="5"/><rect x="744" y="302" width="20" height="30" rx="5"/><rect x="768" y="302" width="20" height="30" rx="5"/><rect x="792" y="302" width="20" height="30" rx="5"/><rect x="816" y="302" width="20" height="30" rx="5"/><rect x="838" y="302" width="12" height="30" rx="5"/>
87
+ <rect x="636" y="338" width="20" height="30" rx="5"/><rect x="660" y="338" width="20" height="30" rx="5"/><rect x="684" y="338" width="20" height="30" rx="5"/><rect x="708" y="338" width="20" height="30" rx="5"/><rect x="732" y="338" width="20" height="30" rx="5"/><rect x="756" y="338" width="20" height="30" rx="5"/><rect x="780" y="338" width="20" height="30" rx="5"/><rect x="804" y="338" width="20" height="30" rx="5"/><rect x="828" y="338" width="22" height="30" rx="5"/>
88
+ <rect x="624" y="374" width="30" height="30" rx="5"/><rect x="660" y="374" width="20" height="30" rx="5"/><rect x="684" y="374" width="20" height="30" rx="5"/><rect x="708" y="374" width="20" height="30" rx="5"/><rect x="732" y="374" width="20" height="30" rx="5"/><rect x="756" y="374" width="20" height="30" rx="5"/><rect x="780" y="374" width="20" height="30" rx="5"/><rect x="806" y="374" width="44" height="30" rx="5"/>
89
+ </g>
90
+ <!-- mic key, active -->
91
+ <rect x="624" y="410" width="52" height="34" rx="7" fill="#7c3aed"/>
92
+ <text x="650" y="432" text-anchor="middle" fill="#ffffff" font-size="13">🎙</text>
93
+ <rect x="682" y="410" width="120" height="34" rx="7" fill="#232430"/>
94
+ <text x="742" y="431" text-anchor="middle" fill="#8d909b" font-size="9">space</text>
95
+ <rect x="808" y="410" width="42" height="34" rx="7" fill="#232430"/>
96
+ <text x="829" y="431" text-anchor="middle" fill="#8d909b" font-size="9">⏎</text>
97
+ <!-- waveform -->
98
+ <g stroke="#a78bfa" stroke-width="2" stroke-linecap="round">
99
+ <line x1="662" y1="470" x2="662" y2="482"/><line x1="672" y1="464" x2="672" y2="488"/><line x1="682" y1="458" x2="682" y2="494"/><line x1="692" y1="466" x2="692" y2="486"/><line x1="702" y1="472" x2="702" y2="480"/><line x1="712" y1="462" x2="712" y2="490"/><line x1="722" y1="468" x2="722" y2="484"/><line x1="732" y1="456" x2="732" y2="496"/><line x1="742" y1="466" x2="742" y2="486"/><line x1="752" y1="472" x2="752" y2="480"/><line x1="762" y1="460" x2="762" y2="492"/><line x1="772" y1="468" x2="772" y2="484"/><line x1="782" y1="474" x2="782" y2="478"/><line x1="792" y1="464" x2="792" y2="488"/><line x1="802" y1="470" x2="802" y2="482"/>
100
+ </g>
101
+ <text x="737" y="518" text-anchor="middle" fill="#8d909b" font-size="9">hold to speak · release to insert</text>
102
+ <rect x="697" y="546" width="80" height="4" rx="2" fill="#33343c"/>
103
+ </g>
104
+ <text x="737" y="592" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="11" fill="#c9cad3"><tspan fill="#a78bfa" font-weight="700">②</tspan> HopBoard — on-device Whisper dictation; audio never leaves the phone</text>
105
+ </svg>
package/hay/CHANGELOG.md CHANGED
@@ -32,6 +32,9 @@
32
32
 
33
33
  ### Bug Fixes
34
34
 
35
+ #### Web terminal
36
+ - **Claude screens render completely on attach without a scroll**: Hop now starts its authoritative screen grid when a session enters alternate-screen or mouse-reporting TUI mode, before startup rows can fall out of a bounded replay tail. Full-screen attaches use that serialized screen, and direct snapshot paints re-arm one delayed repaint in case the browser misses the first WebGL frame.
37
+
35
38
  #### Session restore
36
39
  - **`hop restore` actually restores now (Claude conversations included), from the SessionStart-hook records**: restore built its candidate list from the workspace-scoped session store, which is only populated when a workspace is active — and no workspace is ever activated in normal use, so `restore` found nothing and silently restored zero sessions. It now sources candidates directly from the durable per-session records the SessionStart hook writes (`~/.hop2/claude-sessions/<name>.json` = `{ sessionId, cwd }`), which survive a crash/reboot and carry the exact conversation id. So after a restart, `hop restore` recreates each session in its directory and resumes its Claude conversation with `claude --resume <id>` — no manual capture step. Sessions already live are skipped (idempotent), and `hop restore` now starts the daemon itself if it isn't running, so post-reboot recovery is a single command. `--dry-run` previews the plan. Records are pruned when a session is deleted, so restore never resurrects a session you intentionally closed (and if an unwanted one does come back, deleting it once keeps it gone).
37
40
  - **`hopx_agent_turn` no longer runs non-UI input twice**: in `readable_raw` mode (and `auto` resolving to it — i.e. a plain shell or any non-alternate-screen agent), a synchronous turn pre-sent the input and *then* sent it again via the combined send+wait, so a command driven through `hopx_agent_turn` executed twice. The pre-send is now done only for the paths that need it (the `ui` branch and async turns, which return before the combined call); non-UI synchronous turns let the single send+wait do the send. UI and async turns are unchanged.
@@ -175,6 +175,11 @@ const toPresence = (client: ClientState): PresenceClient => ({
175
175
  });
176
176
 
177
177
  class ClientState {
178
+ // True from attach until this client's serialized snapshot has been sent.
179
+ // Output broadcasts are withheld meanwhile (see broadcast) and delivered
180
+ // as catch-up appended to the snapshot itself.
181
+ awaitingSnapshot = false;
182
+ snapshotCatchUp: string[] = [];
178
183
  id: string;
179
184
  name: string;
180
185
  color: string;
@@ -257,10 +262,15 @@ export class Room extends EventEmitter {
257
262
  // connect created the room, so its hello carries created=true.
258
263
  private hasHadClient = false;
259
264
  // Authoritative screen state for serialized attach (see screenGrid.ts).
260
- // Created lazily once the headless deps load; hydrated from the retained
261
- // ring at creation so bytes that arrived earlier are never missing.
265
+ // Start it as soon as a TUI advertises alternate-screen or mouse mode, so
266
+ // rows painted once at startup cannot age out of a bounded replay tail.
267
+ // Plain shells stay lazy and pay no grid cost until somebody attaches.
262
268
  private grid: ScreenGrid | null = null;
263
269
  private gridHydrated = false;
270
+ private gridStartPending = false;
271
+ private gridUnavailable = false;
272
+ private gridPendingSeed: string[] | null = null;
273
+ private gridPendingSeedBytes = 0;
264
274
 
265
275
  constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
266
276
  super();
@@ -293,6 +303,7 @@ export class Room extends EventEmitter {
293
303
  this.lastActivityAt = now();
294
304
  this.appendOutput(data);
295
305
  this.updateTerminalState(data);
306
+ this.startTuiGrid();
296
307
  this.answerTerminalQueriesIfHeadless(data);
297
308
  this.broadcast({ type: "output", data });
298
309
  this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
@@ -438,9 +449,23 @@ export class Room extends EventEmitter {
438
449
  // the web full screen's ~384KB request wants the grid's full depth.
439
450
  const serializedScrollback = replayRequest !== null && replayRequest <= 131_072 ? 200 : undefined;
440
451
  if (gridForAttach) {
452
+ // Everything already in the ring at THIS moment will be in the
453
+ // serialized screen; everything after it will not (grid writes queue
454
+ // behind the serialize marker). Mute output to this client until the
455
+ // snapshot goes out, retaining the exact withheld chunks for catch-up.
456
+ client.awaitingSnapshot = true;
457
+ client.snapshotCatchUp = [];
441
458
  gridForAttach.serialize((serialized) => {
442
- if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) return;
459
+ if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) {
460
+ client.awaitingSnapshot = false;
461
+ client.snapshotCatchUp = [];
462
+ return;
463
+ }
443
464
  if (serialized === null) {
465
+ // Raw fallback reads the ring as it stands NOW. Unmute first so
466
+ // nothing new slips into the handoff (raw has no in-band RIS).
467
+ client.awaitingSnapshot = false;
468
+ client.snapshotCatchUp = [];
444
469
  sendRawSnapshot();
445
470
  wiggle();
446
471
  return;
@@ -453,14 +478,20 @@ export class Room extends EventEmitter {
453
478
  // read the separate flags — ends up in the app's actual modes.
454
479
  const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
455
480
  + (this.cursorHidden ? "\x1b[?25l" : "");
481
+ // Catch-up: output that arrived while the snapshot was being
482
+ // serialized. Appended INSIDE the snapshot payload so snapshot and
483
+ // delta land as one atomic parse — no frame between reset and delta.
484
+ const catchUp = client.snapshotCatchUp.join("");
456
485
  socket.send(JSON.stringify({
457
486
  type: "snapshot",
458
487
  // In-band RIS: every client applies the serialized screen onto a
459
488
  // clean slate, including ones that don't reset on their own.
460
- data: "\x1bc" + serialized + modeSeq,
489
+ data: "\x1bc" + serialized + modeSeq + catchUp,
461
490
  capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
462
491
  ...snapshotFlags()
463
492
  } satisfies ServerMessage));
493
+ client.awaitingSnapshot = false;
494
+ client.snapshotCatchUp = [];
464
495
  // No wiggle: the serialized grid IS the current screen.
465
496
  }, serializedScrollback);
466
497
  } else {
@@ -542,16 +573,23 @@ export class Room extends EventEmitter {
542
573
 
543
574
  private updateTerminalState(data: string) {
544
575
  const combined = this.controlSequenceTail + data;
576
+ const tailLength = this.controlSequenceTail.length;
577
+ const risIndex = combined.lastIndexOf("\x1bc");
578
+ // Only process a RIS that ends in this chunk; one entirely inside the
579
+ // carry tail was already applied on the preceding call. Modes before the
580
+ // last new RIS are reset, while later sequences in the same chunk win.
581
+ const hasNewRis = risIndex !== -1 && risIndex + 2 > tailLength;
582
+ const stateInput = hasNewRis ? combined.slice(risIndex + 2) : combined;
545
583
 
546
584
  // Parse CSI mode sequences (cursor visibility, alternate screen)
547
585
  const regex = /\x1b\[\?([0-9;]*)([hl])/g;
548
586
  let match: RegExpExecArray | null;
549
- let nextAlternate = this.alternateScreen;
550
- let nextCursorHidden = this.cursorHidden;
587
+ let nextAlternate = hasNewRis ? false : this.alternateScreen;
588
+ let nextCursorHidden = hasNewRis ? false : this.cursorHidden;
551
589
 
552
- let nextMouseReporting = this.mouseReporting;
553
- let nextMouseSgr = this.mouseSgr;
554
- while ((match = regex.exec(combined)) !== null) {
590
+ let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
591
+ let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
592
+ while ((match = regex.exec(stateInput)) !== null) {
555
593
  const params = match[1].split(";").filter(Boolean);
556
594
  const mode = match[2];
557
595
  for (const param of params) {
@@ -571,10 +609,10 @@ export class Room extends EventEmitter {
571
609
 
572
610
  // Enhanced keyboard reporting: kitty keyboard protocol (CSI >flags u push /
573
611
  // CSI <n u pop / CSI =flags u set) and xterm modifyOtherKeys (CSI >4;n m).
574
- let nextKeyboardEnhanced = this.keyboardEnhanced;
612
+ let nextKeyboardEnhanced = hasNewRis ? false : this.keyboardEnhanced;
575
613
  const kbdRegex = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
576
614
  let kbdMatch: RegExpExecArray | null;
577
- while ((kbdMatch = kbdRegex.exec(combined)) !== null) {
615
+ while ((kbdMatch = kbdRegex.exec(stateInput)) !== null) {
578
616
  if (kbdMatch[1] !== undefined) {
579
617
  nextKeyboardEnhanced = kbdMatch[1] !== "<"; // push/set = on, pop = off
580
618
  } else {
@@ -923,7 +961,19 @@ export class Room extends EventEmitter {
923
961
 
924
962
  private broadcast(message: ServerMessage) {
925
963
  const payload = JSON.stringify(message);
964
+ const isOutput = (message as { type?: string }).type === "output";
926
965
  for (const client of this.clients.values()) {
966
+ // A client whose serialized snapshot is still being prepared must not
967
+ // receive live output yet: the snapshot opens with an in-band RIS, so
968
+ // output sent before it would be ERASED by it — and the serialized
969
+ // grid does not contain those bytes either (they queued behind the
970
+ // serialize marker). That silent loss was 'the screen is stale until
971
+ // the app repaints' on every attach that raced live output. The
972
+ // withheld bytes are delivered as catch-up appended to the snapshot.
973
+ if (isOutput && client.awaitingSnapshot) {
974
+ client.snapshotCatchUp.push((message as { data: string }).data);
975
+ continue;
976
+ }
927
977
  if (client.socket.isOpen()) {
928
978
  client.socket.send(payload);
929
979
  }
@@ -1021,13 +1071,38 @@ export class Room extends EventEmitter {
1021
1071
  }
1022
1072
  }
1023
1073
 
1024
- // Grids are LAZY: created on the first serialized attach, not on output,
1025
- // so a room nobody ever opens (agent fleets) pays no grid memory at all.
1026
- // Creation hydrates from a bounded ring tail — deep enough to contain any
1027
- // app's last full repaint plus every update since, which is all screen
1028
- // correctness needs; serialized scrollback is bounded anyway.
1074
+ // Incremental TUIs paint some rows once and then update other regions for
1075
+ // hours. Reconstructing their first grid from any bounded tail can therefore
1076
+ // omit cells until the app happens to repaint (for example, on scroll).
1077
+ // Begin tracking at TUI entry; dependency loading is shared and normally
1078
+ // finishes first, but the callback covers a TUI that starts immediately.
1079
+ private startTuiGrid() {
1080
+ if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
1081
+ if (!this.alternateScreen && !this.mouseReporting) return;
1082
+ if (screenGridsAvailable()) {
1083
+ this.gridUnavailable = this.ensureGrid() === null;
1084
+ return;
1085
+ }
1086
+ this.gridStartPending = true;
1087
+ this.gridPendingSeed = [this.tailOutput(MAX_BUFFER_SIZE)];
1088
+ this.gridPendingSeedBytes = this.gridPendingSeed[0].length;
1089
+ void loadScreenGridDeps().then((available) => {
1090
+ this.gridStartPending = false;
1091
+ this.gridUnavailable = !available;
1092
+ const seed = this.gridPendingSeed?.join("") ?? "";
1093
+ this.gridPendingSeed = null;
1094
+ this.gridPendingSeedBytes = 0;
1095
+ if (available && !this.ended) {
1096
+ this.gridUnavailable = this.ensureGrid(seed) === null;
1097
+ }
1098
+ });
1099
+ }
1100
+
1101
+ // Plain-shell grids remain lazy until the first serialized attach. Creation
1102
+ // hydrates from a bounded ring tail; append-only shell output is safely
1103
+ // reconstructible from that tail, unlike the incremental TUI path above.
1029
1104
  private static readonly GRID_HYDRATE_BYTES = 2_000_000;
1030
- private ensureGrid(): ScreenGrid | null {
1105
+ private ensureGrid(seed?: string): ScreenGrid | null {
1031
1106
  if (!SERIALIZED_ATTACH_ENABLED || this.ended) return this.grid;
1032
1107
  if (!this.grid && screenGridsAvailable()) {
1033
1108
  this.grid = createScreenGrid(this.activeCols, this.activeRows);
@@ -1036,8 +1111,8 @@ export class Room extends EventEmitter {
1036
1111
  if (this.outputBytes > 0) {
1037
1112
  // Cut at a line boundary (same trick as boundSnapshotReplay) so a
1038
1113
  // slice landing mid-escape-sequence can't garble the parse head.
1039
- let tail = this.tailOutput(Room.GRID_HYDRATE_BYTES);
1040
- if (this.outputBytes > tail.length) {
1114
+ let tail = seed ?? this.tailOutput(Room.GRID_HYDRATE_BYTES);
1115
+ if (seed === undefined && this.outputBytes > tail.length) {
1041
1116
  const nl = tail.indexOf("\n");
1042
1117
  if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
1043
1118
  }
@@ -1051,6 +1126,20 @@ export class Room extends EventEmitter {
1051
1126
  /** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
1052
1127
  private appendOutput(data: string) {
1053
1128
  if (!data) return;
1129
+ if (this.gridPendingSeed) {
1130
+ this.gridPendingSeed.push(data);
1131
+ this.gridPendingSeedBytes += data.length;
1132
+ while (this.gridPendingSeed.length > 1
1133
+ && this.gridPendingSeedBytes - this.gridPendingSeed[0].length >= MAX_BUFFER_SIZE) {
1134
+ this.gridPendingSeedBytes -= this.gridPendingSeed[0].length;
1135
+ this.gridPendingSeed.shift();
1136
+ }
1137
+ if (this.gridPendingSeed.length === 1 && this.gridPendingSeedBytes > MAX_BUFFER_SIZE) {
1138
+ const tail = this.gridPendingSeed[0].slice(-MAX_BUFFER_SIZE);
1139
+ this.gridPendingSeed[0] = tail;
1140
+ this.gridPendingSeedBytes = tail.length;
1141
+ }
1142
+ }
1054
1143
  this.outputChunks.push(data);
1055
1144
  this.outputBytes += data.length;
1056
1145
  while (this.outputChunks.length > 1 && this.outputBytes - this.outputChunks[0].length >= MAX_BUFFER_SIZE) {
@@ -766,6 +766,25 @@ describe("Room", () => {
766
766
  expect(snapshot.mouseReporting).toBe(true);
767
767
  });
768
768
 
769
+ it("tracks TUI rows before they age out of the grid hydration tail", async () => {
770
+ const { room, pty } = await setup("early-grid");
771
+ pty().emit("\x1b[?1002h\x1b[?1006h");
772
+ pty().emit("\x1b[2J\x1b[1;1HROW1-EARLY-GRID-MARKER");
773
+ // Repaint only row 20 for more than the lazy grid's 2MB hydration
774
+ // window. A grid first created at attach would have no row 1 to send.
775
+ const updateBatch = ("\x1b[20;1H\x1b[K" + "x".repeat(60)).repeat(64);
776
+ for (let i = 0; i < 500; i++) pty().emit(updateBatch);
777
+
778
+ const socket = new FakeSocket();
779
+ room.attachClient(
780
+ { id: "late", name: "Late", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
781
+ socket
782
+ );
783
+ const snapshot = await waitForSnapshot(socket);
784
+ expect(String(snapshot.data)).toContain("ROW1-EARLY-GRID-MARKER");
785
+ expect(room.getPreviewSource(2_000_000).output).not.toContain("ROW1-EARLY-GRID-MARKER");
786
+ });
787
+
769
788
  it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
770
789
  const { room, pty } = await setup("capped");
771
790
  const big = "\x1b[20;1H" + "y".repeat(4096);
@@ -780,6 +799,74 @@ describe("Room", () => {
780
799
  expect(snapshot.capped).toBe(true);
781
800
  });
782
801
 
802
+ // THE ordering bug behind 'text doesn't update until I scroll' surviving
803
+ // the serialized-attach fix: serialization is ASYNC, the client is already
804
+ // in the broadcast list, so live output landed before the snapshot — and
805
+ // the snapshot's in-band RIS then erased it, while the serialized grid
806
+ // didn't contain those bytes either (they queue behind the serialize
807
+ // marker). Output during the window must be withheld and delivered as
808
+ // catch-up inside the snapshot payload itself.
809
+ it("output racing the serialize is withheld and appended to the snapshot", async () => {
810
+ const { room, pty } = await setup("race");
811
+ pty().emit("\x1b[2J\x1b[1;1Hpre-attach content\r\n");
812
+ const socket = new FakeSocket();
813
+ room.attachClient(
814
+ { id: "racer", name: "Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
815
+ socket
816
+ );
817
+ // Arrives while the snapshot is still serializing (same tick).
818
+ pty().emit("LATE-MARKER-XYZ\r\n");
819
+ const snapshot = await waitForSnapshot(socket);
820
+ const messages = readMessages(socket);
821
+ const snapIdx = messages.findIndex((m) => m.type === "snapshot");
822
+ const outputBefore = messages.slice(0, snapIdx).filter((m) => m.type === "output");
823
+ expect(outputBefore).toHaveLength(0);
824
+ expect(String(snapshot.data)).toContain("LATE-MARKER-XYZ");
825
+ // After the snapshot the stream flows live again, no duplication.
826
+ pty().emit("AFTER-MARKER\r\n");
827
+ const after = readMessages(socket).filter((m) => m.type === "output");
828
+ expect(after.some((m) => String(m.data).includes("AFTER-MARKER"))).toBe(true);
829
+ expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
830
+ });
831
+
832
+ it("retains catch-up larger than the incremental preview limit", async () => {
833
+ const { room, pty } = await setup("large-race");
834
+ pty().emit("\x1b[?1002h\x1b[2J\x1b[1;1Hbefore large catch-up");
835
+ const socket = new FakeSocket();
836
+ room.attachClient(
837
+ { id: "large-racer", name: "Large Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
838
+ socket
839
+ );
840
+ // Larger than getOutputSince's 1MB default: cursor-based catch-up used
841
+ // to reset here and silently drop every withheld byte.
842
+ const late = "x".repeat(1_100_000) + "LARGE-CATCHUP-MARKER";
843
+ pty().emit(late);
844
+ const snapshot = await waitForSnapshot(socket);
845
+ expect(String(snapshot.data)).toContain("LARGE-CATCHUP-MARKER");
846
+ const live = readMessages(socket).filter((m) => m.type === "output");
847
+ expect(live.some((m) => String(m.data).includes("LARGE-CATCHUP-MARKER"))).toBe(false);
848
+ });
849
+
850
+ it("clears tracked private modes when the app sends RIS", async () => {
851
+ const { room, pty } = await setup("ris-modes");
852
+ pty().emit("\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[?25l\x1b[>1u");
853
+ // Split ESC c across chunks to cover the control-sequence carry path.
854
+ pty().emit("\x1b");
855
+ pty().emit("c\x1b[2J\x1b[1;1HAFTER-RIS");
856
+ const socket = new FakeSocket();
857
+ room.attachClient(
858
+ { id: "after-ris", name: "After RIS", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
859
+ socket
860
+ );
861
+ const snapshot = await waitForSnapshot(socket);
862
+ expect(String(snapshot.data)).toContain("AFTER-RIS");
863
+ expect(snapshot.alternateScreen).toBe(false);
864
+ expect(snapshot.mouseReporting).toBe(false);
865
+ expect(snapshot.mouseSgr).toBe(false);
866
+ expect(snapshot.cursorHidden).toBe(false);
867
+ expect(snapshot.keyboardEnhanced).toBe(false);
868
+ });
869
+
783
870
  it("replay=0 means no snapshot at all — live stream only", async () => {
784
871
  const { room, pty } = await setup("claim");
785
872
  pty().emit("some prior output\r\n");