# feat\(server\): hand a thread off to a linked environment · PR \#16751 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/16751)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

State: open

Source repository: pingdotgg/t3code

Source branch: t3code/peer/handoff

Target branch: t3code/peer/handoff-git

Head commit: be89710151b212a24dc62721e0b3c46e67441475

[Discussion and reviews](https://git.cafe/pingdotgg/t3code/pull/16751)

[Commits](https://git.cafe/pingdotgg/t3code/pull/16751/commits?format=markdown)

[Files changed](https://git.cafe/pingdotgg/t3code/pull/16751/diffs?format=markdown)

## Description

---

Part of cross-environment orchestration. A thread can now move to a linked environment with its conversation (#16731) and its git work (#16734), with exactly one copy live.

**How a move goes**
1. **Departing.** `ThreadHandoff` marks the thread `departing` with the internal `thread.handoff.update` command. The orchestrator refuses `message.dispatch` for it, and `startNextQueuedRun` starts nothing on it.
2. **Pack.** It packs the branch and working tree with `HandoffGit`.
3. **Upload.** It uploads the bundle through the other side's signed attachment route, using a forwarded `t3_attachment_prepare_upload` plus a POST.
4. **Import.** It calls `t3_thread_import` there. That tool now takes the bundle, applies it in a new worktree, and only then creates the thread with the history and a continuation prompt. If the import itself fails, the worktree is removed.
5. **Departed.** The thread here is `departed` and read-only, with the other thread's id. A send is refused with `OrchestratorThreadMovedError` ("This thread moved to Box. Continue it there.").
6. **Failure.** Any failure marks the move `failed` with the reason. That state takes turns again, and nothing is left on the other side.

**The agent moving its own thread** ("I need to wrap here, move this to my VPS")
- `t3_thread_handoff` with no `threadId` moves the calling thread. The link and the target project are checked right away, so a bad target fails while the agent can still say so.
- The move waits as `pending` until the agent's turn ends. Then the thread there starts with the agent's `continuationPrompt`, so it picks up where it said it would.
- Any message to the thread after the asking turn started cancels the move, because new instructions win. That includes a message steered into that same turn, and one from an agent the user works through.
- A startup sweep finishes moves that a restart cut short. Import ids derive from the handoff, so a retried move finds the thread already there.

**Surfaces**
- MCP: `t3_thread_handoff`.
- WS RPCs: `threadHandoff.options` (where the thread can go, with reasons when it can't), `threadHandoff.start` and `threadHandoff.cancel`. The UI comes in the next PR.
- Docs: "Continue a thread on another machine" in `docs/user/remote-access.md`, and the one-live-copy invariant in `docs/internals/remote.md`.

**Verification**
- `peer/handoff/ThreadHandoff.test.ts` runs two real orchestrators. The box sits behind its real `/mcp`, OAuth and upload route; the laptop is linked to it. Each has a real clone of the same origin. Three tests:
  - **The move.** The laptop thread, with an unpushed commit, an uncommitted edit and a new file, moves. `options` lists the box with its matching project. On the box, the thread has the history, its first provider turn carries the laptop's question plus the continuation, and its worktree is on `fix/login` with the commit, the edit and the file. On the laptop, the thread is `departed` with the box's thread id, and a new message is refused with "moved to Box".
  - **A diverged branch.** When the box has its own commit on the branch, the move fails with the git reason. The laptop thread is `failed` and takes a new turn. No thread exists on the box.
  - **Self-handoff.** Turns are held open with a queue. Asked mid-turn, the move is `pending`. An agent message steered into that same turn cancels it once the turn ends. Asked again with nothing after, a settle while the turn still runs leaves it `pending`; when the turn ends it departs, and the box's first turn is the agent's continuation prompt.
- Mutation-checked: each of these fails its test when removed:
  - refusing turns on a departed thread;
  - cancel-by-user;
  - waiting for the turn to end;
  - releasing on failure;
  - sending the git work;
  - the continuation prompt.
- Also ran all of `mcp`, `peer` and `cli`, plus `RpcAuthorization`, `ThreadImportService`, `ThreadManagementService`, `ProjectionStore`, `Orchestrator`, the V1 cutover integration and shared `t3McpToolPresentation` (50 files, 485 tests), and contracts `rpc`, `orchestrationV2` and `orchestratorMcp` (45 tests). All passed. Server, contracts, client-runtime, web and mobile typecheck clean, and lint is clean on the changed lines.

Opus 5.5 via Claude Code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- devin-review-badge-begin -->

---

<a href="https://app.devin.ai/review/pingdotgg/t3code/pull/16751" target="_blank"><picture><source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-devin-review-dark.svg?v=4"><img src="https://static.devin.ai/assets/gh-devin-review-light.svg?v=4" alt="Devin Review"></picture></a>
<!-- devin-review-badge-end -->
