hop2 0.11.8 → 0.11.9

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>
@@ -175,6 +175,10 @@ 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;
178
182
  id: string;
179
183
  name: string;
180
184
  color: string;
@@ -438,9 +442,22 @@ export class Room extends EventEmitter {
438
442
  // the web full screen's ~384KB request wants the grid's full depth.
439
443
  const serializedScrollback = replayRequest !== null && replayRequest <= 131_072 ? 200 : undefined;
440
444
  if (gridForAttach) {
445
+ // Everything already in the ring at THIS moment will be in the
446
+ // serialized screen; everything after it will not (grid writes queue
447
+ // behind the serialize marker). Mute output to this client until the
448
+ // snapshot goes out, then hand it the exact delta from this cursor.
449
+ client.awaitingSnapshot = true;
450
+ const catchUpFrom = this.outputStart + this.outputBytes;
441
451
  gridForAttach.serialize((serialized) => {
442
- if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) return;
452
+ if (this.ended || !this.clients.has(client.id) || !socket.isOpen()) {
453
+ client.awaitingSnapshot = false;
454
+ return;
455
+ }
443
456
  if (serialized === null) {
457
+ // Raw fallback reads the ring as it stands NOW — everything the
458
+ // mute withheld is inside it. Unmute first so nothing new slips
459
+ // into the erased-by-reset gap either (raw has no RIS).
460
+ client.awaitingSnapshot = false;
444
461
  sendRawSnapshot();
445
462
  wiggle();
446
463
  return;
@@ -453,14 +470,19 @@ export class Room extends EventEmitter {
453
470
  // read the separate flags — ends up in the app's actual modes.
454
471
  const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
455
472
  + (this.cursorHidden ? "\x1b[?25l" : "");
473
+ // Catch-up: output that arrived while the snapshot was being
474
+ // serialized. Appended INSIDE the snapshot payload so snapshot and
475
+ // delta land as one atomic parse — no frame between reset and delta.
476
+ const catchUp = this.getOutputSince(catchUpFrom);
456
477
  socket.send(JSON.stringify({
457
478
  type: "snapshot",
458
479
  // In-band RIS: every client applies the serialized screen onto a
459
480
  // clean slate, including ones that don't reset on their own.
460
- data: "\x1bc" + serialized + modeSeq,
481
+ data: "\x1bc" + serialized + modeSeq + (catchUp.reset ? "" : catchUp.data),
461
482
  capped: this.outputBytes > SERIALIZED_CAPPED_HINT_BYTES,
462
483
  ...snapshotFlags()
463
484
  } satisfies ServerMessage));
485
+ client.awaitingSnapshot = false;
464
486
  // No wiggle: the serialized grid IS the current screen.
465
487
  }, serializedScrollback);
466
488
  } else {
@@ -923,7 +945,16 @@ export class Room extends EventEmitter {
923
945
 
924
946
  private broadcast(message: ServerMessage) {
925
947
  const payload = JSON.stringify(message);
948
+ const isOutput = (message as { type?: string }).type === "output";
926
949
  for (const client of this.clients.values()) {
950
+ // A client whose serialized snapshot is still being prepared must not
951
+ // receive live output yet: the snapshot opens with an in-band RIS, so
952
+ // output sent before it would be ERASED by it — and the serialized
953
+ // grid does not contain those bytes either (they queued behind the
954
+ // serialize marker). That silent loss was 'the screen is stale until
955
+ // the app repaints' on every attach that raced live output. The
956
+ // withheld bytes are delivered as catch-up appended to the snapshot.
957
+ if (isOutput && client.awaitingSnapshot) continue;
927
958
  if (client.socket.isOpen()) {
928
959
  client.socket.send(payload);
929
960
  }
@@ -780,6 +780,36 @@ describe("Room", () => {
780
780
  expect(snapshot.capped).toBe(true);
781
781
  });
782
782
 
783
+ // THE ordering bug behind 'text doesn't update until I scroll' surviving
784
+ // the serialized-attach fix: serialization is ASYNC, the client is already
785
+ // in the broadcast list, so live output landed before the snapshot — and
786
+ // the snapshot's in-band RIS then erased it, while the serialized grid
787
+ // didn't contain those bytes either (they queue behind the serialize
788
+ // marker). Output during the window must be withheld and delivered as
789
+ // catch-up inside the snapshot payload itself.
790
+ it("output racing the serialize is withheld and appended to the snapshot", async () => {
791
+ const { room, pty } = await setup("race");
792
+ pty().emit("\x1b[2J\x1b[1;1Hpre-attach content\r\n");
793
+ const socket = new FakeSocket();
794
+ room.attachClient(
795
+ { id: "racer", name: "Racer", colorIndex: 0, cols: 80, rows: 24, replayBytes: 65536 },
796
+ socket
797
+ );
798
+ // Arrives while the snapshot is still serializing (same tick).
799
+ pty().emit("LATE-MARKER-XYZ\r\n");
800
+ const snapshot = await waitForSnapshot(socket);
801
+ const messages = readMessages(socket);
802
+ const snapIdx = messages.findIndex((m) => m.type === "snapshot");
803
+ const outputBefore = messages.slice(0, snapIdx).filter((m) => m.type === "output");
804
+ expect(outputBefore).toHaveLength(0);
805
+ expect(String(snapshot.data)).toContain("LATE-MARKER-XYZ");
806
+ // After the snapshot the stream flows live again, no duplication.
807
+ pty().emit("AFTER-MARKER\r\n");
808
+ const after = readMessages(socket).filter((m) => m.type === "output");
809
+ expect(after.some((m) => String(m.data).includes("AFTER-MARKER"))).toBe(true);
810
+ expect(after.some((m) => String(m.data).includes("LATE-MARKER-XYZ"))).toBe(false);
811
+ });
812
+
783
813
  it("replay=0 means no snapshot at all — live stream only", async () => {
784
814
  const { room, pty } = await setup("claim");
785
815
  pty().emit("some prior output\r\n");
package/hop CHANGED
@@ -4090,6 +4090,23 @@ function claudeTranscriptExists(rec) {
4090
4090
  return resolveClaudeTranscriptPath(rec) !== null;
4091
4091
  }
4092
4092
 
4093
+ // Restore patience must scale with the conversation being resumed. The
4094
+ // machinery gave every session the same 90s: right for a 50MB transcript,
4095
+ // hopeless for a 1GB one — claude spends minutes just LOADING it, the
4096
+ // verifier declares 'never started', the answer window closes, and the
4097
+ // session lands on a shell (or parks at an unanswered resume menu). Both
4098
+ // were observed on the 2026-08-05 cycle. Budget: a base plus ~1s per 10MB,
4099
+ // capped — a 1GB transcript gets ~8 minutes to come up.
4100
+ function restorePatienceMs(entry) {
4101
+ let bytes = 0;
4102
+ try {
4103
+ const tPath = resolveClaudeTranscriptPath(readClaudeSessionRecord(entry.internalName));
4104
+ if (tPath) bytes = fs.statSync(tPath).size;
4105
+ } catch (e) { /* no transcript — base patience */ }
4106
+ const scaled = 90_000 + Math.floor(bytes / 10_000_000) * 1000;
4107
+ return Math.min(8 * 60_000, scaled);
4108
+ }
4109
+
4093
4110
  // Rebuild a restorable claude command from a recorded argv string (the hook's
4094
4111
  // launchCmd): strip --resume/--continue and POSITIONAL arguments (an initial
4095
4112
  // prompt must not be re-sent into the resumed conversation); keep flags and
@@ -4371,9 +4388,13 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4371
4388
  return !p || SHELL_NAMES.has(p.toLowerCase());
4372
4389
  };
4373
4390
  const outcomes = new Map();
4374
- const DEADLINE_MS = 90_000;
4391
+ // Per-job deadline, scaled to the transcript being resumed (see
4392
+ // restorePatienceMs) — one giant session must neither be abandoned at
4393
+ // 90s nor hold every other session's verification hostage.
4394
+ for (const job of pending) job.deadlineMs = restorePatienceMs(job.plan);
4375
4395
  const startedAt = Date.now();
4376
- while (pending.length && Date.now() - startedAt < DEADLINE_MS) {
4396
+ const anyAlive = () => pending.some((j) => Date.now() - startedAt < j.deadlineMs);
4397
+ while (pending.length && anyAlive()) {
4377
4398
  await new Promise((r) => setTimeout(r, 700));
4378
4399
  let rooms = [];
4379
4400
  try {
@@ -4389,6 +4410,11 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4389
4410
  try { closeTerminalApiEntry(job.entry.id); } catch (e) { /* ignore */ }
4390
4411
  pending.splice(i, 1);
4391
4412
  };
4413
+ // A job past ITS OWN deadline expires individually — it must not
4414
+ // keep consuming ticks (or type into a session) while healthier
4415
+ // jobs finish around it.
4416
+ if (Date.now() - startedAt >= (job.deadlineMs || 90_000)) { finish('timeout'); continue; }
4417
+
4392
4418
  if (!room) { finish('room-missing'); continue; }
4393
4419
  const running = !isShellProc(room.foregroundProcess);
4394
4420
  if (job.state === 'wait-ready') {
@@ -4422,7 +4448,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4422
4448
  if (!promptVisible) {
4423
4449
  // Nothing to safely type into (blank screen, app booting).
4424
4450
  // Keep waiting; the verify deadline bounds the wait.
4425
- if (Date.now() - job.since > 30_000) { finish('never-started'); }
4451
+ if (Date.now() - job.since > Math.max(30_000, (job.deadlineMs || 90_000) / 3)) { finish('never-started'); }
4426
4452
  continue;
4427
4453
  }
4428
4454
  // A fresh shell reports its own name once it has execed; that
@@ -4474,7 +4500,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
4474
4500
  if (job.state === 'answer-prompt') {
4475
4501
  // Bounded: a conversation small enough not to ask is the
4476
4502
  // common case, so give up quickly and call it resumed.
4477
- if (Date.now() - job.since > 45_000) { finish('resumed'); continue; }
4503
+ if (Date.now() - job.since > Math.max(45_000, (job.deadlineMs || 90_000) / 2)) { finish('resumed'); continue; }
4478
4504
  let screen = '';
4479
4505
  try {
4480
4506
  const grid = await getRoomGridPreviewShared(job.plan.internalName);
@@ -11708,7 +11734,7 @@ async function attachToAllSessions(existingTunnel) {
11708
11734
  const KNOWN_COMMANDS = [
11709
11735
  'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
11710
11736
  'help', 'config', 'url', 'qr', 'health', 'domain', 'domain-clear',
11711
- 'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'claude-skill', 'agent-skill', 'math', 'new'
11737
+ 'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'new'
11712
11738
  ];
11713
11739
 
11714
11740
  // Suggest the closest known command for typos (small edit distances only).
@@ -12806,6 +12832,73 @@ async function main() {
12806
12832
  process.exit(runMathCli(process.argv.slice(3)));
12807
12833
  }
12808
12834
 
12835
+ // hop view <file...> — publish a renderable artifact (HTML, PDF, image)
12836
+ // behind hop's auth and print the link. The browser is the viewer: every
12837
+ // hop client either is one (web) or carries one (the iOS app opens these
12838
+ // in an in-app sheet with the session cookie). Files land under the same
12839
+ // served /assets/ root the briefing uses — no new endpoint, auth-gated by
12840
+ // the session cookie like everything else. Written for agents (the
12841
+ // hop-session skill teaches it): show the human the plot, don't describe it.
12842
+ if (process.argv[2] === 'view') {
12843
+ const files = process.argv.slice(3).filter((a) => !a.startsWith('-'));
12844
+ if (files.length === 0) {
12845
+ log('Usage: hop view <file...> (HTML, PDF, images — anything a browser renders)', YELLOW);
12846
+ process.exit(1);
12847
+ }
12848
+ const state = isServerRunning();
12849
+ if (!state) { printNotRunningError(); process.exit(1); }
12850
+ const session = process.env.HOP_SESSION || 'shared';
12851
+ const destDir = path.join(HAY_WEB_DIR, 'assets', 'view', session);
12852
+ fs.mkdirSync(destDir, { recursive: true });
12853
+ let failed = 0;
12854
+ for (const file of files) {
12855
+ const src = path.resolve(file);
12856
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile()) {
12857
+ log(`hop view: no such file: ${file}`, RED);
12858
+ failed += 1;
12859
+ continue;
12860
+ }
12861
+ // Basename only — the URL should read like the file, and the
12862
+ // session subdir already namespaces collisions between agents.
12863
+ const dest = path.join(destDir, path.basename(src));
12864
+ fs.copyFileSync(src, dest);
12865
+ console.log(`View: ${state.url}/assets/view/${encodeURIComponent(session)}/${encodeURIComponent(path.basename(src))}`);
12866
+ }
12867
+ if (failed === 0 && files.length > 0) {
12868
+ // One bell: the artifact is usually the deliverable the human is
12869
+ // waiting on, and the link scrolls away if nothing pings them.
12870
+ process.stdout.write('\x07');
12871
+ }
12872
+ // The virtual folder: clients browse every published artifact through
12873
+ // one manifest. REBUILT from a disk scan on every publish rather than
12874
+ // read-modify-written, so two agents publishing at once cannot lose
12875
+ // each other's entries — last writer wins with the same complete list.
12876
+ try {
12877
+ const viewRoot = path.join(HAY_WEB_DIR, 'assets', 'view');
12878
+ const entries = [];
12879
+ for (const sess of fs.readdirSync(viewRoot)) {
12880
+ const dir = path.join(viewRoot, sess);
12881
+ if (!fs.statSync(dir).isDirectory()) continue;
12882
+ for (const f of fs.readdirSync(dir)) {
12883
+ if (f === 'manifest.json') continue;
12884
+ const st = fs.statSync(path.join(dir, f));
12885
+ if (!st.isFile()) continue;
12886
+ entries.push({
12887
+ session: sess,
12888
+ name: f,
12889
+ path: `/assets/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}`,
12890
+ bytes: st.size,
12891
+ mtime: Math.floor(st.mtimeMs / 1000)
12892
+ });
12893
+ }
12894
+ }
12895
+ entries.sort((a, b) => b.mtime - a.mtime);
12896
+ fs.writeFileSync(path.join(viewRoot, 'manifest.json'),
12897
+ JSON.stringify({ items: entries }, null, 1));
12898
+ } catch { /* the links printed above still work without a manifest */ }
12899
+ process.exit(failed > 0 ? 1 : 0);
12900
+ }
12901
+
12809
12902
  if (process.argv[2] === '--help' || process.argv[2] === '-h' || process.argv[2] === 'help') {
12810
12903
  printFullHelp();
12811
12904
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.8",
3
+ "version": "0.11.9",
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": {
@@ -203,7 +203,7 @@ const releaseLock = () => {
203
203
  try { log(execSync('hop status 2>&1 | head -8', { encoding: 'utf8', env, timeout: 60000 })); } catch (e) { log('status err: ' + e.message); }
204
204
  await sleep(2000);
205
205
  log('running hop restore…');
206
- try { log(execSync('hop restore 2>&1 | tail -20', { encoding: 'utf8', env, timeout: 120000 })); } catch (e) { log('restore err: ' + e.message); }
206
+ try { log(execSync('hop restore 2>&1 | tail -20', { encoding: 'utf8', env, timeout: 600000 })); } catch (e) { log('restore err: ' + e.message); }
207
207
 
208
208
  // 4. Verify: new host pid + reconcile the restored set + probe env.
209
209
  await sleep(3000);
@@ -246,17 +246,17 @@ const releaseLock = () => {
246
246
  // command a human would use, then re-checked.
247
247
  let pendingClaude = new Set(claudeRooms);
248
248
  let resumeNudged = false;
249
- for (let i = 0; i < 18 && pendingClaude.size > 0; i++) {
249
+ for (let i = 0; i < 60 && pendingClaude.size > 0; i++) {
250
250
  for (const id of [...pendingClaude]) {
251
251
  const text = await roomPreviewText(id);
252
252
  if (CLAUDE_UI_RE.test(text) && !RESUME_PROMPT_RE.test(text)) { pendingClaude.delete(id); continue; }
253
253
  if (RESUME_PROMPT_RE.test(text) && !resumeNudged) {
254
254
  resumeNudged = true;
255
255
  log('resume question on screen — running hop resume-waiting');
256
- try { log(execSync('hop resume-waiting 2>&1 | tail -5', { encoding: 'utf8', timeout: 90000 })); } catch (e) { log('resume-waiting err: ' + e.message); }
256
+ try { log(execSync('hop resume-waiting 2>&1 | tail -5', { encoding: 'utf8', timeout: 300000 })); } catch (e) { log('resume-waiting err: ' + e.message); }
257
257
  }
258
258
  }
259
- if (pendingClaude.size > 0) await sleep(5000);
259
+ if (pendingClaude.size > 0) await sleep(8000);
260
260
  }
261
261
  for (const id of pendingClaude) problems.push(`${id}: claude chrome never appeared (still starting, or resume failed)`);
262
262
  log(`VERIFY: ${Object.keys(preSessions).filter((k) => preSessions[k].live).length} live sessions checked, ` +
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: hop-session
3
- description: Conventions for agents working inside a hop terminal session (the HOP_SESSION environment variable is set). Humans watch these terminals live, often from a phone. Covers rendering math with hop math, ringing the bell for attention, and phone-friendly output.
3
+ description: Conventions for agents working inside a hop terminal session (the HOP_SESSION environment variable is set). Humans watch these terminals live, often from a phone. Covers rendering math with hop math, publishing viewable files (HTML/PDF/images) with hop view, ringing the bell for attention, and phone-friendly output.
4
4
  ---
5
5
 
6
6
  # Working in a hop terminal
@@ -24,6 +24,22 @@ formula in your response; do not repeat the raw LaTeX next to it. When you
24
24
  need the rendered text inside a file or message, capture it:
25
25
  `hop math '<latex>'` and copy the command's output verbatim.
26
26
 
27
+ ## Showing files a terminal cannot render (HTML, PDF, images)
28
+
29
+ When the deliverable is a plot, a figure, an HTML report, a PDF, or any
30
+ image, do not describe it — publish it and hand over the link:
31
+
32
+ ```bash
33
+ hop view results/roc_curve.png
34
+ hop view report.html analysis.pdf
35
+ ```
36
+
37
+ `hop view` copies the file behind hop's auth and prints a `View:` URL. The
38
+ human taps it and their browser renders it — on the desktop wall or from the
39
+ phone app, which opens these in-app. It rings the bell once for you, since a
40
+ published artifact is usually the thing the human is waiting on. Re-running
41
+ it after updating the file re-publishes under the same link.
42
+
27
43
  ## Asking for attention
28
44
 
29
45
  When you are blocked on the human — a question, a decision, a finished