hop2 0.11.61 → 0.11.63
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 +83 -16
- package/package.json +1 -1
- package/scripts/codex-notify-hook.js +46 -4
package/hop
CHANGED
|
@@ -5530,6 +5530,48 @@ function findCodexRolloutById(sessionId) {
|
|
|
5530
5530
|
return null;
|
|
5531
5531
|
}
|
|
5532
5532
|
|
|
5533
|
+
/**
|
|
5534
|
+
* The first line of a rollout (its session_meta), read WITHOUT loading the
|
|
5535
|
+
* file — a long-lived codex thread runs to hundreds of MB.
|
|
5536
|
+
*/
|
|
5537
|
+
function readCodexRolloutMeta(file) {
|
|
5538
|
+
let fd = null;
|
|
5539
|
+
try {
|
|
5540
|
+
fd = fs.openSync(file, 'r');
|
|
5541
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
5542
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
5543
|
+
const text = buf.toString('utf8', 0, n);
|
|
5544
|
+
const line = text.slice(0, text.indexOf('\n') >= 0 ? text.indexOf('\n') : text.length);
|
|
5545
|
+
const parsed = JSON.parse(line);
|
|
5546
|
+
return parsed && parsed.type === 'session_meta' ? (parsed.payload || null) : null;
|
|
5547
|
+
} catch (e) {
|
|
5548
|
+
return null;
|
|
5549
|
+
} finally {
|
|
5550
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (e) { /* ignore */ } }
|
|
5551
|
+
}
|
|
5552
|
+
}
|
|
5553
|
+
|
|
5554
|
+
/**
|
|
5555
|
+
* The thread `codex resume` must be given for a conversation: the ROOT. The
|
|
5556
|
+
* notify hook records whichever thread finished a turn, and with multi-agent
|
|
5557
|
+
* codex that is often a sub-agent (its rollout's session_meta carries a
|
|
5558
|
+
* parent_thread_id); resuming a sub-agent is refused ("resume the parent
|
|
5559
|
+
* first"), which stranded a restored session in a bare shell. Walk up to the
|
|
5560
|
+
* root, reading only each rollout's first line. Unknown ids come back as given.
|
|
5561
|
+
*/
|
|
5562
|
+
function codexRootThreadId(sessionId) {
|
|
5563
|
+
let id = sessionId;
|
|
5564
|
+
for (let hops = 0; hops < 8 && id; hops++) {
|
|
5565
|
+
const file = findCodexRolloutById(id);
|
|
5566
|
+
if (!file) return id;
|
|
5567
|
+
const meta = readCodexRolloutMeta(file);
|
|
5568
|
+
const parent = meta && typeof meta.parent_thread_id === 'string' ? meta.parent_thread_id : null;
|
|
5569
|
+
if (!parent || parent === id) return id;
|
|
5570
|
+
id = parent;
|
|
5571
|
+
}
|
|
5572
|
+
return id;
|
|
5573
|
+
}
|
|
5574
|
+
|
|
5533
5575
|
// Find a transcript for a bare session id across every claude config root,
|
|
5534
5576
|
// returning which root (= the CLAUDE_CONFIG_DIR to resume under) holds it.
|
|
5535
5577
|
function findClaudeTranscriptById(sessionId) {
|
|
@@ -5947,6 +5989,26 @@ function rebuildClaudeLaunch(launchCmd) {
|
|
|
5947
5989
|
// that project directory.
|
|
5948
5990
|
function restorePlanForSession(entry) {
|
|
5949
5991
|
let rec = readClaudeSessionRecord(entry.internalName);
|
|
5992
|
+
// Codex has no SessionStart record; its notify hook leaves only the
|
|
5993
|
+
// .turn naming the thread. Without this, every codex session came back
|
|
5994
|
+
// as a bare shell with its conversation stranded.
|
|
5995
|
+
if (!rec) {
|
|
5996
|
+
const turn = readTurnInfo(entry.internalName);
|
|
5997
|
+
if (turn && turn.agent === 'codex') {
|
|
5998
|
+
const metaCwd = readClaudeSessionMeta(entry.internalName)?.cwd;
|
|
5999
|
+
const cwd = metaCwd || entry.cwd;
|
|
6000
|
+
// The hook may have named a sub-agent's thread; resume the root.
|
|
6001
|
+
const rootId = codexRootThreadId(turn.sessionId);
|
|
6002
|
+
if (!findCodexRolloutById(rootId)) {
|
|
6003
|
+
return {
|
|
6004
|
+
command: null,
|
|
6005
|
+
cwd,
|
|
6006
|
+
warning: `recorded codex conversation ${String(turn.sessionId).slice(0, 8)}… has no rollout on this host — opening a plain shell instead`
|
|
6007
|
+
};
|
|
6008
|
+
}
|
|
6009
|
+
return { command: `codex resume '${rootId.replace(/'/g, `'\\''`)}'`, cwd, agent: 'codex' };
|
|
6010
|
+
}
|
|
6011
|
+
}
|
|
5950
6012
|
// No SessionStart record, but the turn counter may still name this room's
|
|
5951
6013
|
// conversation (the two are written independently). Recover a record from
|
|
5952
6014
|
// it when the transcript is real — otherwise this room would drop to a
|
|
@@ -6147,6 +6209,7 @@ function recordRestoreExpectations(planned) {
|
|
|
6147
6209
|
const state = readRestoreReconcile();
|
|
6148
6210
|
for (const p of planned || []) {
|
|
6149
6211
|
if (!p || !p.internalName || !p.command) continue; // no claude command → nothing to converge
|
|
6212
|
+
if (p.agent && p.agent !== 'claude') continue; // the reconciler only knows claude
|
|
6150
6213
|
state.sessions[p.internalName] = {
|
|
6151
6214
|
internalName: p.internalName,
|
|
6152
6215
|
displayName: p.displayName || p.internalName,
|
|
@@ -6500,6 +6563,7 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6500
6563
|
displayName: e.displayName || e.internalName,
|
|
6501
6564
|
cwd: plan.cwd || e.cwd || process.cwd(),
|
|
6502
6565
|
command: plan.command,
|
|
6566
|
+
agent: plan.agent || (plan.command ? 'claude' : null),
|
|
6503
6567
|
warning: plan.warning || null,
|
|
6504
6568
|
// Plain-shell sessions (no claude/startup command) replay their last
|
|
6505
6569
|
// screen if we persisted a buffer for them at the previous shutdown.
|
|
@@ -6544,7 +6608,11 @@ async function restoreSavedSessionsUnlocked({ dryRun = false, includeAgents = fa
|
|
|
6544
6608
|
} catch (e) {
|
|
6545
6609
|
continue;
|
|
6546
6610
|
}
|
|
6547
|
-
if (p.command && entry) {
|
|
6611
|
+
if (p.command && entry && p.agent !== 'claude') {
|
|
6612
|
+
// The verifier below reads claude's chrome (and may re-type the
|
|
6613
|
+
// command); a codex resume is launched as argv and left alone.
|
|
6614
|
+
setTimeout(() => { try { closeTerminalApiEntry(entry.id); } catch (e) { /* ignore */ } }, 600);
|
|
6615
|
+
} else if (p.command && entry) {
|
|
6548
6616
|
// Launched with the command as argv: nothing to type — just
|
|
6549
6617
|
// confirm it actually came up.
|
|
6550
6618
|
pending.push({ plan: p, entry, state: 'verify', attempts: 1, since: Date.now() });
|
|
@@ -11681,7 +11749,7 @@ happens until someone clicks it.
|
|
|
11681
11749
|
// names the agent and the conversation — the process name
|
|
11682
11750
|
// says "node" and cannot), or a plain shell.
|
|
11683
11751
|
const turn = readTurnInfo(source);
|
|
11684
|
-
const codexId = turn?.agent === 'codex' ? turn.sessionId : null;
|
|
11752
|
+
const codexId = turn?.agent === 'codex' ? codexRootThreadId(turn.sessionId) : null;
|
|
11685
11753
|
const runsCodex = !!codexId
|
|
11686
11754
|
|| /codex/i.test(String(config.foregroundProcess || ''))
|
|
11687
11755
|
|| /codex/i.test(String(getRuntimeSessionMetadata(source)?.foregroundProcess || ''));
|
|
@@ -16201,23 +16269,22 @@ async function main() {
|
|
|
16201
16269
|
}
|
|
16202
16270
|
|
|
16203
16271
|
if (stopAll) {
|
|
16204
|
-
//
|
|
16205
|
-
//
|
|
16272
|
+
// Stop the daemon, then SIGTERM the hay-host. Sessions are NOT
|
|
16273
|
+
// deleted: /api/sessions/delete is the explicit "forget this
|
|
16274
|
+
// session" path (drops its config, display name, and restore
|
|
16275
|
+
// records), so calling it here made every `stop --all` wipe the
|
|
16276
|
+
// restore inventory — 2026-09-26 a full restart lost all 34 live
|
|
16277
|
+
// sessions and `hop restore` found nothing of them. The host's
|
|
16278
|
+
// graceful shutdown persists buffers and keeps every record.
|
|
16206
16279
|
if (state) {
|
|
16207
16280
|
try {
|
|
16208
16281
|
const sessionsRes = await fetchSessionList(state);
|
|
16209
16282
|
const sessions = Array.isArray(sessionsRes?.sessions) ? sessionsRes.sessions : [];
|
|
16210
|
-
|
|
16211
|
-
|
|
16212
|
-
|
|
16213
|
-
} catch (e) { }
|
|
16283
|
+
const liveCount = sessions.filter((s) => s && s.live !== false).length;
|
|
16284
|
+
if (liveCount > 0) {
|
|
16285
|
+
log(`Ending ${liveCount} session(s) — restore records kept (${CYAN}hop restore${RESET}${GREEN} brings them back).`, GREEN);
|
|
16214
16286
|
}
|
|
16215
|
-
|
|
16216
|
-
log(`Closed ${sessions.length} session(s).`, GREEN);
|
|
16217
|
-
}
|
|
16218
|
-
} catch (e) {
|
|
16219
|
-
log(`Warning: could not close sessions via daemon: ${e.message}`, YELLOW);
|
|
16220
|
-
}
|
|
16287
|
+
} catch (e) { /* count is informational only */ }
|
|
16221
16288
|
try {
|
|
16222
16289
|
process.kill(state.pid, 'SIGTERM');
|
|
16223
16290
|
log(`Hop daemon stopped.`, GREEN);
|
|
@@ -16227,7 +16294,7 @@ async function main() {
|
|
|
16227
16294
|
}
|
|
16228
16295
|
}
|
|
16229
16296
|
if (hayHostAlive) {
|
|
16230
|
-
const stopped = await stopExternalHayHostProcess(hayHost);
|
|
16297
|
+
const stopped = await stopExternalHayHostProcess(hayHost, 8000);
|
|
16231
16298
|
if (!stopped) {
|
|
16232
16299
|
log(`Failed to stop hay-host (pid ${hayHost.pid}).`, RED);
|
|
16233
16300
|
process.exit(1);
|
|
@@ -16483,7 +16550,7 @@ async function main() {
|
|
|
16483
16550
|
// by the newest rollout in this directory.
|
|
16484
16551
|
const turn = readTurnInfo(target);
|
|
16485
16552
|
const rollout = turn?.agent === 'codex' ? null : resolveCodexTranscriptByCwd(cwd);
|
|
16486
|
-
const rolloutId = turn?.agent === 'codex' ? turn.sessionId : (rollout ? await trajectoryExtract.codexRolloutId(rollout) : null);
|
|
16553
|
+
const rolloutId = turn?.agent === 'codex' ? codexRootThreadId(turn.sessionId) : (rollout ? await trajectoryExtract.codexRolloutId(rollout) : null);
|
|
16487
16554
|
if (rolloutId) {
|
|
16488
16555
|
command = `codex resume '${rolloutId.replace(/'/g, `'\\''`)}'`;
|
|
16489
16556
|
kind = 'codex';
|
package/package.json
CHANGED
|
@@ -31,6 +31,45 @@ function turnFilePath() {
|
|
|
31
31
|
return path.join(dir, `${process.env.HOP_SESSION}.turn`);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// A sub-agent's rollout names its parent in its first line (session_meta →
|
|
35
|
+
// parent_thread_id). Walk up to the root, reading only that line of each
|
|
36
|
+
// file — a long thread's rollout runs to hundreds of MB. Any failure leaves
|
|
37
|
+
// the id as given; hop resolves it again at restore time.
|
|
38
|
+
function findRollout(id) {
|
|
39
|
+
const root = path.join(os.homedir(), ".codex", "sessions");
|
|
40
|
+
const walk = (dir, depth) => {
|
|
41
|
+
let names = [];
|
|
42
|
+
try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return null; }
|
|
43
|
+
for (const e of names) {
|
|
44
|
+
const p = path.join(dir, e.name);
|
|
45
|
+
if (e.isDirectory() && depth < 3) { const hit = walk(p, depth + 1); if (hit) return hit; }
|
|
46
|
+
else if (e.isFile() && e.name.endsWith(".jsonl") && e.name.includes(id)) return p;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
return walk(root, 0);
|
|
51
|
+
}
|
|
52
|
+
function rootThreadId(id) {
|
|
53
|
+
let cur = id;
|
|
54
|
+
for (let hops = 0; hops < 8 && cur; hops++) {
|
|
55
|
+
const file = findRollout(cur);
|
|
56
|
+
if (!file) return cur;
|
|
57
|
+
let parent = null;
|
|
58
|
+
try {
|
|
59
|
+
const fd = fs.openSync(file, "r");
|
|
60
|
+
const buf = Buffer.alloc(65536);
|
|
61
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
62
|
+
fs.closeSync(fd);
|
|
63
|
+
const text = buf.toString("utf8", 0, n);
|
|
64
|
+
const meta = JSON.parse(text.slice(0, text.indexOf("\n") >= 0 ? text.indexOf("\n") : text.length));
|
|
65
|
+
parent = meta && meta.payload && typeof meta.payload.parent_thread_id === "string" ? meta.payload.parent_thread_id : null;
|
|
66
|
+
} catch { return cur; }
|
|
67
|
+
if (!parent || parent === cur) return cur;
|
|
68
|
+
cur = parent;
|
|
69
|
+
}
|
|
70
|
+
return cur;
|
|
71
|
+
}
|
|
72
|
+
|
|
34
73
|
// Same crash-safe write + same {sessionId, count, at} shape the Claude hook
|
|
35
74
|
// uses, so hop's finishBump() reads one number regardless of which agent wrote.
|
|
36
75
|
function bumpTurn(payload) {
|
|
@@ -42,12 +81,15 @@ function bumpTurn(payload) {
|
|
|
42
81
|
const prev = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
43
82
|
if (prev && Number.isInteger(prev.count) && prev.count >= 0) count = prev.count;
|
|
44
83
|
} catch { /* missing/corrupt -> start at 0 */ }
|
|
84
|
+
const threadId = (payload && typeof payload["thread-id"] === "string") ? payload["thread-id"]
|
|
85
|
+
: (payload && typeof payload.thread_id === "string") ? payload.thread_id
|
|
86
|
+
: (payload && typeof payload.session_id === "string") ? payload.session_id : null;
|
|
45
87
|
const rec = JSON.stringify({
|
|
46
88
|
// The thread id is the conversation (and the rollout file's name); the
|
|
47
|
-
// turn id names one turn and matches nothing on disk.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
89
|
+
// turn id names one turn and matches nothing on disk. With multi-agent
|
|
90
|
+
// codex the thread that finished may be a SUB-agent, which cannot be
|
|
91
|
+
// resumed on its own — record the root, which can.
|
|
92
|
+
sessionId: rootThreadId(threadId),
|
|
51
93
|
count: count + 1,
|
|
52
94
|
at: new Date().toISOString(),
|
|
53
95
|
agent: "codex"
|