hop2 0.11.44 → 0.11.46
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 +25 -3
- package/hay/apps/server/test/rooms.test.ts +38 -0
- package/hay/apps/web/src/App.tsx +30 -1
- package/hay/apps/web/src/utils/echoGuard.ts +26 -0
- package/hay/apps/web/test/echoGuard.test.ts +21 -0
- package/hop +370 -50
- package/lib/trust-dialog.js +65 -0
- package/package.json +1 -1
- package/tests/trust-dialog.unit.test.cjs +45 -0
|
@@ -73,6 +73,12 @@ export type ClientInfo = {
|
|
|
73
73
|
// larger requests are a deliberate ask for scrollback DEPTH and keep the
|
|
74
74
|
// raw-tail replay. Unset = raw tail at the room default (CLI reattach).
|
|
75
75
|
replayBytes?: number;
|
|
76
|
+
// "attach" = the client claims the size AS it connects — the same
|
|
77
|
+
// election a resize message with claim:"attach" runs, but BEFORE the
|
|
78
|
+
// snapshot goes out, so the first frame the client renders is already
|
|
79
|
+
// its own size. A phone opening a session a desktop was driving used to
|
|
80
|
+
// paint the desktop's grid and reflow ~half a second later.
|
|
81
|
+
sizeClaim?: "attach";
|
|
76
82
|
// False = skip the equal-size attach "wiggle" (the −1/+1 column repaint
|
|
77
83
|
// nudge). Wall tiles decline it: they arrive already showing the current
|
|
78
84
|
// grid (the daemon's headless screen, complete — not a raw byte tail), so
|
|
@@ -436,6 +442,13 @@ export class Room extends EventEmitter {
|
|
|
436
442
|
} satisfies ServerMessage)
|
|
437
443
|
);
|
|
438
444
|
|
|
445
|
+
// Connect-time size claim (see ClientInfo.sizeClaim): after hello, so
|
|
446
|
+
// the client can tell the resulting active_size is its own, and before
|
|
447
|
+
// the snapshot, so the replay is already at the claimed size.
|
|
448
|
+
if (info.sizeClaim === "attach") {
|
|
449
|
+
this.handleResize(client, info.cols, info.rows, "attach");
|
|
450
|
+
}
|
|
451
|
+
|
|
439
452
|
socket.send(
|
|
440
453
|
JSON.stringify({
|
|
441
454
|
type: "active_size",
|
|
@@ -1317,17 +1330,26 @@ export class Room extends EventEmitter {
|
|
|
1317
1330
|
* like the attach path so cursor/mouse state present identically too.
|
|
1318
1331
|
*/
|
|
1319
1332
|
serializeScreen(
|
|
1320
|
-
callback: (result: { data: string; cols: number; rows: number } | null) => void,
|
|
1333
|
+
callback: (result: { data: string; cols: number; rows: number; offset: number } | null) => void,
|
|
1321
1334
|
scrollback = 200
|
|
1322
1335
|
) {
|
|
1323
1336
|
if (this.outputBytes === 0) { callback(null); return; }
|
|
1324
1337
|
const grid = this.ensureGrid();
|
|
1325
1338
|
if (!grid) { callback(null); return; }
|
|
1339
|
+
// The output cursor this screen reflects: everything in the ring NOW is
|
|
1340
|
+
// queued into the grid ahead of the serialize marker, everything that
|
|
1341
|
+
// arrives later lands behind it. A consumer that keeps its own parser
|
|
1342
|
+
// (the daemon's preview grids) continues from here with /output?since=
|
|
1343
|
+
// and never misses or repeats a byte — which is what let it seed from a
|
|
1344
|
+
// whole screen instead of a raw tail cut mid-frame (a differential TUI
|
|
1345
|
+
// never re-sends the cells such a tail lost: Codex previews stayed
|
|
1346
|
+
// partially wrong until the app repainted, 2026-09-09).
|
|
1347
|
+
const offset = this.outputStart + this.outputBytes;
|
|
1326
1348
|
grid.serialize((serialized) => {
|
|
1327
1349
|
if (serialized === null) { callback(null); return; }
|
|
1328
1350
|
const modeSeq = (this.mouseReporting ? "\x1b[?1002h" + (this.mouseSgr ? "\x1b[?1006h" : "") : "")
|
|
1329
1351
|
+ (this.cursorHidden ? "\x1b[?25l" : "");
|
|
1330
|
-
callback({ data: "\x1bc" + serialized + modeSeq, cols: this.activeCols, rows: this.activeRows });
|
|
1352
|
+
callback({ data: "\x1bc" + serialized + modeSeq, cols: this.activeCols, rows: this.activeRows, offset });
|
|
1331
1353
|
}, scrollback);
|
|
1332
1354
|
}
|
|
1333
1355
|
|
|
@@ -1471,7 +1493,7 @@ export class RoomManager {
|
|
|
1471
1493
|
|
|
1472
1494
|
serializeRoomScreen(
|
|
1473
1495
|
id: string,
|
|
1474
|
-
callback: (result: { data: string; cols: number; rows: number } | null) => void,
|
|
1496
|
+
callback: (result: { data: string; cols: number; rows: number; offset: number } | null) => void,
|
|
1475
1497
|
scrollback?: number
|
|
1476
1498
|
) {
|
|
1477
1499
|
const room = this.rooms.get(id);
|
|
@@ -121,6 +121,31 @@ describe("Room", () => {
|
|
|
121
121
|
expect(ptyInstance!.resizes.length).toBe(before);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
+
it("a connect-time size claim sizes the PTY before the snapshot goes out", () => {
|
|
125
|
+
let ptyInstance: FakePty | null = null;
|
|
126
|
+
const factory: PtyFactory = () => {
|
|
127
|
+
ptyInstance = new FakePty() as unknown as FakePty;
|
|
128
|
+
return ptyInstance as any;
|
|
129
|
+
};
|
|
130
|
+
const manager = new RoomManager(factory);
|
|
131
|
+
const room = manager.getRoom("claim", { cols: 120, rows: 40 }, "/tmp");
|
|
132
|
+
// A desktop is driving the size, and the session has painted something
|
|
133
|
+
// (a room with no output sends no snapshot at all).
|
|
134
|
+
room.attachClient({ id: "desk", name: "Desk", colorIndex: 0, cols: 120, rows: 40 }, new FakeSocket());
|
|
135
|
+
ptyInstance!.emit("$ ls\r\nREADME.md\r\n");
|
|
136
|
+
// ...and a phone opens the session, claiming its own size as it attaches.
|
|
137
|
+
const phone = new FakeSocket();
|
|
138
|
+
room.attachClient({ id: "phone", name: "iPhone", colorIndex: 1, cols: 60, rows: 30, sizeClaim: "attach" }, phone);
|
|
139
|
+
expect(ptyInstance!.resizes.some((r) => r.cols === 60 && r.rows === 30)).toBe(true);
|
|
140
|
+
const types = readMessages(phone).map((m) => m.type);
|
|
141
|
+
const sized = readMessages(phone).findIndex((m) => m.type === "active_size" && m.cols === 60 && m.rows === 30);
|
|
142
|
+
const snapshot = types.indexOf("snapshot");
|
|
143
|
+
expect(sized).toBeGreaterThanOrEqual(0);
|
|
144
|
+
expect(snapshot).toBeGreaterThan(sized);
|
|
145
|
+
// The phone owns the size: the active_size it receives names it.
|
|
146
|
+
expect(readMessages(phone).some((m) => m.type === "active_size" && m.clientId === "phone")).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
124
149
|
it("broadcasts pty output to all clients", () => {
|
|
125
150
|
let ptyInstance: FakePty | null = null;
|
|
126
151
|
const factory: PtyFactory = () => {
|
|
@@ -863,6 +888,19 @@ describe("Room", () => {
|
|
|
863
888
|
expect(String(snapshot.data)).toContain("ROW-5");
|
|
864
889
|
});
|
|
865
890
|
|
|
891
|
+
it("serializeScreen reports the output cursor the screen reflects, so a delta consumer can continue exactly", async () => {
|
|
892
|
+
const { room, pty } = await setup("offset");
|
|
893
|
+
pty().emit("hello\r\n");
|
|
894
|
+
const first = await new Promise<any>((r) => room.serializeScreen((x) => r(x)));
|
|
895
|
+
expect(first.offset).toBe(room.getOutputSince(undefined).offset);
|
|
896
|
+
pty().emit("more");
|
|
897
|
+
const second = await new Promise<any>((r) => room.serializeScreen((x) => r(x)));
|
|
898
|
+
expect(second.offset).toBe(first.offset + 4);
|
|
899
|
+
const delta = room.getOutputSince(first.offset);
|
|
900
|
+
expect(delta.reset).toBe(false);
|
|
901
|
+
expect(delta.data).toBe("more");
|
|
902
|
+
});
|
|
903
|
+
|
|
866
904
|
it("flags a serialized snapshot as capped when deeper raw history exists", async () => {
|
|
867
905
|
const { room, pty } = await setup("capped");
|
|
868
906
|
const big = "\x1b[20;1H" + "y".repeat(4096);
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { attachScrollFlywheel } from "./utils/scrollFlywheel";
|
|
|
15
15
|
import { collectTerminalMatches, selectTerminalMatch } from "./utils/terminalSearch";
|
|
16
16
|
import { createVoiceHold, speechRecognitionCtor } from "./utils/voiceHold";
|
|
17
17
|
import { tabTitle } from "./utils/tabTitle";
|
|
18
|
+
import { remoteAppOwnsScreen } from "./utils/echoGuard";
|
|
18
19
|
import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
|
|
19
20
|
import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
|
|
20
21
|
import { MobileKeyboard } from "./components/MobileKeyboard";
|
|
@@ -639,6 +640,11 @@ const App = () => {
|
|
|
639
640
|
// local viewport scroll is a no-op there — touch scrolling must instead send the
|
|
640
641
|
// app its own scroll keys (PageUp/PageDown). See the mobile touch handler.
|
|
641
642
|
const remoteAltScreenRef = useRef(false);
|
|
643
|
+
// The other ways an app announces it owns the screen (see utils/echoGuard):
|
|
644
|
+
// Codex draws in a DECSTBM scroll region on the normal screen inside DEC
|
|
645
|
+
// 2026 synchronized-output frames, never entering the alternate screen.
|
|
646
|
+
const remoteScrollRegionRef = useRef(false);
|
|
647
|
+
const remoteSyncOutputSeenAtRef = useRef(0);
|
|
642
648
|
// Mouse tracking requested by the remote app (?1000/1002/1003) + SGR
|
|
643
649
|
// encoding (?1006). When both are on, touch scrolling drives the app with
|
|
644
650
|
// per-line SGR wheel events (smooth, momentum-capable) instead of Page keys.
|
|
@@ -1131,7 +1137,12 @@ const App = () => {
|
|
|
1131
1137
|
// the SHELL, where the terminal echoes char-by-char with no redraw to
|
|
1132
1138
|
// fight. (Reconcile's TUI guard still protects any in-flight echo when the
|
|
1133
1139
|
// app flips to alt-screen mid-keystroke.)
|
|
1134
|
-
&& !
|
|
1140
|
+
&& !remoteAppOwnsScreen({
|
|
1141
|
+
altScreen: remoteAltScreenRef.current,
|
|
1142
|
+
mouseReporting: remoteMouseReportingRef.current,
|
|
1143
|
+
scrollRegion: remoteScrollRegionRef.current,
|
|
1144
|
+
syncOutputSeenAt: remoteSyncOutputSeenAtRef.current
|
|
1145
|
+
}, Date.now())
|
|
1135
1146
|
&& !hasActiveOtherTypist(presenceRef.current, clientIdRef.current, typingLastSeenRef.current, Date.now())
|
|
1136
1147
|
&& (collabModeRef.current ? true : controllerIdRef.current === clientIdRef.current);
|
|
1137
1148
|
const optimisticActive = echoEnabledNow();
|
|
@@ -2172,11 +2183,27 @@ const App = () => {
|
|
|
2172
2183
|
if (ALT_SCREEN_PARAMS.has(n)) remoteAltScreenRef.current = enabled;
|
|
2173
2184
|
if (MOUSE_TRACK_PARAMS.has(n)) remoteMouseReportingRef.current = enabled;
|
|
2174
2185
|
if (n === 1006) remoteMouseSgrRef.current = enabled;
|
|
2186
|
+
// DEC 2026: a synchronized-output frame — only TUIs draw this way.
|
|
2187
|
+
if (n === 2026 && enabled) remoteSyncOutputSeenAtRef.current = Date.now();
|
|
2175
2188
|
}
|
|
2176
2189
|
return false; // let xterm's default handler apply the mode
|
|
2177
2190
|
};
|
|
2178
2191
|
terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (params) => trackAltScreen(params, true));
|
|
2179
2192
|
terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, (params) => trackAltScreen(params, false));
|
|
2193
|
+
// DECSTBM (CSI t;b r): a scroll region narrower than the screen means an
|
|
2194
|
+
// app is composing the screen itself. Bare CSI r (or RIS) clears it.
|
|
2195
|
+
terminal.parser.registerCsiHandler({ final: 'r' }, (params) => {
|
|
2196
|
+
const top = Number(Array.isArray(params[0]) ? params[0][0] : params[0]) || 1;
|
|
2197
|
+
const bottomRaw = Array.isArray(params[1]) ? params[1][0] : params[1];
|
|
2198
|
+
const bottom = Number(bottomRaw) || terminal.rows;
|
|
2199
|
+
remoteScrollRegionRef.current = !(top <= 1 && bottom >= terminal.rows);
|
|
2200
|
+
return false;
|
|
2201
|
+
});
|
|
2202
|
+
terminal.parser.registerEscHandler({ final: 'c' }, () => {
|
|
2203
|
+
remoteScrollRegionRef.current = false;
|
|
2204
|
+
remoteSyncOutputSeenAtRef.current = 0;
|
|
2205
|
+
return false;
|
|
2206
|
+
});
|
|
2180
2207
|
|
|
2181
2208
|
setTerminalReady(true);
|
|
2182
2209
|
|
|
@@ -3129,6 +3156,8 @@ const App = () => {
|
|
|
3129
3156
|
internalName: s.internalName || s.name,
|
|
3130
3157
|
lastActivityAt: Number(s.lastActivityAt) || 0,
|
|
3131
3158
|
bellSeq: Number(s.bellSeq) || 0,
|
|
3159
|
+
attentionReason: typeof s.attentionReason === "string" ? s.attentionReason : "",
|
|
3160
|
+
attentionNote: typeof s.attentionNote === "string" ? s.attentionNote : "",
|
|
3132
3161
|
foregroundProcess: s.foregroundProcess,
|
|
3133
3162
|
agentPermitted: s.agentPermitted === true,
|
|
3134
3163
|
createdBy: s.createdBy === "agent" ? "agent" : "user",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Should optimistic local echo be suppressed because the remote app is a
|
|
2
|
+
// full-screen TUI that repaints on every keystroke?
|
|
3
|
+
//
|
|
4
|
+
// The alternate screen was the only signal. Claude Code, vim and less use
|
|
5
|
+
// it — Codex does not: its TUI draws with absolute cursor positioning in a
|
|
6
|
+
// scroll region on the NORMAL screen, wrapped in DEC 2026 synchronized-output
|
|
7
|
+
// brackets, and enables mouse reporting. Typing into it echoed a raw char
|
|
8
|
+
// that the next frame overwrote — the same flicker the alt-screen guard
|
|
9
|
+
// fixed for Claude, back for Codex. Any of these says "an app owns the
|
|
10
|
+
// screen": no echo.
|
|
11
|
+
export const SYNC_OUTPUT_FRESH_MS = 10_000;
|
|
12
|
+
|
|
13
|
+
export type RemoteScreenSignals = {
|
|
14
|
+
altScreen: boolean;
|
|
15
|
+
mouseReporting: boolean;
|
|
16
|
+
/** A DECSTBM scroll region narrower than the full screen is in effect. */
|
|
17
|
+
scrollRegion: boolean;
|
|
18
|
+
/** Last time a DEC 2026 synchronized-output frame was seen (ms epoch), 0 = never. */
|
|
19
|
+
syncOutputSeenAt: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const remoteAppOwnsScreen = (s: RemoteScreenSignals, now: number): boolean =>
|
|
23
|
+
s.altScreen
|
|
24
|
+
|| s.mouseReporting
|
|
25
|
+
|| s.scrollRegion
|
|
26
|
+
|| (s.syncOutputSeenAt > 0 && now - s.syncOutputSeenAt < SYNC_OUTPUT_FRESH_MS);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { remoteAppOwnsScreen, SYNC_OUTPUT_FRESH_MS } from "../src/utils/echoGuard";
|
|
3
|
+
|
|
4
|
+
const shell = { altScreen: false, mouseReporting: false, scrollRegion: false, syncOutputSeenAt: 0 };
|
|
5
|
+
|
|
6
|
+
describe("optimistic echo guard", () => {
|
|
7
|
+
it("a plain shell gets local echo", () => {
|
|
8
|
+
expect(remoteAppOwnsScreen(shell, 1_000_000)).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
it("the alternate screen (Claude Code, vim) suppresses it", () => {
|
|
11
|
+
expect(remoteAppOwnsScreen({ ...shell, altScreen: true }, 1_000_000)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("Codex's normal-screen TUI suppresses it too: scroll region, sync frames, or mouse reporting", () => {
|
|
14
|
+
expect(remoteAppOwnsScreen({ ...shell, scrollRegion: true }, 1_000_000)).toBe(true);
|
|
15
|
+
expect(remoteAppOwnsScreen({ ...shell, mouseReporting: true }, 1_000_000)).toBe(true);
|
|
16
|
+
expect(remoteAppOwnsScreen({ ...shell, syncOutputSeenAt: 999_000 }, 1_000_000)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
it("a synchronized-output frame ages out once the app is gone", () => {
|
|
19
|
+
expect(remoteAppOwnsScreen({ ...shell, syncOutputSeenAt: 1_000_000 }, 1_000_000 + SYNC_OUTPUT_FRESH_MS + 1)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
});
|
package/hop
CHANGED
|
@@ -2650,21 +2650,8 @@ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc
|
|
|
2650
2650
|
// sat at the dialog for two weeks (lightscope, Neptune). Whitespace-
|
|
2651
2651
|
// tolerant because the grid text can run words together across wraps.
|
|
2652
2652
|
const TRUST_PROMPT_RE = /Quick\s*safety\s*check|Yes,?\s*I\s*trust\s*this\s*folder|Is\s*this\s*a\s*project\s*you\s*created\s*or\s*one\s*you\s*trust/i;
|
|
2653
|
-
//
|
|
2654
|
-
|
|
2655
|
-
// shell happened to wander.
|
|
2656
|
-
function trustDialogWorkspace(screenText) {
|
|
2657
|
-
const m = /Accessing\s*workspace:?\s*(\/[^\s\u2502\u2500]+)/i.exec(String(screenText || ''));
|
|
2658
|
-
return m ? m[1].replace(/\/+$/, '') : null;
|
|
2659
|
-
}
|
|
2660
|
-
function samePath(a, b) {
|
|
2661
|
-
const norm = (p) => {
|
|
2662
|
-
let v = String(p || '').replace(/\/+$/, '');
|
|
2663
|
-
try { v = fs.realpathSync(v); } catch (e) { /* keep as-is */ }
|
|
2664
|
-
return v;
|
|
2665
|
-
};
|
|
2666
|
-
return !!a && !!b && norm(a) === norm(b);
|
|
2667
|
-
}
|
|
2653
|
+
// Trust-dialog reading lives in lib/trust-dialog.js so it can be unit-tested.
|
|
2654
|
+
const { trustDialogWorkspace, trustDialogAnswer, samePath } = require('./lib/trust-dialog.js');
|
|
2668
2655
|
function getResumeMode() {
|
|
2669
2656
|
const mode = String(getHopConfig().resume_mode || '').toLowerCase();
|
|
2670
2657
|
return mode === 'summary' || mode === 'ask' ? mode : 'full';
|
|
@@ -5248,6 +5235,89 @@ function readTurnCount(internalName) {
|
|
|
5248
5235
|
// surfaces (dedupe + seen-marks already handle "one finish, one alert").
|
|
5249
5236
|
// Baselined at arm time (notifyBaseTurn) so arming a long-lived session does
|
|
5250
5237
|
// not fire a single notification for its entire back history.
|
|
5238
|
+
// The ASK record the Claude hook writes when the agent stops to ask the human
|
|
5239
|
+
// something (permission, a question, a plan to approve): {count, at, type,
|
|
5240
|
+
// message}. Always on — unlike a finished turn, a question is worth a buzz by
|
|
5241
|
+
// definition — its count rides bellSeq like a bell, and the message travels
|
|
5242
|
+
// beside it so the phone can say what is being asked.
|
|
5243
|
+
function readAsk(internalName) {
|
|
5244
|
+
try {
|
|
5245
|
+
const a = JSON.parse(fs.readFileSync(
|
|
5246
|
+
path.join(CLAUDE_SESSIONS_DIR, `${internalName}.ask`), 'utf8'));
|
|
5247
|
+
if (!a || !Number.isInteger(a.count) || a.count < 0) return null;
|
|
5248
|
+
const atMs = Date.parse(a.at);
|
|
5249
|
+
return {
|
|
5250
|
+
count: a.count,
|
|
5251
|
+
at: Number.isFinite(atMs) ? atMs : null,
|
|
5252
|
+
type: typeof a.type === 'string' ? a.type : '',
|
|
5253
|
+
message: typeof a.message === 'string' ? a.message : ''
|
|
5254
|
+
};
|
|
5255
|
+
} catch (e) { return null; }
|
|
5256
|
+
}
|
|
5257
|
+
// One read per session per second at most: /api/sessions maps every field
|
|
5258
|
+
// through askOf() and the list is polled by every client.
|
|
5259
|
+
const askMemo = new Map();
|
|
5260
|
+
function askOf(internalName) {
|
|
5261
|
+
const now = Date.now();
|
|
5262
|
+
const hit = askMemo.get(internalName);
|
|
5263
|
+
if (hit && now - hit.t < 1000) return hit.v;
|
|
5264
|
+
const v = readAsk(internalName);
|
|
5265
|
+
askMemo.set(internalName, { t: now, v });
|
|
5266
|
+
return v;
|
|
5267
|
+
}
|
|
5268
|
+
// When the agent's last turn ended, from the same .turn record (Claude's
|
|
5269
|
+
// Stop hook, Codex's notify chain). Used to attribute a bell: Codex rings
|
|
5270
|
+
// the terminal bell at the end of EVERY turn, and a bell that lands within
|
|
5271
|
+
// seconds of the turn record is "finished", not "needs you".
|
|
5272
|
+
function readTurnAt(internalName) {
|
|
5273
|
+
try {
|
|
5274
|
+
const at = Date.parse(JSON.parse(fs.readFileSync(
|
|
5275
|
+
path.join(CLAUDE_SESSIONS_DIR, `${internalName}.turn`), 'utf8')).at);
|
|
5276
|
+
return Number.isFinite(at) ? at : 0;
|
|
5277
|
+
} catch (e) { return 0; }
|
|
5278
|
+
}
|
|
5279
|
+
// The newest published view's time (ms) for a session, from the manifest
|
|
5280
|
+
// the views summary already reads. `hop view` rings one bell per publish.
|
|
5281
|
+
function latestViewAtMs(internalName) {
|
|
5282
|
+
try {
|
|
5283
|
+
const items = JSON.parse(fs.readFileSync(path.join(VIEWS_DIR, 'manifest.json'), 'utf8')).items || [];
|
|
5284
|
+
let best = 0, title = '';
|
|
5285
|
+
for (const it of items) {
|
|
5286
|
+
if (it.session !== internalName) continue;
|
|
5287
|
+
const ms = Number(it.mtime) < 1e11 ? Number(it.mtime) * 1000 : Number(it.mtime);
|
|
5288
|
+
if (ms > best) { best = ms; title = it.title || it.name || ''; }
|
|
5289
|
+
}
|
|
5290
|
+
return { at: best, title };
|
|
5291
|
+
} catch (e) { return { at: 0, title: '' }; }
|
|
5292
|
+
}
|
|
5293
|
+
// WHY a session wants attention — the thing Jian could not find when the UI
|
|
5294
|
+
// said a Codex session "needs your input" and the screen showed a finished
|
|
5295
|
+
// turn. Decided from timestamps hop already has, newest signal first:
|
|
5296
|
+
// ask the agent asked (hook record; also `hop notify`) — note = message
|
|
5297
|
+
// finished the bell landed with the turn record (Codex rings at turn end)
|
|
5298
|
+
// view the bell landed with a `hop view` publish — note = the title
|
|
5299
|
+
// bell a bell nothing explains
|
|
5300
|
+
function attentionReason(internalName, lastBellAt, lastActivityAt) {
|
|
5301
|
+
const ask = askOf(internalName);
|
|
5302
|
+
const now = Date.now();
|
|
5303
|
+
if (ask && ask.at && now - ask.at < 15 * 60 * 1000) {
|
|
5304
|
+
return { reason: 'ask', note: ask.message || '', askType: ask.type || '' };
|
|
5305
|
+
}
|
|
5306
|
+
const bell = Number(lastBellAt) || 0;
|
|
5307
|
+
if (bell > 0) {
|
|
5308
|
+
const turnAt = readTurnAt(internalName);
|
|
5309
|
+
if (turnAt && Math.abs(bell - turnAt) <= 4000) return { reason: 'finished', note: '' };
|
|
5310
|
+
const view = latestViewAtMs(internalName);
|
|
5311
|
+
if (view.at && Math.abs(bell - view.at) <= 4000) return { reason: 'view', note: view.title };
|
|
5312
|
+
// Still producing output: an agent that needs you is QUIET. Codex
|
|
5313
|
+
// rings mid-turn too (six bells in four minutes of "Working…" on
|
|
5314
|
+
// angler-codex) — those are pings, not questions.
|
|
5315
|
+
const active = Number(lastActivityAt) || 0;
|
|
5316
|
+
if (active && now - active < 15000) return { reason: 'working', note: '' };
|
|
5317
|
+
return { reason: 'bell', note: '' };
|
|
5318
|
+
}
|
|
5319
|
+
return { reason: '', note: '' };
|
|
5320
|
+
}
|
|
5251
5321
|
function finishBump(internalName, config) {
|
|
5252
5322
|
if (!config || config.notifyOnFinish !== true) return 0;
|
|
5253
5323
|
const base = Number.isInteger(config.notifyBaseTurn) ? config.notifyBaseTurn : 0;
|
|
@@ -6026,7 +6096,34 @@ async function sweepRestoreReconcile() {
|
|
|
6026
6096
|
// The screen and foreground name lie for argv-launched sessions.
|
|
6027
6097
|
const procUp = claudeProcessRunning(exp.sessionId);
|
|
6028
6098
|
const running = !isShellProcName(entry.foregroundProcess);
|
|
6029
|
-
const
|
|
6099
|
+
const alive = procUp || running;
|
|
6100
|
+
// A trust dialog is only a dialog while claude is up to show it.
|
|
6101
|
+
// Checked BEFORE "converged": a live claude parked at the dialog
|
|
6102
|
+
// used to be declared running and left there forever, while the
|
|
6103
|
+
// dialog's text lingering on a dead session's screen — the
|
|
6104
|
+
// process gone, a shell prompt under it — was "answered" every
|
|
6105
|
+
// tick, typing digits into the shell (motif, 2026-09-10).
|
|
6106
|
+
const settledMs = now - Math.max(exp.lastDriveAt || 0, exp.recordedAt || 0);
|
|
6107
|
+
if (TRUST_PROMPT_RE.test(text) && alive && settledMs >= 6000) {
|
|
6108
|
+
const trust = trustDialogAnswer(text, exp.cwd);
|
|
6109
|
+
if (trust.ours) {
|
|
6110
|
+
if (!exp.trustAnsweredAt || now - exp.trustAnsweredAt > 20_000) {
|
|
6111
|
+
try {
|
|
6112
|
+
const e2 = await attachTerminalApiClient(name, exp.displayName, { actor: 'system', label: 'reconcile' });
|
|
6113
|
+
try { sendTerminalInput(e2, trust.input); } finally { try { closeTerminalApiEntry(e2.id); } catch (e) {} }
|
|
6114
|
+
exp.trustAnsweredAt = now;
|
|
6115
|
+
changed = true;
|
|
6116
|
+
log(`[reconcile] ${exp.displayName}: accepted ${trust.preapproval ? 'pre-approved permissions' : 'workspace trust'} (${trust.how})`, DIM);
|
|
6117
|
+
} catch (e) { /* try again next tick */ }
|
|
6118
|
+
}
|
|
6119
|
+
continue;
|
|
6120
|
+
}
|
|
6121
|
+
// Trust dialog for some OTHER directory — not ours to answer.
|
|
6122
|
+
exp.status = 'needs-attention';
|
|
6123
|
+
exp.reason = `claude is asking to trust ${trust.asked || 'a directory'}, not ${exp.cwd}`;
|
|
6124
|
+
changed = true;
|
|
6125
|
+
continue;
|
|
6126
|
+
const claudeUp = alive || CLAUDE_UI_RE.test(text);
|
|
6030
6127
|
if (claudeUp) {
|
|
6031
6128
|
exp.status = 'converged';
|
|
6032
6129
|
exp.convergedAt = now;
|
|
@@ -6034,23 +6131,6 @@ async function sweepRestoreReconcile() {
|
|
|
6034
6131
|
log(`[reconcile] ${exp.displayName}: verified running`, DIM);
|
|
6035
6132
|
continue;
|
|
6036
6133
|
}
|
|
6037
|
-
// A dialog: the resume sweep answers resume prompts already; the
|
|
6038
|
-
// trust dialog we answer here (only for this session's own cwd).
|
|
6039
|
-
if (TRUST_PROMPT_RE.test(text)) {
|
|
6040
|
-
const asked = trustDialogWorkspace(text);
|
|
6041
|
-
if (asked && samePath(asked, exp.cwd)) {
|
|
6042
|
-
try {
|
|
6043
|
-
const e2 = await attachTerminalApiClient(name, exp.displayName, { actor: 'system', label: 'reconcile' });
|
|
6044
|
-
try { sendTerminalInput(e2, '1\r'); } finally { try { closeTerminalApiEntry(e2.id); } catch (e) {} }
|
|
6045
|
-
log(`[reconcile] ${exp.displayName}: accepted workspace trust`, DIM);
|
|
6046
|
-
} catch (e) { /* try again next tick */ }
|
|
6047
|
-
continue;
|
|
6048
|
-
}
|
|
6049
|
-
// Trust dialog for some OTHER directory — not ours to answer.
|
|
6050
|
-
exp.status = 'needs-attention';
|
|
6051
|
-
exp.reason = `claude is asking to trust ${asked || 'a directory'}, not ${exp.cwd}`;
|
|
6052
|
-
changed = true;
|
|
6053
|
-
continue;
|
|
6054
6134
|
}
|
|
6055
6135
|
if (RESUME_PROMPT_RE.test(text)) continue; // resume sweep owns this
|
|
6056
6136
|
// A busy non-shell foreground we did not recognise as claude: the
|
|
@@ -6477,18 +6557,32 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6477
6557
|
const grid = await getRoomGridPreviewShared(job.plan.internalName);
|
|
6478
6558
|
screen = String(grid?.text || '');
|
|
6479
6559
|
} catch (e) { continue; }
|
|
6480
|
-
|
|
6560
|
+
// Only a LIVE dialog: the same text lingers on screen after
|
|
6561
|
+
// claude has exited (or before the new one has drawn), and
|
|
6562
|
+
// answering it then types into a shell or into a claude that
|
|
6563
|
+
// has not asked yet.
|
|
6564
|
+
// ...and only once the fresh claude has had time to DRAW it:
|
|
6565
|
+
// the previous claude's dialog text is still on screen for the
|
|
6566
|
+
// first seconds after launch, and an answer sent then reaches
|
|
6567
|
+
// a claude that has not asked yet (the Enter lands on whatever
|
|
6568
|
+
// it shows next — for this dialog, the default "No, exit").
|
|
6569
|
+
const dialogLive = TRUST_PROMPT_RE.test(screen) && Date.now() - job.since >= 6000 && (() => {
|
|
6570
|
+
const recId = readClaudeSessionRecord(job.plan.internalName)?.sessionId;
|
|
6571
|
+
const fg = getRuntimeSessionMetadata(job.plan.internalName)?.foregroundProcess;
|
|
6572
|
+
return (!!recId && claudeProcessRunning(recId)) || (!!fg && !isShellProcName(fg));
|
|
6573
|
+
})();
|
|
6574
|
+
if (dialogLive && !job.trustAnswered) {
|
|
6481
6575
|
// Answer only for the workspace this session was launched
|
|
6482
6576
|
// in. A dialog naming any other directory is not ours to
|
|
6483
6577
|
// wave through - leave it on screen and say so.
|
|
6484
|
-
const
|
|
6485
|
-
if (
|
|
6486
|
-
try { sendTerminalInput(job.entry,
|
|
6487
|
-
log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: accepted workspace trust for ${asked}`, DIM);
|
|
6578
|
+
const trust = trustDialogAnswer(screen, job.plan.cwd);
|
|
6579
|
+
if (trust.ours) {
|
|
6580
|
+
try { sendTerminalInput(job.entry, trust.input); } catch (e) { finish('write-failed'); continue; }
|
|
6581
|
+
log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: accepted ${trust.preapproval ? 'pre-approved permissions' : `workspace trust for ${trust.asked}`} (${trust.how})`, DIM);
|
|
6488
6582
|
job.trustAnswered = true;
|
|
6489
6583
|
job.since = Date.now();
|
|
6490
6584
|
} else {
|
|
6491
|
-
log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: claude is asking to trust ${asked || 'an unknown directory'}, not ${job.plan.cwd} — left for you to answer`, YELLOW);
|
|
6585
|
+
log(` ${getSessionDisplayName(job.plan.internalName) || job.plan.internalName}: claude is asking to trust ${trust.asked || 'an unknown directory'}, not ${job.plan.cwd} — left for you to answer`, YELLOW);
|
|
6492
6586
|
finish('trust-prompt');
|
|
6493
6587
|
}
|
|
6494
6588
|
continue;
|
|
@@ -6529,7 +6623,12 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6529
6623
|
// The same script is registered for two events: SessionStart (records the
|
|
6530
6624
|
// conversation id for `hop restore`) and Stop (bumps a per-turn counter a driver
|
|
6531
6625
|
// can wait on). See scripts/claude-session-hook.js.
|
|
6532
|
-
const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop'];
|
|
6626
|
+
const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop', 'Notification', 'PreToolUse'];
|
|
6627
|
+
// Notification (permission prompts, elicitation) and PreToolUse (the two tools
|
|
6628
|
+
// that open a dialog and wait) bump the per-session ASK record — "whenever an
|
|
6629
|
+
// agent asks me a question, notify me" (Jian). PreToolUse gets a matcher, or
|
|
6630
|
+
// the script would run on every tool call for nothing.
|
|
6631
|
+
const CLAUDE_HOOK_MATCHERS = { PreToolUse: 'AskUserQuestion|ExitPlanMode' };
|
|
6533
6632
|
|
|
6534
6633
|
function getClaudeSettingsPath() {
|
|
6535
6634
|
const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
@@ -6649,7 +6748,7 @@ function installClaudeHook() {
|
|
|
6649
6748
|
for (const ev of CLAUDE_HOOK_EVENTS) {
|
|
6650
6749
|
if (!Array.isArray(data.hooks[ev])) data.hooks[ev] = [];
|
|
6651
6750
|
if (!hookGroupsHaveScript(data.hooks[ev])) {
|
|
6652
|
-
data.hooks[ev].push({ matcher: '', hooks: [{ type: 'command', command }] });
|
|
6751
|
+
data.hooks[ev].push({ matcher: CLAUDE_HOOK_MATCHERS[ev] || '', hooks: [{ type: 'command', command }] });
|
|
6653
6752
|
}
|
|
6654
6753
|
}
|
|
6655
6754
|
try {
|
|
@@ -6693,6 +6792,65 @@ function removeClaudeHook() {
|
|
|
6693
6792
|
// there (e.g. Codex Computer Use) via "--chain <orig...>". TOML is edited
|
|
6694
6793
|
// line-by-line — `notify` is a top-level key, and we touch only that one line —
|
|
6695
6794
|
// so the rest of the user's config is never reserialized (and mangled).
|
|
6795
|
+
// Codex hooks (Claude-compatible hooks.json at $CODEX_HOME/hooks.json): the
|
|
6796
|
+
// PermissionRequest event fires before Codex asks the human to approve a
|
|
6797
|
+
// command or network access — the same hop hook script records it as an ASK
|
|
6798
|
+
// so the phone buzzes with Codex's own one-line reason. Parity with Claude's
|
|
6799
|
+
// Notification/PreToolUse asks. Codex requires the user to TRUST a new hook
|
|
6800
|
+
// once, in its `/hooks` screen; until then it does not run.
|
|
6801
|
+
function getCodexHooksPath() {
|
|
6802
|
+
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json');
|
|
6803
|
+
}
|
|
6804
|
+
function codexHookGroupsHaveScript(groups) {
|
|
6805
|
+
return Array.isArray(groups) && groups.some((g) => Array.isArray(g?.hooks) && g.hooks.some(
|
|
6806
|
+
(h) => h && typeof h.command === 'string' && h.command.includes('claude-session-hook.js')
|
|
6807
|
+
));
|
|
6808
|
+
}
|
|
6809
|
+
function readCodexHooks() {
|
|
6810
|
+
const p = getCodexHooksPath();
|
|
6811
|
+
try { return { path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
|
|
6812
|
+
catch (e) { return { path: p, data: { hooks: {} } }; }
|
|
6813
|
+
}
|
|
6814
|
+
function codexHookIsInstalled(data) {
|
|
6815
|
+
return codexHookGroupsHaveScript(data?.hooks?.PermissionRequest);
|
|
6816
|
+
}
|
|
6817
|
+
function installCodexHook() {
|
|
6818
|
+
const { path: p, data } = readCodexHooks();
|
|
6819
|
+
if (codexHookIsInstalled(data)) return { ok: true, already: true, path: p };
|
|
6820
|
+
if (!fs.existsSync(CLAUDE_HOOK_SCRIPT)) return { ok: false, error: `hook script missing: ${CLAUDE_HOOK_SCRIPT}` };
|
|
6821
|
+
data.hooks = data.hooks && typeof data.hooks === 'object' ? data.hooks : {};
|
|
6822
|
+
if (!Array.isArray(data.hooks.PermissionRequest)) data.hooks.PermissionRequest = [];
|
|
6823
|
+
data.hooks.PermissionRequest.push({
|
|
6824
|
+
matcher: '',
|
|
6825
|
+
hooks: [{ type: 'command', command: `${process.execPath} ${CLAUDE_HOOK_SCRIPT}`, timeout: 5,
|
|
6826
|
+
statusMessage: 'hop: noting the question for your phone' }]
|
|
6827
|
+
});
|
|
6828
|
+
if (!data.description) data.description = 'hop: notify the phone when Codex stops to ask (PermissionRequest)';
|
|
6829
|
+
try {
|
|
6830
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
6831
|
+
if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.hop-backup`);
|
|
6832
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
|
|
6833
|
+
return { ok: true, already: false, path: p };
|
|
6834
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
6835
|
+
}
|
|
6836
|
+
function removeCodexHook() {
|
|
6837
|
+
const { path: p, data } = readCodexHooks();
|
|
6838
|
+
if (!codexHookIsInstalled(data)) return { ok: true, already: true, path: p };
|
|
6839
|
+
const groups = data.hooks.PermissionRequest;
|
|
6840
|
+
for (const g of groups) {
|
|
6841
|
+
if (Array.isArray(g.hooks)) {
|
|
6842
|
+
g.hooks = g.hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes('claude-session-hook.js')));
|
|
6843
|
+
}
|
|
6844
|
+
}
|
|
6845
|
+
data.hooks.PermissionRequest = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
|
|
6846
|
+
if (data.hooks.PermissionRequest.length === 0) delete data.hooks.PermissionRequest;
|
|
6847
|
+
try {
|
|
6848
|
+
if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.hop-backup`);
|
|
6849
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
|
|
6850
|
+
return { ok: true, already: false, path: p };
|
|
6851
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
6852
|
+
}
|
|
6853
|
+
|
|
6696
6854
|
const CODEX_NOTIFY_SCRIPT = path.join(__dirname, 'scripts', 'codex-notify-hook.js');
|
|
6697
6855
|
function getCodexConfigPath() {
|
|
6698
6856
|
return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'config.toml');
|
|
@@ -7836,6 +7994,38 @@ setInterval(() => {
|
|
|
7836
7994
|
}
|
|
7837
7995
|
}, 60_000).unref();
|
|
7838
7996
|
|
|
7997
|
+
// The host's whole current screen for a room (RIS + serialized grid + modes),
|
|
7998
|
+
// with the output cursor it reflects. Seeds a daemon preview grid without the
|
|
7999
|
+
// mid-frame holes a raw tail carries. null: host unreachable, room gone, or
|
|
8000
|
+
// an old host without the endpoint — callers fall back to the tail.
|
|
8001
|
+
function fetchHayRoomSerializedScreen(internalName) {
|
|
8002
|
+
const host = hayHostState || readHayHostStateFile();
|
|
8003
|
+
if (!host || !Number.isInteger(host.port) || host.port > 0 === false) return Promise.resolve(null);
|
|
8004
|
+
return new Promise((resolve) => {
|
|
8005
|
+
let settled = false;
|
|
8006
|
+
const done = (v) => { if (!settled) { settled = true; resolve(v); } };
|
|
8007
|
+
const req = http.request({
|
|
8008
|
+
hostname: '127.0.0.1',
|
|
8009
|
+
port: host.port,
|
|
8010
|
+
path: `/rooms/${encodeURIComponent(internalName)}/serialized`,
|
|
8011
|
+
method: 'GET'
|
|
8012
|
+
}, (res) => {
|
|
8013
|
+
let data = '';
|
|
8014
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
8015
|
+
res.on('end', () => {
|
|
8016
|
+
if ((res.statusCode || 0) !== 200) { done(null); return; }
|
|
8017
|
+
try {
|
|
8018
|
+
const parsed = JSON.parse(data || 'null');
|
|
8019
|
+
done(parsed && typeof parsed.data === 'string' && parsed.data ? parsed : null);
|
|
8020
|
+
} catch (e) { done(null); }
|
|
8021
|
+
});
|
|
8022
|
+
});
|
|
8023
|
+
req.on('error', () => done(null));
|
|
8024
|
+
req.setTimeout(6000, () => { req.destroy(); done(null); });
|
|
8025
|
+
req.end();
|
|
8026
|
+
});
|
|
8027
|
+
}
|
|
8028
|
+
|
|
7839
8029
|
function fetchHayRoomOutputSince(internalName, since) {
|
|
7840
8030
|
const host = hayHostState || readHayHostStateFile();
|
|
7841
8031
|
if (!host || !Number.isInteger(host.port) || host.port <= 0) return Promise.resolve(null);
|
|
@@ -7988,7 +8178,7 @@ async function getRoomGridPreview(internalName) {
|
|
|
7988
8178
|
const Term = loadHeadlessTerminal();
|
|
7989
8179
|
if (!Term) return null;
|
|
7990
8180
|
let grid = roomGrids.get(internalName);
|
|
7991
|
-
|
|
8181
|
+
let res = await fetchHayRoomOutputSince(internalName, grid ? grid.offset : undefined);
|
|
7992
8182
|
if (!res) {
|
|
7993
8183
|
// Host unreachable: an existing grid still holds the last true
|
|
7994
8184
|
// screen — serve that rather than nothing.
|
|
@@ -8000,11 +8190,31 @@ async function getRoomGridPreview(internalName) {
|
|
|
8000
8190
|
return null;
|
|
8001
8191
|
}
|
|
8002
8192
|
outputSinceProbe.supported = true;
|
|
8193
|
+
// A grid that must (re)start seeds from the host's WHOLE screen, then
|
|
8194
|
+
// continues with deltas from the cursor that screen reflects. Seeding
|
|
8195
|
+
// from a bounded raw tail cut at an arbitrary byte left a differential
|
|
8196
|
+
// TUI (Codex, Claude's inline UI) with holes it never re-sent: previews
|
|
8197
|
+
// stayed partially wrong until the app repainted in full. An old host
|
|
8198
|
+
// without an offset continues from the delta call's cursor instead —
|
|
8199
|
+
// a few bytes may be skipped once, still far better than the tail.
|
|
8200
|
+
const reseed = !grid || res.reset;
|
|
8201
|
+
let seed = null;
|
|
8202
|
+
if (reseed) {
|
|
8203
|
+
const snap = await fetchHayRoomSerializedScreen(internalName);
|
|
8204
|
+
if (snap) {
|
|
8205
|
+
const from = Number.isFinite(Number(snap.offset)) ? Number(snap.offset) : res.offset;
|
|
8206
|
+
const delta = await fetchHayRoomOutputSince(internalName, from);
|
|
8207
|
+
if (delta && !delta.notFound && !delta.reset) {
|
|
8208
|
+
seed = { data: snap.data, cols: Number(snap.cols) || res.cols, rows: Number(snap.rows) || res.rows };
|
|
8209
|
+
res = delta;
|
|
8210
|
+
}
|
|
8211
|
+
}
|
|
8212
|
+
}
|
|
8003
8213
|
// Generous clamps: rendering NARROWER than the PTY re-wraps every long
|
|
8004
8214
|
// line into garbage (the old 400 clamp mangled a real 476-col session).
|
|
8005
|
-
const cols = Math.max(2, Math.min(1000, Math.floor(Number(res.cols) || 80)));
|
|
8006
|
-
const rows = Math.max(2, Math.min(200, Math.floor(Number(res.rows) || 24)));
|
|
8007
|
-
if (
|
|
8215
|
+
const cols = Math.max(2, Math.min(1000, Math.floor(Number((seed || res).cols) || 80)));
|
|
8216
|
+
const rows = Math.max(2, Math.min(200, Math.floor(Number((seed || res).rows) || 24)));
|
|
8217
|
+
if (reseed) {
|
|
8008
8218
|
disposeRoomGrid(internalName);
|
|
8009
8219
|
let term;
|
|
8010
8220
|
try {
|
|
@@ -8024,6 +8234,11 @@ async function getRoomGridPreview(internalName) {
|
|
|
8024
8234
|
}
|
|
8025
8235
|
grid = { term, serialize, offset: res.offset, cols, rows, lastUsedAt: Date.now() };
|
|
8026
8236
|
roomGrids.set(internalName, grid);
|
|
8237
|
+
if (seed) {
|
|
8238
|
+
await new Promise((resolve) => {
|
|
8239
|
+
try { term.write(seed.data, () => resolve()); } catch (e) { resolve(); }
|
|
8240
|
+
});
|
|
8241
|
+
}
|
|
8027
8242
|
if (roomGrids.size > ROOM_GRID_MAX) {
|
|
8028
8243
|
let oldestKey = null;
|
|
8029
8244
|
let oldestAt = Infinity;
|
|
@@ -11067,8 +11282,18 @@ happens until someone clicks it.
|
|
|
11067
11282
|
// adds to bellSeq so the phone treats a finished response like a
|
|
11068
11283
|
// bell (dedupe/seen-marks make it one alert per finish). Plain
|
|
11069
11284
|
// sessions are unaffected — finishBump returns 0.
|
|
11070
|
-
bellSeq: s.bellSeq + finishBump(s.internalName, getEffectiveSessionConfig(s.internalName))
|
|
11071
|
-
|
|
11285
|
+
bellSeq: s.bellSeq + finishBump(s.internalName, getEffectiveSessionConfig(s.internalName))
|
|
11286
|
+
+ (askOf(s.internalName)?.count || 0),
|
|
11287
|
+
// The bell's timestamp advances to the ask's, so clients that
|
|
11288
|
+
// key seen-markers on time (the web) see a question as new.
|
|
11289
|
+
lastBellAt: Math.max(Number(s.lastBellAt) || 0, askOf(s.internalName)?.at || 0) || s.lastBellAt,
|
|
11290
|
+
// Why it wants you (see attentionReason): ask / finished / view / bell.
|
|
11291
|
+
attentionReason: attentionReason(s.internalName, s.lastBellAt, s.lastActivityAt).reason,
|
|
11292
|
+
attentionNote: attentionReason(s.internalName, s.lastBellAt, s.lastActivityAt).note,
|
|
11293
|
+
askSeq: askOf(s.internalName)?.count || 0,
|
|
11294
|
+
askAt: askOf(s.internalName)?.at || null,
|
|
11295
|
+
askType: askOf(s.internalName)?.type || null,
|
|
11296
|
+
askMessage: askOf(s.internalName)?.message || null,
|
|
11072
11297
|
shell: s.shell,
|
|
11073
11298
|
env: s.env,
|
|
11074
11299
|
cols: s.cols,
|
|
@@ -13225,6 +13450,9 @@ happens until someone clicks it.
|
|
|
13225
13450
|
const rows = parseInt(wsUrl.searchParams.get('rows') || '24', 10);
|
|
13226
13451
|
const source = wsUrl.searchParams.get('source') || '';
|
|
13227
13452
|
const claimId = wsUrl.searchParams.get('claim') || null;
|
|
13453
|
+
// Connect-time size claim (hay ClientInfo.sizeClaim): the phone's
|
|
13454
|
+
// first frame at its own size, not a desktop peer's then a reflow.
|
|
13455
|
+
const sizeClaim = wsUrl.searchParams.get('sizeClaim') === 'attach' ? 'attach' : undefined;
|
|
13228
13456
|
// Client-supplied tab identity; the room evicts a same-key
|
|
13229
13457
|
// predecessor on attach (reconnect replaces its own ghost).
|
|
13230
13458
|
const deviceKeyRaw = wsUrl.searchParams.get('device') || '';
|
|
@@ -13266,7 +13494,7 @@ happens until someone clicks it.
|
|
|
13266
13494
|
|
|
13267
13495
|
// Attach client to hay room
|
|
13268
13496
|
session.room.attachClient(
|
|
13269
|
-
{ id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows },
|
|
13497
|
+
{ id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows, sizeClaim },
|
|
13270
13498
|
hay.createSocketAdapter(ws)
|
|
13271
13499
|
);
|
|
13272
13500
|
});
|
|
@@ -15145,7 +15373,7 @@ async function attachToAllSessions(existingTunnel) {
|
|
|
15145
15373
|
const KNOWN_COMMANDS = [
|
|
15146
15374
|
'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
|
|
15147
15375
|
'help', 'config', 'url', 'qr', 'health', 'domain', 'domain-clear',
|
|
15148
|
-
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
|
|
15376
|
+
'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'recover', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'codex-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
|
|
15149
15377
|
'mail', 'registration', 'canonical'
|
|
15150
15378
|
];
|
|
15151
15379
|
|
|
@@ -15268,6 +15496,7 @@ const HELP_SECTIONS = [
|
|
|
15268
15496
|
] },
|
|
15269
15497
|
{ title: 'Restore', lines: [
|
|
15270
15498
|
`hop restore [--dry-run] [--agents] Recreate your saved sessions, resuming claude conversations (--agents includes agent-created ones)`,
|
|
15499
|
+
`hop recover [<session>] Bring back the agent that last ran here: inside a session, relaunch its conversation in this terminal`,
|
|
15271
15500
|
`hop restore <name>... Redo just those sessions — even live ones, if they hold only an idle shell or a startup dialog`,
|
|
15272
15501
|
`hop restore --health Show sessions the reconciler could not bring back on its own, and why`,
|
|
15273
15502
|
`hop update [--check|--no-host] Self-update from GitHub (daemon + host restart, host waits for the fleet to go idle; --no-host skips the host cycle)`,
|
|
@@ -15879,6 +16108,61 @@ async function main() {
|
|
|
15879
16108
|
// Handle `hop resume-waiting` — answer claude's resume question for any
|
|
15880
16109
|
// session currently parked on it (sessions restored before this existed,
|
|
15881
16110
|
// or restored with resume_mode=ask and since reconsidered).
|
|
16111
|
+
// hop recover [<session>] — bring back the agent that last ran in a
|
|
16112
|
+
// session. Inside a session with no argument, it relaunches that
|
|
16113
|
+
// session's own conversation IN THIS TERMINAL (the same flag-preserving
|
|
16114
|
+
// command `hop restore` would use), so a claude that exited — crash,
|
|
16115
|
+
// /exit, a wrong answer to a dialog — is one command away, and any
|
|
16116
|
+
// dialog it shows is answered by the human sitting there. Named from
|
|
16117
|
+
// anywhere, it is `hop restore <name>`.
|
|
16118
|
+
if (process.argv[2] === 'recover') {
|
|
16119
|
+
const args = process.argv.slice(3);
|
|
16120
|
+
const dryRun = args.includes('--dry-run') || args.includes('-n');
|
|
16121
|
+
const named = args.find((a) => !a.startsWith('-')) || null;
|
|
16122
|
+
const here = process.env.HOP_SESSION || null;
|
|
16123
|
+
if (!named && !here) {
|
|
16124
|
+
log(`Run ${CYAN}hop recover${RESET} inside a hop session, or name one: ${CYAN}hop recover <session>${RESET}`, YELLOW);
|
|
16125
|
+
process.exit(1);
|
|
16126
|
+
}
|
|
16127
|
+
const target = named ? (resolveSessionInternalName(named, named) || named) : here;
|
|
16128
|
+
if (target !== here) {
|
|
16129
|
+
// Not this terminal: the daemon relaunches it in its own room.
|
|
16130
|
+
const child = spawn(process.execPath, [__filename, 'restore', ...(dryRun ? ['--dry-run'] : []), named], { stdio: 'inherit' });
|
|
16131
|
+
child.on('exit', (code) => process.exit(code ?? 1));
|
|
16132
|
+
return;
|
|
16133
|
+
}
|
|
16134
|
+
const display = getSessionDisplayName(target) || target;
|
|
16135
|
+
const meta = readClaudeSessionMeta(target);
|
|
16136
|
+
const cfg = getSessionConfig(target);
|
|
16137
|
+
const cwd = meta?.cwd || cfg?.cwd || process.cwd();
|
|
16138
|
+
const rec = readClaudeSessionRecord(target);
|
|
16139
|
+
if (rec?.sessionId && claudeProcessRunning(rec.sessionId)) {
|
|
16140
|
+
log(`${display}: its claude conversation ${String(rec.sessionId).slice(0, 8)}… is already running in this session.`, YELLOW);
|
|
16141
|
+
process.exit(1);
|
|
16142
|
+
}
|
|
16143
|
+
const plan = restorePlanForSession({ internalName: target, cwd, startup: cfg?.startup, autoStart: cfg?.autoStart });
|
|
16144
|
+
let command = plan?.command || null;
|
|
16145
|
+
let kind = command ? 'claude' : null;
|
|
16146
|
+
if (!command) {
|
|
16147
|
+
// No claude record: a codex conversation is found by its rollout.
|
|
16148
|
+
const rollout = resolveCodexTranscriptByCwd(cwd);
|
|
16149
|
+
const rolloutId = rollout ? await trajectoryExtract.codexRolloutId(rollout) : null;
|
|
16150
|
+
if (rolloutId) {
|
|
16151
|
+
command = `codex resume '${rolloutId.replace(/'/g, `'\\''`)}'`;
|
|
16152
|
+
kind = 'codex';
|
|
16153
|
+
}
|
|
16154
|
+
}
|
|
16155
|
+
if (!command) {
|
|
16156
|
+
log(`${display}: nothing to recover — no claude or codex conversation is recorded for this session${plan?.warning ? ` (${plan.warning})` : ''}.`, YELLOW);
|
|
16157
|
+
process.exit(1);
|
|
16158
|
+
}
|
|
16159
|
+
log(`${dryRun ? 'Would recover' : 'Recovering'} ${BOLD}${display}${RESET} (${kind}) in this terminal:`, RESET);
|
|
16160
|
+
log(` ${DIM}${command}${RESET}`, RESET);
|
|
16161
|
+
if (dryRun) process.exit(0);
|
|
16162
|
+
const child = spawn('/bin/zsh', ['-lc', command], { stdio: 'inherit', cwd: plan?.cwd || cwd, env: process.env });
|
|
16163
|
+
child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
|
|
16164
|
+
return;
|
|
16165
|
+
}
|
|
15882
16166
|
if (process.argv[2] === 'resume-waiting') {
|
|
15883
16167
|
const state = isServerRunning();
|
|
15884
16168
|
if (!state) { printNotRunningError(); process.exit(1); }
|
|
@@ -16041,7 +16325,7 @@ async function main() {
|
|
|
16041
16325
|
log(`${na.length} session(s) need your attention:`, YELLOW);
|
|
16042
16326
|
for (const it of na) {
|
|
16043
16327
|
log(` ${YELLOW}⚠${RESET} ${it.name} ${DIM}${it.reason}${RESET}`, RESET);
|
|
16044
|
-
log(` ${DIM}redo: ${CYAN}hop restore ${it.name}${RESET}`, RESET);
|
|
16328
|
+
log(` ${DIM}redo: ${CYAN}hop restore ${it.name}${RESET}${DIM}, or inside it: ${CYAN}hop recover${RESET}`, RESET);
|
|
16045
16329
|
}
|
|
16046
16330
|
process.exit(0);
|
|
16047
16331
|
}
|
|
@@ -16129,6 +16413,29 @@ async function main() {
|
|
|
16129
16413
|
// Stop hook, so "notify me when it finishes" works for Codex sessions too.
|
|
16130
16414
|
// Installs a WRAPPER into ~/.codex/config.toml that chains to any existing
|
|
16131
16415
|
// notify (Computer Use), so nothing already there breaks.
|
|
16416
|
+
if (process.argv[2] === 'codex-hook') {
|
|
16417
|
+
const sub = process.argv[3] || 'status';
|
|
16418
|
+
if (sub === 'install') {
|
|
16419
|
+
const r = installCodexHook();
|
|
16420
|
+
if (!r.ok) { log(`Failed to install Codex hook: ${r.error}`, RED); process.exit(1); }
|
|
16421
|
+
log(r.already ? `Codex PermissionRequest hook already installed (${r.path}).`
|
|
16422
|
+
: `${GREEN}Installed${RESET} Codex PermissionRequest hook → ${r.path}`, RESET);
|
|
16423
|
+
log(`Codex runs a new hook only after you trust it once: in Codex, type ${CYAN}/hooks${RESET} and choose Trust.`, DIM);
|
|
16424
|
+
process.exit(0);
|
|
16425
|
+
}
|
|
16426
|
+
if (sub === 'remove') {
|
|
16427
|
+
const r = removeCodexHook();
|
|
16428
|
+
if (!r.ok) { log(`Failed to remove Codex hook: ${r.error}`, RED); process.exit(1); }
|
|
16429
|
+
log(r.already ? `Codex PermissionRequest hook was not installed.` : `${GREEN}Removed${RESET} Codex PermissionRequest hook from ${r.path}`, RESET);
|
|
16430
|
+
process.exit(0);
|
|
16431
|
+
}
|
|
16432
|
+
const { path: p, data } = readCodexHooks();
|
|
16433
|
+
log(codexHookIsInstalled(data)
|
|
16434
|
+
? `${GREEN}✓${RESET} Codex PermissionRequest hook installed (${p}) — trusted in Codex's /hooks?`
|
|
16435
|
+
: `${YELLOW}!${RESET} Codex PermissionRequest hook not installed. Run ${CYAN}hop codex-hook install${RESET} to get a phone notification when Codex asks.`, RESET);
|
|
16436
|
+
process.exit(0);
|
|
16437
|
+
}
|
|
16438
|
+
|
|
16132
16439
|
if (process.argv[2] === 'codex-notify') {
|
|
16133
16440
|
const sub = process.argv[3] || 'status';
|
|
16134
16441
|
if (sub === 'install') {
|
|
@@ -16406,6 +16713,19 @@ async function main() {
|
|
|
16406
16713
|
log('Usage: hop notify <why you need the human>', YELLOW);
|
|
16407
16714
|
process.exit(1);
|
|
16408
16715
|
}
|
|
16716
|
+
// The message rides the ASK record too, so the phone can say WHAT is
|
|
16717
|
+
// needed instead of a bare "wants you" (see readAsk / attentionReason).
|
|
16718
|
+
const hs = process.env.HOP_SESSION;
|
|
16719
|
+
if (hs && /^[A-Za-z0-9_-]+$/.test(hs)) {
|
|
16720
|
+
try {
|
|
16721
|
+
const file = path.join(CLAUDE_SESSIONS_DIR, `${hs}.ask`);
|
|
16722
|
+
let count = 0;
|
|
16723
|
+
try { count = JSON.parse(fs.readFileSync(file, 'utf8')).count || 0; } catch (e) { /* first */ }
|
|
16724
|
+
fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
|
|
16725
|
+
fs.writeFileSync(file, JSON.stringify({ sessionId: null, count: count + 1,
|
|
16726
|
+
at: new Date().toISOString(), type: 'notify', message: msg.slice(0, 300) }));
|
|
16727
|
+
} catch (e) { /* the bell still rings */ }
|
|
16728
|
+
}
|
|
16409
16729
|
process.stdout.write(`\n\u2691 NEEDS YOU: ${msg}\n`);
|
|
16410
16730
|
process.stdout.write('\x07');
|
|
16411
16731
|
process.exit(0);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
// The directory the dialog is asking about, so an answer is only ever given
|
|
5
|
+
// for the workspace the session was launched in - never for wherever a
|
|
6
|
+
// shell happened to wander.
|
|
7
|
+
function trustDialogWorkspace(screenText) {
|
|
8
|
+
const m = /Accessing\s*workspace:?\s*(\/[^\s\u2502\u2500]+)/i.exec(String(screenText || ''));
|
|
9
|
+
return m ? m[1].replace(/\/+$/, '') : null;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* What a trust dialog on screen is asking, and the exact keys that say yes.
|
|
13
|
+
*
|
|
14
|
+
* Two variants exist. The classic one names the workspace ("Accessing
|
|
15
|
+
* workspace: /path") and numbers its options ("1. Yes, proceed / 2. No,
|
|
16
|
+
* exit"): typing the number answers it. The other appears when the folder's
|
|
17
|
+
* .claude/settings.json pre-approves tool permissions: it names no path (the
|
|
18
|
+
* folder IS the launch directory), lists "No, exit" FIRST, and is an ARROW
|
|
19
|
+
* SELECTOR — a ❯ marks the highlighted option and Enter confirms it. Typing
|
|
20
|
+
* a digit does nothing there, and the Enter that follows confirms the
|
|
21
|
+
* default: "No, exit". That is exactly how motif's restore was declined
|
|
22
|
+
* three times over — "1\r" on 2026-09-04, "2\r" on 2026-09-10 — and the
|
|
23
|
+
* session came back as a shell each time. So the answer is read off the
|
|
24
|
+
* screen: the number when the options are numbered, otherwise enough arrow
|
|
25
|
+
* presses to move the highlight onto the yes option, then Enter.
|
|
26
|
+
*/
|
|
27
|
+
function trustDialogAnswer(screenText, sessionCwd) {
|
|
28
|
+
const text = String(screenText || '');
|
|
29
|
+
const asked = trustDialogWorkspace(text);
|
|
30
|
+
const preapproval = /pre-?approves\s*\d+\s*tool\s*permissions|Only\s*proceed\s*if\s*you\s*trust\s*this\s*configuration/i.test(text);
|
|
31
|
+
const lines = text.split('\n');
|
|
32
|
+
const isYes = (l) => /Yes,?\s*(I\s*trust\s*this\s*folder|proceed)/i.test(l);
|
|
33
|
+
const isOption = (l) => isYes(l) || /No,?\s*exit/i.test(l);
|
|
34
|
+
const yesIdx = lines.findIndex(isYes);
|
|
35
|
+
let input = '1\r';
|
|
36
|
+
let how = 'option 1';
|
|
37
|
+
if (yesIdx >= 0) {
|
|
38
|
+
const numbered = /(?:^|\s)(\d+)\.\s*Yes/i.exec(lines[yesIdx]);
|
|
39
|
+
if (numbered) {
|
|
40
|
+
input = `${numbered[1]}\r`;
|
|
41
|
+
how = `option ${numbered[1]}`;
|
|
42
|
+
} else {
|
|
43
|
+
// Highlighted row → yes row, counting only option rows between.
|
|
44
|
+
const highlighted = lines.findIndex((l) => /[❯›>]\s*(Yes|No)/i.test(l));
|
|
45
|
+
const from = highlighted >= 0 ? highlighted : lines.findIndex(isOption);
|
|
46
|
+
const between = lines.slice(Math.min(from, yesIdx), Math.max(from, yesIdx)).filter(isOption).length;
|
|
47
|
+
const steps = from === yesIdx ? 0 : Math.max(0, between - (from < yesIdx ? 0 : 0));
|
|
48
|
+
const arrow = from < yesIdx ? '\x1b[B' : '\x1b[A';
|
|
49
|
+
input = arrow.repeat(from === yesIdx ? 0 : Math.max(1, steps)) + '\r';
|
|
50
|
+
how = from === yesIdx ? 'Enter on the highlighted yes' : `${from < yesIdx ? 'down' : 'up'} ×${Math.max(1, steps)} + Enter`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const ours = asked ? samePath(asked, sessionCwd) : preapproval;
|
|
54
|
+
return { input, how, asked, preapproval, ours };
|
|
55
|
+
}
|
|
56
|
+
function samePath(a, b) {
|
|
57
|
+
const norm = (p) => {
|
|
58
|
+
let v = String(p || '').replace(/\/+$/, '');
|
|
59
|
+
try { v = fs.realpathSync(v); } catch (e) { /* keep as-is */ }
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
return !!a && !!b && norm(a) === norm(b);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = { trustDialogWorkspace, trustDialogAnswer, samePath };
|
package/package.json
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { trustDialogAnswer } = require('../lib/trust-dialog');
|
|
4
|
+
|
|
5
|
+
const classic = `
|
|
6
|
+
Quick safety check
|
|
7
|
+
Accessing workspace: /Users/me/proj
|
|
8
|
+
Is this a project you created or one you trust?
|
|
9
|
+
❯ 1. Yes, proceed
|
|
10
|
+
2. No, exit
|
|
11
|
+
`;
|
|
12
|
+
const preapproval = `
|
|
13
|
+
⚠ This folder pre-approves 15 tool permissions in .claude/settings.json:
|
|
14
|
+
mcp__x__read, mcp__y__write, and 7 more
|
|
15
|
+
These will apply without asking. Only proceed if you trust this configuration.
|
|
16
|
+
❯ No, exit
|
|
17
|
+
Yes, I trust this folder
|
|
18
|
+
Enter to confirm · Esc to cancel
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
test('classic numbered dialog: type the number of the yes option, only for the session\'s own workspace', () => {
|
|
22
|
+
const ours = trustDialogAnswer(classic, '/Users/me/proj');
|
|
23
|
+
assert.equal(ours.input, '1\r');
|
|
24
|
+
assert.equal(ours.asked, '/Users/me/proj');
|
|
25
|
+
assert.equal(ours.ours, true);
|
|
26
|
+
assert.equal(trustDialogAnswer(classic, '/Users/me/elsewhere').ours, false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('pre-approval dialog is an arrow selector with "No, exit" highlighted: Down then Enter (the motif regression)', () => {
|
|
30
|
+
const a = trustDialogAnswer(preapproval, '/Users/me');
|
|
31
|
+
assert.equal(a.preapproval, true);
|
|
32
|
+
assert.equal(a.input, '\x1b[B\r');
|
|
33
|
+
assert.equal(a.asked, null); // names no path: the folder is the launch directory
|
|
34
|
+
assert.equal(a.ours, true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('a selector already highlighting yes needs only Enter', () => {
|
|
38
|
+
const t = preapproval.replace('❯ No, exit', ' No, exit').replace(' Yes, I trust', '❯ Yes, I trust');
|
|
39
|
+
assert.equal(trustDialogAnswer(t, '/Users/me').input, '\r');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('a numbered pre-approval layout takes its number', () => {
|
|
43
|
+
const t = preapproval.replace('❯ No, exit', ' 1. No, exit').replace(' Yes, I trust', '❯ 2. Yes, I trust');
|
|
44
|
+
assert.equal(trustDialogAnswer(t, '/Users/me').input, '2\r');
|
|
45
|
+
});
|