hop2 0.11.63 → 0.11.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hay/apps/web/src/App.tsx +34 -4
- package/hay/apps/web/src/components/SecondaryPane.tsx +10 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +74 -12
- package/hay/apps/web/src/components/ViewsPanel.tsx +4 -2
- package/hay/apps/web/src/styles.css +73 -4
- package/hay/apps/web/src/utils/agentPhase.ts +26 -0
- package/hay/apps/web/src/utils/dragSelect.ts +100 -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/dragSelect.test.ts +87 -0
- package/hay/apps/web/test/sizeClaim.test.ts +24 -1
- package/hay/apps/web/test/voiceHold.test.ts +31 -1
- package/hop +149 -8
- package/lib/agent-phase.js +67 -0
- package/lib/codex-identity.js +48 -0
- package/lib/identity-snapshot.js +74 -0
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +75 -0
- package/scripts/codex-notify-hook.js +34 -9
- package/tests/agent-phase.unit.test.cjs +64 -0
- package/tests/claude-session-hook.unit.test.cjs +11 -0
- package/tests/codex-identity.unit.test.cjs +32 -0
- package/tests/codex-notify-hook.unit.test.cjs +47 -0
- package/tests/identity-snapshot.unit.test.cjs +52 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { installDragSelect, shouldSelectLocally, type TrackingTerminal } from "../src/utils/dragSelect";
|
|
3
|
+
|
|
4
|
+
const fakeTerminal = (mode: string, protocol: string) => {
|
|
5
|
+
const mouse = { activeProtocol: protocol };
|
|
6
|
+
const subs = new Set<() => void>();
|
|
7
|
+
const t = {
|
|
8
|
+
modes: { mouseTrackingMode: mode },
|
|
9
|
+
_core: { coreMouseService: mouse },
|
|
10
|
+
mouse,
|
|
11
|
+
selected: false,
|
|
12
|
+
hasSelection: () => t.selected,
|
|
13
|
+
onSelectionChange: (cb: () => void) => { subs.add(cb); return { dispose: () => subs.delete(cb) }; },
|
|
14
|
+
setSelected: (v: boolean) => { t.selected = v; for (const cb of subs) cb(); }
|
|
15
|
+
};
|
|
16
|
+
return t as TrackingTerminal & typeof t;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("shouldSelectLocally — a plain drag on an agent surface selects, tracking or not", () => {
|
|
20
|
+
const base = { button: 0, metaKey: false, ctrlKey: false, trackingMode: "drag", agentSurface: true };
|
|
21
|
+
it("yes for a plain left press while Claude/Codex track the mouse", () => {
|
|
22
|
+
expect(shouldSelectLocally(base)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
it("no when the app is not an agent (vim, htop, tmux keep their drags)", () => {
|
|
25
|
+
expect(shouldSelectLocally({ ...base, agentSurface: false })).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it("no when tracking is already off (xterm selects by itself), or for right/ctrl/cmd presses", () => {
|
|
28
|
+
expect(shouldSelectLocally({ ...base, trackingMode: "none" })).toBe(false);
|
|
29
|
+
expect(shouldSelectLocally({ ...base, button: 2 })).toBe(false);
|
|
30
|
+
expect(shouldSelectLocally({ ...base, ctrlKey: true })).toBe(false);
|
|
31
|
+
expect(shouldSelectLocally({ ...base, metaKey: true })).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("installDragSelect — tracking pauses for the drag and stays paused while the selection stands", () => {
|
|
36
|
+
const press = (el: HTMLElement) => el.dispatchEvent(new MouseEvent("mousedown", { button: 0, bubbles: true }));
|
|
37
|
+
const release = () => window.dispatchEvent(new MouseEvent("mouseup", { button: 0, bubbles: true }));
|
|
38
|
+
it("a drag that selected nothing hands the mouse back on release", () => {
|
|
39
|
+
const t = fakeTerminal("drag", "DRAG");
|
|
40
|
+
const el = document.createElement("div"); document.body.appendChild(el);
|
|
41
|
+
const off = installDragSelect(t, el, () => true);
|
|
42
|
+
press(el);
|
|
43
|
+
expect(t.mouse.activeProtocol).toBe("NONE");
|
|
44
|
+
release();
|
|
45
|
+
expect(t.mouse.activeProtocol).toBe("DRAG");
|
|
46
|
+
off();
|
|
47
|
+
});
|
|
48
|
+
it("a drag that selected text keeps tracking off until the selection clears — so ⌘C still has it", () => {
|
|
49
|
+
const t = fakeTerminal("drag", "DRAG");
|
|
50
|
+
const el = document.createElement("div"); document.body.appendChild(el);
|
|
51
|
+
const off = installDragSelect(t, el, () => true);
|
|
52
|
+
press(el); t.setSelected(true); release();
|
|
53
|
+
expect(t.mouse.activeProtocol).toBe("NONE");
|
|
54
|
+
// A second selecting press replaces the selection, still paused.
|
|
55
|
+
press(el); t.setSelected(true); release();
|
|
56
|
+
expect(t.mouse.activeProtocol).toBe("NONE");
|
|
57
|
+
// A keypress clears the selection (xterm does this): tracking returns.
|
|
58
|
+
t.setSelected(false);
|
|
59
|
+
expect(t.mouse.activeProtocol).toBe("DRAG");
|
|
60
|
+
off();
|
|
61
|
+
});
|
|
62
|
+
it("leaves a non-agent surface alone, and respects the app turning tracking off mid-drag", () => {
|
|
63
|
+
const t = fakeTerminal("drag", "DRAG");
|
|
64
|
+
const el = document.createElement("div"); document.body.appendChild(el);
|
|
65
|
+
let agent = false;
|
|
66
|
+
const off = installDragSelect(t, el, () => agent);
|
|
67
|
+
press(el);
|
|
68
|
+
expect(t.mouse.activeProtocol).toBe("DRAG");
|
|
69
|
+
release();
|
|
70
|
+
agent = true;
|
|
71
|
+
press(el);
|
|
72
|
+
expect(t.mouse.activeProtocol).toBe("NONE");
|
|
73
|
+
t.mouse.activeProtocol = "ANY"; // the app changed its mind during the drag
|
|
74
|
+
release();
|
|
75
|
+
expect(t.mouse.activeProtocol).toBe("ANY");
|
|
76
|
+
off();
|
|
77
|
+
});
|
|
78
|
+
it("a right or ctrl press while paused hands the mouse back first", () => {
|
|
79
|
+
const t = fakeTerminal("drag", "DRAG");
|
|
80
|
+
const el = document.createElement("div"); document.body.appendChild(el);
|
|
81
|
+
const off = installDragSelect(t, el, () => true);
|
|
82
|
+
press(el); t.setSelected(true); release();
|
|
83
|
+
el.dispatchEvent(new MouseEvent("mousedown", { button: 2, bubbles: true }));
|
|
84
|
+
expect(t.mouse.activeProtocol).toBe("DRAG");
|
|
85
|
+
off();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -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,9 @@ const WebSocket = require('ws');
|
|
|
18
18
|
const { resolveActorFromHeaders, canActorAccessSession } = require('./lib/terminal-access');
|
|
19
19
|
const { generateSessionName, getThemeChoices, isValidTheme, DEFAULT_THEME } = require('./lib/naming-themes');
|
|
20
20
|
const { AuthSessionStore, isSessionToken: isAuthSessionToken } = require('./lib/auth-sessions');
|
|
21
|
+
const { agentPhase, screenSaysWorking } = require('./lib/agent-phase');
|
|
22
|
+
const { snapshotFromMetas, planRefiles, pruneList, snapshotFileName } = require('./lib/identity-snapshot');
|
|
23
|
+
const { withCodexIdentity } = require('./lib/codex-identity');
|
|
21
24
|
const { rotateAuditFile, sweepStaleAuditDirs } = require('./lib/audit-rotation');
|
|
22
25
|
const {
|
|
23
26
|
createFailureTracker,
|
|
@@ -5357,6 +5360,65 @@ function readTurnAt(internalName) {
|
|
|
5357
5360
|
return Number.isFinite(at) ? at : 0;
|
|
5358
5361
|
} catch (e) { return 0; }
|
|
5359
5362
|
}
|
|
5363
|
+
// When the agent's current turn BEGAN (Claude's UserPromptSubmit hook; Codex
|
|
5364
|
+
// has no equivalent, so 0 there). Paired with readTurnAt by agentPhase().
|
|
5365
|
+
function readPromptAt(internalName) {
|
|
5366
|
+
try {
|
|
5367
|
+
const at = Date.parse(JSON.parse(fs.readFileSync(
|
|
5368
|
+
path.join(CLAUDE_SESSIONS_DIR, `${internalName}.prompt`), 'utf8')).at);
|
|
5369
|
+
return Number.isFinite(at) ? at : 0;
|
|
5370
|
+
} catch (e) { return 0; }
|
|
5371
|
+
}
|
|
5372
|
+
// The wall's three agent colours, decided here so the web and the phone
|
|
5373
|
+
// agree: working / done-unread / done-read. Plain shells get null.
|
|
5374
|
+
function agentPhaseFields(s, screenWorking = null) {
|
|
5375
|
+
if (!s.agent) return { agentPhase: null, turnSeen: true, turnAt: null };
|
|
5376
|
+
const turnAt = readTurnAt(s.internalName);
|
|
5377
|
+
const r = agentPhase({
|
|
5378
|
+
agent: s.agent,
|
|
5379
|
+
turnAt,
|
|
5380
|
+
promptAt: readPromptAt(s.internalName),
|
|
5381
|
+
lastActivityAt: Number(s.lastActivityAt) || 0,
|
|
5382
|
+
lastUserSeenAt: Number(s.lastUserSeenAt) || 0,
|
|
5383
|
+
userAttached: s.userAttached === true,
|
|
5384
|
+
screenWorking
|
|
5385
|
+
});
|
|
5386
|
+
return { agentPhase: r.phase, turnSeen: r.turnSeen, turnAt: turnAt || null };
|
|
5387
|
+
}
|
|
5388
|
+
// The screen's own word on whether a turn is running ("esc to interrupt" in
|
|
5389
|
+
// both TUIs), read from the host's live grid. Asked only for the sessions
|
|
5390
|
+
// the timing rules call working — the small set that can be wrong that way
|
|
5391
|
+
// (Codex repaints while idle: rotating tips, the user typing) — and memoised
|
|
5392
|
+
// for a few seconds so every client's poll shares one read per session.
|
|
5393
|
+
const screenWorkingMemo = new Map(); // internalName -> { t, p: Promise<boolean|null> }
|
|
5394
|
+
function screenWorkingOf(internalName) {
|
|
5395
|
+
const now = Date.now();
|
|
5396
|
+
const hit = screenWorkingMemo.get(internalName);
|
|
5397
|
+
if (hit && now - hit.t < 4000) return hit.p;
|
|
5398
|
+
const p = (async () => {
|
|
5399
|
+
try {
|
|
5400
|
+
const hostRes = await callExternalHayHostApi(`/rooms/${encodeURIComponent(internalName)}/serialized`);
|
|
5401
|
+
const raw = hostRes?.data?.data;
|
|
5402
|
+
if (typeof raw !== 'string' || !raw) return null;
|
|
5403
|
+
return screenSaysWorking(stripAnsiForSearch(raw));
|
|
5404
|
+
} catch (e) { return null; }
|
|
5405
|
+
})();
|
|
5406
|
+
screenWorkingMemo.set(internalName, { t: now, p });
|
|
5407
|
+
if (screenWorkingMemo.size > 500) screenWorkingMemo.clear();
|
|
5408
|
+
return p;
|
|
5409
|
+
}
|
|
5410
|
+
// Phase for every entry of a list, the screen consulted where it matters.
|
|
5411
|
+
async function agentPhaseFieldsForAll(entries) {
|
|
5412
|
+
const verdicts = new Map();
|
|
5413
|
+
await Promise.all(entries.map(async (s) => {
|
|
5414
|
+
if (!s.agent) return;
|
|
5415
|
+
if (agentPhaseFields(s).agentPhase !== 'working') return;
|
|
5416
|
+
verdicts.set(s.internalName, await screenWorkingOf(s.internalName));
|
|
5417
|
+
}));
|
|
5418
|
+
const out = new Map();
|
|
5419
|
+
for (const s of entries) out.set(s.internalName, agentPhaseFields(s, verdicts.has(s.internalName) ? verdicts.get(s.internalName) : null));
|
|
5420
|
+
return out;
|
|
5421
|
+
}
|
|
5360
5422
|
// The newest published view's time (ms) for a session, from the manifest
|
|
5361
5423
|
// the views summary already reads. `hop view` rings one bell per publish.
|
|
5362
5424
|
function latestViewAtMs(internalName) {
|
|
@@ -5708,6 +5770,55 @@ function listClaudeSessionMetas() {
|
|
|
5708
5770
|
return out;
|
|
5709
5771
|
}
|
|
5710
5772
|
|
|
5773
|
+
// ---- Identity snapshots (lib/identity-snapshot.js) -------------------------
|
|
5774
|
+
// A second copy of the durable metas' user-authored fields — above all which
|
|
5775
|
+
// FOLDER each session is filed in — written at start, at stop and hourly.
|
|
5776
|
+
// The 2026-09-26 `stop --all` deleted every live session's records and the
|
|
5777
|
+
// rebuilt metas had no folder to go back to; the snapshot is what makes that
|
|
5778
|
+
// recoverable next time. restore re-files from the newest one.
|
|
5779
|
+
const IDENTITY_SNAPSHOT_DIR = path.join(HOME_DIR, 'restore-snapshots');
|
|
5780
|
+
function writeIdentitySnapshot(reason) {
|
|
5781
|
+
try {
|
|
5782
|
+
const snap = snapshotFromMetas(listClaudeSessionMetas(), sessionStore.folders, { reason });
|
|
5783
|
+
fs.mkdirSync(IDENTITY_SNAPSHOT_DIR, { recursive: true, mode: 0o700 });
|
|
5784
|
+
const file = path.join(IDENTITY_SNAPSHOT_DIR, snapshotFileName(snap.at));
|
|
5785
|
+
writePrivateFile(file, JSON.stringify(snap, null, 1), 0o600);
|
|
5786
|
+
for (const old of pruneList(fs.readdirSync(IDENTITY_SNAPSHOT_DIR))) {
|
|
5787
|
+
try { fs.unlinkSync(path.join(IDENTITY_SNAPSHOT_DIR, old)); } catch (e) { /* best effort */ }
|
|
5788
|
+
}
|
|
5789
|
+
return file;
|
|
5790
|
+
} catch (e) {
|
|
5791
|
+
log(`[snapshot] could not write identity snapshot: ${e?.message || e}`, DIM);
|
|
5792
|
+
return null;
|
|
5793
|
+
}
|
|
5794
|
+
}
|
|
5795
|
+
function latestIdentitySnapshot() {
|
|
5796
|
+
try {
|
|
5797
|
+
const names = fs.readdirSync(IDENTITY_SNAPSHOT_DIR).filter((n) => /^identity-.*\.json$/.test(n)).sort();
|
|
5798
|
+
if (!names.length) return null;
|
|
5799
|
+
const file = path.join(IDENTITY_SNAPSHOT_DIR, names[names.length - 1]);
|
|
5800
|
+
return { file, snapshot: JSON.parse(fs.readFileSync(file, 'utf8')) };
|
|
5801
|
+
} catch (e) { return null; }
|
|
5802
|
+
}
|
|
5803
|
+
// Sessions whose meta has no folder at all but which the newest snapshot
|
|
5804
|
+
// remembers in a folder that still exists get their folder back. An
|
|
5805
|
+
// explicit `folderId: null` (unfiled on purpose) is respected.
|
|
5806
|
+
function refileFromIdentitySnapshot({ dryRun = false } = {}) {
|
|
5807
|
+
const latest = latestIdentitySnapshot();
|
|
5808
|
+
if (!latest) return [];
|
|
5809
|
+
const plan = planRefiles(listClaudeSessionMetas(), latest.snapshot, sessionStore.folders);
|
|
5810
|
+
for (const item of plan) {
|
|
5811
|
+
const folderName = sessionStore.folders[item.folderId]?.name || item.folderId;
|
|
5812
|
+
if (dryRun) { log(` would re-file ${item.displayName} → ${folderName} (from ${path.basename(latest.file)})`, DIM); continue; }
|
|
5813
|
+
const durable = readClaudeSessionMeta(item.internalName) || {};
|
|
5814
|
+
writeClaudeSessionMeta(item.internalName, { ...durable, internalName: item.internalName, folderId: item.folderId });
|
|
5815
|
+
rememberRuntimeSessionMetadata(item.internalName, { ...(getRuntimeSessionMetadata(item.internalName) || {}), folderId: item.folderId });
|
|
5816
|
+
if (sessionStore.sessions[item.internalName]) moveSessionToFolder(item.internalName, item.folderId);
|
|
5817
|
+
log(` re-filed ${item.displayName} → ${folderName} (from ${path.basename(latest.file)})`, DIM);
|
|
5818
|
+
}
|
|
5819
|
+
return plan;
|
|
5820
|
+
}
|
|
5821
|
+
|
|
5711
5822
|
// Claude Code slugifies a project cwd for <config-root>/projects/<slug>/.
|
|
5712
5823
|
// The rule has changed across versions (older: every non-alphanumeric -> '-';
|
|
5713
5824
|
// newer builds preserve spaces: "nebula-notebook 2" keeps its space), so
|
|
@@ -6349,7 +6460,8 @@ async function sweepRestoreReconcile() {
|
|
|
6349
6460
|
if (!/[%$#\u276f>]\s*$/.test(last)) continue; // not an idle prompt yet
|
|
6350
6461
|
try {
|
|
6351
6462
|
const e2 = await attachTerminalApiClient(name, exp.displayName, { actor: 'system', label: 'reconcile' });
|
|
6352
|
-
const
|
|
6463
|
+
const pinned = withCodexIdentity(exp.command, name);
|
|
6464
|
+
const cmd = pinned.endsWith('\n') ? pinned : `${pinned}\n`;
|
|
6353
6465
|
try { sendTerminalInput(e2, cmd); } finally { try { closeTerminalApiEntry(e2.id); } catch (e) {} }
|
|
6354
6466
|
exp.redrives += 1;
|
|
6355
6467
|
exp.lastDriveAt = now;
|
|
@@ -6448,6 +6560,11 @@ function resolveRestoreTargets(names, snapshot) {
|
|
|
6448
6560
|
return { resolved, unknown };
|
|
6449
6561
|
}
|
|
6450
6562
|
async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = false, only = null } = {}) {
|
|
6563
|
+
// Folders first: a session whose meta lost its folder (records deleted,
|
|
6564
|
+
// meta rebuilt) is put back where the newest identity snapshot says it
|
|
6565
|
+
// was, so the restored wall is filed the way the user left it.
|
|
6566
|
+
const refiled = refileFromIdentitySnapshot({ dryRun });
|
|
6567
|
+
if (refiled.length) log(`${dryRun ? 'Would re-file' : 'Re-filed'} ${refiled.length} session(s) into their folders from the identity snapshot.`, DIM);
|
|
6451
6568
|
const snapshot = await listAllSessionEntriesRuntimeFirst();
|
|
6452
6569
|
const live = new Set(snapshot.entries.filter((e) => e.live).map((e) => e.internalName));
|
|
6453
6570
|
// Targeted redo (`hop restore <name>...`): the named sessions are planned
|
|
@@ -6702,7 +6819,11 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6702
6819
|
// A fresh shell reports its own name once it has execed; that
|
|
6703
6820
|
// is our prompt-ready signal, and it beats guessing with a timer.
|
|
6704
6821
|
if (Date.now() - job.since < 400) continue;
|
|
6705
|
-
|
|
6822
|
+
// A codex launch carries its hop session id in the thread's own
|
|
6823
|
+
// config (lib/codex-identity.js): Codex's shared daemon would
|
|
6824
|
+
// otherwise hand every session the FIRST session's identity.
|
|
6825
|
+
const pinned = withCodexIdentity(job.plan.command, job.internalName);
|
|
6826
|
+
const cmd = pinned.endsWith('\n') ? pinned : `${pinned}\n`;
|
|
6706
6827
|
try { sendTerminalInput(job.entry, cmd); } catch (e) { finish('write-failed'); continue; }
|
|
6707
6828
|
job.attempts += 1;
|
|
6708
6829
|
job.state = 'verify';
|
|
@@ -6822,7 +6943,9 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6822
6943
|
// The same script is registered for two events: SessionStart (records the
|
|
6823
6944
|
// conversation id for `hop restore`) and Stop (bumps a per-turn counter a driver
|
|
6824
6945
|
// can wait on). See scripts/claude-session-hook.js.
|
|
6825
|
-
|
|
6946
|
+
// UserPromptSubmit marks the START of a turn (with Stop's end, the wall
|
|
6947
|
+
// colours the session "working" / "done" — see lib/agent-phase.js).
|
|
6948
|
+
const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop', 'Notification', 'PreToolUse', 'UserPromptSubmit'];
|
|
6826
6949
|
// Notification (permission prompts, elicitation) and PreToolUse (the two tools
|
|
6827
6950
|
// that open a dialog and wait) bump the per-session ASK record — "whenever an
|
|
6828
6951
|
// agent asks me a question, notify me" (Jian). PreToolUse gets a matcher, or
|
|
@@ -9492,7 +9615,7 @@ async function ensureExternalRuntimeSession(sessionName, cwd, displayName, opts
|
|
|
9492
9615
|
// Launch the room ON the relaunch command instead of typing it after the
|
|
9493
9616
|
// shell is up: no prompt to race, nothing half-typed, no command echo.
|
|
9494
9617
|
if (typeof opts.command === 'string' && opts.command) {
|
|
9495
|
-
roomBody.command = opts.command;
|
|
9618
|
+
roomBody.command = withCodexIdentity(opts.command, sessionName);
|
|
9496
9619
|
}
|
|
9497
9620
|
const response = await callExternalHayHostApi('/rooms', 'POST', roomBody, 2500);
|
|
9498
9621
|
if (!response || response.status < 200 || response.status >= 300 || !response.data?.ok) {
|
|
@@ -11629,6 +11752,7 @@ happens until someone clicks it.
|
|
|
11629
11752
|
// compares latestAt against its own last-seen mark. No new state,
|
|
11630
11753
|
// no new poll, and the badge works the same everywhere.
|
|
11631
11754
|
const viewsBySession = viewSummaryBySession();
|
|
11755
|
+
const phaseBySession = await agentPhaseFieldsForAll(visibleEntries);
|
|
11632
11756
|
const sessions = visibleEntries.map(s => ({
|
|
11633
11757
|
views: viewsBySession[s.internalName] || undefined,
|
|
11634
11758
|
name: s.displayName,
|
|
@@ -11644,6 +11768,9 @@ happens until someone clicks it.
|
|
|
11644
11768
|
cwd: s.cwd,
|
|
11645
11769
|
foregroundProcess: s.foregroundProcess,
|
|
11646
11770
|
agent: s.agent || null,
|
|
11771
|
+
// working / done + whether the human has opened it since the
|
|
11772
|
+
// turn ended (server-side seen witness, so every device agrees).
|
|
11773
|
+
...(phaseBySession.get(s.internalName) || { agentPhase: null, turnSeen: true, turnAt: null }),
|
|
11647
11774
|
lastActivityAt: s.lastActivityAt,
|
|
11648
11775
|
// For sessions armed with notify-on-finish, each completed turn
|
|
11649
11776
|
// adds to bellSeq so the phone treats a finished response like a
|
|
@@ -12570,13 +12697,16 @@ happens until someone clicks it.
|
|
|
12570
12697
|
req.on('end', () => {
|
|
12571
12698
|
try {
|
|
12572
12699
|
const { name, internalName, folderId, index } = JSON.parse(body);
|
|
12700
|
+
// Unfiling is recorded as an explicit null: a meta with
|
|
12701
|
+
// NO folderId key is one that may have LOST it, and the
|
|
12702
|
+
// identity snapshot re-files those on restore.
|
|
12573
12703
|
const persistMembership = (target) => {
|
|
12574
12704
|
if (!target) return;
|
|
12575
12705
|
const durable = readClaudeSessionMeta(target) || {};
|
|
12576
12706
|
writeClaudeSessionMeta(target, {
|
|
12577
12707
|
...durable,
|
|
12578
12708
|
internalName: target,
|
|
12579
|
-
folderId: folderId ||
|
|
12709
|
+
folderId: folderId || null
|
|
12580
12710
|
});
|
|
12581
12711
|
};
|
|
12582
12712
|
// Resolve like every other session endpoint: a live
|
|
@@ -13020,7 +13150,8 @@ happens until someone clicks it.
|
|
|
13020
13150
|
});
|
|
13021
13151
|
|
|
13022
13152
|
if (startup && autoStart !== false) {
|
|
13023
|
-
|
|
13153
|
+
const pinnedStartup = withCodexIdentity(startup, internalName);
|
|
13154
|
+
sendTerminalInput(entry, pinnedStartup.endsWith('\n') ? pinnedStartup : `${pinnedStartup}\n`);
|
|
13024
13155
|
}
|
|
13025
13156
|
|
|
13026
13157
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -13218,7 +13349,7 @@ happens until someone clicks it.
|
|
|
13218
13349
|
res.end(JSON.stringify({ error: 'Missing command' }));
|
|
13219
13350
|
return;
|
|
13220
13351
|
}
|
|
13221
|
-
const line = String(command);
|
|
13352
|
+
const line = withCodexIdentity(String(command), entry.sessionName);
|
|
13222
13353
|
sendTerminalInput(entry, line.endsWith('\n') ? line : `${line}\n`);
|
|
13223
13354
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13224
13355
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -16563,7 +16694,7 @@ async function main() {
|
|
|
16563
16694
|
log(`${dryRun ? 'Would recover' : 'Recovering'} ${BOLD}${display}${RESET} (${kind}) in this terminal:`, RESET);
|
|
16564
16695
|
log(` ${DIM}${command}${RESET}`, RESET);
|
|
16565
16696
|
if (dryRun) process.exit(0);
|
|
16566
|
-
const child = spawn('/bin/zsh', ['-lc', command], { stdio: 'inherit', cwd: plan?.cwd || cwd, env: process.env });
|
|
16697
|
+
const child = spawn('/bin/zsh', ['-lc', withCodexIdentity(command, target)], { stdio: 'inherit', cwd: plan?.cwd || cwd, env: process.env });
|
|
16567
16698
|
child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
|
|
16568
16699
|
return;
|
|
16569
16700
|
}
|
|
@@ -17392,6 +17523,9 @@ async function main() {
|
|
|
17392
17523
|
} else {
|
|
17393
17524
|
console.log(`View: ${proxyUrl}`);
|
|
17394
17525
|
}
|
|
17526
|
+
// Name the session it was filed under, so a wrong one is visible at
|
|
17527
|
+
// once (an inherited HOP_SESSION can belong to another session).
|
|
17528
|
+
console.log(`Filed under ${getSessionDisplayName(session) || session}${wantSession ? '' : ' (from HOP_SESSION; pass --session <name> to file elsewhere)'}.`);
|
|
17395
17529
|
// Same one-bell convention as `hop view` — the human is usually
|
|
17396
17530
|
// waiting on this, and the link scrolls away unannounced otherwise.
|
|
17397
17531
|
process.stdout.write('\x07');
|
|
@@ -19317,6 +19451,12 @@ async function main() {
|
|
|
19317
19451
|
// per file, and a no-op when the folder doesn't exist yet.
|
|
19318
19452
|
setTimeout(enforceUploadBudget, 20000).unref?.();
|
|
19319
19453
|
setInterval(enforceUploadBudget, 3600000).unref?.();
|
|
19454
|
+
|
|
19455
|
+
// Identity snapshot (folders, names, origins): once the boot settles,
|
|
19456
|
+
// then hourly. Cheap — one small file — and the reason restore can
|
|
19457
|
+
// put sessions back in their folders after their records are lost.
|
|
19458
|
+
setTimeout(() => writeIdentitySnapshot('start'), 30000).unref?.();
|
|
19459
|
+
setInterval(() => writeIdentitySnapshot('hourly'), 3600000).unref?.();
|
|
19320
19460
|
}
|
|
19321
19461
|
|
|
19322
19462
|
if (DAEMON_FLAG) {
|
|
@@ -19324,6 +19464,7 @@ async function main() {
|
|
|
19324
19464
|
if (shuttingDown) return;
|
|
19325
19465
|
shuttingDown = true;
|
|
19326
19466
|
log(`Cleaning up...`, YELLOW);
|
|
19467
|
+
writeIdentitySnapshot('stop');
|
|
19327
19468
|
|
|
19328
19469
|
stopTunnelHealthMonitor();
|
|
19329
19470
|
|