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.
- package/hay/apps/web/src/App.tsx +10 -4
- package/hay/apps/web/src/components/SessionSwitcher.tsx +63 -12
- package/hay/apps/web/src/components/ViewsPanel.tsx +4 -2
- package/hay/apps/web/src/styles.css +53 -0
- package/hay/apps/web/src/utils/agentPhase.ts +26 -0
- package/hay/apps/web/src/utils/sizeClaim.ts +33 -0
- package/hay/apps/web/src/utils/switcherModel.ts +3 -0
- package/hay/apps/web/src/utils/voiceHold.ts +50 -13
- package/hay/apps/web/test/agentPhase.test.ts +22 -0
- package/hay/apps/web/test/sizeClaim.test.ts +24 -1
- package/hay/apps/web/test/voiceHold.test.ts +31 -1
- package/hop +97 -2
- package/lib/agent-phase.js +57 -0
- package/lib/identity-snapshot.js +74 -0
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +75 -0
- package/tests/agent-phase.unit.test.cjs +47 -0
- package/tests/claude-session-hook.unit.test.cjs +11 -0
- package/tests/identity-snapshot.unit.test.cjs +52 -0
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
// the
|
|
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
@@ -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
|
+
});
|