hop2 0.11.74 → 0.11.76
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/hop +77 -5
- package/package.json +1 -1
- package/scripts/codex-notify-hook.js +25 -3
- package/tests/codex-notify-hook.unit.test.cjs +29 -13
package/hop
CHANGED
|
@@ -2733,6 +2733,20 @@ const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc
|
|
|
2733
2733
|
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
2734
|
// Trust-dialog reading lives in lib/trust-dialog.js so it can be unit-tested.
|
|
2735
2735
|
const { trustDialogWorkspace, trustDialogAnswer, samePath } = require('./lib/trust-dialog.js');
|
|
2736
|
+
// The transport cloudflared uses to reach Cloudflare's edge. Its default is
|
|
2737
|
+
// QUIC (UDP): on networks that shape or drop UDP — campus networks often do —
|
|
2738
|
+
// a lost packet stalls every request in flight on that connection for a
|
|
2739
|
+
// retransmit timeout, and keystrokes from a phone or laptop arrive in
|
|
2740
|
+
// 1–3 s bursts while the host itself is idle (measured 2026-10-11: QUIC
|
|
2741
|
+
// connection 0 had 120 timeout losses; one request in four took 1.2–2.9 s).
|
|
2742
|
+
// "http2" runs the tunnel over TCP instead. Set `tunnel_protocol` in
|
|
2743
|
+
// ~/.hop2 config ("quic" | "http2" | "auto") or HOP_TUNNEL_PROTOCOL.
|
|
2744
|
+
function getTunnelProtocol() {
|
|
2745
|
+
const raw = String(process.env.HOP_TUNNEL_PROTOCOL || getHopConfig().tunnel_protocol || '').toLowerCase();
|
|
2746
|
+
return raw === 'quic' || raw === 'http2' || raw === 'auto' ? raw : null;
|
|
2747
|
+
}
|
|
2748
|
+
const tunnelProtocolLine = () => { const p = getTunnelProtocol(); return p ? `protocol: ${p}\n` : ''; };
|
|
2749
|
+
|
|
2736
2750
|
function getResumeMode() {
|
|
2737
2751
|
const mode = String(getHopConfig().resume_mode || '').toLowerCase();
|
|
2738
2752
|
return mode === 'summary' || mode === 'ask' ? mode : 'full';
|
|
@@ -6021,6 +6035,20 @@ function runShellProbe(command, cwd) {
|
|
|
6021
6035
|
// agent-turn path has always sent them as separate writes; this is that.
|
|
6022
6036
|
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
6023
6037
|
// Is the message still sitting in the composer (the `›` line) — typed, not sent?
|
|
6038
|
+
/**
|
|
6039
|
+
* Is this session's agent up and showing its own UI — the only thing a
|
|
6040
|
+
* check-back may type into? Not running, a plain shell, a trust or resume
|
|
6041
|
+
* dialog, or an unreadable screen are all "no".
|
|
6042
|
+
*/
|
|
6043
|
+
async function agentScreenReady(entry) {
|
|
6044
|
+
if (!entry || entry.live !== true) return { ok: false, reason: 'waiting: the session is not running' };
|
|
6045
|
+
const verdict = await roomIsRedoable(entry);
|
|
6046
|
+
// roomIsRedoable says "not redoable, and here is whether the agent is
|
|
6047
|
+
// idle" exactly when an agent's UI owns the screen.
|
|
6048
|
+
if (!verdict.ok && verdict.agentIdle !== undefined) return { ok: true };
|
|
6049
|
+
return { ok: false, reason: `waiting: no agent on screen (${verdict.reason})` };
|
|
6050
|
+
}
|
|
6051
|
+
|
|
6024
6052
|
async function composerStillHolds(internalName, message) {
|
|
6025
6053
|
try {
|
|
6026
6054
|
const hostRes = await callExternalHayHostApi(`/rooms/${encodeURIComponent(internalName)}/serialized`);
|
|
@@ -6100,6 +6128,17 @@ async function runCheckbackSweep() {
|
|
|
6100
6128
|
continue;
|
|
6101
6129
|
}
|
|
6102
6130
|
if (!checkbacks.mayDeliver(cb, ctx)) continue; // mid-turn: next sweep
|
|
6131
|
+
// Type only into a RUNNING agent. Attaching to a stopped
|
|
6132
|
+
// session creates its room — as a bare shell — and the
|
|
6133
|
+
// message then ran as shell commands ("zsh: command not
|
|
6134
|
+
// found: Collect"); worse, that shell made restore think the
|
|
6135
|
+
// session was up and skip it (surf, alphagenome, 2026-10-12
|
|
6136
|
+
// after a reboot). Wait until the agent's own UI is on screen.
|
|
6137
|
+
const ready = await agentScreenReady(entry);
|
|
6138
|
+
if (!ready.ok) {
|
|
6139
|
+
if (cb.lastError !== ready.reason) { cb.lastError = ready.reason; dirty = true; }
|
|
6140
|
+
continue;
|
|
6141
|
+
}
|
|
6103
6142
|
const e2 = await attachTerminalApiClient(cb.session, entry.displayName || cb.session, { actor: 'system', label: 'checkback', typeOnly: true });
|
|
6104
6143
|
try { await typeAndSubmit(e2, cb.session, cb.message); }
|
|
6105
6144
|
finally { try { closeTerminalApiEntry(e2.id); } catch (e) { /* closed */ } }
|
|
@@ -6384,8 +6423,13 @@ function rebuildClaudeLaunch(launchCmd) {
|
|
|
6384
6423
|
if (!valued && out.includes(t)) continue;
|
|
6385
6424
|
out.push(t);
|
|
6386
6425
|
if (valued) out.push(tokens[++i]);
|
|
6426
|
+
continue;
|
|
6387
6427
|
}
|
|
6388
|
-
// bare positional
|
|
6428
|
+
// A bare positional is the initial PROMPT, and everything after it
|
|
6429
|
+
// is prompt text too — split on spaces, its words included things
|
|
6430
|
+
// like "--source-ref" and "-q", which were kept as flags and made
|
|
6431
|
+
// claude exit at once on every restore (pulse-1004-claude-o3).
|
|
6432
|
+
break;
|
|
6389
6433
|
}
|
|
6390
6434
|
return out.join(' ');
|
|
6391
6435
|
}
|
|
@@ -6965,6 +7009,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6965
7009
|
const { resolved, unknown } = resolveRestoreTargets(only, snapshot);
|
|
6966
7010
|
for (const name of unknown) skipped.push({ name, reason: 'no such session' });
|
|
6967
7011
|
onlySet = resolved;
|
|
7012
|
+
let closedAny = false;
|
|
6968
7013
|
for (const internalName of resolved) {
|
|
6969
7014
|
if (!live.has(internalName)) continue;
|
|
6970
7015
|
const entry = snapshot.entries.find((e) => e.internalName === internalName);
|
|
@@ -6999,7 +7044,13 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6999
7044
|
}
|
|
7000
7045
|
log(` ${display}: closed the ${verdict.reason} left by the last restore`, DIM);
|
|
7001
7046
|
live.delete(internalName);
|
|
7047
|
+
closedAny = true;
|
|
7002
7048
|
}
|
|
7049
|
+
// The keeper ends the old terminal a beat AFTER the room closes. A
|
|
7050
|
+
// relaunch inside that beat was refused ("already running"), and
|
|
7051
|
+
// then the old one exited too — both sessions vanished (alphagenome,
|
|
7052
|
+
// angler-codex-claude-codex, 2026-10-12). Let the old ones go first.
|
|
7053
|
+
if (closedAny) await new Promise((r) => setTimeout(r, 2000));
|
|
7003
7054
|
}
|
|
7004
7055
|
const candidates = snapshot.entries.filter((e) => e && e.type === 'terminal' && !live.has(e.internalName));
|
|
7005
7056
|
// Primary source: the SessionStart-hook records. Add any recorded session that
|
|
@@ -13320,7 +13371,11 @@ happens until someone clicks it.
|
|
|
13320
13371
|
const result = await restoreSavedSessions({
|
|
13321
13372
|
dryRun: parsed.dryRun === true,
|
|
13322
13373
|
includeAgents: parsed.includeAgents === true,
|
|
13323
|
-
only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null
|
|
13374
|
+
only: Array.isArray(parsed.only) ? parsed.only.filter((n) => typeof n === 'string').slice(0, 200) : null,
|
|
13375
|
+
// Restart named agents that sit idle at their prompt
|
|
13376
|
+
// (never one mid-turn) — e.g. one resumed the wrong
|
|
13377
|
+
// conversation. Only together with `only`.
|
|
13378
|
+
replaceIdleAgent: parsed.replaceIdleAgent === true && Array.isArray(parsed.only)
|
|
13324
13379
|
});
|
|
13325
13380
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13326
13381
|
res.end(JSON.stringify(result));
|
|
@@ -14497,7 +14552,24 @@ happens until someone clicks it.
|
|
|
14497
14552
|
return;
|
|
14498
14553
|
}
|
|
14499
14554
|
await reconcileExternalRuntimeSessions();
|
|
14500
|
-
|
|
14555
|
+
// A dead session that holds an agent conversation comes
|
|
14556
|
+
// back AS that agent, whoever opens it — a viewer, a
|
|
14557
|
+
// check-back, a tile. Creating a bare shell here left
|
|
14558
|
+
// surf a zsh prompt after a reboot (a check-back's attach
|
|
14559
|
+
// beat restore by two seconds, and restore then skipped
|
|
14560
|
+
// the "running" session). Same plan restore would use.
|
|
14561
|
+
let revive = null;
|
|
14562
|
+
if (!sessionConfig?.startup && !(await runtimeTerminalSessionExists(sessionName))) {
|
|
14563
|
+
try {
|
|
14564
|
+
const plan = restorePlanForSession({ internalName: sessionName, cwd: sessionConfig?.cwd, startup: sessionConfig?.startup, autoStart: sessionConfig?.autoStart });
|
|
14565
|
+
if (plan?.command) {
|
|
14566
|
+
revive = plan;
|
|
14567
|
+
log(`[ws] ${sessionName}: not running — starting it as its agent (${plan.command.slice(0, 80)})`, DIM);
|
|
14568
|
+
}
|
|
14569
|
+
} catch (e) { /* no plan: a plain shell, as before */ }
|
|
14570
|
+
}
|
|
14571
|
+
await ensureExternalRuntimeSession(sessionName, revive?.cwd || sessionConfig?.cwd, getSessionDisplayName(sessionName), {
|
|
14572
|
+
command: revive?.command || undefined,
|
|
14501
14573
|
shell: sessionConfig?.shell,
|
|
14502
14574
|
env: sessionConfig?.env,
|
|
14503
14575
|
cols: sessionConfig?.cols,
|
|
@@ -14729,7 +14801,7 @@ function startTunnel(isRestart = false) {
|
|
|
14729
14801
|
const tunnelConfig = `
|
|
14730
14802
|
url: http://${localTunnelHost}:${PROXY_PORT}
|
|
14731
14803
|
tunnel: ${domainConfig.tunnelName}
|
|
14732
|
-
`;
|
|
14804
|
+
${tunnelProtocolLine()}`;
|
|
14733
14805
|
writePrivateFile(tunnelConfigPath, tunnelConfig);
|
|
14734
14806
|
|
|
14735
14807
|
const runArgs = ['tunnel', '--config', tunnelConfigPath, 'run'];
|
|
@@ -14907,7 +14979,7 @@ function startClientTunnel(isRestart = false) {
|
|
|
14907
14979
|
url: http://127.0.0.1:${PROXY_PORT}
|
|
14908
14980
|
tunnel: ${tunnelId}
|
|
14909
14981
|
credentials-file: ${path.resolve(credentialsPath)}
|
|
14910
|
-
`;
|
|
14982
|
+
${tunnelProtocolLine()}`;
|
|
14911
14983
|
writePrivateFile(tunnelConfigPath, tunnelConfig);
|
|
14912
14984
|
|
|
14913
14985
|
const tunnel = spawn('cloudflared', ['tunnel', '--config', tunnelConfigPath, 'run'], {
|
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
|
|
@@ -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', () => {
|