hop2 0.11.0 → 0.11.2
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/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +130 -0
- package/DESIGN-input-acks.md +349 -0
- package/MULTI-AGENT-CHECKLIST.md +5 -5
- package/README-MCP.md +84 -5
- package/README.md +55 -4
- package/demo/README.md +52 -0
- package/demo/capture/capture-env.mjs +108 -0
- package/demo/capture/capture.mjs +634 -0
- package/demo/capture/dispatch-task.mjs +44 -0
- package/demo/capture/mcp-client.mjs +61 -0
- package/demo/capture/native-css.mjs +49 -0
- package/demo/capture/setup-04.mjs +67 -0
- package/demo/capture/setup-sessions.mjs +40 -0
- package/demo/capture/spawn-aurora.mjs +53 -0
- package/demo/capture/tools/agent.sh +30 -0
- package/demo/capture/tools/build.sh +18 -0
- package/demo/capture/tools/metrics.sh +17 -0
- package/docs/hero-overview.svg +119 -0
- package/docs/hero.svg +118 -0
- package/docs/restore-reliability.md +139 -0
- package/docs/session-identity.md +76 -0
- package/docs/switcher-first-ux.md +103 -0
- package/hay/CHANGELOG.md +59 -1
- package/hay/apps/cli/package.json +3 -2
- package/hay/apps/cli/src/index.ts +705 -28
- package/hay/apps/server/src/pty.ts +36 -2
- package/hay/apps/server/src/rooms.ts +525 -34
- package/hay/apps/server/test/rooms.test.ts +411 -1
- package/hay/apps/web/index.html +2 -0
- package/hay/apps/web/package.json +14 -12
- package/hay/apps/web/scripts/run-e2e.mjs +42 -5
- package/hay/apps/web/src/App.tsx +2492 -281
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +135 -14
- package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +2525 -0
- package/hay/apps/web/src/setupTests.ts +23 -0
- package/hay/apps/web/src/styles.css +2460 -180
- package/hay/apps/web/src/utils/fileDrop.ts +15 -0
- package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
- package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
- package/hay/apps/web/src/utils/switcherModel.ts +314 -0
- package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
- package/hay/apps/web/src/vite-env.d.ts +3 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +324 -0
- package/hay/apps/web/test/fileDrop.test.ts +22 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
- package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
- package/hay/apps/web/test/switcherModel.test.ts +377 -0
- package/hay/apps/web/test/terminalSearch.test.ts +37 -0
- package/hay/apps/web/vite.config.ts +20 -0
- package/hay/package-lock.json +1696 -1171
- package/hay/packages/shared/src/protocol.ts +25 -2
- package/hay-web/assets/index-BohAWpcL.css +1 -0
- package/hay-web/assets/index-DfGT3DuO.js +130 -0
- package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
- package/hay-web/index.html +4 -2
- package/hop +4348 -302
- package/hopa +465 -0
- package/mcp/hop-mcp.js +2878 -122
- package/package.json +7 -4
- package/scripts/claude-session-hook.js +152 -0
- package/scripts/hay-host.js +227 -4
- package/scripts/hop-math.js +316 -0
- package/scripts/salvage-and-restart-host.js +217 -0
- package/skills/hop-manager/SKILL.md +62 -0
- package/skills/hop-session/SKILL.md +42 -0
- package/skills/hopa/SKILL.md +75 -0
- package/tests/agent-skill.unit.test.cjs +60 -0
- package/tests/composer-state.unit.test.cjs +102 -0
- package/tests/hop-mcp.integration.test.cjs +476 -94
- package/tests/hopa-parity.unit.test.cjs +188 -0
- package/tests/readable-echo.unit.test.cjs +94 -0
- package/tests/readable-flush.unit.test.cjs +93 -0
- package/tests/submit-verification.unit.test.cjs +331 -0
- package/tests/terminal-api.integration.test.cjs +301 -13
- package/hay-web/assets/index-BzJB9r_I.js +0 -64
- package/hay-web/assets/index-BzJB9r_I.js.map +0 -1
- package/hay-web/assets/index-DG8Ntznc.css +0 -32
- package/sessions.html +0 -726
|
@@ -18,48 +18,108 @@ export type SocketAdapter = {
|
|
|
18
18
|
export type ClientInfo = {
|
|
19
19
|
id: string;
|
|
20
20
|
name: string;
|
|
21
|
+
// "local-cli" = hop CLI attach; "monitor" = read-only live tile (session
|
|
22
|
+
// switcher wall): excluded from presence and client counts, and given a
|
|
23
|
+
// small snapshot so a wall of tiles doesn't pull megabytes per open.
|
|
21
24
|
source?: string;
|
|
22
25
|
colorIndex: number;
|
|
23
26
|
cols: number;
|
|
24
27
|
rows: number;
|
|
28
|
+
// Optional per-connection cap for the join snapshot (clamped to the
|
|
29
|
+
// room-level bound). Monitors request ~64KB.
|
|
30
|
+
replayBytes?: number;
|
|
31
|
+
// False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
|
|
32
|
+
// nudge). Wall tiles decline it: they arrive already showing the current
|
|
33
|
+
// grid (the daemon's headless screen, complete — not a raw byte tail), so
|
|
34
|
+
// the nudge's two SIGWINCHes only produce a visible reflow of the running
|
|
35
|
+
// app at the exact moment the tile goes live — the "twitch".
|
|
36
|
+
nudge?: boolean;
|
|
25
37
|
};
|
|
26
38
|
|
|
27
39
|
export type RoomCreateOptions = {
|
|
28
40
|
cwd: string;
|
|
29
41
|
env?: Record<string, string>;
|
|
30
42
|
shell?: string;
|
|
43
|
+
// Raw terminal output to pre-load into the room's buffer before the PTY
|
|
44
|
+
// starts producing data. Used by `hop restore` to replay a plain-shell
|
|
45
|
+
// session's last screen (so reopening it picks up where it left off) ahead of
|
|
46
|
+
// the fresh shell's first prompt. Clamped to the buffer cap like live output.
|
|
47
|
+
seedOutput?: string;
|
|
48
|
+
// Launch the room ON this command (passed to the shell as argv) instead of
|
|
49
|
+
// typing it in afterwards — see PtyFactoryOptions.command.
|
|
50
|
+
command?: string;
|
|
31
51
|
};
|
|
32
52
|
|
|
33
53
|
export type RoomSummary = {
|
|
34
54
|
id: string;
|
|
35
55
|
cwd: string;
|
|
36
56
|
liveCwd: string;
|
|
57
|
+
foregroundProcess: string;
|
|
58
|
+
lastActivityAt: number;
|
|
59
|
+
bellSeq: number;
|
|
60
|
+
lastBellAt: number;
|
|
37
61
|
clientCount: number;
|
|
38
62
|
localCliCount: number;
|
|
39
63
|
};
|
|
40
64
|
|
|
41
|
-
// Raw output retained for reattach snapshots. ~
|
|
42
|
-
// roughly a full client scrollback (
|
|
43
|
-
// streams are escape-dense and reconstruct less.
|
|
65
|
+
// Raw output retained for reattach snapshots. ~20MB of raw stream reconstructs
|
|
66
|
+
// roughly a full client scrollback (tens of thousands of lines) of plain
|
|
67
|
+
// output; TUI streams are escape-dense and reconstruct less. This is the whole
|
|
68
|
+
// buffer replayed (and parsed by the client) on reattach, so very large values
|
|
69
|
+
// trade memory + reattach-parse time for deeper restored history. Override with
|
|
44
70
|
// HAY_SNAPSHOT_BUFFER_BYTES (bytes of raw stream kept per room).
|
|
45
71
|
const MAX_BUFFER_SIZE = (() => {
|
|
46
72
|
const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
|
|
47
|
-
return Number.isFinite(env) && env > 0 ? env :
|
|
73
|
+
return Number.isFinite(env) && env > 0 ? env : 20_000_000;
|
|
48
74
|
})();
|
|
49
75
|
// Keep rooms alive indefinitely; only explicit kill/remove should end a session.
|
|
50
76
|
const CLEANUP_DELAY_MS = 0;
|
|
77
|
+
// Join-time replay bound: the room keeps up to MAX_BUFFER_SIZE of history,
|
|
78
|
+
// but replaying tens of MB through the client's parser on every open/switch
|
|
79
|
+
// is what made sessions feel slow to load (a phone on a tunnel downloads and
|
|
80
|
+
// parses all of it before first paint). Send a bounded tail instead; the cut
|
|
81
|
+
// avoids starting mid-line so a partial escape sequence can't corrupt the
|
|
82
|
+
// replay. Alt-screen apps redraw fully, and mode flags (alternateScreen,
|
|
83
|
+
// cursorHidden, keyboardEnhanced) travel separately, so a tail is safe.
|
|
84
|
+
const SNAPSHOT_REPLAY_BYTES = (() => {
|
|
85
|
+
const env = Number(process.env.HAY_SNAPSHOT_REPLAY_BYTES);
|
|
86
|
+
return Number.isFinite(env) && env > 0 ? env : 1_500_000;
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
const boundSnapshotReplay = (buffer: string): string => {
|
|
90
|
+
if (buffer.length <= SNAPSHOT_REPLAY_BYTES) return buffer;
|
|
91
|
+
let tail = buffer.slice(buffer.length - SNAPSHOT_REPLAY_BYTES);
|
|
92
|
+
const nl = tail.indexOf("\n");
|
|
93
|
+
if (nl !== -1 && nl < 8192) {
|
|
94
|
+
tail = tail.slice(nl + 1);
|
|
95
|
+
}
|
|
96
|
+
return tail;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Autofit-on-attach: a resize may claim the shared size when every OTHER
|
|
100
|
+
// client has been input-idle at least this long. Someone actively typing keeps
|
|
101
|
+
// the size they set; a viewer opening the session after everyone went idle
|
|
102
|
+
// takes it over immediately instead of waiting to type first.
|
|
103
|
+
const RESIZE_CLAIM_IDLE_MS = (() => {
|
|
104
|
+
const env = Number(process.env.HAY_RESIZE_CLAIM_IDLE_MS);
|
|
105
|
+
return Number.isFinite(env) && env >= 0 ? env : 60_000;
|
|
106
|
+
})();
|
|
107
|
+
// A claim:"attach" resize (a fresh client's first autofit) is deliberate:
|
|
108
|
+
// someone just opened the session there. It takes the shared size unless a
|
|
109
|
+
// peer typed within this window — only an actively-typing peer holds it.
|
|
110
|
+
// 2.5s (was 5s): an actively typing peer keeps refreshing its own window, so
|
|
111
|
+
// this only decides how long a PAUSED typist blocks a deliberate new attach —
|
|
112
|
+
// measured 5s made wall-tile focus feel stuck (converged at ~5s through the
|
|
113
|
+
// tunnel; ~3s with this).
|
|
114
|
+
const ATTACH_CLAIM_IDLE_MS = (() => {
|
|
115
|
+
const env = Number(process.env.HAY_ATTACH_CLAIM_IDLE_MS);
|
|
116
|
+
return Number.isFinite(env) && env >= 0 ? env : 2_500;
|
|
117
|
+
})();
|
|
51
118
|
const CONTROL_SEQUENCE_TAIL = 32;
|
|
52
119
|
const DEBUG_STATE = process.env.HAY_DEBUG === "1";
|
|
53
120
|
|
|
54
121
|
const now = () => Date.now();
|
|
55
122
|
|
|
56
|
-
const clampBuffer = (value: string) => {
|
|
57
|
-
if (value.length <= MAX_BUFFER_SIZE) {
|
|
58
|
-
return value;
|
|
59
|
-
}
|
|
60
|
-
return value.slice(value.length - MAX_BUFFER_SIZE);
|
|
61
|
-
};
|
|
62
|
-
|
|
63
123
|
// Build a useful error for a payload that failed schema validation: name the
|
|
64
124
|
// message type and the offending field instead of a bare "Invalid message".
|
|
65
125
|
const describeInvalidMessage = (payload: string): string => {
|
|
@@ -124,14 +184,48 @@ export class Room extends EventEmitter {
|
|
|
124
184
|
private pty: IPty;
|
|
125
185
|
private readonly initialCwd: string;
|
|
126
186
|
private liveCwd: string;
|
|
187
|
+
private foregroundProcess = "";
|
|
188
|
+
private lastActivityAt = now(); // ms of the most recent PTY output
|
|
189
|
+
// Terminal bells (BEL outside escape sequences) rung by the app — agents and
|
|
190
|
+
// long jobs ring one to ask for attention. Monotonic counter + timestamp so
|
|
191
|
+
// clients can diff against a last-seen value; never reset for a room's life.
|
|
192
|
+
private bellSeq = 0;
|
|
193
|
+
private lastBellAt = 0;
|
|
194
|
+
// Whether the previous output chunk ended inside an unterminated OSC (whose
|
|
195
|
+
// BEL terminator must not count as a bell) — see trackBells.
|
|
196
|
+
private inOscForBells = false;
|
|
197
|
+
private bellEscCarry = false;
|
|
127
198
|
private clients = new Map<string, ClientState>();
|
|
128
199
|
private collabMode = true;
|
|
129
200
|
private controllerId: string | null = null;
|
|
130
201
|
private activeClientId: string | null = null;
|
|
131
202
|
private activeCols: number;
|
|
132
203
|
private activeRows: number;
|
|
133
|
-
|
|
204
|
+
// Retained raw output as a chunk ring: append is O(1) and trimming drops
|
|
205
|
+
// whole chunks from the head. NEVER joined in full on the hot path — a flat
|
|
206
|
+
// string here meant every chunk past the 20MB cap re-copied the entire
|
|
207
|
+
// buffer (≈3ms of blocked event loop per 2KB chunk, measured), which froze
|
|
208
|
+
// the host for minutes under a busy fleet and got it killed as unresponsive.
|
|
209
|
+
private outputChunks: string[] = [];
|
|
210
|
+
private outputBytes = 0;
|
|
211
|
+
// Absolute stream offset (UTF-16 units) of the FIRST retained char — i.e.
|
|
212
|
+
// how much has been trimmed off the head over the room's lifetime. Lets
|
|
213
|
+
// getOutputSince hand out stable cursors into an ever-trimming ring.
|
|
214
|
+
private outputStart = 0;
|
|
215
|
+
// Last viewer's terminal colors (6-hex, no '#'), used to answer OSC 10/11
|
|
216
|
+
// while headless. Defaults to the web client's dark surface.
|
|
217
|
+
private clientBg = "0d1117";
|
|
218
|
+
private clientFg = "e6edf3";
|
|
134
219
|
private alternateScreen = false;
|
|
220
|
+
// Enhanced keyboard reporting requested by the remote app (kitty keyboard protocol
|
|
221
|
+
// or xterm modifyOtherKeys). Tracked like alternateScreen so a reattaching client
|
|
222
|
+
// can restore it on its real terminal (see snapshot below).
|
|
223
|
+
private keyboardEnhanced = false;
|
|
224
|
+
// Mouse tracking requested by the remote app (any of ?1000/1002/1003) and
|
|
225
|
+
// whether it asked for SGR encoding (?1006). Snapshotted so a reattaching
|
|
226
|
+
// client can scroll the app with wheel events instead of Page keys.
|
|
227
|
+
private mouseReporting = false;
|
|
228
|
+
private mouseSgr = false;
|
|
135
229
|
private cursorHidden = false;
|
|
136
230
|
private controlSequenceTail = "";
|
|
137
231
|
private oscBuffer = "";
|
|
@@ -151,15 +245,29 @@ export class Room extends EventEmitter {
|
|
|
151
245
|
cols: initialSize.cols,
|
|
152
246
|
rows: initialSize.rows,
|
|
153
247
|
cwd: options.cwd,
|
|
154
|
-
|
|
155
|
-
|
|
248
|
+
// Tag every session's environment with its room id so a Claude SessionStart
|
|
249
|
+
// hook can map a running claude conversation back to this hop session (used
|
|
250
|
+
// by `hop restore` to resume the exact conversation per session).
|
|
251
|
+
env: { ...(options.env || {}), HOP_SESSION: id },
|
|
252
|
+
shell: options.shell,
|
|
253
|
+
command: options.command
|
|
156
254
|
});
|
|
157
255
|
this.activeCols = initialSize.cols;
|
|
158
256
|
this.activeRows = initialSize.rows;
|
|
159
257
|
|
|
258
|
+
// Restore replay: pre-load the previous session's tail output so the first
|
|
259
|
+
// client to attach sees the restored screen (via the snapshot) before the
|
|
260
|
+
// fresh shell repaints. This is raw bytes that already happened, so we only
|
|
261
|
+
// stage it into the buffer — we don't re-broadcast or re-parse it.
|
|
262
|
+
if (options.seedOutput) {
|
|
263
|
+
this.appendOutput(options.seedOutput);
|
|
264
|
+
}
|
|
265
|
+
|
|
160
266
|
this.pty.onData((data: string) => {
|
|
161
|
-
this.
|
|
267
|
+
this.lastActivityAt = now();
|
|
268
|
+
this.appendOutput(data);
|
|
162
269
|
this.updateTerminalState(data);
|
|
270
|
+
this.answerTerminalQueriesIfHeadless(data);
|
|
163
271
|
this.broadcast({ type: "output", data });
|
|
164
272
|
this.emit("pty_output", { roomId: this.id, data, timestamp: now() });
|
|
165
273
|
});
|
|
@@ -170,24 +278,44 @@ export class Room extends EventEmitter {
|
|
|
170
278
|
console.log(
|
|
171
279
|
`[hay] PTY exit room=${this.id} code=${normalizedExit ?? "null"} signal=${normalizedSignal ?? "null"}`
|
|
172
280
|
);
|
|
173
|
-
|
|
281
|
+
// Spurious-exit guard: an exit event with the shell still alive must not
|
|
282
|
+
// end the session — a wrongly `ended` room keeps serving output but its
|
|
283
|
+
// cwd poll and cleanup are permanently dead (observed live: a restored
|
|
284
|
+
// session whose cwd froze for hours while the shell ran fine). If the
|
|
285
|
+
// pid still exists shortly after the event, ignore it.
|
|
286
|
+
const pid = (this.pty as unknown as { pid?: number }).pid;
|
|
287
|
+
const finish = () => this.endSession({
|
|
174
288
|
exitCode: normalizedExit,
|
|
175
289
|
signal: normalizedSignal,
|
|
176
290
|
message: "Session ended"
|
|
177
291
|
});
|
|
292
|
+
if (!pid) {
|
|
293
|
+
finish();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
try {
|
|
298
|
+
process.kill(pid, 0); // throws when the process is gone
|
|
299
|
+
console.log(`[hay] room=${this.id} ignoring spurious exit event (pid ${pid} still alive)`);
|
|
300
|
+
} catch {
|
|
301
|
+
finish();
|
|
302
|
+
}
|
|
303
|
+
}, 150);
|
|
178
304
|
};
|
|
179
305
|
|
|
180
306
|
if (typeof this.pty.onExit === "function") {
|
|
181
307
|
this.pty.onExit((event) => {
|
|
182
308
|
handleExit(event.exitCode, event.signal);
|
|
183
309
|
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
310
|
+
} else {
|
|
311
|
+
// Legacy fallback ONLY when onExit is unavailable — registering both
|
|
312
|
+
// fired every real exit twice.
|
|
313
|
+
const legacyOn = (this.pty as unknown as { on?: (event: string, handler: (...args: any[]) => void) => void }).on;
|
|
314
|
+
if (typeof legacyOn === "function") {
|
|
315
|
+
legacyOn.call(this.pty, "exit", (exitCode: number, signal?: number) => {
|
|
316
|
+
handleExit(exitCode, signal);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
191
319
|
}
|
|
192
320
|
|
|
193
321
|
// Start PID-based cwd polling as fallback for shells without OSC 7
|
|
@@ -226,15 +354,47 @@ export class Room extends EventEmitter {
|
|
|
226
354
|
} satisfies ServerMessage)
|
|
227
355
|
);
|
|
228
356
|
|
|
229
|
-
if (this.
|
|
357
|
+
if (this.outputBytes > 0) {
|
|
230
358
|
socket.send(JSON.stringify({
|
|
231
359
|
type: "snapshot",
|
|
232
|
-
data: this.
|
|
360
|
+
data: boundSnapshotReplay(this.tailOutput(
|
|
361
|
+
Number.isFinite(info.replayBytes) && (info.replayBytes as number) > 0
|
|
362
|
+
? Math.min(SNAPSHOT_REPLAY_BYTES, Math.floor(info.replayBytes as number))
|
|
363
|
+
: SNAPSHOT_REPLAY_BYTES
|
|
364
|
+
)),
|
|
233
365
|
alternateScreen: this.alternateScreen,
|
|
234
|
-
cursorHidden: this.cursorHidden
|
|
366
|
+
cursorHidden: this.cursorHidden,
|
|
367
|
+
keyboardEnhanced: this.keyboardEnhanced,
|
|
368
|
+
mouseReporting: this.mouseReporting,
|
|
369
|
+
mouseSgr: this.mouseSgr
|
|
235
370
|
} satisfies ServerMessage));
|
|
236
371
|
}
|
|
237
372
|
|
|
373
|
+
// The snapshot is a raw byte TAIL, and incremental TUIs (Claude Code/Ink)
|
|
374
|
+
// never re-emit rows that don't change — e.g. the input box's horizontal
|
|
375
|
+
// border lines — so the replay can be missing them. When this client's
|
|
376
|
+
// size differs from the PTY's, the attach resize triggers a full repaint
|
|
377
|
+
// anyway; when sizes are EQUAL nothing would repaint, so nudge: a one-
|
|
378
|
+
// column wiggle (tmux's redraw trick) makes full-screen apps repaint.
|
|
379
|
+
if (
|
|
380
|
+
info.nudge !== false &&
|
|
381
|
+
client.cols === this.activeCols &&
|
|
382
|
+
client.rows === this.activeRows &&
|
|
383
|
+
this.activeCols > 4 &&
|
|
384
|
+
!this.ended
|
|
385
|
+
) {
|
|
386
|
+
const cols = this.activeCols;
|
|
387
|
+
const rows = this.activeRows;
|
|
388
|
+
try { this.pty.resize(cols - 1, rows); } catch { /* pty may be gone */ }
|
|
389
|
+
setTimeout(() => {
|
|
390
|
+
if (this.ended) return;
|
|
391
|
+
// Only bounce back if nobody claimed a different size meanwhile.
|
|
392
|
+
if (this.activeCols === cols && this.activeRows === rows) {
|
|
393
|
+
try { this.pty.resize(cols, rows); } catch { /* pty may be gone */ }
|
|
394
|
+
}
|
|
395
|
+
}, 60);
|
|
396
|
+
}
|
|
397
|
+
|
|
238
398
|
// Send current cwd to the new client
|
|
239
399
|
socket.send(JSON.stringify({
|
|
240
400
|
type: "cwd_changed",
|
|
@@ -272,7 +432,7 @@ export class Room extends EventEmitter {
|
|
|
272
432
|
this.handleInput(client, message.data);
|
|
273
433
|
break;
|
|
274
434
|
case "resize":
|
|
275
|
-
this.handleResize(client, message.cols, message.rows);
|
|
435
|
+
this.handleResize(client, message.cols, message.rows, message.claim, message.user === true);
|
|
276
436
|
break;
|
|
277
437
|
case "typing":
|
|
278
438
|
client.typing = message.active;
|
|
@@ -316,6 +476,8 @@ export class Room extends EventEmitter {
|
|
|
316
476
|
let nextAlternate = this.alternateScreen;
|
|
317
477
|
let nextCursorHidden = this.cursorHidden;
|
|
318
478
|
|
|
479
|
+
let nextMouseReporting = this.mouseReporting;
|
|
480
|
+
let nextMouseSgr = this.mouseSgr;
|
|
319
481
|
while ((match = regex.exec(combined)) !== null) {
|
|
320
482
|
const params = match[1].split(";").filter(Boolean);
|
|
321
483
|
const mode = match[2];
|
|
@@ -324,11 +486,36 @@ export class Room extends EventEmitter {
|
|
|
324
486
|
nextCursorHidden = mode === "l";
|
|
325
487
|
} else if (param === "47" || param === "1047" || param === "1049") {
|
|
326
488
|
nextAlternate = mode === "h";
|
|
489
|
+
} else if (param === "1000" || param === "1002" || param === "1003") {
|
|
490
|
+
// Mouse tracking (any flavor) — lets a reattaching client know it can
|
|
491
|
+
// scroll the app with wheel events instead of coarse Page keys.
|
|
492
|
+
nextMouseReporting = mode === "h";
|
|
493
|
+
} else if (param === "1006") {
|
|
494
|
+
nextMouseSgr = mode === "h";
|
|
327
495
|
}
|
|
328
496
|
}
|
|
329
497
|
}
|
|
330
498
|
|
|
499
|
+
// Enhanced keyboard reporting: kitty keyboard protocol (CSI >flags u push /
|
|
500
|
+
// CSI <n u pop / CSI =flags u set) and xterm modifyOtherKeys (CSI >4;n m).
|
|
501
|
+
let nextKeyboardEnhanced = this.keyboardEnhanced;
|
|
502
|
+
const kbdRegex = /\x1b\[([<>=])[0-9;:]*u|\x1b\[>([0-9;]*)m/g;
|
|
503
|
+
let kbdMatch: RegExpExecArray | null;
|
|
504
|
+
while ((kbdMatch = kbdRegex.exec(combined)) !== null) {
|
|
505
|
+
if (kbdMatch[1] !== undefined) {
|
|
506
|
+
nextKeyboardEnhanced = kbdMatch[1] !== "<"; // push/set = on, pop = off
|
|
507
|
+
} else {
|
|
508
|
+
const parts = (kbdMatch[2] ?? "").split(";");
|
|
509
|
+
if (parts[0] === "4") nextKeyboardEnhanced = (parts[1] ?? "0") !== "0";
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
331
513
|
let stateChanged = false;
|
|
514
|
+
if (nextMouseReporting !== this.mouseReporting || nextMouseSgr !== this.mouseSgr) {
|
|
515
|
+
this.mouseReporting = nextMouseReporting;
|
|
516
|
+
this.mouseSgr = nextMouseSgr;
|
|
517
|
+
stateChanged = true;
|
|
518
|
+
}
|
|
332
519
|
if (nextAlternate !== this.alternateScreen) {
|
|
333
520
|
if (DEBUG_STATE) {
|
|
334
521
|
console.log(`[hay] room=${this.id} alternateScreen=${nextAlternate}`);
|
|
@@ -336,6 +523,13 @@ export class Room extends EventEmitter {
|
|
|
336
523
|
this.alternateScreen = nextAlternate;
|
|
337
524
|
stateChanged = true;
|
|
338
525
|
}
|
|
526
|
+
if (nextKeyboardEnhanced !== this.keyboardEnhanced) {
|
|
527
|
+
if (DEBUG_STATE) {
|
|
528
|
+
console.log(`[hay] room=${this.id} keyboardEnhanced=${nextKeyboardEnhanced}`);
|
|
529
|
+
}
|
|
530
|
+
this.keyboardEnhanced = nextKeyboardEnhanced;
|
|
531
|
+
stateChanged = true;
|
|
532
|
+
}
|
|
339
533
|
if (nextCursorHidden !== this.cursorHidden) {
|
|
340
534
|
if (DEBUG_STATE) {
|
|
341
535
|
console.log(`[hay] room=${this.id} cursorHidden=${nextCursorHidden}`);
|
|
@@ -358,9 +552,70 @@ export class Room extends EventEmitter {
|
|
|
358
552
|
this.parseOsc7(data);
|
|
359
553
|
}
|
|
360
554
|
|
|
555
|
+
this.trackBells(data);
|
|
556
|
+
|
|
361
557
|
this.controlSequenceTail = combined.slice(-CONTROL_SEQUENCE_TAIL);
|
|
362
558
|
}
|
|
363
559
|
|
|
560
|
+
// Count attention bells: BEL bytes that are NOT the terminator of an OSC
|
|
561
|
+
// sequence (title updates, OSC 7 cwd reports, clipboard writes all end in
|
|
562
|
+
// BEL and must not register). Tracks OSC state across chunk boundaries; a
|
|
563
|
+
// trailing lone ESC is carried so a split "\x1b]" still opens an OSC.
|
|
564
|
+
private trackBells(data: string) {
|
|
565
|
+
let chunk = data;
|
|
566
|
+
if (this.bellEscCarry) {
|
|
567
|
+
chunk = "\x1b" + chunk;
|
|
568
|
+
this.bellEscCarry = false;
|
|
569
|
+
}
|
|
570
|
+
if (!this.inOscForBells && chunk.indexOf("\x07") === -1) {
|
|
571
|
+
// Fast path: no bell to classify; just keep the cross-chunk OSC state.
|
|
572
|
+
const lastOsc = chunk.lastIndexOf("\x1b]");
|
|
573
|
+
if (lastOsc !== -1) {
|
|
574
|
+
const tail = chunk.slice(lastOsc);
|
|
575
|
+
this.inOscForBells = tail.indexOf("\x07") === -1 && tail.indexOf("\x1b\\") === -1;
|
|
576
|
+
}
|
|
577
|
+
if (chunk.endsWith("\x1b")) this.bellEscCarry = true;
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
let bells = 0;
|
|
581
|
+
let i = 0;
|
|
582
|
+
const len = chunk.length;
|
|
583
|
+
while (i < len) {
|
|
584
|
+
if (this.inOscForBells) {
|
|
585
|
+
const bel = chunk.indexOf("\x07", i);
|
|
586
|
+
const st = chunk.indexOf("\x1b\\", i);
|
|
587
|
+
if (bel === -1 && st === -1) {
|
|
588
|
+
i = len;
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
if (bel !== -1 && (st === -1 || bel < st)) {
|
|
592
|
+
this.inOscForBells = false;
|
|
593
|
+
i = bel + 1;
|
|
594
|
+
} else {
|
|
595
|
+
this.inOscForBells = false;
|
|
596
|
+
i = st + 2;
|
|
597
|
+
}
|
|
598
|
+
} else {
|
|
599
|
+
const bel = chunk.indexOf("\x07", i);
|
|
600
|
+
const osc = chunk.indexOf("\x1b]", i);
|
|
601
|
+
if (bel === -1 && osc === -1) break;
|
|
602
|
+
if (bel !== -1 && (osc === -1 || bel < osc)) {
|
|
603
|
+
bells += 1;
|
|
604
|
+
i = bel + 1;
|
|
605
|
+
} else {
|
|
606
|
+
this.inOscForBells = true;
|
|
607
|
+
i = osc + 2;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!this.inOscForBells && chunk.endsWith("\x1b")) this.bellEscCarry = true;
|
|
612
|
+
if (bells > 0) {
|
|
613
|
+
this.bellSeq += bells;
|
|
614
|
+
this.lastBellAt = now();
|
|
615
|
+
this.emit("bell", { roomId: this.id, bellSeq: this.bellSeq, timestamp: this.lastBellAt });
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
364
619
|
// Regex: matches OSC 7 with BEL (\x07) or ST (\x1b\\) terminator
|
|
365
620
|
private static readonly OSC7_RE = /\x1b\]7;([^\x07\x1b]*?)(?:\x07|\x1b\\)/g;
|
|
366
621
|
|
|
@@ -438,6 +693,12 @@ export class Room extends EventEmitter {
|
|
|
438
693
|
|
|
439
694
|
const poll = () => {
|
|
440
695
|
if (this.ended) return;
|
|
696
|
+
// node-pty exposes the foreground process name (already short, e.g. "vim",
|
|
697
|
+
// "node", "claude"); cheap getter, sampled on the same cadence as cwd.
|
|
698
|
+
const fg = (this.pty as unknown as { process?: string }).process;
|
|
699
|
+
if (typeof fg === "string" && fg) {
|
|
700
|
+
this.foregroundProcess = fg.replace(/^-/, ""); // strip login-shell "-zsh" dash
|
|
701
|
+
}
|
|
441
702
|
if (process.platform === "darwin") {
|
|
442
703
|
execFile("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { timeout: 5000 }, (err, stdout) => {
|
|
443
704
|
if (err || !stdout) return;
|
|
@@ -512,7 +773,7 @@ export class Room extends EventEmitter {
|
|
|
512
773
|
this.emit("pty_input", { roomId: this.id, clientId: client.id, data, timestamp: now() });
|
|
513
774
|
}
|
|
514
775
|
|
|
515
|
-
private handleResize(client: ClientState, cols: number, rows: number) {
|
|
776
|
+
private handleResize(client: ClientState, cols: number, rows: number, claim?: "attach", userClaim = false) {
|
|
516
777
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
|
517
778
|
if (cols < 1 || cols > 500 || rows < 1 || rows > 200) return;
|
|
518
779
|
|
|
@@ -527,15 +788,47 @@ export class Room extends EventEmitter {
|
|
|
527
788
|
// 0) only wins when no one else has typed either — so the first/sole client
|
|
528
789
|
// can still size the PTY before typing.
|
|
529
790
|
const maxInputAt = Math.max(...[...this.clients.values()].map((c) => c.lastInputAt));
|
|
530
|
-
const
|
|
791
|
+
const othersMaxInputAt = Math.max(
|
|
792
|
+
0,
|
|
793
|
+
...[...this.clients.values()].filter((c) => c.id !== client.id).map((c) => c.lastInputAt)
|
|
794
|
+
);
|
|
795
|
+
const claimIdleMs = claim === "attach" ? ATTACH_CLAIM_IDLE_MS : RESIZE_CLAIM_IDLE_MS;
|
|
796
|
+
// A user-flagged claim is a deliberate human act (clicking a wall tile,
|
|
797
|
+
// switching to the session): it wins outright. Anyone actively typing
|
|
798
|
+
// elsewhere reclaims with their next keystroke, so misfires self-heal.
|
|
799
|
+
const isActive = userClaim ||
|
|
800
|
+
client.lastInputAt >= maxInputAt || now() - othersMaxInputAt > claimIdleMs;
|
|
531
801
|
if (isActive) {
|
|
532
802
|
this.pty.resize(cols, rows);
|
|
533
803
|
this.activeCols = cols;
|
|
534
804
|
this.activeRows = rows;
|
|
535
805
|
this.activeClientId = client.id;
|
|
536
|
-
// Broadcast the new active size to other clients so they can adjust
|
|
806
|
+
// Broadcast the new active size to other clients so they can adjust —
|
|
807
|
+
// and CONFIRM to the winner: a claimant otherwise learns nothing on
|
|
808
|
+
// success and can only distinguish accept from reject by timeout.
|
|
537
809
|
this.broadcastActiveSize(client);
|
|
810
|
+
client.socket.send(
|
|
811
|
+
JSON.stringify({
|
|
812
|
+
type: "active_size",
|
|
813
|
+
clientId: client.id,
|
|
814
|
+
cols,
|
|
815
|
+
rows
|
|
816
|
+
} satisfies ServerMessage)
|
|
817
|
+
);
|
|
538
818
|
this.emit("pty_resize", { roomId: this.id, clientId: client.id, cols, rows, timestamp: now() });
|
|
819
|
+
} else if (cols !== this.activeCols || rows !== this.activeRows) {
|
|
820
|
+
// The resize lost the election (a passive viewer auto-fitted itself).
|
|
821
|
+
// Snap that client back to the shared size — otherwise its local
|
|
822
|
+
// terminal re-wraps the buffer at a size the PTY isn't, and it has no
|
|
823
|
+
// way to learn the real size until the next winning resize.
|
|
824
|
+
client.socket.send(
|
|
825
|
+
JSON.stringify({
|
|
826
|
+
type: "active_size",
|
|
827
|
+
clientId: this.activeClientId ?? client.id,
|
|
828
|
+
cols: this.activeCols,
|
|
829
|
+
rows: this.activeRows
|
|
830
|
+
} satisfies ServerMessage)
|
|
831
|
+
);
|
|
539
832
|
}
|
|
540
833
|
}
|
|
541
834
|
|
|
@@ -564,7 +857,9 @@ export class Room extends EventEmitter {
|
|
|
564
857
|
}
|
|
565
858
|
|
|
566
859
|
private broadcastPresence() {
|
|
567
|
-
|
|
860
|
+
// Monitor tiles are spectators of the UI, not participants — eight live
|
|
861
|
+
// tiles must not read as eight viewers in every session.
|
|
862
|
+
const clients = [...this.clients.values()].filter((c) => c.source !== "monitor").map(toPresence);
|
|
568
863
|
this.broadcast({ type: "presence", clients });
|
|
569
864
|
}
|
|
570
865
|
|
|
@@ -599,6 +894,97 @@ export class Room extends EventEmitter {
|
|
|
599
894
|
}, CLEANUP_DELAY_MS);
|
|
600
895
|
}
|
|
601
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Headless terminal-identity: hop rooms usually start with NO client
|
|
899
|
+
* attached, so an app's startup probes — "what's your background color?"
|
|
900
|
+
* (OSC 10/11), "who are you?" (DA1), "do you support truecolor?"
|
|
901
|
+
* (XTGETTCAP RGB/Tc) — go unanswered, and the app falls back to its most
|
|
902
|
+
* conservative palette. Measured live: Claude Code emits only 256-color
|
|
903
|
+
* SGR in hop while emitting truecolor in iTerm2, purely because iTerm
|
|
904
|
+
* answers these and nobody here did. When a room is headless, the ROOM is
|
|
905
|
+
* the terminal — answer as one (dark background, truecolor-capable).
|
|
906
|
+
* With a client attached, the real terminal answers and we stay silent.
|
|
907
|
+
*/
|
|
908
|
+
private answerTerminalQueriesIfHeadless(data: string) {
|
|
909
|
+
if (this.clients.size > 0 || this.ended) return;
|
|
910
|
+
if (data.indexOf("\x1b") === -1) return;
|
|
911
|
+
let reply = "";
|
|
912
|
+
// OSC 10/11: foreground / background color queries — answered with the
|
|
913
|
+
// LAST VIEWER'S actual colors, so an app that themes itself from the
|
|
914
|
+
// background (Claude Code picks light vs dark this way) matches what the
|
|
915
|
+
// user is really looking at. Falls back to the dark default before any
|
|
916
|
+
// client has ever attached.
|
|
917
|
+
const osc = (n: number, hex: string) => {
|
|
918
|
+
const p = (i: number) => hex.slice(i, i + 2).repeat(2);
|
|
919
|
+
return `\x1b]${n};rgb:${p(0)}/${p(2)}/${p(4)}\x1b\\`;
|
|
920
|
+
};
|
|
921
|
+
if (/\x1b\]10;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(10, this.clientFg);
|
|
922
|
+
if (/\x1b\]11;\?(?:\x07|\x1b\\)/.test(data)) reply += osc(11, this.clientBg);
|
|
923
|
+
// DA1: identify as a VT220-class terminal (matches xterm.js's own reply).
|
|
924
|
+
if (/\x1b\[0?c/.test(data)) reply += "\x1b[?62;22c";
|
|
925
|
+
// DSR 5 (status): "OK".
|
|
926
|
+
if (/\x1b\[5n/.test(data)) reply += "\x1b[0n";
|
|
927
|
+
// XTGETTCAP: confirm truecolor for the two conventional capability names
|
|
928
|
+
// (RGB / Tc, hex-encoded); other capability requests get a valid "not
|
|
929
|
+
// recognized" so the app never hangs waiting.
|
|
930
|
+
const tcap = data.match(/\x1bP\+q([0-9a-fA-F;]+)\x1b\\/g);
|
|
931
|
+
if (tcap) {
|
|
932
|
+
for (const q of tcap) {
|
|
933
|
+
const names = (q.slice(4, -2) || "").split(";"); // strip ESC P + q … ESC \
|
|
934
|
+
for (const hex of names) {
|
|
935
|
+
const name = Buffer.from(hex, "hex").toString("utf8");
|
|
936
|
+
if (name === "RGB" || name === "Tc") reply += `\x1bP1+r${hex}\x1b\\`;
|
|
937
|
+
else reply += `\x1bP0+r${hex}\x1b\\`;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
if (reply) {
|
|
942
|
+
try {
|
|
943
|
+
this.pty.write(reply);
|
|
944
|
+
} catch {
|
|
945
|
+
/* pty gone — nothing to answer */
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** O(1) append; trims whole chunks from the head past MAX_BUFFER_SIZE. */
|
|
951
|
+
private appendOutput(data: string) {
|
|
952
|
+
if (!data) return;
|
|
953
|
+
this.outputChunks.push(data);
|
|
954
|
+
this.outputBytes += data.length;
|
|
955
|
+
while (this.outputChunks.length > 1 && this.outputBytes - this.outputChunks[0].length >= MAX_BUFFER_SIZE) {
|
|
956
|
+
this.outputBytes -= this.outputChunks[0].length;
|
|
957
|
+
this.outputStart += this.outputChunks[0].length;
|
|
958
|
+
this.outputChunks.shift();
|
|
959
|
+
}
|
|
960
|
+
// A single chunk larger than the whole cap (giant seed/paste): slice once.
|
|
961
|
+
if (this.outputChunks.length === 1 && this.outputBytes > MAX_BUFFER_SIZE) {
|
|
962
|
+
const only = this.outputChunks[0].slice(this.outputChunks[0].length - MAX_BUFFER_SIZE);
|
|
963
|
+
this.outputStart += this.outputChunks[0].length - only.length;
|
|
964
|
+
this.outputChunks[0] = only;
|
|
965
|
+
this.outputBytes = only.length;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Join at most the last maxBytes of retained output. Every consumer
|
|
971
|
+
* (snapshot replay, preview, persistence) needs only a bounded tail, so the
|
|
972
|
+
* full ring is never flattened.
|
|
973
|
+
*/
|
|
974
|
+
private tailOutput(maxBytes: number): string {
|
|
975
|
+
if (this.outputBytes === 0 || maxBytes <= 0) return "";
|
|
976
|
+
const cap = Math.min(maxBytes, this.outputBytes);
|
|
977
|
+
const parts: string[] = [];
|
|
978
|
+
let collected = 0;
|
|
979
|
+
for (let i = this.outputChunks.length - 1; i >= 0 && collected < cap; i--) {
|
|
980
|
+
parts.push(this.outputChunks[i]);
|
|
981
|
+
collected += this.outputChunks[i].length;
|
|
982
|
+
}
|
|
983
|
+
parts.reverse();
|
|
984
|
+
const joined = parts.join("");
|
|
985
|
+
return joined.length > cap ? joined.slice(joined.length - cap) : joined;
|
|
986
|
+
}
|
|
987
|
+
|
|
602
988
|
private endSession(payload: { exitCode: number | null; signal: string | null; message: string; by?: string }) {
|
|
603
989
|
if (this.ended) return;
|
|
604
990
|
this.ended = true;
|
|
@@ -623,13 +1009,87 @@ export class Room extends EventEmitter {
|
|
|
623
1009
|
this.emit("empty");
|
|
624
1010
|
}
|
|
625
1011
|
|
|
1012
|
+
/**
|
|
1013
|
+
* Bounded source for an on-demand screen preview: the terminal's size plus a
|
|
1014
|
+
* tail of the raw output (enough to reconstruct the visible screen for the
|
|
1015
|
+
* common case of full-repaint TUIs). The actual rendering happens in the
|
|
1016
|
+
* daemon, on demand, only when someone is looking — so idle rooms cost nothing.
|
|
1017
|
+
*/
|
|
1018
|
+
getPreviewSource(maxBytes = 65536): { cols: number; rows: number; output: string } {
|
|
1019
|
+
return { cols: this.activeCols, rows: this.activeRows, output: this.tailOutput(maxBytes) };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Incremental output for a persistent consumer-side screen parser (the
|
|
1024
|
+
* daemon's preview grids). `since` is an absolute cursor from a previous
|
|
1025
|
+
* call's `offset`; the response is exactly the chars appended after it, so
|
|
1026
|
+
* parser state carries forward with no arbitrary cut points. A cursor
|
|
1027
|
+
* that's unknown, ahead of the stream, fallen off the retained ring, or too
|
|
1028
|
+
* far behind to be worth replaying comes back as `reset: true` with a
|
|
1029
|
+
* bounded tail and a fresh cursor — the caller re-seeds.
|
|
1030
|
+
*/
|
|
1031
|
+
/** Remember the viewer's terminal colors for headless OSC 10/11 answers. */
|
|
1032
|
+
setClientColors(bg?: string | null, fg?: string | null) {
|
|
1033
|
+
if (bg && /^[0-9a-f]{6}$/i.test(bg)) this.clientBg = bg.toLowerCase();
|
|
1034
|
+
if (fg && /^[0-9a-f]{6}$/i.test(fg)) this.clientFg = fg.toLowerCase();
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
getOutputSince(
|
|
1038
|
+
since?: number,
|
|
1039
|
+
maxBytes = 1_048_576
|
|
1040
|
+
): { offset: number; data: string; reset: boolean; cols: number; rows: number } {
|
|
1041
|
+
const end = this.outputStart + this.outputBytes;
|
|
1042
|
+
const dims = { cols: this.activeCols, rows: this.activeRows };
|
|
1043
|
+
if (
|
|
1044
|
+
typeof since !== "number" ||
|
|
1045
|
+
!Number.isFinite(since) ||
|
|
1046
|
+
since < this.outputStart ||
|
|
1047
|
+
since > end ||
|
|
1048
|
+
end - since > maxBytes
|
|
1049
|
+
) {
|
|
1050
|
+
return { offset: end, data: this.tailOutput(maxBytes), reset: true, ...dims };
|
|
1051
|
+
}
|
|
1052
|
+
// The delta from `since` to the end IS the tail of that length, and
|
|
1053
|
+
// since >= outputStart guarantees every char of it is still retained.
|
|
1054
|
+
const deltaLen = end - since;
|
|
1055
|
+
return { offset: end, data: deltaLen === 0 ? "" : this.tailOutput(deltaLen), reset: false, ...dims };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* A bounded tail of the raw output, for persisting across a host restart so
|
|
1060
|
+
* `hop restore` can replay the last screen of a plain-shell session. Returns
|
|
1061
|
+
* an empty string for an empty buffer so callers can skip writing a file.
|
|
1062
|
+
*/
|
|
1063
|
+
getPersistableBuffer(maxBytes = 65536): string {
|
|
1064
|
+
return this.tailOutput(maxBytes);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
hasEnded() {
|
|
1068
|
+
return this.ended;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
626
1071
|
getSummary(): RoomSummary {
|
|
627
1072
|
const localCliCount = [...this.clients.values()].filter((client) => client.source === "local-cli").length;
|
|
1073
|
+
// Read the foreground process name fresh (cheap getter) so the session
|
|
1074
|
+
// manager reflects what's running now; fall back to the last polled sample.
|
|
1075
|
+
let foregroundProcess = this.foregroundProcess;
|
|
1076
|
+
try {
|
|
1077
|
+
const live = (this.pty as unknown as { process?: string }).process;
|
|
1078
|
+
if (typeof live === "string" && live) {
|
|
1079
|
+
foregroundProcess = live.replace(/^-/, "");
|
|
1080
|
+
}
|
|
1081
|
+
} catch {
|
|
1082
|
+
/* keep the polled fallback */
|
|
1083
|
+
}
|
|
628
1084
|
return {
|
|
629
1085
|
id: this.id,
|
|
630
1086
|
cwd: this.initialCwd,
|
|
631
1087
|
liveCwd: this.liveCwd,
|
|
632
|
-
|
|
1088
|
+
foregroundProcess,
|
|
1089
|
+
lastActivityAt: this.lastActivityAt,
|
|
1090
|
+
bellSeq: this.bellSeq,
|
|
1091
|
+
lastBellAt: this.lastBellAt,
|
|
1092
|
+
clientCount: [...this.clients.values()].filter((c) => c.source !== "monitor").length,
|
|
633
1093
|
localCliCount
|
|
634
1094
|
};
|
|
635
1095
|
}
|
|
@@ -645,7 +1105,9 @@ export class RoomManager {
|
|
|
645
1105
|
|
|
646
1106
|
getRoom(roomId: string, initialSize: { cols: number; rows: number }, cwdOrOptions: string | RoomCreateOptions) {
|
|
647
1107
|
const existing = this.rooms.get(roomId);
|
|
648
|
-
|
|
1108
|
+
// Never hand out a room that already ended — its poll/cleanup are dead and
|
|
1109
|
+
// its pty is (supposed to be) gone. Replace it with a fresh generation.
|
|
1110
|
+
if (existing && !existing.hasEnded()) {
|
|
649
1111
|
return existing;
|
|
650
1112
|
}
|
|
651
1113
|
const options = typeof cwdOrOptions === "string"
|
|
@@ -656,7 +1118,12 @@ export class RoomManager {
|
|
|
656
1118
|
}
|
|
657
1119
|
const room = new Room(roomId, this.ptyFactory, initialSize, options);
|
|
658
1120
|
room.on("empty", () => {
|
|
659
|
-
|
|
1121
|
+
// Generation-safe delete: only remove the mapping if it still points at
|
|
1122
|
+
// THIS room. A late 'empty' from a dead generation must not evict a
|
|
1123
|
+
// freshly created successor under the same id.
|
|
1124
|
+
if (this.rooms.get(roomId) === room) {
|
|
1125
|
+
this.rooms.delete(roomId);
|
|
1126
|
+
}
|
|
660
1127
|
});
|
|
661
1128
|
this.rooms.set(roomId, room);
|
|
662
1129
|
return room;
|
|
@@ -683,6 +1150,30 @@ export class RoomManager {
|
|
|
683
1150
|
}
|
|
684
1151
|
}
|
|
685
1152
|
|
|
1153
|
+
getRoomPreviewSource(id: string, maxBytes?: number) {
|
|
1154
|
+
const room = this.rooms.get(id);
|
|
1155
|
+
return room ? room.getPreviewSource(maxBytes) : null;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
getRoomOutputSince(id: string, since?: number, maxBytes?: number) {
|
|
1159
|
+
const room = this.rooms.get(id);
|
|
1160
|
+
return room ? room.getOutputSince(since, maxBytes) : null;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Snapshot every live room's tail buffer for persistence across a host
|
|
1165
|
+
* restart. Rooms with no output are omitted. The host script writes these to
|
|
1166
|
+
* disk on shutdown; `hop restore` replays them as `seedOutput`.
|
|
1167
|
+
*/
|
|
1168
|
+
getPersistableBuffers(maxBytes?: number): Array<{ id: string; output: string }> {
|
|
1169
|
+
const out: Array<{ id: string; output: string }> = [];
|
|
1170
|
+
for (const room of this.rooms.values()) {
|
|
1171
|
+
const output = room.getPersistableBuffer(maxBytes);
|
|
1172
|
+
if (output) out.push({ id: room.id, output });
|
|
1173
|
+
}
|
|
1174
|
+
return out;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
686
1177
|
listRooms(): RoomSummary[] {
|
|
687
1178
|
return Array.from(this.rooms.values()).map((room) => room.getSummary());
|
|
688
1179
|
}
|