hop2 0.8.0 → 0.9.1
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/AGENTS.md +33 -0
- package/MULTI-AGENT-CHECKLIST.md +30 -0
- package/README-MCP.md +213 -0
- package/README.md +117 -17
- package/hay/CHANGELOG.md +6 -0
- package/hay/apps/cli/src/index.ts +285 -48
- package/hay/apps/server/src/index.ts +4 -3
- package/hay/apps/server/src/lib.ts +4 -3
- package/hay/apps/server/src/pty.ts +20 -7
- package/hay/apps/server/src/rooms.ts +93 -12
- package/hay/apps/server/test/rooms.test.ts +82 -3
- package/hay/apps/web/src/App.tsx +253 -40
- package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
- package/hay/apps/web/src/styles.css +9 -2
- package/hay/package-lock.json +4 -4
- package/hay/packages/shared/src/protocol.ts +4 -0
- package/hay-web/assets/{index-CTTzApRp.css → index-B7rkCY1_.css} +1 -1
- package/hay-web/assets/index-kr4B-B06.js +60 -0
- package/hay-web/assets/{index-FDfdolCs.js.map → index-kr4B-B06.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +6150 -1670
- package/hop.bak2 +8071 -0
- package/lib/terminal-access.js +17 -0
- package/mcp/hop-mcp.js +4530 -0
- package/mcp/hop-mcp.js.bak +4492 -0
- package/mcp/hop-mcp.js.bak2 +4530 -0
- package/package.json +6 -3
- package/scripts/hay-host.js +189 -0
- package/sessions.html +314 -26
- package/setup-mcp.js +443 -0
- package/tests/hop-mcp.integration.test.cjs +3014 -0
- package/tests/terminal-access.unit.test.cjs +21 -0
- package/tests/terminal-api.integration.test.cjs +298 -0
- package/hay-web/assets/index-FDfdolCs.js +0 -60
|
@@ -21,6 +21,18 @@ export type ClientInfo = {
|
|
|
21
21
|
rows: number;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export type RoomCreateOptions = {
|
|
25
|
+
cwd: string;
|
|
26
|
+
env?: Record<string, string>;
|
|
27
|
+
shell?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type RoomSummary = {
|
|
31
|
+
id: string;
|
|
32
|
+
cwd: string;
|
|
33
|
+
clientCount: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
24
36
|
const MAX_BUFFER_SIZE = 200_000;
|
|
25
37
|
// Keep rooms alive indefinitely; only explicit kill/remove should end a session.
|
|
26
38
|
const CLEANUP_DELAY_MS = 0;
|
|
@@ -67,6 +79,7 @@ class ClientState {
|
|
|
67
79
|
export class Room extends EventEmitter {
|
|
68
80
|
id: string;
|
|
69
81
|
private pty: IPty;
|
|
82
|
+
private readonly cwd: string;
|
|
70
83
|
private clients = new Map<string, ClientState>();
|
|
71
84
|
private collabMode = true;
|
|
72
85
|
private controllerId: string | null = null;
|
|
@@ -80,10 +93,17 @@ export class Room extends EventEmitter {
|
|
|
80
93
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
81
94
|
private ended = false;
|
|
82
95
|
|
|
83
|
-
constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number },
|
|
96
|
+
constructor(id: string, ptyFactory: PtyFactory, initialSize: { cols: number; rows: number }, options: RoomCreateOptions) {
|
|
84
97
|
super();
|
|
85
98
|
this.id = id;
|
|
86
|
-
this.
|
|
99
|
+
this.cwd = options.cwd;
|
|
100
|
+
this.pty = ptyFactory({
|
|
101
|
+
cols: initialSize.cols,
|
|
102
|
+
rows: initialSize.rows,
|
|
103
|
+
cwd: options.cwd,
|
|
104
|
+
env: options.env,
|
|
105
|
+
shell: options.shell
|
|
106
|
+
});
|
|
87
107
|
this.activeCols = initialSize.cols;
|
|
88
108
|
this.activeRows = initialSize.rows;
|
|
89
109
|
|
|
@@ -91,6 +111,7 @@ export class Room extends EventEmitter {
|
|
|
91
111
|
this.outputBuffer = clampBuffer(this.outputBuffer + data);
|
|
92
112
|
this.updateTerminalState(data);
|
|
93
113
|
this.broadcast({ type: "output", data });
|
|
114
|
+
this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
|
|
94
115
|
});
|
|
95
116
|
|
|
96
117
|
const handleExit = (exitCode?: number | null, signal?: number | string) => {
|
|
@@ -243,17 +264,29 @@ export class Room extends EventEmitter {
|
|
|
243
264
|
}
|
|
244
265
|
}
|
|
245
266
|
|
|
267
|
+
let stateChanged = false;
|
|
246
268
|
if (nextAlternate !== this.alternateScreen) {
|
|
247
269
|
if (DEBUG_STATE) {
|
|
248
270
|
console.log(`[hay] room=${this.id} alternateScreen=${nextAlternate}`);
|
|
249
271
|
}
|
|
250
272
|
this.alternateScreen = nextAlternate;
|
|
273
|
+
stateChanged = true;
|
|
251
274
|
}
|
|
252
275
|
if (nextCursorHidden !== this.cursorHidden) {
|
|
253
276
|
if (DEBUG_STATE) {
|
|
254
277
|
console.log(`[hay] room=${this.id} cursorHidden=${nextCursorHidden}`);
|
|
255
278
|
}
|
|
256
279
|
this.cursorHidden = nextCursorHidden;
|
|
280
|
+
stateChanged = true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (stateChanged) {
|
|
284
|
+
this.emit("pty_state", {
|
|
285
|
+
roomId: this.id,
|
|
286
|
+
alternateScreen: this.alternateScreen,
|
|
287
|
+
cursorHidden: this.cursorHidden,
|
|
288
|
+
timestamp: now()
|
|
289
|
+
});
|
|
257
290
|
}
|
|
258
291
|
|
|
259
292
|
this.controlSequenceTail = combined.slice(-CONTROL_SEQUENCE_TAIL);
|
|
@@ -264,10 +297,31 @@ export class Room extends EventEmitter {
|
|
|
264
297
|
clearTimeout(this.cleanupTimer);
|
|
265
298
|
this.cleanupTimer = null;
|
|
266
299
|
}
|
|
267
|
-
|
|
300
|
+
try {
|
|
301
|
+
this.pty.kill();
|
|
302
|
+
} catch (e) {
|
|
303
|
+
/* swallow – PTY may already be dead */
|
|
304
|
+
}
|
|
268
305
|
this.endSession({ exitCode: null, signal: null, message: "Session terminated" });
|
|
269
306
|
}
|
|
270
307
|
|
|
308
|
+
notifySessionRenamed(displayName: string) {
|
|
309
|
+
this.broadcast({ type: "session_renamed", displayName });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
sendSystemInput(data: string, source = "system") {
|
|
313
|
+
if (!data) return;
|
|
314
|
+
this.pty.write(data);
|
|
315
|
+
this.emit("pty_input", {
|
|
316
|
+
roomId: this.id,
|
|
317
|
+
clientId: null,
|
|
318
|
+
actor: "system",
|
|
319
|
+
source,
|
|
320
|
+
data,
|
|
321
|
+
timestamp: now()
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
271
325
|
private handleInput(client: ClientState, data: string) {
|
|
272
326
|
if (!this.collabMode && this.controllerId !== client.id) {
|
|
273
327
|
client.socket.send(
|
|
@@ -277,6 +331,7 @@ export class Room extends EventEmitter {
|
|
|
277
331
|
}
|
|
278
332
|
client.lastActive = now();
|
|
279
333
|
this.pty.write(data);
|
|
334
|
+
this.emit("pty_input", { roomId: this.id, clientId: client.id, data, timestamp: now() });
|
|
280
335
|
}
|
|
281
336
|
|
|
282
337
|
private handleResize(client: ClientState, cols: number, rows: number) {
|
|
@@ -297,6 +352,7 @@ export class Room extends EventEmitter {
|
|
|
297
352
|
this.activeClientId = client.id;
|
|
298
353
|
// Broadcast the new active size to other clients so they can adjust
|
|
299
354
|
this.broadcastActiveSize(client);
|
|
355
|
+
this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
|
|
300
356
|
}
|
|
301
357
|
}
|
|
302
358
|
|
|
@@ -369,32 +425,49 @@ export class Room extends EventEmitter {
|
|
|
369
425
|
}
|
|
370
426
|
const message = JSON.stringify({ type: "session_ended", ...payload } satisfies ServerMessage);
|
|
371
427
|
for (const client of this.clients.values()) {
|
|
372
|
-
|
|
373
|
-
client.socket.
|
|
428
|
+
try {
|
|
429
|
+
if (client.socket.isOpen()) {
|
|
430
|
+
client.socket.send(message);
|
|
431
|
+
}
|
|
432
|
+
client.socket.close();
|
|
433
|
+
} catch (e) {
|
|
434
|
+
/* swallow – don't let one bad socket kill the loop */
|
|
374
435
|
}
|
|
375
|
-
client.socket.close();
|
|
376
436
|
}
|
|
377
437
|
this.clients.clear();
|
|
438
|
+
this.emit("session_end", { roomId: this.id, ...payload, timestamp: now() });
|
|
378
439
|
this.emit("empty");
|
|
379
440
|
}
|
|
441
|
+
|
|
442
|
+
getSummary(): RoomSummary {
|
|
443
|
+
return {
|
|
444
|
+
id: this.id,
|
|
445
|
+
cwd: this.cwd,
|
|
446
|
+
clientCount: this.clients.size
|
|
447
|
+
};
|
|
448
|
+
}
|
|
380
449
|
}
|
|
381
450
|
|
|
382
451
|
export class RoomManager {
|
|
383
452
|
private rooms = new Map<string, Room>();
|
|
384
453
|
private ptyFactory: PtyFactory;
|
|
385
|
-
private cwd?: string;
|
|
386
454
|
|
|
387
|
-
constructor(ptyFactory: PtyFactory
|
|
455
|
+
constructor(ptyFactory: PtyFactory) {
|
|
388
456
|
this.ptyFactory = ptyFactory;
|
|
389
|
-
this.cwd = cwd;
|
|
390
457
|
}
|
|
391
458
|
|
|
392
|
-
getRoom(roomId: string, initialSize: { cols: number; rows: number },
|
|
459
|
+
getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwdOrOptions: string | RoomCreateOptions) {
|
|
393
460
|
const existing = this.rooms.get(roomId);
|
|
394
461
|
if (existing) {
|
|
395
462
|
return existing;
|
|
396
463
|
}
|
|
397
|
-
const
|
|
464
|
+
const options = typeof cwdOrOptions === "string"
|
|
465
|
+
? { cwd: cwdOrOptions }
|
|
466
|
+
: cwdOrOptions;
|
|
467
|
+
if (!options.cwd) {
|
|
468
|
+
throw new Error(`[RoomManager] cwd is required when creating room "${roomId}"`);
|
|
469
|
+
}
|
|
470
|
+
const room = new Room(roomId, this.ptyFactory, initialSize, options);
|
|
398
471
|
room.on("empty", () => {
|
|
399
472
|
this.rooms.delete(roomId);
|
|
400
473
|
});
|
|
@@ -409,7 +482,11 @@ export class RoomManager {
|
|
|
409
482
|
closeRoom(roomId: string) {
|
|
410
483
|
const room = this.rooms.get(roomId);
|
|
411
484
|
if (!room) return;
|
|
412
|
-
|
|
485
|
+
try {
|
|
486
|
+
room.kill();
|
|
487
|
+
} catch (e) {
|
|
488
|
+
/* swallow – ensure we still remove the room from the map */
|
|
489
|
+
}
|
|
413
490
|
this.rooms.delete(roomId);
|
|
414
491
|
}
|
|
415
492
|
|
|
@@ -418,4 +495,8 @@ export class RoomManager {
|
|
|
418
495
|
this.closeRoom(roomId);
|
|
419
496
|
}
|
|
420
497
|
}
|
|
498
|
+
|
|
499
|
+
listRooms(): RoomSummary[] {
|
|
500
|
+
return Array.from(this.rooms.values()).map((room) => room.getSummary());
|
|
501
|
+
}
|
|
421
502
|
}
|
|
@@ -81,6 +81,25 @@ const findMessages = (socket: FakeSocket, type: string) => {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
describe("Room", () => {
|
|
84
|
+
it("passes room create options to the PTY factory", () => {
|
|
85
|
+
let capturedOptions: any = null;
|
|
86
|
+
const factory: PtyFactory = (options) => {
|
|
87
|
+
capturedOptions = options;
|
|
88
|
+
return new FakePty() as any;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const manager = new RoomManager(factory);
|
|
92
|
+
manager.getRoom("opts", { cols: 80, rows: 24 }, {
|
|
93
|
+
cwd: "/tmp/demo",
|
|
94
|
+
env: { HISTFILE: "/tmp/.history" },
|
|
95
|
+
shell: "/bin/zsh",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(capturedOptions?.cwd).toBe("/tmp/demo");
|
|
99
|
+
expect(capturedOptions?.env?.HISTFILE).toBe("/tmp/.history");
|
|
100
|
+
expect(capturedOptions?.shell).toBe("/bin/zsh");
|
|
101
|
+
});
|
|
102
|
+
|
|
84
103
|
it("broadcasts pty output to all clients", () => {
|
|
85
104
|
let ptyInstance: FakePty | null = null;
|
|
86
105
|
const factory: PtyFactory = () => {
|
|
@@ -89,7 +108,7 @@ describe("Room", () => {
|
|
|
89
108
|
};
|
|
90
109
|
|
|
91
110
|
const manager = new RoomManager(factory);
|
|
92
|
-
const room = manager.getRoom("alpha", { cols: 80, rows: 24 });
|
|
111
|
+
const room = manager.getRoom("alpha", { cols: 80, rows: 24 }, "/tmp");
|
|
93
112
|
const socketA = new FakeSocket();
|
|
94
113
|
const socketB = new FakeSocket();
|
|
95
114
|
|
|
@@ -113,7 +132,7 @@ describe("Room", () => {
|
|
|
113
132
|
};
|
|
114
133
|
|
|
115
134
|
const manager = new RoomManager(factory);
|
|
116
|
-
const room = manager.getRoom("bravo", { cols: 80, rows: 24 });
|
|
135
|
+
const room = manager.getRoom("bravo", { cols: 80, rows: 24 }, "/tmp");
|
|
117
136
|
const socketA = new FakeSocket();
|
|
118
137
|
const socketB = new FakeSocket();
|
|
119
138
|
|
|
@@ -130,7 +149,7 @@ describe("Room", () => {
|
|
|
130
149
|
|
|
131
150
|
it("updates presence on disconnect", () => {
|
|
132
151
|
const manager = new RoomManager(() => new FakePty() as any);
|
|
133
|
-
const room = manager.getRoom("charlie", { cols: 80, rows: 24 });
|
|
152
|
+
const room = manager.getRoom("charlie", { cols: 80, rows: 24 }, "/tmp");
|
|
134
153
|
const socketA = new FakeSocket();
|
|
135
154
|
const socketB = new FakeSocket();
|
|
136
155
|
|
|
@@ -143,4 +162,64 @@ describe("Room", () => {
|
|
|
143
162
|
const latest = presenceMessages.at(-1) as any;
|
|
144
163
|
expect(latest?.clients).toHaveLength(1);
|
|
145
164
|
});
|
|
165
|
+
|
|
166
|
+
it("emits room PTY lifecycle events for embedding servers", () => {
|
|
167
|
+
let ptyInstance: FakePty | null = null;
|
|
168
|
+
const factory: PtyFactory = () => {
|
|
169
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
170
|
+
return ptyInstance as any;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const manager = new RoomManager(factory);
|
|
174
|
+
const room = manager.getRoom("events", { cols: 80, rows: 24 }, "/tmp");
|
|
175
|
+
const socket = new FakeSocket();
|
|
176
|
+
room.attachClient({ id: "a", name: "Alex", colorIndex: 0, cols: 80, rows: 24 }, socket);
|
|
177
|
+
|
|
178
|
+
const inputs: any[] = [];
|
|
179
|
+
const outputs: any[] = [];
|
|
180
|
+
const resizes: any[] = [];
|
|
181
|
+
const states: any[] = [];
|
|
182
|
+
const ends: any[] = [];
|
|
183
|
+
room.on("pty_input", (payload) => inputs.push(payload));
|
|
184
|
+
room.on("pty_output", (payload) => outputs.push(payload));
|
|
185
|
+
room.on("pty_resize", (payload) => resizes.push(payload));
|
|
186
|
+
room.on("pty_state", (payload) => states.push(payload));
|
|
187
|
+
room.on("session_end", (payload) => ends.push(payload));
|
|
188
|
+
|
|
189
|
+
socket.emitMessage({ type: "input", data: "pwd\n" });
|
|
190
|
+
ptyInstance?.emit("ok\n");
|
|
191
|
+
ptyInstance?.emit("\x1b[?1049h");
|
|
192
|
+
ptyInstance?.emit("\x1b[?1049l");
|
|
193
|
+
socket.emitMessage({ type: "resize", cols: 100, rows: 30 });
|
|
194
|
+
room.kill();
|
|
195
|
+
|
|
196
|
+
expect(inputs.at(-1)?.clientId).toBe("a");
|
|
197
|
+
expect(inputs.at(-1)?.data).toBe("pwd\n");
|
|
198
|
+
expect(outputs.some((payload) => payload?.data === "ok\n")).toBe(true);
|
|
199
|
+
expect(resizes.at(-1)?.cols).toBe(100);
|
|
200
|
+
expect(resizes.at(-1)?.rows).toBe(30);
|
|
201
|
+
expect(states.at(0)?.alternateScreen).toBe(true);
|
|
202
|
+
expect(states.at(1)?.alternateScreen).toBe(false);
|
|
203
|
+
expect(ends.at(-1)?.message).toBe("Session terminated");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("supports system input writes for host-level session setup", () => {
|
|
207
|
+
let ptyInstance: FakePty | null = null;
|
|
208
|
+
const factory: PtyFactory = () => {
|
|
209
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
210
|
+
return ptyInstance as any;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const manager = new RoomManager(factory);
|
|
214
|
+
const room = manager.getRoom("system", { cols: 80, rows: 24 }, "/tmp");
|
|
215
|
+
const inputs: any[] = [];
|
|
216
|
+
room.on("pty_input", (payload) => inputs.push(payload));
|
|
217
|
+
|
|
218
|
+
room.sendSystemInput("export HISTFILE=/tmp/demo.history\\n", "hop");
|
|
219
|
+
|
|
220
|
+
expect(ptyInstance?.writes.at(-1)).toBe("export HISTFILE=/tmp/demo.history\\n");
|
|
221
|
+
expect(inputs.at(-1)?.actor).toBe("system");
|
|
222
|
+
expect(inputs.at(-1)?.source).toBe("hop");
|
|
223
|
+
expect(inputs.at(-1)?.clientId).toBe(null);
|
|
224
|
+
});
|
|
146
225
|
});
|