Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4ab8be1
fix(session): keep user images in Cursor sessions
SaulMoro Oct 14, 2026
8bf711c
fix(session): serialize team repo index writes
SaulMoro Oct 14, 2026
5f9bb8f
fix(session): derive every target id from the target workspace
SaulMoro Oct 14, 2026
1579cb2
feat(session): copy a session once and report an existing copy
SaulMoro Oct 14, 2026
f974a4e
fix(session): search tool results
SaulMoro Oct 14, 2026
d212ed0
feat(session): print one JSON envelope from session migrate --json
SaulMoro Oct 14, 2026
3196bd1
fix(session): read the session file the caller listed
SaulMoro Oct 14, 2026
e48adf8
fix(session): find Codex on Windows and refuse Cursor and WorkBuddy t…
SaulMoro Oct 14, 2026
23d1cf6
feat(session): record the commit and tracked-file state a session sta…
SaulMoro Oct 14, 2026
e910874
feat(session): migrate a session from OpenCode
SaulMoro Oct 14, 2026
7745a58
feat(session): list the sessions of every tool through AgentSession
SaulMoro Oct 14, 2026
f9d141a
feat(session): migrate a session to OpenCode
SaulMoro Oct 14, 2026
fd6aba2
fix(session): list a Claude Code session under the workspace it recorded
SaulMoro Oct 14, 2026
41aa9b7
ci(session): run the OpenCode E2Es against a pinned OpenCode 2
SaulMoro Oct 14, 2026
11e61a0
fix(session): read and write sessions under the recorded tool root
SaulMoro Oct 14, 2026
55df15f
fix(session): address review findings
SaulMoro Oct 14, 2026
2fab180
fix(session): close gaps in never-overwrite, unreadable, and the Wind…
SaulMoro Oct 14, 2026
e94e935
fix(session): date damaged sessions by their file and roll back store…
SaulMoro Oct 14, 2026
3dcd3f2
fix(session): lock each target copy, and find archived Codex copies
SaulMoro Oct 14, 2026
104fec6
fix(session): search the whole tool output, not the truncated transcript
SaulMoro Oct 14, 2026
2dbda05
fix(session): print search scores to two significant digits
SaulMoro Oct 14, 2026
a810a8c
fix(session): skip a session a batch cannot read and finish the rest
SaulMoro Oct 14, 2026
4de75f5
Merge branch 'main' into feat/agent-sessions-593
SaulMoro Oct 15, 2026
01e9549
Merge branch 'main' into feat/agent-sessions-593
SaulMoro Oct 15, 2026
114dc5d
test(session): a second session pull leaves _index.json byte-identical
SaulMoro Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ src/

See [docs/dev/adding-a-provider.md](../docs/dev/adding-a-provider.md) for how to add a new git provider.

Before you change `src/session-flow/`, read [docs/dev/agent-sessions.md](../docs/dev/agent-sessions.md). It lists the rules that span more than one file.

Before you change `src/workflow/`, read [docs/dev/workflow-engine.md](../docs/dev/workflow-engine.md). It lists the rules that span more than one file.

## Making a Change
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# OpenCode 2 for the session migrate and OpenCode hooks E2Es. `opencode-ai` on
# npm is OpenCode 1; OpenCode 2 ships as `@opencode/cli`, one npm package per
# platform. Bump both values together.
env:
OPENCODE2_VERSION: 2.0.29
OPENCODE2_LINUX_X64_SHA256: 14b5a6638a1a093ac9d484599c844b10c29b445d04844b682ccc668ce26530a2

jobs:
lint-and-test:
name: Lint & Test (Node ${{ matrix.node-version }} on ${{ matrix.os }})
Expand Down Expand Up @@ -120,6 +127,18 @@ jobs:
name: dist
path: dist

# A pinned tarball checked against its sha256, never a piped install script.
- name: Install OpenCode 2
run: |
dir="$RUNNER_TEMP/opencode2"
mkdir -p "$dir"
curl -fsSL -o "$dir/cli.tgz" "https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-$OPENCODE2_VERSION.tgz"
echo "$OPENCODE2_LINUX_X64_SHA256 $dir/cli.tgz" | sha256sum -c -
tar -xzf "$dir/cli.tgz" -C "$dir"
"$dir/package/bin/opencode" --version
echo "$dir/package/bin" >> "$GITHUB_PATH"
echo "TEAMAI_OPENCODE_V2_BIN=$dir/package/bin/opencode" >> "$GITHUB_ENV"

# Fork code is untrusted. Never expose repository secrets to this job.
# Remote-provider cases skip themselves when credentials are absent.
# The e2e setup file (src/__tests__/helpers/isolate-e2e-env.ts) gives each
Expand Down Expand Up @@ -162,6 +181,19 @@ jobs:
- name: Build
run: npm run build

# A pinned tarball checked against its sha256, never a piped install
# script: this job holds a repository token.
- name: Install OpenCode 2
run: |
dir="$RUNNER_TEMP/opencode2"
mkdir -p "$dir"
curl -fsSL -o "$dir/cli.tgz" "https://registry.npmjs.org/@opencode/cli-linux-x64/-/cli-linux-x64-$OPENCODE2_VERSION.tgz"
echo "$OPENCODE2_LINUX_X64_SHA256 $dir/cli.tgz" | sha256sum -c -
tar -xzf "$dir/cli.tgz" -C "$dir"
"$dir/package/bin/opencode" --version
echo "$dir/package/bin" >> "$GITHUB_PATH"
echo "TEAMAI_OPENCODE_V2_BIN=$dir/package/bin/opencode" >> "$GITHUB_ENV"

# Each test file runs in a sandbox HOME with its own git identity
# (src/__tests__/helpers/isolate-e2e-env.ts); the remote cases clone the
# fixture repo into it themselves (prepareRemoteTeamHome in e2e.test.ts).
Expand Down
9 changes: 6 additions & 3 deletions docs/designs/session-user-repo-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,12 @@ regression suite):
separate codex response_items and read back as more messages than went in.
- **Empty-content messages** are dropped by several adapters' writers/readers (semantic
choice per adapter; unifying would change existing behavior).
- **sessionId is platform-native.** v4 (claude-code), v7 (codex), 32-hex (codebuddy-ide)
each regenerate on write; a cross-platform chain therefore accumulates one archive per
platform. The session id you resume with is always the target platform's.
- **Every write gets a new sessionId.** The target id is derived from the target platform,
the target workspace and the source id, and the source id is never reused. A
cross-platform chain therefore accumulates one archive per platform, and the session id
you resume with is always the target's. Copies made by an older TeamAI kept a source UUID,
or (claude-code, codebuddy) left the workspace out of the hash. A re-migration also looks
for those ids in the target workspace, and reports a copy it finds there as reused.
- **claude-code flattenDag** drops sidechain branches and can promote orphan nodes early;
fork branches interleave into the main timeline.
- **cursor has no stored title** — the title is derived from the first real user text;
Expand Down
21 changes: 21 additions & 0 deletions docs/dev/agent-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Agent sessions

Maintainer notes on `src/session-flow/`, the code behind `teamai session`. The commands are described in the [Dashboard](../guide/dashboard.md) 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.

## Rules that span files

- **Every target id is derived.** An adapter's `writeSession` gets the id it writes under from `deriveTargetSessionId` in `ids.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 calls `deriveTargetSessionId` before 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 from `deriveOpencodeMessageIds`.
- **One workspace has one spelling.** `deriveTargetSessionId` hashes the native real path of the workspace, so `/tmp/x` and `/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 an `md5:<hash>` placeholder. Don't hash a workspace through `resolveRealCwd` in `fs.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.copy` in `agent-session.ts` for `session migrate`, and `session resume`) writes through `writeCopyOnce` in `agent-session.ts`, which derives the id and looks the target up through the adapter's `existingSessionId`: 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 (`legacyTargetSessionIds` in `ids.ts`) in the target workspace only. A target that exists but does not parse still exists. When one is found, the result is `reused` and 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. `writeJsonl` writes 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>.lock` keyed 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 with `copy_busy`.
- **Tool CLIs are found and started the Windows way.** An adapter that runs a tool's CLI finds it with `probeBinary` and runs it through `cross-spawn` (`execCommand` in `utils/exec.ts`), as the Codex and OpenCode adapters do: a hand-built `PATH` join and `node:child_process` miss the npm `.cmd` shim on Windows. An adapter whose CLI cannot run, or cannot tell whether a session exists, throws `ToolUnavailableError` (`adapters/base.ts`), which `AgentSession` and `session migrate --json` report as `tool_unavailable`, never as a missing session. The sqlite3 CLI comes from `findSqlite3` in `sqlite.ts`. Cursor and WorkBuddy list a copy only once sqlite3 registers it, so on Windows `noAdapter` in `agent-session.ts` refuses them as targets with `no_adapter` and `reason: '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 `filePath` it listed (`SessionReadRef` in `adapters/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 through `AgentSession.find`, which redacts unless the caller passes `scrub: false`; `session push` and `session migrate --push` pass their `--scrub` choice.
- **List through `AgentSession.list`.** A caller that wants a member's sessions (`session migrate`, `session push`, a scorer, the dashboard) calls `AgentSession.list({ platforms?, cwd?, updatedSince? })` in `agent-session.ts`, never an adapter's `listConversations`. Each listed session is a ref that `find` reads as is. A session whose first lines do not parse is still listed, under a placeholder title, so `find` reports it `unreadable`, not `not_found`. A tool that cannot be listed is one failure value (`no_adapter`, `tool_unavailable`, or `list_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. Without `cwd`, OpenCode is `list_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 and Codex keep their sessions under the root the member recorded with `teamai init` (`toolRoots`), resolved by `resolveMemberToolRoots` and `resolveToolRootDir`, never from `CLAUDE_CONFIG_DIR` or `CODEX_HOME` at run time. `AgentSession` and the `session` commands that build an adapter themselves (`resume`, `rollback`, `platforms`) resolve the roots with `recordedToolRoots` in `agent-session.ts` for the workspace at hand and pass them through the adapter registry (`adapters/index.ts`) to `getClaudeCodeProjectsDir` and `getCodexSessionsDir` in `fs.ts`. A run that reads or copies many sessions passes one `toolRootsLookup()`, so each workspace is resolved once. With nothing recorded they stay `~/.claude/projects` and `~/.codex/sessions`. 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, the step starts a fresh session. The workflow engine never copies a session. A step's report carries the tool and that tool's native session id.
- **Session index lock.** Every write to a team repo archive (`saveSession`: dedup lookup, name choice, file writes, `_index.json` upsert) and every index rebuild (`rebuildIndex`, `rebuildAllIndexes`) runs inside `withSessionIndexLock(repoRoot, fn)`. The async command callers take it; `SyncManager` stays synchronous and never takes it, so it cannot re-enter. The lock file is `~/.teamai/locks/sessions-<hash>.lock`, keyed by the real path of the team repo's `sessions/`. It stays out of the team repo because there the archive commit could stage it and `pull --all`'s directory renames could move it while held. It covers one machine only; two machines writing `_index.json` is #1133.

## Tests

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.
2 changes: 1 addition & 1 deletion docs/dev/ci-e2e-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The `e2e` job runs on:

Several PRs or pushes operating on the same fixture repo at once would corrupt its state, so the job runs under `concurrency: e2e-fixture-repo`: runs queue up and a run in progress is not cancelled, so it finishes and leaves a clean state behind.

PRs from forks run `fork-e2e` (no token; the remote cases skip). A test must never rebuild `dist/` or reinstall the OpenCode binary: workers share both.
PRs from forks run `fork-e2e` (no token; the remote cases skip). A test must never rebuild `dist/` or reinstall the OpenCode binary: workers share both. Both jobs also install a pinned OpenCode 2 from npm (`@opencode/cli`) and point `TEAMAI_OPENCODE_V2_BIN` at it; to bump it, change `OPENCODE2_VERSION` and `OPENCODE2_LINUX_X64_SHA256` together in the `env` block at the top of `.github/workflows/ci.yml`.

---

Expand Down
6 changes: 5 additions & 1 deletion docs/guide/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The local copy goes to `~/.teamai/session-logs/<year-month>.md`. `--push` sends

`teamai session` moves a full transcript between tools, or archives it in the team repo. `session save` remains the scrubbed summary.

Platforms: `claude-code`, `codex`, `codebuddy`, `codebuddy-ide`, `cursor`, `workbuddy`. `teamai session platforms` lists what is installed here.
Platforms: `claude-code`, `codex`, `codebuddy`, `codebuddy-ide`, `cursor`, `workbuddy`, and `opencode` (through the `opencode` CLI, version 2 or later; an OpenCode target workspace must exist). `teamai session platforms` lists what is installed here. On Windows, `cursor` and `workbuddy` cannot be a migrate or resume target: they list a session only once the `sqlite3` CLI registers it, so TeamAI refuses instead of writing a copy they never show.

```bash
teamai session migrate <sessionId> -s codebuddy-ide -t claude-code
Expand All @@ -61,4 +61,8 @@ teamai session search <query>
teamai session resume <name> --platform claude-code
```

A migration never overwrites. If the target workspace already has a copy of the session, from this TeamAI or an older one, `migrate` prints `already migrated as <id>` and writes nothing, so turns you added to the copy stay. To refresh a copy, delete it with `session rollback <id> --platform <tool> --cwd <workspace>` and migrate again. `session resume` follows the same rule: a session already restored into that workspace prints `already restored as <id>` and is left as it is.

For a script, `migrate <sessionId> -s <tool> -t <tool> --json` prints one line on stdout, the same `{ "data": ... }` or `{ "error": { "code", "message" } }` shape as `teamai workflow`; progress goes to stderr. `data` has the target `sessionId`, `cwd`, `reused`, and the `fidelity` report. `error.code` names the failure, such as `no_adapter` (unknown tool), `tool_unavailable` (the source or target tool is not installed, or its CLI cannot run), `not_found`, or `unreadable`, and the exit code is 1. `--json` never prompts: a missing id, `-s`, or `-t` fails with `prompt_refused`, and `--push` needs `-y` or `--scrub`.

`--dry-run` previews. `--scrub` redacts before an archive; without it, an interactive run asks and a script needs `-y`. `--all` confirms above 10 sessions (push: 5). Archives go to `sessions/repos/<repo>/<author>/` under the session's own git remote, or `_unattributed`.
6 changes: 5 additions & 1 deletion docs/guide/zh-CN/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ teamai session save --push --force

`teamai session` 在工具之间迁移完整会话,或归档到团队仓库。`session save` 仍是脱敏摘要。

平台:`claude-code`、`codex`、`codebuddy`、`codebuddy-ide`、`cursor`、`workbuddy`。`teamai session platforms` 查看本机已安装的。
平台:`claude-code`、`codex`、`codebuddy`、`codebuddy-ide`、`cursor`、`workbuddy` 和 `opencode`(通过 2.0 及以上版本的 `opencode` CLI;作为目标时,目标工作区必须已存在)。`teamai session platforms` 查看本机已安装的。在 Windows 上,`cursor` 和 `workbuddy` 不能作为 migrate 或 resume 的目标:它们要等 `sqlite3` CLI 登记后才会列出会话,所以 TeamAI 直接拒绝,而不是写一份它们永远不显示的副本。

```bash
teamai session migrate <sessionId> -s codebuddy-ide -t claude-code
Expand All @@ -61,4 +61,8 @@ teamai session search <query>
teamai session resume <name> --platform claude-code
```

迁移从不覆盖。目标工作区里已有这个会话的副本时(不论是当前还是旧版 TeamAI 写的),`migrate` 输出 `already migrated as <id>`,不写任何东西,你在副本里新加的对话都会保留。要刷新副本,先用 `session rollback <id> --platform <工具> --cwd <工作区>` 删掉它,再迁移一次。`session resume` 遵循同一规则:已恢复到该工作区的会话会输出 `already restored as <id>`,保持原样。

脚本可用 `migrate <sessionId> -s <工具> -t <工具> --json`:stdout 只输出一行,格式与 `teamai workflow` 相同,即 `{ "data": ... }` 或 `{ "error": { "code", "message" } }`;进度信息走 stderr。`data` 包含目标 `sessionId`、`cwd`、`reused` 和 `fidelity` 报告。`error.code` 标明失败原因,如 `no_adapter`(未知工具)、`tool_unavailable`(源工具或目标工具未安装,或其 CLI 无法运行)、`not_found`、`unreadable`,退出码为 1。`--json` 从不询问:缺少会话 id、`-s` 或 `-t` 时以 `prompt_refused` 失败,`--push` 需要 `-y` 或 `--scrub`。

`--dry-run` 只预览。`--scrub` 先脱敏再归档;不加时,交互运行会询问,脚本需要 `-y`。`--all` 超过 10 条要确认(push 为 5 条)。归档在 `sessions/repos/<repo>/<author>/`,按会话自己的 git remote;否则归入 `_unattributed`。
1 change: 1 addition & 0 deletions skill-data/core/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ Generated: do not edit by hand. Regenerate with
- `--all` — Migrate every session from source (not just the 5 most recent)
- `--limit <n>` — Max sessions to migrate (only caps --all; ignored otherwise)
- `-y, --yes` — Skip confirmation prompts, including archiving unredacted transcripts
- `--json` — Print one {data}|{error} JSON envelope on stdout; never prompts (needs a session id, -s and -t)
- `teamai session push` — Push local sessions to the team repo
- `--source <platform>` — Source platform to read sessions from
- `--repo-root <path>` — Team repo root (defaults to cwd)
Expand Down
Loading
Loading