hop2 0.11.64 → 0.11.65

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.
@@ -22,6 +22,7 @@ import { getMathTip } from "./utils/mathTooltip";
22
22
  import { scanKeyboardProtocol } from "./utils/keyboardProtocol";
23
23
  import { originalPathHint, pasteableUploadPaths } from "./utils/fileDrop";
24
24
  import { claimOnAttach, claimOnClick, isPlainClick } from "./utils/sizeClaim";
25
+ import { installDragSelect } from "./utils/dragSelect";
25
26
  import { MobileKeyboard } from "./components/MobileKeyboard";
26
27
  import { SessionSwitcher } from "./components/SessionSwitcher";
27
28
  import { SecondaryPane } from "./components/SecondaryPane";
@@ -743,6 +744,14 @@ const App = () => {
743
744
  return screenLooksLikeClaude();
744
745
  };
745
746
  foregroundIsClaudeRef.current = foregroundIsClaude;
747
+ // Which agent the current session runs, by the daemon's hook records.
748
+ const currentSessionAgentRef = useRef<(() => "claude" | "codex" | null) | null>(null);
749
+ currentSessionAgentRef.current = () => {
750
+ const room = activeSessionRoomRef.current;
751
+ if (!room) return null;
752
+ const s = sessionsRef.current.find((x) => (x.internalName || x.name) === room);
753
+ return s?.agent || null;
754
+ };
746
755
  const drawerOpenRef = useRef(drawerOpen);
747
756
  drawerOpenRef.current = drawerOpen;
748
757
  const shortcutHelpRef = useRef(shortcutHelpOpen);
@@ -2098,6 +2107,9 @@ const App = () => {
2098
2107
  fontFamily: getComputedStyle(document.documentElement).getPropertyValue("--font-terminal").trim()
2099
2108
  || "Menlo, Monaco, 'Courier New', monospace",
2100
2109
  fontSize,
2110
+ // ⌥-drag always selects, iTerm's escape hatch for apps that track the
2111
+ // mouse. (Plain drag selects too in agent sessions — utils/dragSelect.)
2112
+ macOptionClickForcesSelection: true,
2101
2113
  lineHeight: 1.3,
2102
2114
  cursorBlink: true,
2103
2115
  scrollback: 50000,
@@ -2136,6 +2148,14 @@ const App = () => {
2136
2148
  // Unicode, the browser around the terminal draws properly.
2137
2149
  enableMathHover(terminal, getMathTip());
2138
2150
  terminal.open(containerRef.current);
2151
+ // Plain drag selects in Claude/Codex sessions even though they track the
2152
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
2153
+ // Hooked on the SCROLL wrapper, not xterm's host: a press in the gutter
2154
+ // lands on the wrapper (which still starts a selection when tracking is
2155
+ // off), and a capture listener only sees presses on its own subtree.
2156
+ (terminal as any).__dragSelectCleanup = installDragSelect(terminal as never,
2157
+ (containerRef.current.closest(".terminal-scroll") as HTMLElement | null) || containerRef.current, () =>
2158
+ foregroundIsClaudeRef.current?.() === true || currentSessionAgentRef.current?.() === "codex");
2139
2159
 
2140
2160
  // GPU-accelerated rendering (same renderer VS Code uses). Must load after
2141
2161
  // open(). If WebGL isn't available (old GPU, blocklisted driver) the
@@ -3032,6 +3052,10 @@ const App = () => {
3032
3052
  (terminal as any).__overlayScrollbarCleanup();
3033
3053
  (terminal as any).__overlayScrollbarCleanup = null;
3034
3054
  }
3055
+ if ((terminal as any).__dragSelectCleanup) {
3056
+ (terminal as any).__dragSelectCleanup();
3057
+ (terminal as any).__dragSelectCleanup = null;
3058
+ }
3035
3059
  terminal.dispose();
3036
3060
  termRef.current = null;
3037
3061
  fitRef.current = null;
@@ -1,4 +1,6 @@
1
1
  import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
2
+ import { installDragSelect } from "../utils/dragSelect";
3
+ import { bufferLooksLikeClaude } from "../utils/voiceHold";
2
4
  import { Terminal } from "@xterm/xterm";
3
5
  import { FitAddon } from "@xterm/addon-fit";
4
6
  import { safeParseServerMessage } from "hay-shared";
@@ -58,6 +60,8 @@ export const SecondaryPane = ({
58
60
  onSplit,
59
61
  onZoom
60
62
  }: Props) => {
63
+ const procLabelRef = useRef(procLabel);
64
+ procLabelRef.current = procLabel;
61
65
  const hostRef = useRef<HTMLDivElement>(null);
62
66
  const boxRef = useRef<HTMLDivElement>(null);
63
67
  const termRef = useRef<Terminal | null>(null);
@@ -77,6 +81,7 @@ export const SecondaryPane = ({
77
81
  cols: sizeRef.current.cols,
78
82
  rows: sizeRef.current.rows,
79
83
  fontSize,
84
+ macOptionClickForcesSelection: true,
80
85
  fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
81
86
  theme: theme as never,
82
87
  scrollback: 2000,
@@ -86,6 +91,10 @@ export const SecondaryPane = ({
86
91
  term.loadAddon(fitAddon);
87
92
  enableMathHover(term, getMathTip());
88
93
  term.open(hostRef.current);
94
+ // Plain drag selects in Claude/Codex panes even though they track the
95
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
96
+ const uninstallDragSelect = installDragSelect(term as never, hostRef.current, () =>
97
+ /^(claude|codex)$/i.test(procLabelRef.current || "") || bufferLooksLikeClaude(term as never));
89
98
  termRef.current = term;
90
99
 
91
100
  let shouldReconnect = true;
@@ -222,6 +231,7 @@ export const SecondaryPane = ({
222
231
  ro.disconnect();
223
232
  dataSub.dispose();
224
233
  try { wsRef.current?.close(); } catch { /* ignore */ }
234
+ uninstallDragSelect();
225
235
  term.dispose();
226
236
  termRef.current = null;
227
237
  };
@@ -17,6 +17,7 @@ import { enableMathHover } from "../utils/mathLinkProvider";
17
17
  import { getMathTip } from "../utils/mathTooltip";
18
18
  import { claimOnTileFocus, sizeIsForeign } from "../utils/sizeClaim";
19
19
  import { cardPhase, phaseLabel, phaseTitle } from "../utils/agentPhase";
20
+ import { installDragSelect } from "../utils/dragSelect";
20
21
  import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
21
22
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
22
23
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
@@ -356,7 +357,7 @@ const runningApp = (s: SwitcherSession) => {
356
357
  // responsiveness and whether keystrokes are accepted. The old design — an
357
358
  // HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
358
359
  const LIVETILE_POLL_MS = 5000;
359
- const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
360
+ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, codexApp = false, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
360
361
  /** The wall keeps a registry of live tiles' input senders (file drops). */
361
362
  onSender?: (send: ((data: string) => void) | null) => void;
362
363
  wsBase: string; room: string; userName: string; theme: object | undefined;
@@ -366,7 +367,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
366
367
  * pre-focusing the current session as it opened. Only a deliberate focus
367
368
  * takes the session's size — see claimOnTileFocus.
368
369
  */
369
- live: boolean; deliberate: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
370
+ live: boolean; deliberate: boolean; claudeApp: boolean; codexApp?: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
370
371
  onFullscreen: () => void; onUnfocus: () => void; onNotice: (m: string) => void;
371
372
  }) => {
372
373
  const boxRef = useRef<HTMLDivElement | null>(null);
@@ -427,6 +428,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
427
428
  // every session that came back through `hop restore`. The buffer's own
428
429
  // chrome is the honest witness; both callers share it.
429
430
  const looksLikeClaude = () => isClaudeSurface(claudeAppRef.current, termRef.current as never);
431
+ const codexAppRef = useRef(codexApp);
432
+ codexAppRef.current = codexApp;
430
433
  if (!voiceHoldRef.current) {
431
434
  voiceHoldRef.current = createVoiceHold({
432
435
  send: (data) => sendInputRef.current?.(data),
@@ -501,6 +504,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
501
504
  cols: activeCols && activeCols > 1 ? activeCols : 80,
502
505
  rows: activeRows && activeRows > 1 ? activeRows : 24,
503
506
  scrollback: 2000,
507
+ macOptionClickForcesSelection: true,
504
508
  // Same scroll feel as the full-screen terminal: one wheel notch moves
505
509
  // 4 lines (xterm's default of 1 is the "tiles feel sluggish" report),
506
510
  // Shift+wheel blasts. Momentum is attached below.
@@ -532,6 +536,9 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
532
536
  }));
533
537
  enableMathHover(term, getMathTip()); // rendered math on hover, here too
534
538
  term.open(box);
539
+ // Plain drag selects in Claude/Codex tiles even though they track the
540
+ // mouse; the wheel still reaches them. See utils/dragSelect.ts.
541
+ const uninstallDragSelect = installDragSelect(term as never, box, () => looksLikeClaude() || codexAppRef.current);
535
542
  termRef.current = term;
536
543
  fitRef.current = fit;
537
544
  const inner = box.querySelector(".xterm") as HTMLElement | null;
@@ -751,6 +758,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
751
758
  detachFlywheel();
752
759
  ro?.disconnect();
753
760
  sub.dispose();
761
+ uninstallDragSelect();
754
762
  term.dispose();
755
763
  termRef.current = null;
756
764
  fitRef.current = null;
@@ -2877,6 +2885,8 @@ export const SessionSwitcher = ({
2877
2885
  // signal, and two rings on one card were unreadable.
2878
2886
  const phase = cardPhase(s);
2879
2887
  const activeNow = !current && !phase && now - (s.lastActivityAt || 0) < 12000;
2888
+ // A question waiting on you is the card's colour too (amber), over the phase.
2889
+ const needsYou = s.attentionReason === "ask" || waitingOnUser(preview);
2880
2890
  // Freshness: recency the eye can read WITHOUT the row moving. A left-edge
2881
2891
  // bar whose intensity decays from full (just active) to nothing over
2882
2892
  // ~10 minutes — bright means "just now", faint means "a while ago",
@@ -2897,7 +2907,7 @@ export const SessionSwitcher = ({
2897
2907
  tabIndex={0}
2898
2908
  data-nav-index={navIndexByKey.get(key)}
2899
2909
  data-session-key={key}
2900
- className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${phase ? ` phase-${phase}` : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}${fileDropKey === key ? " file-drop" : ""}`}
2910
+ className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${phase ? ` phase-${phase}` : ""}${needsYou ? " needs-you" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}${fileDropKey === key ? " file-drop" : ""}`}
2901
2911
  draggable={draggable}
2902
2912
  onDragStart={draggable ? (e) => { setDragKey(key); e.dataTransfer.effectAllowed = "move"; } : undefined}
2903
2913
  onDragEnter={draggable ? () => { if (dragKey && dragKey !== key) moveManual(dragKey, key); } : undefined}
@@ -3028,6 +3038,7 @@ export const SessionSwitcher = ({
3028
3038
  live={focusedKey === key}
3029
3039
  deliberate={deliberateFocusRef.current === key}
3030
3040
  claudeApp={appLabel(s) === "claude"}
3041
+ codexApp={appLabel(s) === "codex"}
3031
3042
  claimSize={s.hasLocalCli !== true}
3032
3043
  activeCols={preview?.cols}
3033
3044
  activeRows={preview?.rows}
@@ -2249,8 +2249,18 @@ button.primary {
2249
2249
  .switcher-card.active-now .switcher-card-fresh { animation: none; }
2250
2250
  }
2251
2251
 
2252
- .switcher-card.current {
2253
- border-color: var(--accent);
2252
+ /* CURRENT and FOCUSED used to paint the whole frame purple, which made
2253
+ purple the loudest colour on the wall — and purple says nothing about
2254
+ the session. The frame and the wash now belong to STATUS (see the
2255
+ phase/needs-you rules below); current is a purple bar along the top
2256
+ edge plus its chip, focused a thin purple outline. */
2257
+ .switcher-card.current::before {
2258
+ content: "";
2259
+ position: absolute;
2260
+ top: 0; left: 0; right: 0;
2261
+ height: 3px;
2262
+ background: var(--accent);
2263
+ pointer-events: none;
2254
2264
  }
2255
2265
 
2256
2266
  /* The wall's order freezes while open — a session producing output RIGHT NOW
@@ -2971,7 +2981,6 @@ button.primary {
2971
2981
  .switcher-row.kbd-selected {
2972
2982
  outline: 2px solid var(--accent);
2973
2983
  outline-offset: -2px;
2974
- background: color-mix(in srgb, var(--accent) 7%, var(--panel));
2975
2984
  }
2976
2985
 
2977
2986
  /* AI tagline (hop ai tagline): what this session is working on, in words the
@@ -3185,7 +3194,8 @@ button.primary {
3185
3194
  }
3186
3195
 
3187
3196
  .switcher-card.focused {
3188
- border-color: var(--accent);
3197
+ outline: 1px solid var(--accent);
3198
+ outline-offset: -1px;
3189
3199
  }
3190
3200
 
3191
3201
  @media (min-width: 769px) {
@@ -3831,13 +3841,19 @@ body.pane-dragging { user-select: none; }
3831
3841
  @media (prefers-reduced-motion: reduce) {
3832
3842
  .switcher-chip.phase.working .phase-dot { animation: none; }
3833
3843
  }
3834
- .switcher-card.phase-working {
3835
- border-color: color-mix(in srgb, #3b82f6 65%, var(--line));
3836
- box-shadow: 0 0 0 1px color-mix(in srgb, #3b82f6 30%, transparent);
3837
- }
3838
- .switcher-card.phase-done-unread {
3839
- border-color: color-mix(in srgb, #22a06b 75%, var(--line));
3840
- box-shadow: 0 0 0 1px color-mix(in srgb, #22a06b 40%, transparent), 0 0 12px -2px color-mix(in srgb, #22a06b 45%, transparent);
3844
+ /* STATUS owns the card's colour: the frame, a wash over the whole card and
3845
+ a soft ring, all in one hue — blue working, green finished-and-unread,
3846
+ amber asking you (wins over the phase: a question is the more urgent
3847
+ fact). A read session goes quiet. Purple is reserved for "you are here". */
3848
+ .switcher-card.phase-working { --status: #3b82f6; }
3849
+ .switcher-card.phase-done-unread { --status: #22a06b; }
3850
+ .switcher-card.needs-you { --status: var(--warn); }
3851
+ .switcher-card.phase-working,
3852
+ .switcher-card.phase-done-unread,
3853
+ .switcher-card.needs-you {
3854
+ border-color: var(--status);
3855
+ background: color-mix(in srgb, var(--status) 9%, var(--panel));
3856
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--status) 35%, transparent), 0 0 14px -3px color-mix(in srgb, var(--status) 45%, transparent);
3841
3857
  }
3842
3858
  .switcher-card.phase-done-read {
3843
3859
  opacity: 0.88;
@@ -0,0 +1,100 @@
1
+ // Drag to select in an AGENT session, even while the app has mouse tracking on.
2
+ //
3
+ // Claude Code and Codex both enable mouse tracking (for the wheel), and
4
+ // xterm then hands every drag to the app instead of selecting. On a Mac
5
+ // xterm offers no rescue gesture by default: Shift is ignored there, and
6
+ // ⌥-drag needs an option nobody sets. So "I selected text and ⌘C copied
7
+ // nothing" was really "nothing was ever selected" — the drag went to Claude,
8
+ // which does nothing with it.
9
+ //
10
+ // Neither agent uses clicks or drags, only the wheel. So while the pointer
11
+ // is down for a plain left drag on an agent surface, tracking is switched
12
+ // off LOCALLY (xterm's protocol state is local anyway — the app is never
13
+ // told), the drag selects, and the protocol is put back on release. The
14
+ // wheel still reaches the app between drags. Other TUIs (vim, htop, tmux
15
+ // with the mouse on) keep their drags: the gate is the agent surface, not
16
+ // the tracking mode.
17
+
18
+ export type TrackingTerminal = {
19
+ modes: { mouseTrackingMode: string };
20
+ hasSelection: () => boolean;
21
+ onSelectionChange: (cb: () => void) => { dispose: () => void };
22
+ _core?: { coreMouseService?: { activeProtocol: string } };
23
+ };
24
+
25
+ /** A plain left press on an agent surface while the app tracks the mouse. */
26
+ export const shouldSelectLocally = (o: {
27
+ button: number;
28
+ metaKey: boolean;
29
+ ctrlKey: boolean;
30
+ trackingMode: string;
31
+ agentSurface: boolean;
32
+ }): boolean =>
33
+ o.button === 0 && !o.metaKey && !o.ctrlKey && o.trackingMode !== "none" && o.agentSurface;
34
+
35
+ /**
36
+ * Installs the capture-phase mousedown that pauses tracking for the drag.
37
+ * Tracking stays paused for as long as the selection it produced exists:
38
+ * xterm DISABLES its selection service when a protocol comes back on, and
39
+ * disabling clears the selection — restoring on release wiped the very
40
+ * text the drag had just selected (so ⌘C copied nothing). The selection
41
+ * clears on the next keypress or a click elsewhere, and tracking returns
42
+ * then. Returns the uninstaller. `agentSurface` is consulted at press time,
43
+ * so a session that stops being an agent session (shell after `exit`) gets
44
+ * its drags forwarded again.
45
+ */
46
+ export const installDragSelect = (
47
+ terminal: TrackingTerminal,
48
+ element: HTMLElement,
49
+ agentSurface: () => boolean
50
+ ): (() => void) => {
51
+ let paused: { prev: string; sub: { dispose: () => void } | null } | null = null;
52
+ const mouse = () => terminal._core?.coreMouseService;
53
+ const resume = () => {
54
+ if (!paused) return;
55
+ const { prev, sub } = paused;
56
+ paused = null;
57
+ sub?.dispose();
58
+ const m = mouse();
59
+ // Put it back only if nothing else changed it meanwhile (the app
60
+ // switching tracking off during the pause is its own decision).
61
+ try { if (m && m.activeProtocol === "NONE") m.activeProtocol = prev; } catch { /* gone */ }
62
+ };
63
+ const onUp = () => {
64
+ if (!paused) return;
65
+ let has = false;
66
+ try { has = terminal.hasSelection(); } catch { has = false; }
67
+ if (!has) { resume(); return; }
68
+ if (!paused.sub) {
69
+ paused.sub = terminal.onSelectionChange(() => {
70
+ let still = false;
71
+ try { still = terminal.hasSelection(); } catch { still = false; }
72
+ if (!still) resume();
73
+ });
74
+ }
75
+ };
76
+ const onDown = (ev: MouseEvent) => {
77
+ const m = mouse();
78
+ if (!m) return;
79
+ let trackingMode = "none";
80
+ try { trackingMode = terminal.modes.mouseTrackingMode; } catch { return; }
81
+ const local = shouldSelectLocally({ button: ev.button, metaKey: ev.metaKey, ctrlKey: ev.ctrlKey, trackingMode, agentSurface: agentSurface() });
82
+ if (paused) {
83
+ // Already paused for a standing selection: another selecting press
84
+ // just replaces it; any other press hands the mouse back first.
85
+ if (!local) resume();
86
+ return;
87
+ }
88
+ if (!local) return;
89
+ const prev = m.activeProtocol;
90
+ try { m.activeProtocol = "NONE"; } catch { return; }
91
+ paused = { prev, sub: null };
92
+ };
93
+ element.addEventListener("mousedown", onDown, true);
94
+ window.addEventListener("mouseup", onUp, true);
95
+ return () => {
96
+ element.removeEventListener("mousedown", onDown, true);
97
+ window.removeEventListener("mouseup", onUp, true);
98
+ resume();
99
+ };
100
+ };
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { installDragSelect, shouldSelectLocally, type TrackingTerminal } from "../src/utils/dragSelect";
3
+
4
+ const fakeTerminal = (mode: string, protocol: string) => {
5
+ const mouse = { activeProtocol: protocol };
6
+ const subs = new Set<() => void>();
7
+ const t = {
8
+ modes: { mouseTrackingMode: mode },
9
+ _core: { coreMouseService: mouse },
10
+ mouse,
11
+ selected: false,
12
+ hasSelection: () => t.selected,
13
+ onSelectionChange: (cb: () => void) => { subs.add(cb); return { dispose: () => subs.delete(cb) }; },
14
+ setSelected: (v: boolean) => { t.selected = v; for (const cb of subs) cb(); }
15
+ };
16
+ return t as TrackingTerminal & typeof t;
17
+ };
18
+
19
+ describe("shouldSelectLocally — a plain drag on an agent surface selects, tracking or not", () => {
20
+ const base = { button: 0, metaKey: false, ctrlKey: false, trackingMode: "drag", agentSurface: true };
21
+ it("yes for a plain left press while Claude/Codex track the mouse", () => {
22
+ expect(shouldSelectLocally(base)).toBe(true);
23
+ });
24
+ it("no when the app is not an agent (vim, htop, tmux keep their drags)", () => {
25
+ expect(shouldSelectLocally({ ...base, agentSurface: false })).toBe(false);
26
+ });
27
+ it("no when tracking is already off (xterm selects by itself), or for right/ctrl/cmd presses", () => {
28
+ expect(shouldSelectLocally({ ...base, trackingMode: "none" })).toBe(false);
29
+ expect(shouldSelectLocally({ ...base, button: 2 })).toBe(false);
30
+ expect(shouldSelectLocally({ ...base, ctrlKey: true })).toBe(false);
31
+ expect(shouldSelectLocally({ ...base, metaKey: true })).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe("installDragSelect — tracking pauses for the drag and stays paused while the selection stands", () => {
36
+ const press = (el: HTMLElement) => el.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true }));
37
+ const release = () => window.dispatchEvent(new MouseEvent("mouseup", { button: 0, bubbles: true }));
38
+ it("a drag that selected nothing hands the mouse back on release", () => {
39
+ const t = fakeTerminal("drag", "DRAG");
40
+ const el = document.createElement("div"); document.body.appendChild(el);
41
+ const off = installDragSelect(t, el, () => true);
42
+ press(el);
43
+ expect(t.mouse.activeProtocol).toBe("NONE");
44
+ release();
45
+ expect(t.mouse.activeProtocol).toBe("DRAG");
46
+ off();
47
+ });
48
+ it("a drag that selected text keeps tracking off until the selection clears — so ⌘C still has it", () => {
49
+ const t = fakeTerminal("drag", "DRAG");
50
+ const el = document.createElement("div"); document.body.appendChild(el);
51
+ const off = installDragSelect(t, el, () => true);
52
+ press(el); t.setSelected(true); release();
53
+ expect(t.mouse.activeProtocol).toBe("NONE");
54
+ // A second selecting press replaces the selection, still paused.
55
+ press(el); t.setSelected(true); release();
56
+ expect(t.mouse.activeProtocol).toBe("NONE");
57
+ // A keypress clears the selection (xterm does this): tracking returns.
58
+ t.setSelected(false);
59
+ expect(t.mouse.activeProtocol).toBe("DRAG");
60
+ off();
61
+ });
62
+ it("leaves a non-agent surface alone, and respects the app turning tracking off mid-drag", () => {
63
+ const t = fakeTerminal("drag", "DRAG");
64
+ const el = document.createElement("div"); document.body.appendChild(el);
65
+ let agent = false;
66
+ const off = installDragSelect(t, el, () => agent);
67
+ press(el);
68
+ expect(t.mouse.activeProtocol).toBe("DRAG");
69
+ release();
70
+ agent = true;
71
+ press(el);
72
+ expect(t.mouse.activeProtocol).toBe("NONE");
73
+ t.mouse.activeProtocol = "ANY"; // the app changed its mind during the drag
74
+ release();
75
+ expect(t.mouse.activeProtocol).toBe("ANY");
76
+ off();
77
+ });
78
+ it("a right or ctrl press while paused hands the mouse back first", () => {
79
+ const t = fakeTerminal("drag", "DRAG");
80
+ const el = document.createElement("div"); document.body.appendChild(el);
81
+ const off = installDragSelect(t, el, () => true);
82
+ press(el); t.setSelected(true); release();
83
+ el.dispatchEvent(new MouseEvent("mousedown", { button: 2, bubbles: true }));
84
+ expect(t.mouse.activeProtocol).toBe("DRAG");
85
+ off();
86
+ });
87
+ });
package/hop CHANGED
@@ -18,8 +18,9 @@ const WebSocket = require('ws');
18
18
  const { resolveActorFromHeaders, canActorAccessSession } = require('./lib/terminal-access');
19
19
  const { generateSessionName, getThemeChoices, isValidTheme, DEFAULT_THEME } = require('./lib/naming-themes');
20
20
  const { AuthSessionStore, isSessionToken: isAuthSessionToken } = require('./lib/auth-sessions');
21
- const { agentPhase } = require('./lib/agent-phase');
21
+ const { agentPhase, screenSaysWorking } = require('./lib/agent-phase');
22
22
  const { snapshotFromMetas, planRefiles, pruneList, snapshotFileName } = require('./lib/identity-snapshot');
23
+ const { withCodexIdentity } = require('./lib/codex-identity');
23
24
  const { rotateAuditFile, sweepStaleAuditDirs } = require('./lib/audit-rotation');
24
25
  const {
25
26
  createFailureTracker,
@@ -5370,7 +5371,7 @@ function readPromptAt(internalName) {
5370
5371
  }
5371
5372
  // The wall's three agent colours, decided here so the web and the phone
5372
5373
  // agree: working / done-unread / done-read. Plain shells get null.
5373
- function agentPhaseFields(s) {
5374
+ function agentPhaseFields(s, screenWorking = null) {
5374
5375
  if (!s.agent) return { agentPhase: null, turnSeen: true, turnAt: null };
5375
5376
  const turnAt = readTurnAt(s.internalName);
5376
5377
  const r = agentPhase({
@@ -5379,10 +5380,45 @@ function agentPhaseFields(s) {
5379
5380
  promptAt: readPromptAt(s.internalName),
5380
5381
  lastActivityAt: Number(s.lastActivityAt) || 0,
5381
5382
  lastUserSeenAt: Number(s.lastUserSeenAt) || 0,
5382
- userAttached: s.userAttached === true
5383
+ userAttached: s.userAttached === true,
5384
+ screenWorking
5383
5385
  });
5384
5386
  return { agentPhase: r.phase, turnSeen: r.turnSeen, turnAt: turnAt || null };
5385
5387
  }
5388
+ // The screen's own word on whether a turn is running ("esc to interrupt" in
5389
+ // both TUIs), read from the host's live grid. Asked only for the sessions
5390
+ // the timing rules call working — the small set that can be wrong that way
5391
+ // (Codex repaints while idle: rotating tips, the user typing) — and memoised
5392
+ // for a few seconds so every client's poll shares one read per session.
5393
+ const screenWorkingMemo = new Map(); // internalName -> { t, p: Promise<boolean|null> }
5394
+ function screenWorkingOf(internalName) {
5395
+ const now = Date.now();
5396
+ const hit = screenWorkingMemo.get(internalName);
5397
+ if (hit && now - hit.t < 4000) return hit.p;
5398
+ const p = (async () => {
5399
+ try {
5400
+ const hostRes = await callExternalHayHostApi(`/rooms/${encodeURIComponent(internalName)}/serialized`);
5401
+ const raw = hostRes?.data?.data;
5402
+ if (typeof raw !== 'string' || !raw) return null;
5403
+ return screenSaysWorking(stripAnsiForSearch(raw));
5404
+ } catch (e) { return null; }
5405
+ })();
5406
+ screenWorkingMemo.set(internalName, { t: now, p });
5407
+ if (screenWorkingMemo.size > 500) screenWorkingMemo.clear();
5408
+ return p;
5409
+ }
5410
+ // Phase for every entry of a list, the screen consulted where it matters.
5411
+ async function agentPhaseFieldsForAll(entries) {
5412
+ const verdicts = new Map();
5413
+ await Promise.all(entries.map(async (s) => {
5414
+ if (!s.agent) return;
5415
+ if (agentPhaseFields(s).agentPhase !== 'working') return;
5416
+ verdicts.set(s.internalName, await screenWorkingOf(s.internalName));
5417
+ }));
5418
+ const out = new Map();
5419
+ for (const s of entries) out.set(s.internalName, agentPhaseFields(s, verdicts.has(s.internalName) ? verdicts.get(s.internalName) : null));
5420
+ return out;
5421
+ }
5386
5422
  // The newest published view's time (ms) for a session, from the manifest
5387
5423
  // the views summary already reads. `hop view` rings one bell per publish.
5388
5424
  function latestViewAtMs(internalName) {
@@ -6424,7 +6460,8 @@ async function sweepRestoreReconcile() {
6424
6460
  if (!/[%$#\u276f>]\s*$/.test(last)) continue; // not an idle prompt yet
6425
6461
  try {
6426
6462
  const e2 = await attachTerminalApiClient(name, exp.displayName, { actor: 'system', label: 'reconcile' });
6427
- const cmd = exp.command.endsWith('\n') ? exp.command : `${exp.command}\n`;
6463
+ const pinned = withCodexIdentity(exp.command, name);
6464
+ const cmd = pinned.endsWith('\n') ? pinned : `${pinned}\n`;
6428
6465
  try { sendTerminalInput(e2, cmd); } finally { try { closeTerminalApiEntry(e2.id); } catch (e) {} }
6429
6466
  exp.redrives += 1;
6430
6467
  exp.lastDriveAt = now;
@@ -6782,7 +6819,11 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6782
6819
  // A fresh shell reports its own name once it has execed; that
6783
6820
  // is our prompt-ready signal, and it beats guessing with a timer.
6784
6821
  if (Date.now() - job.since < 400) continue;
6785
- const cmd = job.plan.command.endsWith('\n') ? job.plan.command : `${job.plan.command}\n`;
6822
+ // A codex launch carries its hop session id in the thread's own
6823
+ // config (lib/codex-identity.js): Codex's shared daemon would
6824
+ // otherwise hand every session the FIRST session's identity.
6825
+ const pinned = withCodexIdentity(job.plan.command, job.internalName);
6826
+ const cmd = pinned.endsWith('\n') ? pinned : `${pinned}\n`;
6786
6827
  try { sendTerminalInput(job.entry, cmd); } catch (e) { finish('write-failed'); continue; }
6787
6828
  job.attempts += 1;
6788
6829
  job.state = 'verify';
@@ -9574,7 +9615,7 @@ async function ensureExternalRuntimeSession(sessionName, cwd, displayName, opts
9574
9615
  // Launch the room ON the relaunch command instead of typing it after the
9575
9616
  // shell is up: no prompt to race, nothing half-typed, no command echo.
9576
9617
  if (typeof opts.command === 'string' && opts.command) {
9577
- roomBody.command = opts.command;
9618
+ roomBody.command = withCodexIdentity(opts.command, sessionName);
9578
9619
  }
9579
9620
  const response = await callExternalHayHostApi('/rooms', 'POST', roomBody, 2500);
9580
9621
  if (!response || response.status < 200 || response.status >= 300 || !response.data?.ok) {
@@ -11711,6 +11752,7 @@ happens until someone clicks it.
11711
11752
  // compares latestAt against its own last-seen mark. No new state,
11712
11753
  // no new poll, and the badge works the same everywhere.
11713
11754
  const viewsBySession = viewSummaryBySession();
11755
+ const phaseBySession = await agentPhaseFieldsForAll(visibleEntries);
11714
11756
  const sessions = visibleEntries.map(s => ({
11715
11757
  views: viewsBySession[s.internalName] || undefined,
11716
11758
  name: s.displayName,
@@ -11728,7 +11770,7 @@ happens until someone clicks it.
11728
11770
  agent: s.agent || null,
11729
11771
  // working / done + whether the human has opened it since the
11730
11772
  // turn ended (server-side seen witness, so every device agrees).
11731
- ...agentPhaseFields(s),
11773
+ ...(phaseBySession.get(s.internalName) || { agentPhase: null, turnSeen: true, turnAt: null }),
11732
11774
  lastActivityAt: s.lastActivityAt,
11733
11775
  // For sessions armed with notify-on-finish, each completed turn
11734
11776
  // adds to bellSeq so the phone treats a finished response like a
@@ -13108,7 +13150,8 @@ happens until someone clicks it.
13108
13150
  });
13109
13151
 
13110
13152
  if (startup && autoStart !== false) {
13111
- sendTerminalInput(entry, startup.endsWith('\n') ? startup : `${startup}\n`);
13153
+ const pinnedStartup = withCodexIdentity(startup, internalName);
13154
+ sendTerminalInput(entry, pinnedStartup.endsWith('\n') ? pinnedStartup : `${pinnedStartup}\n`);
13112
13155
  }
13113
13156
 
13114
13157
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -13306,7 +13349,7 @@ happens until someone clicks it.
13306
13349
  res.end(JSON.stringify({ error: 'Missing command' }));
13307
13350
  return;
13308
13351
  }
13309
- const line = String(command);
13352
+ const line = withCodexIdentity(String(command), entry.sessionName);
13310
13353
  sendTerminalInput(entry, line.endsWith('\n') ? line : `${line}\n`);
13311
13354
  res.writeHead(200, { 'Content-Type': 'application/json' });
13312
13355
  res.end(JSON.stringify({ ok: true }));
@@ -16651,7 +16694,7 @@ async function main() {
16651
16694
  log(`${dryRun ? 'Would recover' : 'Recovering'} ${BOLD}${display}${RESET} (${kind}) in this terminal:`, RESET);
16652
16695
  log(` ${DIM}${command}${RESET}`, RESET);
16653
16696
  if (dryRun) process.exit(0);
16654
- const child = spawn('/bin/zsh', ['-lc', command], { stdio: 'inherit', cwd: plan?.cwd || cwd, env: process.env });
16697
+ const child = spawn('/bin/zsh', ['-lc', withCodexIdentity(command, target)], { stdio: 'inherit', cwd: plan?.cwd || cwd, env: process.env });
16655
16698
  child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
16656
16699
  return;
16657
16700
  }
@@ -17480,6 +17523,9 @@ async function main() {
17480
17523
  } else {
17481
17524
  console.log(`View: ${proxyUrl}`);
17482
17525
  }
17526
+ // Name the session it was filed under, so a wrong one is visible at
17527
+ // once (an inherited HOP_SESSION can belong to another session).
17528
+ console.log(`Filed under ${getSessionDisplayName(session) || session}${wantSession ? '' : ' (from HOP_SESSION; pass --session <name> to file elsewhere)'}.`);
17483
17529
  // Same one-bell convention as `hop view` — the human is usually
17484
17530
  // waiting on this, and the link scrolls away unannounced otherwise.
17485
17531
  process.stdout.write('\x07');
@@ -30,6 +30,10 @@ const TURN_TAIL_MS = 1500;
30
30
  * @param {number} [o.lastActivityAt] ms, last PTY output
31
31
  * @param {number} [o.lastUserSeenAt] ms, when a user client last left (or attached)
32
32
  * @param {boolean} [o.userAttached] a user client is on it right now
33
+ * @param {boolean|null} [o.screenWorking] what the LIVE screen says, when it was read:
34
+ * both TUIs print "esc to interrupt" only while a turn runs. Wins over
35
+ * the timing heuristics — Codex repaints while idle (rotating tips, the
36
+ * user typing), which looked like work from the timestamps alone.
33
37
  * @param {number} [o.now]
34
38
  * @returns {{ phase: 'working'|'done'|null, turnSeen: boolean }}
35
39
  */
@@ -40,7 +44,9 @@ function agentPhase(o) {
40
44
  const promptAt = Number(o.promptAt) || 0;
41
45
  const active = Number(o.lastActivityAt) || 0;
42
46
  let working;
43
- if (promptAt > turnAt) {
47
+ if (o.screenWorking === true || o.screenWorking === false) {
48
+ working = o.screenWorking;
49
+ } else if (promptAt > turnAt) {
44
50
  // Claude, with the prompt hook: a prompt newer than the last turn end
45
51
  // is a turn in flight — unless the screen has been dead quiet for so
46
52
  // long that it was interrupted and never finished.
@@ -54,4 +60,8 @@ function agentPhase(o) {
54
60
  return { phase: 'done', turnSeen: seenAt >= turnAt };
55
61
  }
56
62
 
57
- module.exports = { agentPhase, WORKING_RECENT_MS, PROMPT_STALE_MS, TURN_TAIL_MS };
63
+ /** The screen's own word: a turn in flight prints this in both TUIs. */
64
+ const WORKING_SCREEN_RE = /esc to interrupt/i;
65
+ const screenSaysWorking = (text) => WORKING_SCREEN_RE.test(String(text || ''));
66
+
67
+ module.exports = { agentPhase, screenSaysWorking, WORKING_SCREEN_RE, WORKING_RECENT_MS, PROMPT_STALE_MS, TURN_TAIL_MS };
@@ -0,0 +1,48 @@
1
+ 'use strict';
2
+ // Codex 0.159 runs EVERY session's shell commands and MCP servers through one
3
+ // shared app-server daemon, spawned by whichever codex started first. The
4
+ // daemon inherits that first session's environment, so HOP_SESSION — hop's
5
+ // "which session am I in" — named the first session for all of them: the
6
+ // surf session's `hop view` filed its result under Accessibility-fork-codex,
7
+ // and the hop MCP server every codex talks to answered with the wrong name.
8
+ //
9
+ // Identity must travel with the THREAD, not the process tree. Codex applies
10
+ // its shell_environment_policy per thread when it spawns a command, and the
11
+ // per-thread MCP server config likewise, and both accept per-launch `-c`
12
+ // overrides. So every codex hop launches carries its own session id in
13
+ // both — verified: with HOP_SESSION=wrong in the environment, a command run
14
+ // by codex saw the override.
15
+ //
16
+ // And it runs WITHOUT the shared daemon (`--no-daemon`): hop is already the
17
+ // multiplexer, and a second one inside Codex is what mixed the sessions up —
18
+ // its notify program (the turn-complete hook behind "finished" and restore)
19
+ // also ran with the daemon's environment, so every session's turns were
20
+ // counted against the first one. Verified: without the daemon the MCP
21
+ // servers are children of the session's own codex, carrying its id.
22
+
23
+ const FLAG_MARK = 'shell_environment_policy.set.HOP_SESSION';
24
+
25
+ /** The per-launch overrides that pin a codex thread to a hop session. */
26
+ function codexIdentityFlags(internalName) {
27
+ const id = String(internalName || '').replace(/[^A-Za-z0-9_-]/g, '');
28
+ if (!id) return '';
29
+ return `--no-daemon -c '${FLAG_MARK}="${id}"' -c 'mcp_servers.hop.env.HOP_SESSION="${id}"'`;
30
+ }
31
+
32
+ /**
33
+ * `codex …` (optionally behind `command ` / `exec `) becomes
34
+ * `codex <identity flags> …`. Anything else — a shell, claude, an already
35
+ * pinned codex line — is returned unchanged.
36
+ */
37
+ function withCodexIdentity(command, internalName) {
38
+ const raw = typeof command === 'string' ? command : '';
39
+ const flags = codexIdentityFlags(internalName);
40
+ if (!flags) return raw;
41
+ if (raw.includes(FLAG_MARK)) return raw;
42
+ const m = /^(\s*(?:(?:command|exec)\s+)?)codex(?=\s|$)/.exec(raw);
43
+ if (!m) return raw;
44
+ const rest = raw.slice(m[0].length);
45
+ return `${m[1]}codex ${flags}${rest}`;
46
+ }
47
+
48
+ module.exports = { withCodexIdentity, codexIdentityFlags, FLAG_MARK };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.64",
3
+ "version": "0.11.65",
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": {
@@ -24,11 +24,33 @@ const os = require("node:os");
24
24
  const path = require("node:path");
25
25
  const { spawn } = require("node:child_process");
26
26
 
27
- function turnFilePath() {
27
+ function turnsDir() {
28
28
  const home = process.env.HOP_HOME || path.join(os.homedir(), ".hop2");
29
29
  const dir = path.join(home, "claude-sessions"); // shared with the claude hook; hop reads <internal>.turn here
30
30
  try { fs.mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
31
- return path.join(dir, `${process.env.HOP_SESSION}.turn`);
31
+ return dir;
32
+ }
33
+
34
+ // WHICH hop session finished a turn. The environment says HOP_SESSION, but
35
+ // under Codex's shared app-server daemon every session's notify ran with
36
+ // the FIRST session's environment, so surf's turns were counted against
37
+ // Accessibility-fork-codex (233 of them) and its own record sat at 1. The
38
+ // thread is the truth: if another session's turn record already names this
39
+ // root thread, the turn is that session's. (hop launches codex without the
40
+ // daemon now; this keeps the sessions that predate the change honest.)
41
+ function resolveHopSession(dir, rootId, envSession) {
42
+ if (!rootId) return envSession;
43
+ let names = [];
44
+ try { names = fs.readdirSync(dir).filter((f) => f.endsWith(".turn")); } catch { return envSession; }
45
+ const owners = [];
46
+ for (const f of names) {
47
+ try {
48
+ const rec = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
49
+ if (rec && rec.agent === "codex" && rec.sessionId === rootId) owners.push(f.slice(0, -".turn".length));
50
+ } catch { /* unreadable record */ }
51
+ }
52
+ if (owners.includes(envSession)) return envSession;
53
+ return owners.length === 1 ? owners[0] : envSession;
32
54
  }
33
55
 
34
56
  // A sub-agent's rollout names its parent in its first line (session_meta →
@@ -73,23 +95,26 @@ function rootThreadId(id) {
73
95
  // Same crash-safe write + same {sessionId, count, at} shape the Claude hook
74
96
  // uses, so hop's finishBump() reads one number regardless of which agent wrote.
75
97
  function bumpTurn(payload) {
76
- const hopSession = process.env.HOP_SESSION;
77
- if (!hopSession || !/^[A-Za-z0-9_-]+$/.test(hopSession)) return; // not a hop session
78
- const file = turnFilePath();
98
+ const envSession = process.env.HOP_SESSION;
99
+ if (!envSession || !/^[A-Za-z0-9_-]+$/.test(envSession)) return; // not a hop session
100
+ const dir = turnsDir();
101
+ const rawThread = (payload && typeof payload["thread-id"] === "string") ? payload["thread-id"]
102
+ : (payload && typeof payload.thread_id === "string") ? payload.thread_id
103
+ : (payload && typeof payload.session_id === "string") ? payload.session_id : null;
104
+ const rootId = rootThreadId(rawThread);
105
+ const hopSession = resolveHopSession(dir, rootId, envSession);
106
+ const file = path.join(dir, `${hopSession}.turn`);
79
107
  let count = 0;
80
108
  try {
81
109
  const prev = JSON.parse(fs.readFileSync(file, "utf8"));
82
110
  if (prev && Number.isInteger(prev.count) && prev.count >= 0) count = prev.count;
83
111
  } catch { /* missing/corrupt -> start at 0 */ }
84
- const threadId = (payload && typeof payload["thread-id"] === "string") ? payload["thread-id"]
85
- : (payload && typeof payload.thread_id === "string") ? payload.thread_id
86
- : (payload && typeof payload.session_id === "string") ? payload.session_id : null;
87
112
  const rec = JSON.stringify({
88
113
  // The thread id is the conversation (and the rollout file's name); the
89
114
  // turn id names one turn and matches nothing on disk. With multi-agent
90
115
  // codex the thread that finished may be a SUB-agent, which cannot be
91
116
  // resumed on its own — record the root, which can.
92
- sessionId: rootThreadId(threadId),
117
+ sessionId: rootId,
93
118
  count: count + 1,
94
119
  at: new Date().toISOString(),
95
120
  agent: "codex"
@@ -2,7 +2,7 @@
2
2
  // from the records hop keeps, the same way for the web and the phone.
3
3
  const test = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
- const { agentPhase } = require('../lib/agent-phase');
5
+ const { agentPhase, screenSaysWorking } = require('../lib/agent-phase');
6
6
 
7
7
  const NOW = 1_800_000_000_000;
8
8
  const s = (o) => agentPhase({ agent: 'claude', now: NOW, ...o });
@@ -45,3 +45,20 @@ test('an agent session that has never completed a turn and is quiet has no phase
45
45
  assert.equal(s({ turnAt: 0, lastActivityAt: NOW - 60_000 }).phase, null);
46
46
  assert.equal(s({ turnAt: 0, lastActivityAt: NOW - 1_000 }).phase, 'working');
47
47
  });
48
+
49
+ test('the live screen decides when it was read: an idle composer is DONE however recent the repaint', () => {
50
+ // Codex idle at "› Ask Codex to do anything", tips rotating, user typing: fresh output, no turn.
51
+ const r = agentPhase({ agent: 'codex', turnAt: NOW - 60_000, lastActivityAt: NOW - 1_000, lastUserSeenAt: NOW, screenWorking: false, now: NOW });
52
+ assert.equal(r.phase, 'done');
53
+ // A silent long think still shows "esc to interrupt": working, whatever the timestamps say.
54
+ assert.equal(s({ turnAt: NOW - 60_000, lastActivityAt: NOW - 120_000, screenWorking: true }).phase, 'working');
55
+ // No screen read (null): the timing rules apply as before.
56
+ assert.equal(agentPhase({ agent: 'codex', turnAt: NOW - 60_000, lastActivityAt: NOW - 1_000, screenWorking: null, now: NOW }).phase, 'working');
57
+ });
58
+
59
+ test('screenSaysWorking reads both TUIs\' in-flight line', () => {
60
+ assert.equal(screenSaysWorking('⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← 1 agent'), true);
61
+ assert.equal(screenSaysWorking('• Working (12s • esc to interrupt)'), true);
62
+ assert.equal(screenSaysWorking('› Ask Codex to do anything\n ? for shortcuts'), false);
63
+ assert.equal(screenSaysWorking(''), false);
64
+ });
@@ -0,0 +1,32 @@
1
+ // Every codex hop launches carries its hop session id in the thread's own
2
+ // config, so commands run through Codex's shared daemon still know which
3
+ // session they belong to.
4
+ const test = require('node:test');
5
+ const assert = require('node:assert/strict');
6
+ const { withCodexIdentity, codexIdentityFlags } = require('../lib/codex-identity');
7
+
8
+ test('a bare codex launch gets both overrides', () => {
9
+ const out = withCodexIdentity('codex', 's_82da12887f');
10
+ assert.equal(out, `codex --no-daemon -c 'shell_environment_policy.set.HOP_SESSION="s_82da12887f"' -c 'mcp_servers.hop.env.HOP_SESSION="s_82da12887f"'`);
11
+ });
12
+
13
+ test('a resume keeps its arguments after the flags; command/exec prefixes survive', () => {
14
+ assert.equal(withCodexIdentity("codex resume '01a0e0dc-314d'", 'surf'),
15
+ `codex ${codexIdentityFlags('surf')} resume '01a0e0dc-314d'`);
16
+ assert.equal(withCodexIdentity('command codex --full-auto -c check_for_update_on_startup=false', 's_1'),
17
+ `command codex ${codexIdentityFlags('s_1')} --full-auto -c check_for_update_on_startup=false`);
18
+ });
19
+
20
+ test('anything else is untouched: shells, claude, a codex somewhere later in the line, an already pinned launch', () => {
21
+ assert.equal(withCodexIdentity('claude --resume abc', 's_1'), 'claude --resume abc');
22
+ assert.equal(withCodexIdentity('cd x && codex', 's_1'), 'cd x && codex');
23
+ assert.equal(withCodexIdentity('codexify --help', 's_1'), 'codexify --help');
24
+ const pinned = withCodexIdentity('codex', 's_1');
25
+ assert.equal(withCodexIdentity(pinned, 's_2'), pinned);
26
+ assert.equal(withCodexIdentity('', 's_1'), '');
27
+ assert.equal(withCodexIdentity('codex', ''), 'codex');
28
+ });
29
+
30
+ test('the id is sanitised — it lands inside a quoted config value', () => {
31
+ assert.equal(codexIdentityFlags('bad"id;rm -rf'), `--no-daemon -c 'shell_environment_policy.set.HOP_SESSION="badidrm-rf"' -c 'mcp_servers.hop.env.HOP_SESSION="badidrm-rf"'`);
32
+ });
@@ -0,0 +1,47 @@
1
+ // The Codex notify wrapper: which session a finished turn is counted against.
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { execFileSync } = require('node:child_process');
8
+
9
+ const HOOK = path.join(__dirname, '..', 'scripts', 'codex-notify-hook.js');
10
+ const freshHome = () => {
11
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'hop-codex-hook-'));
12
+ fs.mkdirSync(path.join(home, 'claude-sessions'), { recursive: true });
13
+ return home;
14
+ };
15
+ const run = (home, session, payload) => execFileSync(process.execPath, [HOOK, JSON.stringify(payload)], {
16
+ env: { ...process.env, HOP_HOME: home, HOP_SESSION: session }
17
+ });
18
+ const turn = (home, name) => { try { return JSON.parse(fs.readFileSync(path.join(home, 'claude-sessions', `${name}.turn`), 'utf8')); } catch { return null; } };
19
+
20
+ test('a turn is counted against HOP_SESSION when no other session claims the thread', () => {
21
+ const home = freshHome();
22
+ run(home, 'alpha', { type: 'agent-turn-complete', 'thread-id': 'thread-A', 'turn-id': 't1' });
23
+ assert.equal(turn(home, 'alpha').count, 1);
24
+ assert.equal(turn(home, 'alpha').sessionId, 'thread-A');
25
+ assert.equal(turn(home, 'alpha').agent, 'codex');
26
+ });
27
+
28
+ test("under a shared daemon the environment lies: the session whose record already names the thread gets the turn", () => {
29
+ const home = freshHome();
30
+ fs.writeFileSync(path.join(home, 'claude-sessions', 'surf.turn'), JSON.stringify({ sessionId: 'thread-S', count: 4, at: '2026-09-28T22:46:53.450Z', agent: 'codex' }));
31
+ fs.writeFileSync(path.join(home, 'claude-sessions', 'owner.turn'), JSON.stringify({ sessionId: 'thread-O', count: 9, at: '2026-09-29T20:00:00.000Z', agent: 'codex' }));
32
+ // The daemon runs notify with HOP_SESSION=owner, for surf's thread.
33
+ run(home, 'owner', { type: 'agent-turn-complete', 'thread-id': 'thread-S', 'turn-id': 't2' });
34
+ assert.equal(turn(home, 'surf').count, 5, 'surf got its turn');
35
+ assert.equal(turn(home, 'surf').sessionId, 'thread-S');
36
+ assert.equal(turn(home, 'owner').count, 9, 'the owner was not credited with it');
37
+ assert.equal(turn(home, 'owner').sessionId, 'thread-O', "and the owner's own thread id was not overwritten");
38
+ // The owner's own turn still lands on the owner.
39
+ run(home, 'owner', { type: 'agent-turn-complete', 'thread-id': 'thread-O', 'turn-id': 't3' });
40
+ assert.equal(turn(home, 'owner').count, 10);
41
+ });
42
+
43
+ test('other event types count nothing', () => {
44
+ const home = freshHome();
45
+ run(home, 'alpha', { type: 'something-else', 'thread-id': 'thread-A' });
46
+ assert.equal(turn(home, 'alpha'), null);
47
+ });