hop2 0.11.63 → 0.11.64

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.
@@ -261,6 +261,10 @@ type SessionInfo = {
261
261
  createdBy?: "user" | "agent";
262
262
  createdVia?: string | null;
263
263
  agent?: "claude" | "codex" | null;
264
+ // Server-decided: a turn in flight, or the last one finished — and whether
265
+ // a human has opened the session since it finished (see lib/agent-phase.js).
266
+ agentPhase?: "working" | "done" | null;
267
+ turnSeen?: boolean;
264
268
  attentionReason?: string;
265
269
  attentionNote?: string;
266
270
  cols?: number;
@@ -3205,6 +3209,8 @@ const App = () => {
3205
3209
  createdBy: s.createdBy === "agent" ? "agent" : "user",
3206
3210
  createdVia: typeof s.createdVia === "string" ? s.createdVia : null,
3207
3211
  agent: s.agent === "claude" || s.agent === "codex" ? s.agent : null,
3212
+ agentPhase: s.agentPhase === "working" || s.agentPhase === "done" ? s.agentPhase : null,
3213
+ turnSeen: s.turnSeen !== false,
3208
3214
  cols: Number.isInteger(s.cols) ? s.cols : undefined,
3209
3215
  rows: Number.isInteger(s.rows) ? s.rows : undefined,
3210
3216
  // These three were silently dropped here, so the switcher never saw
@@ -3966,7 +3972,7 @@ const App = () => {
3966
3972
  grab();
3967
3973
  setViewsOpen((v) => v ? null
3968
3974
  : (activeSessionRoomRef.current
3969
- ? { session: activeSessionRoomRef.current, dock: true }
3975
+ ? { session: activeSessionRoomRef.current }
3970
3976
  : {}));
3971
3977
  return;
3972
3978
  }
@@ -4160,7 +4166,7 @@ const App = () => {
4160
4166
  // DOCKED and scoped: in full-screen mode you almost always want
4161
4167
  // THIS session's results, read beside the live terminal rather
4162
4168
  // than over it. The panel's own toggle widens to the fleet.
4163
- onClick={() => setViewsOpen({ session: session.room, dock: true })}
4169
+ onClick={() => setViewsOpen({ session: session.room })}
4164
4170
  >
4165
4171
  Views
4166
4172
  {(() => {
@@ -4214,7 +4220,7 @@ const App = () => {
4214
4220
  onRefresh={() => fetchSessions({ showLoading: false })}
4215
4221
  onNotice={showToast}
4216
4222
  tileWsBase={resolveWsUrl()}
4217
- onOpenViews={(scope) => setViewsOpen({ session: scope, dock: true })}
4223
+ onOpenViews={(scope) => setViewsOpen({ session: scope })}
4218
4224
  userName={name}
4219
4225
  terminalTheme={resolveTerminalTheme(themeMode)}
4220
4226
  />
@@ -4878,7 +4884,7 @@ const App = () => {
4878
4884
  tileWsBase={resolveWsUrl()}
4879
4885
  onFocusSession={focusSessionInPlace}
4880
4886
  folders={folders}
4881
- onOpenViews={(scope) => setViewsOpen({ session: scope, dock: true })}
4887
+ onOpenViews={(scope) => setViewsOpen({ session: scope })}
4882
4888
  userName={name}
4883
4889
  terminalTheme={resolveTerminalTheme(themeMode)}
4884
4890
  onOpenSettings={() => {
@@ -15,6 +15,8 @@ import { FitAddon } from "@xterm/addon-fit";
15
15
  import { WebLinksAddon } from "@xterm/addon-web-links";
16
16
  import { enableMathHover } from "../utils/mathLinkProvider";
17
17
  import { getMathTip } from "../utils/mathTooltip";
18
+ import { claimOnTileFocus, sizeIsForeign } from "../utils/sizeClaim";
19
+ import { cardPhase, phaseLabel, phaseTitle } from "../utils/agentPhase";
18
20
  import { urlAtCell, cellAtPoint } from "../utils/urlAtCell";
19
21
  import { attachScrollFlywheel } from "../utils/scrollFlywheel";
20
22
  import { ContextMenu, type MenuRequest } from "./ContextMenu";
@@ -354,11 +356,17 @@ const runningApp = (s: SwitcherSession) => {
354
356
  // responsiveness and whether keystrokes are accepted. The old design — an
355
357
  // HTML preview swapped for a fresh xterm + socket behind a veil — is gone.
356
358
  const LIVETILE_POLL_MS = 5000;
357
- const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
359
+ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp, claimSize, activeCols, activeRows, onFullscreen, onUnfocus, onNotice, onSender }: {
358
360
  /** The wall keeps a registry of live tiles' input senders (file drops). */
359
361
  onSender?: (send: ((data: string) => void) | null) => void;
360
362
  wsBase: string; room: string; userName: string; theme: object | undefined;
361
- live: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
363
+ /**
364
+ * `live` says the tile is the focused one; `deliberate` says the human
365
+ * put it there (click, Enter, start, create) rather than the wall
366
+ * pre-focusing the current session as it opened. Only a deliberate focus
367
+ * takes the session's size — see claimOnTileFocus.
368
+ */
369
+ live: boolean; deliberate: boolean; claudeApp: boolean; claimSize: boolean; activeCols?: number; activeRows?: number;
362
370
  onFullscreen: () => void; onUnfocus: () => void; onNotice: (m: string) => void;
363
371
  }) => {
364
372
  const boxRef = useRef<HTMLDivElement | null>(null);
@@ -367,6 +375,8 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
367
375
  const rescaleRef = useRef<() => void>(() => {});
368
376
  // Wired by the live effect; watch mode leaves them null so keys go nowhere.
369
377
  const sendInputRef = useRef<((data: string) => void) | null>(null);
378
+ const deliberateRef = useRef(deliberate);
379
+ deliberateRef.current = deliberate;
370
380
  // When the link addon last opened something from this tile — so the click
371
381
  // that follows its mouseup does not open the same URL a second time.
372
382
  const lastLinkOpenAtRef = useRef(0);
@@ -782,13 +792,9 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
782
792
  // for sizes that are genuinely foreign (full-screen leftovers), not for
783
793
  // disagreements about rounding.
784
794
  const cur = termRef.current;
785
- if (cur) {
786
- const closeCols = Math.abs(cur.cols - dims.cols) <= Math.max(3, Math.round(dims.cols * 0.15));
787
- const closeRows = Math.abs(cur.rows - dims.rows) <= Math.max(2, Math.round(dims.rows * 0.15));
788
- if (closeCols && closeRows) {
789
- lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
790
- return;
791
- }
795
+ if (cur && !sizeIsForeign({ cols: cur.cols, rows: cur.rows }, { cols: dims.cols, rows: dims.rows })) {
796
+ lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
797
+ return;
792
798
  }
793
799
  lastClaimRef.current = { cols: dims.cols, rows: dims.rows, at: performance.now() };
794
800
  const sep = wsBase.includes("?") ? "&" : "?";
@@ -931,6 +937,31 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
931
937
  let reconnectTimer = 0;
932
938
  let reconnectAttempt = 0;
933
939
  const pendingInput: Array<{ data: string; at: number }> = [];
940
+ // Engaging a tile is an act on it, the same as opening the session full
941
+ // screen, and takes the size the same way — through THIS socket, as an
942
+ // attach claim, so ownership moves here and the election's active_size
943
+ // confirms the fit (the handler below resizes the tile to it). Until
944
+ // now a clicked tile only ever attached at the session's current size
945
+ // and fitted itself on the first keystroke; a session wearing a
946
+ // full-screen size stayed a wall of tiny text when clicked ("clicking
947
+ // the tile does not enter the terminal, but full screen does"). Once
948
+ // per focus: a reconnect is the same socket healing, not a new act.
949
+ let focusClaimDone = false;
950
+ const claimFitOnFocus = (sock: WebSocket) => {
951
+ if (focusClaimDone) return;
952
+ focusClaimDone = true;
953
+ const fitAddon = fitRef.current;
954
+ const boxEl = boxRef.current;
955
+ const t = termRef.current;
956
+ if (!fitAddon || !boxEl || !t || boxEl.clientWidth < 60 || boxEl.clientHeight < 40) return;
957
+ const dims = fitAddon.proposeDimensions();
958
+ const natural = dims?.cols && dims?.rows ? { cols: dims.cols, rows: dims.rows } : null;
959
+ if (!natural || !claimOnTileFocus({ deliberate: deliberateRef.current, natural, current: { cols: t.cols, rows: t.rows } })) return;
960
+ lastClaimRef.current = { cols: natural.cols, rows: natural.rows, at: performance.now() };
961
+ try {
962
+ sock.send(JSON.stringify({ type: "resize", cols: natural.cols, rows: natural.rows, claim: "attach", user: true }));
963
+ } catch { /* closing; the next act re-claims */ }
964
+ };
934
965
  // Sub-second drops stay invisible: the "reconnecting…" chrome waits out
935
966
  // a grace window, and the first retry goes immediately — a tunnel
936
967
  // restart or a waking laptop usually reconnects before either fires.
@@ -963,6 +994,7 @@ const LiveTile = ({ wsBase, room, userName, theme, live, claudeApp, claimSize, a
963
994
  if (disposed || ws !== sock) return;
964
995
  reconnectAttempt = 0;
965
996
  markLive();
997
+ claimFitOnFocus(sock);
966
998
  window.setTimeout(() => rescaleRef.current(), 30);
967
999
  const cutoff = Date.now() - 15000;
968
1000
  for (const pend of pendingInput) {
@@ -1614,7 +1646,11 @@ export const SessionSwitcher = ({
1614
1646
  else sessionStorage.removeItem("hay_switcher_focus");
1615
1647
  } catch { /* private mode */ }
1616
1648
  }, [focusedKey]);
1617
- useEffect(() => { if (!open) setFocusedKey(null); }, [open]);
1649
+ // The key the HUMAN focused (click, Enter, start, create), as opposed to
1650
+ // the wall pre-focusing the current session as it opens. The live tile
1651
+ // takes the session's size only for the former.
1652
+ const deliberateFocusRef = useRef<string | null>(null);
1653
+ useEffect(() => { if (!open) { setFocusedKey(null); deliberateFocusRef.current = null; } }, [open]);
1618
1654
  useEffect(() => { if (!interactiveTiles) setFocusedKey(null); }, [interactiveTiles]);
1619
1655
  const [renameDraft, setRenameDraft] = useState("");
1620
1656
  const [creating, setCreating] = useState(false);
@@ -2002,6 +2038,7 @@ export const SessionSwitcher = ({
2002
2038
  if (!current) return;
2003
2039
  initialFocusDoneRef.current = true;
2004
2040
  const key = sessionKey(current);
2041
+ deliberateFocusRef.current = null; // the wall's own pre-focus, not an act
2005
2042
  setFocusedKey(key);
2006
2043
  const idx = navIndexByKey.get(key);
2007
2044
  if (typeof idx === "number") {
@@ -2242,6 +2279,7 @@ export const SessionSwitcher = ({
2242
2279
  const focusInPlace = (session: SwitcherSession) => {
2243
2280
  const key = sessionKey(session);
2244
2281
  filterInputRef.current?.blur();
2282
+ deliberateFocusRef.current = key;
2245
2283
  setFocusedKey(key);
2246
2284
  onFocusSession?.(session);
2247
2285
  const idx = flatNav.findIndex((x) => sessionKey(x) === key);
@@ -2291,6 +2329,7 @@ export const SessionSwitcher = ({
2291
2329
  kick.onerror = () => { try { kick.close(); } catch { /* refused */ } };
2292
2330
  } catch { /* daemon will surface it as starting on the next poll */ }
2293
2331
  onNotice("Starting " + (session.displayName || session.name) + "…");
2332
+ deliberateFocusRef.current = key;
2294
2333
  setFocusedKey(key);
2295
2334
  onFocusSession?.(session);
2296
2335
  onRefresh();
@@ -2703,6 +2742,7 @@ export const SessionSwitcher = ({
2703
2742
  if (hiddenByOrigin) setOriginScope("all");
2704
2743
  if (taken.parked) setParkedOpen(true);
2705
2744
  const key = sessionKey(taken);
2745
+ deliberateFocusRef.current = key;
2706
2746
  setFocusedKey(key);
2707
2747
  if (visible) {
2708
2748
  const idx = navIndexByKey.get(key);
@@ -2746,6 +2786,7 @@ export const SessionSwitcher = ({
2746
2786
  // The id is minted server-side now; the display name is what the user
2747
2787
  // typed. Keys ride on the id, labels on the name.
2748
2788
  onFocusSession?.({ name: data.displayName || data.name, displayName: data.displayName || data.name, active: false, starting: true, internalName: data.internalName || data.name });
2789
+ deliberateFocusRef.current = data.internalName || data.name;
2749
2790
  setFocusedKey(data.internalName || data.name);
2750
2791
  onRefresh();
2751
2792
  } catch {
@@ -2831,7 +2872,11 @@ export const SessionSwitcher = ({
2831
2872
  // producing output RIGHT NOW glows instead of jumping to the top.
2832
2873
  // Window comfortably exceeds the 10s relative-time tick so a busy
2833
2874
  // session's glow holds steady rather than flickering between polls.
2834
- const activeNow = !current && now - (s.lastActivityAt || 0) < 12000;
2875
+ // Agent sessions wear their PHASE (working / done / read) instead of the
2876
+ // generic "output right now" ring: the daemon's verdict is the sharper
2877
+ // signal, and two rings on one card were unreadable.
2878
+ const phase = cardPhase(s);
2879
+ const activeNow = !current && !phase && now - (s.lastActivityAt || 0) < 12000;
2835
2880
  // Freshness: recency the eye can read WITHOUT the row moving. A left-edge
2836
2881
  // bar whose intensity decays from full (just active) to nothing over
2837
2882
  // ~10 minutes — bright means "just now", faint means "a while ago",
@@ -2852,7 +2897,7 @@ export const SessionSwitcher = ({
2852
2897
  tabIndex={0}
2853
2898
  data-nav-index={navIndexByKey.get(key)}
2854
2899
  data-session-key={key}
2855
- className={`switcher-card${current ? " current" : ""}${activeNow ? " active-now" : ""}${kbdSelected ? " kbd-selected" : ""}${focusedKey === key ? " focused" : ""}${dragKey === key ? " dragging" : ""}${draggable ? " draggable" : ""}${fileDropKey === key ? " file-drop" : ""}`}
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" : ""}`}
2856
2901
  draggable={draggable}
2857
2902
  onDragStart={draggable ? (e) => { setDragKey(key); e.dataTransfer.effectAllowed = "move"; } : undefined}
2858
2903
  onDragEnter={draggable ? () => { if (dragKey && dragKey !== key) moveManual(dragKey, key); } : undefined}
@@ -2941,6 +2986,11 @@ export const SessionSwitcher = ({
2941
2986
  </span>
2942
2987
  )}
2943
2988
  {!current && s.starting && !s.active && <span className="switcher-chip starting">STARTING</span>}
2989
+ {phase && (
2990
+ <span className={`switcher-chip phase ${phase}`} title={phaseTitle(phase)}>
2991
+ <span className="phase-dot" aria-hidden="true" />{phaseLabel(phase)}
2992
+ </span>
2993
+ )}
2944
2994
  {/* A result waiting on this session is worth as much as its status:
2945
2995
  a published PDF or write-up was otherwise invisible from the wall.
2946
2996
  stopPropagation on pointerdown keeps the long-press sheet out of
@@ -2976,6 +3026,7 @@ export const SessionSwitcher = ({
2976
3026
  userName={userName || "user"}
2977
3027
  theme={terminalTheme}
2978
3028
  live={focusedKey === key}
3029
+ deliberate={deliberateFocusRef.current === key}
2979
3030
  claudeApp={appLabel(s) === "claude"}
2980
3031
  claimSize={s.hasLocalCli !== true}
2981
3032
  activeCols={preview?.cols}
@@ -60,8 +60,10 @@ type Props = {
60
60
  // Only used to translate internalName into the name the user renamed it to;
61
61
  // the manifest has no idea a session was ever renamed.
62
62
  sessions?: { name: string; displayName?: string; internalName?: string }[];
63
- // Right-side dock beside a live terminal (full-screen mode). Falls back to
64
- // the modal below ~1100px, where a dock would leave no terminal to see.
63
+ // Right-side dock beside a live terminal. Off by default everywhere now:
64
+ // the reader opens as the CENTERED window, index beside page — a dock at
65
+ // the screen's edge was farther from where the eye already is. The dock
66
+ // stays available for a caller that wants it.
65
67
  dock?: boolean;
66
68
  onClose: () => void;
67
69
  };
@@ -3795,6 +3795,59 @@ body.pane-dragging { user-select: none; }
3795
3795
  color: #1a1200;
3796
3796
  }
3797
3797
 
3798
+ /* The agent's phase, the wall's three colours (utils/agentPhase.ts):
3799
+ WORKING blue, DONE (unread) green, READ quiet. Purple stays "current /
3800
+ focused", amber stays "asking you", so no two signals share a hue. */
3801
+ .switcher-chip.phase {
3802
+ display: inline-flex;
3803
+ align-items: center;
3804
+ gap: 4px;
3805
+ }
3806
+ .switcher-chip.phase .phase-dot {
3807
+ width: 6px;
3808
+ height: 6px;
3809
+ border-radius: 50%;
3810
+ background: currentColor;
3811
+ }
3812
+ .switcher-chip.phase.working {
3813
+ background: color-mix(in srgb, #3b82f6 22%, var(--bg-alt));
3814
+ color: #3b82f6;
3815
+ }
3816
+ .switcher-chip.phase.working .phase-dot {
3817
+ animation: phase-working-pulse 1.2s ease-in-out infinite;
3818
+ }
3819
+ @keyframes phase-working-pulse {
3820
+ 0%, 100% { opacity: 1; transform: scale(1); }
3821
+ 50% { opacity: 0.35; transform: scale(0.7); }
3822
+ }
3823
+ .switcher-chip.phase.done-unread {
3824
+ background: #22a06b;
3825
+ color: #04170d;
3826
+ }
3827
+ .switcher-chip.phase.done-read {
3828
+ background: var(--bg-alt);
3829
+ color: var(--ink-muted);
3830
+ }
3831
+ @media (prefers-reduced-motion: reduce) {
3832
+ .switcher-chip.phase.working .phase-dot { animation: none; }
3833
+ }
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);
3841
+ }
3842
+ .switcher-card.phase-done-read {
3843
+ opacity: 0.88;
3844
+ }
3845
+ .switcher-card.phase-done-read.current,
3846
+ .switcher-card.phase-done-read.focused,
3847
+ .switcher-card.phase-done-read:hover {
3848
+ opacity: 1;
3849
+ }
3850
+
3798
3851
  /* Colour-preview rows. Inline spans paint their background only over the
3799
3852
  glyph box, and at line-height 1.3 the half-leading above and below stayed
3800
3853
  the tile's background — code blocks rendered as horizontal stripes with a
@@ -0,0 +1,26 @@
1
+ // The wall's three agent colours, from the daemon's verdict. The daemon
2
+ // decides working / done and whether a human has opened the session since
3
+ // the turn ended (its seen witness counts every device), so the web wall and
4
+ // the phone paint the same session the same colour.
5
+ //
6
+ // working blue a turn is in flight
7
+ // done-unread green finished; nobody has opened it since
8
+ // done-read quiet finished, and looked at
9
+ //
10
+ // A question the agent is asking (attentionReason "ask") keeps its own amber
11
+ // signal on top; it is not folded in here.
12
+ export type CardPhase = "working" | "done-unread" | "done-read" | null;
13
+
14
+ export const cardPhase = (s: { agentPhase?: "working" | "done" | null; turnSeen?: boolean }): CardPhase => {
15
+ if (s.agentPhase === "working") return "working";
16
+ if (s.agentPhase === "done") return s.turnSeen === false ? "done-unread" : "done-read";
17
+ return null;
18
+ };
19
+
20
+ export const phaseLabel = (p: CardPhase): string =>
21
+ p === "working" ? "WORKING" : p === "done-unread" ? "DONE" : p === "done-read" ? "READ" : "";
22
+
23
+ export const phaseTitle = (p: CardPhase): string =>
24
+ p === "working" ? "The agent is working on a turn"
25
+ : p === "done-unread" ? "The agent finished — you have not opened this session since"
26
+ : p === "done-read" ? "The agent finished, and you have looked" : "";
@@ -56,3 +56,36 @@ export const isPlainClick = (
56
56
  if (!down) return true;
57
57
  return Math.hypot(up.x - down.x, up.y - down.y) <= slopPx;
58
58
  };
59
+
60
+ /**
61
+ * CLOSE ENOUGH is the right size. A tile that renders the session scaled
62
+ * by ~0.9 is visually free; a claim is for sizes that are genuinely
63
+ * foreign (a full-screen leftover on a wall tile), not for two walls
64
+ * disagreeing about rounding — that disagreement, claimed, was two
65
+ * browsers re-sizing every session back and forth on every open.
66
+ */
67
+ export const sizeIsForeign = (current: Size, natural: Size): boolean => {
68
+ const closeCols = Math.abs(current.cols - natural.cols) <= Math.max(3, Math.round(natural.cols * 0.15));
69
+ const closeRows = Math.abs(current.rows - natural.rows) <= Math.max(2, Math.round(natural.rows * 0.15));
70
+ return !(closeCols && closeRows);
71
+ };
72
+
73
+ /**
74
+ * A wall tile going live because the human ENGAGED it — a click, Enter,
75
+ * starting or creating a session there — is the same act as opening a
76
+ * session full screen, and takes the size the same way: only when what the
77
+ * session is wearing is foreign to the tile. The wall pre-focusing the
78
+ * current session as it opens is not an act (nobody clicked), and a tile
79
+ * that already fits has nothing to claim — a claim there would be a resize
80
+ * on every wall open, the fight the size-ownership rules exist to end.
81
+ */
82
+ export const claimOnTileFocus = (o: {
83
+ deliberate: boolean;
84
+ natural: Size | null;
85
+ current: Size | null;
86
+ }): boolean => {
87
+ if (!o.deliberate || !o.natural) return false;
88
+ if (o.natural.cols < 20 || o.natural.rows < 5) return false;
89
+ if (!o.current) return true;
90
+ return sizeIsForeign(o.current, o.natural);
91
+ };
@@ -35,6 +35,9 @@ export type SwitcherSession = {
35
35
  foregroundProcess?: string;
36
36
  /** What the session runs, by its hooks' records: claude, codex, or nothing known. */
37
37
  agent?: "claude" | "codex" | null;
38
+ /** Server-decided: a turn in flight / the last turn finished; turnSeen = a human opened it since. */
39
+ agentPhase?: "working" | "done" | null;
40
+ turnSeen?: boolean;
38
41
  agentPermitted?: boolean;
39
42
  cols?: number;
40
43
  rows?: number;
@@ -49,9 +49,26 @@ export type VoiceHoldOptions = {
49
49
  thresholdMs?: number;
50
50
  };
51
51
 
52
+ // A ring of what the controller saw and did — key events with their
53
+ // verdicts, recogniser lifecycle, why a hold ended. Read from the console
54
+ // (window.__hopVoiceTrace) or shipped with the client diagnostics, so a
55
+ // "dictation stopped and spaces started typing" report can be replayed
56
+ // instead of guessed at.
57
+ const TRACE_MAX = 240;
58
+ export const voiceTrace: string[] = [];
59
+ const trace = (line: string) => {
60
+ voiceTrace.push(`${new Date().toISOString().slice(11, 23)} ${line}`);
61
+ if (voiceTrace.length > TRACE_MAX) voiceTrace.splice(0, voiceTrace.length - TRACE_MAX);
62
+ };
63
+ if (typeof window !== "undefined") (window as unknown as { __hopVoiceTrace?: string[] }).__hopVoiceTrace = voiceTrace;
64
+
52
65
  export const createVoiceHold = (opts: VoiceHoldOptions) => {
53
66
  const threshold = opts.thresholdMs ?? 550;
54
67
  const MAX_HOLD_MS = 60000;
68
+ // A recogniser that ends this soon after starting, having heard nothing,
69
+ // was refused (no mic, no service), not silent: Chrome's own silence
70
+ // timeout is several seconds. Only refusals count toward giving up.
71
+ const REFUSED_MS = 1500;
55
72
  let timer = 0;
56
73
  let watchdog = 0;
57
74
  let active = false;
@@ -66,8 +83,9 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
66
83
  /** Consecutive recogniser restarts that produced nothing — a loop guard. */
67
84
  let emptyRestarts = 0;
68
85
 
69
- const finish = (sendText: boolean) => {
86
+ const finish = (sendText: boolean, why = "") => {
70
87
  if (!active) return;
88
+ trace(`finish send=${sendText} why=${why} final=${JSON.stringify(finalText.slice(-40))} interim=${JSON.stringify(interimText.slice(-40))}`);
71
89
  active = false;
72
90
  pending = false;
73
91
  stopping = false;
@@ -91,7 +109,8 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
91
109
  const Ctor = speechRecognitionCtor();
92
110
  // Silence here read as "voice randomly does nothing": the space vanished
93
111
  // (or didn't) and no overlay ever appeared, with no reason given.
94
- if (!Ctor) { opts.notify("Voice input is not supported in this browser"); return; }
112
+ if (!Ctor) { trace("start: no recogniser"); opts.notify("Voice input is not supported in this browser"); return; }
113
+ trace(`start spacesTyped=${spacesTyped}`);
95
114
  active = true;
96
115
  stopping = false;
97
116
  emptyRestarts = 0;
@@ -103,7 +122,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
103
122
  armWatchdog();
104
123
  window.addEventListener("blur", onWindowBlur);
105
124
  opts.setOverlay("");
106
- if (!listen(Ctor)) { opts.notify("Voice input could not start"); finish(false); }
125
+ if (!listen(Ctor)) { opts.notify("Voice input could not start"); finish(false, "listen-failed"); }
107
126
  };
108
127
 
109
128
  // One recogniser session. The hold outlives it: browser recognisers end
@@ -117,6 +136,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
117
136
  let r: RecognitionLike;
118
137
  try { r = new Ctor(); } catch { return false; }
119
138
  let heard = false;
139
+ const startedAt = Date.now();
120
140
  r.lang = navigator.language || "en-US";
121
141
  r.continuous = true;
122
142
  r.interimResults = true;
@@ -133,21 +153,35 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
133
153
  opts.setOverlay((finalText + interim).trim());
134
154
  };
135
155
  const ended = () => {
156
+ const age = Date.now() - startedAt;
157
+ trace(`rec end heard=${heard} age=${age}ms stopping=${stopping} current=${rec === r}`);
136
158
  if (!active || rec !== r) return; // an old recogniser, or already finished
137
- if (stopping) { finish(true); return; } // released: type what we have
159
+ if (stopping) { finish(true, "released"); return; } // released: type what we have
138
160
  // A finished phrase is folded in; the interim is gone with the session.
139
161
  interimText = "";
140
- // Three recognisers in a row that heard nothing is the service refusing
141
- // us, not silence — give the space bar back rather than spin forever.
142
- if (!heard && ++emptyRestarts >= 3) { finish(true); return; }
162
+ // Silence is not a reason to stop: Space is still down, and the hold
163
+ // ending on its own is exactly what "dictation just cut out and then
164
+ // spaces typed" looks like from the chair. Only a recogniser that is
165
+ // REFUSING us — ending at once, three times, having heard nothing —
166
+ // gives the space bar back rather than spin forever.
167
+ if (!heard && age < REFUSED_MS) {
168
+ if (++emptyRestarts >= 3) { finish(true, "refused"); return; }
169
+ } else {
170
+ emptyRestarts = 0;
171
+ }
143
172
  opts.setOverlay(finalText.trim());
144
- if (!listen(Ctor)) finish(true);
173
+ if (!listen(Ctor)) finish(true, "restart-failed");
145
174
  };
146
175
  r.onerror = (ev) => {
147
176
  const code = ev?.error || "unknown";
177
+ trace(`rec error ${code} stopping=${stopping} current=${rec === r}`);
178
+ // Our own stop() is finishing this hold with the words it has; an
179
+ // error the recogniser raises on the way out ("aborted") must not
180
+ // finish it again WITHOUT them.
181
+ if (stopping || rec !== r) return;
148
182
  // Quiet or interrupted while still held: not a failure, just this
149
183
  // recogniser's session ending — the onend that follows restarts it.
150
- if (active && !stopping && (code === "no-speech" || code === "aborted")) return;
184
+ if (active && (code === "no-speech" || code === "aborted")) return;
151
185
  // EVERY real failure explains itself. Only mic-denial was surfaced
152
186
  // before; Chrome's cloud recogniser routinely fails with "network",
153
187
  // and that silent death read as "voice randomly doesn't work".
@@ -160,7 +194,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
160
194
  } else if (code !== "aborted") {
161
195
  opts.notify(`Voice input failed: ${code}`);
162
196
  }
163
- finish(false);
197
+ finish(false, `error:${code}`);
164
198
  };
165
199
  r.onend = ended;
166
200
  rec = r;
@@ -170,6 +204,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
170
204
 
171
205
  const stop = () => {
172
206
  const r = rec;
207
+ trace("stop");
173
208
  stopping = true;
174
209
  try { r?.stop(); } catch { /* already gone */ }
175
210
  rec = null;
@@ -179,7 +214,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
179
214
  // true — and since every space is deliberately eaten while active, that
180
215
  // is a terminal that cannot type a space AT ALL. onend arriving later is
181
216
  // harmless; finish() is a no-op once inactive.
182
- finish(true);
217
+ finish(true, "stop");
183
218
  };
184
219
 
185
220
  // Last line of defence. Nothing should hold the microphone for a minute,
@@ -223,8 +258,10 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
223
258
  }
224
259
  // A keypress carries no `code` in some engines; fall back to the key.
225
260
  const isSpace = ev.code === "Space" || (ev.type === "keypress" && ev.key === " ");
226
- if (isSpace && !ev.metaKey && !ev.ctrlKey && !ev.altKey && !ev.shiftKey
227
- && !!speechRecognitionCtor() && (active || pending || opts.eligible())) {
261
+ if (isSpace && !ev.metaKey && !ev.ctrlKey && !ev.altKey && !ev.shiftKey) {
262
+ const eligible = active || pending || opts.eligible();
263
+ trace(`space ${ev.type}${ev.repeat ? " repeat" : ""} active=${active} pending=${pending} eligible=${eligible}`);
264
+ if (!speechRecognitionCtor() || !eligible) return undefined;
228
265
  if (ev.type === "keydown") {
229
266
  if (active) {
230
267
  // A genuine auto-repeat is the only thing that may be eaten while
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cardPhase, phaseLabel } from "../src/utils/agentPhase";
3
+
4
+ describe("cardPhase — the wall's three agent colours come from the daemon's verdict", () => {
5
+ it("working while a turn is in flight", () => {
6
+ expect(cardPhase({ agentPhase: "working", turnSeen: true })).toBe("working");
7
+ });
8
+ it("done and unread until a human has opened it since the turn ended", () => {
9
+ expect(cardPhase({ agentPhase: "done", turnSeen: false })).toBe("done-unread");
10
+ expect(cardPhase({ agentPhase: "done", turnSeen: true })).toBe("done-read");
11
+ });
12
+ it("nothing for a plain shell or an agent that has not finished a turn", () => {
13
+ expect(cardPhase({ agentPhase: null })).toBe(null);
14
+ expect(cardPhase({})).toBe(null);
15
+ });
16
+ it("labels", () => {
17
+ expect(phaseLabel("working")).toBe("WORKING");
18
+ expect(phaseLabel("done-unread")).toBe("DONE");
19
+ expect(phaseLabel("done-read")).toBe("READ");
20
+ expect(phaseLabel(null)).toBe("");
21
+ });
22
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { claimOnAttach, claimOnClick, isPlainClick } from "../src/utils/sizeClaim";
2
+ import { claimOnAttach, claimOnClick, claimOnTileFocus, isPlainClick, sizeIsForeign } from "../src/utils/sizeClaim";
3
3
 
4
4
  describe("claimOnAttach — only a deliberate open takes the size", () => {
5
5
  const base = { viewMode: "fit" as const, visible: true, deliberate: true };
@@ -40,3 +40,26 @@ describe("isPlainClick — the release of a drag is not a click", () => {
40
40
  expect(isPlainClick({ x: 100, y: 100 }, { x: 100, y: 100 }, true)).toBe(false);
41
41
  });
42
42
  });
43
+
44
+ describe("claimOnTileFocus — a tile you engaged fits itself, a tile the wall pre-focused does not", () => {
45
+ const natural = { cols: 90, rows: 28 };
46
+ it("claims when a click lands on a tile wearing a full-screen size", () => {
47
+ expect(claimOnTileFocus({ deliberate: true, natural, current: { cols: 180, rows: 48 } })).toBe(true);
48
+ });
49
+ it("stays quiet when the session already fits, within rounding", () => {
50
+ expect(claimOnTileFocus({ deliberate: true, natural, current: { cols: 88, rows: 30 } })).toBe(false);
51
+ expect(claimOnTileFocus({ deliberate: true, natural, current: natural })).toBe(false);
52
+ });
53
+ it("never claims for the wall's own pre-focus of the current session", () => {
54
+ expect(claimOnTileFocus({ deliberate: false, natural, current: { cols: 180, rows: 48 } })).toBe(false);
55
+ });
56
+ it("needs a measurable tile and refuses a degenerate fit", () => {
57
+ expect(claimOnTileFocus({ deliberate: true, natural: null, current: { cols: 180, rows: 48 } })).toBe(false);
58
+ expect(claimOnTileFocus({ deliberate: true, natural: { cols: 12, rows: 3 }, current: { cols: 180, rows: 48 } })).toBe(false);
59
+ });
60
+ it("sizeIsForeign is the same tolerance the ⤢ badge uses", () => {
61
+ expect(sizeIsForeign({ cols: 93, rows: 30 }, natural)).toBe(false);
62
+ expect(sizeIsForeign({ cols: 120, rows: 28 }, natural)).toBe(true);
63
+ expect(sizeIsForeign({ cols: 90, rows: 40 }, natural)).toBe(true);
64
+ });
65
+ });
@@ -261,7 +261,7 @@ describe("hold-space dictation", () => {
261
261
  expect(FakeRecognition.last).not.toBe(first);
262
262
  });
263
263
 
264
- it("gives the space bar back when recognisers keep ending without hearing anything", () => {
264
+ it("gives the space bar back when recognisers REFUSE — ending at once, three times, hearing nothing", () => {
265
265
  const hold = makeHold();
266
266
  hold.handleKey(keydown());
267
267
  vi.advanceTimersByTime(500);
@@ -273,6 +273,36 @@ describe("hold-space dictation", () => {
273
273
  expect(overlays[overlays.length - 1]).toBe(null);
274
274
  });
275
275
 
276
+ it("silence never ends a hold that is still held — sessions timing out are replaced for as long as Space is down", () => {
277
+ const hold = makeHold();
278
+ hold.handleKey(keydown());
279
+ vi.advanceTimersByTime(500);
280
+ // Chrome's no-speech comes after seconds of silence, session after session.
281
+ for (let i = 0; i < 6; i++) {
282
+ vi.advanceTimersByTime(8000);
283
+ FakeRecognition.last!.onerror?.({ error: "no-speech" });
284
+ FakeRecognition.last!.onend?.();
285
+ expect(hold.isActive()).toBe(true);
286
+ expect(overlays[overlays.length - 1]).not.toBe(null);
287
+ }
288
+ // Still held, still listening: an auto-repeat is eaten, not typed.
289
+ expect(pressSpace(hold, { repeat: true }).typed).toBe(false);
290
+ hold.handleKey(keyup());
291
+ expect(hold.isActive()).toBe(false);
292
+ });
293
+
294
+ it("an 'aborted' error raised while we are stopping does not throw the words away", () => {
295
+ const hold = makeHold();
296
+ hold.handleKey(keydown());
297
+ vi.advanceTimersByTime(500);
298
+ FakeRecognition.last!.onresult?.({ resultIndex: 0, results: [Object.assign([{ transcript: "keep this" }], { isFinal: true })] } as never);
299
+ const r = FakeRecognition.last!;
300
+ hold.handleKey(keyup()); // stop(): finishes with the words
301
+ r.onerror?.({ error: "aborted" }); // the recogniser objecting on its way out
302
+ r.onend?.();
303
+ expect(sent.filter((d) => d !== "\x7f")).toEqual(["keep this"]);
304
+ });
305
+
276
306
  it("a recogniser that never ends cannot kill the space bar", () => {
277
307
  // The regression that bit Jian: dictation started, the recogniser never
278
308
  // fired onend, `active` stuck true — and because every space is eaten
package/hop CHANGED
@@ -18,6 +18,8 @@ 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');
22
+ const { snapshotFromMetas, planRefiles, pruneList, snapshotFileName } = require('./lib/identity-snapshot');
21
23
  const { rotateAuditFile, sweepStaleAuditDirs } = require('./lib/audit-rotation');
22
24
  const {
23
25
  createFailureTracker,
@@ -5357,6 +5359,30 @@ function readTurnAt(internalName) {
5357
5359
  return Number.isFinite(at) ? at : 0;
5358
5360
  } catch (e) { return 0; }
5359
5361
  }
5362
+ // When the agent's current turn BEGAN (Claude's UserPromptSubmit hook; Codex
5363
+ // has no equivalent, so 0 there). Paired with readTurnAt by agentPhase().
5364
+ function readPromptAt(internalName) {
5365
+ try {
5366
+ const at = Date.parse(JSON.parse(fs.readFileSync(
5367
+ path.join(CLAUDE_SESSIONS_DIR, `${internalName}.prompt`), 'utf8')).at);
5368
+ return Number.isFinite(at) ? at : 0;
5369
+ } catch (e) { return 0; }
5370
+ }
5371
+ // The wall's three agent colours, decided here so the web and the phone
5372
+ // agree: working / done-unread / done-read. Plain shells get null.
5373
+ function agentPhaseFields(s) {
5374
+ if (!s.agent) return { agentPhase: null, turnSeen: true, turnAt: null };
5375
+ const turnAt = readTurnAt(s.internalName);
5376
+ const r = agentPhase({
5377
+ agent: s.agent,
5378
+ turnAt,
5379
+ promptAt: readPromptAt(s.internalName),
5380
+ lastActivityAt: Number(s.lastActivityAt) || 0,
5381
+ lastUserSeenAt: Number(s.lastUserSeenAt) || 0,
5382
+ userAttached: s.userAttached === true
5383
+ });
5384
+ return { agentPhase: r.phase, turnSeen: r.turnSeen, turnAt: turnAt || null };
5385
+ }
5360
5386
  // The newest published view's time (ms) for a session, from the manifest
5361
5387
  // the views summary already reads. `hop view` rings one bell per publish.
5362
5388
  function latestViewAtMs(internalName) {
@@ -5708,6 +5734,55 @@ function listClaudeSessionMetas() {
5708
5734
  return out;
5709
5735
  }
5710
5736
 
5737
+ // ---- Identity snapshots (lib/identity-snapshot.js) -------------------------
5738
+ // A second copy of the durable metas' user-authored fields — above all which
5739
+ // FOLDER each session is filed in — written at start, at stop and hourly.
5740
+ // The 2026-09-26 `stop --all` deleted every live session's records and the
5741
+ // rebuilt metas had no folder to go back to; the snapshot is what makes that
5742
+ // recoverable next time. restore re-files from the newest one.
5743
+ const IDENTITY_SNAPSHOT_DIR = path.join(HOME_DIR, 'restore-snapshots');
5744
+ function writeIdentitySnapshot(reason) {
5745
+ try {
5746
+ const snap = snapshotFromMetas(listClaudeSessionMetas(), sessionStore.folders, { reason });
5747
+ fs.mkdirSync(IDENTITY_SNAPSHOT_DIR, { recursive: true, mode: 0o700 });
5748
+ const file = path.join(IDENTITY_SNAPSHOT_DIR, snapshotFileName(snap.at));
5749
+ writePrivateFile(file, JSON.stringify(snap, null, 1), 0o600);
5750
+ for (const old of pruneList(fs.readdirSync(IDENTITY_SNAPSHOT_DIR))) {
5751
+ try { fs.unlinkSync(path.join(IDENTITY_SNAPSHOT_DIR, old)); } catch (e) { /* best effort */ }
5752
+ }
5753
+ return file;
5754
+ } catch (e) {
5755
+ log(`[snapshot] could not write identity snapshot: ${e?.message || e}`, DIM);
5756
+ return null;
5757
+ }
5758
+ }
5759
+ function latestIdentitySnapshot() {
5760
+ try {
5761
+ const names = fs.readdirSync(IDENTITY_SNAPSHOT_DIR).filter((n) => /^identity-.*\.json$/.test(n)).sort();
5762
+ if (!names.length) return null;
5763
+ const file = path.join(IDENTITY_SNAPSHOT_DIR, names[names.length - 1]);
5764
+ return { file, snapshot: JSON.parse(fs.readFileSync(file, 'utf8')) };
5765
+ } catch (e) { return null; }
5766
+ }
5767
+ // Sessions whose meta has no folder at all but which the newest snapshot
5768
+ // remembers in a folder that still exists get their folder back. An
5769
+ // explicit `folderId: null` (unfiled on purpose) is respected.
5770
+ function refileFromIdentitySnapshot({ dryRun = false } = {}) {
5771
+ const latest = latestIdentitySnapshot();
5772
+ if (!latest) return [];
5773
+ const plan = planRefiles(listClaudeSessionMetas(), latest.snapshot, sessionStore.folders);
5774
+ for (const item of plan) {
5775
+ const folderName = sessionStore.folders[item.folderId]?.name || item.folderId;
5776
+ if (dryRun) { log(` would re-file ${item.displayName} → ${folderName} (from ${path.basename(latest.file)})`, DIM); continue; }
5777
+ const durable = readClaudeSessionMeta(item.internalName) || {};
5778
+ writeClaudeSessionMeta(item.internalName, { ...durable, internalName: item.internalName, folderId: item.folderId });
5779
+ rememberRuntimeSessionMetadata(item.internalName, { ...(getRuntimeSessionMetadata(item.internalName) || {}), folderId: item.folderId });
5780
+ if (sessionStore.sessions[item.internalName]) moveSessionToFolder(item.internalName, item.folderId);
5781
+ log(` re-filed ${item.displayName} → ${folderName} (from ${path.basename(latest.file)})`, DIM);
5782
+ }
5783
+ return plan;
5784
+ }
5785
+
5711
5786
  // Claude Code slugifies a project cwd for <config-root>/projects/<slug>/.
5712
5787
  // The rule has changed across versions (older: every non-alphanumeric -> '-';
5713
5788
  // newer builds preserve spaces: "nebula-notebook 2" keeps its space), so
@@ -6448,6 +6523,11 @@ function resolveRestoreTargets(names, snapshot) {
6448
6523
  return { resolved, unknown };
6449
6524
  }
6450
6525
  async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = false, only = null } = {}) {
6526
+ // Folders first: a session whose meta lost its folder (records deleted,
6527
+ // meta rebuilt) is put back where the newest identity snapshot says it
6528
+ // was, so the restored wall is filed the way the user left it.
6529
+ const refiled = refileFromIdentitySnapshot({ dryRun });
6530
+ if (refiled.length) log(`${dryRun ? 'Would re-file' : 'Re-filed'} ${refiled.length} session(s) into their folders from the identity snapshot.`, DIM);
6451
6531
  const snapshot = await listAllSessionEntriesRuntimeFirst();
6452
6532
  const live = new Set(snapshot.entries.filter((e) => e.live).map((e) => e.internalName));
6453
6533
  // Targeted redo (`hop restore <name>...`): the named sessions are planned
@@ -6822,7 +6902,9 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
6822
6902
  // The same script is registered for two events: SessionStart (records the
6823
6903
  // conversation id for `hop restore`) and Stop (bumps a per-turn counter a driver
6824
6904
  // can wait on). See scripts/claude-session-hook.js.
6825
- const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop', 'Notification', 'PreToolUse'];
6905
+ // UserPromptSubmit marks the START of a turn (with Stop's end, the wall
6906
+ // colours the session "working" / "done" — see lib/agent-phase.js).
6907
+ const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop', 'Notification', 'PreToolUse', 'UserPromptSubmit'];
6826
6908
  // Notification (permission prompts, elicitation) and PreToolUse (the two tools
6827
6909
  // that open a dialog and wait) bump the per-session ASK record — "whenever an
6828
6910
  // agent asks me a question, notify me" (Jian). PreToolUse gets a matcher, or
@@ -11644,6 +11726,9 @@ happens until someone clicks it.
11644
11726
  cwd: s.cwd,
11645
11727
  foregroundProcess: s.foregroundProcess,
11646
11728
  agent: s.agent || null,
11729
+ // working / done + whether the human has opened it since the
11730
+ // turn ended (server-side seen witness, so every device agrees).
11731
+ ...agentPhaseFields(s),
11647
11732
  lastActivityAt: s.lastActivityAt,
11648
11733
  // For sessions armed with notify-on-finish, each completed turn
11649
11734
  // adds to bellSeq so the phone treats a finished response like a
@@ -12570,13 +12655,16 @@ happens until someone clicks it.
12570
12655
  req.on('end', () => {
12571
12656
  try {
12572
12657
  const { name, internalName, folderId, index } = JSON.parse(body);
12658
+ // Unfiling is recorded as an explicit null: a meta with
12659
+ // NO folderId key is one that may have LOST it, and the
12660
+ // identity snapshot re-files those on restore.
12573
12661
  const persistMembership = (target) => {
12574
12662
  if (!target) return;
12575
12663
  const durable = readClaudeSessionMeta(target) || {};
12576
12664
  writeClaudeSessionMeta(target, {
12577
12665
  ...durable,
12578
12666
  internalName: target,
12579
- folderId: folderId || undefined
12667
+ folderId: folderId || null
12580
12668
  });
12581
12669
  };
12582
12670
  // Resolve like every other session endpoint: a live
@@ -19317,6 +19405,12 @@ async function main() {
19317
19405
  // per file, and a no-op when the folder doesn't exist yet.
19318
19406
  setTimeout(enforceUploadBudget, 20000).unref?.();
19319
19407
  setInterval(enforceUploadBudget, 3600000).unref?.();
19408
+
19409
+ // Identity snapshot (folders, names, origins): once the boot settles,
19410
+ // then hourly. Cheap — one small file — and the reason restore can
19411
+ // put sessions back in their folders after their records are lost.
19412
+ setTimeout(() => writeIdentitySnapshot('start'), 30000).unref?.();
19413
+ setInterval(() => writeIdentitySnapshot('hourly'), 3600000).unref?.();
19320
19414
  }
19321
19415
 
19322
19416
  if (DAEMON_FLAG) {
@@ -19324,6 +19418,7 @@ async function main() {
19324
19418
  if (shuttingDown) return;
19325
19419
  shuttingDown = true;
19326
19420
  log(`Cleaning up...`, YELLOW);
19421
+ writeIdentitySnapshot('stop');
19327
19422
 
19328
19423
  stopTunnelHealthMonitor();
19329
19424
 
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+ // What an agent session is doing right now, for the wall's colours — the web
3
+ // wall and the phone both read it from the session list, so they agree:
4
+ //
5
+ // working a turn is in flight
6
+ // done the last turn finished, and the human has not opened the
7
+ // session since (turnSeen: false)
8
+ // done, read the last turn finished and the human has looked (turnSeen: true)
9
+ //
10
+ // Decided from records hop already keeps. The turn record (`.turn`, written by
11
+ // Claude's Stop hook and Codex's notify chain) says when the last turn ENDED.
12
+ // Claude's UserPromptSubmit hook (`.prompt`) says when the current one began;
13
+ // Codex has no such hook, so for it — and for Claude sessions that predate
14
+ // the hook — output flowing after the last turn ended stands in for it.
15
+ // "Read" is the daemon's seen witness: a user client (not a wall preview, not
16
+ // an agent's terminal) attached to the session since the turn ended.
17
+
18
+ /** Output this recent means a turn is in flight (Codex spins while it works). */
19
+ const WORKING_RECENT_MS = 15000;
20
+ /** A submitted prompt with no output for this long was interrupted (Esc), not working. */
21
+ const PROMPT_STALE_MS = 45000;
22
+ /** The final render lands just after the Stop hook fires; output inside this tail is the turn's own. */
23
+ const TURN_TAIL_MS = 1500;
24
+
25
+ /**
26
+ * @param {object} o
27
+ * @param {string|null} o.agent 'claude' | 'codex' | null
28
+ * @param {number} [o.turnAt] ms, when the last turn ended (0 = never)
29
+ * @param {number} [o.promptAt] ms, when the last prompt was submitted (0 = unknown)
30
+ * @param {number} [o.lastActivityAt] ms, last PTY output
31
+ * @param {number} [o.lastUserSeenAt] ms, when a user client last left (or attached)
32
+ * @param {boolean} [o.userAttached] a user client is on it right now
33
+ * @param {number} [o.now]
34
+ * @returns {{ phase: 'working'|'done'|null, turnSeen: boolean }}
35
+ */
36
+ function agentPhase(o) {
37
+ const now = Number.isFinite(o.now) ? o.now : Date.now();
38
+ if (!o.agent) return { phase: null, turnSeen: true };
39
+ const turnAt = Number(o.turnAt) || 0;
40
+ const promptAt = Number(o.promptAt) || 0;
41
+ const active = Number(o.lastActivityAt) || 0;
42
+ let working;
43
+ if (promptAt > turnAt) {
44
+ // Claude, with the prompt hook: a prompt newer than the last turn end
45
+ // is a turn in flight — unless the screen has been dead quiet for so
46
+ // long that it was interrupted and never finished.
47
+ working = active > 0 && now - active < PROMPT_STALE_MS;
48
+ } else {
49
+ working = active > 0 && now - active < WORKING_RECENT_MS && active > turnAt + TURN_TAIL_MS;
50
+ }
51
+ if (working) return { phase: 'working', turnSeen: true };
52
+ if (!turnAt) return { phase: null, turnSeen: true };
53
+ const seenAt = o.userAttached ? now : (Number(o.lastUserSeenAt) || 0);
54
+ return { phase: 'done', turnSeen: seenAt >= turnAt };
55
+ }
56
+
57
+ module.exports = { agentPhase, WORKING_RECENT_MS, PROMPT_STALE_MS, TURN_TAIL_MS };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // A second copy of what only the durable session metas know — the user's
3
+ // own filing (folder), name and origin per session — written on every
4
+ // daemon start and stop and hourly in between, so losing the metas (the
5
+ // 2026-09-26 `stop --all` deleted all 34 live sessions' records; a
6
+ // thermal-sleep crash before that lost three days of them) no longer means
7
+ // losing the folders too. `hop restore` re-files a session from the newest
8
+ // snapshot when its meta has forgotten which folder it was in.
9
+ //
10
+ // Pure: the daemon supplies the metas, folders and clock, and applies the
11
+ // plan. The one subtlety is the difference between "unfiled on purpose"
12
+ // and "forgot": the move endpoint now records an unfiling as an explicit
13
+ // `folderId: null`, so a meta with NO folderId key is the one that may have
14
+ // lost it. Metas older than that change have no key either way, which is
15
+ // why a session is only re-filed when a snapshot actually remembers a
16
+ // folder for it.
17
+
18
+ const KEEP = 24;
19
+
20
+ /** The identity worth a second copy, per session. */
21
+ function snapshotFromMetas(metas, folders, { at, reason } = {}) {
22
+ const sessions = {};
23
+ for (const m of metas || []) {
24
+ if (!m || !m.internalName) continue;
25
+ sessions[m.internalName] = {
26
+ displayName: m.displayName || undefined,
27
+ folderId: m.folderId || null,
28
+ createdBy: m.createdBy || undefined,
29
+ cwd: m.cwd || undefined,
30
+ parked: m.parked === true || undefined,
31
+ archived: m.archived === true || undefined
32
+ };
33
+ }
34
+ return {
35
+ at: at || new Date().toISOString(),
36
+ reason: reason || '',
37
+ folders: folders || {},
38
+ sessions
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Which sessions to re-file, given the current metas and the newest
44
+ * snapshot: those whose meta has no folderId key at all (never filed, or
45
+ * lost) but which the snapshot remembers in a folder that still exists.
46
+ * A meta that says `folderId: null` was unfiled on purpose and is left
47
+ * alone; one that already names a folder is not second-guessed.
48
+ */
49
+ function planRefiles(metas, snapshot, folders) {
50
+ if (!snapshot || !snapshot.sessions) return [];
51
+ const out = [];
52
+ for (const m of metas || []) {
53
+ if (!m || !m.internalName) continue;
54
+ if (Object.prototype.hasOwnProperty.call(m, 'folderId')) continue;
55
+ const remembered = snapshot.sessions[m.internalName];
56
+ const folderId = remembered && remembered.folderId;
57
+ if (!folderId) continue;
58
+ if (!folders || !folders[folderId]) continue;
59
+ out.push({ internalName: m.internalName, folderId, displayName: m.displayName || remembered.displayName || m.internalName });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Snapshot file names sort by time; keep the newest KEEP. */
65
+ function pruneList(names, keep = KEEP) {
66
+ const mine = (names || []).filter((n) => /^identity-.*\.json$/.test(n)).sort();
67
+ return mine.slice(0, Math.max(0, mine.length - keep));
68
+ }
69
+
70
+ function snapshotFileName(at) {
71
+ return `identity-${String(at || new Date().toISOString()).replace(/[:.]/g, '-')}.json`;
72
+ }
73
+
74
+ module.exports = { snapshotFromMetas, planRefiles, pruneList, snapshotFileName, KEEP };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.63",
3
+ "version": "0.11.64",
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": {
@@ -8,6 +8,9 @@
8
8
  //
9
9
  // Stop -> bump a per-turn completion counter at
10
10
  // <HOP_HOME>/claude-sessions/<HOP_SESSION>.turn
11
+ // UserPromptSubmit -> note that a turn just BEGAN, at
12
+ // <HOP_HOME>/claude-sessions/<HOP_SESSION>.prompt
13
+ // (with .turn this is how the wall colours a session "working")
11
14
  // ({ sessionId, count, at }). `count` increments once each time Claude
12
15
  // finishes a turn, giving a deterministic "agent is done with this turn"
13
16
  // signal a driver (the hop MCP) can wait on instead of scraping the screen.
@@ -190,6 +193,74 @@ function bumpTurn(dir, hopSession, payload) {
190
193
  } catch { /* ignore */ }
191
194
  }
192
195
 
196
+ // A turn just began: the human (or a driver) submitted a prompt. With the
197
+ // Stop record this brackets the turn, so the wall can colour a session
198
+ // "working" from the first second instead of guessing from output — and
199
+ // keeps colouring it through a long silent think. Overwritten each prompt.
200
+ function notePrompt(dir, hopSession, payload) {
201
+ const file = path.join(dir, `${hopSession}.prompt`);
202
+ const record = JSON.stringify({
203
+ sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
204
+ at: new Date().toISOString()
205
+ });
206
+ try { writeDurable(file, record); } catch { /* ignore */ }
207
+ }
208
+
209
+ // The agent is ASKING the human something — a permission prompt, an
210
+ // AskUserQuestion dialog, a plan awaiting approval — and will sit there until
211
+ // answered. Recorded per hop session as `<session>.ask` {count, at, type,
212
+ // message}: the daemon folds the count into bellSeq (always on — a question
213
+ // is worth a buzz by definition) and passes the message through, so the
214
+ // phone can say WHAT is being asked. Claude reports these two ways:
215
+ // the Notification event (permission_prompt / elicitation_dialog — the
216
+ // idle_prompt and auth_success types are not questions) and PreToolUse for
217
+ // the two tools that open a dialog (AskUserQuestion carries the question
218
+ // text itself; ExitPlanMode is a plan waiting for approval).
219
+ function bumpAsk(dir, hopSession, payload, event) {
220
+ let type = "", message = "";
221
+ if (event === "Notification") {
222
+ type = typeof payload.notification_type === "string" ? payload.notification_type : "notification";
223
+ if (type === "idle_prompt" || type === "auth_success") return;
224
+ message = typeof payload.message === "string" && payload.message
225
+ ? payload.message
226
+ : (typeof payload.title === "string" && payload.title ? payload.title : "needs your attention");
227
+ } else if (event === "PermissionRequest") {
228
+ // Codex (hooks.json): fires before Codex asks to approve a command or
229
+ // network access. tool_input.description is Codex's own one-line reason.
230
+ type = "permission";
231
+ const ti = payload.tool_input && typeof payload.tool_input === "object" ? payload.tool_input : {};
232
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
233
+ message = typeof ti.description === "string" && ti.description ? ti.description
234
+ : (typeof ti.command === "string" && ti.command ? `wants to run: ${ti.command}`
235
+ : (tool ? `needs your approval for ${tool}` : "needs your approval"));
236
+ } else if (event === "PreToolUse") {
237
+ const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
238
+ if (tool === "AskUserQuestion") {
239
+ type = "question";
240
+ const qs = Array.isArray(payload.tool_input?.questions) ? payload.tool_input.questions : [];
241
+ message = qs.map((q) => (q && typeof q.question === "string" ? q.question : ""))
242
+ .filter(Boolean).join(" · ") || "has a question for you";
243
+ } else if (tool === "ExitPlanMode") {
244
+ type = "plan";
245
+ message = "has a plan ready for your approval";
246
+ } else return;
247
+ } else return;
248
+ const file = path.join(dir, `${hopSession}.ask`);
249
+ let count = 0;
250
+ try {
251
+ const prev = JSON.parse(fs.readFileSync(file, "utf8"));
252
+ if (prev && Number.isInteger(prev.count) && prev.count >= 0) count = prev.count;
253
+ } catch { /* first ask */ }
254
+ try {
255
+ writeDurable(file, JSON.stringify({
256
+ sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
257
+ count: count + 1,
258
+ at: new Date().toISOString(),
259
+ type,
260
+ message: String(message).replace(/\s+/g, " ").trim().slice(0, 300)
261
+ }));
262
+ } catch { /* ignore */ }
263
+ }
193
264
  function main() {
194
265
  const hopSession = process.env.HOP_SESSION;
195
266
  if (!hopSession || !/^[A-Za-z0-9_-]+$/.test(hopSession)) return; // not a hop session
@@ -226,6 +297,10 @@ function main() {
226
297
  // signals a premature "turn done".
227
298
  if (event === "Stop") {
228
299
  bumpTurn(dir, hopSession, payload);
300
+ } else if (event === "UserPromptSubmit") {
301
+ notePrompt(dir, hopSession, payload);
302
+ } else if (event === "Notification" || event === "PreToolUse" || event === "PermissionRequest") {
303
+ bumpAsk(dir, hopSession, payload, event);
229
304
  } else {
230
305
  recordSession(dir, hopSession, payload, launchCmd); // SessionStart (default)
231
306
  }
@@ -0,0 +1,47 @@
1
+ // The wall's three agent colours — working / done / done-and-read — decided
2
+ // from the records hop keeps, the same way for the web and the phone.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { agentPhase } = require('../lib/agent-phase');
6
+
7
+ const NOW = 1_800_000_000_000;
8
+ const s = (o) => agentPhase({ agent: 'claude', now: NOW, ...o });
9
+
10
+ test('a plain shell has no phase', () => {
11
+ assert.deepEqual(agentPhase({ agent: null, turnAt: NOW - 1000, lastActivityAt: NOW, now: NOW }), { phase: null, turnSeen: true });
12
+ });
13
+
14
+ test('claude with the prompt hook: a prompt newer than the last turn end is WORKING while the screen moves', () => {
15
+ assert.equal(s({ turnAt: NOW - 60_000, promptAt: NOW - 5_000, lastActivityAt: NOW - 2_000 }).phase, 'working');
16
+ // Thinking for a while with a slow spinner still counts.
17
+ assert.equal(s({ turnAt: NOW - 60_000, promptAt: NOW - 30_000, lastActivityAt: NOW - 20_000 }).phase, 'working');
18
+ });
19
+
20
+ test('an interrupted prompt (dead quiet for long) is not working forever', () => {
21
+ const r = s({ turnAt: NOW - 600_000, promptAt: NOW - 300_000, lastActivityAt: NOW - 120_000, lastUserSeenAt: NOW - 100_000 });
22
+ assert.equal(r.phase, 'done');
23
+ assert.equal(r.turnSeen, true);
24
+ });
25
+
26
+ test('codex (no prompt hook): output flowing after the last turn ended is WORKING', () => {
27
+ assert.equal(agentPhase({ agent: 'codex', turnAt: NOW - 60_000, lastActivityAt: NOW - 3_000, now: NOW }).phase, 'working');
28
+ });
29
+
30
+ test('the final render right after the Stop hook is the turn\'s own output, not a new turn', () => {
31
+ assert.equal(s({ turnAt: NOW - 3_000, lastActivityAt: NOW - 2_200 }).phase, 'done');
32
+ });
33
+
34
+ test('quiet after a turn is DONE; unread until a user client has been on it since', () => {
35
+ assert.deepEqual(s({ turnAt: NOW - 60_000, lastActivityAt: NOW - 61_000, lastUserSeenAt: NOW - 120_000 }), { phase: 'done', turnSeen: false });
36
+ assert.deepEqual(s({ turnAt: NOW - 60_000, lastActivityAt: NOW - 61_000, lastUserSeenAt: NOW - 30_000 }), { phase: 'done', turnSeen: true });
37
+ assert.deepEqual(s({ turnAt: NOW - 60_000, lastActivityAt: NOW - 61_000, lastUserSeenAt: 0 }), { phase: 'done', turnSeen: false });
38
+ });
39
+
40
+ test('a user attached right now has read whatever just finished', () => {
41
+ assert.deepEqual(s({ turnAt: NOW - 1_000, lastActivityAt: NOW - 2_000, lastUserSeenAt: 0, userAttached: true }), { phase: 'done', turnSeen: true });
42
+ });
43
+
44
+ test('an agent session that has never completed a turn and is quiet has no phase', () => {
45
+ assert.equal(s({ turnAt: 0, lastActivityAt: NOW - 60_000 }).phase, null);
46
+ assert.equal(s({ turnAt: 0, lastActivityAt: NOW - 1_000 }).phase, 'working');
47
+ });
@@ -192,3 +192,14 @@ test('outside hop the hook is a no-op', () => {
192
192
  });
193
193
  assert.equal(fs.readdirSync(path.join(home, 'claude-sessions')).length, 0);
194
194
  });
195
+
196
+ test('a submitted prompt is noted as the start of a turn, and a headless helper\'s is not', () => {
197
+ const home = freshHome();
198
+ runHook(home, 'alpha', { hook_event_name: 'UserPromptSubmit', session_id: 'sess-1', prompt: 'do the thing' });
199
+ const rec = JSON.parse(fs.readFileSync(path.join(home, 'claude-sessions', 'alpha.prompt'), 'utf8'));
200
+ assert.equal(rec.sessionId, 'sess-1');
201
+ assert.ok(Number.isFinite(Date.parse(rec.at)));
202
+ // A nested claude (its env carries the parent's id) never marks the terminal's turn.
203
+ runHook(home, 'beta', { hook_event_name: 'UserPromptSubmit', session_id: 'child-1' }, { CLAUDE_CODE_SESSION_ID: 'parent-1' });
204
+ assert.equal(fs.existsSync(path.join(home, 'claude-sessions', 'beta.prompt')), false);
205
+ });
@@ -0,0 +1,52 @@
1
+ // The second copy of the user's filing, and how restore uses it.
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const { snapshotFromMetas, planRefiles, pruneList, snapshotFileName } = require('../lib/identity-snapshot');
5
+
6
+ const folders = { f_a: { id: 'f_a', name: 'Research' }, f_b: { id: 'f_b', name: 'Softwares' } };
7
+
8
+ test('a snapshot keeps name, folder and origin per session, and the folders themselves', () => {
9
+ const snap = snapshotFromMetas([
10
+ { internalName: 's_1', displayName: 'Aurora', folderId: 'f_a', createdBy: 'user', cwd: '/w' },
11
+ { internalName: 's_2', displayName: 'Lyra' },
12
+ { internalName: '' }
13
+ ], folders, { at: '2026-09-29T00:00:00.000Z', reason: 'start' });
14
+ assert.equal(snap.reason, 'start');
15
+ assert.deepEqual(Object.keys(snap.sessions), ['s_1', 's_2']);
16
+ assert.equal(snap.sessions.s_1.folderId, 'f_a');
17
+ assert.equal(snap.sessions.s_2.folderId, null);
18
+ assert.equal(snap.folders.f_b.name, 'Softwares');
19
+ });
20
+
21
+ test('a session whose meta forgot its folder is re-filed from the snapshot', () => {
22
+ const snap = snapshotFromMetas([{ internalName: 's_1', displayName: 'Aurora', folderId: 'f_a' }], folders);
23
+ const plan = planRefiles([{ internalName: 's_1', displayName: 'Aurora', cwd: '/w' }], snap, folders);
24
+ assert.deepEqual(plan, [{ internalName: 's_1', folderId: 'f_a', displayName: 'Aurora' }]);
25
+ });
26
+
27
+ test('unfiled on purpose (explicit null) and already-filed metas are left alone', () => {
28
+ const snap = snapshotFromMetas([
29
+ { internalName: 's_1', folderId: 'f_a' },
30
+ { internalName: 's_2', folderId: 'f_a' }
31
+ ], folders);
32
+ const plan = planRefiles([
33
+ { internalName: 's_1', folderId: null },
34
+ { internalName: 's_2', folderId: 'f_b' }
35
+ ], snap, folders);
36
+ assert.deepEqual(plan, []);
37
+ });
38
+
39
+ test('a folder that no longer exists, or a session the snapshot never filed, is not re-filed', () => {
40
+ const snap = snapshotFromMetas([
41
+ { internalName: 's_1', folderId: 'f_gone' },
42
+ { internalName: 's_2' }
43
+ ], folders);
44
+ assert.deepEqual(planRefiles([{ internalName: 's_1' }, { internalName: 's_2' }, { internalName: 's_3' }], snap, folders), []);
45
+ assert.deepEqual(planRefiles([{ internalName: 's_1' }], null, folders), []);
46
+ });
47
+
48
+ test('snapshot files sort by time and the oldest are pruned', () => {
49
+ const names = ['identity-2026-09-01T00-00-00-000Z.json', 'identity-2026-09-03T00-00-00-000Z.json', 'other.json', 'identity-2026-09-02T00-00-00-000Z.json'];
50
+ assert.deepEqual(pruneList(names, 2), ['identity-2026-09-01T00-00-00-000Z.json']);
51
+ assert.equal(snapshotFileName('2026-09-29T01:02:03.004Z'), 'identity-2026-09-29T01-02-03-004Z.json');
52
+ });