hop2 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/AGENTS.md +33 -0
  2. package/INTEGRATION.md +77 -85
  3. package/MULTI-AGENT-CHECKLIST.md +37 -0
  4. package/README-MCP.md +232 -0
  5. package/README.md +209 -148
  6. package/demo/30s-product-demo.md +98 -0
  7. package/demo/README.md +106 -0
  8. package/demo/director.mjs +236 -0
  9. package/demo/drive-hop-terminal.mjs +70 -0
  10. package/demo/hop-demo-lib.mjs +525 -0
  11. package/demo/record-hop-view.mjs +26 -0
  12. package/demo/static-preview-server.mjs +45 -0
  13. package/demo/stitch-demo-video.mjs +128 -0
  14. package/hay/CHANGELOG.md +6 -0
  15. package/hay/DEV_NOTES.md +25 -0
  16. package/hay/README.md +136 -198
  17. package/hay/apps/cli/src/index.ts +321 -67
  18. package/hay/apps/server/src/index.ts +4 -3
  19. package/hay/apps/server/src/lib.ts +5 -4
  20. package/hay/apps/server/src/pty.ts +20 -7
  21. package/hay/apps/server/src/rooms.ts +226 -12
  22. package/hay/apps/server/test/rooms.test.ts +82 -3
  23. package/hay/apps/web/src/App.tsx +487 -112
  24. package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
  25. package/hay/apps/web/src/styles.css +259 -29
  26. package/hay/package-lock.json +4 -4
  27. package/hay/packages/shared/src/protocol.ts +8 -0
  28. package/hay-web/assets/index-BmWjq_Nh.css +32 -0
  29. package/hay-web/assets/index-D9aOC5Zt.js +63 -0
  30. package/hay-web/assets/{index-FDfdolCs.js.map → index-D9aOC5Zt.js.map} +1 -1
  31. package/hay-web/index.html +2 -2
  32. package/hop +6801 -1714
  33. package/lib/naming-themes.js +194 -0
  34. package/lib/terminal-access.js +17 -0
  35. package/mcp/hop-mcp.js +4730 -0
  36. package/package.json +6 -3
  37. package/scripts/hay-host.js +189 -0
  38. package/sessions.html +378 -26
  39. package/setup-mcp.js +443 -0
  40. package/tests/hop-mcp.integration.test.cjs +3014 -0
  41. package/tests/terminal-access.unit.test.cjs +21 -0
  42. package/tests/terminal-api.integration.test.cjs +298 -0
  43. package/.hop-tmux.conf +0 -18
  44. package/hay-web/assets/index-CTTzApRp.css +0 -32
  45. package/hay-web/assets/index-FDfdolCs.js +0 -60
  46. package/tunnel-shell.js +0 -254
@@ -0,0 +1,236 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ buildSanitizedLaunchCommand,
5
+ createSession,
6
+ ensureTerminal,
7
+ execTerminal,
8
+ findFreePort,
9
+ getHopState,
10
+ parseArgs,
11
+ prepareSanitizedWorkspace,
12
+ recordHopView,
13
+ resizeTerminal,
14
+ sleep,
15
+ startHopDaemon,
16
+ startStaticPreviewServer,
17
+ stopHopDaemon,
18
+ waitForTerminalOutput,
19
+ writeTerminal
20
+ } from "./hop-demo-lib.mjs";
21
+
22
+ function readPrompt(args, key, fallback) {
23
+ const fileKey = `${key}-file`;
24
+ if (args[fileKey]) {
25
+ return fs.readFileSync(path.resolve(args[fileKey]), "utf8").trim();
26
+ }
27
+ if (args[key]) {
28
+ return String(args[key]).trim();
29
+ }
30
+ return fallback;
31
+ }
32
+
33
+ function resolvePreset(args) {
34
+ const preset = args.preset || "summary";
35
+ if (preset === "landing-page") {
36
+ return {
37
+ prompt: "Open index.html and turn it into a more polished, visually striking product landing page for Hop. Keep it as a single static page. Make the changes directly.",
38
+ followup: "Now tighten the headline and hero copy so it reads like a product demo page in one quick scan."
39
+ };
40
+ }
41
+ return {
42
+ prompt: "Summarize the current project in 3 short bullets and suggest one safe improvement. Do not mention any local usernames, hostnames, or file paths.",
43
+ followup: "Now restate the improvement as one short sentence fit for an on-screen demo caption. Do not mention local paths."
44
+ };
45
+ }
46
+
47
+ async function bestEffortOutputWait(state, terminalId, timeoutMs) {
48
+ try {
49
+ await waitForTerminalOutput(state, terminalId, { timeoutMs });
50
+ } catch {
51
+ // Demo capture should keep moving even if a terminal stream reconnect is noisy.
52
+ }
53
+ }
54
+
55
+ async function ensurePortSession(state, name, port) {
56
+ try {
57
+ await createSession(state, {
58
+ name,
59
+ type: "port",
60
+ port
61
+ });
62
+ } catch (error) {
63
+ if (!String(error.message || error).includes("already in use")) {
64
+ throw error;
65
+ }
66
+ }
67
+ }
68
+
69
+ const args = parseArgs(process.argv.slice(2));
70
+ const outRoot = path.resolve(args.out || path.join("demo-output", `run-${Date.now()}`));
71
+ const isolatedHopHome = path.resolve(args["hop-home"] || path.join(outRoot, "hop-home"));
72
+ const startDaemon = args["start-daemon"] === true || args["start-daemon"] === "true";
73
+ const stopDaemon = args["stop-daemon"] === true || args["stop-daemon"] === "true";
74
+ const sessionName = args.name || "demo-agent";
75
+ const previewSessionName = args["preview-name"] || "demo-preview";
76
+ const workspaceDir = path.resolve(args.cwd || path.join(outRoot, "workspace"));
77
+ const baseLaunchCommand = args.launch || "codex";
78
+ const commandSettleMs = Number(args["command-settle"] || 1000);
79
+ const desktopDurationMs = Number(args["desktop-duration"] || 8000);
80
+ const followupDurationMs = Number(args["followup-duration"] || 5000);
81
+ const previewDurationMs = Number(args["preview-duration"] || 3500);
82
+ const mobileDurationMs = Number(args["mobile-duration"] || 3500);
83
+ const sessionsDurationMs = Number(args["sessions-duration"] || 2500);
84
+ const desktopWidth = Number(args.width || 1920);
85
+ const desktopHeight = Number(args.height || 1080);
86
+ const skipSessions = args["skip-sessions"] === true || args["skip-sessions"] === "true";
87
+ const skipPreview = args["skip-preview"] === true || args["skip-preview"] === "true";
88
+ const skipMobile = args["skip-mobile"] === true || args["skip-mobile"] === "true";
89
+
90
+ const preset = resolvePreset(args);
91
+ const prompt = readPrompt(args, "prompt", preset.prompt);
92
+ const followup = readPrompt(args, "followup", preset.followup);
93
+
94
+ fs.mkdirSync(outRoot, { recursive: true });
95
+ prepareSanitizedWorkspace(workspaceDir, {
96
+ reset: args["reset-workspace"] !== "false"
97
+ });
98
+
99
+ const launchCommand = buildSanitizedLaunchCommand(baseLaunchCommand, {
100
+ cwd: workspaceDir,
101
+ cwdLabel: "workspace",
102
+ homeDir: path.join(outRoot, "shell-home")
103
+ });
104
+
105
+ let state = startDaemon ? await startHopDaemon(isolatedHopHome) : getHopState(args["hop-home"]);
106
+ let previewServer = null;
107
+
108
+ try {
109
+ const terminal = await ensureTerminal(state, {
110
+ name: sessionName,
111
+ cwd: workspaceDir,
112
+ cols: 140,
113
+ rows: 40
114
+ });
115
+
116
+ await resizeTerminal(state, terminal.id, 140, 40);
117
+ await bestEffortOutputWait(state, terminal.id, 3000);
118
+
119
+ const allowedSessions = [sessionName];
120
+
121
+ if (!skipPreview) {
122
+ const previewPort = await findFreePort();
123
+ previewServer = await startStaticPreviewServer(workspaceDir, previewPort);
124
+ await ensurePortSession(state, previewSessionName, previewPort);
125
+ allowedSessions.push(previewSessionName);
126
+ }
127
+
128
+ const manifest = {
129
+ outRoot,
130
+ sessionName,
131
+ previewSessionName: skipPreview ? null : previewSessionName,
132
+ localUrl: state.localUrl,
133
+ publicUrl: state.publicUrl,
134
+ clips: []
135
+ };
136
+
137
+ if (!skipSessions) {
138
+ const clipDir = path.join(outRoot, "01-sessions");
139
+ await recordHopView(state, {
140
+ outDir: clipDir,
141
+ width: desktopWidth,
142
+ height: desktopHeight,
143
+ allowedSessions,
144
+ pageMode: "session-picker",
145
+ durationMs: sessionsDurationMs,
146
+ settleMs: 500
147
+ });
148
+ manifest.clips.push({ name: "sessions", dir: clipDir });
149
+ }
150
+
151
+ await execTerminal(state, terminal.id, launchCommand);
152
+ await bestEffortOutputWait(state, terminal.id, 6000);
153
+ await sleep(commandSettleMs);
154
+
155
+ if (prompt) {
156
+ await writeTerminal(state, terminal.id, `${prompt}\n`);
157
+ await bestEffortOutputWait(state, terminal.id, 2000);
158
+ }
159
+
160
+ const liveDir = path.join(outRoot, "02-agent-live");
161
+ await recordHopView(state, {
162
+ session: sessionName,
163
+ outDir: liveDir,
164
+ width: desktopWidth,
165
+ height: desktopHeight,
166
+ allowedSessions: [sessionName],
167
+ durationMs: desktopDurationMs,
168
+ settleMs: 500
169
+ });
170
+ manifest.clips.push({ name: "agent-live", dir: liveDir });
171
+
172
+ if (followup) {
173
+ await writeTerminal(state, terminal.id, `${followup}\n`);
174
+ await bestEffortOutputWait(state, terminal.id, 1500);
175
+
176
+ const followupDir = path.join(outRoot, "03-agent-redirect");
177
+ await recordHopView(state, {
178
+ session: sessionName,
179
+ outDir: followupDir,
180
+ width: desktopWidth,
181
+ height: desktopHeight,
182
+ allowedSessions: [sessionName],
183
+ durationMs: followupDurationMs,
184
+ settleMs: 500
185
+ });
186
+ manifest.clips.push({ name: "agent-redirect", dir: followupDir });
187
+ }
188
+
189
+ if (!skipPreview) {
190
+ const previewDir = path.join(outRoot, "04-preview");
191
+ await recordHopView(state, {
192
+ session: previewSessionName,
193
+ outDir: previewDir,
194
+ width: desktopWidth,
195
+ height: desktopHeight,
196
+ allowedSessions,
197
+ durationMs: previewDurationMs,
198
+ settleMs: 500
199
+ });
200
+ manifest.clips.push({ name: "preview", dir: previewDir });
201
+ }
202
+
203
+ if (!skipMobile) {
204
+ const mobileDir = path.join(outRoot, skipPreview ? "04-mobile" : "05-mobile");
205
+ await recordHopView(state, {
206
+ session: sessionName,
207
+ outDir: mobileDir,
208
+ mobile: true,
209
+ allowedSessions: [sessionName],
210
+ durationMs: mobileDurationMs,
211
+ settleMs: 500
212
+ });
213
+ manifest.clips.push({ name: "mobile", dir: mobileDir });
214
+ }
215
+
216
+ const manifestPath = path.join(outRoot, "manifest.json");
217
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
218
+
219
+ console.log(JSON.stringify({
220
+ ok: true,
221
+ outRoot,
222
+ manifest: manifestPath,
223
+ sessionName,
224
+ previewSessionName: skipPreview ? null : previewSessionName,
225
+ workspaceDir,
226
+ hopHome: isolatedHopHome,
227
+ clips: manifest.clips
228
+ }, null, 2));
229
+ } finally {
230
+ if (previewServer) {
231
+ previewServer.kill("SIGTERM");
232
+ }
233
+ if (stopDaemon) {
234
+ stopHopDaemon(isolatedHopHome);
235
+ }
236
+ }
@@ -0,0 +1,70 @@
1
+ import path from "node:path";
2
+ import {
3
+ closeTerminal,
4
+ ensureTerminal,
5
+ execTerminal,
6
+ getHopState,
7
+ parseArgs,
8
+ resizeTerminal,
9
+ waitForTerminalOutput,
10
+ writeTerminal
11
+ } from "./hop-demo-lib.mjs";
12
+
13
+ const args = parseArgs(process.argv.slice(2));
14
+ const state = getHopState(args["hop-home"]);
15
+ const name = args.name || "demo-agent";
16
+ const cwd = args.cwd ? path.resolve(args.cwd) : process.cwd();
17
+ const cols = Number(args.cols || 140);
18
+ const rows = Number(args.rows || 40);
19
+ const kill = args.kill === true || args.kill === "true";
20
+ const readyTimeoutMs = Number(args["ready-timeout"] || 3000);
21
+ const skipReadyWait = args["skip-ready-wait"] === true || args["skip-ready-wait"] === "true";
22
+
23
+ const terminal = await ensureTerminal(state, {
24
+ name,
25
+ cwd,
26
+ cols,
27
+ rows
28
+ });
29
+
30
+ await resizeTerminal(state, terminal.id, cols, rows);
31
+
32
+ if (!skipReadyWait && (args.exec || args.write)) {
33
+ await waitForTerminalOutput(state, terminal.id, {
34
+ timeoutMs: readyTimeoutMs
35
+ });
36
+ }
37
+
38
+ if (args.exec) {
39
+ await execTerminal(state, terminal.id, String(args.exec));
40
+ }
41
+
42
+ if (args.write) {
43
+ await writeTerminal(state, terminal.id, String(args.write));
44
+ }
45
+
46
+ if (args["wait-pattern"] || args["wait-timeout"]) {
47
+ const waitResult = await waitForTerminalOutput(state, terminal.id, {
48
+ pattern: args["wait-pattern"],
49
+ timeoutMs: Number(args["wait-timeout"] || 15000)
50
+ });
51
+ console.log(JSON.stringify({
52
+ ok: true,
53
+ terminalId: terminal.id,
54
+ sessionName: terminal.sessionName,
55
+ displayName: terminal.displayName,
56
+ waitMatched: Boolean(waitResult.payload),
57
+ outputPreview: waitResult.text.slice(-500)
58
+ }, null, 2));
59
+ } else {
60
+ console.log(JSON.stringify({
61
+ ok: true,
62
+ terminalId: terminal.id,
63
+ sessionName: terminal.sessionName,
64
+ displayName: terminal.displayName
65
+ }, null, 2));
66
+ }
67
+
68
+ if (kill) {
69
+ await closeTerminal(state, terminal.id, true);
70
+ }