hop2 0.11.43 → 0.11.45

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",
@@ -12,6 +12,7 @@ import {
12
12
  } from "react";
13
13
  import { Terminal } from "@xterm/xterm";
14
14
  import { FitAddon } from "@xterm/addon-fit";
15
+ import { WebLinksAddon } from "@xterm/addon-web-links";
15
16
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
16
17
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
17
18
  import { CwdField } from "./CwdField";
@@ -473,6 +474,16 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
473
474
  });
474
475
  const fit = new FitAddon();
475
476
  term.loadAddon(fit);
477
+ // Same as the full-screen terminal: URLs are links (line-wrapped OAuth
478
+ // and codex sign-in URLs included). The tile is where most reading
479
+ // happens now, and a URL you could click full-screen but not here read
480
+ // as "links don't work in hop". window.open inside the click handler
481
+ // keeps the popup-blocker happy; the tile's own click handler already
482
+ // stops the card from treating the click as a switch.
483
+ term.loadAddon(new WebLinksAddon((event, uri) => {
484
+ event.preventDefault();
485
+ window.open(uri, "_blank", "noopener");
486
+ }));
476
487
  term.open(box);
477
488
  termRef.current = term;
478
489
  fitRef.current = fit;
@@ -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
@@ -5248,6 +5248,89 @@ function readTurnCount(internalName) {
5248
5248
  // surfaces (dedupe + seen-marks already handle "one finish, one alert").
5249
5249
  // Baselined at arm time (notifyBaseTurn) so arming a long-lived session does
5250
5250
  // not fire a single notification for its entire back history.
5251
+ // The ASK record the Claude hook writes when the agent stops to ask the human
5252
+ // something (permission, a question, a plan to approve): {count, at, type,
5253
+ // message}. Always on — unlike a finished turn, a question is worth a buzz by
5254
+ // definition — its count rides bellSeq like a bell, and the message travels
5255
+ // beside it so the phone can say what is being asked.
5256
+ function readAsk(internalName) {
5257
+ try {
5258
+ const a = JSON.parse(fs.readFileSync(
5259
+ path.join(CLAUDE_SESSIONS_DIR, `${internalName}.ask`), 'utf8'));
5260
+ if (!a || !Number.isInteger(a.count) || a.count < 0) return null;
5261
+ const atMs = Date.parse(a.at);
5262
+ return {
5263
+ count: a.count,
5264
+ at: Number.isFinite(atMs) ? atMs : null,
5265
+ type: typeof a.type === 'string' ? a.type : '',
5266
+ message: typeof a.message === 'string' ? a.message : ''
5267
+ };
5268
+ } catch (e) { return null; }
5269
+ }
5270
+ // One read per session per second at most: /api/sessions maps every field
5271
+ // through askOf() and the list is polled by every client.
5272
+ const askMemo = new Map();
5273
+ function askOf(internalName) {
5274
+ const now = Date.now();
5275
+ const hit = askMemo.get(internalName);
5276
+ if (hit && now - hit.t < 1000) return hit.v;
5277
+ const v = readAsk(internalName);
5278
+ askMemo.set(internalName, { t: now, v });
5279
+ return v;
5280
+ }
5281
+ // When the agent's last turn ended, from the same .turn record (Claude's
5282
+ // Stop hook, Codex's notify chain). Used to attribute a bell: Codex rings
5283
+ // the terminal bell at the end of EVERY turn, and a bell that lands within
5284
+ // seconds of the turn record is "finished", not "needs you".
5285
+ function readTurnAt(internalName) {
5286
+ try {
5287
+ const at = Date.parse(JSON.parse(fs.readFileSync(
5288
+ path.join(CLAUDE_SESSIONS_DIR, `${internalName}.turn`), 'utf8')).at);
5289
+ return Number.isFinite(at) ? at : 0;
5290
+ } catch (e) { return 0; }
5291
+ }
5292
+ // The newest published view's time (ms) for a session, from the manifest
5293
+ // the views summary already reads. `hop view` rings one bell per publish.
5294
+ function latestViewAtMs(internalName) {
5295
+ try {
5296
+ const items = JSON.parse(fs.readFileSync(path.join(VIEWS_DIR, 'manifest.json'), 'utf8')).items || [];
5297
+ let best = 0, title = '';
5298
+ for (const it of items) {
5299
+ if (it.session !== internalName) continue;
5300
+ const ms = Number(it.mtime) < 1e11 ? Number(it.mtime) * 1000 : Number(it.mtime);
5301
+ if (ms > best) { best = ms; title = it.title || it.name || ''; }
5302
+ }
5303
+ return { at: best, title };
5304
+ } catch (e) { return { at: 0, title: '' }; }
5305
+ }
5306
+ // WHY a session wants attention — the thing Jian could not find when the UI
5307
+ // said a Codex session "needs your input" and the screen showed a finished
5308
+ // turn. Decided from timestamps hop already has, newest signal first:
5309
+ // ask the agent asked (hook record; also `hop notify`) — note = message
5310
+ // finished the bell landed with the turn record (Codex rings at turn end)
5311
+ // view the bell landed with a `hop view` publish — note = the title
5312
+ // bell a bell nothing explains
5313
+ function attentionReason(internalName, lastBellAt, lastActivityAt) {
5314
+ const ask = askOf(internalName);
5315
+ const now = Date.now();
5316
+ if (ask && ask.at && now - ask.at < 15 * 60 * 1000) {
5317
+ return { reason: 'ask', note: ask.message || '', askType: ask.type || '' };
5318
+ }
5319
+ const bell = Number(lastBellAt) || 0;
5320
+ if (bell > 0) {
5321
+ const turnAt = readTurnAt(internalName);
5322
+ if (turnAt && Math.abs(bell - turnAt) <= 4000) return { reason: 'finished', note: '' };
5323
+ const view = latestViewAtMs(internalName);
5324
+ if (view.at && Math.abs(bell - view.at) <= 4000) return { reason: 'view', note: view.title };
5325
+ // Still producing output: an agent that needs you is QUIET. Codex
5326
+ // rings mid-turn too (six bells in four minutes of "Working…" on
5327
+ // angler-codex) — those are pings, not questions.
5328
+ const active = Number(lastActivityAt) || 0;
5329
+ if (active && now - active < 15000) return { reason: 'working', note: '' };
5330
+ return { reason: 'bell', note: '' };
5331
+ }
5332
+ return { reason: '', note: '' };
5333
+ }
5251
5334
  function finishBump(internalName, config) {
5252
5335
  if (!config || config.notifyOnFinish !== true) return 0;
5253
5336
  const base = Number.isInteger(config.notifyBaseTurn) ? config.notifyBaseTurn : 0;
@@ -6529,7 +6612,12 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6529
6612
  // The same script is registered for two events: SessionStart (records the
6530
6613
  // conversation id for `hop restore`) and Stop (bumps a per-turn counter a driver
6531
6614
  // can wait on). See scripts/claude-session-hook.js.
6532
- const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop'];
6615
+ const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop', 'Notification', 'PreToolUse'];
6616
+ // Notification (permission prompts, elicitation) and PreToolUse (the two tools
6617
+ // that open a dialog and wait) bump the per-session ASK record — "whenever an
6618
+ // agent asks me a question, notify me" (Jian). PreToolUse gets a matcher, or
6619
+ // the script would run on every tool call for nothing.
6620
+ const CLAUDE_HOOK_MATCHERS = { PreToolUse: 'AskUserQuestion|ExitPlanMode' };
6533
6621
 
6534
6622
  function getClaudeSettingsPath() {
6535
6623
  const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
@@ -6649,7 +6737,7 @@ function installClaudeHook() {
6649
6737
  for (const ev of CLAUDE_HOOK_EVENTS) {
6650
6738
  if (!Array.isArray(data.hooks[ev])) data.hooks[ev] = [];
6651
6739
  if (!hookGroupsHaveScript(data.hooks[ev])) {
6652
- data.hooks[ev].push({ matcher: '', hooks: [{ type: 'command', command }] });
6740
+ data.hooks[ev].push({ matcher: CLAUDE_HOOK_MATCHERS[ev] || '', hooks: [{ type: 'command', command }] });
6653
6741
  }
6654
6742
  }
6655
6743
  try {
@@ -6693,6 +6781,65 @@ function removeClaudeHook() {
6693
6781
  // there (e.g. Codex Computer Use) via "--chain <orig...>". TOML is edited
6694
6782
  // line-by-line — `notify` is a top-level key, and we touch only that one line —
6695
6783
  // so the rest of the user's config is never reserialized (and mangled).
6784
+ // Codex hooks (Claude-compatible hooks.json at $CODEX_HOME/hooks.json): the
6785
+ // PermissionRequest event fires before Codex asks the human to approve a
6786
+ // command or network access — the same hop hook script records it as an ASK
6787
+ // so the phone buzzes with Codex's own one-line reason. Parity with Claude's
6788
+ // Notification/PreToolUse asks. Codex requires the user to TRUST a new hook
6789
+ // once, in its `/hooks` screen; until then it does not run.
6790
+ function getCodexHooksPath() {
6791
+ return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'hooks.json');
6792
+ }
6793
+ function codexHookGroupsHaveScript(groups) {
6794
+ return Array.isArray(groups) && groups.some((g) => Array.isArray(g?.hooks) && g.hooks.some(
6795
+ (h) => h && typeof h.command === 'string' && h.command.includes('claude-session-hook.js')
6796
+ ));
6797
+ }
6798
+ function readCodexHooks() {
6799
+ const p = getCodexHooksPath();
6800
+ try { return { path: p, data: JSON.parse(fs.readFileSync(p, 'utf8')) }; }
6801
+ catch (e) { return { path: p, data: { hooks: {} } }; }
6802
+ }
6803
+ function codexHookIsInstalled(data) {
6804
+ return codexHookGroupsHaveScript(data?.hooks?.PermissionRequest);
6805
+ }
6806
+ function installCodexHook() {
6807
+ const { path: p, data } = readCodexHooks();
6808
+ if (codexHookIsInstalled(data)) return { ok: true, already: true, path: p };
6809
+ if (!fs.existsSync(CLAUDE_HOOK_SCRIPT)) return { ok: false, error: `hook script missing: ${CLAUDE_HOOK_SCRIPT}` };
6810
+ data.hooks = data.hooks && typeof data.hooks === 'object' ? data.hooks : {};
6811
+ if (!Array.isArray(data.hooks.PermissionRequest)) data.hooks.PermissionRequest = [];
6812
+ data.hooks.PermissionRequest.push({
6813
+ matcher: '',
6814
+ hooks: [{ type: 'command', command: `${process.execPath} ${CLAUDE_HOOK_SCRIPT}`, timeout: 5,
6815
+ statusMessage: 'hop: noting the question for your phone' }]
6816
+ });
6817
+ if (!data.description) data.description = 'hop: notify the phone when Codex stops to ask (PermissionRequest)';
6818
+ try {
6819
+ fs.mkdirSync(path.dirname(p), { recursive: true });
6820
+ if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.hop-backup`);
6821
+ fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
6822
+ return { ok: true, already: false, path: p };
6823
+ } catch (e) { return { ok: false, error: e.message }; }
6824
+ }
6825
+ function removeCodexHook() {
6826
+ const { path: p, data } = readCodexHooks();
6827
+ if (!codexHookIsInstalled(data)) return { ok: true, already: true, path: p };
6828
+ const groups = data.hooks.PermissionRequest;
6829
+ for (const g of groups) {
6830
+ if (Array.isArray(g.hooks)) {
6831
+ g.hooks = g.hooks.filter((h) => !(h && typeof h.command === 'string' && h.command.includes('claude-session-hook.js')));
6832
+ }
6833
+ }
6834
+ data.hooks.PermissionRequest = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0);
6835
+ if (data.hooks.PermissionRequest.length === 0) delete data.hooks.PermissionRequest;
6836
+ try {
6837
+ if (fs.existsSync(p)) fs.copyFileSync(p, `${p}.hop-backup`);
6838
+ fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n');
6839
+ return { ok: true, already: false, path: p };
6840
+ } catch (e) { return { ok: false, error: e.message }; }
6841
+ }
6842
+
6696
6843
  const CODEX_NOTIFY_SCRIPT = path.join(__dirname, 'scripts', 'codex-notify-hook.js');
6697
6844
  function getCodexConfigPath() {
6698
6845
  return path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'config.toml');
@@ -7836,6 +7983,38 @@ setInterval(() => {
7836
7983
  }
7837
7984
  }, 60_000).unref();
7838
7985
 
7986
+ // The host's whole current screen for a room (RIS + serialized grid + modes),
7987
+ // with the output cursor it reflects. Seeds a daemon preview grid without the
7988
+ // mid-frame holes a raw tail carries. null: host unreachable, room gone, or
7989
+ // an old host without the endpoint — callers fall back to the tail.
7990
+ function fetchHayRoomSerializedScreen(internalName) {
7991
+ const host = hayHostState || readHayHostStateFile();
7992
+ if (!host || !Number.isInteger(host.port) || host.port > 0 === false) return Promise.resolve(null);
7993
+ return new Promise((resolve) => {
7994
+ let settled = false;
7995
+ const done = (v) => { if (!settled) { settled = true; resolve(v); } };
7996
+ const req = http.request({
7997
+ hostname: '127.0.0.1',
7998
+ port: host.port,
7999
+ path: `/rooms/${encodeURIComponent(internalName)}/serialized`,
8000
+ method: 'GET'
8001
+ }, (res) => {
8002
+ let data = '';
8003
+ res.on('data', (chunk) => { data += chunk; });
8004
+ res.on('end', () => {
8005
+ if ((res.statusCode || 0) !== 200) { done(null); return; }
8006
+ try {
8007
+ const parsed = JSON.parse(data || 'null');
8008
+ done(parsed && typeof parsed.data === 'string' && parsed.data ? parsed : null);
8009
+ } catch (e) { done(null); }
8010
+ });
8011
+ });
8012
+ req.on('error', () => done(null));
8013
+ req.setTimeout(6000, () => { req.destroy(); done(null); });
8014
+ req.end();
8015
+ });
8016
+ }
8017
+
7839
8018
  function fetchHayRoomOutputSince(internalName, since) {
7840
8019
  const host = hayHostState || readHayHostStateFile();
7841
8020
  if (!host || !Number.isInteger(host.port) || host.port <= 0) return Promise.resolve(null);
@@ -7988,7 +8167,7 @@ async function getRoomGridPreview(internalName) {
7988
8167
  const Term = loadHeadlessTerminal();
7989
8168
  if (!Term) return null;
7990
8169
  let grid = roomGrids.get(internalName);
7991
- const res = await fetchHayRoomOutputSince(internalName, grid ? grid.offset : undefined);
8170
+ let res = await fetchHayRoomOutputSince(internalName, grid ? grid.offset : undefined);
7992
8171
  if (!res) {
7993
8172
  // Host unreachable: an existing grid still holds the last true
7994
8173
  // screen — serve that rather than nothing.
@@ -8000,11 +8179,31 @@ async function getRoomGridPreview(internalName) {
8000
8179
  return null;
8001
8180
  }
8002
8181
  outputSinceProbe.supported = true;
8182
+ // A grid that must (re)start seeds from the host's WHOLE screen, then
8183
+ // continues with deltas from the cursor that screen reflects. Seeding
8184
+ // from a bounded raw tail cut at an arbitrary byte left a differential
8185
+ // TUI (Codex, Claude's inline UI) with holes it never re-sent: previews
8186
+ // stayed partially wrong until the app repainted in full. An old host
8187
+ // without an offset continues from the delta call's cursor instead —
8188
+ // a few bytes may be skipped once, still far better than the tail.
8189
+ const reseed = !grid || res.reset;
8190
+ let seed = null;
8191
+ if (reseed) {
8192
+ const snap = await fetchHayRoomSerializedScreen(internalName);
8193
+ if (snap) {
8194
+ const from = Number.isFinite(Number(snap.offset)) ? Number(snap.offset) : res.offset;
8195
+ const delta = await fetchHayRoomOutputSince(internalName, from);
8196
+ if (delta && !delta.notFound && !delta.reset) {
8197
+ seed = { data: snap.data, cols: Number(snap.cols) || res.cols, rows: Number(snap.rows) || res.rows };
8198
+ res = delta;
8199
+ }
8200
+ }
8201
+ }
8003
8202
  // Generous clamps: rendering NARROWER than the PTY re-wraps every long
8004
8203
  // 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) {
8204
+ const cols = Math.max(2, Math.min(1000, Math.floor(Number((seed || res).cols) || 80)));
8205
+ const rows = Math.max(2, Math.min(200, Math.floor(Number((seed || res).rows) || 24)));
8206
+ if (reseed) {
8008
8207
  disposeRoomGrid(internalName);
8009
8208
  let term;
8010
8209
  try {
@@ -8024,6 +8223,11 @@ async function getRoomGridPreview(internalName) {
8024
8223
  }
8025
8224
  grid = { term, serialize, offset: res.offset, cols, rows, lastUsedAt: Date.now() };
8026
8225
  roomGrids.set(internalName, grid);
8226
+ if (seed) {
8227
+ await new Promise((resolve) => {
8228
+ try { term.write(seed.data, () => resolve()); } catch (e) { resolve(); }
8229
+ });
8230
+ }
8027
8231
  if (roomGrids.size > ROOM_GRID_MAX) {
8028
8232
  let oldestKey = null;
8029
8233
  let oldestAt = Infinity;
@@ -11067,8 +11271,18 @@ happens until someone clicks it.
11067
11271
  // adds to bellSeq so the phone treats a finished response like a
11068
11272
  // bell (dedupe/seen-marks make it one alert per finish). Plain
11069
11273
  // sessions are unaffected — finishBump returns 0.
11070
- bellSeq: s.bellSeq + finishBump(s.internalName, getEffectiveSessionConfig(s.internalName)),
11071
- lastBellAt: s.lastBellAt,
11274
+ bellSeq: s.bellSeq + finishBump(s.internalName, getEffectiveSessionConfig(s.internalName))
11275
+ + (askOf(s.internalName)?.count || 0),
11276
+ // The bell's timestamp advances to the ask's, so clients that
11277
+ // key seen-markers on time (the web) see a question as new.
11278
+ lastBellAt: Math.max(Number(s.lastBellAt) || 0, askOf(s.internalName)?.at || 0) || s.lastBellAt,
11279
+ // Why it wants you (see attentionReason): ask / finished / view / bell.
11280
+ attentionReason: attentionReason(s.internalName, s.lastBellAt, s.lastActivityAt).reason,
11281
+ attentionNote: attentionReason(s.internalName, s.lastBellAt, s.lastActivityAt).note,
11282
+ askSeq: askOf(s.internalName)?.count || 0,
11283
+ askAt: askOf(s.internalName)?.at || null,
11284
+ askType: askOf(s.internalName)?.type || null,
11285
+ askMessage: askOf(s.internalName)?.message || null,
11072
11286
  shell: s.shell,
11073
11287
  env: s.env,
11074
11288
  cols: s.cols,
@@ -13225,6 +13439,9 @@ happens until someone clicks it.
13225
13439
  const rows = parseInt(wsUrl.searchParams.get('rows') || '24', 10);
13226
13440
  const source = wsUrl.searchParams.get('source') || '';
13227
13441
  const claimId = wsUrl.searchParams.get('claim') || null;
13442
+ // Connect-time size claim (hay ClientInfo.sizeClaim): the phone's
13443
+ // first frame at its own size, not a desktop peer's then a reflow.
13444
+ const sizeClaim = wsUrl.searchParams.get('sizeClaim') === 'attach' ? 'attach' : undefined;
13228
13445
  // Client-supplied tab identity; the room evicts a same-key
13229
13446
  // predecessor on attach (reconnect replaces its own ghost).
13230
13447
  const deviceKeyRaw = wsUrl.searchParams.get('device') || '';
@@ -13266,7 +13483,7 @@ happens until someone clicks it.
13266
13483
 
13267
13484
  // Attach client to hay room
13268
13485
  session.room.attachClient(
13269
- { id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows },
13486
+ { id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows, sizeClaim },
13270
13487
  hay.createSocketAdapter(ws)
13271
13488
  );
13272
13489
  });
@@ -15145,7 +15362,7 @@ async function attachToAllSessions(existingTunnel) {
15145
15362
  const KNOWN_COMMANDS = [
15146
15363
  'client', 'start', 'attach', 'local', 'stop', 'status', 'doctor', 'logs',
15147
15364
  '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',
15365
+ 'password', 'user', 'workspace', 'session', 'wipe', 'restore', 'update', 'resume-waiting', 'claude-hook', 'codex-notify', 'codex-hook', 'claude-skill', 'agent-skill', 'math', 'view', 'port', 'notify', 'whoami', 'new',
15149
15366
  'mail', 'registration', 'canonical'
15150
15367
  ];
15151
15368
 
@@ -16129,6 +16346,29 @@ async function main() {
16129
16346
  // Stop hook, so "notify me when it finishes" works for Codex sessions too.
16130
16347
  // Installs a WRAPPER into ~/.codex/config.toml that chains to any existing
16131
16348
  // notify (Computer Use), so nothing already there breaks.
16349
+ if (process.argv[2] === 'codex-hook') {
16350
+ const sub = process.argv[3] || 'status';
16351
+ if (sub === 'install') {
16352
+ const r = installCodexHook();
16353
+ if (!r.ok) { log(`Failed to install Codex hook: ${r.error}`, RED); process.exit(1); }
16354
+ log(r.already ? `Codex PermissionRequest hook already installed (${r.path}).`
16355
+ : `${GREEN}Installed${RESET} Codex PermissionRequest hook → ${r.path}`, RESET);
16356
+ log(`Codex runs a new hook only after you trust it once: in Codex, type ${CYAN}/hooks${RESET} and choose Trust.`, DIM);
16357
+ process.exit(0);
16358
+ }
16359
+ if (sub === 'remove') {
16360
+ const r = removeCodexHook();
16361
+ if (!r.ok) { log(`Failed to remove Codex hook: ${r.error}`, RED); process.exit(1); }
16362
+ log(r.already ? `Codex PermissionRequest hook was not installed.` : `${GREEN}Removed${RESET} Codex PermissionRequest hook from ${r.path}`, RESET);
16363
+ process.exit(0);
16364
+ }
16365
+ const { path: p, data } = readCodexHooks();
16366
+ log(codexHookIsInstalled(data)
16367
+ ? `${GREEN}✓${RESET} Codex PermissionRequest hook installed (${p}) — trusted in Codex's /hooks?`
16368
+ : `${YELLOW}!${RESET} Codex PermissionRequest hook not installed. Run ${CYAN}hop codex-hook install${RESET} to get a phone notification when Codex asks.`, RESET);
16369
+ process.exit(0);
16370
+ }
16371
+
16132
16372
  if (process.argv[2] === 'codex-notify') {
16133
16373
  const sub = process.argv[3] || 'status';
16134
16374
  if (sub === 'install') {
@@ -16406,6 +16646,19 @@ async function main() {
16406
16646
  log('Usage: hop notify <why you need the human>', YELLOW);
16407
16647
  process.exit(1);
16408
16648
  }
16649
+ // The message rides the ASK record too, so the phone can say WHAT is
16650
+ // needed instead of a bare "wants you" (see readAsk / attentionReason).
16651
+ const hs = process.env.HOP_SESSION;
16652
+ if (hs && /^[A-Za-z0-9_-]+$/.test(hs)) {
16653
+ try {
16654
+ const file = path.join(CLAUDE_SESSIONS_DIR, `${hs}.ask`);
16655
+ let count = 0;
16656
+ try { count = JSON.parse(fs.readFileSync(file, 'utf8')).count || 0; } catch (e) { /* first */ }
16657
+ fs.mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
16658
+ fs.writeFileSync(file, JSON.stringify({ sessionId: null, count: count + 1,
16659
+ at: new Date().toISOString(), type: 'notify', message: msg.slice(0, 300) }));
16660
+ } catch (e) { /* the bell still rings */ }
16661
+ }
16409
16662
  process.stdout.write(`\n\u2691 NEEDS YOU: ${msg}\n`);
16410
16663
  process.stdout.write('\x07');
16411
16664
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.43",
3
+ "version": "0.11.45",
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": {