Skip to content

feat(workspace): send an agent to another open window - #1484

Open
Juliusolsson05 wants to merge 16 commits into
mainfrom
feat/send-lane-to-window
Open

Juliusolsson05 wants to merge 16 commits into
mainfrom
feat/send-lane-to-window

Conversation

@Juliusolsson05

@Juliusolsson05 Juliusolsson05 commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner

Problem

With several Agent Code windows open (one per monitor, say), you can't move a running agent from one window to another. The only cross-window handover today happens when a window closes. Root Agent Code Management agents (#906) can rearrange lanes inside a window but can't move work between windows either.

What this adds

Send to Window… (palette, plus the Sessions right-click menu) moves the focused lane's agent or terminal to another open window you pick:

  • Move, not copy. Nothing restarts. The process, conversation, draft, pin, title and MCP choices go as they are, and session IDs don't change.
  • Children move with it. Linked agents and orchestration workers go too, transitively, using the same ownership edge the index nests by.
  • Where it lands. It joins a target project that already holds the same folder, otherwise a new project tab, and it is shown in the target's focused lane.
  • The lane it leaves is cleared, like Clear Lane (⌥⌫).
  • Picker labels. Each window is shown as Window N · <project titles> · <n> sessions, because every window has the same OS title. The picker starts on the window you used last.
  • Root management tool. agents.moveToWindow is published as ac_agents_move_to_window to root-management agents and the external operator. It returns once the move has committed.

How it works (ordering is the safety property)

source A                      main                          target B
send ─────────────► window:send-sessions
                     validate, move routing → B, start a 20 s deadline
                     ─────► workspace:receive-sessions ──► merge, seed runtimes,
                                                          select into focused lane,
                                                          autosave (durable)
                     ◄──── window:session-move-complete ◄─┘
◄── workspace:sessions-moved-out
drop rows (no kill)
  • Routing moves first (reusing the router's transfer), so events emitted mid-handoff reach the window about to show them. Parked agents are claimed for the target, so the source can't wake them mid-move.
  • The target confirms only from the autosave success path. This is the same discipline the window-close adoption uses, because the confirmation makes the source drop its rows.
  • Refusal or deadline: main rolls back only the exact leases it created (compared by identity), and the source keeps the agent. On a deadline the target is also told to give back what it merged (rows only).
  • Duplicates over loss: between the target's save and the source's save, both slices list the agent. A crash there leaves a duplicate row, never a lost agent. The opposite order would leave a window where no slice lists a live agent.
  • Fresh tab ids: a new target project gets a fresh tab id. A shared id would make a later window-close adoption refuse the whole workspace on collision.

Design decisions

  • Your four rulings: move rather than copy; same-folder project, else a new tab; children move along; the source lane is cleared.
  • UNCONFIRMED defaults, please confirm:
    • the target window is not raised;
    • the command title is Send to Window…;
    • terminals can be moved, but extension views can't.
  • No up-front "no other window" check. The renderer can't know the window count synchronously, so the picker says "No other Agent Code window is open" and names New Window.
  • Picker labels come from each window's last saved workspace (at most one autosave old). Asking every live renderer would cost an IPC round trip per window.

Refactors and fixes along the way

Tests

  • main/window/sessionMove.test.ts:
    • start authority (same window, unready target, foreign owner, already moving, malformed);
    • confirm authority (wrong window, reloaded renderer);
    • refusal and deadline rollback;
    • "only its own leases";
    • a late confirm is ignored;
    • a closed source is released, not transferred back.
    • I checked by mutation that two of these guards are each caught by a test.
  • workspace/sessionMove.test.ts: transitive and cycle-safe subtree; packet round trip; same-folder join versus a fresh tab id; collision refusal; source drop; target cancel restoring the original state.
  • hook/ipc/useSessionMoves.renderer.test.tsx:
    • the target queues its confirmation for autosave, refuses collisions and mismatched packets, and holds offers until bootstrap;
    • a cancel arriving during the snapshot wait stops the merge (this was a real bug in my first version; the test fails without the fix);
    • the source drops rows only on main's commit and never kills.
  • storage/workspaceFile.test.ts: picker labels.
  • Updated the pinned lists for the catalog, context menu and command target.

Verification

  • npx tsc -b: clean.
  • npm run check:keybindings and npm run test:contract: OK.
  • Full npm test: 994 files, 8259 passed, 1 skipped (Node 24, local). The two follow-up fixes after that run (cancel during merge, picker default) were verified with their own suites plus tsc -b.

Known limitations

  • A window closing mid-move (source or target): the window-close handover may hit an id collision and refuse. The slice then comes back next launch as its own window. A duplicate, not a loss.
  • A child moved without its parent keeps its relationship ids. It shows as a top-level row, and the parent's orchestration tools no longer reach it from the old window.
  • Not yet handled: a session replacement or provider switch while a move is pending.
  • Manual app QA is still owed. I don't launch the app, so the two-window flow has not been exercised end to end on screen.

Review dispositions (one round: Codex on correctness, Claude on integration)

Finding Verdict Disposition
Codex 1 / Claude 1: an in-flight save could confirm a move (or an adoption) whose rows it did not contain, so the source dropped rows that were not durable anywhere valid (the adoption half predates this PR) fixed in 856eb5e0: confirmations are captured at serialization; a regression test fails on the old code
Codex 2: waking a parked agent in the target rotated the lease, so a deadline rollback skipped it while the target dropped its rows valid fixed in d883ef63: rollback also takes back a newer claim by the target window; any other window's claim is still left alone
Codex 3: after a rollback the source kept stale readiness and conditions that had been delivered only to the target valid fixed in d883ef63: rollback marks a router gap, so the source's existing routing-gap repair resyncs
Codex 4: the source retiring its Browser Pocket guest unregistered the target's new guest under the same pocket id valid fixed in 2d13863d: only the renderer holding the current registration may unregister it
Claude 2: agents.moveToWindow accepted extension views valid fixed in 45d00581: the rule now lives in the packet builder and is shared by every entry point
Claude 3: moving a child alone silently cuts it off from its parent's orchestration tools valid (UX) 45d00581: the picker and the capability description warn. Redirecting the move to the root owner is a product call, left UNCONFIRMED
Claude 4: the picker offered windows that were closing or not ready valid, low fixed in d883ef63
Mutation survivors: failed offer delivery, autosave confirm drain, moving map never cleared, outcome split, append ordering valid gaps tests added (failed delivery, two moves in a row, in-flight save, not_started/unknown, clock behind the target's rows)
Remaining survivors (pin dedupe, self-link guard, packet version, fresh-id retry, empty drafts, unmount settle, H6/H8/H11/H17/H19) nits declined: each guard has a WHY comment, and none has a user-visible failure without another bug first
Suspicion: session:input and deliver-prompt do not check display-window ownership pre-existing, unproven not taken up here; no failure sequence was shown for this branch
Suspicion: replacement or provider switch during a pending move, and leftover history refs after a move-out unproven recorded as residuals in the plan

Fixes #1481

🤖 Generated with Claude Code

Juliusolsson05 and others added 16 commits October 13, 2026 18:48
Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seeding a window's view of sessions that are already alive and routed to it
(snapshot before paint, a runtime for every session, condition re-seed after
the seed, history only for live backends) was inline in useWorkspaceAdoption.
Moving agents between live windows needs exactly the same steps, and each one
is a regression fix (#895, #1083). One shared copy keeps them in force for both
paths instead of a second copy that would have to rediscover them.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…them

Adds the protocol for moving an agent and its descendants from one live window
to another. Routing moves to the target first. The target merges the rows, shows
the agent in its focused lane, and confirms only after a durable save. Only then
does main tell the source to drop its rows, which it does without killing
anything. A refused or expired move hands routing back (only the exact claims it
made), and the target removes whatever it merged.

The ordering prefers a transient duplicate row over a lost agent: between the
target's save and the source's save both slices list the session, while the
reverse order would have a gap where no slice lists a live agent.

A new project tab in the target gets a fresh id, because tab ids are
window-local and a shared id would make a later window-close adoption refuse
the whole workspace.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A picker of the other open windows, each labelled with its project titles and
session count, because every Agent Code window has the same OS title. It
captures the target session when it opens, so from the right-click menu it
moves the clicked row even if focus changes while the user chooses.

The row drop on both sides now reuses workspaceWithoutSessions directly. It
already clears lanes and scrubs row metadata, so the move-specific wrapper was
a duplicate.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agents.moveToWindow (ac_agents_move_to_window) waits for the commit, so the
caller's next read already sees the agent in the target window. A failure is
not_started when main refused or rolled back the move. It is unknown only when
the renderer stopped waiting without a verdict, so an operator observes before
retrying.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The confirmation still listed attaching, detaching and burying agents, which
#992 removed. It now names what the grant really allows, including moving
agents between windows.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main: only the target's current renderer may settle a move, and a rollback
undoes only the exact claims it made. Renderer: the target confirms only
through autosave, refuses collisions, and holds offers until bootstrap. The
source drops rows only on main's commit and never kills.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… wait

Main's deadline can fire while the target is still awaiting backend snapshots.
The cancel found nothing registered yet and was dropped, so the merge then
published rows whose routing had already gone back to the source: dead panes
in the target. The cancel is now remembered and the merge stops before
publishing. Also drops an early-verdict buffer that could never be reached,
since main's invoke reply always precedes its verdict events.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the picker is open the focused window is always the sender, so marking
the OS-focused window never highlighted anything useful. Main now reports the
other window the user was in most recently, from the registry's focus order.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The autosave success callback confirmed every move and adoption queued by the
time the write resolved, including ones that landed while that write was in
flight and so were not in it. A move confirmation makes the source window drop
its rows, and an adoption confirmation makes main delete a closed window's
slice, so a crash before the next save could lose the agent entirely. Only ids
queued before the snapshot was serialized are confirmed now. The adoption half
of this bug predates #1481.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iled move

Waking a parked agent in the target's focused lane re-claims it, which rotates
the lease. The rollback's identity check then skipped it while the target
dropped its rows, stranding the agent where no window displayed it. A rollback
now also takes back a newer claim by the target window itself (any other
window's claim is still left alone). After routing returns, it marks a routing
gap so the source's existing repair re-reads readiness, conditions and history
it missed while the target held the claim.

The move picker also leaves out windows that are closing or still loading,
instead of offering them and then failing.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cket

A pocket id moves with its session to another window. The target registers a
new guest under it, and the source then retires its old guest and unregistered
the same id, deleting the target's live registration. Browser tools then
answered no_pocket while the pocket stayed on screen. Only the renderer that
holds the current registration may release it.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
agents.moveToWindow accepted extension views, which the command hides and the
plan excludes. The rule now lives in the packet builder, so every entry point
refuses the same thing. Sending a child agent on its own leaves its parent in
the old window, where the parent's orchestration tools can no longer reach it.
The picker and the capability description now say so before the user commits.

Adds tests for the capability's not_started/unknown outcome split, which is its
contract to operators, and for appending moved rows after the target's own rows
even when the clock is behind.

Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refs #1481

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/renderer/src/features/command-palette/catalog.test.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(workspace): send a lane's agent to another open window

1 participant