Repository navigation
Switch chats between Chat UI and a terminal without forking - #31595
brennanb2025 wants to merge 157 commits into
Conversation
…uses to start while a terminal writes its conversation Each chat link now carries its role (head or earlier) and key; Session History names the chat continuing a conversation first, duplicate adoption checks only head links in both the pre-commit check and the store transaction, and terminal resumes stay refused exactly as before. The terminal-writer check now also runs inside acquireOwner before the spawn, refusing with the typed pre-spawn reason conversationOpenInTerminal.
…l reports it An in-memory registry of managed terminal starts per conversation, registered in the same step as the resume admission (terminal.send with a resume command, createTerminal with a command), bound to the PTY execution that runs it, and ended only when that terminal's hooks report the conversation or the execution is gone. A chat's agent start refuses while an entry may hold its conversation.
… an open chat continues Resume recipes built from a pane's saved facts (cold restore, deferred and late reattach, sleeping wake, host mobile materialization) carry resumeOrigin 'saved'. The execution host decides before any command is issued: while an open chat continues the conversation, or a chat may be writing it, the pane starts its plain shell with the started-fresh notice and its saved claim is forgotten; with unreadable records only a saved restore is refused and its record kept. An admitted resume registers its managed start in the same step, so the chat's agent start refuses until the terminal reports it or exits. Clearing the last saved record now reaches disk.
…s terminal in place One enablement source (Codex) drives same-id terminal resume, the per-agent switch capability and History. agentSession.switchToTerminal admits only an idle chat, registers the switch claim in the chat's ordered step, stops the agent with proven exit while the tab stays, and returns the resume recipe. The claim refuses a chat start until the switch terminal's own spawn binds it; the bound proof ends only when the agent that terminal ran is proven exited and the pane is shell alone.
…nal in place Switch to CLI (tab menu, hover card, chat top-right icon, History) asks the chat's host to stop the idle chat and opens the same-id resume in the chat's slot, then closes the chat tab. Switch to native chat opens the chat in the terminal's slot, or reveals the chat that already holds the conversation, and closes the terminal pane. Hosts without the agent's switch capability keep the new-tab entries, worded Resume in New Chat / Resume in New Terminal; the view toggle reads Show as chat / Show as terminal.
Covers the host op stopping an idle chat while a racing send is refused, the switch claim lifetime (unaccepted lapse, accepted spawn past the window, bind, identity-only replay, second switch refused), the switch spawn claiming before its first await, and the renderer refusing hosts that do not advertise the switch. Updates label and owner-reveal expectations for the new wording.
…ches back to it Switching a terminal's conversation into the chat that already holds it now starts that chat's agent on its host (agentSession.switchToChat), so the provider open catches up on the terminal's turns before the user sends. The host uses the ordinary operation start and answers success only when that same child is ready; an ended child's typed failure, such as the conversation still open elsewhere, is the answer. The renderer sends it only to hosts that advertise the switch, retires the terminal pane only after the start succeeded and the chat's tab is really active, and keeps the terminal on any refusal.
…k it A switch claim is now owned by the terminal spawn attempt that accepted it (its host session id, else its pane). Only one attempt may hold a conversation: a different attempt is refused while one is accepted or a switched terminal may still hold it, and a retry of the same attempt joins it. Settlement follows the spawn's real result, including a reattach; a spawn that ended without a reply keeps its claim until the host's own session list proves that session absent, so a lost reply can no longer let a chat start beside the terminal. Also from review: a paired Orca server that switches the agent in place now offers the switch back from its terminals, and the paired create names its new tab so the switch back is offered before the first prompt; the presentation seed binds to the switched pane's leaf and PTY and ends when that PTY is replaced or the pane goes, so a split's plain shell is never switched or closed; and an unreadable Windows process identity reads as unverifiable, with only a fresh process table's absence or a different start time proving exit.
…roves the start over The chat to terminal switch now carries one generation and the owner that creates the terminal. The daemon session and the local process are bound to that generation before any later step can fail, and only their own end, or a proved agent exit with that exact shell alone, releases the conversation. The accepted start is saved on the chat it left, so a restart still knows it may exist; a lost owner never speaks for it. The caller-side settlement and the renderer's guessed tab seed are gone. A switched terminal's conversation is presented from the host's launch identity, stamped with the exact PTY and incarnation, and the switch back retires only the pane that still holds that execution.
… just its shell, are proved over - Every release needs each recorded agent proved exited; the owner's root exit alone no longer clears it, and the daemon reports startup settled only once its write gate flushes. - Descriptor writes are compare-and-set on generation, so a retired switch's late evidence never replaces or resurrects one. - After full owner loss, a host reboot since acceptance ends a start with no recorded identity; evidence a save missed is retried. - An owner that cannot answer fails the routed read and holds; only every reachable owner answering without it counts as loss. - The local owner binds the process before any callback or cancellation cleanup can throw. - Snapshots carry the launch stamp, the paired projection drops a stamp naming a replaced incarnation, and the switch back retires a pane only while its host still reports the incarnation it started with.
…reattached switch terminal The saved switch keeps the host boot as its id where the OS has one plus the boot time derived from uptime, so Windows can also prove a later boot (by more than 60 seconds). A reattached switch terminal regains its exact-execution stamp from the saved switch, without persisting it.
…tirement, owner-recovered restamp - Only a changed OS boot id proves a later boot; Windows (no id) never releases on a wall clock. - A paired switch back closes its terminal through the host's terminal.close under the incarnation it started with; the host refuses a replacement under the same handle. - After a restart, a reattached switch terminal asks the original owner for its binding, saves it, and restamps only while that exact execution is still the pane's. - Saved evidence is a copy, so the frozen journal row never freezes the live claim's processes. - A failed local commit callback stops that exact process; the switch ends only on its exit.
… on every platform A saved switch records the host boot id (Linux boot_id, macOS boot session, or the Windows BootId through one read-only reg.exe query) and the uptime at acceptance. After owner loss, a different boot id or an uptime lower than recorded proves a reboot; wall-clock time never does, and macOS uptime, which Node derives from the wall clock, is not used.
[High impact] The PR changes conversation switching across shared chat, terminal, and process-lifecycle paths. The PR is not ready to merge because four earlier blocking findings remain unresolved. Findings
SummaryThis PR lets users move the same conversation between Chat UI and a terminal without making a copy.
The PR description explicitly says not to merge.
|
| const result = await this.client.request<CreateOrAttachResult>( | ||
| 'createOrAttach', | ||
| { | ||
| sessionId: dispatchedSessionId, | ||
| cols: context.effectiveCols, | ||
| rows: context.effectiveRows, | ||
| attachOnly: true | ||
| }, | ||
| 5000 | ||
| ) |
There was a problem hiding this comment.
Failed launches block recovery
When a switch uses an older daemon’s ordinary creation path, a create rejected before any child starts leaves its completion promise unresolved. The later attachOnly request throws SessionNotFoundError, but this branch never settles or removes the retained operation. Later native starts keep waiting, and Restart Terminal Daemon fails at settleSwitchWorkForReplacement.
Settle explicit creation failures through the original request’s outcome. Do not treat an uncertain missing session as proof of exit.
| const prepared = await prepareLegacyTranscriptImport({ | ||
| agent: source.agent, | ||
| piAfterEntry, | ||
| sessionId: source.sessionId, | ||
| options: { filePath: source.filePath } | ||
| }) |
There was a problem hiding this comment.
This call passes only filePath, so a paginated Codex conversation with history_base cannot return to chat. prepareLegacyTranscriptImport follows that base through readCodexLocalHistory, but the resolver throws without account roots. Every Codex return calls this helper, even after the source shell has stopped.
Forward the source’s account storage so the importer can read the earlier history.
| @@ -66,14 +82,14 @@ export class DaemonPtySpawnPreparations { | |||
| this.finish(sessionId, preparation) | |||
| }, 720_000) | |||
There was a problem hiding this comment.
If a prepare reply is lost, the daemon keeps an entry in retained that nobody can release. The client only records recovery after receiving the anchor. The daemon's expiry callback converts the entry into a completed record but does not delete it. Since hasOwedOutcomes() counts every retained record, idle shutdown stays blocked even after all terminals and clients leave.
This leaves an empty daemon running indefinitely and adds retained records on each repeat. Keep enough request identity to release an unanswered prepare, or discard abandoned preparations that never started a child.
| // Shutdown already accepted must finish even when the observing request expires. | ||
| try { | ||
| await host.trackSwitchPhysical(sessionId, close.stop()) |
There was a problem hiding this comment.
Cross-workspace returns close the terminal
When the conversation already has a native chat in another workspace, this path still stops the source terminal and later calls close.retire(). switchRetainedOwner can select that other workspace, but neither close checks the destination workspace. The source terminal disappears instead of staying open as the PR describes.
Compare the source and destination workspaces before stopping the source, and skip its retirement for this case.
| (prior.direction === direction && | ||
| (!prior.settled || | ||
| (prior.result?.status === 'completed' && | ||
| prior.deadline.remaining() > 0 && | ||
| !prior.deadline.signal.aborted && | ||
| (!prior.workCurrent || prior.workCurrent())))) |
There was a problem hiding this comment.
Switching reuses a closed terminal
After a user closes a switched terminal, a fresh “Show as terminal” click from Session History can return the earlier success without opening anything. This branch reuses completed same-direction commands for up to 12 minutes. Its workCurrent check watches chat sends and the switch generation, but not whether the destination terminal still exists.
Reuse completed results only for the original invocationId, or check and reveal the surviving destination before sharing them.
What changes
Switch between Chat UI and a terminal while continuing the same agent conversation. The destination replaces the source in the same tab position. Closing one source pane preserves its split siblings. Session History can open the existing conversation and select its workspace with one click.
The switch appears in the pane header, tab hover card, tab menu and Session History only while the Chat UI setting is on. Claude, Codex, Grok, OpenCode, OpenCode 2 and Pi are enabled when their execution host supports the switch. omp remains disabled. The display toggle reads Show as terminal / Show as chat.
When the agent is replying, Orca asks Stop and switch. Cancel keeps the reply and current surface. Confirm uses ordinary Stop, proves the source agent exited, imports terminal conversation changes where needed, acquires the destination through ordinary admission, then publishes and selects it. A suspended agent also requires confirmed physical exit. User-entered commands and configured agent commands remain unchanged.
Returning to chat makes the saved transcript readable immediately after import and publication. Provider startup is observed separately; the user does not wait for it to finish to read the conversation. The source presentation retires through its ordinary close owner. The next send uses ordinary agent startup and writer admission. The switch does not replay the interrupted prompt automatically.
Failures name the agent and give an action without provider or transport details. Failed destination creation leaves a readable recovery path and no empty chat tab. The client wait covers the host deadline and refreshes actual state if it cannot confirm the result. Invocation identity and the existing resource owners prevent a stale effect from claiming a newer destination or stopping unrelated work.
Compatibility and differences
Intended: local and paired execution use the same host-owned operation, ordinary Stop, process-exit evidence and destination owners. Optional metadata on existing messages carries captured placement to the actual layout writer; it is not saved in session layouts. Independently updated peers keep their existing compatible path. A peer without confirmed-exit support cannot be treated as proof that the source stopped; its fallback and actionable failure remain visible. There is no minimum server version.
Intended: one-pane switching follows ordinary pane-close semantics; split siblings remain intact. A destination already used or moved keeps its current placement. Navigation made after the click takes precedence over delayed selection. The common pattern supplies ordinary lifecycle and presentation owners; the explicit transfer between a native conversation and a user shell requires the additional captured source identity and host authority.
Temporary — pin state: a switched tab can return unpinned. Tracked in STA-12006.
Verification and current state
The correction is still in review and is not ready. The latest placement candidate has independent source agreement. Focused tests exercise actual snapshot, frame, insertion and selection writers, same-slot restoration, coalesced client tabs, split siblings, cross-workspace History, newer navigation, optional-message compatibility and exclusion of transient metadata from saved layouts. The current correction also requires production-trigger N/10N operation-count contracts.
Fresh CI and the remaining targeted Mac and Windows Claude/Codex lifecycle runs are pending on the corrected published head. Historical failures and qualified evidence remain in the single review status comment; they are not a current-head pass. Final screenshots, exact-head CI, runtime results, corpus timings and reference comparison will be recorded there after completion.
The ownership prerequisite tracked in #30930 is merged; this PR targets main. Tracked in the chat-terminal-flip lane.
Brennan requested an ad hoc build from the finished branch to try it. Do not merge this PR. The separate proposal to keep the reply going across the switch is on hold.
Author: @BrennanKB5