Repository navigation
feat(session): safe session reads and copies for workflows, drivers, scorers, and benchmarks (#593 follow-up) - #1135
Conversation
Cursor has no image storage. The transcript and composer writers now write the shared image placeholder for a pasted image instead of dropping it, so an image-only user message still appears and the written session matches the fidelity report.
Two session pushes at once could both pick the same free archive name and drop one session and its index entry. push, migrate --push and pull now hold a lock per team repo archive around each save and each index rebuild. The lock lives under ~/.teamai/locks, never inside the team repo.
A migrated session never reuses the source id in the target tool. Reusing a source UUID wrote the copy over whatever already held that id, and Claude Code resumes by id across project directories, so the target workspace is now part of every platform's id, Claude Code and CodeBuddy included. The workspace is hashed as its native real path, so /tmp/x and /private/tmp/x, or two letter cases on macOS, name one target. A target workspace that is not absolute is refused before anything is written. Ids already derived for correctly spelled workspaces on Cursor, Codex, WorkBuddy and CodeBuddy IDE do not change. Copies from earlier releases of a UUID source, or Claude Code and CodeBuddy copies, carry other ids.
session migrate now goes through AgentSession.copy. Before writing, copy looks the target up by id across the target tool's storage, including ids an older TeamAI wrote in that workspace. When a copy exists, readable or not, it prints 'already migrated as <id>' and writes nothing, so turns added to the copy survive. session resume runs the same check and prints 'already restored as <id>'. A failed write removes only the target that call created, in the target workspace. JSONL transcripts are written through a temp file and a rename.
AgentSession.transcript renders a session as one entry per message: `text` holds what the member and the agent wrote, with tool calls and their input as JSON, and `toolOutput` holds the tool results, each truncated to 2,000 characters. Session search indexes it in place of its own extractor, so a query finds an error a command printed, and nested tool input no longer indexes as "[object Object]". Search scores title, conversation and tool output as separate BM25 fields weighted 3x, 1x and 0.1x, so a session that names the query in its title or conversation ranks above one that only repeats it in a long tool dump. Snippets are collapsed to one line.
session migrate --json prints a single { data } | { error } line on stdout,
the shape the workflow commands use, and sends progress to stderr. data
carries the target session id, its workspace, reused, and the fidelity
report. Expected failures keep their names (no_adapter, tool_unavailable,
not_found, unreadable, invalid_workspace, write_failed); a held archive
lock is busy. --json never prompts: a missing id, source or target fails
with prompt_refused, --push needs -y or --scrub, and --all is refused.
Adapters read by { sessionId, projectPath?, filePath? }. Callers that enumerate pass the file they listed, so push --all no longer archives one copy twice when an id lives in two workspaces. Codex honors the workspace and returns the full id for a suffix; Cursor and WorkBuddy fall back to the id. AgentSession.find redacts by default; push and migrate --push pass their --scrub choice. The migrate preview reads the source through AgentSession.find, so --json prints not_found or unreadable instead of unexpected_error.
…here Codex is found with probeBinary and run through cross-spawn, so the npm codex.cmd shim on Windows is found and started. findSqlite3 looks for sqlite3.exe on Windows, and Cursor uses the same lookup. On Windows, Cursor and WorkBuddy list a copy only after the sqlite3 CLI registers it, so migrate and resume refuse them as targets with no_adapter and reason "unsupported on win32" instead of writing a copy the tool never shows. The no_adapter message now has one home. A Codex rollback that cannot find sqlite3 while Codex's databases exist now fails and keeps the rollout, instead of reporting success and leaving the thread listed. Workspace ids treat c:\ and C:\, and / and \, as one Windows path. Windows is not verified: there is no Windows CI.
…rted on A session_start event in a git work tree now carries gitHead and gitDirty (tracked changes only; untracked files are not listed). Each start records its own state; collectSession reports the earliest as gitAtStart. Outside git, with no commits, with no cwd, or when git fails or takes over 2 s, both fields are absent and the event is still written. The monthly log and session save --push are unchanged.
OpenCode becomes a source platform through its CLI: the adapter lists sessions with `opencode session list --format json`, reads one with `opencode session export`, and deletes with `opencode session delete`, always with `--standalone` so no background service is left running. The CLI is found with probeBinary and run through cross-spawn; a missing CLI or OpenCode 1 (no `session export`) is reported as tool_unavailable by AgentSession.find and by `session migrate --json`. An export carries user text and inline image files, and assistant text, reasoning and tool parts with their input and result. Tool calls and their results become separate turns, as Claude Code stores them, so search and the copy see tool output. OpenCode joins the fidelity tables (thinking, images, its tool names). AgentSession.platformForTool maps a workflow tool name to its session platform (`claude` to claude-code, `opencode` to opencode). Writing OpenCode sessions is not supported yet.
AgentSession.list({ platforms?, cwd?, updatedSince? }) returns every listed
session as a ref find reads as is, newest first, plus one failure per tool
that could not be listed (no_adapter, tool_unavailable, or the new
list_failed), so one broken tool does not hide the others. session migrate
and session push list through it, and migrate --json reports list_failed in
its envelope.
OpenCode's CLI lists one workspace at a time, so listing it without a
workspace is list_failed instead of the current directory passed off as all
of them, and its listed sessions carry no message count or size rather than 0.
A named source that is not installed now fails with tool_unavailable instead
of "No sessions found".
OpenCode becomes a target platform: the adapter writes the session in the shape `opencode session export` prints and imports it with `opencode session import --directory <workspace>`, through the same probed CLI and `--standalone` as its reads. OpenCode orders sessions and messages by the time encoded in their ids and refuses an id it already has, so copies keep its id shape: `ses_` plus the time part of an OpenCode source, or the source's creation time encoded the way OpenCode 2 encodes it, plus a suffix derived from the target workspace and the source id. Every message gets a new `msg_` id that sorts in message order, so two copies of one source never share one. Tool results go back into the tool part of the call they answer, and assistant messages carry `time.completed`, without which OpenCode drops them on import. A copy into a second workspace imports, and a repeat into the same workspace is reused. OpenCode refuses a workspace that does not exist, so such a copy fails with the path instead of writing.
The listing took the workspace from the project directory name, which decodes only when the path has no '-', '_', '.' or space; otherwise it returned the encoded name. It now takes the absolute cwd the session's records carry, as reading the session already does, and falls back to the decoded name.
npm's opencode-ai is OpenCode 1, so the E2E that migrates a session into OpenCode, and the OpenCode 2 case of the hooks E2E, skipped in CI. Both full-suite E2E jobs now fetch the @opencode/cli-linux-x64 2.0.29 tarball from the npm registry, check it against a pinned sha256, put its bin on PATH and set TEAMAI_OPENCODE_V2_BIN to it; no install script runs. Under CI the migrate test now fails instead of skipping when no OpenCode 2 is on PATH.
A member who relocated Claude Code (CLAUDE_CONFIG_DIR) or Codex (CODEX_HOME) and recorded it with `teamai init` had their sessions looked up in ~/.claude/projects and ~/.codex/sessions: sources were not found, and copies landed where `claude --resume` never looks. AgentSession, `session resume`, `session rollback` and `session platforms` now resolve the recorded `toolRoots` for the workspace at hand and pass them through the adapter registry to the store paths. A run over many sessions (migrate, push) resolves each workspace once. Nothing recorded keeps the default paths; the variants keep their fixed ones. The Codex CLI that indexes a copy gets CODEX_HOME set to the recorded root, as it already did for the default one. Looking up the recorded roots is a pure read: it never migrates or rewrites the member's config, also under --dry-run.
- session migrate into OpenCode reports tool_unavailable, not unexpected_error, when the opencode CLI is missing or is OpenCode 1. - An OpenCode export that fails for another reason than "Session not found" is tool_unavailable (existence unknown, nothing written), not a reused copy. - OpenCode listings keep only the workspace asked for: inside a git repo its CLI lists every directory of the repo. - AgentSession.copy and session resume write through one writeCopyOnce path, so a restore whose write fails is rolled back and a retry writes. - session_start events carry gitAtStart (HeadState from utils/git) instead of gitHead and gitDirty; SessionSummary.gitAtStart uses it. - AgentSessionListed derives from SessionMeta, the OpenCode tool error is parsed with zod, and noAdapter takes the OS as a parameter. - The session contract suites take their platforms from the adapter registry; OpenCode joins the find suite. - Docs: who picks how a session continues, tool_unavailable covers the source and the target, and the pinned OpenCode 2 in the CI E2E jobs.
Testing: The PR description provides sufficient real-CLI evidence at |
…ows refusal - Cursor: a composer whose transcript was deleted but whose rows remain in Cursor's store now counts as existing, so a copy is reused instead of rewriting its bubbles. A store that cannot be read fails the copy as tool_unavailable. - Claude Code, CodeBuddy, Cursor and WorkBuddy list a session whose first lines do not parse, under a placeholder title, so `session migrate <id>` reports unreadable instead of not_found. - `session migrate` refuses a target it cannot write once, before listing or previewing anything (text and --json). - Pin that the Cursor adapter keeps raw reasoning as <thinking> text in the transcript and out of the bubbles, and fix the comment that said otherwise.
…-only Cursor copies - Claude Code and CodeBuddy date a listed session with no readable timestamp by its file's mtime instead of now, so a damaged session does not sort as the newest one. - Cursor's deleteSession removes a composer that is only in Cursor's store (in that workspace when one is given), so the rollback a reused copy points to works and the next migrate writes a fresh copy.
Two commands copying the same session into the same workspace could both see no copy and both write it, and a failing one's rollback could delete the other's good copy. writeCopyOnce now runs the check, the write and the rollback under a lock on that one copy, keyed by the target platform and the derived id, under ~/.teamai/locks/. A second command waits and then reuses the first copy; one still blocked after ten seconds fails with copy_busy and says to run again when the other command finishes. Codex reuse already counted rollouts in archived_sessions, but reading and deleting looked only in sessions/, so migrate --push failed to read an archived copy back and rollback reported it not found. The read and delete lookup now searches archived rollouts too, with the same workspace scoping.
Testing: The description provides sufficient real-CLI evidence, including migration, repeat-copy reuse, and Codex continuation. Its checks name the older commit |
Resolved: The per-target lock now covers the existence check, write, and cleanup. Codex read/delete lookup now includes archived rollouts. Neither earlier finding is raised again. Testing: The PR description supplies sufficient real-CLI evidence, including migration, repeat-copy reuse, and Codex continuation. The record names the older commit I only read the scoped diff; I did not run, build, or install PR code. |
AgentSession.transcript keeps the first 2,000 characters of each tool result, which suits a scorer's prompt. Search indexed that transcript, so an error at the end of a long build log could not be found. Search now indexes each message's full tool output; the transcript stays truncated.
|
No new findings I’m confident are real in the scoped diff. Resolved: All three earlier findings are fixed: the copy lock covers check/write/cleanup, Codex read/delete includes archived rollouts, and search indexes complete tool output rather than the truncated transcript. Testing: The PR description documents sufficient built, real-CLI verification, including migration, repeat-copy reuse, and Codex continuation. The record names older commit I only read the scoped diff and trusted base review criteria; I did not run, build, or install PR code. |
A session that matches only in tool output scores well under 0.1, so `session search` printed it as `Score: 0.0`. It now prints two significant digits, for example 0.029 or 2.6.
Resolved: All three earlier findings remain fixed: the copy lock covers check/write/cleanup, Codex read/delete includes archived rollouts, and search indexes complete tool output. Testing: The description supplies sufficient built, real-CLI evidence, including migration, repeat-copy reuse, and Codex continuation. The end-to-end record names an older commit; that is not blocking. No additional provider × agent matrix is required. I only read the scoped diff and trusted base criteria; I did not run, build, or install PR code. |
migrate --all, its --push archive, and push --all stopped at the first session they could not read. Each now names it, counts it, carries on with the others, and exits 1. A failed archive write still stops before the commit, so no half-written entry is published.
|
No new findings I’m confident are real in the scoped diff. Resolved: All earlier findings are fixed: copy locking covers check/write/cleanup, Codex read/delete includes archived rollouts, search indexes complete tool output, and batch migration skips unreadable sessions while continuing with healthy ones and exiting nonzero. Testing: The PR description supplies sufficient built, real-CLI evidence, including migration, repeat-copy reuse, Codex continuation, and damaged-session batch verification at I only read the scoped diff and trusted base review criteria; I did not run, build, or install PR code. |
|
No new findings I’m confident are real in the scoped diff. Resolved: All earlier findings remain fixed: copy locking covers check/write/cleanup, Codex read/delete includes archived rollouts, search indexes complete tool output, and batch migration continues past unreadable sessions with a nonzero exit status. Testing: The description documents sufficient built, real-CLI verification, including migration, repeat-copy reuse, Codex continuation, and damaged-session batch verification at I only read the scoped diff and trusted base review criteria; I did not run, build, or install PR code. |
|
No new findings I’m confident are real in the scoped diff. Resolved: All earlier findings remain fixed: copy locking covers check/write/cleanup, Codex read/delete includes archived rollouts, search indexes complete tool output, and batch migration continues past unreadable sessions with a nonzero exit status. Testing: The PR description documents sufficient built, real-CLI verification, including checks at I only read the scoped diff and trusted base review criteria. I did not run, build, or install PR code. |
Follow-up to #593: fixes its review findings and gives every caller that reads or copies a member's agent session one safe way to do it. Related: #1080 (session continuation), #1085 (scorers), #1133 (multi-machine index conflicts, not covered here).
Summary
One module,
AgentSession, sits in front of the session adapters.session migrate,session push --all, and later the workflow local driver go through it.Who calls it:
session migrate,session push --all(members)list,find,copycopy,platformForToolteamai session migrate --jsonfind(redacted by default),transcriptlist,find,transcriptlist,find,transcript, git state at startlist,find,transcripttranscriptgives one text shape for every tool, with tool results apart from what the member and the agent wrote, for a judge's prompt. A scorer that counts tool calls reads them fromsession.messages, which has the same shape for every tool; tokens, interruptions, and corrections are already in the dashboard events under the same session id.A session file records its workspace and time but not the commit it started from (Claude Code records only the branch), and that cannot be rebuilt later. The session start hook now records the commit and whether tracked files had changes, so a benchmark can replay a session on the code it ran against.
A copy never overwrites:
Changes, one commit each:
session migrate --jsonwith tagged failuresAgentSession-,_,.or a space)@opencode/cli2.0.29 checked against its sha256)session rollbackremoves it from the storeunreadable, notnot_found, and is dated by its file rather than nowsession migrate -t cursor|workbuddyis refused once, before anything is listedcopy_busyThe workflow engine does not change, and
AgentSessionis not part of the driver contract. A driver can keep and restore sessions its own way: a host that runs several agents, such as T3 Code or Paseo, may store conversations and move them between its agents with its own mechanism. The driver that runs a step picks how a session continues, in this order:AgentSession.copy: what the local driver does, orteamai session migrate --jsonfor what a driver's own mechanism does not cover;Whatever the route, the driver reports the tool and its native session id; TeamAI needs nothing else.
AgentSessionreads only each tool's own session store, so a conversation that lives only in a driver's store is invisible to it; a driver with its own handle reports that separately. Importing such a conversation (as the session format the team repo archive already stores) is left for when a driver needs it. The note on #1080 has the rule.Evidence
End to end, built at 104fec6. A Claude Code session started from T3 Code, found through its thread's resume id, is copied to Codex in another workspace. The run uses an isolated
HOMEholding a copy of that session file, so the real~/.claudeand~/.codexare untouched.The source session asked about #567, so Codex answers from the copied context.
Before and after, per change. Every After cell is a real-CLI run. Before is
main: run for the index lock, read from the code for the other rows. The concurrent copy, Codex archive and build-log rows fix gaps in this branch, so their Before is this branch before the fix: read from the code for the concurrent copy, run for the other two.main)session pushinto one team clone lost a session 6 times out of 6migraterewrote the copy, losing a turn added theresession search ZZTOPScore: 0.029(one decimal printed0.0before), the snippet endserror TS2304: Cannot find name ZZTOP~/.claude/projects, andclaude --resumewithCLAUDE_CONFIG_DIRsaid "No conversation found"claude --resumefinds it;session rollbackremoves itopencode session list, a repeat is reused, andopencode run --sessionrecalls a keyword from it (OpenCode 2.0.29)--jsonprintstool_unavailable, exit 1, nothing writtenmigratewrote over itreused: true,state.vscdbbyte-identical;session rollbackremoves the store rows and the nextmigratewrites a fresh copymigrate --json:not_foundunreadable, with the parse error, exit 1session migrateof one session into Codex at once--jsonexit 1 withcopy_busyand nothing writtenmigrate --all -t codebuddy, thenpush --all2 session(s) migrated, 1 failed., exit 1 (--dry-runthe same); push commits the other two and prints1 session(s) could not be read and were not archived., exit 1archived_sessions, thenmigrate --pushandsession rollbackCodex session not found, exit 1; rollback "Session not found"Checks at 114dc5d (
mainmerged in again at 01e9549, bringing #1141 and #1117):tscand lint pass. The unit suite and the e2e suite (run at 01e9549; 114dc5d only adds a unit test) pass, with three exceptions: files that hitspawn git EAGAINor time out on a loaded machine, which pass when rerun;gitlab-clone-realspawn, which also times out locally onmain; andsource-freshness-recall(/private/varvs/varon macOS, fails onmaintoo). Every_index.jsonwrite still goes throughwriteIndex, which leaves an unchanged index byte-identical (#1141), and every command that reaches it holds the session index lock; a new test runssession pullandsession pull --alltwice through that lock and checks the second leaves_index.jsonbyte-identical. Real-CLI run at 114dc5d with an isolatedHOME:session platformslists Codex as installed with only~/.codexpresent;session push --allof three Claude Code sessions, the middle one damaged, archives and pushes the other two and exits 1; twosession pull --allruns leave_index.jsonwith the same SHA-256 and the team checkout clean. At 55df15f: 62 session, dashboard and hook test files (1453 tests) pass with aTMPDIRthat contains_and a space, as the macOS runners have.commands-referencepasses, and the OpenCode migrate E2E passes against OpenCode 2.0.29.Not verified:
codex exec resumeunder a relocatedCODEX_HOME: only a stand-in CLI was used.gitlabandgithubproviders: left to CI.Merge Danger
Door: two-way
Re-running
session migrateno longer overwrites the target; it reports the existing copy. Copies made before this change are recognized under their old ids.Naming a source tool that is not installed (
session migrate -s codex,session push --source codex --all) now fails withtool_unavailableand exit 1, where it printed "No sessions found" and exited 0.Blast Radius: sessions
session migrate,session push,session search, every session adapter, and the session start hook (onegitcall per session start). Windows is not verified: there is no Windows CI.