#!/usr/bin/env node import { execSync } from 'node:child_process'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { linearSnapshotLines, readSession } from './harness-session.mjs'; // --- Argument parsing --- function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i++) { if (argv[i].startsWith('--')) { const key = argv[i].slice(2); const val = argv[i + 1]; if (!val || val.startsWith('--')) { args[key] = true; } else { args[key] = val; i++; } } } return args; } // Last-resort data dir. Claude Code's plugin runtime always provides one, so this is // what makes the start marker and session record work under any other harness. function defaultDataDir() { const home = process.env.HOME || '/root'; return path.join(home, '.raccoon', 'snapshot-data'); } const args = parseArgs(process.argv); const slug = args.slug; const annotationPath = args.annotation; const outputDir = args['output-dir']; if (!args['mark-start'] && (!slug || !annotationPath || !outputDir)) { console.error( 'Usage: capture-snapshot.mjs --slug --annotation --output-dir [--plugin-data ]\n' + ' capture-snapshot.mjs --mark-start [--harness ]' ); process.exit(1); } // --- Locate session info --- // --harness wins over the launcher's RACCOON_HARNESS so a caller that knows which // conversation it is capturing can say so; the default keeps Claude Code's plugin // working unchanged. Claude Code has an exact cut point (the snapshot slash command) // and a message tree to prune, so it keeps the bespoke path below; other harnesses go // through the shared reader. const HARNESS = args.harness || process.env.RACCOON_HARNESS || 'claude-code'; const IS_CLAUDE = HARNESS === 'claude-code'; // The generated restore.sh writes one of exactly two session layouts, and everything // below branches on IS_CLAUDE — so a third harness would silently be handed codex's // $CODEX_HOME/sessions paths. Refuse instead; adding a harness means adding a layout. if (!IS_CLAUDE && HARNESS !== 'codex') { console.error( `capture-snapshot: no session-restore layout for harness "${HARNESS}". ` + 'Add one to capture-snapshot.mjs (and harness-session.mjs) before capturing with it.' ); process.exit(1); } // Try multiple strategies to find the current session transcript: // 1. Plugin data dir (from SessionStart hook) // 2. Scan ~/.claude/projects/ for the most recently modified JSONL let session_id = null; let transcript_path = null; const dataDir = args['plugin-data'] || process.env.RACCOON_SNAPSHOT_DATA || process.env.CLAUDE_PLUGIN_DATA || (process.env.CLAUDE_PLUGIN_ROOT && path.join(process.env.CLAUDE_PLUGIN_ROOT, '.data')) || defaultDataDir(); // The SessionStart hook records the live session for every harness, so prefer it over // guessing. `readSession` falls back to the newest file on disk when it is absent. let recordedSession = null; if (dataDir) { const sessionInfoPath = path.join(dataDir, 'current-session.json'); if (fs.existsSync(sessionInfoPath)) { try { recordedSession = JSON.parse(fs.readFileSync(sessionInfoPath, 'utf8')); } catch { recordedSession = null; } } } const startMarkerPath = dataDir ? path.join(dataDir, 'snapshot-start.json') : null; // `--mark-start` runs BEFORE the annotation Q&A and records how long the conversation // was at that moment. It is the linear-harness stand-in for Claude Code's slash-command // line: without it, capture would stage the snapshot's own Q&A as conversation. if (args['mark-start']) { const session = readSession(HARNESS); if (!session) { console.error(`No ${HARNESS} session found to mark.`); process.exit(1); } if (!startMarkerPath) { console.error('No data dir available to record the snapshot start marker.'); process.exit(1); } fs.mkdirSync(path.dirname(startMarkerPath), { recursive: true }); fs.writeFileSync( startMarkerPath, JSON.stringify( { harness: HARNESS, transcript_path: session.rawPath, line_count: session.lines.length }, null, 2 ) + '\n' ); console.log(`Snapshot start marked at ${session.lines.length} records.`); process.exit(0); } let harnessSession = null; if (!IS_CLAUDE) { harnessSession = readSession(HARNESS, recordedSession?.transcript_path); if (!harnessSession) { console.error( `Could not find a ${HARNESS} session to capture. Capture has to run from inside the ${HARNESS} conversation you want to snapshot.` ); process.exit(1); } transcript_path = harnessSession.rawPath; // Fall back to a fresh id only if the harness records none — restore.sh names the // installed session by it, so it has to match what `resume` will look up. session_id = recordedSession?.session_id || harnessSession.sessionId || crypto.randomUUID(); } else if (recordedSession) { session_id = recordedSession.session_id; transcript_path = recordedSession.transcript_path; } // Fallback: find the most recently modified JSONL in ~/.claude/projects/ if (!transcript_path) { const homeDir = process.env.HOME || '/root'; const projectsDir = path.join(homeDir, '.claude', 'projects'); if (fs.existsSync(projectsDir)) { let newest = null; let newestMtime = 0; for (const projEntry of fs.readdirSync(projectsDir)) { const projDir = path.join(projectsDir, projEntry); if (!fs.statSync(projDir).isDirectory()) continue; for (const file of fs.readdirSync(projDir)) { if (!file.endsWith('.jsonl')) continue; const filePath = path.join(projDir, file); const mtime = fs.statSync(filePath).mtimeMs; if (mtime > newestMtime) { newestMtime = mtime; newest = filePath; session_id = file.replace(/\.jsonl$/, ''); } } } transcript_path = newest; } } if (!transcript_path || !fs.existsSync(transcript_path)) { console.error( "Could not find a Claude Code session transcript. This script should be run from within a Claude Code conversation via the /create-snapshot:snapshot command. Please file a bug if you're seeing this unexpectedly." ); process.exit(1); } if (!transcript_path || !fs.existsSync(transcript_path)) { console.error(`Transcript file not found at ${transcript_path}. Please file a bug.`); process.exit(1); } // --- Require a git repo at capture time --- // // A snapshot is "commit SHA + diff vs HEAD", reconstituted later via // `git archive | tar -x` + `git apply workspace.patch`. Without a git // repo here we have no SHA to pin, no patch to record, and no way for // downstream `build-workspace.sh` to reproduce the workspace — the resulting // snapshot would be structurally meaningless. This check runs BEFORE the // snapshot directory is created so a misconfigured invocation leaves no // half-written state behind. // Find the git repo by asking git itself — walks up from cwd looking for // `.git`, handling submodules and worktrees correctly. Returns null when // cwd is outside any repo, so the worker gets a clear "cd into your repo" // error instead of silently descending into something they didn't name. function findGitRepo() { try { const top = execSync('git rev-parse --show-toplevel', { stdio: ['ignore', 'pipe', 'ignore'], }) .toString() .trim(); return top || null; } catch { return null; } } const gitRepo = findGitRepo(); if (!gitRepo) { console.error( "Error: Not running inside a git repo. /create-snapshot needs a git repo so it can pin a commit SHA and record a diff of in-flight changes; without one the snapshot can't be reproduced as a task. cd into the repo you're exploring (the toolkit's repo/ submodule) and re-run /create-snapshot:snapshot." ); process.exit(1); } // --- Create snapshot directory --- const ts = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15); // 20260403-225449 const snapshotDir = path.join(outputDir, `${ts}-${slug}`); if (fs.existsSync(snapshotDir)) { console.error(`Snapshot directory already exists: ${snapshotDir}\nPlease file a bug.`); process.exit(1); } fs.mkdirSync(snapshotDir, { recursive: true }); // --- Copy conversation transcript (trimmed + branch-pruned) --- // The JSONL is a tree of messages linked by parentUuid. When the user rewinds // a conversation, old branches remain in the file. We need to: // 1. Cut at the LAST /create-snapshot:snapshot command (later invocations // supersede earlier ones in the same session) // 2. Find the tip of the active branch (last message before the cut) // 3. Walk parentUuid back to the root, collecting only messages on that path // 4. Exclude the Q&A subgraphs of any PRIOR /create-snapshot:snapshot // invocations in this session (their cut points are on the same // conversation branch, so the walk would otherwise pull in the // assistant's annotation questions and the user's answers — a // contamination path that snapshot.patch doesn't show). Boundaries // for prior invocations are recorded in a side file (see end of // this script) so this run can identify them. // 5. Drop bookkeeping entries whose content can leak rewound-branch state. const rawLines = fs.readFileSync(transcript_path, 'utf8').trimEnd().split('\n'); // A user message is a /create-snapshot:snapshot invocation when its content // STARTS with one of Claude Code's slash-command tags AND mentions the // command name. The "starts with" guard distinguishes a real invocation // from prose that quotes the command (a worker reporting a bug, the // command-listing skill output, etc.) — prose doesn't begin with those // tags. The whitespace-tolerant pattern survives minor format drift in // Claude Code's slash-command rendering. const SNAPSHOT_CMD_PATTERN = /\s*\/?\s*create-snapshot:snapshot\s*<\/command-(?:name|message)>/; function isSnapshotCommandContent(content) { if (typeof content !== 'string') return false; const trimmed = content.trimStart(); if (!trimmed.startsWith('') && !trimmed.startsWith('')) { return false; } return SNAPSHOT_CMD_PATTERN.test(content); } // Find every snapshot-command line index, in order. The LAST one is the // current invocation (cut point); earlier ones bound prior Q&A subgraphs. const snapshotCmdIndexes = []; for (let i = 0; i < rawLines.length; i++) { try { const entry = JSON.parse(rawLines[i]); if (entry.type === 'user' && isSnapshotCommandContent(entry.message?.content)) { snapshotCmdIndexes.push(i); } } catch { // Skip malformed lines } } const cutIndex = snapshotCmdIndexes.length > 0 ? snapshotCmdIndexes[snapshotCmdIndexes.length - 1] : rawLines.length; const priorCmdIndexes = snapshotCmdIndexes.slice(0, -1); // Load prior-snapshot boundary records so we know where each earlier // invocation's Q&A subgraph ended. The boundary file is written at the // end of every capture run (see below) and is keyed by session uuid. function loadPriorBoundaries() { if (!dataDir || !session_id) return []; const boundariesPath = path.join(dataDir, 'snapshot-boundaries.jsonl'); if (!fs.existsSync(boundariesPath)) return []; const lines = fs.readFileSync(boundariesPath, 'utf8').trimEnd().split('\n'); const out = []; for (const line of lines) { if (!line) continue; try { const rec = JSON.parse(line); if (rec.sessionUuid === session_id && rec.snapshotCommandUuid) out.push(rec); } catch { /* skip malformed */ } } return out; } const priorBoundaries = loadPriorBoundaries(); // Compute the line ranges to exclude for each prior snapshot. The Q&A // subgraph starts at the prior snapshot's command line and runs through // the line whose entry uuid matches the boundary record (the last entry // in the JSONL when that prior capture-snapshot completed). // // Fall back to the next snapshot command (or the current cut) when no // matching boundary record exists — better to drop too much than to leak // the Q&A; the visible cost is excluding any "real work" that happened // between snapshots without a recorded boundary, which only occurs if // the boundary log was wiped or the prior capture crashed. function findUuidLineIndex(targetUuid, startLine, endLineExclusive) { for (let i = startLine; i < endLineExclusive; i++) { try { const entry = JSON.parse(rawLines[i]); if (entry.uuid === targetUuid) return i; } catch { /* skip */ } } return -1; } const priorQAExcludedLines = new Set(); for (let i = 0; i < priorCmdIndexes.length; i++) { const startLine = priorCmdIndexes[i]; const nextCutLine = i + 1 < priorCmdIndexes.length ? priorCmdIndexes[i + 1] : cutIndex; let snapshotCmdUuid = null; try { snapshotCmdUuid = JSON.parse(rawLines[startLine]).uuid || null; } catch { /* unparseable command line — skip */ } let endLine = -1; if (snapshotCmdUuid) { const boundary = priorBoundaries.find((b) => b.snapshotCommandUuid === snapshotCmdUuid); if (boundary && boundary.lastEntryUuid) { endLine = findUuidLineIndex(boundary.lastEntryUuid, startLine, nextCutLine); } } // No matching boundary: bound the exclusion at the next snapshot/current // cut so the Q&A doesn't leak even if state was lost. if (endLine < 0) endLine = nextCutLine - 1; for (let j = startLine; j <= endLine; j++) priorQAExcludedLines.add(j); } // Step 2: parse all entries before the cut, build uuid index const preCutEntries = []; const byUuid = {}; for (let i = 0; i < cutIndex; i++) { try { const entry = JSON.parse(rawLines[i]); preCutEntries.push({ line: rawLines[i], entry, index: i }); if (entry.uuid) { byUuid[entry.uuid] = entry; } } catch { // Keep unparseable lines (they'll be included as non-message entries) preCutEntries.push({ line: rawLines[i], entry: null, index: i }); } } // Step 3: find the tip of the active branch. The snapshot command's parentUuid // points to the message the user was looking at when they ran the snapshot — // this is authoritative even after rewinds. let tipUuid = null; if (cutIndex < rawLines.length) { try { const snapshotCmd = JSON.parse(rawLines[cutIndex]); tipUuid = snapshotCmd.parentUuid || null; } catch { // not valid JSON — leave tipUuid null } } // If the live tip is itself inside a prior snapshot's Q&A subgraph (e.g. // the user ran /create-snapshot:snapshot a second time WITHOUT typing // anything between the two — there's no "real work" gap), walk back past // the excluded range to find the closest non-excluded ancestor. Otherwise // activeBranchUuids would be empty and we'd produce an empty snapshot. function nearestNonExcludedAncestor(startUuid) { let cur = startUuid; while (cur) { const e = byUuid[cur]; if (!e) return cur; // unknown uuid — best effort, keep // Find the line index of this entry to check exclusion. // (Line index isn't stored on the entry; recompute via preCutEntries.) const found = preCutEntries.find((p) => p.entry?.uuid === cur); if (!found || !priorQAExcludedLines.has(found.index)) return cur; cur = e.parentUuid || null; } return null; } if (tipUuid) tipUuid = nearestNonExcludedAncestor(tipUuid); // Fallback: if no snapshot command found, use the last entry with a uuid if (!tipUuid) { for (let i = preCutEntries.length - 1; i >= 0; i--) { if (preCutEntries[i].entry?.uuid && !priorQAExcludedLines.has(preCutEntries[i].index)) { tipUuid = preCutEntries[i].entry.uuid; break; } } } // Collect all uuids on the active branch const activeBranchUuids = new Set(); let current = tipUuid; while (current) { activeBranchUuids.add(current); current = byUuid[current]?.parentUuid || null; } // Step 4: filter — keep entries on the active branch. // // Claude Code writes several bookkeeping entry types alongside the message // tree that don't carry a branch uuid. Their content references whatever // branch was active when they were written, so if the user has rewound, // these will leak rewound-branch state (file backups, prior prompt text, // stale titles, queued prompts, PR links, etc.) into the snapshot — a leak // snapshot.patch doesn't show. Drop the ones we can't attribute to the // active branch. // // `file-history-snapshot` is special-cased: it carries a `messageId` // pointing at the message whose pre-edit state it tracks, so we can // keep only those whose messageId is on the active branch. That // preserves /rewind functionality after a snapshot is restored (rewind // needs the file-backup metadata) while still dropping records from // rewound branches. const BLANKET_DROP_TYPES = new Set([ 'agent-name', 'ai-title', 'custom-title', 'last-prompt', 'permission-mode', 'pr-link', 'queue-operation', ]); function prunedClaudeLines() { const kept = []; for (const { line, entry, index } of preCutEntries) { if (priorQAExcludedLines.has(index)) continue; if (!entry) { // Unparseable line — keep as-is so we don't lose data we can't classify. kept.push(line); continue; } if (entry.uuid) { if (activeBranchUuids.has(entry.uuid)) kept.push(line); continue; } // No uuid: bookkeeping entry. if (entry.type === 'file-history-snapshot') { // Keep only if the message it tracks is on the active branch. if (entry.messageId && activeBranchUuids.has(entry.messageId)) { kept.push(line); } continue; } if (!BLANKET_DROP_TYPES.has(entry.type)) kept.push(line); } return kept; } // Rewind branches and prior-Q&A exclusion are Claude-transcript concerns; a linear // harness transcript just truncates at its boundary. let startLine; if (!IS_CLAUDE && startMarkerPath && fs.existsSync(startMarkerPath)) { try { const marker = JSON.parse(fs.readFileSync(startMarkerPath, 'utf8')); if (marker.transcript_path === harnessSession.rawPath) startLine = marker.line_count; } catch { startLine = undefined; } } if (!IS_CLAUDE && startLine === undefined) { console.error( 'WARNING: no snapshot start marker for this session — the snapshot Q&A may be captured as conversation. Run capture-snapshot.mjs --mark-start before the annotation questions.' ); } const outputLines = IS_CLAUDE ? prunedClaudeLines() : linearSnapshotLines(harnessSession, startLine); if (outputLines.length === 0) { console.error( `WARNING: found no conversation to seed in ${transcript_path}, so this snapshot has no prior turns. The task will run cold from its prompt alone — fine if that is what you want, but if you meant to capture a conversation, check that the exchange you wanted came BEFORE this snapshot.` ); } // Zero bytes, not a lone newline, when there is nothing to seed: downstream decides // single- vs multi-turn on the file's SIZE, so a 1-byte file would try to resume nothing. fs.writeFileSync( path.join(snapshotDir, 'session.jsonl'), outputLines.length > 0 ? outputLines.join('\n') + '\n' : '' ); // --- Write boundary record so the NEXT capture-snapshot in this session // can identify and exclude this snapshot's Q&A subgraph --- // // The record pairs the current invocation's command-line uuid with the // uuid of the last entry in the JSONL at this moment (which is whichever // assistant turn invoked us as a tool). A subsequent capture run reads // this file, finds these two uuids in its raw lines, and excludes the // range — a small leak still exists for entries appended AFTER capture // returns (the assistant's "Snapshot saved to: ..." reply), but the // substantive annotation Q&A is fully bounded. if (dataDir && session_id && cutIndex < rawLines.length) { let snapshotCommandUuid = null; try { snapshotCommandUuid = JSON.parse(rawLines[cutIndex]).uuid || null; } catch { /* leave null — we'll skip writing */ } // Re-read transcript so we pick up any lines Claude Code has appended // since we read it above (the assistant's tool-use entry, etc.). let lastEntryUuid = null; try { const liveLines = fs.readFileSync(transcript_path, 'utf8').trimEnd().split('\n'); for (let i = liveLines.length - 1; i >= 0; i--) { try { const e = JSON.parse(liveLines[i]); if (e.uuid) { lastEntryUuid = e.uuid; break; } } catch { /* skip */ } } } catch { /* transcript unreadable now — skip writing */ } if (snapshotCommandUuid && lastEntryUuid) { const boundariesPath = path.join(dataDir, 'snapshot-boundaries.jsonl'); try { fs.mkdirSync(dataDir, { recursive: true }); fs.appendFileSync( boundariesPath, JSON.stringify({ sessionUuid: session_id, snapshotCommandUuid, lastEntryUuid, timestamp: new Date().toISOString(), }) + '\n' ); } catch { // Best-effort: a missing boundary just means the next run falls back // to the conservative "exclude through next snapshot" heuristic. } } } // Copy subagents and tool-results if they exist const sessionSiblingDir = transcript_path.replace(/\.jsonl$/, ''); if (fs.existsSync(sessionSiblingDir) && fs.statSync(sessionSiblingDir).isDirectory()) { fs.cpSync(sessionSiblingDir, path.join(snapshotDir, 'session'), { recursive: true }); // Claude Code creates subagent files with write-only permissions (--w-------). // Fix them so downstream tools (cpSync in snapshot-to-task, Harbor's dirhash) can read them. execSync(`chmod -R +r "${path.join(snapshotDir, 'session')}"`, { stdio: 'pipe' }); } // --- Capture git state as a patch --- // Returns raw stdout bytes — callers that want a single-line value must // .trim() themselves. Don't trim here: some callers (git diff) produce // patches where a trailing " \n" blank-context line is load-bearing, and // stripping it corrupts the patch. function git(cmd, opts) { try { return execSync(`git ${cmd}`, { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024, cwd: gitRepo, stdio: ['pipe', 'pipe', 'pipe'], ...opts, }); } catch { return null; } } const commit = git('rev-parse HEAD')?.trim() ?? null; const branch = git('rev-parse --abbrev-ref HEAD')?.trim() ?? null; const remoteUrl = git('remote get-url origin')?.trim() ?? null; // Generate a unified patch representing the workspace state AT THE END OF // THE PRIOR TURN — i.e., everything done up to but not including the turn // being snapshotted. This is the state the trial agent should inherit so // it gets a fresh attempt at the prompt that triggered the snapshot. // // The UserPromptSubmit hook checkpoints the working tree to // `refs/raccoon/turn-checkpoint` at every turn boundary (skipping snapshot // invocations themselves), so the latest checkpoint is exactly the state // at the start of the snapshotted turn. We diff HEAD against that // checkpoint to produce the patch. // // Falls back to the pre-checkpoint behavior (full working-tree diff) when // no checkpoint exists — e.g., the worker took a snapshot before any // non-snapshot user message was sent, or the hook never fired (legacy // session, plugin re-installed mid-session, etc.). if (gitRepo) { try { const tmpIndex = path.join(snapshotDir, '.tmp-git-index'); const indexEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex }; // Prefer the FROZEN ref — this is set by checkpoint-workspace at the // moment the user invokes /create-snapshot:*, before any Q&A turns // have a chance to advance the live checkpoint past the state we // want to capture. Fall back to the live checkpoint (then to // working-tree diff) for backward-compat or if the freeze step failed. let baseline = null; try { baseline = git('rev-parse refs/raccoon/turn-checkpoint-frozen')?.trim() ?? null; } catch { baseline = null; } if (!baseline) { try { baseline = git('rev-parse refs/raccoon/turn-checkpoint')?.trim() ?? null; } catch { baseline = null; } } // --binary --full-index, on both branches: a plain `git diff` records a // binary difference as an opaque `Binary files a/x and /dev/null differ` // stub, and `git apply` refuses it ("without full index line"), so // build-workspace.sh can't rebuild the task at all. Nobody has to edit a // binary to hit this — a tracked .DS_Store the toolkit zip strips from the // shipped checkout reads as a binary deletion in every session. // // maxBuffer: inlined binaries make patches far bigger than text diffs, and // exceeding the default cap would throw away the whole patch silently. const diffOpts = { env: indexEnv, maxBuffer: 512 * 1024 * 1024 }; let patch; if (baseline) { // Diff HEAD against the prior-turn checkpoint. Untracked files in // the checkpoint have been committed to the checkpoint tree, so // they're included automatically. patch = git(`diff --binary --full-index HEAD ${baseline}`, diffOpts); } else { // No checkpoint — fall back to live working-tree diff (pre-fix // behavior). Captures everything different from HEAD, including // any agent edits during the current turn. git('read-tree HEAD', { env: indexEnv }); git('add -A', { env: indexEnv }); patch = git('diff --cached --binary --full-index HEAD', diffOpts); if (patch) { console.error( 'Warning: no turn checkpoint was found, so snapshot.patch holds every change in the ' + 'working tree, including edits the agent made during the captured turn. Check it ' + 'and remove those before converting the snapshot to a task.' ); } } try { fs.unlinkSync(tmpIndex); } catch { /* ignore */ } if (patch) { fs.writeFileSync( path.join(snapshotDir, 'snapshot.patch'), patch.endsWith('\n') ? patch : patch + '\n' ); } } catch { // Read-only repo or other git error — skip patch generation } } // --- Copy annotation --- const annotation = JSON.parse(fs.readFileSync(annotationPath, 'utf8')); fs.writeFileSync( path.join(snapshotDir, 'annotation.json'), JSON.stringify(annotation, null, 2) + '\n' ); // Clean up temp file try { fs.unlinkSync(annotationPath); } catch { // Ignore cleanup failures } // --- Write metadata --- const metadata = { slug: slug, session_uuid: session_id, // The harness the session was actually read as, so it can't disagree with what // was captured. harness: HARNESS, original_cwd: process.cwd(), commit: commit, branch: branch, remote_url: remoteUrl, timestamp: new Date().toISOString(), plugin_version: '0.2.0', }; fs.writeFileSync(path.join(snapshotDir, 'metadata.json'), JSON.stringify(metadata, null, 2) + '\n'); // --- Generate restore.sh --- const restoreScript = `#!/usr/bin/env bash set -euo pipefail # Restore a snapshot for resuming a Claude Code conversation. # # Usage: ./restore.sh [target-dir] # target-dir: directory to clone/checkout the repo into (default: ./repo) SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="\${1:-./repo}" # Read metadata COMMIT=$(jq -r '.commit' "$SCRIPT_DIR/metadata.json") REMOTE=$(jq -r '.remote_url' "$SCRIPT_DIR/metadata.json") SESSION_UUID=$(jq -r '.session_uuid' "$SCRIPT_DIR/metadata.json") echo "Cloning $REMOTE at $COMMIT..." git clone "$REMOTE" "$TARGET_DIR" cd "$TARGET_DIR" git checkout "$COMMIT" # Apply snapshot patch if present if [ -f "$SCRIPT_DIR/snapshot.patch" ]; then echo "Applying snapshot.patch..." git apply "$SCRIPT_DIR/snapshot.patch" fi # Install conversation so the authoring harness can resume it ${ IS_CLAUDE ? `ENCODED_CWD=$(echo "$PWD" | sed 's|/|-|g; s|^-||') DEST_DIR="$HOME/.claude/projects/-$ENCODED_CWD" mkdir -p "$DEST_DIR" cp "$SCRIPT_DIR/session.jsonl" "$DEST_DIR/$SESSION_UUID.jsonl" if [ -d "$SCRIPT_DIR/session" ]; then cp -r "$SCRIPT_DIR/session" "$DEST_DIR/$SESSION_UUID" fi echo "" echo "Snapshot restored. To resume the conversation:" echo " cd $TARGET_DIR" echo " claude --resume $SESSION_UUID"` : `DEST_DIR="\${CODEX_HOME:-$HOME/.codex}/sessions/$(date -u +%Y/%m/%d)" mkdir -p "$DEST_DIR" cp "$SCRIPT_DIR/session.jsonl" \\ "$DEST_DIR/rollout-$(date -u +%Y-%m-%dT%H-%M-%S).000Z-$SESSION_UUID.jsonl" echo "" echo "Snapshot restored. To resume the conversation:" echo " cd $TARGET_DIR" echo " codex resume $SESSION_UUID"` } `; fs.writeFileSync(path.join(snapshotDir, 'restore.sh'), restoreScript); fs.chmodSync(path.join(snapshotDir, 'restore.sh'), 0o755); try { execSync('bash -ic "_ev snapshot_created 2>/dev/null" 2>/dev/null', { stdio: 'ignore', timeout: 5000, }); } catch { // best-effort } // --- Done --- const fullSnapshotDir = path.resolve(snapshotDir); console.log(`Snapshot saved to: ${fullSnapshotDir}`); console.log(` session.jsonl — conversation transcript`); if (fs.existsSync(sessionSiblingDir) && fs.statSync(sessionSiblingDir).isDirectory()) { console.log(` session/ — subagents + tool results`); } if (fs.existsSync(path.join(snapshotDir, 'snapshot.patch'))) { console.log(` snapshot.patch — working tree changes`); } console.log(` annotation.json — worker annotations`); console.log(` metadata.json — session metadata`); console.log(` restore.sh — restore script for resuming`);