Files
project-work/worker-toolkit-stocks-in-the-future/explore/plugins/create-snapshot/bin/capture-snapshot.mjs
Eric Bell 392781f7aa chore: init commit
in worker.../repo/GITFOLDER.zip is the .git folder.
2026-08-11 14:44:09 -04:00

789 lines
29 KiB
JavaScript
Executable File

#!/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 <slug> --annotation <path> --output-dir <dir> [--plugin-data <path>]\n' +
' capture-snapshot.mjs --mark-start [--harness <id>]'
);
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 <SHA> | 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 =
/<command-(?:name|message)>\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('<command-name>') && !trimmed.startsWith('<command-message>')) {
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);
}
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`);