hop2 0.11.39 → 0.11.41

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 CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { spawn, execSync, execFile, execFileSync } = require('child_process');
4
+ const trajectoryExtract = require('./scripts/trajectory-extract.js');
4
5
  const fs = require('fs');
5
6
  const path = require('path');
6
7
  const crypto = require('crypto');
@@ -539,6 +540,45 @@ function enforceViewBudget() {
539
540
  } catch (e) { /* a failed sweep never blocks a publish */ }
540
541
  }
541
542
 
543
+ // The uploads cache's housekeeping — same shape as enforceViewBudget. Runs
544
+ // after every upload AND hourly on a timer, so the cache shrinks even on a
545
+ // quiet day. Copies only: the phone/browser still has the original.
546
+ function enforceUploadBudget() {
547
+ const root = path.join(HOME_DIR, 'uploads');
548
+ try {
549
+ const files = [];
550
+ const now = Date.now();
551
+ for (const sess of fs.readdirSync(root)) {
552
+ const dir = path.join(root, sess);
553
+ let st; try { st = fs.statSync(dir); } catch (e) { continue; }
554
+ if (!st.isDirectory()) continue;
555
+ for (const f of fs.readdirSync(dir)) {
556
+ const p = path.join(dir, f);
557
+ let fst; try { fst = fs.statSync(p); } catch (e) { continue; }
558
+ if (!fst.isFile()) continue;
559
+ if (now - fst.mtimeMs > UPLOADS_MAX_AGE_MS) {
560
+ try { fs.rmSync(p); log(`[uploads] aged out ${sess}/${f}`, DIM); } catch (e) { /* next */ }
561
+ continue;
562
+ }
563
+ files.push({ p, sess, f, bytes: fst.size, mtime: fst.mtimeMs });
564
+ }
565
+ // A session folder emptied by the sweep is just clutter.
566
+ try { if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } catch (e) { /* ignore */ }
567
+ }
568
+ let total = files.reduce((a, b) => a + b.bytes, 0);
569
+ if (total <= UPLOADS_BUDGET_BYTES) return;
570
+ files.sort((a, b) => a.mtime - b.mtime);
571
+ for (const it of files) {
572
+ if (total <= UPLOADS_BUDGET_BYTES) break;
573
+ try {
574
+ fs.rmSync(it.p);
575
+ total -= it.bytes;
576
+ log(`[uploads] budget: evicted ${it.sess}/${it.f} (${Math.round(it.bytes / 1024)} KB, oldest first)`, DIM);
577
+ } catch (e) { /* the next candidate still evicts */ }
578
+ }
579
+ } catch (e) { /* no uploads dir yet — and a failed sweep never blocks an upload */ }
580
+ }
581
+
542
582
  // Does the proxied session behind a Views server row still exist? Answered
543
583
  // from the ports ledger on disk (plus the in-memory store, when this is the
544
584
  // daemon), so the CLI and the daemon agree.
@@ -645,6 +685,12 @@ const SESSION_COOKIE_NAME = 'tunnel_session';
645
685
  const LOG_DIR = path.join(HOME_DIR, 'logs');
646
686
  const SESSION_NAME_RE = /^[a-zA-Z0-9_-]+$/;
647
687
  const UPLOAD_MAX_BYTES = parsePositiveIntEnv('HOP_UPLOAD_MAX_BYTES', 100 * 1024 * 1024);
688
+ // Uploads (photos from the phone, files dropped on the web) are transient
689
+ // inputs — once the agent has read one it is dead weight, and nothing else
690
+ // ever deleted them (Jian: "we need to clean cache periodically"). Two
691
+ // limits: an AGE sweep and a SIZE budget, oldest evicted first.
692
+ const UPLOADS_BUDGET_BYTES = parsePositiveIntEnv('HOP_UPLOADS_BUDGET_MB', 512) * 1024 * 1024;
693
+ const UPLOADS_MAX_AGE_MS = parsePositiveIntEnv('HOP_UPLOADS_MAX_AGE_DAYS', 14) * 86400000;
648
694
  const DEFAULT_WORKSPACE_NAME = 'default';
649
695
  const HISTORY_DIR_NAME = 'history';
650
696
  const WORKSPACE_LOGS_DIR_NAME = 'logs';
@@ -914,7 +960,19 @@ function resolveDisplayNameClaim(internalName, claimed, liveIds = null) {
914
960
  if (claimed === internalName) return claimed;
915
961
  const owner = toInternalSessionName(claimed) || claimed;
916
962
  const taken = liveIds ? liveIds.has(owner) : hopSessionExists(owner);
917
- return taken ? own : claimed;
963
+ if (!taken) return claimed;
964
+ // The borrower yields the name, but a minted id ("s_f131c76e88") is not
965
+ // a name a person can use: nine of those appeared in the wall at once
966
+ // when `hop restore` brought back legacy rooms whose ids were the astro
967
+ // names newer sessions had since been given (2026-09-04). Keep the name
968
+ // it was given and mark WHICH one this is — unique, stable, and no
969
+ // lookups, which matters because this runs inside every config read.
970
+ return isMintedSessionId(internalName) ? `${claimed} (${internalName.slice(2, 6)})` : own;
971
+ }
972
+
973
+ /** A machine-minted session id (see mintSessionInternalName), never a name. */
974
+ function isMintedSessionId(internalName) {
975
+ return /^s_[0-9a-f]{10}$/.test(String(internalName || ''));
918
976
  }
919
977
 
920
978
  function toDisplaySessionName(internalName) {
@@ -11073,7 +11131,7 @@ happens until someone clicks it.
11073
11131
  req.on('data', chunk => body += chunk);
11074
11132
  req.on('end', async () => {
11075
11133
  try {
11076
- const { name, internalName } = JSON.parse(body);
11134
+ const { name, internalName, target } = JSON.parse(body);
11077
11135
  const source = resolveSessionInternalName(name, internalName);
11078
11136
  if (!source || !(hopSessionExists(source) || getEffectiveSessionConfig(source))) {
11079
11137
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -11094,27 +11152,82 @@ happens until someone clicks it.
11094
11152
  const plausibleCwd = (c) => c && !/[\\/]\.claude[^\\/]*[\\/]projects([\\/]|$)/.test(String(c));
11095
11153
  const cwd = [rec?.cwd, meta?.cwd, config.cwd].find(plausibleCwd) || undefined;
11096
11154
 
11097
- let command;
11098
- let kind = 'shell';
11099
- if (rec) {
11100
- const base = rebuildClaudeLaunch(rec.launchCmd) || 'claude';
11101
- command = base + ' --resume ' + rec.sessionId + ' --fork-session';
11102
- kind = 'claude';
11103
- } else if (/codex/i.test(String(config.foregroundProcess || '')) ||
11104
- /codex/i.test(String(getRuntimeSessionMetadata(source)?.foregroundProcess || ''))) {
11105
- command = 'codex resume';
11106
- kind = 'codex';
11155
+ // What the source is running: a claude conversation (the
11156
+ // hook's record), codex (by process — codex has no hook),
11157
+ // or a plain shell.
11158
+ const runsCodex = /codex/i.test(String(config.foregroundProcess || ''))
11159
+ || /codex/i.test(String(getRuntimeSessionMetadata(source)?.foregroundProcess || ''));
11160
+ const sourceKind = rec ? 'claude' : runsCodex ? 'codex' : 'shell';
11161
+ const wantTarget = typeof target === 'string' && target.trim() ? target.trim().toLowerCase() : null;
11162
+ if (wantTarget && wantTarget !== 'claude' && wantTarget !== 'codex') {
11163
+ res.writeHead(400, { 'Content-Type': 'application/json' });
11164
+ res.end(JSON.stringify({ error: `Unknown fork target "${wantTarget}" — claude or codex` }));
11165
+ return;
11166
+ }
11167
+ // Crossing tools is a HANDOFF, not a fork: neither tool can
11168
+ // resume the other's transcript, so the conversation is
11169
+ // extracted to a document the new agent reads before its
11170
+ // first turn (scripts/trajectory-extract.js). Same tool,
11171
+ // or no target, is the conversation fork as before.
11172
+ const handoff = !!wantTarget && wantTarget !== sourceKind;
11173
+ if (handoff && sourceKind === 'shell') {
11174
+ res.writeHead(400, { 'Content-Type': 'application/json' });
11175
+ res.end(JSON.stringify({ error: 'This session has no claude or codex conversation to hand off' }));
11176
+ return;
11107
11177
  }
11178
+ const codexRollout = sourceKind === 'codex' ? resolveCodexTranscriptByCwd(cwd) : null;
11108
11179
 
11109
11180
  const sourceDisplay = getSessionDisplayName(source) || source;
11110
- let forkName = sourceDisplay + '-fork';
11181
+ const suffix = handoff ? '-' + wantTarget : '-fork';
11182
+ let forkName = sourceDisplay + suffix;
11111
11183
  let n = 2;
11112
11184
  while (isDisplayNameInUse(forkName) || hopSessionExists(toInternalSessionName(forkName))) {
11113
- forkName = sourceDisplay + '-fork' + n;
11185
+ forkName = sourceDisplay + suffix + n;
11114
11186
  n += 1;
11115
11187
  }
11116
11188
  const forkInternal = toInternalSessionName(forkName);
11117
11189
 
11190
+ let command;
11191
+ let kind = 'shell';
11192
+ let extract = null;
11193
+ const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
11194
+ if (handoff) {
11195
+ const transcript = sourceKind === 'claude' ? resolveClaudeTranscriptPath(rec) : codexRollout;
11196
+ if (!transcript) {
11197
+ res.writeHead(404, { 'Content-Type': 'application/json' });
11198
+ res.end(JSON.stringify({ error: `No ${sourceKind} transcript found for ${sourceDisplay} to hand off` }));
11199
+ return;
11200
+ }
11201
+ // Budget by what the RECEIVER can hold: Codex's context is
11202
+ // a fraction of Claude's, and a document it cannot finish
11203
+ // reading is worse than a shorter one it can.
11204
+ const maxChars = wantTarget === 'codex' ? 400000 : 800000;
11205
+ const result = await trajectoryExtract.extractConversation(transcript, { tool: sourceKind, maxChars });
11206
+ // Staged where uploads for the new session live: the
11207
+ // one per-session directory that already exists for
11208
+ // "a file this session is meant to read".
11209
+ const stage = path.join(HOME_DIR, 'uploads', forkInternal);
11210
+ fs.mkdirSync(stage, { recursive: true, mode: 0o700 });
11211
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
11212
+ const extractPath = path.join(stage, `handoff-from-${sourceDisplay}-${stamp}.md`);
11213
+ writePrivateFile(extractPath, result.text);
11214
+ const bytes = Buffer.byteLength(result.text);
11215
+ const prompt = trajectoryExtract.handoffPrompt(extractPath, bytes, sourceKind);
11216
+ command = `${wantTarget} ${shq(prompt)}`;
11217
+ kind = 'handoff';
11218
+ extract = { path: extractPath, bytes, turns: result.turns, totalTurns: result.totalTurns };
11219
+ } else if (rec) {
11220
+ const base = rebuildClaudeLaunch(rec.launchCmd) || 'claude';
11221
+ command = base + ' --resume ' + rec.sessionId + ' --fork-session';
11222
+ kind = 'claude';
11223
+ } else if (runsCodex) {
11224
+ // By id when the rollout is identifiable — the picker
11225
+ // otherwise, which asks the user to find it.
11226
+ const rolloutId = codexRollout ? await trajectoryExtract.codexRolloutId(codexRollout) : null;
11227
+ command = rolloutId ? `codex resume ${shq(rolloutId)}` : 'codex resume';
11228
+ kind = 'codex';
11229
+ }
11230
+
11118
11231
  // The fork inherits the source's origin: forking an agent
11119
11232
  // worker makes another agent worker; forking your own
11120
11233
  // session makes yours.
@@ -11137,10 +11250,11 @@ happens until someone clicks it.
11137
11250
  displayName: forkName,
11138
11251
  cwd,
11139
11252
  createdBy,
11140
- forkedFrom: source
11253
+ forkedFrom: source,
11254
+ ...(handoff ? { handoffFrom: sourceKind, handoffTo: wantTarget } : {})
11141
11255
  });
11142
11256
  res.writeHead(200, { 'Content-Type': 'application/json' });
11143
- res.end(JSON.stringify({ ok: true, name: forkName, internalName: forkInternal, kind }));
11257
+ res.end(JSON.stringify({ ok: true, name: forkName, internalName: forkInternal, kind, target: handoff ? wantTarget : sourceKind, extract }));
11144
11258
  } catch (e) {
11145
11259
  res.writeHead(500, { 'Content-Type': 'application/json' });
11146
11260
  res.end(JSON.stringify({ error: e?.message || 'Fork failed' }));
@@ -11386,6 +11500,7 @@ happens until someone clicks it.
11386
11500
  fs.mkdirSync(dir, { recursive: true });
11387
11501
  const filePath = uniqueUploadPath(dir, safeUploadFilename(parsedUrl.query.filename));
11388
11502
  fs.writeFileSync(filePath, Buffer.concat(chunks), { mode: 0o600 });
11503
+ enforceUploadBudget(); // every upload pays its way; hourly timer covers idle days
11389
11504
  log(`[upload] ${getSessionDisplayName(internalName) || internalName}: ${path.basename(filePath)} (${received} bytes)`, DIM);
11390
11505
  res.writeHead(200, { 'Content-Type': 'application/json' });
11391
11506
  res.end(JSON.stringify({ ok: true, path: filePath, name: path.basename(filePath), bytes: received }));
@@ -11573,6 +11688,17 @@ happens until someone clicks it.
11573
11688
  req.on('end', async () => {
11574
11689
  try {
11575
11690
  let { name, type, port, cwd, folderId, startRuntime, workspace, workspaceName, attachedTo, startup } = JSON.parse(body);
11691
+ // The web create form takes the directory as the user
11692
+ // typed it, ~ included; the host only checks that the
11693
+ // path exists, so an unexpanded ~ silently fell back to
11694
+ // home. Expand it here, once, for every caller.
11695
+ if (typeof cwd === 'string') {
11696
+ const t = cwd.trim();
11697
+ cwd = t === '' ? undefined
11698
+ : t === '~' ? os.homedir()
11699
+ : t.startsWith('~/') ? path.join(os.homedir(), t.slice(2))
11700
+ : t;
11701
+ }
11576
11702
  // A new session can boot straight into an app instead of a
11577
11703
  // bare shell: the web New picker sends startup='claude' /
11578
11704
  // 'codex' (or empty for a plain terminal). Bounded, and
@@ -11901,6 +12027,40 @@ happens until someone clicks it.
11901
12027
  }
11902
12028
 
11903
12029
  // API: Restore saved sessions (recreate rooms + resume claude conversations)
12030
+ if (parsedUrl.pathname === '/api/fs/complete' && req.method === 'GET') {
12031
+ // Directory completion for the create form's working-directory
12032
+ // field. Directories only: the last segment of `q` is a prefix
12033
+ // (case-insensitive), everything before it must already exist. A
12034
+ // leading ~ is the home directory and a relative path resolves
12035
+ // under it, matching what a shell would do with the same text.
12036
+ // Dot-directories appear only once the prefix itself starts with a
12037
+ // dot, the way tab completion behaves.
12038
+ const sp = parsedUrl.searchParams || new URLSearchParams(parsedUrl.search || '');
12039
+ const q = String(sp.get('q') || '').trim();
12040
+ const home = os.homedir();
12041
+ const trailing = q.endsWith('/');
12042
+ const expanded = q === '' || q === '~' ? home + '/'
12043
+ : q.startsWith('~/') ? path.join(home, q.slice(2)) + (trailing ? '/' : '')
12044
+ : q.startsWith('/') ? q
12045
+ : path.join(home, q) + (trailing ? '/' : '');
12046
+ const dir = expanded.endsWith('/') ? expanded.replace(/\/+$/, '') || '/' : path.dirname(expanded);
12047
+ const prefix = expanded.endsWith('/') ? '' : path.basename(expanded).toLowerCase();
12048
+ const entries = [];
12049
+ try {
12050
+ for (const name of fs.readdirSync(dir)) {
12051
+ if (!name.toLowerCase().startsWith(prefix)) continue;
12052
+ if (name.startsWith('.') && !prefix.startsWith('.')) continue;
12053
+ const full = path.join(dir, name);
12054
+ try { if (!fs.statSync(full).isDirectory()) continue; } catch (e) { continue; }
12055
+ entries.push({ name, path: full });
12056
+ if (entries.length >= 40) break;
12057
+ }
12058
+ } catch (e) { /* not a directory, or unreadable: no completions */ }
12059
+ entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
12060
+ res.writeHead(200, { 'Content-Type': 'application/json' });
12061
+ res.end(JSON.stringify({ home, dir, entries }));
12062
+ return;
12063
+ }
11904
12064
  if (parsedUrl.pathname === '/api/sessions/restore/health' && req.method === 'GET') {
11905
12065
  const state = readRestoreReconcile();
11906
12066
  const sessions = Object.values(state.sessions || {});
@@ -18245,6 +18405,12 @@ async function main() {
18245
18405
  };
18246
18406
  setTimeout(runRestoreReconcile, 12000).unref?.();
18247
18407
  setInterval(runRestoreReconcile, 30000).unref?.();
18408
+
18409
+ // Uploads cache: age out + size-cap, once at boot (a daemon that sat
18410
+ // idle for a week still cleans up) and then hourly. Cheap: one stat
18411
+ // per file, and a no-op when the folder doesn't exist yet.
18412
+ setTimeout(enforceUploadBudget, 20000).unref?.();
18413
+ setInterval(enforceUploadBudget, 3600000).unref?.();
18248
18414
  }
18249
18415
 
18250
18416
  if (DAEMON_FLAG) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hop2",
3
- "version": "0.11.39",
3
+ "version": "0.11.41",
4
4
  "description": "🐰 Hop into your terminal from anywhere. Secure remote shell access with 2FA via Cloudflare Tunnel. Next-gen rewrite with native terminal sharing.",
5
5
  "main": "hop",
6
6
  "bin": {
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+ // Turn a Claude Code or Codex transcript into a readable handoff document.
3
+ //
4
+ // This is how a conversation crosses tools. A Claude transcript is a JSONL
5
+ // of API messages; a Codex rollout is a JSONL of events; neither tool can
6
+ // resume the other's. What CAN cross is the conversation itself — every user
7
+ // turn, the assistant's reply, and a one-line summary of each action taken —
8
+ // written as markdown the receiving agent reads before its first turn. That
9
+ // is context sharing, not replay: tool results are not carried, and the
10
+ // receiver starts by summarising what it read.
11
+ //
12
+ // Same document format as ~/Code/agent_tools/agent_migration.py --extract,
13
+ // ported so hop needs no Python (and no TUI dependencies) to hand a session
14
+ // off — with replies paired to the turn they answer (see readClaudeTurns).
15
+ //
16
+ // Streams line by line: real transcripts run to hundreds of MB.
17
+
18
+ const fs = require('fs');
19
+ const readline = require('readline');
20
+
21
+ const NOISE_PREFIXES = [
22
+ '<task-notification>', '<turn_aborted>', '<local-command-caveat>',
23
+ '[Request interrupted', '<command-name>', '<system-reminder>',
24
+ '# AGENTS.md', '<INSTRUCTIONS>', '<local-command-stdout>',
25
+ 'Automation:', '<subagent_notification>'
26
+ ];
27
+
28
+ function summarizeToolCall(block) {
29
+ const name = block?.name || '?';
30
+ const inp = (block && typeof block.input === 'object' && block.input) || {};
31
+ switch (name) {
32
+ case 'Bash': return `[Bash] ${String(inp.command || '').slice(0, 120)}`;
33
+ case 'Read': return `[Read] ${inp.file_path || '?'}`;
34
+ case 'Edit': return `[Edit] ${inp.file_path || '?'}`;
35
+ case 'Write': return `[Write] ${inp.file_path || '?'}`;
36
+ case 'Glob': return `[Glob] ${inp.pattern || '?'}`;
37
+ case 'Grep': return `[Grep] ${inp.pattern || '?'}`;
38
+ case 'Agent': return `[Agent] ${String(inp.prompt || '?').slice(0, 80)}`;
39
+ default: {
40
+ const short = name.includes('__') ? name.split('__').pop() : name;
41
+ const args = Object.entries(inp).slice(0, 2).map(([k, v]) => `${k}=${String(v).slice(0, 30)}`).join(' ');
42
+ return `[${short}] ${args}`.slice(0, 120);
43
+ }
44
+ }
45
+ }
46
+
47
+ async function eachJsonLine(file, onEntry) {
48
+ const rl = readline.createInterface({ input: fs.createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
49
+ for await (const line of rl) {
50
+ if (!line) continue;
51
+ let entry;
52
+ try { entry = JSON.parse(line); } catch (e) { continue; }
53
+ if (onEntry(entry) === false) { rl.close(); break; }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Claude Code transcript → [{ text, ts, assistant, assistantTs, tools }].
59
+ *
60
+ * A reply belongs to the user turn it answers. agent_migration.py attached
61
+ * each assistant message to the NEXT user turn and overwrote earlier text
62
+ * blocks, so a reply could land on a harness message (a <system-reminder>)
63
+ * and vanish with it when noise was filtered. Here every assistant text and
64
+ * tool call accrues to the most recent real user turn; harness messages and
65
+ * tool results never become turns at all.
66
+ */
67
+ async function readClaudeTurns(file) {
68
+ const turns = [];
69
+ let current = null;
70
+ await eachJsonLine(file, (entry) => {
71
+ const ts = String(entry.timestamp || '');
72
+ if (entry.type === 'assistant') {
73
+ const msg = entry.message;
74
+ if (!msg || typeof msg !== 'object') return;
75
+ for (const block of Array.isArray(msg.content) ? msg.content : []) {
76
+ if (!block || typeof block !== 'object') continue;
77
+ if (block.type === 'text' && block.text) {
78
+ if (current) {
79
+ current.assistant = current.assistant ? `${current.assistant}\n${block.text}` : String(block.text);
80
+ current.assistantTs = ts.slice(0, 19);
81
+ }
82
+ } else if (block.type === 'tool_use') {
83
+ if (current) current.tools.push(summarizeToolCall(block));
84
+ }
85
+ }
86
+ } else if (entry.type === 'user') {
87
+ const msg = entry.message;
88
+ const content = msg && typeof msg === 'object' ? msg.content : '';
89
+ let text = '';
90
+ if (typeof content === 'string') text = content.trim();
91
+ else if (Array.isArray(content)) {
92
+ for (const block of content) {
93
+ if (block && typeof block === 'object' && block.type === 'text') { text = String(block.text || '').trim(); break; }
94
+ if (typeof block === 'string') { text = block.trim(); break; }
95
+ }
96
+ }
97
+ if (!text || NOISE_PREFIXES.some((p) => text.startsWith(p))) return;
98
+ current = { text, ts: ts.slice(0, 19), assistant: '', assistantTs: '', tools: [] };
99
+ turns.push(current);
100
+ }
101
+ });
102
+ return turns;
103
+ }
104
+
105
+ /** Codex rollout → same shape (no tool summaries: rollouts do not carry them). */
106
+ async function readCodexTurns(file) {
107
+ const turns = [];
108
+ let current = null;
109
+ await eachJsonLine(file, (entry) => {
110
+ const ts = String(entry.timestamp || '');
111
+ const payload = (entry.payload && typeof entry.payload === 'object') ? entry.payload : {};
112
+ if (entry.type === 'response_item' && payload.role === 'assistant' && payload.type === 'message') {
113
+ const parts = [];
114
+ for (const block of Array.isArray(payload.content) ? payload.content : []) {
115
+ if (block && typeof block === 'object' && block.type === 'output_text' && block.text) parts.push(block.text);
116
+ }
117
+ if (parts.length && current) {
118
+ const text = parts.join('\n');
119
+ current.assistant = current.assistant ? `${current.assistant}\n${text}` : text;
120
+ current.assistantTs = ts.slice(0, 19);
121
+ }
122
+ return;
123
+ }
124
+ let text = '';
125
+ if (entry.type === 'event_msg' && payload.type === 'user_message') {
126
+ text = String(payload.message || '').trim();
127
+ } else if (entry.type === 'response_item' && payload.role === 'user' && payload.type === 'message') {
128
+ for (const block of Array.isArray(payload.content) ? payload.content : []) {
129
+ if (block && typeof block === 'object' && block.type === 'input_text') { text = String(block.text || '').trim(); break; }
130
+ }
131
+ }
132
+ if (!text || text.length <= 3) return;
133
+ // Codex logs a user turn twice (event + response item); keep one.
134
+ if (current && current.text.slice(0, 100) === text.slice(0, 100) && current.ts === ts.slice(0, 19)) return;
135
+ current = { text, ts: ts.slice(0, 19), assistant: '', assistantTs: '', tools: [] };
136
+ turns.push(current);
137
+ });
138
+ return turns;
139
+ }
140
+
141
+ function detectTool(file) {
142
+ if (/[\\/]\.codex[\\/]/.test(file)) return 'codex';
143
+ if (/[\\/]\.gemini[\\/]/.test(file)) return 'gemini';
144
+ return 'claude';
145
+ }
146
+
147
+ /** The working directory the transcript was recorded in, from its first entry that says. */
148
+ async function readTranscriptCwd(file) {
149
+ let cwd = 'unknown';
150
+ await eachJsonLine(file, (entry) => {
151
+ const c = entry.cwd || (entry.payload && entry.payload.cwd);
152
+ if (typeof c === 'string' && c) { cwd = c; return false; }
153
+ return true;
154
+ });
155
+ return cwd;
156
+ }
157
+
158
+ /** A Codex rollout's own session id, for `codex resume <id>`. */
159
+ async function codexRolloutId(file) {
160
+ let id = null;
161
+ await eachJsonLine(file, (entry) => {
162
+ if (entry.type === 'session_meta' && entry.payload && typeof entry.payload.id === 'string') id = entry.payload.id;
163
+ return false;
164
+ });
165
+ return id;
166
+ }
167
+
168
+ /**
169
+ * Build the handoff document. Turns are taken from the END until the
170
+ * character budget fills — the most recent context is what the receiver
171
+ * needs — then harness noise is dropped.
172
+ */
173
+ async function extractConversation(file, { maxChars = 800000, tool } = {}) {
174
+ const kind = tool || detectTool(file);
175
+ if (kind === 'gemini') throw new Error('Gemini transcripts are not supported for handoff');
176
+ const turns = kind === 'codex' ? await readCodexTurns(file) : await readClaudeTurns(file);
177
+ const selected = [];
178
+ let total = 0;
179
+ for (let i = turns.length - 1; i >= 0; i--) {
180
+ const t = turns[i];
181
+ const size = t.text.length + (t.assistant || '').length + (t.tools || []).reduce((n, x) => n + x.length, 0);
182
+ if (total + size > maxChars && selected.length) break;
183
+ selected.push(t);
184
+ total += size;
185
+ }
186
+ selected.reverse();
187
+ const kept = selected.filter((t) => !NOISE_PREFIXES.some((p) => t.text.trim().startsWith(p)));
188
+ const cwd = await readTranscriptCwd(file);
189
+ const lines = [
190
+ '# Conversation Extract',
191
+ `# Source tool: ${kind}`,
192
+ `# Source file: ${file}`,
193
+ `# Working directory: ${cwd}`
194
+ ];
195
+ if (turns.length) {
196
+ lines.push(`# First message: ${turns[0].ts || 'unknown'}`);
197
+ lines.push(`# Last message: ${turns[turns.length - 1].ts || 'unknown'}`);
198
+ }
199
+ lines.push(`# Turns included below: ${kept.length} (most recent, within token budget)`);
200
+ lines.push(`# Extracted: ${new Date().toISOString().slice(0, 19)}`);
201
+ lines.push('');
202
+ for (const t of kept) {
203
+ lines.push(`## User [${t.ts || ''}]`, t.text, '');
204
+ if (t.tools && t.tools.length) {
205
+ lines.push('### Actions taken:');
206
+ for (const x of t.tools) lines.push(` ${x}`);
207
+ lines.push('');
208
+ }
209
+ if (t.assistant) lines.push('## Assistant', t.assistant, '');
210
+ }
211
+ return { text: lines.join('\n'), tool: kind, cwd, turns: kept.length, totalTurns: turns.length };
212
+ }
213
+
214
+ /** The instruction the receiving agent starts with. */
215
+ function handoffPrompt(extractPath, bytes, sourceTool) {
216
+ const kb = Math.max(1, Math.round(bytes / 1024));
217
+ return `Read the ENTIRE file ${extractPath} (${kb}KB) from start to finish — do not stop at the first chunk. `
218
+ + `It contains a previous ${sourceTool} conversation. After reading all of it, summarize what we were working on and ask me what to do next.`;
219
+ }
220
+
221
+ module.exports = { extractConversation, readClaudeTurns, readCodexTurns, summarizeToolCall, detectTool, codexRolloutId, handoffPrompt };
222
+
223
+ if (require.main === module) {
224
+ const argv = process.argv.slice(2);
225
+ const file = argv.find((a) => !a.startsWith('-'));
226
+ if (!file) {
227
+ console.error('Usage: node scripts/trajectory-extract.js <transcript.jsonl> [--max-chars N] [--tool claude|codex]');
228
+ process.exit(1);
229
+ }
230
+ const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; };
231
+ extractConversation(file, { maxChars: Number(flag('--max-chars')) || undefined, tool: flag('--tool') })
232
+ .then((r) => process.stdout.write(r.text))
233
+ .catch((e) => { console.error(e.message); process.exit(1); });
234
+ }
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { extractConversation, codexRolloutId, handoffPrompt } = require('../scripts/trajectory-extract.js');
8
+
9
+ const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hop-extract-'));
10
+ const jsonl = (file, entries) => fs.writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
11
+
12
+ test('claude transcript: turns, tool summaries, noise dropped, most-recent-first budget', async () => {
13
+ const dir = tmp();
14
+ const file = path.join(dir, 'claude.jsonl');
15
+ jsonl(file, [
16
+ { type: 'user', timestamp: '2026-09-01T10:00:00.000Z', cwd: '/Users/me/proj', message: { role: 'user', content: 'fix the build' } },
17
+ { type: 'assistant', timestamp: '2026-09-01T10:00:05.000Z', message: { role: 'assistant', content: [
18
+ { type: 'text', text: 'Looking.' },
19
+ { type: 'tool_use', name: 'Bash', input: { command: 'npm test' } },
20
+ { type: 'tool_use', name: 'Edit', input: { file_path: '/Users/me/proj/a.ts' } }
21
+ ] } },
22
+ { type: 'user', timestamp: '2026-09-01T10:00:06.000Z', message: { role: 'user', content: [{ type: 'tool_result', content: 'ok' }] } },
23
+ { type: 'assistant', timestamp: '2026-09-01T10:00:09.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'Fixed a.ts.' }] } },
24
+ { type: 'user', timestamp: '2026-09-01T10:01:00.000Z', message: { role: 'user', content: '<system-reminder>ignore me</system-reminder>' } },
25
+ { type: 'user', timestamp: '2026-09-01T10:02:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'now ship it' }] } },
26
+ { type: 'assistant', timestamp: '2026-09-01T10:02:30.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'Shipped.' }] } }
27
+ ]);
28
+ const r = await extractConversation(file);
29
+ assert.equal(r.tool, 'claude');
30
+ assert.equal(r.cwd, '/Users/me/proj');
31
+ assert.equal(r.totalTurns, 2); // the tool result and the system-reminder are not turns
32
+ assert.equal(r.turns, 2);
33
+ assert.match(r.text, /^# Conversation Extract\n# Source tool: claude/);
34
+ assert.match(r.text, /## User \[2026-09-01T10:00:00\]\nfix the build\n/);
35
+ assert.match(r.text, /### Actions taken:\n {2}\[Bash\] npm test\n {2}\[Edit\] \/Users\/me\/proj\/a\.ts/);
36
+ // Both text blocks of the reply, in order, on the turn they answer.
37
+ assert.match(r.text, /## Assistant\nLooking\.\nFixed a\.ts\.\n/);
38
+ assert.ok(!r.text.includes('ignore me'));
39
+ assert.ok(r.text.indexOf('fix the build') < r.text.indexOf('now ship it'));
40
+ assert.match(r.text, /## Assistant\nShipped\.\n$/);
41
+
42
+ // A tight budget keeps the NEWEST turn.
43
+ const small = await extractConversation(file, { maxChars: 20 });
44
+ assert.equal(small.turns, 1);
45
+ assert.ok(small.text.includes('now ship it'));
46
+ assert.ok(!small.text.includes('fix the build'));
47
+ });
48
+
49
+ test('codex rollout: user turns deduped, assistant paired, id readable', async () => {
50
+ const dir = tmp();
51
+ const file = path.join(dir, '.codex', 'sessions', 'rollout.jsonl');
52
+ fs.mkdirSync(path.dirname(file), { recursive: true });
53
+ jsonl(file, [
54
+ { type: 'session_meta', timestamp: '2026-09-01T11:00:00.000Z', payload: { id: 'abc-123', cwd: '/Users/me/other' } },
55
+ { type: 'event_msg', timestamp: '2026-09-01T11:00:01.000Z', payload: { type: 'user_message', message: 'summarize the repo' } },
56
+ { type: 'response_item', timestamp: '2026-09-01T11:00:01.000Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'summarize the repo' }] } },
57
+ { type: 'response_item', timestamp: '2026-09-01T11:00:20.000Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'It is a CLI.' }] } },
58
+ { type: 'event_msg', timestamp: '2026-09-01T11:05:00.000Z', payload: { type: 'user_message', message: 'ok' } }
59
+ ]);
60
+ const r = await extractConversation(file);
61
+ assert.equal(r.tool, 'codex');
62
+ assert.equal(r.cwd, '/Users/me/other');
63
+ assert.equal(r.totalTurns, 1); // "ok" is too short to count; the duplicate is folded
64
+ assert.match(r.text, /## User \[2026-09-01T11:00:01\]\nsummarize the repo\n\n## Assistant\nIt is a CLI\./);
65
+ assert.equal(await codexRolloutId(file), 'abc-123');
66
+ });
67
+
68
+ test('handoff prompt names the file, its size, and the source tool', () => {
69
+ const p = handoffPrompt('/x/handoff.md', 5 * 1024, 'claude');
70
+ assert.match(p, /Read the ENTIRE file \/x\/handoff\.md \(5KB\)/);
71
+ assert.match(p, /previous claude conversation/);
72
+ });