
Agent Session Resume
- 75 installs
- 235 repo stars
- Updated June 30, 2026
- hacktivist123/agent-session-resume
Reconstruct and resume a prior coding-agent session across platforms by locating the transcript, verifying work state against the repo, and continuing from the real stopping point.
About
Locates and reads a previous agent session's transcript, reconstructs goal/decisions/task status with evidence refs, and validates against git before continuing. A developer uses it to resume or audit prior work on Claude Code, Codex, Cursor, Antigravity, or OpenCode.
- Provenance self-check plus evidence-referenced DONE/PARTIAL/NOT-DONE task classification
- Report-only, continue-edit, quick, and deep resume modes with a required response shape
Agent Session Resume by the numbers
- 75 all-time installs (skills.sh)
- Ranked #5,460 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hacktivist123/agent-session-resume --skill agent-session-resumeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 75 |
|---|---|
| repo stars | ★ 235 |
| Last updated | June 30, 2026 |
| Repository | hacktivist123/agent-session-resume ↗ |
What it does
Reconstruct and resume a prior coding-agent session across platforms by locating the transcript, verifying work state against the repo, and continuing from the real stopping point.
Files
Agent Session Resume
Purpose
Resume or audit prior coding-agent work with continuity. Reconstruct what happened before acting, then continue from the real stopping point.
Core Workflow
1. Run a provenance self-check.
- Run
python3 skills/agent-session-resume/scripts/skill-provenance.pywhen available. - Otherwise compare the loaded SKILL.md path and size against the known install paths under
$HOME/.claudeand${CODEX_HOME:-$HOME/.codex}. - Report staleness on the
Loaded skillline of the resume report. Details:references/evidence-and-provenance.md.
2. Identify the source. If the user names a platform, read the matching file in references/; if the ask spans platforms, read references/cross-platform.md. Otherwise inspect the workspace for session folders, exports, summaries, and artifacts. When a session title is given, prefer exact or fuzzy title matches over recency.
3. Locate the transcript or best substitute. Prefer full transcripts over summaries, workspace-local session data over global history, and explicit user-provided paths over discovered paths.
4. Read the full available session record before acting. For large transcripts, inventory files, event types, and timestamps first, then read the evidence-bearing slices until the record is accounted for. Full coverage means no relevant evidence skipped; bounded searches and slices are fine for giant records, but say so and name the file/event. Do not edit files or repeat prior work before this pass is complete.
5. Record loaded-skill provenance in the report: path and source/version marker, or unknown, never a guess. Details: references/evidence-and-provenance.md.
6. Reconstruct context. Summarize the goal, decisions, constraints, and preferences; identify completed work, changed files, commands and tests run; pin the exact stopping point. Every work-state claim carries an evidence ref (src/file.ts:L20-L35, transcript lines, command output, or explicit "not checked yet"). Prior resume reports, summaries, and handoffs are claims, not primary evidence: re-verify against transcripts, files, git state, or command output, or label them unverified. Preserve explicit user deferrals ("skip", "park", "not now", "hold") with evidence, scope, and reopen condition.
7. Extract tasks. Capture explicit TODOs, plans, and open questions; infer implicit tasks from failing tests, unfinished edits, and "next step" language. Keep specific unfinished tasks specific. Track deferred work separately from NOT DONE. Classify: DONE (completed and verified, or no longer needed), PARTIALLY DONE (started but missing implementation, tests, review, commit, push, or confirmation), NOT DONE (not started or only discussed).
8. Validate against the workspace. Inspect git status before editing and mention it in the checkpoint; read files the prior session touched. Preserve unrelated user changes in a dirty worktree; use a separate worktree or ask before colliding work. If transcript claims conflict with current files, trust current files and report the mismatch.
9. Continue from the first unfinished step. Do not repeat completed work; follow the established approach and style unless clearly broken. Ask the user only when blocked by missing information or an unsafe choice.
Resume Modes
Decide from the user's prompt how far to go after the checkpoint:
Report-only: the ask is what happened, done versus pending, or to check/audit/review a prior session without edits. Stop after the resume report and clear next action.Continue-edit: the ask is to continue, fix, implement, open a PR, or run tests. Report first, then continue from the first unfinished safe step.Quick resume: status report or task breakdown. Prefer a compact source inventory, task classification, and next action.Deep resume: implementation continues, the source is ambiguous, or files may have drifted. Read the full record and current git state, then continue.
Platform References
- Claude Code:
references/claude-code.md - Codex:
references/codex.md - Cursor:
references/cursor.md - Antigravity:
references/antigravity.md - OpenCode:
references/opencode.md - GitHub Copilot:
references/github-copilot.md - Cross-platform / multi-agent asks:
references/cross-platform.md
Required Response Shape
Before continuing execution, report:
## Brief context summary
- Goal: <prior session goal>
- Loaded skill: path=<loaded SKILL.md path or "unknown">; source/version=<version marker or "unknown">
- Source reviewed: <transcript/export/artifact refs>
- Current workspace check: <git status summary and touched-file refs, or why not checked>
- Transcript/current repo mismatches: <none found | claim, transcript ref, current-repo ref, action>
- User deferrals: <none found | deferred scope, user wording, evidence ref, reopen condition>
- Stopping point: <last command, edit, failure, or user pause instruction with evidence>
## Task status breakdown
- DONE: <task> - evidence: <implementation refs>; verification: <test/tool refs or "not recorded">.
- PARTIALLY DONE: <task> - evidence: <started-work refs>; missing: <remaining gap refs>.
- NOT DONE: <task> - evidence: <TODO, failing test, absent artifact, or transcript gap refs>.
## Clear next action
- Next: <first unfinished step to take now>
- Blocked: <no | yes - reason and evidence>Then continue immediately unless blocked. Evidence rules and the static idempotency contract: references/evidence-and-provenance.md.
Guardrails
- Never assume the newest file is the right transcript when the user supplied a title or path.
- Never summarize from filenames alone or treat a compact summary as equivalent to an available full transcript.
- Never reset, revert, or discard existing changes unless the user explicitly asks.
- Never mark a task
DONEorPARTIALLY DONEfrom a plan alone; status requires evidence of completion or started work. - Never omit transcript/current-repo mismatches when the transcript and checked files disagree.
- Never unpark deferred scope from a vague "proceed" or "continue"; confirm the user wants that parked work reopened.
interface:
display_name: "Agent Session Resume"
short_description: "Resume prior AI coding sessions"
default_prompt: "Use $agent-session-resume to continue the previous agent session from its transcript or handoff."
Antigravity Adapter
Use this adapter when resuming work from Google Antigravity or an Antigravity-exported handoff.
Discovery
Prefer explicit exports, workspace artifacts, and user-provided paths over private application storage. Antigravity emphasizes agent-visible artifacts such as task lists, implementation plans, screenshots, walkthroughs, and browser recordings, so these may be the best available substitute when a raw chat transcript is unavailable.
Inspect the workspace for likely handoff material:
find . -maxdepth 5 -type f \( \
-iname '*artifact*' -o \
-iname '*walkthrough*' -o \
-iname '*plan*' -o \
-iname '*task*' -o \
-iname '*transcript*' -o \
-iname '*summary*' \
\) 2>/dev/nullAlso inspect any Antigravity-specific workspace folder if present, but do not rely on undocumented private storage as the only source.
When the user explicitly asks to use local Antigravity data, prefer the readable artifact store before application databases or binary conversation records. On macOS, useful local artifacts may exist under:
find "$HOME/.gemini/antigravity/brain" -maxdepth 2 -type f \( \
-name '*.metadata.json' -o \
-name 'task.md' -o \
-name 'implementation_plan.md' -o \
-name '*.resolved' -o \
-name '*.resolved.*' \
\) 2>/dev/nullRead *.metadata.json first. Metadata commonly includes artifactType, summary, updatedAt, and sometimes version, which is enough to rank candidate conversations before loading larger task or plan artifacts.
Before reading large artifacts, count them:
find "$HOME/.gemini/antigravity/brain" -maxdepth 2 -type f \( \
-name 'task.md' -o -name 'implementation_plan.md' -o -name '*.resolved*' \
\) -print0 2>/dev/null | xargs -0 wc -lc 2>/dev/nullLocal App Storage
Antigravity may also keep VS Code/Electron-style state under ~/Library/Application Support/Antigravity. Use it as bounded discovery context, not as the first transcript source.
Workspace mappings can connect a repository path to a workspace storage hash:
find "$HOME/Library/Application Support/Antigravity/User/workspaceStorage" \
-maxdepth 2 -name workspace.json -type f 2>/dev/nullFor SQLite state databases, inspect keys and value sizes before reading values:
sqlite3 "$HOME/Library/Application Support/Antigravity/User/globalStorage/state.vscdb" \
"select key, length(value) from ItemTable order by key;" 2>/dev/nullThe global and workspace databases may contain useful routing keys such as chat.ChatSessionStore.index, antigravityUnifiedStateSync.trajectorySummaries, antigravityUnifiedStateSync.artifactReview, history.entries, and terminal state. They may also contain sensitive authentication or preference state such as OAuth tokens. Do not dump raw database values into the model; inventory keys first, then read only a specific safe value if it is necessary and clearly relevant.
Other local surfaces are lower priority:
~/Library/Application Support/Antigravity/User/History/*/entries.jsoncan show file edit-history clues, but it does not prove implementation or verification.~/Library/Application Support/Antigravity/logs/*/*.logcan help explain errors, permission prompts, or crashes, but logs may be empty or noisy.~/.gemini/antigravity/conversations/*.pbis a binary conversation store. Treat it as a last resort after readable artifacts and exports fail.~/.gemini/antigravity/browser_recordings/<conversation-id>/may contain many screenshots. Inventory counts and timestamps first, then inspect only relevant frames.~/.gemini/antigravity/code_tracker/can contain copied source snapshots and secrets such as.envfiles. Use filenames and metadata for routing; read contents only when explicitly relevant and redact secrets.
Artifact Ordering
When no full transcript is available, build an artifact inventory before reading deeply:
find . -maxdepth 5 -type f \( \
-iname '*artifact*' -o \
-iname '*walkthrough*' -o \
-iname '*plan*' -o \
-iname '*task*' -o \
-iname '*transcript*' -o \
-iname '*summary*' \
\) -print0 2>/dev/null | xargs -0 ls -lt 2>/dev/nullPrefer the strongest source for the question being resumed:
1. Explicit user-provided export, transcript, or artifact path. 2. Full transcript or conversation export. 3. Local Antigravity brain/<conversation-id>/*.metadata.json whose summary, type, and updatedAt match the current repo or user request. 4. Task list, implementation plan, or resolved artifact sidecar with concrete steps and verification notes. 5. Workspace storage mapping that exactly matches the current repository path. 6. Walkthrough, screenshot, browser recording, or verification artifact. 7. Generated summary or status note. 8. Logs, edit history, app database keys, and file modification times, used only as supporting clues or tie-breakers.
If artifacts disagree, prefer the latest artifact that includes concrete verification evidence, then validate against current repository files and git state.
Safe Reading
Check size before opening any candidate artifact body:
wc -lc "$HOME/.gemini/antigravity/brain/<conversation-id>/task.md"Read *.metadata.json peeks before bodies, and rank candidates by strongest signal before reading: explicit user-supplied path first, then exact repo/workspace match from metadata or workspace storage, then summary/title match, with recency only as a tie-breaker.
For markdown artifacts (task.md, implementation_plan.md, walkthroughs), map structure first and read only evidence-bearing slices:
rg -n "^#|^-|TODO|\[x\]|\[ \]|error|failed|verified" path/to/task.md
sed -n '20,80p' path/to/implementation_plan.mdIf a JSONL transcript or handoff from another agent surfaces in the workspace, project it with the packaged scripts from the skill's scripts/ directory (next to SKILL.md) instead of reading raw lines:
python3 "$skill_dir/scripts/session-events.py" path/to/transcript.jsonl --limit 200
python3 "$skill_dir/scripts/session-digest.py" path/to/transcript.jsonlReading
Read artifacts in chronological order when possible. For local Antigravity data, inspect metadata first, then the smallest task/plan artifacts that explain:
- the user request
- the agent plan
- completed implementation steps
- verification evidence
- review comments or follow-up instructions
For screenshots and recordings, extract only the resume-relevant facts: visible state, tested flow, errors, and what the prior agent claimed was verified. Do not treat a visual artifact as proof that code is complete until the repository state confirms it.
For User/History entries, record which files appear to have local history, then verify against the current repo. For app-state databases, cite the key that guided discovery rather than pasting the raw value. If only artifacts are available, state that no full transcript was found and reconstruct task status from the artifacts plus current repository state.
Resume Notes
- Conversation transcript wins over artifacts when both are available.
- Artifacts are evidence, not proof of completion. Verify claimed changes in the actual files.
- If Antigravity history is inaccessible and no export exists, ask the user for an export or the relevant artifact only after workspace inspection fails.
- Local Antigravity storage can be private and sensitive. Prefer metadata inventory, explicit paths, and bounded reads over broad transcript or database dumps.
Reference: Google describes Antigravity agents producing reviewable artifacts such as task lists, implementation plans, screenshots, and recordings in its launch post: https://developers.googleblog.com/en/build-with-google-antigravity-our-new-agentic-development-platform/
Claude Code Adapter
Use this adapter when the prior session came from Claude Code or when the user points to a .claude/ directory.
Packaged helper scripts live in the skill's scripts/ directory, next to SKILL.md. Resolve them relative to the skill base directory and set skill_dir accordingly before using the commands below.
Discovery
Start in the current workspace:
find .claude -type f 2>/dev/nullTreat workspace .claude/ configuration as routing context, not as transcript evidence. Files such as .claude/settings.local.json can explain permissions or local setup, but they do not prove what happened in a prior session. If workspace .claude/ contains only configuration, continue to the user-level transcript store.
If the current workspace path is known, derive the likely Claude project directory before broad scans. Claude Code commonly stores project transcripts under a path-encoded directory:
cwd="$(pwd)"
project_dir="$HOME/.claude/projects/${cwd//\//-}"
find "$project_dir" -maxdepth 2 -type f -name '*.jsonl' 2>/dev/nullIf that directory does not exist and the user did not provide a path, check known user-level Claude Code history locations only if they are accessible. Prefer bounded transcript locations over find ~/.claude -type f:
find "$HOME/.claude/projects" -maxdepth 2 -type f -name '*.jsonl' 2>/dev/null
find "$HOME/.claude" -maxdepth 1 -type f -name 'history.jsonl' 2>/dev/nullClaude Code stores full transcripts, per-session sidecars, and prompt history in different places:
~/.claude/projects/<project>/<session>.jsonl: full conversation transcript with messages, tool calls, and tool results.~/.claude/projects/<project>/<sessionId>/subagents/agent-*.jsonl: subagent transcripts. When the main transcript delegates work to agents, include these in the evidence set; the main transcript may show only the dispatch and a summary.~/.claude/projects/<project>/<sessionId>/tool-results/*.txt: large tool outputs spilled to disk. Read these targeted files instead of giant inline transcript lines.~/.claude/history.jsonl: prompt history used for up-arrow recall, containing prompts with timestamps and project paths.
Use history.jsonl as a locator and context supplement, not as a transcript replacement. It can reveal the project path, the user's exact prompts, and nearby session intent even when the matching transcript is hard to identify. When a relevant history entry is used, include the project path or prompt-history clue in the context summary.
Treat Claude Code project transcripts as append logs that may still be active. A recent transcript timestamp or file modification time proves transcript freshness only; it does not prove the repository, branch, dependencies, or generated files are fresh. Compare transcript freshness and repository freshness separately.
Do not treat a history.jsonl miss as evidence that no transcript exists. If prompt history does not contain the current cwd or session topic, inspect the cwd-derived ~/.claude/projects/<project> directory before broadening discovery.
Common useful formats include JSONL transcripts, Markdown exports, text exports, and metadata files.
Raw-grep trap: do not grep ~/.claude/projects/ transcript bodies for a topic, skill name, or tool name as a discovery step. Every transcript embeds the available-skills list, plugin descriptions, and other boilerplate inside system-reminder blocks, so a topic-shaped rg produces mass false positives. Concrete failure shape: rg -l "agent-session-resume" ~/.claude/projects/ matches roughly 20 unrelated sessions whose only "mention" of the skill is the skills list injected into every conversation. Project user messages first, then search the projected view:
python3 "$skill_dir/scripts/session-events.py" "$candidate" | rg "text/user" | rg -i "<session name or topic>"For shortlisting candidates by cwd, topic, or time window without opening bodies, use the packaged lister. Time-bounded asks map to --since / --until, which accept relative windows (7d, 12h) or ISO dates:
python3 "$skill_dir/scripts/session-candidates.py" --platform claude-code --cwd "$(pwd)" --topic "<session name>"
python3 "$skill_dir/scripts/session-candidates.py" --platform claude-code --cwd "$(pwd)" --since 7d --until 1dTo filter prompt history by the current workspace path:
rg -F "$(pwd)" ~/.claude/history.jsonl 2>/dev/nullBefore reading candidate message bodies, project routing metadata from the JSONL:
jq -r '
select(.type == "user")
| [.timestamp, .cwd, .sessionId, .entrypoint, .gitBranch]
| @tsv
' "$session" | headWhen multiple Claude project directories or transcripts could match, rank candidates by the strongest signal:
1. Explicit transcript path or session ID supplied by the user. 2. Exact path-encoded project directory for the current cwd. 3. Exact transcript cwd match from user events. 4. Parent/child cwd match. 5. Session title, prompt history, or user prompt match. 6. Recency, used only as a tie-breaker.
Do not let prefix siblings outrank an exact cwd match. For example, ~/.claude/projects/-Users-ojima-Desktop-experiments should outrank ~/.claude/projects/-Users-ojima-Desktop-experiments-trybreak-prototype when the current cwd is /Users/ojima/Desktop/experiments.
If no title is provided, sort candidate files by modified time only after applying the stronger path and metadata signals.
When comparing candidate times, normalize to UTC or epoch seconds before deciding which record is newer. session-candidates.py prints every row's updated_at normalized to ISO-8601 UTC with seconds precision (e.g. 2026-06-10T00:15:30Z) on both platforms, so its rows sort chronologically as plain strings, and session-events.py prints normalized event timestamps; cross-check against file and repo time:
stat -f '%m %N' "$session" 2>/dev/null
git log -1 --format='%ct %h %s'If a Deep resume may run for a long time, recheck the chosen transcript tail and git status --short --branch before the checkpoint report. If the tail changed while reading, account for the appended events before classifying tasks.
Reading
For JSONL transcripts, read entries in order. Capture user messages, assistant responses, tool calls, tool results, system reminders, compaction summaries, and any error output that explains the stopping point.
Do not stop at the first TODO list. Continue through the end of the transcript so later changes, corrections, or completed tasks are not missed.
When using history.jsonl, read it near the relevant timestamp or project path to recover user intent, but classify task status from the full transcript and current workspace whenever possible.
Use event types to skim before deep reading:
| Event type | Resume use |
|---|---|
user | User prompts, tool results, cwd/session metadata |
assistant | Assistant text responses and tool-use requests |
system | System reminders and compaction context |
attachment | Attached context or artifacts; inspect when relevant |
ai-title | Session title metadata; deduplicate repeated values |
queue-operation | Prompt queue boundaries; usually routing metadata |
last-prompt | Latest prompt/stopping-point clue |
Repeated ai-title events should be treated as one title signal per (sessionId, aiTitle) pair. Do not count duplicate title rows as progress, task evidence, or user/assistant turns.
For a message-only skim, extract visible user and assistant text plus bounded tool summaries with the packaged projector. It emits one previewed event per line with transcript line references and skips opaque thinking/signature payloads, which add noise and do not normally change task status:
python3 "$skill_dir/scripts/session-events.py" "$session" --limit 200For a compact reusable summary with evidence cues, build a digest. It writes a persistent <transcript>.digest.json sidecar cache and processes only the appended tail on later runs:
python3 "$skill_dir/scripts/session-digest.py" "$session"Claude Code persists oversized tool results outside the JSONL transcript, under the per-session sidecar directory ~/.claude/projects/<project>/<sessionId>/tool-results/*.txt. If a tool result contains a placeholder such as <persisted-output> or says the full output was saved to tool-results/<id>.txt, treat that sidecar as part of the session record. Likewise check ~/.claude/projects/<project>/<sessionId>/subagents/agent-*.jsonl whenever the main transcript dispatches subagents; project them with session-events.py like any other transcript.
Inspect sidecars safely:
wc -lc path/to/tool-results/<id>.txt
rg -n "error|failed|TODO|not done|next|<file-or-symbol-pattern>" path/to/tool-results/<id>.txt
sed -n '120,180p' path/to/tool-results/<id>.txtDo not read a large sidecar from beginning to end unless it is small enough for the active context. Search for the command, error text, file path, task label, or final summary that explains the resume state. In the resume report, state that the evidence came from a sidecar file when it did.
For large transcript or tool-output files, use an evidence inventory before deep reading:
1. Count lines and bytes. 2. List JSONL event types or artifact names. 3. Identify user prompts, assistant summaries, tool calls, tool failures, and persisted-output pointers. 4. Search/slice the relevant evidence. 5. Continue to the final transcript event so late corrections or appended turns are not missed.
If the transcript may still be active or was modified during resume, recheck the tail before reporting. Use a bounded projected tail instead of dumping raw JSONL, so the final scan keeps visible messages, tool-use summaries, and tool-result previews while skipping opaque thinking or signature payloads:
python3 "$skill_dir/scripts/session-events.py" "$session" | tail -n 40Re-running session-digest.py is also cheap here: its <transcript>.digest.json sidecar cache means only the appended tail is processed.
The exact stopping point should come from the final meaningful events, not from the first TODO list. Capture:
- the last user prompt or instruction
- the last assistant response after that prompt
- any final tool call or tool result that explains a blocker
- whether the last state is a completed report, an unanswered question, a failed command, or a pending next step
Claude Code work can fork into parallel terminals, sidecar tool outputs, or handoffs to another agent. Detect these by shared session IDs, worker labels in messages, sidecar paths, matching cwd, nearby timestamps, or explicit delegation language. Review the parent transcript and relevant forked streams as one evidence set, but keep source references separate in the report.
Resume Notes
- Prefer
.claude/inside the current project over global Claude history. - If both a transcript and
.meta.jsonexist, use metadata for title and timing, but use the transcript for task state. - Use
~/.claude/history.jsonlto find prompts, project paths, and likely sessions; do not treat it as evidence that implementation or verification happened. - Claude Code sessions often include plans and tool output; classify task status from what actually happened, not from the plan text alone.
Reference: Claude Code documents history.jsonl as prompt history and projects/<project>/<session>.jsonl as full transcripts: https://code.claude.com/docs/en/claude-directory
Codex Adapter
Use this adapter when resuming a Codex session, continuing from a Codex desktop or CLI handoff, or when a Codex conversation summary is present.
Packaged helper scripts live in the skill's scripts/ directory, next to SKILL.md. Resolve them relative to the skill base directory and set skill_dir accordingly before using the commands below.
Discovery
Codex may provide prior context directly in the active conversation, through a compaction summary, or through files in the workspace. Treat injected conversation context as a session record, then validate it against the repository before editing.
Inspect the workspace for:
find . -maxdepth 4 -type f \( -name '*session*' -o -name '*transcript*' -o -name '*handoff*' -o -name '*summary*' \) 2>/dev/null
find .codex .agents -type f 2>/dev/nullCodex normally stores conversation transcripts in the user-level Codex home, not inside each repository. If the user asks for the "most recent Codex session" and no project-local handoff is present, inspect the user-level index before broad transcript scans:
tail -n 40 "${CODEX_HOME:-$HOME/.codex}/session_index.jsonl" 2>/dev/nullUse session_index.jsonl to shortlist candidate session IDs by thread name, session name, and update time. Use the packaged candidate lister, which ranks candidates without dumping transcript bodies and accepts --cwd, --topic, --since, and --until filters:
python3 "$skill_dir/scripts/session-candidates.py" --platform codex --cwd "$(pwd)" --topic "<session name or topic>"Warning: session_index.jsonl does not cover every transcript. Codex Desktop sub-threads (transcripts with a parent_thread_id) never get index entries, so any index-only listing silently omits them. The packaged lister compensates with an mtime fallback sweep over sessions/YYYY/MM/DD that surfaces unindexed in-window transcripts as source=mtime rows (untitled, so --topic cannot match them). On windowed asks where completeness matters, also broaden directly on disk:
find "${CODEX_HOME:-$HOME/.codex}/sessions" -name '*.jsonl' -newermt '<date, e.g. 2026-06-03>' 2>/dev/nullAfter choosing a candidate ID, resolve it to the transcript file:
session_id="<candidate id>"
find "${CODEX_HOME:-$HOME/.codex}/sessions" "${CODEX_HOME:-$HOME/.codex}/archived_sessions" -type f -name "*${session_id}*.jsonl" 2>/dev/nullIf the index is missing or inconclusive, then broaden discovery:
find "${CODEX_HOME:-$HOME/.codex}" -maxdepth 5 -type f -name '*.jsonl' 2>/dev/nullCommon locations:
${CODEX_HOME:-$HOME/.codex}/session_index.jsonl- session IDs, names, and update times.${CODEX_HOME:-$HOME/.codex}/sessions/YYYY/MM/DD/*.jsonl- active or recent session transcripts.${CODEX_HOME:-$HOME/.codex}/archived_sessions/*.jsonl- archived transcripts.
Treat Codex transcripts as append logs that may still be active. A recent updated_at or file modification time proves transcript freshness only; it does not prove the repository is fresh. Compare transcript timestamps, file mtimes, and git status/remote refs separately.
Before reading a transcript body, confirm that its session_meta cwd matches the current repository. Project only the field you need instead of dumping the raw record, because Codex session_meta can include large base instructions and tool metadata:
session="<candidate transcript>"
jq -r 'select(.type == "session_meta") | .payload.cwd // empty' "$session" | head -n 1When checking several candidate transcripts, use session-candidates.py (above) to print a compact ranked inventory instead of looping jq over every file.
Candidate Ranking
When multiple Codex transcripts could match, rank candidates by the strongest matching signal. Do not let several weaker signals outrank a stronger one:
1. Explicit path or session ID supplied by the user. 2. Exact cwd, workdir, or repo path match with the current repository. 3. Parent/child cwd match, where the transcript cwd contains the repo or is inside it. 4. Thread name, title, or session name match from the index or transcript metadata. 5. Mentioned files, package names, repository names, or branch names from the current work. 6. Recency, used only as a tie-breaker among otherwise equal candidates.
Only inspect transcript bodies for mentioned files or symbols when candidates are still tied after stronger metadata signals. If candidates are still tied after recency, choose the stable lexical order of transcript path or session ID and mention the ambiguity in the context summary. Do not pick the newest global Codex session if it appears to belong to a different project.
Example:
| Candidate | Signal | Result |
|---|---|---|
A, updated today | thread name matches, cwd is another repo | skip |
B, updated yesterday | exact cwd match | choose |
C, updated today | mentions a package name, no cwd match | inspect only if no stronger match exists |
Use git status --short --branch early to understand what already changed. If the active folder is not a git repository, locate the relevant repo from the transcript or user-provided path.
When comparing candidate times, normalize to UTC or epoch seconds before deciding which record is newer. session-candidates.py prints every row's updated_at normalized to ISO-8601 UTC with seconds precision (e.g. 2026-06-10T00:15:30Z) on both platforms; cross-check against file and repo time:
stat -f '%m %N' "$session_file" 2>/dev/null
git log -1 --format='%ct %h %s'If a Deep resume may run for a long time, recheck the chosen transcript tail and git status --short --branch before the checkpoint report. If the tail changed while reading, account for the appended events before classifying tasks.
Safe Reading
Codex session files can contain raw metadata and very large tool results. Do not load or paste entire session_meta records, full session indexes, or giant tool outputs when only routing fields or a small evidence slice is needed.
Before reading a candidate transcript, check its size and line count:
wc -lc "$session_file"Project metadata and the bounded event stream with the packaged projector instead of dumping raw JSONL. Its first projected event is the session_meta routing record (id, cwd, originator, CLI version), followed by previewed user/agent messages, tool calls, and truncated tool output:
python3 "$skill_dir/scripts/session-events.py" "$session_file" --limit 200Use event types to decide what to inspect next:
| Event shape | Resume use |
|---|---|
session_meta | Session ID, cwd, originator, CLI version |
event_msg with user_message or agent_message | Human-readable conversation timeline |
event_msg with reasoning | Optional decision context when user/agent messages are not enough |
response_item with function_call | Commands or tools the agent ran |
response_item with function_call_output | Tool output; inspect selectively when relevant |
| token counts, web-search status, and other telemetry | Usually skip unless debugging the resume process itself |
For large tool outputs, first identify the relevant event, command, file, or error text. Then search a sidecar or narrowed output source, or slice the specific transcript line range, instead of loading the whole output:
rg -n "error|failed|TODO|<file-or-symbol-pattern>" path/to/tool-output-or-sidecar.txt
sed -n '120,220p' "$session_file"Do not use broad raw JSONL regex scans as the first evidence pass for Codex transcripts. Matches inside session_meta, embedded developer/system instructions, tool schemas, or serialized prompts can look like user-visible TODOs or errors even when they are only context. Project the event stream first, then apply targeted rg to that projected view or to a narrowed line range:
python3 "$skill_dir/scripts/session-events.py" "$session_file" | rg -n "error|failed|TODO|<file-or-symbol-pattern>"For a compact reusable summary with evidence cues, build a digest. It writes a persistent <transcript>.digest.json sidecar cache and processes only the appended tail on later runs, so rechecks of an active transcript stay cheap:
python3 "$skill_dir/scripts/session-digest.py" "$session_file"Use raw rg only after the projection reveals a specific event, file path, command, or line range worth inspecting.
Reading
Read the current conversation summary, local handoff files, and changed files referenced by the prior session. When a full transcript is unavailable, explicitly distinguish:
- facts from the transcript or summary
- facts verified from files
- inferences from current repository state
For large Codex JSONL transcripts, start with a bounded skim to orient yourself before deeper review:
python3 "$skill_dir/scripts/session-events.py" "$session_file" | rg "user_message|agent_message"This keeps only previewed user and agent messages with timestamps and transcript line references, skipping session metadata and large event payloads. Use it as an orientation step, not as a replacement for evidence review: still inspect relevant tool outputs, changed files, git state, tests, and artifacts before continuing work.
Resume Notes
- If the user says the prior session was from Claude Code and Codex is only the current runtime, use the Claude Code adapter for transcript discovery.
- Codex work often includes compacted context. Treat summaries as useful but incomplete until checked against changed files, tests, and git state.
- Codex work can fork into worker or sub-agent sessions. Detect child sessions by
parent_session_id, shared thread IDs, worker metadata, nearby timestamps, matching cwd, or messages that mention delegated/background work. Review the parent and relevant child transcripts as one evidence set, but keep their source references separate. - Do not overwrite user edits in a dirty worktree while trying to recreate prior work.
Cross-Platform Resume
Use this workflow when one ask spans multiple agent platforms: "review my threads across Claude and Codex", "what did my agents do on this repo last week", or any resume/audit naming more than one runtime.
Detect the Span
Treat the ask as cross-platform when the user names two or more runtimes, says "all my agents/sessions/threads", or when discovery for one named platform surfaces a matching session on another runtime for the same repo and time window. State the platform set you are covering in the context summary before reading transcripts.
Discover Per Platform
Run each platform adapter's discovery independently and completely. Do not let one runtime's results stop or narrow discovery on another.
- Claude Code:
references/claude-code.md - Codex:
references/codex.md - Cursor:
references/cursor.md - Antigravity:
references/antigravity.md - OpenCode:
references/opencode.md
Keep one candidate list per runtime, each entry carrying session ID, transcript path, cwd, title, and updated time.
Window the Ask
Time-bounded asks ("past week", "since Monday", "before yesterday") map to the lister's --since / --until flags, which accept relative windows (7d, 12h) or ISO dates/datetimes. Pass the same window to every runtime so the merged timeline covers one consistent span:
python3 scripts/session-candidates.py --platform claude-code --since 7d --until 1d --format tsv
python3 scripts/session-candidates.py --platform codex --since 7d --until 1d --format tsvNormalize Timestamps
Runtimes mix ISO-8601 strings (often with fractional seconds), local times, and file mtimes. Convert every candidate's time to UTC epoch seconds before comparing across runtimes. Do not reach for BSD date -j -f: on real Codex index timestamps such as 2026-06-09T14:03:11.482931Z it emits "Ignoring 8 extraneous characters" and parses the wrong instant. Use a python3 one-liner that handles fractional-ISO and plain-ISO alike:
# ISO-8601 (fractional or plain, Z or numeric offset) -> epoch seconds
python3 -c 'import sys,datetime as dt; print(int(dt.datetime.fromisoformat(sys.argv[1].replace("Z","+00:00")).timestamp()))' '2026-06-09T14:03:11.482931Z'
# file mtime -> epoch
stat -f '%m' "$transcript" 2>/dev/null || stat -c '%Y' "$transcript"The same one-liner accepts both platforms' lister output: session-candidates.py emits updated_at normalized to ISO-8601 UTC with seconds precision (e.g. 2026-06-10T00:15:30Z) for claude-code and codex alike, so lister rows can also be compared or sorted as plain strings.
Never rank candidates from mixed-format timestamp strings.
Build One Merged Timeline
Merge the per-runtime candidate lists into a single timeline keyed by (cwd match, time window). Concretely, with two lister TSVs:
python3 scripts/session-candidates.py --platform claude-code --since 7d --format tsv > /tmp/claude.tsv
python3 scripts/session-candidates.py --platform codex --since 7d --format tsv > /tmp/codex.tsv
# Drop headers, merge, sort chronologically. updated_at (column 3) is normalized
# ISO-8601 UTC on both platforms, so a plain string sort is a time sort.
tail -n +2 /tmp/claude.tsv > /tmp/merged-sessions.tsv
tail -n +2 /tmp/codex.tsv >> /tmp/merged-sessions.tsv
sort -t $'\t' -k3,3 /tmp/merged-sessions.tsv | column -t -s $'\t'When iterating rows, never name a loop variable path: in zsh, read ... path assigns the tied $path array and clobbers $PATH, breaking every later command lookup in the shell. Use transcript (or similar) instead:
while IFS=$'\t' read -r score runtime updated source cwd title transcript; do
printf '%s\t%s\t%s\t%s\n' "$updated" "$runtime" "$cwd" "$transcript"
done < /tmp/merged-sessions.tsv | sort -t $'\t' -k1,1Group sessions that share a cwd (or parent/child cwd) into per-repo lanes, then read them in time order. The merged timeline decides reading order; the per-runtime adapters decide how to read each transcript safely.
Attribute Evidence
Every evidence item names its runtime and transcript path, not just a line number:
codex: ~/.codex/sessions/2026/06/09/rollout-<id>.jsonl:L42claude-code: ~/.claude/projects/<project-slug>/<uuid>.jsonl:L17
Never cite "the transcript" when more than one runtime is in play.
Dedupe Overlapping Work
Same repo, overlapping time window, and same files touched usually means one task seen from two runtimes (for example, work started in Claude Code and reviewed or continued in Codex). For each suspected overlap:
1. Compare the touched-file sets and the commands run in both transcripts. 2. If they describe the same task, merge into one task entry, keep both runtimes' evidence refs, and note which runtime carried it furthest. 3. If you cannot confirm they are the same task, keep them as separate entries and flag the possible overlap in the context summary.
Do not double-count merged work in the task status breakdown, and do not let a stale transcript from one runtime downgrade work a later session completed.
Report
Produce one task-status breakdown (the Required Response Shape in SKILL.md) covering all runtimes:
Source reviewedlists each runtime with its transcript paths.- Each
DONE/PARTIALLY DONE/NOT DONEline cites per-runtime evidence refs. - Mismatch and deferral lines name the runtime whose transcript carries the evidence.
- The clear next action is a single step, even when the prior work spans runtimes; name the runtime context only if it changes what to do.
Cursor Adapter
Use this adapter when resuming work from Cursor, a Cursor Agent chat export, a Cursor Background Agent handoff, or Cursor project-local artifacts.
If the prior work was GitHub Copilot Chat running in stock VS Code rather than Cursor's own Agent/Composer chat, use references/github-copilot.md instead. Cursor is a VS Code fork, so it shares the workspaceStorage/<hash>/workspace.json mapping, but it stores its native chat in state.vscdb, not in VS Code's chatSessions/*.json.
Source Priority
Cursor has both documented handoff surfaces and local application state. Prefer stable, explicit sources before inspecting local internals:
1. User-provided Cursor Agent Markdown export. 2. Project-local handoff, transcript, summary, or artifact files. 3. Project-local Cursor context such as .cursor/rules, .cursor/commands, AGENTS.md, and .cursorrules. 4. Observed local project roots such as ~/.cursor/projects/<path-encoded-project>/. 5. Workspace storage metadata under ~/Library/Application Support/Cursor/User/workspaceStorage/. 6. Global Cursor app storage only as a last resort, and only when the user has made it accessible and explicitly wants local storage inspection.
Do not treat undocumented Cursor databases as the default transcript API. Cursor's documented preservation path is exporting chats as Markdown.
Discovery
Inspect the current workspace for explicit exports and handoffs first:
find . -maxdepth 5 -type f \( \
-iname '*cursor*' -o \
-iname '*session*' -o \
-iname '*transcript*' -o \
-iname '*handoff*' -o \
-iname '*summary*' \
\) 2>/dev/nullCursor project context can explain the prior agent's behavior, but it does not prove task completion. Inventory it separately from transcript evidence:
find . -maxdepth 4 -type f \( \
-path '*/.cursor/rules/*' -o \
-path '*/.cursor/commands/*' -o \
-name 'AGENTS.md' -o \
-name '.cursorrules' \
\) 2>/dev/nullIf no export is present and the user asks for a local Cursor session, inspect project-scoped Cursor roots before broad app storage. Cursor commonly uses path-encoded project directories under ~/.cursor/projects:
cwd="$(pwd)"
encoded="${cwd#/}"
encoded="${encoded//\//-}"
project_dir="$HOME/.cursor/projects/$encoded"
find "$project_dir" -maxdepth 3 -type f 2>/dev/nullObserved project-scoped folders can include:
agent-transcripts/- possible transcript artifacts when present.terminals/- terminal output artifacts; inspect only relevant bounded slices.rules/- project-specific Cursor rules.mcps/andmcp-cache.json- tool/server context, not task-completion evidence.assets/andagent-tools/- supporting artifacts.
If the path-encoded project directory is missing or inconclusive, map Cursor workspace storage back to the current project by reading only workspace.json files:
find "$HOME/Library/Application Support/Cursor/User/workspaceStorage" \
-maxdepth 2 -name 'workspace.json' 2>/dev/nullSearch these small mapping files for the current path, then consider the matching workspace folder. Do not scan every state.vscdb body.
Official Export Reading
Cursor Agent exports are Markdown files. Treat an export as transcript evidence when it contains chronological user/assistant messages, file references, code blocks, commands, tool output, task summaries, or stopping-point instructions.
Read the export in order. Capture:
- original user request
- assistant plan and decisions
- files referenced or changed
- commands and results
- explicit TODOs, blockers, and "stop here" instructions
- final user prompt and final assistant response
Cursor export docs say exports include messages/responses, code blocks, file references/context, and chronological conversation flow. Still verify claims against current files and git status before editing.
Safe Reading
Cursor exports and project-scoped artifacts can be large. Check size before reading any candidate body:
wc -lc path/to/export.mdPeek structure and evidence cues before reading bodies, then read only the slices that carry evidence:
rg -n "^#|^##|TODO|error|failed|stop here" path/to/export.md
sed -n '40,120p' path/to/export.mdRank candidates by strongest signal before opening anything: explicit user-supplied path first, then exact cwd/workspace match, then title match, with recency only as a tie-breaker (see Candidate Ranking below). Do not read whole files to decide between candidates.
If a JSONL transcript surfaces (for example under ~/.cursor/projects/<project>/agent-transcripts/, or a handoff produced by another agent), project it with the packaged scripts from the skill's scripts/ directory (next to SKILL.md) instead of dumping raw lines:
python3 "$skill_dir/scripts/session-events.py" path/to/transcript.jsonl --limit 200
python3 "$skill_dir/scripts/session-digest.py" path/to/transcript.jsonlFor terminals/ and other artifact folders, slice bounded ranges with rg -n plus sed -n rather than reading entire outputs.
Local Storage Safety
Cursor chat history may be stored locally in SQLite, while Background Agent chats are not part of normal history and may be remote. Local storage formats can be large and unstable.
If the user explicitly asks to inspect local storage:
1. Count candidate files before opening them. 2. Prefer workspace.json mappings and directory inventories. 3. For SQLite files such as state.vscdb, inspect table names, key names, and value sizes before reading values. 4. Read values only after narrowing to a likely session and only when the user has requested local-storage inspection. 5. Treat database-derived content as sensitive. Redact secrets and private data in reports and fixtures.
Useful metadata-only checks:
sqlite3 path/to/state.vscdb '.tables'
sqlite3 path/to/state.vscdb 'select key, length(value) from ItemTable order by key limit 80;'Keys such as composer.composerData, aiService.prompts, aiService.generations, workbench.backgroundComposer.workspacePersistentData, interactive.sessions, and terminal buffer state are routing clues, not guaranteed transcript schema.
Avoid global storage by default. It can be very large, noisy, locked, or unavailable, and it is more likely to include unrelated projects.
Background Agents
If the prior work came from Cursor Background Agents, do not expect it in normal Cursor chat history. Prefer:
- the branch or PR produced by the Background Agent
- the changed files and git history
- exported Background Agent conversation when the user provides it
- project-local artifacts written by the agent
Report when Background Agent chat history is unavailable and ask for an export or explicit path if the transcript is needed.
Candidate Ranking
When multiple Cursor sources could match, rank by:
1. Explicit export path, handoff path, branch, PR, or Background Agent link supplied by the user. 2. Workspace-local export or handoff whose path/title matches the requested session. 3. Exact current cwd match from a path-encoded ~/.cursor/projects/<project> directory. 4. Exact workspace.json file:// folder match in Cursor workspaceStorage. 5. Title, file, branch, command, or task references inside bounded exports/artifacts. 6. Recency, used only as a tie-breaker among otherwise equal candidates.
Do not let a newer unrelated Cursor workspace outrank an exact cwd or explicit export match.
Reading
Read the full available Cursor export before continuing. If only local storage clues are available, clearly distinguish:
- facts from a Cursor export or handoff
- facts from current repository files, git history, commands, or tests
- context from Cursor rules, commands, memories, MCP metadata, or terminal artifacts
- inferences from local storage metadata
Do not mark tasks DONE from Cursor rules, commands, memories, or MCP metadata. Those are context. Task status requires transcript/export evidence, file evidence, git evidence, command output, test output, or an explicit missing-evidence note.
Resume Notes
- Prefer Cursor Markdown exports over local database scraping.
- Project rules, commands, memories, and
AGENTS.mdshape behavior but are not task-completion evidence. - Background Agent evidence may be in a remote UI, branch, PR, or export rather than normal local history.
- If no transcript/export is available, produce a report saying what was inspected and what explicit export/path is needed before classifying task state.
References:
- Cursor chat export: https://docs.cursor.com/agent/chat/export
- Cursor chat history: https://docs.cursor.com/agent/chat/history
- Cursor rules and
AGENTS.md: https://docs.cursor.com/en/context - Cursor commands: https://docs.cursor.com/en/agent/chat/commands
- Cursor memories: https://docs.cursor.com/context/memories
- Cursor Background Agents: https://docs.cursor.com/background-agent
Evidence and Provenance
Use this reference for the detailed rules behind the Loaded skill line, evidence references, and repeat-resume stability. SKILL.md carries the workflow; this file carries the long tail.
Provenance Self-Check
Run the bundled checker before writing the resume report:
python3 skills/agent-session-resume/scripts/skill-provenance.pyRun it from the root that contains skills/agent-session-resume/. If the script is not at that path (older installs keep it at scripts/skill-provenance.py in the repo root), check there before falling back to manual comparison. It compares the repo copy of SKILL.md against the Codex and Claude install paths and prints bytes, lines, and SHA-256 per surface. Pass --repo-root, --codex-home, --claude-home, or --format json to adjust.
If the script is unavailable, compare manually:
wc -c "$HOME/.claude/skills/agent-session-resume/SKILL.md" \
"${CODEX_HOME:-$HOME/.codex}/skills/agent-session-resume/SKILL.md" 2>/dev/null
shasum -a 256 path/to/loaded/SKILL.md path/to/candidate/SKILL.md 2>/dev/nullReport the outcome on the Loaded skill line: matches-repo, differs-from-repo (stale), missing, or unknown.
Recording Skill Provenance
- Name the loaded skill file path in the checkpoint when the runtime exposes it, for example a
skills/agent-session-resume/SKILL.mdpath. If the runtime does not expose the loaded path, writeunknown. - Name a source/version marker when available: plugin manifest version, marketplace package version, git tag or commit, package source, or checksum from the loaded skill file. If none is available, write
unknown. - Do not infer the active skill version from an unrelated repository checkout, local clone, docs page, or install command. Label those as candidate sources unless you can prove they are the loaded artifact.
- When comparing Codex and Claude behavior, compare the known install paths and reported source/version markers from each runtime. Common standalone paths are
${CODEX_HOME:-$HOME/.codex}/skills/agent-session-resume/SKILL.mdfor Codex and$HOME/.claude/skills/agent-session-resume/SKILL.mdfor Claude Code. Claude Code plugin installs may expose a plugin-managed path or only the plugin manifest/version. - After updating installed skill files, assume an already-running agent may still be using the previous loaded instructions until the app, CLI, plugin, or session is restarted or reloaded.
Evidence Rules
- Every task status line must include
evidence:with at least one concrete source reference. DONErequires evidence of completion, not just a plan or intention.PARTIALLY DONErequires evidence that work started plus the missing completion or verification.NOT DONErequires evidence from an explicit TODO, failing command, missing artifact, or transcript gap.- If current-repo verification has not happened yet, say so plainly instead of implying the transcript is current.
- The loaded skill path and source/version marker may be
unknown, but must not be guessed. If only a candidate install path is known, sayunknownfor the loaded path and mention the candidate path separately. - User deferrals require evidence from the transcript, handoff, or active prompt. Preserve the deferred scope even when the rest of the work is ready to continue. Do not reintroduce deferred scope from a vague go-ahead such as "proceed"; ask for confirmation unless the user clearly names the parked scope or its reopening condition has been met.
- Use compact, stable references so a person or script can trace the claim:
session.jsonl:L4,handoff.md:L7-L10,src/file.ts:L20-L35, orgit status --short --branch. - Prior resume reports can help route the investigation, but they do not prove task state by themselves. Cite the primary evidence that verifies the claim, or mark the claim unverified when primary evidence is unavailable.
Static Idempotency Contract
- For static sources, repeated resumes over the same unchanged transcript, artifacts, and workspace should converge on the same task status breakdown and next-action class.
- Exact wording may vary, but evidence references, task classifications, mismatch handling, and whether the next action is blocked or actionable should remain stable.
- Static idempotency does not apply to live or active transcripts, changing repositories, remote GitHub state, running commands, or other sources that may drift between runs.
GitHub Copilot Adapter
Use this adapter when the prior session came from GitHub Copilot Chat in VS Code (or VS Code Insiders), or when the user points to Copilot chat history or an exported Copilot chat.
Discovery
GitHub Copilot Chat stores conversations per workspace inside the VS Code user-data directory, not in the project tree. Each workspace maps to a hash-named folder under workspaceStorage:
- macOS:
~/Library/Application Support/Code/User/workspaceStorage/<hash>/ - Linux:
~/.config/Code/User/workspaceStorage/<hash>/ - Windows:
%APPDATA%\Code\User\workspaceStorage\<hash>\
For VS Code Insiders, replace Code with Code - Insiders. Forks such as Cursor or VSCodium use their own product directory (for example ~/Library/Application Support/Cursor/User/...); only treat those as GitHub Copilot sources when the user confirms Copilot Chat ran there. For Cursor's own Agent/Composer chat, use references/cursor.md instead — Cursor stores that in state.vscdb, not in VS Code's chatSessions/.
Inside each <hash> folder, the relevant entries are:
workspace.json— records the project this hash represents, as afolderURI such asfile:///Users/you/project. Use it to find the hash that matches the current workspace before reading any chat file.GitHub.copilot-chat/transcripts/<session-id>.jsonl— the Copilot Chat extension's own transcript: a line-delimited event stream. This is the most readable source; prefer it when present.chatSessions/<session-id>.jsonl— VS Code's core chat-model serialization for the same sessions. Useful as a session-id locator; the body is a versioned{kind, v}record stream that is harder to parse (see Reading).chatEditingSessions/<session-id>/— edit-mode (Edits/agent) working state per session.state.vscdb(plusstate.vscdb.backup) — SQLite metadata and the session index.
Note these are .jsonl files, not .json.
Match the workspace by path, not by recency. The hash is derived from the folder path and its creation time, so one project can map to several hashes and old chats can be orphaned in a stale hash. Check workspace.json in each candidate before trusting a recent modification time.
Find the hash for the current workspace:
storage="$HOME/Library/Application Support/Code/User/workspaceStorage" # macOS
# Linux: storage="$HOME/.config/Code/User/workspaceStorage"
# Insiders: replace "Code" with "Code - Insiders"
grep -rl "$(pwd)" "$storage"/*/workspace.json 2>/dev/nullList the transcripts for the matching hash, newest first:
ls -t "$storage/<hash>/GitHub.copilot-chat/transcripts/"*.jsonl 2>/dev/null
# VS Code core chat-model copies (same session ids):
ls -t "$storage/<hash>/chatSessions/"*.jsonl 2>/dev/nullIf workspace.json does not point at the current cwd, do not assume there is no prior session; the project may have been re-hashed. Fall back to searching transcript contents for the project path or topic:
grep -rl "<file-or-topic-pattern>" \
"$storage"/*/GitHub.copilot-chat/transcripts/*.jsonl \
"$storage"/*/chatSessions/*.jsonl 2>/dev/nullTreat workspace.json and state.vscdb as routing context, not as transcript evidence. Do not dump state.vscdb; read a single key or fall back to the JSONL transcripts for task state.
Reading
Prefer the extension transcript GitHub.copilot-chat/transcripts/<id>.jsonl. It is true JSON Lines — one event object per line, each shaped like {type, data, id, parentId, timestamp}. Read events in order and key off type:
session.start—datacarries provenance:copilotVersion,vscodeVersion,sessionId,startTime. Useful as source/version markers in the checkpoint.user.message—data.contentis the user prompt;data.attachmentslists referenced files/context.assistant.message—data.contentis the assistant reply;data.toolRequestslists tool calls;data.reasoningTextis optional thinking.assistant.turn_start/assistant.turn_end— turn boundaries.tool.execution_start—data.toolName,data.arguments,data.toolCallId;tool.execution_completecarries the result.
Skim the conversation without dumping tool noise:
jq -r '
select(.type == "user.message" or .type == "assistant.message")
| (.type | ascii_upcase) + ": " + ((.data.content // "") | tostring | .[0:800])
' "$transcript"List the tools the session ran (names only):
jq -r 'select(.type == "tool.execution_start") | .data.toolName' "$transcript" | sort | uniq -cCapture the user requests, assistant responses, referenced files (user.message attachments), tool executions, and the final turn. Do not stop at the first plan or TODO list; continue to the last event so later corrections and completed work are not missed.
If only the core chatSessions/<id>.jsonl file exists (no extension transcript), it is a stream of {kind, v} records, not a flat array. The full session object lives in the kind == 0 record under .v, with fields such as requests, responderUsername, and sessionId:
jq -r 'select(.kind == 0) | .v
| (.requests // [])[]
| "USER: " + ((.message.text // "") | tostring),
"ASSISTANT: " + ([.response[]? | .value? // empty] | map(select(type == "string")) | join(" ") | .[0:800])
' "$session"This core format changes between VS Code versions and some records are placeholders, so treat it as a fallback to the extension transcript.
Copilot Chat can also be exported through the built-in Chat: Export Chat... command. When the user supplies an exported chat file or pasted transcript, treat that as the session source and skip the workspaceStorage discovery.
The stopping point should come from the final meaningful turn, not the first plan. Capture:
- the last
user.messageinstruction - the last
assistant.messageafter it - any final
tool.execution_complete, applied edit, or error that explains a blocker - whether the session ends in a completed answer, an unanswered question, a failed command, or a pending next step
Resume Notes
- Prefer
GitHub.copilot-chat/transcripts/<id>.jsonl(clean event stream) over thechatSessions/<id>.jsonlcore serialization; not every session has an extension transcript, so fall back when it is absent. - A recent transcript timestamp proves chat freshness only, not repository freshness. Compare chat freshness and repo freshness separately, and confirm task state against current files and
git status. - A single project can own several hashes; reconcile them as one evidence set but keep source references (hash plus session id) separate in the report.
workspace.jsonandstate.vscdbare routing/config context, not proof that implementation or verification happened.- Copilot inline-completion history (ghost text) is not stored as chat; only Copilot Chat and Edits sessions are recoverable here. Classify task status from the transcript plus the current workspace.
Reference: VS Code stores GitHub Copilot Chat under User/workspaceStorage/<hash>/, with workspace.json mapping the hash to a project folder, the Copilot extension transcript at GitHub.copilot-chat/transcripts/<id>.jsonl, and VS Code's core chat model at chatSessions/<id>.jsonl: https://github.com/orgs/community/discussions/129888
OpenCode Adapter
Use this adapter when resuming work from OpenCode or an OpenCode session export, share link, summary, or configured agent workflow.
Discovery
Prefer official session exports, share links, SDK access, or CLI-supported session commands over scraping internal storage.
Inspect the workspace for OpenCode configuration and project-local agents:
find . -maxdepth 4 -type f \( -name 'opencode.json' -o -path '*/.opencode/*' \) 2>/dev/nullUse configuration files as routing context, not as session evidence. They can explain which agents, models, or project rules shaped the prior work, but they do not prove what was done.
If the opencode CLI is installed, inspect its help for session-related commands before using them:
opencode --help
opencode session --helpPrefer documented session commands when they are available. Common next checks are:
opencode session list --help
opencode session export --help
opencode session show --helpIf a command exists, use it to list sessions first, then fetch or export only the likely session. Prefer filters such as title, session ID, timestamp, cwd, or project path over dumping every session.
If an OpenCode server or SDK context is available, prefer session API access to retrieve session, message, and summary data.
When no CLI/API path is available, search only likely export and handoff files before considering broad workspace scans:
find . -maxdepth 5 -type f \( \
-iname '*opencode*' -o \
-iname '*session*' -o \
-iname '*handoff*' -o \
-iname '*transcript*' -o \
-iname '*summary*' \
\) 2>/dev/nullSafe Reading
Check size before opening any candidate export, storage file, or fetched session dump:
wc -lc path/to/export-or-session-fileProject or peek metadata before bodies: prefer opencode session list style listings, session titles, and timestamps over opening message bodies. Rank candidates by strongest signal before reading anything: explicit user-supplied path, share link, or session ID first, then exact cwd/project match, then title or summary match, with recency only as a tie-breaker.
For markdown or text exports, map structure with rg -n "^#|TODO|error|failed" and read bounded sed -n '<start>,<end>p' slices instead of whole files. If a JSONL transcript surfaces (an export, or a handoff produced by another agent), project it with the packaged scripts from the skill's scripts/ directory (next to SKILL.md):
python3 "$skill_dir/scripts/session-events.py" path/to/transcript.jsonl --limit 200
python3 "$skill_dir/scripts/session-digest.py" path/to/transcript.jsonlReading
Read the full session export or fetched session messages when available. If only a generated title or summary is available, treat it as incomplete and verify against files, tests, and git state.
OpenCode supports session titles, summaries, multiple sessions, and configured agents. Use these as navigation aids, not as replacements for reading the actual session record.
When multiple sessions could match, rank them by:
1. Explicit path, share link, or session ID supplied by the user. 2. Exact cwd, project path, or repository match. 3. Session title or summary match. 4. Files, package names, branch names, or agents mentioned in the current workspace. 5. Recency, used only as a tie-breaker.
If only a summary is available, state that task status is summary-derived until confirmed against repository files, tests, and git state.
Resume Notes
- Inspect
opencode.jsonand.opencode/agents/to understand any project-specific agent behavior that shaped the prior session. - If a shared session link is provided, fetch or open it using the environment's allowed tools and summarize only enough to resume.
- Do not assume a stable private storage path. Prefer documented CLI, SDK, or export paths.
References:
- OpenCode overview and session sharing: https://dev.opencode.ai/
- OpenCode agents, session titles, summaries, and agent config: https://open-code.ai/en/docs/agents
- OpenCode SDK session types and client access: https://opencode.ai/docs/sdk/
#!/usr/bin/env python3
"""List likely agent-session transcripts without dumping transcript bodies.
Supports time-window filters (`--since`, `--until`, ISO dates or relative
values such as `7d`) and a `--cwd` filter that keeps only sessions whose
workspace matches the given path exactly or as a parent/child directory.
Every row's `updated_at` is normalized to ISO-8601 UTC with seconds precision
(e.g. 2026-06-10T00:15:30Z) on both platforms, and carries a `source` marker:
`index` (platform session index) or `mtime` (transcript file mtime). For codex,
an mtime fallback sweep surfaces in-window transcripts the session index never
recorded (e.g. Codex Desktop sub-threads).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
RELATIVE_RE = re.compile(r"^(?P<value>\d+)(?P<unit>[smhdw])$")
RELATIVE_UNITS = {
"s": "seconds",
"m": "minutes",
"h": "hours",
"d": "days",
"w": "weeks",
}
# Where a candidate's updated_at came from: a platform session index ("index")
# or the transcript file's mtime ("mtime").
SOURCE_INDEX = "index"
SOURCE_MTIME = "mtime"
# Cap for the optional first-line cwd peek on unindexed codex transcripts, so
# the mtime fallback never reads more than this many bytes per file.
MAX_CWD_PEEK_BYTES = 65536
def parse_when(raw: str) -> float:
"""Parse an ISO date/datetime or a relative window like 7d into epoch seconds."""
value = raw.strip()
match = RELATIVE_RE.match(value)
if match:
delta = timedelta(**{RELATIVE_UNITS[match.group("unit")]: int(match.group("value"))})
return (datetime.now(timezone.utc) - delta).timestamp()
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
raise SystemExit(f"invalid --since/--until value: {raw!r} (use ISO date/datetime or relative like 7d, 12h)")
if parsed.tzinfo is None:
parsed = parsed.astimezone()
return parsed.timestamp()
def parse_iso_epoch(raw: str) -> float | None:
try:
parsed = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.astimezone()
return parsed.timestamp()
def iso_utc(epoch: float) -> str:
"""Render an epoch as ISO-8601 UTC with seconds precision (the one updated_at format both platforms emit)."""
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def normalize_updated_at(raw: str, path: Path | None) -> str:
"""Normalize index timestamps (fractional ISO, plain ISO, or epoch nanoseconds) to iso_utc; fall back to file mtime."""
value = str(raw or "").strip()
if value.isdigit():
return iso_utc(int(value) / 1_000_000_000)
if value:
epoch = parse_iso_epoch(value)
if epoch is not None:
return iso_utc(epoch)
if path is not None:
try:
return iso_utc(path.stat().st_mtime)
except OSError:
return ""
return ""
def candidate_epoch(candidate: dict[str, Any]) -> float | None:
updated_at = str(candidate.get("updated_at") or "")
if updated_at.isdigit():
return int(updated_at) / 1_000_000_000
epoch = parse_iso_epoch(updated_at) if updated_at else None
if epoch is not None:
return epoch
path = candidate.get("path") or ""
if path:
try:
return Path(path).stat().st_mtime
except OSError:
return None
return None
def cwd_related(candidate_cwd: str, filter_cwd: str) -> bool:
if not candidate_cwd:
return False
candidate_norm = os.path.normpath(candidate_cwd)
filter_norm = os.path.normpath(filter_cwd)
return (
candidate_norm == filter_norm
or candidate_norm.startswith(filter_norm + os.sep)
or filter_norm.startswith(candidate_norm + os.sep)
)
def apply_filters(
candidates: list[dict[str, Any]],
since_epoch: float | None,
until_epoch: float | None,
filter_cwd: str | None,
) -> list[dict[str, Any]]:
kept: list[dict[str, Any]] = []
for candidate in candidates:
if filter_cwd is not None and not cwd_related(candidate.get("cwd") or "", filter_cwd):
continue
if since_epoch is not None or until_epoch is not None:
epoch = candidate_epoch(candidate)
if epoch is None:
continue
if since_epoch is not None and epoch < since_epoch:
continue
if until_epoch is not None and epoch > until_epoch:
continue
kept.append(candidate)
return kept
def read_jsonl(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
try:
with path.open(encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
except OSError:
return []
return rows
def first_codex_cwd(path: Path) -> str:
for row in read_jsonl(path):
if row.get("type") == "session_meta":
payload = row.get("payload") or {}
return str(payload.get("cwd") or "")
return ""
def first_claude_cwd(path: Path) -> str:
for row in read_jsonl(path):
cwd = row.get("cwd")
if row.get("type") == "user" and cwd:
return str(cwd)
return ""
def first_claude_title(path: Path) -> str:
seen: set[str] = set()
for row in read_jsonl(path):
if row.get("type") != "ai-title":
continue
title = str(row.get("aiTitle") or "")
if title and title not in seen:
return title
seen.add(title)
return ""
def score_candidate(cwd: str, title: str, target_cwd: str, topic: str) -> tuple[int, list[str]]:
score = 0
signals: list[str] = []
if target_cwd and cwd == target_cwd:
score += 100
signals.append("exact-cwd")
elif target_cwd and (cwd.startswith(target_cwd + os.sep) or target_cwd.startswith(cwd + os.sep)):
score += 60
signals.append("parent-child-cwd")
if topic and topic.lower() in title.lower():
score += 30
signals.append("title-match")
return score, signals
def find_codex_transcript(codex_home: Path, session_id: str) -> Path | None:
for directory in (codex_home / "sessions", codex_home / "archived_sessions"):
if not directory.exists():
continue
matches = sorted(directory.rglob(f"*{session_id}*.jsonl"))
if matches:
return matches[-1]
return None
def peek_codex_cwd(path: Path) -> str:
"""Read at most MAX_CWD_PEEK_BYTES of the first line to recover session_meta cwd; empty string when not cheap/parseable."""
try:
with path.open(encoding="utf-8", errors="replace") as handle:
line = handle.readline(MAX_CWD_PEEK_BYTES).strip()
except OSError:
return ""
if not line or not line.endswith("}"):
return ""
try:
row = json.loads(line)
except json.JSONDecodeError:
return ""
if row.get("type") != "session_meta":
return ""
payload = row.get("payload") or {}
return str(payload.get("cwd") or "")
def codex_mtime_fallback(
codex_home: Path,
known_paths: set[str],
target_cwd: str,
since_epoch: float | None,
until_epoch: float | None,
) -> list[dict[str, Any]]:
"""Surface in-window transcripts the session index never recorded (e.g. Codex Desktop sub-threads).
Cost is bounded: os.walk + stat per file, plus a size-capped first-line cwd
peek for files that pass the time window.
"""
sessions_dir = codex_home / "sessions"
if not sessions_dir.exists():
return []
fallback: list[dict[str, Any]] = []
for dirpath, _dirnames, filenames in os.walk(sessions_dir):
for name in sorted(filenames):
if not name.endswith(".jsonl"):
continue
path = Path(dirpath) / name
if str(path) in known_paths:
continue
try:
mtime = path.stat().st_mtime
except OSError:
continue
if since_epoch is not None and mtime < since_epoch:
continue
if until_epoch is not None and mtime > until_epoch:
continue
cwd = peek_codex_cwd(path)
score, signals = score_candidate(cwd, "", target_cwd, "")
fallback.append(
{
"platform": "codex",
"id": path.stem,
"title": "",
"updated_at": iso_utc(mtime),
"cwd": cwd,
"path": str(path),
"score": score,
"signals": signals,
"source": SOURCE_MTIME,
}
)
return fallback
def codex_candidates(
codex_home: Path,
target_cwd: str,
topic: str,
since_epoch: float | None,
until_epoch: float | None,
) -> list[dict[str, Any]]:
index = codex_home / "session_index.jsonl"
candidates: list[dict[str, Any]] = []
rows = read_jsonl(index) if index.exists() else []
for row in rows:
session_id = str(row.get("id") or "")
title = str(row.get("thread_name") or "")
if topic and topic.lower() not in title.lower():
continue
path = find_codex_transcript(codex_home, session_id) if session_id else None
cwd = first_codex_cwd(path) if path else ""
score, signals = score_candidate(cwd, title, target_cwd, topic)
candidates.append(
{
"platform": "codex",
"id": session_id,
"title": title,
"updated_at": normalize_updated_at(str(row.get("updated_at") or ""), path),
"cwd": cwd,
"path": str(path) if path else "",
"score": score,
"signals": signals,
"source": SOURCE_INDEX,
}
)
# The index silently omits unindexed transcripts (Codex Desktop sub-threads
# with parent_thread_id never get index entries); sweep sessions/ by mtime so
# those still surface. Skipped under --topic: unindexed files have no title
# to match, so the topic filter would drop every fallback row anyway.
if not topic:
known_paths = {candidate["path"] for candidate in candidates if candidate["path"]}
candidates.extend(codex_mtime_fallback(codex_home, known_paths, target_cwd, since_epoch, until_epoch))
return candidates
def encode_claude_project_path(cwd: str) -> str:
return cwd.replace("/", "-")
def claude_candidates(claude_home: Path, target_cwd: str, topic: str, scan_all_projects: bool) -> list[dict[str, Any]]:
projects = claude_home / "projects"
project_dirs: list[Path] = []
if target_cwd and not scan_all_projects:
derived = projects / encode_claude_project_path(target_cwd)
if derived.exists():
project_dirs.append(derived)
if not project_dirs and projects.exists():
project_dirs = sorted(projects.iterdir())
candidates: list[dict[str, Any]] = []
for project_dir in project_dirs:
if not project_dir.is_dir():
continue
for path in sorted(project_dir.glob("*.jsonl")):
title = first_claude_title(path)
if topic and topic.lower() not in title.lower():
continue
cwd = first_claude_cwd(path)
score, signals = score_candidate(cwd, title, target_cwd, topic)
candidates.append(
{
"platform": "claude-code",
"id": path.stem,
"title": title,
"updated_at": iso_utc(path.stat().st_mtime),
"cwd": cwd,
"path": str(path),
"score": score,
"signals": signals,
"source": SOURCE_MTIME,
}
)
return candidates
def print_tsv(candidates: list[dict[str, Any]]) -> None:
print("score\tplatform\tupdated_at\tsource\tcwd\ttitle\tpath")
for candidate in candidates:
print(
"\t".join(
[
str(candidate["score"]),
candidate["platform"],
str(candidate["updated_at"]),
str(candidate.get("source") or ""),
candidate["cwd"],
candidate["title"],
candidate["path"],
]
)
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--platform", choices=["codex", "claude-code"], required=True)
parser.add_argument(
"--cwd",
default=None,
help=(
"Workspace path filter; keeps sessions whose cwd matches exactly or as a parent/child path "
"and boosts ranking. When omitted, the current directory is used for ranking only."
),
)
parser.add_argument("--topic", default="", help="Optional title/topic filter.")
parser.add_argument(
"--since",
default=None,
help="Keep sessions updated at/after this ISO date/datetime or relative window (e.g. 7d, 12h).",
)
parser.add_argument(
"--until",
default=None,
help="Keep sessions updated at/before this ISO date/datetime or relative window (e.g. 1d).",
)
parser.add_argument("--limit", type=int, default=10)
parser.add_argument("--format", choices=["json", "tsv"], default="json")
parser.add_argument("--codex-home", default=os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
parser.add_argument("--claude-home", default=str(Path.home() / ".claude"))
args = parser.parse_args()
filter_cwd = os.path.abspath(args.cwd) if args.cwd is not None else None
target_cwd = filter_cwd or os.getcwd()
since_epoch = parse_when(args.since) if args.since else None
until_epoch = parse_when(args.until) if args.until else None
if since_epoch is not None and until_epoch is not None and since_epoch > until_epoch:
print("session-candidates: --since is later than --until; no sessions can match", file=sys.stderr)
if args.platform == "codex":
candidates = codex_candidates(Path(args.codex_home), target_cwd, args.topic, since_epoch, until_epoch)
else:
# A --cwd filter accepts parent/child workspaces, so the single derived
# project directory is too narrow; scan all project directories instead.
scan_all_projects = filter_cwd is not None
candidates = claude_candidates(Path(args.claude_home), target_cwd, args.topic, scan_all_projects)
candidates = apply_filters(candidates, since_epoch, until_epoch, filter_cwd)
candidates.sort(key=lambda item: (item["score"], item.get("updated_at", ""), item.get("path", "")), reverse=True)
candidates = candidates[: args.limit]
if args.format == "tsv":
print_tsv(candidates)
else:
print(json.dumps(candidates, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Create a compact evidence digest from agent-session files.
Digests are cached in a sidecar file (`<transcript>.digest.json`) written next
to the source file by default. On rerun the sidecar is reused when the source
is unchanged, and only the appended tail is processed when the source has
grown append-only (the default incremental behavior). If the previously
digested prefix changed, the digest is recomputed from scratch.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from pathlib import Path
from typing import Any
MAX_PREVIEW = 500
KEYWORDS = ("todo", "not done", "partially done", "failed", "error", "next", "pause", "stop here")
SIDECAR_RE = re.compile(r"Full output saved to:\s*(?P<path>[^\s]+)")
CACHE_VERSION = 1
CACHE_SUFFIX = ".digest.json"
JSONL_PLATFORMS = {"codex", "claude-code"}
def notice(message: str) -> None:
print(f"session-digest: {message}", file=sys.stderr)
def preview(text: Any, limit: int = MAX_PREVIEW) -> str:
value = text if isinstance(text, str) else json.dumps(text, ensure_ascii=False)
value = " ".join(value.split())
if len(value) <= limit:
return value
return value[: limit - 1] + "…"
def parse_jsonl_bytes(data: bytes) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for line in data.decode("utf-8", errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(row, dict):
rows.append(row)
return rows
def read_jsonl(path: Path) -> list[dict[str, Any]]:
return parse_jsonl_bytes(path.read_bytes())
def is_codex(rows: list[dict[str, Any]]) -> bool:
return any(row.get("type") == "session_meta" for row in rows)
def is_claude(rows: list[dict[str, Any]]) -> bool:
return any(row.get("type") in {"ai-title", "queue-operation"} for row in rows) or any(
row.get("type") in {"user", "assistant", "tool_result"} and "message" in row for row in rows
)
def text_from_claude_content(content: Any) -> str:
if isinstance(content, str):
return content
if not isinstance(content, list):
return preview(content)
parts: list[str] = []
for item in content:
if not isinstance(item, dict):
continue
item_type = item.get("type")
if item_type == "text":
parts.append(str(item.get("text") or ""))
elif item_type == "tool_use":
parts.append(f"[tool_use {item.get('name')}] {preview(item.get('input') or '')}")
elif item_type == "tool_result":
parts.append(f"[tool_result] {preview(item.get('content') or '')}")
return "\n".join(part for part in parts if part)
def sidecar_paths(text: str, transcript_path: Path) -> list[Path]:
paths: list[Path] = []
for match in SIDECAR_RE.finditer(text):
raw_path = match.group("path")
candidate = Path(raw_path)
if not candidate.is_absolute():
candidate = transcript_path.parent / candidate
paths.append(candidate)
return paths
def line_count(path: Path) -> int:
with path.open(encoding="utf-8", errors="replace") as handle:
return sum(1 for _ in handle)
def digest_sidecar(path: Path) -> dict[str, Any]:
if not path.exists() or not path.is_file():
return {"path": str(path), "exists": False, "bytes": 0, "lines": 0, "preview": "", "cue_hits": []}
cue_hits: list[str] = []
preview_lines: list[str] = []
with path.open(encoding="utf-8", errors="replace") as handle:
for index, line in enumerate(handle, start=1):
clean = line.strip()
if index <= 5 and clean:
preview_lines.append(clean)
if clean and any(keyword in clean.lower() for keyword in KEYWORDS):
cue_hits.append(f"{path}:L{index}: {preview(clean)}")
if len(cue_hits) >= 8 and index > 5:
break
return {
"path": str(path),
"exists": True,
"bytes": path.stat().st_size,
"lines": line_count(path),
"preview": preview(" ".join(preview_lines)),
"cue_hits": cue_hits,
}
def digest_codex(path: Path, rows: list[dict[str, Any]]) -> dict[str, Any]:
meta: dict[str, Any] = {}
timeline: list[str] = []
tool_calls: list[str] = []
evidence: list[str] = []
for row in rows:
row_type = row.get("type")
payload = row.get("payload") or {}
if row_type == "session_meta":
meta = {
"id": payload.get("id"),
"cwd": payload.get("cwd"),
"timestamp": payload.get("timestamp"),
"originator": payload.get("originator"),
}
if row_type not in {"event_msg", "response_item"}:
continue
payload_type = payload.get("type")
timestamp = row.get("timestamp") or ""
if payload_type in {"user_message", "agent_message"}:
message = preview(payload.get("message") or payload.get("text") or "")
timeline.append(f"{timestamp} {payload_type}: {message}")
if any(keyword in message.lower() for keyword in KEYWORDS):
evidence.append(f"{timestamp} {message}")
elif payload_type == "function_call":
tool_calls.append(f"{timestamp} {payload.get('name') or 'tool'}: {preview(payload.get('arguments') or payload)}")
elif payload_type == "function_call_output":
output = preview(payload.get("output") or payload)
if any(keyword in output.lower() for keyword in KEYWORDS):
evidence.append(f"{timestamp} tool_output: {output}")
return {"platform": "codex", "path": str(path), "meta": meta, "timeline": timeline, "tool_calls": tool_calls, "evidence": evidence}
def digest_claude(path: Path, rows: list[dict[str, Any]], seen_titles: set[str] | None = None) -> dict[str, Any]:
meta: dict[str, Any] = {"titles": []}
timeline: list[str] = []
tool_calls: list[str] = []
evidence: list[str] = []
sidecars: dict[str, dict[str, Any]] = {}
seen_titles = set(seen_titles or ())
for row in rows:
row_type = row.get("type")
timestamp = row.get("timestamp") or ""
if row_type == "ai-title":
title = str(row.get("aiTitle") or "")
if title and title not in seen_titles:
meta["titles"].append(title)
seen_titles.add(title)
continue
if row_type not in {"user", "assistant", "system", "tool_result"}:
continue
if row.get("cwd") and "cwd" not in meta:
meta["cwd"] = row.get("cwd")
if row.get("sessionId") and "id" not in meta:
meta["id"] = row.get("sessionId")
if row_type == "tool_result":
role = "tool_result"
content = {
"command": row.get("command"),
"output": row.get("output"),
}
else:
message = row.get("message") or {}
role = message.get("role") or row_type if isinstance(message, dict) else row_type
content = message.get("content") if isinstance(message, dict) else message
text = text_from_claude_content(content)
if not text:
continue
for sidecar_path in sidecar_paths(text, path):
sidecars[str(sidecar_path)] = digest_sidecar(sidecar_path)
line = f"{timestamp} {role}: {preview(text)}"
timeline.append(line)
if "[tool_use" in text:
tool_calls.append(line)
if any(keyword in text.lower() for keyword in KEYWORDS):
evidence.append(line)
return {
"platform": "claude-code",
"path": str(path),
"meta": meta,
"timeline": timeline,
"tool_calls": tool_calls,
"evidence": evidence,
"sidecars": list(sidecars.values()),
}
def digest_text(path: Path) -> dict[str, Any]:
text = path.read_text(encoding="utf-8")
lines = [line.strip() for line in text.splitlines() if line.strip()]
evidence = [line for line in lines if any(keyword in line.lower() for keyword in KEYWORDS)]
return {"platform": "text", "path": str(path), "meta": {}, "timeline": lines[:20], "tool_calls": [], "evidence": evidence[:20]}
def digest_file(path: Path) -> dict[str, Any]:
if path.suffix == ".jsonl":
rows = read_jsonl(path)
if is_codex(rows):
return digest_codex(path, rows)
if is_claude(rows):
return digest_claude(path, rows)
return digest_text(path)
def digest_jsonl_rows(path: Path, platform: str, rows: list[dict[str, Any]], seen_titles: set[str]) -> dict[str, Any]:
if platform == "codex":
return digest_codex(path, rows)
return digest_claude(path, rows, seen_titles=seen_titles)
def merge_digests(base: dict[str, Any], tail: dict[str, Any]) -> dict[str, Any]:
merged = dict(base)
for key in ("timeline", "tool_calls", "evidence"):
merged[key] = list(base.get(key) or []) + list(tail.get(key) or [])
base_meta = dict(base.get("meta") or {})
for key, value in (tail.get("meta") or {}).items():
if key == "titles":
titles = list(base_meta.get("titles") or [])
titles.extend(title for title in value if title not in titles)
base_meta["titles"] = titles
elif key not in base_meta and value:
base_meta[key] = value
merged["meta"] = base_meta
if "sidecars" in base or "sidecars" in tail:
by_path: dict[str, dict[str, Any]] = {}
for record in list(base.get("sidecars") or []) + list(tail.get("sidecars") or []):
by_path[str(record.get("path"))] = record
merged["sidecars"] = list(by_path.values())
return merged
def sha256_prefix(path: Path, length: int) -> str:
hasher = hashlib.sha256()
remaining = length
with path.open("rb") as handle:
while remaining > 0:
chunk = handle.read(min(1024 * 1024, remaining))
if not chunk:
break
hasher.update(chunk)
remaining -= len(chunk)
return hasher.hexdigest()
def last_complete_newline_offset(path: Path, size: int) -> int:
if size == 0:
return 0
offset = 0
with path.open("rb") as handle:
while True:
chunk = handle.read(min(1024 * 1024, size - offset))
if not chunk:
break
idx = chunk.rfind(b"\n")
if idx != -1:
offset += idx + 1
else:
offset += len(chunk)
return offset
def cache_sidecar_path(path: Path, sidecar_dir: Path | None) -> Path:
name = path.name + CACHE_SUFFIX
if sidecar_dir is not None:
return sidecar_dir / name
return path.with_name(name)
def load_cache(path: Path, cache_path: Path) -> dict[str, Any] | None:
try:
record = json.loads(cache_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(record, dict):
return None
if record.get("cache_version") != CACHE_VERSION:
return None
if record.get("source") != str(path.resolve()):
return None
if not isinstance(record.get("digest"), dict):
return None
if not isinstance(record.get("size"), int) or not isinstance(record.get("offset"), int):
return None
if not isinstance(record.get("sha256"), str) or not record["sha256"]:
return None
return record
def write_cache(path: Path, cache_path: Path, digest: dict[str, Any]) -> None:
size = path.stat().st_size
offset = last_complete_newline_offset(path, size)
record = {
"cache_version": CACHE_VERSION,
"source": str(path.resolve()),
"size": size,
"sha256": sha256_prefix(path, offset),
"offset": offset,
"digest": digest,
}
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
cache_path.write_text(json.dumps(record, ensure_ascii=False), encoding="utf-8")
except OSError as exc:
notice(f"skipping sidecar for {path} (not writable: {exc})")
def digest_file_cached(path: Path, sidecar_dir: Path | None, use_sidecar: bool, incremental: bool) -> dict[str, Any]:
if not use_sidecar:
return digest_file(path)
cache_path = cache_sidecar_path(path, sidecar_dir)
cached = load_cache(path, cache_path) if cache_path.exists() else None
size = path.stat().st_size
if cached is not None:
if size == cached["size"] and sha256_prefix(path, size) == cached["sha256"]:
notice(f"cache hit for {path} (reused {cache_path})")
digest = cached["digest"]
digest["path"] = str(path)
return digest
platform = (cached["digest"] or {}).get("platform")
if (
incremental
and platform in JSONL_PLATFORMS
and sha256_prefix(path, cached["offset"]) == cached["sha256"]
):
notice(f"incremental update for {path} (processing bytes {cached['offset']}..{size})")
with path.open("rb") as handle:
handle.seek(cached["offset"])
tail_bytes = handle.read()
base = cached["digest"]
base["path"] = str(path)
seen_titles = set((base.get("meta") or {}).get("titles") or [])
tail = digest_jsonl_rows(path, platform, parse_jsonl_bytes(tail_bytes), seen_titles)
digest = merge_digests(base, tail)
write_cache(path, cache_path, digest)
return digest
notice(f"cache invalidated for {path} (source changed); recomputing")
digest = digest_file(path)
write_cache(path, cache_path, digest)
return digest
def print_digest(digests: list[dict[str, Any]]) -> None:
print("# Session Digest")
for digest in digests:
print(f"\n## {digest['path']}")
print(f"- Platform: {digest['platform']}")
if digest["meta"]:
print(f"- Metadata: `{json.dumps(digest['meta'], ensure_ascii=False)}`")
print("\n### Timeline Preview")
for line in digest["timeline"][:12]:
print(f"- {line}")
if digest["tool_calls"]:
print("\n### Tool Calls")
for line in digest["tool_calls"][:8]:
print(f"- {line}")
if digest["evidence"]:
print("\n### Evidence Cues")
for line in digest["evidence"][:12]:
print(f"- {line}")
if digest.get("sidecars"):
print("\n### Sidecars")
for sidecar in digest["sidecars"][:8]:
exists = "yes" if sidecar["exists"] else "no"
print(f"- {sidecar['path']} (exists: {exists}, bytes: {sidecar['bytes']}, lines: {sidecar['lines']})")
if sidecar["preview"]:
print(f" - preview: {sidecar['preview']}")
for cue in sidecar["cue_hits"][:4]:
print(f" - cue: {cue}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("files", nargs="+", type=Path)
parser.add_argument(
"--sidecar-dir",
type=Path,
default=None,
help=f"Directory for `<name>{CACHE_SUFFIX}` cache sidecars; defaults to the transcript's own directory.",
)
parser.add_argument("--no-sidecar", action="store_true", help="Disable reading and writing digest cache sidecars.")
parser.add_argument(
"--no-incremental",
action="store_true",
help="Disable incremental append-only reuse; unchanged files still get whole-sidecar cache hits.",
)
args = parser.parse_args()
print_digest(
[
digest_file_cached(path, args.sidecar_dir, not args.no_sidecar, not args.no_incremental)
for path in args.files
]
)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Project agent-session JSONL files into bounded evidence events."""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Iterable
DEFAULT_PREVIEW_CHARS = 300
@dataclass
class Event:
file: str
line: int
platform: str
timestamp: str
kind: str
role: str
text: str
@property
def ref(self) -> str:
return f"{self.file}:L{self.line}"
def compact(value: Any, limit: int) -> str:
if isinstance(value, str):
text = value
else:
text = json.dumps(value, ensure_ascii=False, sort_keys=True)
text = " ".join(text.split())
if len(text) <= limit:
return text
return text[: max(0, limit - 1)] + "..."
def read_jsonl(path: Path) -> Iterable[tuple[int, dict[str, Any]]]:
with path.open(encoding="utf-8") as handle:
for index, line in enumerate(handle, start=1):
stripped = line.strip()
if not stripped:
continue
try:
yield index, json.loads(stripped)
except json.JSONDecodeError:
yield index, {"type": "invalid-json", "raw": stripped}
def detect_platform(rows: list[tuple[int, dict[str, Any]]]) -> str:
if any(row.get("type") == "session_meta" for _, row in rows):
return "codex"
if any(row.get("type") in {"ai-title", "queue-operation"} for _, row in rows):
return "claude-code"
if any(row.get("type") in {"user", "assistant", "system"} and "message" in row for _, row in rows):
return "claude-code"
return "unknown"
def project_codex(path: Path, rows: list[tuple[int, dict[str, Any]]], preview_chars: int) -> list[Event]:
events: list[Event] = []
for line, row in rows:
row_type = row.get("type")
timestamp = str(row.get("timestamp") or "")
payload = row.get("payload") or {}
if row_type == "session_meta":
text = compact(
{
"id": payload.get("id"),
"cwd": payload.get("cwd"),
"originator": payload.get("originator"),
"cli_version": payload.get("cli_version"),
},
preview_chars,
)
events.append(Event(str(path), line, "codex", timestamp, "session_meta", "metadata", text))
continue
if row_type not in {"event_msg", "response_item"}:
continue
payload_type = str(payload.get("type") or "")
if payload_type in {"user_message", "agent_message"}:
text = compact(payload.get("message") or payload.get("text") or "", preview_chars)
events.append(Event(str(path), line, "codex", timestamp, payload_type, payload_type, text))
elif payload_type == "function_call":
name = str(payload.get("name") or "tool")
text = compact(payload.get("arguments") or payload, preview_chars)
events.append(Event(str(path), line, "codex", timestamp, "function_call", name, text))
elif payload_type == "function_call_output":
text = compact(payload.get("output") or payload, preview_chars)
events.append(Event(str(path), line, "codex", timestamp, "function_call_output", "tool", text))
return events
def claude_content_items(content: Any) -> Iterable[tuple[str, str, Any]]:
if isinstance(content, str):
yield "text", "assistant", content
return
if not isinstance(content, list):
yield "text", "assistant", content
return
for item in content:
if not isinstance(item, dict):
continue
item_type = str(item.get("type") or "")
if item_type == "text":
yield "text", "assistant", item.get("text") or ""
elif item_type == "tool_use":
yield "tool_use", str(item.get("name") or "tool"), item.get("input") or {}
elif item_type == "tool_result":
yield "tool_result", "tool", item.get("content") or ""
def project_claude(path: Path, rows: list[tuple[int, dict[str, Any]]], preview_chars: int) -> list[Event]:
events: list[Event] = []
seen_titles: set[str] = set()
for line, row in rows:
row_type = str(row.get("type") or "")
timestamp = str(row.get("timestamp") or "")
if row_type == "ai-title":
title = str(row.get("aiTitle") or "")
if title and title not in seen_titles:
seen_titles.add(title)
events.append(Event(str(path), line, "claude-code", timestamp, "ai-title", "metadata", compact(title, preview_chars)))
continue
if row_type == "tool_result":
text = compact({"command": row.get("command"), "output": row.get("output")}, preview_chars)
events.append(Event(str(path), line, "claude-code", timestamp, "tool_result", "tool", text))
continue
if row_type not in {"user", "assistant", "system"}:
continue
message = row.get("message") or {}
if isinstance(message, dict):
role = str(message.get("role") or row_type)
content = message.get("content")
else:
role = row_type
content = message
for kind, item_role, text_value in claude_content_items(content):
event_role = role if kind == "text" else item_role
text = compact(text_value, preview_chars)
if text:
events.append(Event(str(path), line, "claude-code", timestamp, kind, event_role, text))
return events
def project_file(path: Path, preview_chars: int) -> tuple[str, list[Event], dict[str, int]]:
rows = list(read_jsonl(path))
platform = detect_platform(rows)
if platform == "codex":
events = project_codex(path, rows, preview_chars)
elif platform == "claude-code":
events = project_claude(path, rows, preview_chars)
else:
events = []
projected_chars = sum(len(event.text) for event in events)
metrics = {
"raw_bytes": path.stat().st_size,
"raw_lines": sum(1 for _ in path.open(encoding="utf-8")),
"projected_events": len(events),
"projected_chars": projected_chars,
}
return platform, events, metrics
def print_text(path: Path, platform: str, events: list[Event], metrics: dict[str, int]) -> None:
print(f"## {path}")
print(
"metrics "
f"platform={platform} raw_bytes={metrics['raw_bytes']} raw_lines={metrics['raw_lines']} "
f"projected_events={metrics['projected_events']} projected_chars={metrics['projected_chars']}"
)
for event in events:
stamp = f"{event.timestamp} " if event.timestamp else ""
print(f"{event.ref}\t{stamp}{event.platform}/{event.kind}/{event.role}\t{event.text}")
def print_tsv(path: Path, platform: str, events: list[Event], metrics: dict[str, int]) -> None:
print("file\tline\tplatform\ttimestamp\tkind\trole\ttext")
for event in events:
print(
"\t".join(
[
event.file,
str(event.line),
event.platform,
event.timestamp,
event.kind,
event.role,
event.text.replace("\t", " "),
]
)
)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("files", nargs="+", type=Path)
parser.add_argument("--format", choices=["text", "json", "tsv"], default="text")
parser.add_argument("--limit", type=int, default=0, help="Maximum events per file; 0 means no limit.")
parser.add_argument("--preview-chars", type=int, default=DEFAULT_PREVIEW_CHARS)
args = parser.parse_args()
all_json: list[dict[str, Any]] = []
for path in args.files:
platform, events, metrics = project_file(path, args.preview_chars)
if args.limit:
events = events[: args.limit]
if args.format == "json":
all_json.append(
{
"file": str(path),
"platform": platform,
"metrics": metrics,
"events": [event_json(event) for event in events],
}
)
elif args.format == "tsv":
print_tsv(path, platform, events, metrics)
else:
print_text(path, platform, events, metrics)
if args.format == "json":
print(json.dumps(all_json, indent=2, ensure_ascii=False))
def event_json(event: Event) -> dict[str, Any]:
value = asdict(event)
value["ref"] = event.ref
return value
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Compare repo, Codex, and Claude installed skill artifacts."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
from typing import Any
DEFAULT_SKILL = "agent-session-resume"
def file_stats(path: Path) -> dict[str, Any]:
if not path.exists() or not path.is_file():
return {
"path": str(path),
"exists": False,
"bytes": 0,
"lines": 0,
"sha256": "",
}
data = path.read_bytes()
return {
"path": str(path),
"exists": True,
"bytes": len(data),
"lines": len(data.decode("utf-8", errors="replace").splitlines()),
"sha256": hashlib.sha256(data).hexdigest(),
}
def read_json(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def annotate_status(records: dict[str, dict[str, Any]]) -> None:
repo_hash = records.get("repo", {}).get("sha256") or ""
for name, record in records.items():
if not record.get("exists"):
record["status"] = "missing"
elif name == "repo":
record["status"] = "reference"
elif repo_hash and record.get("sha256") == repo_hash:
record["status"] = "matches-repo"
elif repo_hash:
record["status"] = "differs-from-repo"
else:
record["status"] = "unknown"
def build_report(args: argparse.Namespace) -> dict[str, Any]:
repo_root = Path(args.repo_root).resolve()
codex_home = Path(args.codex_home).expanduser()
claude_home = Path(args.claude_home).expanduser()
skill = args.skill
records = {
"repo": file_stats(repo_root / "skills" / skill / "SKILL.md"),
"codex": file_stats(codex_home / "skills" / skill / "SKILL.md"),
"claude": file_stats(claude_home / "skills" / skill / "SKILL.md"),
}
annotate_status(records)
plugin_manifest = read_json(repo_root / ".claude-plugin" / "plugin.json")
openai_yaml = repo_root / "skills" / skill / "agents" / "openai.yaml"
return {
"skill": skill,
"repo_root": str(repo_root),
"version": plugin_manifest.get("version") or "",
"repository": plugin_manifest.get("repository") or "",
"openai_yaml": file_stats(openai_yaml),
"artifacts": records,
"reload_note": "If installed artifacts differ from the repo, restart or reload the receiving agent before assuming the update is active.",
}
def print_text(report: dict[str, Any]) -> None:
print("# Skill Provenance")
print(f"- Skill: {report['skill']}")
print(f"- Repo root: {report['repo_root']}")
print(f"- Version: {report['version'] or 'unknown'}")
print(f"- Repository: {report['repository'] or 'unknown'}")
print("")
print("| Surface | Status | Lines | Bytes | SHA-256 | Path |")
print("| --- | --- | ---: | ---: | --- | --- |")
for surface in ("repo", "codex", "claude"):
record = report["artifacts"][surface]
digest = record["sha256"][:12] if record["sha256"] else ""
print(
f"| {surface} | {record['status']} | {record['lines']} | {record['bytes']} | "
f"{digest or 'unknown'} | {record['path']} |"
)
print("")
print(report["reload_note"])
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--skill", default=DEFAULT_SKILL)
parser.add_argument("--repo-root", default=os.getcwd())
parser.add_argument("--codex-home", default=os.environ.get("CODEX_HOME", str(Path.home() / ".codex")))
parser.add_argument("--claude-home", default=str(Path.home() / ".claude"))
parser.add_argument("--format", choices=["text", "json"], default="text")
args = parser.parse_args()
report = build_report(args)
if args.format == "json":
print(json.dumps(report, indent=2, ensure_ascii=False))
else:
print_text(report)
if __name__ == "__main__":
main()