Maintainer notes on src/session-flow/, the code behind teamai session. The commands are described in the Dashboard guide page.
Each rule below spans more than one file. The rule names the code that owns it, so you can read the details there.
-
Every target id is derived. An adapter's
writeSessiongets the id it writes under fromderiveTargetSessionIdinids.ts, never from the source session, even when the source id is a UUID. The key is the target platform, the target workspace, and the source id, for every platform. Claude Code resumes by id across all project directories, so a copy in a second workspace needs its own id. A new adapter callsderiveTargetSessionIdbefore it writes anything. OpenCode ids encode their creation time and OpenCode refuses an id it already has, so an OpenCode copy keeps OpenCode's id shape (time part, then the derived suffix) and gets new message ids fromderiveOpencodeMessageIds. -
One workspace has one spelling.
deriveTargetSessionIdhashes the native real path of the workspace, so/tmp/xand/private/tmp/x, or two letter cases on macOS and Windows, name the same target. It refuses a workspace that is not absolute, such as a relative path or anmd5:<hash>placeholder. Don't hash a workspace throughresolveRealCwdinfs.ts: that one keeps the letter case it was given and decides where files land, not ids. -
A copy never overwrites. Every path that writes a session into a tool (
AgentSession.copyinagent-session.tsforsession migrate, andsession resume) writes throughwriteCopyOnceinagent-session.ts, which derives the id and looks the target up through the adapter'sexistingSessionId: the derived id across all of the platform's storage (every Codex rollout, archived ones included, and Cursor's composer store as well as its transcripts), then the ids an older TeamAI wrote (legacyTargetSessionIdsinids.ts) in the target workspace only. A target that exists but does not parse still exists. When one is found, the result isreusedand nothing is written. When the write fails, the rollback deletes only the derived id, and only in the target workspace: it is the one target this call created.writeJsonlwrites through a temp file and a rename. The check, the write and the rollback hold a lock on that one copy,~/.teamai/locks/session-copy-<hash>.lockkeyed by the target platform and the derived id, so a second command copying the same session into the same workspace waits and then reuses the copy, and one still blocked after ten seconds fails withcopy_busy. -
Tool CLIs are found and started the Windows way. An adapter that runs a tool's CLI finds it with
probeBinaryand runs it throughcross-spawn(execCommandinutils/exec.ts), as the Codex and OpenCode adapters do: a hand-builtPATHjoin andnode:child_processmiss the npm.cmdshim on Windows. An adapter whose CLI cannot run, or cannot tell whether a session exists, throwsToolUnavailableError(adapters/base.ts), whichAgentSessionandsession migrate --jsonreport astool_unavailable, never as a missing session. The sqlite3 CLI comes fromfindSqlite3insqlite.ts. Cursor and WorkBuddy list a copy only once sqlite3 registers it, so on WindowsnoAdapterinagent-session.tsrefuses them as targets withno_adapterandreason: 'unsupported on win32'; every command that names a platform reports that failure. Windows has no CI: test it through the seams that take a platform. -
Read the session that was listed. One id can live in several workspaces (copies an older TeamAI wrote), and Codex also accepts an id suffix. A caller that enumerates sessions passes the
filePathit listed (SessionReadRefinadapters/base.ts; a conversation directory on CodeBuddy IDE), so exactly that session is read, and the session id comes from what was opened, never from the request. Reads outside the copy path go throughAgentSession.find, which redacts unless the caller passesscrub: false;session pushandsession migrate --pushpass their--scrubchoice. -
List through
AgentSession.list. A caller that wants a member's sessions (session migrate,session push, a scorer, the dashboard) callsAgentSession.list({ platforms?, cwd?, updatedSince? })inagent-session.ts, never an adapter'slistConversations. Each listed session is a ref thatfindreads as is. A session whose first lines do not parse is still listed, under a placeholder title, sofindreports itunreadable, notnot_found. A tool that cannot be listed is one failure value (no_adapter,tool_unavailable, orlist_failed) next to the other tools' sessions. OpenCode's CLI lists one project at a time, and inside a git repo that project is the whole repo, so its adapter keeps only the sessions of the workspace asked for. Withoutcwd, OpenCode islist_failed, rather than returning the workspace the process runs in as if it were all of them. Its listing has no message count or size, so those fields are absent rather than 0. -
Stores follow the recorded tool root. Claude Code, Codex and Cursor CLI keep their sessions under the root the member recorded with
teamai init(toolRoots), resolved byresolveMemberToolRootsandresolveToolRootDir, never fromCLAUDE_CONFIG_DIR,CODEX_HOMEorCURSOR_DATA_DIRat run time.AgentSessionand thesessioncommands that build an adapter themselves (resume,rollback,platforms) resolve the roots withrecordedToolRootsinagent-session.tsfor the workspace at hand and pass them through the adapter registry (adapters/index.ts) togetClaudeCodeProjectsDir,getCodexSessionsDirandgetCursorProjectsDirinfs.ts. A run that reads or copies many sessions passes onetoolRootsLookup(), so each workspace is resolved once. With nothing recorded they stay~/.claude/projects,~/.codex/sessionsand~/.cursor/projects. Cursor is the one tool with two roots, recorded ascursor-dataandcursor-configrather than ascursor, because thetoolPathsentry namedcursormust not move: its usermcp.json,rules/andcommands/stay in~/.cursorwhatever the variables say. Its co-author write follows the config root throughcursorConfigDirintypes.ts, which also carries the$XDG_CONFIG_HOME/cursorfallback thattoolRootscannot record. The variants (claude-internal,codex-internal, ...) keep their fixed paths. -
The driver picks how a session continues. The driver that runs a workflow step decides: the same tool on the same machine resumes the session by id; a driver with its own way to move a session uses it; otherwise the local driver copies it with
AgentSession.copy. Failing those, it restores a copy from the team session archive (openSessionArchiveinarchive.ts). Failing that too, the step starts a fresh session, with a warning that says why each way failed.workflow publish --sessionsis what puts a run's sessions in the archive.sessionArchiveRootinsession-cmd-shared.tsis the one rule for where the archive lives, for the session commands and the driver alike. A restore reads the teamai-reports checkout first, as it is, since the claim that hands a run over has just synced it. On a git-kind team, a session teamai-reports does not hold is then read from the archive teamai kept on the default branch before: the first such read in a driver run fetches origin's default branch into the team clone'srefs/remotes/origin/<branch>and checks that commit out in a temporary--sharedclone. Every later fallback read in that run reads that clone, and the member's branch, HEAD, and files never move.close()removes the clone after the run's prompt has exited. A failed fetch reads the team clone as it is, and the restore says it may be older than origin. In self mode only teamai-reports is read, so the driver does not fetch. The workflow engine never copies a session, and a run snapshot carries only the session's{ tool, id }. A step's report carries the tool and that tool's native session id. A step that continued the lease's session also echoes it asresumed, and the engine records that the reported session continues it. The next step that resumes the conversation then gets that copy, with every turn, on any machine that has it or finds it in the archive; failing that, it tries the earlier sessions the lease lists, newest first, and gets fewer turns. -
Session index lock. Every write to a team repo archive (
saveSession: dedup lookup, name choice, file writes,_index.jsonupsert) and every index rebuild (rebuildIndex,rebuildAllIndexes) runs insidewithSessionIndexLock(repoRoot, fn).SyncManager.saveSession,deleteSession,rebuildIndex,rebuildAllIndexesandcommitRebuiltIndexesare asynchronous entry points that take it themselves; the synchronous mutations are private. An existing command holding the lock can call them within the same active operation without acquiring it again. The ownership context expires when that operation releases the lock. The lock file is~/.teamai/locks/sessions-<hash>.lock, keyed by the real path of the team repo'ssessions/. It stays out of the team repo because there the archive commit could stage it andpull --all's directory renames could move it while held. It covers one machine only. Two machines that both wrote_index.jsondiverge there:SyncManager.gitPull(also the retry insidegitPush) merges and returns the conflicted indexes, and the command caller rebuilds them and commits the merge (commitRebuiltIndexes) under the lock, with the network pull and push outside it. When the lock stays busy the merge is left in progress. The nextteamai session pullfinishes it first, then runs its own pull. Every session writer to the teamai-reports checkout (session save --push,session push,session migrate --push, the merge and index rebuild ofsession pull, andworkflow publish --sessions) goes throughupdateReports, under the reports lock, and the commands that only read it (session list,session search,session resume, andscore --team-session) open it throughreadableReportsWorktree. -
Session publication lock.
session push,session migrate --push, andsession pulltakewithSessionPublicationLockaround archive mutation, Git commit, pull/merge, and push. It is keyed by the archive checkout real path under~/.teamai/locks/session-publish-<hash>.lock; a contender waits up to ten seconds before reporting busy. Lock order is publication, then index. Network operations hold publication ownership but never the index lock. A save stages both files, keeps existing files as renameable backups, and restores/removes the pair if the atomic index write fails. Index repair happens before installing the pair so a rebuild cannot adopt a partial save. A rollback failure retains and names the backups.
Test ids through an adapter's writeSession under a temporary home, as src/__tests__/session-target-id.test.ts does. Test copies through AgentSession.copy, as the contract suite in src/__tests__/agent-session-copy.test.ts does. Test reads through AgentSession.find, as src/__tests__/agent-session-find.test.ts does, and listings through AgentSession.list, as src/__tests__/agent-session-list.test.ts does. The copy, id and find suites run against WRITABLE in src/__tests__/helpers/writable-platforms.ts, which reads the adapter registry, so a new adapter is covered without being added by hand. A case that does not apply to a platform filters it out next to the case, with the reason.