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.
@@ -35,6 +35,9 @@ export type SwitcherSession = {
35
35
  foregroundProcess?: string;
36
36
  /** What the session runs, by its hooks' records: claude, codex, or nothing known. */
37
37
  agent?: "claude" | "codex" | null;
38
+ /** Server-decided: a turn in flight / the last turn finished; turnSeen = a human opened it since. */
39
+ agentPhase?: "working" | "done" | null;
40
+ turnSeen?: boolean;
38
41
  agentPermitted?: boolean;
39
42
  cols?: number;
40
43
  rows?: number;
@@ -49,9 +49,26 @@ export type VoiceHoldOptions = {
49
49
  thresholdMs?: number;
50
50
  };
51
51
 
52
+ // A ring of what the controller saw and did — key events with their
53
+ // verdicts, recogniser lifecycle, why a hold ended. Read from the console
54
+ // (window.__hopVoiceTrace) or shipped with the client diagnostics, so a
55
+ // "dictation stopped and spaces started typing" report can be replayed
56
+ // instead of guessed at.
57
+ const TRACE_MAX = 240;
58
+ export const voiceTrace: string[] = [];
59
+ const trace = (line: string) => {
60
+ voiceTrace.push(`${new Date().toISOString().slice(11, 23)} ${line}`);
61
+ if (voiceTrace.length > TRACE_MAX) voiceTrace.splice(0, voiceTrace.length - TRACE_MAX);
62
+ };
63
+ if (typeof window !== "undefined") (window as unknown as { __hopVoiceTrace?: string[] }).__hopVoiceTrace = voiceTrace;
64
+
52
65
  export const createVoiceHold = (opts: VoiceHoldOptions) => {
53
66
  const threshold = opts.thresholdMs ?? 550;
54
67
  const MAX_HOLD_MS = 60000;
68
+ // A recogniser that ends this soon after starting, having heard nothing,
69
+ // was refused (no mic, no service), not silent: Chrome's own silence
70
+ // timeout is several seconds. Only refusals count toward giving up.
71
+ const REFUSED_MS = 1500;
55
72
  let timer = 0;
56
73
  let watchdog = 0;
57
74
  let active = false;
@@ -66,8 +83,9 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
66
83
  /** Consecutive recogniser restarts that produced nothing — a loop guard. */
67
84
  let emptyRestarts = 0;
68
85
 
69
- const finish = (sendText: boolean) => {
86
+ const finish = (sendText: boolean, why = "") => {
70
87
  if (!active) return;
88
+ trace(`finish send=${sendText} why=${why} final=${JSON.stringify(finalText.slice(-40))} interim=${JSON.stringify(interimText.slice(-40))}`);
71
89
  active = false;
72
90
  pending = false;
73
91
  stopping = false;
@@ -91,7 +109,8 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
91
109
  const Ctor = speechRecognitionCtor();
92
110
  // Silence here read as "voice randomly does nothing": the space vanished
93
111
  // (or didn't) and no overlay ever appeared, with no reason given.
94
- if (!Ctor) { opts.notify("Voice input is not supported in this browser"); return; }
112
+ if (!Ctor) { trace("start: no recogniser"); opts.notify("Voice input is not supported in this browser"); return; }
113
+ trace(`start spacesTyped=${spacesTyped}`);
95
114
  active = true;
96
115
  stopping = false;
97
116
  emptyRestarts = 0;
@@ -103,7 +122,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
103
122
  armWatchdog();
104
123
  window.addEventListener("blur", onWindowBlur);
105
124
  opts.setOverlay("");
106
- if (!listen(Ctor)) { opts.notify("Voice input could not start"); finish(false); }
125
+ if (!listen(Ctor)) { opts.notify("Voice input could not start"); finish(false, "listen-failed"); }
107
126
  };
108
127
 
109
128
  // One recogniser session. The hold outlives it: browser recognisers end
@@ -117,6 +136,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
117
136
  let r: RecognitionLike;
118
137
  try { r = new Ctor(); } catch { return false; }
119
138
  let heard = false;
139
+ const startedAt = Date.now();
120
140
  r.lang = navigator.language || "en-US";
121
141
  r.continuous = true;
122
142
  r.interimResults = true;
@@ -133,21 +153,35 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
133
153
  opts.setOverlay((finalText + interim).trim());
134
154
  };
135
155
  const ended = () => {
156
+ const age = Date.now() - startedAt;
157
+ trace(`rec end heard=${heard} age=${age}ms stopping=${stopping} current=${rec === r}`);
136
158
  if (!active || rec !== r) return; // an old recogniser, or already finished
137
- if (stopping) { finish(true); return; } // released: type what we have
159
+ if (stopping) { finish(true, "released"); return; } // released: type what we have
138
160
  // A finished phrase is folded in; the interim is gone with the session.
139
161
  interimText = "";
140
- // Three recognisers in a row that heard nothing is the service refusing
141
- // us, not silence — give the space bar back rather than spin forever.
142
- if (!heard && ++emptyRestarts >= 3) { finish(true); return; }
162
+ // Silence is not a reason to stop: Space is still down, and the hold
163
+ // ending on its own is exactly what "dictation just cut out and then
164
+ // spaces typed" looks like from the chair. Only a recogniser that is
165
+ // REFUSING us — ending at once, three times, having heard nothing —
166
+ // gives the space bar back rather than spin forever.
167
+ if (!heard && age < REFUSED_MS) {
168
+ if (++emptyRestarts >= 3) { finish(true, "refused"); return; }
169
+ } else {
170
+ emptyRestarts = 0;
171
+ }
143
172
  opts.setOverlay(finalText.trim());
144
- if (!listen(Ctor)) finish(true);
173
+ if (!listen(Ctor)) finish(true, "restart-failed");
145
174
  };
146
175
  r.onerror = (ev) => {
147
176
  const code = ev?.error || "unknown";
177
+ trace(`rec error ${code} stopping=${stopping} current=${rec === r}`);
178
+ // Our own stop() is finishing this hold with the words it has; an
179
+ // error the recogniser raises on the way out ("aborted") must not
180
+ // finish it again WITHOUT them.
181
+ if (stopping || rec !== r) return;
148
182
  // Quiet or interrupted while still held: not a failure, just this
149
183
  // recogniser's session ending — the onend that follows restarts it.
150
- if (active && !stopping && (code === "no-speech" || code === "aborted")) return;
184
+ if (active && (code === "no-speech" || code === "aborted")) return;
151
185
  // EVERY real failure explains itself. Only mic-denial was surfaced
152
186
  // before; Chrome's cloud recogniser routinely fails with "network",
153
187
  // and that silent death read as "voice randomly doesn't work".
@@ -160,7 +194,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
160
194
  } else if (code !== "aborted") {
161
195
  opts.notify(`Voice input failed: ${code}`);
162
196
  }
163
- finish(false);
197
+ finish(false, `error:${code}`);
164
198
  };
165
199
  r.onend = ended;
166
200
  rec = r;
@@ -170,6 +204,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
170
204
 
171
205
  const stop = () => {
172
206
  const r = rec;
207
+ trace("stop");
173
208
  stopping = true;
174
209
  try { r?.stop(); } catch { /* already gone */ }
175
210
  rec = null;
@@ -179,7 +214,7 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
179
214
  // true — and since every space is deliberately eaten while active, that
180
215
  // is a terminal that cannot type a space AT ALL. onend arriving later is
181
216
  // harmless; finish() is a no-op once inactive.
182
- finish(true);
217
+ finish(true, "stop");
183
218
  };
184
219
 
185
220
  // Last line of defence. Nothing should hold the microphone for a minute,
@@ -223,8 +258,10 @@ export const createVoiceHold = (opts: VoiceHoldOptions) => {
223
258
  }
224
259
  // A keypress carries no `code` in some engines; fall back to the key.
225
260
  const isSpace = ev.code === "Space" || (ev.type === "keypress" && ev.key === " ");
226
- if (isSpace && !ev.metaKey && !ev.ctrlKey && !ev.altKey && !ev.shiftKey
227
- && !!speechRecognitionCtor() && (active || pending || opts.eligible())) {
261
+ if (isSpace && !ev.metaKey && !ev.ctrlKey && !ev.altKey && !ev.shiftKey) {
262
+ const eligible = active || pending || opts.eligible();
263
+ trace(`space ${ev.type}${ev.repeat ? " repeat" : ""} active=${active} pending=${pending} eligible=${eligible}`);
264
+ if (!speechRecognitionCtor() || !eligible) return undefined;
228
265
  if (ev.type === "keydown") {
229
266
  if (active) {
230
267
  // A genuine auto-repeat is the only thing that may be eaten while
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { cardPhase, phaseLabel } from "../src/utils/agentPhase";
3
+
4
+ describe("cardPhase — the wall's three agent colours come from the daemon's verdict", () => {
5
+ it("working while a turn is in flight", () => {
6
+ expect(cardPhase({ agentPhase: "working", turnSeen: true })).toBe("working");
7
+ });
8
+ it("done and unread until a human has opened it since the turn ended", () => {
9
+ expect(cardPhase({ agentPhase: "done", turnSeen: false })).toBe("done-unread");
10
+ expect(cardPhase({ agentPhase: "done", turnSeen: true })).toBe("done-read");
11
+ });
12
+ it("nothing for a plain shell or an agent that has not finished a turn", () => {
13
+ expect(cardPhase({ agentPhase: null })).toBe(null);
14
+ expect(cardPhase({})).toBe(null);
15
+ });
16
+ it("labels", () => {
17
+ expect(phaseLabel("working")).toBe("WORKING");
18
+ expect(phaseLabel("done-unread")).toBe("DONE");
19
+ expect(phaseLabel("done-read")).toBe("READ");
20
+ expect(phaseLabel(null)).toBe("");
21
+ });
22
+ });
@@ -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 keep ending without hearing anything", () => {
264
+ it("gives the space bar back when recognisers REFUSE — ending at once, three times, hearing nothing", () => {
265
265
  const hold = makeHold();
266
266
  hold.handleKey(keydown());
267
267
  vi.advanceTimersByTime(500);
@@ -273,6 +273,36 @@ describe("hold-space dictation", () => {
273
273
  expect(overlays[overlays.length - 1]).toBe(null);
274
274
  });
275
275
 
276
+ it("silence never ends a hold that is still held — sessions timing out are replaced for as long as Space is down", () => {
277
+ const hold = makeHold();
278
+ hold.handleKey(keydown());
279
+ vi.advanceTimersByTime(500);
280
+ // Chrome's no-speech comes after seconds of silence, session after session.
281
+ for (let i = 0; i < 6; i++) {
282
+ vi.advanceTimersByTime(8000);
283
+ FakeRecognition.last!.onerror?.({ error: "no-speech" });
284
+ FakeRecognition.last!.onend?.();
285
+ expect(hold.isActive()).toBe(true);
286
+ expect(overlays[overlays.length - 1]).not.toBe(null);
287
+ }
288
+ // Still held, still listening: an auto-repeat is eaten, not typed.
289
+ expect(pressSpace(hold, { repeat: true }).typed).toBe(false);
290
+ hold.handleKey(keyup());
291
+ expect(hold.isActive()).toBe(false);
292
+ });
293
+
294
+ it("an 'aborted' error raised while we are stopping does not throw the words away", () => {
295
+ const hold = makeHold();
296
+ hold.handleKey(keydown());
297
+ vi.advanceTimersByTime(500);
298
+ FakeRecognition.last!.onresult?.({ resultIndex: 0, results: [Object.assign([{ transcript: "keep this" }], { isFinal: true })] } as never);
299
+ const r = FakeRecognition.last!;
300
+ hold.handleKey(keyup()); // stop(): finishes with the words
301
+ r.onerror?.({ error: "aborted" }); // the recogniser objecting on its way out
302
+ r.onend?.();
303
+ expect(sent.filter((d) => d !== "\x7f")).toEqual(["keep this"]);
304
+ });
305
+
276
306
  it("a recogniser that never ends cannot kill the space bar", () => {
277
307
  // The regression that bit Jian: dictation started, the recogniser never
278
308
  // fired onend, `active` stuck true — and because every space is eaten
package/hop CHANGED
@@ -18,6 +18,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 cmd = exp.command.endsWith('\n') ? exp.command : `${exp.command}\n`;
6463
+ const pinned = withCodexIdentity(exp.command, name);
6464
+ const cmd = pinned.endsWith('\n') ? pinned : `${pinned}\n`;
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
- const cmd = job.plan.command.endsWith('\n') ? job.plan.command : `${job.plan.command}\n`;
6822
+ // A codex launch carries its hop session id in the thread's own
6823
+ // config (lib/codex-identity.js): Codex's shared daemon would
6824
+ // otherwise hand every session the FIRST session's identity.
6825
+ const pinned = withCodexIdentity(job.plan.command, job.internalName);
6826
+ const cmd = pinned.endsWith('\n') ? pinned : `${pinned}\n`;
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
- const CLAUDE_HOOK_EVENTS = ['SessionStart', 'Stop', 'Notification', 'PreToolUse'];
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 || undefined
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
- sendTerminalInput(entry, startup.endsWith('\n') ? startup : `${startup}\n`);
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