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.
@@ -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);
@@ -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
- && !remoteAltScreenRef.current
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
- // The directory the dialog is asking about, so an answer is only ever given
2654
- // for the workspace the session was launched in - never for wherever a
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 claudeUp = procUp || running || CLAUDE_UI_RE.test(text);
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
- if (TRUST_PROMPT_RE.test(screen) && !job.trustAnswered) {
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 asked = trustDialogWorkspace(screen);
6485
- if (asked && samePath(asked, job.plan.cwd)) {
6486
- try { sendTerminalInput(job.entry, '1\r'); } catch (e) { finish('write-failed'); continue; }
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
- const res = await fetchHayRoomOutputSince(internalName, grid ? grid.offset : undefined);
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 (!grid || res.reset) {
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
- lastBellAt: s.lastBellAt,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.44",
3
+ "version": "0.11.46",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -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
+ });