hop2 0.11.75 → 0.11.77
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/server/src/rooms.ts +39 -1
- package/hay/apps/server/test/roomModes.test.ts +30 -0
- package/hay/apps/web/src/App.tsx +10 -1
- package/hay/apps/web/src/components/SessionSwitcher.tsx +7 -1
- package/hay/apps/web/src/utils/fullscreenChord.ts +18 -0
- package/hay/apps/web/test/fullscreenChord.test.ts +22 -0
- package/hop +125 -9
- package/package.json +1 -1
- package/scripts/codex-notify-hook.js +25 -3
- package/scripts/hay-host.js +32 -2
- package/tests/codex-notify-hook.unit.test.cjs +29 -13
|
@@ -142,6 +142,11 @@ export type RoomSummary = {
|
|
|
142
142
|
// 6MB still exceeds every reader: the deep-history replay asks 1.5MB, the
|
|
143
143
|
// serialized attach reads the grid, and `hop restore` persists 64KB tails.
|
|
144
144
|
// Override with HAY_SNAPSHOT_BUFFER_BYTES for deeper rings at GC's expense.
|
|
145
|
+
// DEC private modes worth restoring on adopt: cursor keys, mouse (any
|
|
146
|
+
// flavour and encoding), focus events, alternate screen, bracketed paste,
|
|
147
|
+
// cursor visibility.
|
|
148
|
+
const TRACKED_DEC_MODES = new Set(["1", "25", "47", "1000", "1002", "1003", "1004", "1005", "1006", "1015", "1047", "1049", "2004"]);
|
|
149
|
+
|
|
145
150
|
const MAX_BUFFER_SIZE = (() => {
|
|
146
151
|
const env = Number(process.env.HAY_SNAPSHOT_BUFFER_BYTES);
|
|
147
152
|
return Number.isFinite(env) && env > 0 ? env : 6_000_000;
|
|
@@ -321,6 +326,13 @@ export class Room extends EventEmitter {
|
|
|
321
326
|
private mouseReporting = false;
|
|
322
327
|
private mouseSgr = false;
|
|
323
328
|
private cursorHidden = false;
|
|
329
|
+
// The DEC private modes an app set (alternate screen, mouse, focus,
|
|
330
|
+
// bracketed paste…), exactly. An app sets them once at startup; a host
|
|
331
|
+
// restart rebuilds the room from a bounded replay that no longer holds
|
|
332
|
+
// that moment, so the room forgot Codex was full-screen and the wheel
|
|
333
|
+
// scrolled the page instead of the app (2026-10-12). The host saves
|
|
334
|
+
// modePrefix() and puts it back in front of the replay on adopt.
|
|
335
|
+
private decModes = new Map<string, boolean>();
|
|
324
336
|
private controlSequenceTail = "";
|
|
325
337
|
private oscBuffer = "";
|
|
326
338
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
@@ -678,10 +690,13 @@ export class Room extends EventEmitter {
|
|
|
678
690
|
|
|
679
691
|
let nextMouseReporting = hasNewRis ? false : this.mouseReporting;
|
|
680
692
|
let nextMouseSgr = hasNewRis ? false : this.mouseSgr;
|
|
693
|
+
const modesBefore = this.modePrefix();
|
|
694
|
+
if (hasNewRis) this.decModes.clear();
|
|
681
695
|
while ((match = regex.exec(stateInput)) !== null) {
|
|
682
696
|
const params = match[1].split(";").filter(Boolean);
|
|
683
697
|
const mode = match[2];
|
|
684
698
|
for (const param of params) {
|
|
699
|
+
if (TRACKED_DEC_MODES.has(param)) this.decModes.set(param, mode === "h");
|
|
685
700
|
if (param === "25") {
|
|
686
701
|
nextCursorHidden = mode === "l";
|
|
687
702
|
} else if (param === "47" || param === "1047" || param === "1049") {
|
|
@@ -738,6 +753,9 @@ export class Room extends EventEmitter {
|
|
|
738
753
|
stateChanged = true;
|
|
739
754
|
}
|
|
740
755
|
|
|
756
|
+
const modesAfter = this.modePrefix();
|
|
757
|
+
if (modesAfter !== modesBefore) this.emit("modes", modesAfter);
|
|
758
|
+
|
|
741
759
|
if (stateChanged) {
|
|
742
760
|
this.emit("pty_state", {
|
|
743
761
|
roomId: this.id,
|
|
@@ -1204,6 +1222,22 @@ export class Room extends EventEmitter {
|
|
|
1204
1222
|
// omit cells until the app happens to repaint (for example, on scroll).
|
|
1205
1223
|
// Begin tracking at TUI entry; dependency loading is shared and normally
|
|
1206
1224
|
// finishes first, but the callback covers a TUI that starts immediately.
|
|
1225
|
+
/**
|
|
1226
|
+
* The escape sequences that put a fresh terminal into this app's current
|
|
1227
|
+
* modes: the alternate screen first (so the rest apply to it), then each
|
|
1228
|
+
* mode that is on, and a hidden cursor. "" for a plain shell.
|
|
1229
|
+
*/
|
|
1230
|
+
modePrefix(): string {
|
|
1231
|
+
let alt = "", rest = "";
|
|
1232
|
+
for (const [mode, on] of this.decModes) {
|
|
1233
|
+
if (mode === "25") { if (!on) rest += "\x1b[?25l"; continue; }
|
|
1234
|
+
if (!on) continue;
|
|
1235
|
+
if (mode === "1049" || mode === "1047" || mode === "47") alt = `\x1b[?${mode}h`;
|
|
1236
|
+
else rest += `\x1b[?${mode}h`;
|
|
1237
|
+
}
|
|
1238
|
+
return alt + rest;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1207
1241
|
private startTuiGrid() {
|
|
1208
1242
|
if (!SERIALIZED_ATTACH_ENABLED || this.grid || this.gridStartPending || this.gridUnavailable || this.ended) return;
|
|
1209
1243
|
if (!this.alternateScreen && !this.mouseReporting) return;
|
|
@@ -1244,7 +1278,11 @@ export class Room extends EventEmitter {
|
|
|
1244
1278
|
const nl = tail.indexOf("\n");
|
|
1245
1279
|
if (nl !== -1 && nl < 8192) tail = tail.slice(nl + 1);
|
|
1246
1280
|
}
|
|
1247
|
-
|
|
1281
|
+
// The tail rarely reaches back to where the app set its modes
|
|
1282
|
+
// (an adopted room's saved prefix sits at the very front of the
|
|
1283
|
+
// ring): put the room's current modes first, or the grid serializes
|
|
1284
|
+
// a full-screen app as a plain shell. The tail's own changes win.
|
|
1285
|
+
this.grid.write(this.modePrefix() + tail);
|
|
1248
1286
|
}
|
|
1249
1287
|
}
|
|
1250
1288
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { RoomManager } from "../src/rooms";
|
|
3
|
+
import type { PtyFactory } from "../src/pty";
|
|
4
|
+
|
|
5
|
+
class FakePty {
|
|
6
|
+
private handlers: Array<(data: string) => void> = [];
|
|
7
|
+
onData(handler: (data: string) => void) { this.handlers.push(handler); }
|
|
8
|
+
onExit() {}
|
|
9
|
+
emit(data: string) { for (const h of this.handlers) h(data); }
|
|
10
|
+
write() {}
|
|
11
|
+
resize() {}
|
|
12
|
+
kill() {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("terminal modes a room remembers", () => {
|
|
16
|
+
it("a room reports the DEC modes its app set, alternate screen first", () => {
|
|
17
|
+
const pty = new FakePty();
|
|
18
|
+
const manager = new RoomManager((() => pty) as unknown as PtyFactory);
|
|
19
|
+
const room = manager.getRoom("codex", { cols: 80, rows: 24 }, "/tmp");
|
|
20
|
+
const seen: string[] = [];
|
|
21
|
+
room.on("modes", (p: string) => seen.push(p));
|
|
22
|
+
pty.emit("\x1b[?2004h\x1b[?1004h\x1b[?1049h\x1b[?1003h\x1b[?1006h\x1b[?25l hello");
|
|
23
|
+
expect(room.modePrefix()).toBe("\x1b[?1049h\x1b[?2004h\x1b[?1004h\x1b[?1003h\x1b[?1006h\x1b[?25l");
|
|
24
|
+
expect(seen.at(-1)).toBe(room.modePrefix());
|
|
25
|
+
pty.emit("\x1b[?1003l\x1b[?1049l");
|
|
26
|
+
expect(room.modePrefix()).toBe("\x1b[?2004h\x1b[?1004h\x1b[?1006h\x1b[?25l");
|
|
27
|
+
pty.emit("\x1bc");
|
|
28
|
+
expect(room.modePrefix()).toBe("");
|
|
29
|
+
});
|
|
30
|
+
});
|
package/hay/apps/web/src/App.tsx
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from "./utils/paneTree";
|
|
50
50
|
import { ViewsPanel, hasUnseenViews, loadViewsSeen } from "./components/ViewsPanel";
|
|
51
51
|
import type { ViewsSummary } from "./utils/switcherModel";
|
|
52
|
+
import { isAppCtrlEnter, isFullscreenChord } from "./utils/fullscreenChord";
|
|
52
53
|
|
|
53
54
|
const createRoomId = () => `room-${Math.random().toString(36).slice(2, 7)}`;
|
|
54
55
|
|
|
@@ -2329,13 +2330,21 @@ const App = () => {
|
|
|
2329
2330
|
terminal.attachCustomKeyEventHandler((event) => {
|
|
2330
2331
|
// ⌘⏎ leaves full screen for the wall — the mirror of ⌘⏎ on a wall
|
|
2331
2332
|
// tile entering it. One chord toggles the mode from either side.
|
|
2332
|
-
|
|
2333
|
+
// On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
|
|
2334
|
+
if (isFullscreenChord(event)) {
|
|
2333
2335
|
if (event.type === 'keydown') {
|
|
2334
2336
|
event.preventDefault();
|
|
2335
2337
|
setSwitcherOpen(true);
|
|
2336
2338
|
}
|
|
2337
2339
|
return false;
|
|
2338
2340
|
}
|
|
2341
|
+
if (isAppCtrlEnter(event) && (remoteKbdEnhancedRef.current || foregroundIsClaude())) {
|
|
2342
|
+
if (event.type === 'keydown') {
|
|
2343
|
+
event.preventDefault();
|
|
2344
|
+
handleUserInput('\x1b[13;5u');
|
|
2345
|
+
}
|
|
2346
|
+
return false;
|
|
2347
|
+
}
|
|
2339
2348
|
// Long-press SPACE = voice input — shared controller; see voiceHold.ts.
|
|
2340
2349
|
{
|
|
2341
2350
|
const verdict = voiceHoldRef.current?.handleKey(event);
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type SwitcherSortMode,
|
|
38
38
|
insertAfter, describeAccount, type AgentHome, type AgentHomes } from "../utils/switcherModel";
|
|
39
39
|
import { scanKeyboardProtocol } from "../utils/keyboardProtocol";
|
|
40
|
+
import { isAppCtrlEnter, isFullscreenChord } from "../utils/fullscreenChord";
|
|
40
41
|
import { DigestCard } from "./DigestCard";
|
|
41
42
|
import { hasUnseenViews, loadViewsSeen } from "./ViewsPanel";
|
|
42
43
|
|
|
@@ -693,10 +694,15 @@ const LiveTile = ({ wsBase, room, userName, theme, live, deliberate, claudeApp,
|
|
|
693
694
|
if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;2u");
|
|
694
695
|
return false;
|
|
695
696
|
}
|
|
696
|
-
|
|
697
|
+
// On a Mac only ⌘⏎ — Ctrl⏎ goes to the app (see fullscreenChord.ts).
|
|
698
|
+
if (isFullscreenChord(ev)) {
|
|
697
699
|
if (ev.type === "keydown") onFullscreenRef.current();
|
|
698
700
|
return false;
|
|
699
701
|
}
|
|
702
|
+
if (isAppCtrlEnter(ev) && (kbdEnhancedRef.current || looksLikeClaude())) {
|
|
703
|
+
if (ev.type === "keydown") sendInputRef.current?.("\x1b[13;5u");
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
700
706
|
if ((ev.metaKey || ev.ctrlKey) && !ev.altKey && !ev.shiftKey && ev.key === ".") {
|
|
701
707
|
if (ev.type === "keydown") onUnfocusRef.current();
|
|
702
708
|
return false;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// The full-screen ⇄ wall chord inside a terminal. On a Mac it is ⌘⏎ only:
|
|
2
|
+
// Ctrl⏎ belongs to the app (Claude Code binds it), and hop taking it meant
|
|
3
|
+
// the keystroke never arrived. Elsewhere there is no ⌘, so Ctrl⏎ stays.
|
|
4
|
+
export const IS_MAC = typeof navigator !== "undefined"
|
|
5
|
+
&& /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent || "");
|
|
6
|
+
|
|
7
|
+
type Chord = { key: string; metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean };
|
|
8
|
+
|
|
9
|
+
export function isFullscreenChord(ev: Chord, mac = IS_MAC): boolean {
|
|
10
|
+
if (ev.key !== "Enter" || ev.altKey || ev.shiftKey) return false;
|
|
11
|
+
return mac ? ev.metaKey && !ev.ctrlKey : ev.metaKey || ev.ctrlKey;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Ctrl⏎ passed to the app: CSI-u (13;5u), since xterm.js would send a bare \r. */
|
|
15
|
+
export function isAppCtrlEnter(ev: Chord, mac = IS_MAC): boolean {
|
|
16
|
+
return ev.key === "Enter" && ev.ctrlKey && !ev.metaKey && !ev.altKey && !ev.shiftKey
|
|
17
|
+
&& !isFullscreenChord(ev, mac);
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isAppCtrlEnter, isFullscreenChord } from "../src/utils/fullscreenChord";
|
|
3
|
+
|
|
4
|
+
const key = (mods: Partial<{ metaKey: boolean; ctrlKey: boolean; altKey: boolean; shiftKey: boolean }>, k = "Enter") =>
|
|
5
|
+
({ key: k, metaKey: false, ctrlKey: false, altKey: false, shiftKey: false, ...mods });
|
|
6
|
+
|
|
7
|
+
describe("full-screen chord", () => {
|
|
8
|
+
it("on a Mac only ⌘⏎ toggles; Ctrl⏎ is the app's", () => {
|
|
9
|
+
expect(isFullscreenChord(key({ metaKey: true }), true)).toBe(true);
|
|
10
|
+
expect(isFullscreenChord(key({ ctrlKey: true }), true)).toBe(false);
|
|
11
|
+
expect(isAppCtrlEnter(key({ ctrlKey: true }), true)).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
it("without ⌘ (Windows/Linux) Ctrl⏎ still toggles", () => {
|
|
14
|
+
expect(isFullscreenChord(key({ ctrlKey: true }), false)).toBe(true);
|
|
15
|
+
expect(isAppCtrlEnter(key({ ctrlKey: true }), false)).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
it("other chords are untouched", () => {
|
|
18
|
+
expect(isFullscreenChord(key({ metaKey: true, shiftKey: true }), true)).toBe(false);
|
|
19
|
+
expect(isFullscreenChord(key({}), true)).toBe(false);
|
|
20
|
+
expect(isAppCtrlEnter(key({ ctrlKey: true }, "a"), true)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
});
|
package/hop
CHANGED
|
@@ -802,7 +802,18 @@ async function withLifecycleLock(op, fn, { waitMs = 120_000, onWait } = {}) {
|
|
|
802
802
|
}
|
|
803
803
|
// Marker so the authenticator setup QR is only shown on first run (UX only; no auth logic).
|
|
804
804
|
const AUTH_QR_SHOWN_FILE = path.join(HOME_DIR, '.auth_qr_shown');
|
|
805
|
-
|
|
805
|
+
// A keeper-backed host re-adopts every running terminal (replaying each
|
|
806
|
+
// screen) BEFORE it opens its port. With 30 rooms that took ~8 s, then ~41 s
|
|
807
|
+
// once one replay timed out (2026-10-12), so first a 5 s and then a 25 s
|
|
808
|
+
// limit killed each new host mid-adoption and spawned the next — a restart
|
|
809
|
+
// loop that left hop with no host, and took the daemon down with it ("Fatal:
|
|
810
|
+
// Hay host did not open its port in time") while a `hop restore` was in
|
|
811
|
+
// flight, which the CLI reported as "socket hang up". This is now a ceiling,
|
|
812
|
+
// not the expected wait: the spawner keeps waiting while the host process is
|
|
813
|
+
// alive (see waitForHayHostPort), gives up early only once it has died, and
|
|
814
|
+
// refreshes the spawn lock as it waits so a rival spawner never mistakes a
|
|
815
|
+
// long adoption for a stale lock.
|
|
816
|
+
const HAY_HOST_READY_TIMEOUT_MS = parsePositiveIntEnv('HOP_HAY_HOST_READY_TIMEOUT_MS', 180000);
|
|
806
817
|
const HAY_HOST_DISCOVERY_CACHE_MS = parsePositiveIntEnv('HOP_HAY_HOST_DISCOVERY_CACHE_MS', 10000);
|
|
807
818
|
const HAY_HOST_HOME_HASH = crypto.createHash('sha256').update(path.resolve(HOME_DIR)).digest('hex').slice(0, 24);
|
|
808
819
|
const HAY_HOST_HOME_FLAG = `--hop-home-hash=${HAY_HOST_HOME_HASH}`;
|
|
@@ -2733,6 +2744,20 @@ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc
|
|
|
2733
2744
|
const TRUST_PROMPT_RE = /Quick\s*safety\s*check|Yes,?\s*I\s*trust\s*this\s*folder|Is\s*this\s*a\s*project\s*you\s*created\s*or\s*one\s*you\s*trust/i;
|
|
2734
2745
|
// Trust-dialog reading lives in lib/trust-dialog.js so it can be unit-tested.
|
|
2735
2746
|
const { trustDialogWorkspace, trustDialogAnswer, samePath } = require('./lib/trust-dialog.js');
|
|
2747
|
+
// The transport cloudflared uses to reach Cloudflare's edge. Its default is
|
|
2748
|
+
// QUIC (UDP): on networks that shape or drop UDP — campus networks often do —
|
|
2749
|
+
// a lost packet stalls every request in flight on that connection for a
|
|
2750
|
+
// retransmit timeout, and keystrokes from a phone or laptop arrive in
|
|
2751
|
+
// 1–3 s bursts while the host itself is idle (measured 2026-10-11: QUIC
|
|
2752
|
+
// connection 0 had 120 timeout losses; one request in four took 1.2–2.9 s).
|
|
2753
|
+
// "http2" runs the tunnel over TCP instead. Set `tunnel_protocol` in
|
|
2754
|
+
// ~/.hop2 config ("quic" | "http2" | "auto") or HOP_TUNNEL_PROTOCOL.
|
|
2755
|
+
function getTunnelProtocol() {
|
|
2756
|
+
const raw = String(process.env.HOP_TUNNEL_PROTOCOL || getHopConfig().tunnel_protocol || '').toLowerCase();
|
|
2757
|
+
return raw === 'quic' || raw === 'http2' || raw === 'auto' ? raw : null;
|
|
2758
|
+
}
|
|
2759
|
+
const tunnelProtocolLine = () => { const p = getTunnelProtocol(); return p ? `protocol: ${p}\n` : ''; };
|
|
2760
|
+
|
|
2736
2761
|
function getResumeMode() {
|
|
2737
2762
|
const mode = String(getHopConfig().resume_mode || '').toLowerCase();
|
|
2738
2763
|
return mode === 'summary' || mode === 'ask' ? mode : 'full';
|
|
@@ -6021,6 +6046,20 @@ function runShellProbe(command, cwd) {
|
|
|
6021
6046
|
// agent-turn path has always sent them as separate writes; this is that.
|
|
6022
6047
|
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6023
6048
|
// Is the message still sitting in the composer (the `›` line) — typed, not sent?
|
|
6049
|
+
/**
|
|
6050
|
+
* Is this session's agent up and showing its own UI — the only thing a
|
|
6051
|
+
* check-back may type into? Not running, a plain shell, a trust or resume
|
|
6052
|
+
* dialog, or an unreadable screen are all "no".
|
|
6053
|
+
*/
|
|
6054
|
+
async function agentScreenReady(entry) {
|
|
6055
|
+
if (!entry || entry.live !== true) return { ok: false, reason: 'waiting: the session is not running' };
|
|
6056
|
+
const verdict = await roomIsRedoable(entry);
|
|
6057
|
+
// roomIsRedoable says "not redoable, and here is whether the agent is
|
|
6058
|
+
// idle" exactly when an agent's UI owns the screen.
|
|
6059
|
+
if (!verdict.ok && verdict.agentIdle !== undefined) return { ok: true };
|
|
6060
|
+
return { ok: false, reason: `waiting: no agent on screen (${verdict.reason})` };
|
|
6061
|
+
}
|
|
6062
|
+
|
|
6024
6063
|
async function composerStillHolds(internalName, message) {
|
|
6025
6064
|
try {
|
|
6026
6065
|
const hostRes = await callExternalHayHostApi(`/rooms/${encodeURIComponent(internalName)}/serialized`);
|
|
@@ -6100,6 +6139,17 @@ async function runCheckbackSweep() {
|
|
|
6100
6139
|
continue;
|
|
6101
6140
|
}
|
|
6102
6141
|
if (!checkbacks.mayDeliver(cb, ctx)) continue; // mid-turn: next sweep
|
|
6142
|
+
// Type only into a RUNNING agent. Attaching to a stopped
|
|
6143
|
+
// session creates its room — as a bare shell — and the
|
|
6144
|
+
// message then ran as shell commands ("zsh: command not
|
|
6145
|
+
// found: Collect"); worse, that shell made restore think the
|
|
6146
|
+
// session was up and skip it (surf, alphagenome, 2026-10-12
|
|
6147
|
+
// after a reboot). Wait until the agent's own UI is on screen.
|
|
6148
|
+
const ready = await agentScreenReady(entry);
|
|
6149
|
+
if (!ready.ok) {
|
|
6150
|
+
if (cb.lastError !== ready.reason) { cb.lastError = ready.reason; dirty = true; }
|
|
6151
|
+
continue;
|
|
6152
|
+
}
|
|
6103
6153
|
const e2 = await attachTerminalApiClient(cb.session, entry.displayName || cb.session, { actor: 'system', label: 'checkback', typeOnly: true });
|
|
6104
6154
|
try { await typeAndSubmit(e2, cb.session, cb.message); }
|
|
6105
6155
|
finally { try { closeTerminalApiEntry(e2.id); } catch (e) { /* closed */ } }
|
|
@@ -6384,8 +6434,13 @@ function rebuildClaudeLaunch(launchCmd) {
|
|
|
6384
6434
|
if (!valued && out.includes(t)) continue;
|
|
6385
6435
|
out.push(t);
|
|
6386
6436
|
if (valued) out.push(tokens[++i]);
|
|
6437
|
+
continue;
|
|
6387
6438
|
}
|
|
6388
|
-
// bare positional
|
|
6439
|
+
// A bare positional is the initial PROMPT, and everything after it
|
|
6440
|
+
// is prompt text too — split on spaces, its words included things
|
|
6441
|
+
// like "--source-ref" and "-q", which were kept as flags and made
|
|
6442
|
+
// claude exit at once on every restore (pulse-1004-claude-o3).
|
|
6443
|
+
break;
|
|
6389
6444
|
}
|
|
6390
6445
|
return out.join(' ');
|
|
6391
6446
|
}
|
|
@@ -6965,6 +7020,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6965
7020
|
const { resolved, unknown } = resolveRestoreTargets(only, snapshot);
|
|
6966
7021
|
for (const name of unknown) skipped.push({ name, reason: 'no such session' });
|
|
6967
7022
|
onlySet = resolved;
|
|
7023
|
+
let closedAny = false;
|
|
6968
7024
|
for (const internalName of resolved) {
|
|
6969
7025
|
if (!live.has(internalName)) continue;
|
|
6970
7026
|
const entry = snapshot.entries.find((e) => e.internalName === internalName);
|
|
@@ -6999,7 +7055,13 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6999
7055
|
}
|
|
7000
7056
|
log(` ${display}: closed the ${verdict.reason} left by the last restore`, DIM);
|
|
7001
7057
|
live.delete(internalName);
|
|
7058
|
+
closedAny = true;
|
|
7002
7059
|
}
|
|
7060
|
+
// The keeper ends the old terminal a beat AFTER the room closes. A
|
|
7061
|
+
// relaunch inside that beat was refused ("already running"), and
|
|
7062
|
+
// then the old one exited too — both sessions vanished (alphagenome,
|
|
7063
|
+
// angler-codex-claude-codex, 2026-10-12). Let the old ones go first.
|
|
7064
|
+
if (closedAny) await new Promise((r) => setTimeout(r, 2000));
|
|
7003
7065
|
}
|
|
7004
7066
|
const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !live.has(e.internalName));
|
|
7005
7067
|
// Primary source: the SessionStart-hook records. Add any recorded session that
|
|
@@ -9655,6 +9717,27 @@ async function stopExternalHayHostProcess(hostState, timeoutMs = 1500) {
|
|
|
9655
9717
|
return !isProcessAlive(pid);
|
|
9656
9718
|
}
|
|
9657
9719
|
|
|
9720
|
+
// Wait for a freshly spawned host to open its port. Adopting a big fleet from
|
|
9721
|
+
// the pty-keeper can take a minute or more, so the wait is bounded by the
|
|
9722
|
+
// host's LIFE first and the ceiling second: a host that dies returns at once
|
|
9723
|
+
// instead of burning the whole ceiling, and one still working keeps the spawn
|
|
9724
|
+
// lock's mtime fresh so a concurrent spawner (30 s staleness rule) never calls
|
|
9725
|
+
// the lock stale and starts a rival host mid-adoption.
|
|
9726
|
+
async function waitForHayHostPort(started, ceilingMs) {
|
|
9727
|
+
const deadline = Date.now() + ceilingMs;
|
|
9728
|
+
let lastLockTouch = 0;
|
|
9729
|
+
while (Date.now() < deadline) {
|
|
9730
|
+
if (await waitForPort(started.port, '127.0.0.1', 1000, 120)) return true;
|
|
9731
|
+
if (!isProcessAlive(started.pid)) return false;
|
|
9732
|
+
if (Date.now() - lastLockTouch > 5000) {
|
|
9733
|
+
lastLockTouch = Date.now();
|
|
9734
|
+
const now = new Date();
|
|
9735
|
+
try { fs.utimesSync(HAY_HOST_SPAWN_LOCK, now, now); } catch (e) { /* lock gone — nothing to refresh */ }
|
|
9736
|
+
}
|
|
9737
|
+
}
|
|
9738
|
+
return false;
|
|
9739
|
+
}
|
|
9740
|
+
|
|
9658
9741
|
function markExternalHayHostResponsive(host) {
|
|
9659
9742
|
const recovered = hayHostProbeFailures.recordSuccess(Number(host?.pid));
|
|
9660
9743
|
if (recovered) {
|
|
@@ -9781,6 +9864,9 @@ async function ensureExternalHayHostReadyOnce() {
|
|
|
9781
9864
|
if (spawned && isProcessAlive(spawned.pid) && await probeExternalHayHostFromHost(spawned, 1500)) {
|
|
9782
9865
|
return markExternalHayHostResponsive(spawned);
|
|
9783
9866
|
}
|
|
9867
|
+
// The winner releases the lock on success and failure alike;
|
|
9868
|
+
// once it is gone there is nothing left to wait for.
|
|
9869
|
+
if (!fs.existsSync(HAY_HOST_SPAWN_LOCK)) break;
|
|
9784
9870
|
}
|
|
9785
9871
|
// Winner never delivered — fall through and spawn ourselves.
|
|
9786
9872
|
}
|
|
@@ -9866,10 +9952,13 @@ async function ensureExternalHayHostReadyOnce() {
|
|
|
9866
9952
|
throw new Error(`Hay host failed to start; see ${hayHostLogFile}`);
|
|
9867
9953
|
}
|
|
9868
9954
|
|
|
9869
|
-
const ready = await
|
|
9955
|
+
const ready = await waitForHayHostPort(started, HAY_HOST_READY_TIMEOUT_MS);
|
|
9870
9956
|
if (!ready) {
|
|
9957
|
+
const died = !isProcessAlive(started.pid);
|
|
9871
9958
|
await stopExternalHayHostProcess(started);
|
|
9872
|
-
throw new Error(
|
|
9959
|
+
throw new Error(died
|
|
9960
|
+
? `Hay host (pid ${started.pid}) exited before opening its port; see ${hayHostLogFile}`
|
|
9961
|
+
: `Hay host did not open its port within ${Math.round(HAY_HOST_READY_TIMEOUT_MS / 1000)}s (still adopting its terminals? HOP_HAY_HOST_READY_TIMEOUT_MS raises the ceiling); see ${hayHostLogFile}`);
|
|
9873
9962
|
}
|
|
9874
9963
|
const healthy = await probeExternalHayHostFromHost(started, 1500);
|
|
9875
9964
|
if (!healthy) {
|
|
@@ -13320,7 +13409,11 @@ happens until someone clicks it.
|
|
|
13320
13409
|
const result = await restoreSavedSessions({
|
|
13321
13410
|
dryRun: parsed.dryRun === true,
|
|
13322
13411
|
includeAgents: parsed.includeAgents === true,
|
|
13323
|
-
only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null
|
|
13412
|
+
only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null,
|
|
13413
|
+
// Restart named agents that sit idle at their prompt
|
|
13414
|
+
// (never one mid-turn) — e.g. one resumed the wrong
|
|
13415
|
+
// conversation. Only together with `only`.
|
|
13416
|
+
replaceIdleAgent: parsed.replaceIdleAgent === true && Array.isArray(parsed.only)
|
|
13324
13417
|
});
|
|
13325
13418
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13326
13419
|
res.end(JSON.stringify(result));
|
|
@@ -14497,7 +14590,24 @@ happens until someone clicks it.
|
|
|
14497
14590
|
return;
|
|
14498
14591
|
}
|
|
14499
14592
|
await reconcileExternalRuntimeSessions();
|
|
14500
|
-
|
|
14593
|
+
// A dead session that holds an agent conversation comes
|
|
14594
|
+
// back AS that agent, whoever opens it — a viewer, a
|
|
14595
|
+
// check-back, a tile. Creating a bare shell here left
|
|
14596
|
+
// surf a zsh prompt after a reboot (a check-back's attach
|
|
14597
|
+
// beat restore by two seconds, and restore then skipped
|
|
14598
|
+
// the "running" session). Same plan restore would use.
|
|
14599
|
+
let revive = null;
|
|
14600
|
+
if (!sessionConfig?.startup && !(await runtimeTerminalSessionExists(sessionName))) {
|
|
14601
|
+
try {
|
|
14602
|
+
const plan = restorePlanForSession({ internalName: sessionName, cwd: sessionConfig?.cwd, startup: sessionConfig?.startup, autoStart: sessionConfig?.autoStart });
|
|
14603
|
+
if (plan?.command) {
|
|
14604
|
+
revive = plan;
|
|
14605
|
+
log(`[ws] ${sessionName}: not running — starting it as its agent (${plan.command.slice(0, 80)})`, DIM);
|
|
14606
|
+
}
|
|
14607
|
+
} catch (e) { /* no plan: a plain shell, as before */ }
|
|
14608
|
+
}
|
|
14609
|
+
await ensureExternalRuntimeSession(sessionName, revive?.cwd || sessionConfig?.cwd, getSessionDisplayName(sessionName), {
|
|
14610
|
+
command: revive?.command || undefined,
|
|
14501
14611
|
shell: sessionConfig?.shell,
|
|
14502
14612
|
env: sessionConfig?.env,
|
|
14503
14613
|
cols: sessionConfig?.cols,
|
|
@@ -14729,7 +14839,7 @@ function startTunnel(isRestart = false) {
|
|
|
14729
14839
|
const tunnelConfig = `
|
|
14730
14840
|
url: http://${localTunnelHost}:${PROXY_PORT}
|
|
14731
14841
|
tunnel: ${domainConfig.tunnelName}
|
|
14732
|
-
`;
|
|
14842
|
+
${tunnelProtocolLine()}`;
|
|
14733
14843
|
writePrivateFile(tunnelConfigPath, tunnelConfig);
|
|
14734
14844
|
|
|
14735
14845
|
const runArgs = ['tunnel', '--config', tunnelConfigPath, 'run'];
|
|
@@ -14907,7 +15017,7 @@ function startClientTunnel(isRestart = false) {
|
|
|
14907
15017
|
url: http://127.0.0.1:${PROXY_PORT}
|
|
14908
15018
|
tunnel: ${tunnelId}
|
|
14909
15019
|
credentials-file: ${path.resolve(credentialsPath)}
|
|
14910
|
-
`;
|
|
15020
|
+
${tunnelProtocolLine()}`;
|
|
14911
15021
|
writePrivateFile(tunnelConfigPath, tunnelConfig);
|
|
14912
15022
|
|
|
14913
15023
|
const tunnel = spawn('cloudflared', ['tunnel', '--config', tunnelConfigPath, 'run'], {
|
|
@@ -17487,7 +17597,13 @@ async function main() {
|
|
|
17487
17597
|
try {
|
|
17488
17598
|
res = await callLocalApi(state, '/api/sessions/restore', 'POST', { dryRun, includeAgents, only: only.length ? only : undefined });
|
|
17489
17599
|
} catch (e) {
|
|
17490
|
-
|
|
17600
|
+
// "socket hang up" here means the daemon closed the connection
|
|
17601
|
+
// without answering: it was restarted, or died (e.g. its host
|
|
17602
|
+
// never came up), while the restore was in flight. Nothing about
|
|
17603
|
+
// the restore itself failed, so say what to do next.
|
|
17604
|
+
const gone = !isServerRunning();
|
|
17605
|
+
log(`Restore request failed: ${e.message}${gone ? ' — the daemon went away mid-request' : ''}.`, RED);
|
|
17606
|
+
log(` ${DIM}Check ${CYAN}hop logs${RESET}${DIM} (look for "Fatal:" or "Cleaning up"), then run ${CYAN}hop restore${RESET}${DIM} again — it only recreates sessions that are not running.${RESET}`, RESET);
|
|
17491
17607
|
process.exit(1);
|
|
17492
17608
|
}
|
|
17493
17609
|
if (!res || res.status < 200 || res.status >= 300) {
|
package/package.json
CHANGED
|
@@ -38,7 +38,17 @@ function turnsDir() {
|
|
|
38
38
|
// thread is the truth: if another session's turn record already names this
|
|
39
39
|
// root thread, the turn is that session's. (hop launches codex without the
|
|
40
40
|
// daemon now; this keeps the sessions that predate the change honest.)
|
|
41
|
+
//
|
|
42
|
+
// Retired (2026-10-12): every codex hop launches now carries its own session
|
|
43
|
+
// in the per-thread overrides (lib/codex-identity.js), so HOP_SESSION is the
|
|
44
|
+
// truth — and the "another record already names this thread" rule did harm:
|
|
45
|
+
// once a record held the wrong thread, every later turn of that thread was
|
|
46
|
+
// filed there for good. The hand-off session angler-codex-claude-codex lost
|
|
47
|
+
// its whole conversation to angler-codex that way, and after a reboot
|
|
48
|
+
// angler-codex resumed the hand-off's thread instead of its own. Kept only
|
|
49
|
+
// for a launch with no session in its environment.
|
|
41
50
|
function resolveHopSession(dir, rootId, envSession) {
|
|
51
|
+
if (envSession) return envSession;
|
|
42
52
|
if (!rootId) return envSession;
|
|
43
53
|
let names = [];
|
|
44
54
|
try { names = fs.readdirSync(dir).filter((f) => f.endsWith(".turn")); } catch { return envSession; }
|
|
@@ -58,7 +68,9 @@ function resolveHopSession(dir, rootId, envSession) {
|
|
|
58
68
|
// file — a long thread's rollout runs to hundreds of MB. Any failure leaves
|
|
59
69
|
// the id as given; hop resolves it again at restore time.
|
|
60
70
|
function findRollout(id) {
|
|
61
|
-
|
|
71
|
+
// The account's own root: a session running as another Codex login keeps
|
|
72
|
+
// its rollouts under that CODEX_HOME.
|
|
73
|
+
const root = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions");
|
|
62
74
|
const walk = (dir, depth) => {
|
|
63
75
|
let names = [];
|
|
64
76
|
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
|
|
@@ -101,14 +113,24 @@ function bumpTurn(payload) {
|
|
|
101
113
|
const rawThread = (payload && typeof payload["thread-id"] === "string") ? payload["thread-id"]
|
|
102
114
|
: (payload && typeof payload.thread_id === "string") ? payload.thread_id
|
|
103
115
|
: (payload && typeof payload.session_id === "string") ? payload.session_id : null;
|
|
104
|
-
|
|
116
|
+
let rootId = rootThreadId(rawThread);
|
|
105
117
|
const hopSession = resolveHopSession(dir, rootId, envSession);
|
|
106
118
|
const file = path.join(dir, `${hopSession}.turn`);
|
|
107
119
|
let count = 0;
|
|
120
|
+
let prev = null;
|
|
108
121
|
try {
|
|
109
|
-
|
|
122
|
+
prev = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
110
123
|
if (prev && Number.isInteger(prev.count) && prev.count >= 0) count = prev.count;
|
|
111
124
|
} catch { /* missing/corrupt -> start at 0 */ }
|
|
125
|
+
// A thread Codex never saved cannot be the conversation. Codex's helper
|
|
126
|
+
// threads (the approval "guardian", among others) finish turns and notify
|
|
127
|
+
// like any other, but have no rollout — recording one replaced the real
|
|
128
|
+
// thread, and restore then had nothing to resume (alphagenome came back a
|
|
129
|
+
// bare shell). Keep the saved thread already on record.
|
|
130
|
+
if (rootId && !findRollout(rootId) && prev && typeof prev.sessionId === "string"
|
|
131
|
+
&& prev.sessionId !== rootId && findRollout(prev.sessionId)) {
|
|
132
|
+
rootId = prev.sessionId;
|
|
133
|
+
}
|
|
112
134
|
const rec = JSON.stringify({
|
|
113
135
|
// The thread id is the conversation (and the rollout file's name); the
|
|
114
136
|
// turn id names one turn and matches nothing on disk. With multi-agent
|
package/scripts/hay-host.js
CHANGED
|
@@ -91,10 +91,19 @@ function loadViewerColors(hay) {
|
|
|
91
91
|
if (typeof hay.setDefaultViewerColors === 'function') hay.setDefaultViewerColors(saved.bg, saved.fg);
|
|
92
92
|
} catch (e) { /* none yet: the dark default */ }
|
|
93
93
|
}
|
|
94
|
+
// Compared against what is on disk, not setDefaultViewerColors' "changed":
|
|
95
|
+
// room.setClientColors already updates the defaults before this runs, so
|
|
96
|
+
// that always said "unchanged" and the file was never written — every host
|
|
97
|
+
// restart went back to the dark default (Codex's charcoal composer).
|
|
98
|
+
let savedViewerColors = null;
|
|
94
99
|
function rememberViewerColors(hay, bg, fg) {
|
|
95
100
|
if (typeof hay.setDefaultViewerColors !== 'function') return;
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
hay.setDefaultViewerColors(bg, fg);
|
|
102
|
+
const json = JSON.stringify(hay.getDefaultViewerColors());
|
|
103
|
+
if (savedViewerColors === null) { try { savedViewerColors = fs.readFileSync(VIEWER_COLORS_FILE, 'utf8'); } catch (e) { savedViewerColors = ''; } }
|
|
104
|
+
if (json === savedViewerColors) return;
|
|
105
|
+
try { fs.writeFileSync(VIEWER_COLORS_FILE, json); savedViewerColors = json; } catch (e) { /* best effort */ }
|
|
106
|
+
}
|
|
98
107
|
const CLAUDE_SESSIONS_DIR = path.join(HOP_HOME, 'claude-sessions');
|
|
99
108
|
|
|
100
109
|
// Scrub Claude Code SESSION markers from our environment before any PTY is
|
|
@@ -153,9 +162,29 @@ const watchedRooms = new WeakSet();
|
|
|
153
162
|
// restore <name>`): their records must survive the close, exactly as they
|
|
154
163
|
// survive a host shutdown. Consumed once, on the next session_end.
|
|
155
164
|
const preserveRecordsOnClose = new Set();
|
|
165
|
+
// Each room's terminal modes (Room.modePrefix), saved as they change so a
|
|
166
|
+
// re-adopted room starts in them: an app turns alternate screen and mouse
|
|
167
|
+
// reporting on once, at startup, which a bounded replay no longer holds.
|
|
168
|
+
const ROOM_MODES_DIR = path.join(HOP_HOME, 'room-modes');
|
|
169
|
+
const roomModesFile = (id) => path.join(ROOM_MODES_DIR, `${id}.modes`);
|
|
170
|
+
function loadRoomModes(id) {
|
|
171
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(String(id || ''))) return '';
|
|
172
|
+
try { return fs.readFileSync(roomModesFile(id), 'utf8'); } catch (e) { return ''; }
|
|
173
|
+
}
|
|
174
|
+
function saveRoomModes(id, prefix) {
|
|
175
|
+
try {
|
|
176
|
+
if (!prefix) { fs.unlinkSync(roomModesFile(id)); return; }
|
|
177
|
+
fs.mkdirSync(ROOM_MODES_DIR, { recursive: true });
|
|
178
|
+
fs.writeFileSync(roomModesFile(id), prefix);
|
|
179
|
+
} catch (e) { /* best effort */ }
|
|
180
|
+
}
|
|
156
181
|
function watchRoomEnd(room) {
|
|
157
182
|
if (!room || typeof room.on !== 'function' || watchedRooms.has(room)) return room;
|
|
158
183
|
watchedRooms.add(room);
|
|
184
|
+
if (/^[A-Za-z0-9_.-]+$/.test(String(room.id || ''))) {
|
|
185
|
+
room.on('modes', (prefix) => saveRoomModes(String(room.id), String(prefix || '')));
|
|
186
|
+
if (typeof room.modePrefix === 'function') saveRoomModes(String(room.id), room.modePrefix());
|
|
187
|
+
}
|
|
159
188
|
room.on('session_end', () => {
|
|
160
189
|
if (shuttingDown) return;
|
|
161
190
|
const id = String(room.id || '');
|
|
@@ -168,6 +197,7 @@ function watchRoomEnd(room) {
|
|
|
168
197
|
// request to forget its conversation; that is an explicit delete's
|
|
169
198
|
// job, daemon-side.
|
|
170
199
|
try { fs.unlinkSync(path.join(BUFFER_DIR, `${id}.raw`)); } catch (e) { /* best effort */ }
|
|
200
|
+
try { fs.unlinkSync(roomModesFile(id)); } catch (e) { /* best effort */ }
|
|
171
201
|
});
|
|
172
202
|
return room;
|
|
173
203
|
}
|
|
@@ -10,10 +10,15 @@ const HOOK = path.join(__dirname, '..', 'scripts', 'codex-notify-hook.js');
|
|
|
10
10
|
const freshHome = () => {
|
|
11
11
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'hop-codex-hook-'));
|
|
12
12
|
fs.mkdirSync(path.join(home, 'claude-sessions'), { recursive: true });
|
|
13
|
+
fs.mkdirSync(path.join(home, 'codex', 'sessions', '2026', '10', '12'), { recursive: true });
|
|
13
14
|
return home;
|
|
14
15
|
};
|
|
16
|
+
// A thread Codex saved has a rollout file named after it.
|
|
17
|
+
const saveRollout = (home, id) => fs.writeFileSync(
|
|
18
|
+
path.join(home, 'codex', 'sessions', '2026', '10', '12', `rollout-2026-10-12T00-00-00-${id}.jsonl`),
|
|
19
|
+
JSON.stringify({ type: 'session_meta', payload: { id } }) + '\n');
|
|
15
20
|
const run = (home, session, payload) => execFileSync(process.execPath, [HOOK, JSON.stringify(payload)], {
|
|
16
|
-
env: { ...process.env, HOP_HOME: home, HOP_SESSION: session }
|
|
21
|
+
env: { ...process.env, HOP_HOME: home, HOP_SESSION: session, CODEX_HOME: path.join(home, 'codex') }
|
|
17
22
|
});
|
|
18
23
|
const turn = (home, name) => { try { return JSON.parse(fs.readFileSync(path.join(home, 'claude-sessions', `${name}.turn`), 'utf8')); } catch { return null; } };
|
|
19
24
|
|
|
@@ -25,19 +30,30 @@ test('a turn is counted against HOP_SESSION when no other session claims the thr
|
|
|
25
30
|
assert.equal(turn(home, 'alpha').agent, 'codex');
|
|
26
31
|
});
|
|
27
32
|
|
|
28
|
-
test(
|
|
33
|
+
test('the turn belongs to the session it ran in, even when another record names the thread', () => {
|
|
34
|
+
// Retired rule: "a record already naming this thread owns it" filed the
|
|
35
|
+
// hand-off session's turns under angler-codex for good. Every hop launch
|
|
36
|
+
// now carries its own session, so the environment is the truth.
|
|
29
37
|
const home = freshHome();
|
|
30
|
-
|
|
31
|
-
fs.writeFileSync(path.join(home, 'claude-sessions', '
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
assert.equal(turn(home, '
|
|
35
|
-
assert.equal(turn(home, '
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
saveRollout(home, 'thread-H'); saveRollout(home, 'thread-A');
|
|
39
|
+
fs.writeFileSync(path.join(home, 'claude-sessions', 'angler.turn'), JSON.stringify({ sessionId: 'thread-H', count: 9, at: '2026-10-09T00:00:00.000Z', agent: 'codex' }));
|
|
40
|
+
run(home, 'handoff', { type: 'agent-turn-complete', 'thread-id': 'thread-H', 'turn-id': 't1' });
|
|
41
|
+
assert.equal(turn(home, 'handoff').sessionId, 'thread-H');
|
|
42
|
+
assert.equal(turn(home, 'handoff').count, 1);
|
|
43
|
+
assert.equal(turn(home, 'angler').count, 9, 'the other record is left alone');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a thread Codex never saved (a helper like the approval guardian) does not replace the session's conversation", () => {
|
|
47
|
+
const home = freshHome();
|
|
48
|
+
saveRollout(home, 'thread-main');
|
|
49
|
+
run(home, 'alpha', { type: 'agent-turn-complete', 'thread-id': 'thread-main', 'turn-id': 't1' });
|
|
50
|
+
run(home, 'alpha', { type: 'agent-turn-complete', 'thread-id': 'thread-guardian', 'turn-id': 't2' });
|
|
51
|
+
assert.equal(turn(home, 'alpha').sessionId, 'thread-main', 'still the saved thread');
|
|
52
|
+
assert.equal(turn(home, 'alpha').count, 2, 'the turn still counts');
|
|
53
|
+
// A NEW saved thread (the person started over) does take over.
|
|
54
|
+
saveRollout(home, 'thread-next');
|
|
55
|
+
run(home, 'alpha', { type: 'agent-turn-complete', 'thread-id': 'thread-next', 'turn-id': 't3' });
|
|
56
|
+
assert.equal(turn(home, 'alpha').sessionId, 'thread-next');
|
|
41
57
|
});
|
|
42
58
|
|
|
43
59
|
test('other event types count nothing', () => {
|