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.
- package/demo/capture/capture.mjs +49 -2
- package/demo/product-video-v2.md +64 -0
- package/docs/hero-mobile.svg +105 -0
- package/hay/apps/server/src/rooms.ts +33 -2
- package/hay/apps/server/test/rooms.test.ts +30 -0
- package/hop +98 -5
- package/package.json +1 -1
- package/scripts/salvage-and-restart-host.js +4 -4
- package/skills/hop-session/SKILL.md +17 -1
package/demo/capture/capture.mjs
CHANGED
|
@@ -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">  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"> 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"> 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"> </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"> Polaris</tspan><tspan fill="#f0883e"> · 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"> Aurora</tspan><tspan fill="#8d909b"> · 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"> 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"> 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())
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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:
|
|
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 <
|
|
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:
|
|
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(
|
|
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
|