hop2 0.11.76 → 0.11.77
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/hay/apps/server/src/rooms.ts +39 -1
- package/hay/apps/server/test/roomModes.test.ts +30 -0
- package/hay/apps/web/src/App.tsx +10 -1
- package/hay/apps/web/src/components/SessionSwitcher.tsx +7 -1
- package/hay/apps/web/src/utils/fullscreenChord.ts +18 -0
- package/hay/apps/web/test/fullscreenChord.test.ts +22 -0
- package/hop +48 -4
- package/package.json +1 -1
- package/scripts/hay-host.js +32 -2
|
@@ -142,6 +142,11 @@ export type RoomSummary = {
|
|
|
142
142
|
// 6MB still exceeds every reader: the deep-history replay asks 1.5MB, the
|
|
143
143
|
// serialized attach reads the grid, and `hop restore` persists 64KB tails.
|
|
144
144
|
// Override with HAY_SNAPSHOT_BUFFER_BYTES for deeper rings at GC's expense.
|
|
145
|
+
// DEC private modes worth restoring on adopt: cursor keys, mouse (any
|
|
146
|
+
// flavour and encoding), focus events, alternate screen, bracketed paste,
|
|
147
|
+
// cursor visibility.
|
|
148
|
+
const TRACKED_DEC_MODES = new Set(["1", "25", "47", "1000", "1002", "1003", "1004", "1005", "1006", "1015", "1047", "1049", "2004"]);
|
|
149
|
+
|
|
145
150
|
const MAX_BUFFER_SIZE = (() => {
|
|
146
151
|
const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
|
|
147
152
|
return Number.isFinite(env) && env > 0 ? env : 6_000_000;
|
|
@@ -321,6 +326,13 @@ export class Room extends EventEmitter {
|
|
|
321
326
|
private mouseReporting = false;
|
|
322
327
|
private mouseSgr = false;
|
|
323
328
|
private cursorHidden = false;
|
|
329
|
+
// The DEC private modes an app set (alternate screen, mouse, focus,
|
|
330
|
+
// bracketed paste…), exactly. An app sets them once at startup; a host
|
|
331
|
+
// restart rebuilds the room from a bounded replay that no longer holds
|
|
332
|
+
// that moment, so the room forgot Codex was full-screen and the wheel
|
|
333
|
+
// scrolled the page instead of the app (2026-10-12). The host saves
|
|
334
|
+
// modePrefix() and puts it back in front of the replay on adopt.
|
|
335
|
+
private decModes = new Map<string, boolean>();
|
|
324
336
|
private controlSequenceTail = "";
|
|
325
337
|
private oscBuffer = "";
|
|
326
338
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
@@ -678,10 +690,13 @@ export class Room extends EventEmitter {
|
|
|
678
690
|
|
|
679
691
|
let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
|
|
680
692
|
let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
|
|
693
|
+
const modesBefore = this.modePrefix();
|
|
694
|
+
if (hasNewRis) this.decModes.clear();
|
|
681
695
|
while ((match = regex.exec(stateInput)) !== null) {
|
|
682
696
|
const params = match[1].split(";").filter(Boolean);
|
|
683
697
|
const mode = match[2];
|
|
684
698
|
for (const param of params) {
|
|
699
|
+
if (TRACKED_DEC_MODES.has(param)) this.decModes.set(param, mode === "h");
|
|
685
700
|
if (param === "25") {
|
|
686
701
|
nextCursorHidden = mode === "l";
|
|
687
702
|
} else if (param === "47" || param === "1047" || param === "1049") {
|
|
@@ -738,6 +753,9 @@ export class Room extends EventEmitter {
|
|
|
738
753
|
stateChanged = true;
|
|
739
754
|
}
|
|
740
755
|
|
|
756
|
+
const modesAfter = this.modePrefix();
|
|
757
|
+
if (modesAfter !== modesBefore) this.emit("modes", modesAfter);
|
|
758
|
+
|
|
741
759
|
if (stateChanged) {
|
|
742
760
|
this.emit("pty_state", {
|
|
743
761
|
roomId: this.id,
|
|
@@ -1204,6 +1222,22 @@ export class Room extends EventEmitter {
|
|
|
1204
1222
|
// omit cells until the app happens to repaint (for example, on scroll).
|
|
1205
1223
|
// Begin tracking at TUI entry; dependency loading is shared and normally
|
|
1206
1224
|
// finishes first, but the callback covers a TUI that starts immediately.
|
|
1225
|
+
/**
|
|
1226
|
+
* The escape sequences that put a fresh terminal into this app's current
|
|
1227
|
+
* modes: the alternate screen first (so the rest apply to it), then each
|
|
1228
|
+
* mode that is on, and a hidden cursor. "" for a plain shell.
|
|
1229
|
+
*/
|
|
1230
|
+
modePrefix(): string {
|
|
1231
|
+
let alt = "", rest = "";
|
|
1232
|
+
for (const [mode, on] of this.decModes) {
|
|
1233
|
+
if (mode === "25") { if (!on) rest += "\x1b[?25l"; continue; }
|
|
1234
|
+
if (!on) continue;
|
|
1235
|
+
if (mode === "1049" || mode === "1047" || mode === "47") alt = `\x1b[?${mode}h`;
|
|
1236
|
+
else rest += `\x1b[?${mode}h`;
|
|
1237
|
+
}
|
|
1238
|
+
return alt + rest;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1207
1241
|
private startTuiGrid() {
|
|
1208
1242
|
if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
|
|
1209
1243
|
if (!this.alternateScreen && !this.mouseReporting) return;
|
|
@@ -1244,7 +1278,11 @@ export class Room extends EventEmitter {
|
|
|
1244
1278
|
const nl = tail.indexOf("\n");
|
|
1245
1279
|
if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
|
|
1246
1280
|
}
|
|
1247
|
-
|
|
1281
|
+
// The tail rarely reaches back to where the app set its modes
|
|
1282
|
+
// (an adopted room's saved prefix sits at the very front of the
|
|
1283
|
+
// ring): put the room's current modes first, or the grid serializes
|
|
1284
|
+
// a full-screen app as a plain shell. The tail's own changes win.
|
|
1285
|
+
this.grid.write(this.modePrefix() + tail);
|
|
1248
1286
|
}
|
|
1249
1287
|
}
|
|
1250
1288
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { RoomManager } from "../src/rooms";
|
|
3
|
+
import type { PtyFactory } from "../src/pty";
|
|
4
|
+
|
|
5
|
+
class FakePty {
|
|
6
|
+
private handlers: Array<(data: string) => void> = [];
|
|
7
|
+
onData(handler: (data: string) => void) { this.handlers.push(handler); }
|
|
8
|
+
onExit() {}
|
|
9
|
+
emit(data: string) { for (const h of this.handlers) h(data); }
|
|
10
|
+
write() {}
|
|
11
|
+
resize() {}
|
|
12
|
+
kill() {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("terminal modes a room remembers", () => {
|
|
16
|
+
it("a room reports the DEC modes its app set, alternate screen first", () => {
|
|
17
|
+
const pty = new FakePty();
|
|
18
|
+
const manager = new RoomManager((() => pty) as unknown as PtyFactory);
|
|
19
|
+
const room = manager.getRoom("codex", { cols: 80, rows: 24 }, "/tmp");
|
|
20
|
+
const seen: string[] = [];
|
|
21
|
+
room.on("modes", (p: string) => seen.push(p));
|
|
22
|
+
pty.emit("\x1b[?2004h\x1b[?1004h\x1b[?1049h\x1b[?1003h\x1b[?1006h\x1b[?25l hello");
|
|
23
|
+
expect(room.modePrefix()).toBe("\x1b[?1049h\x1b[?2004h\x1b[?1004h\x1b[?1003h\x1b[?1006h\x1b[?25l");
|
|
24
|
+
expect(seen.at(-1)).toBe(room.modePrefix());
|
|
25
|
+
pty.emit("\x1b[?1003l\x1b[?1049l");
|
|
26
|
+
expect(room.modePrefix()).toBe("\x1b[?2004h\x1b[?1004h\x1b[?1006h\x1b[?25l");
|
|
27
|
+
pty.emit("\x1bc");
|
|
28
|
+
expect(room.modePrefix()).toBe("");
|
|
29
|
+
});
|
|
30
|
+
});
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from "./utils/paneTree";
|
|
50
50
|
import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
|
|
51
51
|
import type { ViewsSummary } from "./utils/switcherModel";
|
|
52
|
+
import { isAppCtrlEnter, isFullscreenChord } from "./utils/fullscreenChord";
|
|
52
53
|
|
|
53
54
|
const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
|
|
54
55
|
|
|
@@ -2329,13 +2330,21 @@ const App = () => {
|
|
|
2329
2330
|
terminal.attachCustomKeyEventHandler((event) => {
|
|
2330
2331
|
// ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
|
|
2331
2332
|
// tile entering it. One chord toggles the mode from either side.
|
|
2332
|
-
|
|
2333
|
+
// On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
|
|
2334
|
+
if (isFullscreenChord(event)) {
|
|
2333
2335
|
if (event.type === 'keydown') {
|
|
2334
2336
|
event.preventDefault();
|
|
2335
2337
|
setSwitcherOpen(true);
|
|
2336
2338
|
}
|
|
2337
2339
|
return false;
|
|
2338
2340
|
}
|
|
2341
|
+
if (isAppCtrlEnter(event) && (remoteKbdEnhancedRef.current || foregroundIsClaude())) {
|
|
2342
|
+
if (event.type === 'keydown') {
|
|
2343
|
+
event.preventDefault();
|
|
2344
|
+
handleUserInput('\x1b[13;5u');
|
|
2345
|
+
}
|
|
2346
|
+
return false;
|
|
2347
|
+
}
|
|
2339
2348
|
// Long-press SPACE = voice input — shared controller; see voiceHold.ts.
|
|
2340
2349
|
{
|
|
2341
2350
|
const verdict = voiceHoldRef.current?.handleKey(event);
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type SwitcherSortMode,
|
|
38
38
|
insertAfter, describeAccount, type AgentHome, type AgentHomes } from "../utils/switcherModel";
|
|
39
39
|
import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
|
|
40
|
+
import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
|
|
40
41
|
import { DigestCard } from "./DigestCard";
|
|
41
42
|
import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
|
|
42
43
|
|
|
@@ -693,10 +694,15 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
693
694
|
if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;2u");
|
|
694
695
|
return false;
|
|
695
696
|
}
|
|
696
|
-
|
|
697
|
+
// On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
|
|
698
|
+
if (isFullscreenChord(ev)) {
|
|
697
699
|
if (ev.type === "keydown") onFullscreenRef.current();
|
|
698
700
|
return false;
|
|
699
701
|
}
|
|
702
|
+
if (isAppCtrlEnter(ev) && (kbdEnhancedRef.current || looksLikeClaude())) {
|
|
703
|
+
if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;5u");
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
700
706
|
if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key === ".") {
|
|
701
707
|
if (ev.type === "keydown") onUnfocusRef.current();
|
|
702
708
|
return false;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The full-screen ⇄ wall chord inside a terminal. On a Mac it is ⌘⏎ only:
|
|
2
|
+
// Ctrl⏎ belongs to the app (Claude Code binds it), and hop taking it meant
|
|
3
|
+
// the keystroke never arrived. Elsewhere there is no ⌘, so Ctrl⏎ stays.
|
|
4
|
+
export const IS_MAC = typeof navigator !== "undefined"
|
|
5
|
+
&& /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
|
|
6
|
+
|
|
7
|
+
type Chord = { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean };
|
|
8
|
+
|
|
9
|
+
export function isFullscreenChord(ev: Chord, mac = IS_MAC): boolean {
|
|
10
|
+
if (ev.key !== "Enter" || ev.altKey || ev.shiftKey) return false;
|
|
11
|
+
return mac ? ev.metaKey && !ev.ctrlKey : ev.metaKey || ev.ctrlKey;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Ctrl⏎ passed to the app: CSI-u (13;5u), since xterm.js would send a bare \r. */
|
|
15
|
+
export function isAppCtrlEnter(ev: Chord, mac = IS_MAC): boolean {
|
|
16
|
+
return ev.key === "Enter" && ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.shiftKey
|
|
17
|
+
&& !isFullscreenChord(ev, mac);
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isAppCtrlEnter, isFullscreenChord } from "../src/utils/fullscreenChord";
|
|
3
|
+
|
|
4
|
+
const key = (mods: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }>, k = "Enter") =>
|
|
5
|
+
({ key: k, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...mods });
|
|
6
|
+
|
|
7
|
+
describe("full-screen chord", () => {
|
|
8
|
+
it("on a Mac only ⌘⏎ toggles; Ctrl⏎ is the app's", () => {
|
|
9
|
+
expect(isFullscreenChord(key({ metaKey: true }), true)).toBe(true);
|
|
10
|
+
expect(isFullscreenChord(key({ ctrlKey: true }), true)).toBe(false);
|
|
11
|
+
expect(isAppCtrlEnter(key({ ctrlKey: true }), true)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("without ⌘ (Windows/Linux) Ctrl⏎ still toggles", () => {
|
|
14
|
+
expect(isFullscreenChord(key({ ctrlKey: true }), false)).toBe(true);
|
|
15
|
+
expect(isAppCtrlEnter(key({ ctrlKey: true }), false)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
it("other chords are untouched", () => {
|
|
18
|
+
expect(isFullscreenChord(key({ metaKey: true, shiftKey: true }), true)).toBe(false);
|
|
19
|
+
expect(isFullscreenChord(key({}), true)).toBe(false);
|
|
20
|
+
expect(isAppCtrlEnter(key({ ctrlKey: true }, "a"), true)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/hop
CHANGED
|
@@ -802,7 +802,18 @@ async function withLifecycleLock(op, fn, { waitMs = 120_000, onWait } = {}) {
|
|
|
802
802
|
}
|
|
803
803
|
// Marker so the authenticator setup QR is only shown on first run (UX only; no auth logic).
|
|
804
804
|
const AUTH_QR_SHOWN_FILE = path.join(HOME_DIR, '.auth_qr_shown');
|
|
805
|
-
|
|
805
|
+
// A keeper-backed host re-adopts every running terminal (replaying each
|
|
806
|
+
// screen) BEFORE it opens its port. With 30 rooms that took ~8 s, then ~41 s
|
|
807
|
+
// once one replay timed out (2026-10-12), so first a 5 s and then a 25 s
|
|
808
|
+
// limit killed each new host mid-adoption and spawned the next — a restart
|
|
809
|
+
// loop that left hop with no host, and took the daemon down with it ("Fatal:
|
|
810
|
+
// Hay host did not open its port in time") while a `hop restore` was in
|
|
811
|
+
// flight, which the CLI reported as "socket hang up". This is now a ceiling,
|
|
812
|
+
// not the expected wait: the spawner keeps waiting while the host process is
|
|
813
|
+
// alive (see waitForHayHostPort), gives up early only once it has died, and
|
|
814
|
+
// refreshes the spawn lock as it waits so a rival spawner never mistakes a
|
|
815
|
+
// long adoption for a stale lock.
|
|
816
|
+
const HAY_HOST_READY_TIMEOUT_MS = parsePositiveIntEnv('HOP_HAY_HOST_READY_TIMEOUT_MS', 180000);
|
|
806
817
|
const HAY_HOST_DISCOVERY_CACHE_MS = parsePositiveIntEnv('HOP_HAY_HOST_DISCOVERY_CACHE_MS', 10000);
|
|
807
818
|
const HAY_HOST_HOME_HASH = crypto.createHash('sha256').update(path.resolve(HOME_DIR)).digest('hex').slice(0, 24);
|
|
808
819
|
const HAY_HOST_HOME_FLAG = `--hop-home-hash=${HAY_HOST_HOME_HASH}`;
|
|
@@ -9706,6 +9717,27 @@ async function stopExternalHayHostProcess(hostState, timeoutMs = 1500) {
|
|
|
9706
9717
|
return !isProcessAlive(pid);
|
|
9707
9718
|
}
|
|
9708
9719
|
|
|
9720
|
+
// Wait for a freshly spawned host to open its port. Adopting a big fleet from
|
|
9721
|
+
// the pty-keeper can take a minute or more, so the wait is bounded by the
|
|
9722
|
+
// host's LIFE first and the ceiling second: a host that dies returns at once
|
|
9723
|
+
// instead of burning the whole ceiling, and one still working keeps the spawn
|
|
9724
|
+
// lock's mtime fresh so a concurrent spawner (30 s staleness rule) never calls
|
|
9725
|
+
// the lock stale and starts a rival host mid-adoption.
|
|
9726
|
+
async function waitForHayHostPort(started, ceilingMs) {
|
|
9727
|
+
const deadline = Date.now() + ceilingMs;
|
|
9728
|
+
let lastLockTouch = 0;
|
|
9729
|
+
while (Date.now() < deadline) {
|
|
9730
|
+
if (await waitForPort(started.port, '127.0.0.1', 1000, 120)) return true;
|
|
9731
|
+
if (!isProcessAlive(started.pid)) return false;
|
|
9732
|
+
if (Date.now() - lastLockTouch > 5000) {
|
|
9733
|
+
lastLockTouch = Date.now();
|
|
9734
|
+
const now = new Date();
|
|
9735
|
+
try { fs.utimesSync(HAY_HOST_SPAWN_LOCK, now, now); } catch (e) { /* lock gone — nothing to refresh */ }
|
|
9736
|
+
}
|
|
9737
|
+
}
|
|
9738
|
+
return false;
|
|
9739
|
+
}
|
|
9740
|
+
|
|
9709
9741
|
function markExternalHayHostResponsive(host) {
|
|
9710
9742
|
const recovered = hayHostProbeFailures.recordSuccess(Number(host?.pid));
|
|
9711
9743
|
if (recovered) {
|
|
@@ -9832,6 +9864,9 @@ async function ensureExternalHayHostReadyOnce() {
|
|
|
9832
9864
|
if (spawned && isProcessAlive(spawned.pid) && await probeExternalHayHostFromHost(spawned, 1500)) {
|
|
9833
9865
|
return markExternalHayHostResponsive(spawned);
|
|
9834
9866
|
}
|
|
9867
|
+
// The winner releases the lock on success and failure alike;
|
|
9868
|
+
// once it is gone there is nothing left to wait for.
|
|
9869
|
+
if (!fs.existsSync(HAY_HOST_SPAWN_LOCK)) break;
|
|
9835
9870
|
}
|
|
9836
9871
|
// Winner never delivered — fall through and spawn ourselves.
|
|
9837
9872
|
}
|
|
@@ -9917,10 +9952,13 @@ async function ensureExternalHayHostReadyOnce() {
|
|
|
9917
9952
|
throw new Error(`Hay host failed to start; see ${hayHostLogFile}`);
|
|
9918
9953
|
}
|
|
9919
9954
|
|
|
9920
|
-
const ready = await
|
|
9955
|
+
const ready = await waitForHayHostPort(started, HAY_HOST_READY_TIMEOUT_MS);
|
|
9921
9956
|
if (!ready) {
|
|
9957
|
+
const died = !isProcessAlive(started.pid);
|
|
9922
9958
|
await stopExternalHayHostProcess(started);
|
|
9923
|
-
throw new Error(
|
|
9959
|
+
throw new Error(died
|
|
9960
|
+
? `Hay host (pid ${started.pid}) exited before opening its port; see ${hayHostLogFile}`
|
|
9961
|
+
: `Hay host did not open its port within ${Math.round(HAY_HOST_READY_TIMEOUT_MS / 1000)}s (still adopting its terminals? HOP_HAY_HOST_READY_TIMEOUT_MS raises the ceiling); see ${hayHostLogFile}`);
|
|
9924
9962
|
}
|
|
9925
9963
|
const healthy = await probeExternalHayHostFromHost(started, 1500);
|
|
9926
9964
|
if (!healthy) {
|
|
@@ -17559,7 +17597,13 @@ async function main() {
|
|
|
17559
17597
|
try {
|
|
17560
17598
|
res = await callLocalApi(state, '/api/sessions/restore', 'POST', { dryRun, includeAgents, only: only.length ? only : undefined });
|
|
17561
17599
|
} catch (e) {
|
|
17562
|
-
|
|
17600
|
+
// "socket hang up" here means the daemon closed the connection
|
|
17601
|
+
// without answering: it was restarted, or died (e.g. its host
|
|
17602
|
+
// never came up), while the restore was in flight. Nothing about
|
|
17603
|
+
// the restore itself failed, so say what to do next.
|
|
17604
|
+
const gone = !isServerRunning();
|
|
17605
|
+
log(`Restore request failed: ${e.message}${gone ? ' — the daemon went away mid-request' : ''}.`, RED);
|
|
17606
|
+
log(` ${DIM}Check ${CYAN}hop logs${RESET}${DIM} (look for "Fatal:" or "Cleaning up"), then run ${CYAN}hop restore${RESET}${DIM} again — it only recreates sessions that are not running.${RESET}`, RESET);
|
|
17563
17607
|
process.exit(1);
|
|
17564
17608
|
}
|
|
17565
17609
|
if (!res || res.status < 200 || res.status >= 300) {
|
package/package.json
CHANGED
package/scripts/hay-host.js
CHANGED
|
@@ -91,10 +91,19 @@ function loadViewerColors(hay) {
|
|
|
91
91
|
if (typeof hay.setDefaultViewerColors === 'function') hay.setDefaultViewerColors(saved.bg, saved.fg);
|
|
92
92
|
} catch (e) { /* none yet: the dark default */ }
|
|
93
93
|
}
|
|
94
|
+
// Compared against what is on disk, not setDefaultViewerColors' "changed":
|
|
95
|
+
// room.setClientColors already updates the defaults before this runs, so
|
|
96
|
+
// that always said "unchanged" and the file was never written — every host
|
|
97
|
+
// restart went back to the dark default (Codex's charcoal composer).
|
|
98
|
+
let savedViewerColors = null;
|
|
94
99
|
function rememberViewerColors(hay, bg, fg) {
|
|
95
100
|
if (typeof hay.setDefaultViewerColors !== 'function') return;
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
hay.setDefaultViewerColors(bg, fg);
|
|
102
|
+
const json = JSON.stringify(hay.getDefaultViewerColors());
|
|
103
|
+
if (savedViewerColors === null) { try { savedViewerColors = fs.readFileSync(VIEWER_COLORS_FILE, 'utf8'); } catch (e) { savedViewerColors = ''; } }
|
|
104
|
+
if (json === savedViewerColors) return;
|
|
105
|
+
try { fs.writeFileSync(VIEWER_COLORS_FILE, json); savedViewerColors = json; } catch (e) { /* best effort */ }
|
|
106
|
+
}
|
|
98
107
|
const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
|
|
99
108
|
|
|
100
109
|
// Scrub Claude Code SESSION markers from our environment before any PTY is
|
|
@@ -153,9 +162,29 @@ const watchedRooms = new WeakSet();
|
|
|
153
162
|
// restore <name>`): their records must survive the close, exactly as they
|
|
154
163
|
// survive a host shutdown. Consumed once, on the next session_end.
|
|
155
164
|
const preserveRecordsOnClose = new Set();
|
|
165
|
+
// Each room's terminal modes (Room.modePrefix), saved as they change so a
|
|
166
|
+
// re-adopted room starts in them: an app turns alternate screen and mouse
|
|
167
|
+
// reporting on once, at startup, which a bounded replay no longer holds.
|
|
168
|
+
const ROOM_MODES_DIR = path.join(HOP_HOME, 'room-modes');
|
|
169
|
+
const roomModesFile = (id) => path.join(ROOM_MODES_DIR, `${id}.modes`);
|
|
170
|
+
function loadRoomModes(id) {
|
|
171
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(String(id || ''))) return '';
|
|
172
|
+
try { return fs.readFileSync(roomModesFile(id), 'utf8'); } catch (e) { return ''; }
|
|
173
|
+
}
|
|
174
|
+
function saveRoomModes(id, prefix) {
|
|
175
|
+
try {
|
|
176
|
+
if (!prefix) { fs.unlinkSync(roomModesFile(id)); return; }
|
|
177
|
+
fs.mkdirSync(ROOM_MODES_DIR, { recursive: true });
|
|
178
|
+
fs.writeFileSync(roomModesFile(id), prefix);
|
|
179
|
+
} catch (e) { /* best effort */ }
|
|
180
|
+
}
|
|
156
181
|
function watchRoomEnd(room) {
|
|
157
182
|
if (!room || typeof room.on !== 'function' || watchedRooms.has(room)) return room;
|
|
158
183
|
watchedRooms.add(room);
|
|
184
|
+
if (/^[A-Za-z0-9_.-]+$/.test(String(room.id || ''))) {
|
|
185
|
+
room.on('modes', (prefix) => saveRoomModes(String(room.id), String(prefix || '')));
|
|
186
|
+
if (typeof room.modePrefix === 'function') saveRoomModes(String(room.id), room.modePrefix());
|
|
187
|
+
}
|
|
159
188
|
room.on('session_end', () => {
|
|
160
189
|
if (shuttingDown) return;
|
|
161
190
|
const id = String(room.id || '');
|
|
@@ -168,6 +197,7 @@ function watchRoomEnd(room) {
|
|
|
168
197
|
// request to forget its conversation; that is an explicit delete's
|
|
169
198
|
// job, daemon-side.
|
|
170
199
|
try { fs.unlinkSync(path.join(BUFFER_DIR, `${id}.raw`)); } catch (e) { /* best effort */ }
|
|
200
|
+
try { fs.unlinkSync(roomModesFile(id)); } catch (e) { /* best effort */ }
|
|
171
201
|
});
|
|
172
202
|
return room;
|
|
173
203
|
}
|