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.
- package/hay/apps/web/src/App.tsx +24 -0
- package/hay/apps/web/src/components/SecondaryPane.tsx +10 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +14 -3
- package/hay/apps/web/src/styles.css +27 -11
- package/hay/apps/web/src/utils/dragSelect.ts +100 -0
- package/hay/apps/web/test/dragSelect.test.ts +87 -0
- package/hop +56 -10
- package/lib/agent-phase.js +12 -2
- package/lib/codex-identity.js +48 -0
- package/package.json +1 -1
- package/scripts/codex-notify-hook.js +34 -9
- package/tests/agent-phase.unit.test.cjs +18 -1
- package/tests/codex-identity.unit.test.cjs +32 -0
- package/tests/codex-notify-hook.unit.test.cjs +47 -0
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
-
|
|
2253
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
.switcher-card.phase-
|
|
3839
|
-
|
|
3840
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
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');
|
package/lib/agent-phase.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
77
|
-
if (!
|
|
78
|
-
const
|
|
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:
|
|
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
|
+
});
|