docs/internal/orchestrator-sessions-design.md
Status: Design Document Date: May 2026 (last updated: May 2026 — Tier 1–7 migration status, Editor → Window resource-sync fix, prep steps for the next migration batch) Branch:
claude/plan-orchestrator-architecture-6YsJt(design),claude/move-editor-to-window-csOP0(Tier 7 migrations, follow-up to PR #1936) Driving feature: "Orchestrator" multi-agent orchestration UI (PRD external). Core change required: first-classSessionabstraction in the editor.
The "Orchestrator" feature lets a developer run multiple AI coding agents
(aider, claude -p, opencode, …) in parallel, each in its own git
worktree, and switch between them from a single Fresh process. The PRD
calls for two modes:
The user-facing requirement that drives this design is:
Switching sessions from Orchestrator should feel like swapping the entire Fresh state. File explorer, LSP, quick-open scope, ignore rules, buffer set, splits — all of it retargets atomically. Orchestrator itself stays anchored above the swap, with its session list, collision matrix, and agent PTY handles untouched.
Today, Fresh's editor state is built around a single implicit project
root. The cwd is read in dozens of places (getCwd() on the plugin
API, file explorer init, LSP root URI, ignore-matcher construction,
quick-open scoping, plugin path resolution). There is no abstraction
that bundles "everything rooted at one project" so that several can
coexist and one can be made active. A Orchestrator plugin alone cannot
deliver the required UX, because the things that need to retarget
(file explorer, quick-open, LSP set) are core-owned and scoped
implicitly to whatever getCwd() returns.
This document specifies the smallest core abstraction that makes the
required UX possible — a first-class Session — and the plugin-API
surface a Orchestrator plugin needs on top of it. It deliberately does
not specify the Orchestrator plugin itself; that is a follow-up doc once
this design is settled.
This is the top priority. All further Orchestrator feature work (Control Room polish, hotkeys, diff/merge actions, collision radar) is gated on completing the session-as-window architecture below. The interim warm-swap implementation that currently sits on the branch (
§ Implementation status snapshot) is a transitional bridge, not the destination.
Window (next-up work plan)Steps 0a–0k shipped per-window state for the major subsystems
(buffers, splits, file_explorer, lsp, terminals, event_logs,
position_history, bookmarks, grouped_subtrees, composite buffers,
LSP request-tracking maps, async bridges, status_message + prompt).
Tier 1 (buffer_metadata) and most of Tiers 2–6 shipped through
PR #1936 and the follow-up branch claude/move-editor-to-window-csOP0,
including the resource-sync fix described below. The remaining
impl Editor surface is genuinely editor-global by design or
requires structural prep (Tier 7) before another batch can land.
Tier 1 — biggest leverage, do first. ✅ Shipped (Step 0l).
buffer_metadata: HashMap<BufferId, BufferMetadata> →
Window. Shipped as Step 0l. Tracks Window.buffers
(already per-window). Every "what's the language / file_uri /
lsp_opened_with set of this buffer" lookup that used to route
through Editor.buffer_metadata now goes through
self.active_window().buffer_metadata (or
_mut()); cross-window addressing uses
self.windows.get(&id).buffer_metadata. Field migration was
mechanical (~130 call sites bulk-rewritten); ~10 borrow-
conflict sites were resolved with inline __win = self.windows .get_mut(&self.active_window) extractions that split-borrow
buffer_metadata alongside whatever other window field the
caller needed (lsp, event_logs, splits, etc.).
scroll_sync::ensure_active_tab_visible is now one TODO
closer to moving onto impl Window — only composite_buffers
blocked the move pre-0l, and that field is already per-window.Tier 2 — small per-window state that's clearly miscategorised. ✅ Shipped.
All listed fields moved onto Window in PR #1936:
scroll_sync_manager, same_buffer_scroll_sync,
previous_viewports, preview, seen_byte_ranges,
terminal_mode, terminal_mode_resume, auto-revert state
(last_auto_revert_poll, dir_mod_times,
git_index_resolved, pending_file_poll_rx,
pending_dir_poll_rx), goto_line_preview,
interactive_replace_state.Tier 3 — per-window LSP state still on Editor. ✅ Shipped.
All listed fields and the methods that operate on them now
live on Window:
stored_diagnostics, stored_push_diagnostics,
stored_pull_diagnostics, stored_folding_ranges,
lsp_window_messages, lsp_log_messages,
lsp_server_statuses, lsp_progress,
lsp_diagnostic_namespace, diagnostic_result_ids,
next_lsp_request_id and the pending-request maps.Window::update_lsp_warning_domain,
Window::check_diagnostic_pull_timer,
Window::has_active_lsp_progress,
Window::get_lsp_progress,
Window::is_lsp_server_ready,
Window::running_lsp_servers,
Window::initialized_lsp_server_count,
Window::shutdown_lsp_server ship in the follow-up branch.Tier 4 — per-window UX / search. ✅ Shipped.
hover, search_state, search_namespace,
pending_search_range, live_grep_last_state,
overlay_preview_state all on Window.Window::search_match_at_primary_cursor,
Window::check_semantic_highlight_timer,
Window::clear_warnings (general + LSP domains) shipped.Tier 5 — file-explorer chrome flags. ✅ Shipped.
file_explorer_visible, file_explorer_sync_in_progress,
file_explorer_decorations, file_explorer_decoration_cache,
pending_file_explorer_show_*, file_explorer_width,
file_explorer_side, file_explorer_clipboard on Window.Window::focus_editor, Window::file_explorer_search_clear,
Window::handle_set_file_explorer_decorations,
Window::handle_clear_file_explorer_decorations,
Window::rebuild_file_explorer_decoration_cache,
Window::file_explorer_copy, Window::file_explorer_cut,
Window::set_explorer_clipboard shipped.Tier 6 — completion service. ✅ Shipped.
completion_service now lives on Window and uses the
window's own provider state (dabbrev, buffer words, LSP,
plugin providers).Window::take_pending_semantic_token_request and
Window::take_pending_semantic_token_range_request shipped.Tier 7 — full-file impl Editor → impl Window migrations.
✅ Six files shipped on the follow-up branch:
view_actions.rs — page-view toggle, tab-switch animation,
split content-rect lookup.terminal_mouse.rs — mouse-event forwarding to PTY in
alternate-screen mode. Pulled five terminal-IO helpers
(get_active_terminal_state, send_terminal_input,
send_terminal_key, send_terminal_mouse,
is_terminal_in_alternate_screen) onto Window to make this
possible.event_debug_actions.rs — event-debug modal handlers; the
event_debug: Option<EventDebug> field also moved from
Editor to Window because the dialog records keystrokes
destined for one window's input pipeline.help_actions.rs — open_help_manual /
open_keyboard_shortcuts. Pulled create_virtual_buffer and
create_virtual_buffer_detached onto Window to make this
possible.scrollbar_input.rs — entire scrollbar input cluster (705
lines: mouse-wheel, horizontal pan, scrollbar drag and jump,
plus composite-buffer variants). Pulled
split_at_position, move_cursor_to_visible_area, and
calculate_max_scroll_position onto Window for it.menu_context.rs — nine private menu-context helpers
(is_line_numbers_visible, is_lsp_available, etc.) on
Window. The orchestrator update_menu_context stays on
Editor because it writes self.menu_state.context — the
only file-internal Editor-global access.Plus method-level migrations in many other files (chrome toggle
trio, fold toggles, file-open helpers, resolver methods, etc.) —
without delegators, callers go through self.active_window() /
self.active_window_mut() directly.
Resource propagation fix. WindowResources clones the editor's
Arc<Config>, Arc<ThemeRegistry>, Arc<GrammarRegistry>, and
Authority at construction. Until the follow-up branch, mutations
on the editor side left each window holding a stale clone — only
exposed once Window::resolve_line_wrap_for_buffer and
Window::open_local_file started routing through Window::config().
Fixed by adding Editor::sync_windows_config (called from
set_config and the one config_mut-using path that flips a
window-read field, toggle_inlay_hints), and analogous propagation
in Editor::set_boot_authority, Editor::reload_themes, and the
async-dispatch grammar-rebuild handler.
Borderline (decide per-case, not necessarily move):
search_case_sensitive, search_whole_word,
search_use_regex, search_confirm_each — search prefs.
Today they persist editor-wide. Per-window means each window
remembers its own "case-sensitive" toggle. Argued either way.macros: MacroState — one macro at a time today. Per-window
would allow parallel recording. Probably stay editor-global
unless a use-case surfaces.watch_path_handles — registration source dependent
(plugin? window?). Stay editor-global.tab_bar_visible, prompt_line_visible, menu_bar_visible,
status_bar_visible — chrome flags. Moved to Window in PR
#1936; per-window toggling is the right model when a window
has its own status bar. (Was listed in this section in the
earlier draft as "stay editor-global"; the design call flipped
during the PR.)What truly stays on Editor (no movement planned):
tokio_runtime, authority,
local_filesystem, fs_manager, working_dir,
dir_context, time_source, clipboard,
event_broadcaster. (All but clipboard are now also
cloned onto every Window.resources via the WindowResources
bundle so window-side handlers can reach them; the canonical
store stays on Editor.)plugin_manager (one QuickJS,
Arc-shared via WindowResources), keybindings
(Arc<RwLock>, shared via WindowResources), mode_registry,
command_registry, quick_open_registry,
grammar_registry, theme_registry, theme, config
(Arc).terminal_width/height (mirrored onto each
Window), key_translator, pending_escape_sequences,
last_window_title.menu_state, menus,
expanded_menus_cache, theme_cache,
software_cursor_only, session_mode, ansi_background*,
background_fade. (chrome_layout is now per-window.)settings_state, calibration_wizard, keybinding_editor,
global_popups. (Per-window modal state — event_debug,
theme_info_popup, file_explorer_context_menu,
tab_context_menu, file_open_state, file_browser_layout,
file_explorer_clipboard — moved to Window.)background_process_handles,
host_process_handles, host_process_kill_senders.
Per-buffer or per-window wait_tracking / completed_waits
moved to Window.should_quit, should_detach, restart_with_dir,
pending_authority, session_name, plugin_errors.recovery_service (one .recovery/ dir per editor
session — save_buffer_to_recovery writes to disk, not to a
per-window store).last_path_change_for_test,
last_watch_response_for_test.impl Editor blocks: structural prep neededThe remaining files with impl Editor survive because they
orchestrate plugin hooks, mutate editor-global modal state, or
write to the recovery service. Two prep steps unlock the next
batch:
Prep step 1 — Move clipboard onto WindowResources.
Editor::clipboard: Clipboard is the last per-process service
that hasn't been bundled. Three files block on it:
composite_buffer_actions::handle_composite_copy (writes the
selection text), clipboard.rs::paste/copy_selection (the
whole file is the clipboard surface), and parts of
text_ops.rs. Once Clipboard is a Window::clipboard()
accessor backed by an Arc<Mutex<Clipboard>> in WindowResources
(the same pattern theme/plugin_manager already follow), the
copy/paste cluster can migrate by file.
Prep step 2 — Expose plugin-hook firing from Window.
The remaining hard blocker is Editor::apply_event_to_active_buffer
and its sibling Editor::apply_events_as_bulk_edit. Both fire
plugin hooks (after_insert, after_delete, etc.) that LSP
relies on for didChange consistency. Today Window::apply_event_to_buffer
exists as the no-hook version; the gap is a Window-side
apply_event_to_active_buffer_with_hooks that reads
self.resources.plugin_manager and calls run_hook itself.
The Editor wrapper then becomes a thin delegator (plus its
existing snapshot refresh). Files that unblock once this lands:
undo_actions.rs, diagnostic_jumps.rs,
bookmark_actions.rs — small, two- or three-method files
that fire one or two hook-bearing events each.dabbrev_actions.rs — calls log_and_apply_event from
multiple sites.text_ops.rs — smart_home, toggle_comment,
goto_matching_bracket all fire MoveCursor events through
the hook orchestrator.popup_overlay_actions.rs — Show/Hide/AddOverlay/
RemoveOverlay/PopupSelectNext events all route through
the Editor orchestrator today.search_ops.rs — perform_replace,
start_interactive_replace, and the bulk-edit path each
have one apply call.What's genuinely editor-global (no migration planned):
menu_context::update_menu_context, menu_actions.rs,
keybinding_editor_actions.rs — write self.menu_state /
self.menus, both single-instance per process.calibration_actions.rs — self.key_translator is one
calibration per process.recovery_actions.rs — self.recovery_service owns the
on-disk .recovery/ dir; not per-window.lifecycle.rs — should_quit, should_detach,
session_mode, update_checker, etc.orchestrator_persistence.rs — by definition iterates every
window.file_open_queue::process_pending_file_opens — orchestrates
through Editor::open_file and large-file encoding prompts.file_open_orchestrators.rs::open_file and family — still
the canonical place that fires buffer_activated /
buffer_opened hooks.Architectural test (re-stated for this audit): if a Window
handler body needs to know its own WindowId to call into
editor-level logic, that's a sign the editor-level logic is in
the wrong place. With Tiers 1–6 shipped, the remaining failures
of this test all live in the two prep-step targets above.
Each Session is the editor-state equivalent of a VS Code
window: an isolated bundle of everything the user sees and acts
on within that session. Closing a session evicts everything it
owned; opening the same file in two sessions creates two
independent buffers; "save all" / "close all" / quick-open / find-
in-files act on this session only.
Concretely:
Lives on Session (per-window state) | Lives on Editor (cross-session) |
|---|---|
buffers: HashMap<BufferId, EditorState> | next_buffer_id (still globally unique) |
event_logs (undo history per buffer) | Plugin runtime (single QuickJS) |
terminal_manager, terminal_buffers, terminal_backing_files | Theme, config, keybindings (user-level) |
splits, split_view_states | plugin_global_state (cross-session by definition) |
file_explorer, lsp, panel_ids, file_mod_times | sessions: HashMap<SessionId, Session>, active_session |
position_history, bookmarks | Workspace recovery framework |
cached_layout (split / tab / file-explorer rects) | Chrome layout (status bar, menu, prompt overlay rects) |
Editor becomes the multiplexer: it holds the session map, the
active-session pointer, the cross-session shared infrastructure,
and editor chrome. Almost no command logic reads editor-global
state directly — commands are dispatched on the active session.
render_session(frame, area, &Session, &Editor /* chrome */)
call works for any session — active, previewed, or
off-screen. The "preview the entire editor UI" requirement
from § Rich Control Room rendering falls out for free, with
no swap gymnastics and no risk of side-effects bleeding across
sessions.active_session.buffers because that's where buffers live.
No risk of acting on another session's content. Cross-session
operations (e.g. compare alpha vs base for a diff) become
explicit, opt-in APIs.closeSession is principled. Drop the Session struct;
its buffers, terminals, undo logs, watchers all go with it.
No refcounting, no "is anyone else using this buffer?" logic.setActiveSession
becomes a single field write; there are no stashes to keep
in sync; the bug class "I forgot to swap field X back" goes
away.Earlier in this doc's history, buffer storage was deliberately
kept Editor-global with Session.buffers: HashSet<BufferId> as
a membership pointer (§ Why each session owns its buffers).
The rationale was "two sessions might want to share a buffer"
and "Orchestrator's terminal buffers need to be addressable from
the Control Room."
In practice both arguments fold:
editor.sessions.get(sid).buffer(id), which is a one-line
helper. The "global lookup" benefit was illusory.The half-migrated state on the branch — buffers and terminals global, view state per-session — is architecturally inconsistent: rendering correctly scopes to the active session, but commands operate on the global buffer pool. Every command that doesn't go through the session is a latent bug ("save-all from alpha saves base's files too" is one such bug already observed).
The work landed on this branch is not wasted:
Session struct, the sessions map, active_session
pointer, createSession/setActiveSession/closeSession
plugin APIs, the lifecycle hooks, and cross-restart
persistence all stay.splits_stash, lsp_stash, etc.)
become live fields on Session rather than Option<…>
stashes — most of the storage shape is right; only the
ownership semantics flip.What gets discarded:
swap_active_session_state implementation
(session_actions.rs::set_active_session) — replaced by a
pointer write.render_session_preview_into_rect) — replaced by
render_session(&Session, area, &Editor).The migration sequence is laid out in § Migration sequence,
Step 0 below.
§ Trade-off discussion). One Fresh process, one
plugin runtime, one editor instance.AUTHORITY_DESIGN.md) is orthogonal; sessions and authorities
compose, but this doc only specifies sessions on the local
authority. Remote sessions are a follow-up.panelId / utility_dock machinery. This
design composes on top of it (§ Control Room placement).The minimum viable Orchestrator delivers the load-bearing UX claim:
spawn agents in parallel worktrees, switch between them with the entire editor retargeting (file tree, LSP, quick-open, ignore rules, buffer set, splits), and have Orchestrator's session list survive every switch unchanged.
Everything else in this document is wanted but deferrable. Items
throughout the doc are tagged [MVP] or [v1.1+]. This section is
the index.
Gating constraint (May 2026):
Step 0 — Session-as-window migration(§ Migration sequence) is the new top priority and blocks every MVP item below that hasn't already shipped.Progress: Step 0a (cached_layout split) and Step 0b (warm- swap stashes → live Window fields) shipped on
claude/window-state-migration-RjEwX.set_active_windowis now a pointer write — the warm-swap pattern is dead for every field exceptEditor.buffers(and the field-pairs that follow from it: terminals, event_logs, position_history). Step 0c (Editor.buffers→Window.buffers) is the gating piece for 0d–0i; first attempt reverted on borrow-checker friction (see§ Step 0c). UX features built on top of the warm-swap interim still accumulate technical debt and must wait for the rest of Step 0 to complete.
[MVP] — load-bearing for the core UX promiseCore abstraction
Session struct with: id, label, root, buffers,
file_tree, ignore_matcher, lsp_clients, split_layout,
view_states, panel_idsEditor.sessions + active_session pointersetActiveSession)Plugin APIs
listSessions, activeSession, createSession,
setActiveSession, closeSessionsession_created, session_closed, active_session_changed eventscreateTerminal({ sessionId, cwd, ... }) (existing API gains
sessionId field)terminal_output, terminal_exit events (the smallest core
change; § Background)Screens
Screen 1) — fullScreen 2) — reduced column set: #, LABEL,
ROOT PATH, AGENT, STATE, DIFF, AGE. The COMMITS
column, memory header, and collision-radar pane are deferred;
the radar pane area renders an empty placeholder.Screen 3) — full (falls out of the architecture
for free)Screen 4) — fullStates (in the STATE column)
ACTIVE, RUNNING, AWAITING (Y/n), READY, ERRORED,
KILLED. KILLED rows drop immediately in MVP (no tombstone).Controls
Ctrl+n / Ctrl+p (navigate)Enter (dive), n (new), d (diff), m (merge), k
(kill+drop), Esc (close)Diff invocation (d)
docs/internal/REVIEW_DIFF_*.md) on the selected session's
worktree against the base. No new diff renderer is needed for
MVP. The native side-by-side renderer in § Plugin API surface
is a v1.1+ refinement, not a prerequisite.Migration steps from § Migration sequence
This MVP set delivers PRD user stories 1 (orchestrate parallel agents) and 2 (focused coding in a single worktree) and the review-and-merge flow from story 3. It does not deliver passive status-bar awareness or pre-merge collision warnings.
[v1.1+] — wanted, deferredPlugin APIs
watchPath / unwatchPath / path_changed event (enables
collision radar)setSessionState / getSessionState (other plugins' concern)openDiffView for a native side-by-side diff renderer
invoked programmatically with arbitrary oldText/newText.
MVP uses the existing review-diff feature instead, invoked from
Orchestrator's d action.openFile({ sessionId }) (MVP only opens in active session)Control Room enrichments
COMMITS column2.1GB / 32GB)SYNCING state and Orchestrator-driven git operationsKILLED tombstones with two-press semantics for kd
on a multi-selectionmr)Lifecycle
AWAITING / ERROREDeditor.prewarmSession(id)Other
Screen 5) — depends on watchPathStep 7)registerStatusBarElement in PR #1843)r rename. The branch name is the default label and works.
Adding rename without a UI for "what was the original branch
again?" is mildly confusing; defer until needed.<Leader>o only and tune later.Fresh has no project or workspace struct. The cwd of the Fresh
process is the project root, surfaced to plugins via
editor.getCwd() and read directly in many places:
crates/fresh-editor/src/app/file_explorer.rs)
walks from cwd.crates/fresh-editor/src/input/quick_open/providers.rs) is scoped
to cwd.crates/fresh-editor/src/view/file_tree/ignore.rs)
load .gitignore from cwd upward.crates/fresh-editor/src/services/lsp/manager.rs).There is no central registry; each subsystem reads cwd when it needs it. Changing cwd at runtime today would race against any of these readers and would not retroactively rebuild file-tree or LSP state.
crates/fresh-editor/src/app/mod.rs (the Editor struct) owns:
buffers: HashMap<BufferId, Buffer> — every open buffer.split_manager: SplitManager — the pane tree.split_view_states: HashMap<SplitId, SplitViewState> — per-split
scroll/cursor state.terminal_manager — every PTY.plugin_manager — single plugin runtime, single QuickJS instance.file_mod_times: HashMap<PathBuf, _> — polling-based change
detection.panel_ids: HashMap<String, BufferId> — utility-dock occupancy.None of these are scoped by project root. There is one of each, for the whole Fresh process.
The plugin runtime lives on the Editor (singleton). Plugin state in JS is whatever the plugin module's top-level scope holds, which persists for the lifetime of the editor (or until plugin reload). No plugin state is currently scoped narrower than that. This is fortunate: it is exactly the property that lets Orchestrator "live above" sessions for free, once sessions exist.
utility_dock and virtual bufferscreateVirtualBufferInSplit({ role: "utility_dock", … }) (handled at
crates/fresh-editor/src/app/plugin_dispatch.rs:2167 onward)
implements a one-leaf-per-role dock for diagnostics, file explorer,
search/replace, finder. Orchestrator's Control Room will use this same
dock with its own role tag.
defineMode(name, bindings, …)
(crates/fresh-plugin-runtime/src/backend/quickjs_backend.rs:3196)
binds keys to commands within a named mode that virtual buffers can
opt into via the mode field. This is how Orchestrator binds its own
hotkeys.
AsyncMessage::TerminalOutput { terminal_id } and
AsyncMessage::TerminalExited { terminal_id } are emitted from
crates/fresh-editor/src/services/terminal/manager.rs:407,433 and
consumed internally at
crates/fresh-editor/src/app/async_dispatch.rs:427,453. They are not
exposed to plugins today. Surfacing them is one of the changes this
design requires (§ Plugin API surface).
Fresh's client/server (crates/fresh-editor/src/server/) is already
robust and used for persistence-across-disconnect. This design does
not introduce a second server or a new RPC channel. The daemon
hosts one Editor with N sessions; the client renders whichever
session is active plus the editor-level chrome.
Session abstractionA Session owns the per-project-root state that today is implicit on
the Editor.
pub struct Session {
pub id: SessionId,
pub label: String, // user-visible
pub root: PathBuf, // canonical absolute path
// What used to be "the editor's"
pub buffers: HashSet<BufferId>, // ids; storage stays Editor-global
pub split_layout: SplitTree,
pub view_states: HashMap<SplitId, SplitViewState>,
pub active_split: SplitId,
pub panel_ids: HashMap<String, BufferId>, // utility-dock occupancy
pub file_tree: FileTreeState,
pub ignore_matcher: IgnoreMatcher,
pub lsp_clients: LspClientSet, // keyed by language, rooted at `root`
pub watch_handles: Vec<WatchHandle>,
pub plugin_state: HashMap<PluginId, JsValue>, // session-scoped, opt-in
// Persistence
pub layout_snapshot: Option<LayoutSnapshot>, // for save/restore
pub created_at: SystemTime,
}
pub struct Editor {
sessions: HashMap<SessionId, Session>,
active_session: SessionId,
// Editor-global (one per process):
buffers: HashMap<BufferId, Buffer>, // owned here; sessions hold ids
terminal_manager: TerminalManager, // PTYs survive session swaps
plugin_manager: PluginManager, // one runtime
plugin_global_state: HashMap<PluginId, JsValue>,
theme: Theme,
config: Arc<Config>,
keybindings: KeyBindings,
// ...
}
+-----------------------------------+
| Editor (global) |
| ------------------------------- |
| plugin runtime (one QuickJS) |
| plugin_global_state ............| <- Orchestrator's
| orchestrator: { | session list,
| sessions: Map, | collision matrix,
| collisions: Map, | agent PTY refs
| watchers: Map, |
| } |
| terminal_manager (all PTYs) |
| buffers (storage) |
| theme, config, keybindings |
| active_session ----------+ |
+----------------------------|------+
|
+---------------------------------+----+
| | |
v v v
+------------------+ +------------------+ +------------------+
| Session 1 | | Session 2 * | | Session 3 |
| ---------------- | | ---------------- | | ---------------- |
| root: /repo | | root: /wt/auth | | root: /wt/redis |
| file tree | | file tree | | file tree |
| ignore matcher | | ignore matcher | | ignore matcher |
| LSP clients | | LSP clients | | LSP clients |
| watch handles | | watch handles | | watch handles |
| split layout | | split layout | | split layout |
| buffers: {1} | | buffers: {2,3,4}| | buffers: {5} |
| panel_ids: {..} | | panel_ids: {..} | | panel_ids: {..} |
| plugin_state:{} | | plugin_state:{} | | plugin_state:{} |
+------------------+ +------------------+ +------------------+
* ACTIVE
^
|
renderer reads this
once per frame
The renderer's only session-aware read is editor.active_session().
Everything Orchestrator owns is in plugin_global_state, which the
swap pointer does not touch — that is the structural property that
makes "Orchestrator lives above sessions" true.
Revised. The earlier "buffer storage stays Editor-global" framing has been rejected (
§ Architecture priority). This section describes the target model.
Each Session owns its own HashMap<BufferId, EditorState>.
Opening the same file in two sessions creates two independent
buffers; edits diverge. BufferId allocation stays globally
unique (single Editor.next_buffer_id counter) so plugin APIs
that thread buffer ids around don't have to disambiguate by
session, but the storage lives in whichever session the buffer
was opened into.
Why this is the right ownership:
foo.rs should
not echo into beta's view. Independent buffers per
session is the model that delivers this.closeSession becomes trivial. Drop the Session,
take its buffer map and terminal manager with it. No
refcount, no "shared with another session" check.active_session.buffers — there's nothing else to
iterate. "Close all," buffer cycling (Ctrl+Tab),
quick-open, find-in-files, list-buffers all naturally act
on this session's buffers because that's where buffers
live.foo.rs against base's foo.rs calls
editor.sessions[base].buffers[id] — a one-line helper.
Cross-session is opt-in and visible at every call site,
which is what we want for a feature whose UX promise is
"each session is its own world."Session tooTerminal PTYs (the OS process), terminal grid state, and
backing-file paths all live on Session alongside its buffer
map. The terminal_manager's read/wait threads are owned by
the session that created them. Closing a session sends SIGTERM
to its agents and joins the threads — no orphan PTYs.
This matches user expectation: closing alpha's worktree should clean up alpha's agent. With editor-global terminal storage we'd need a separate "which session does this PTY belong to" lookup plus closure logic; with per-session storage it falls out.
EditorSome state genuinely is cross-session and stays on Editor:
next_buffer_id — single counter so buffer ids are globally
unique.plugin_global_state — explicitly cross-session by
definition.sessions: HashMap<SessionId, Session> — the multiplexer's
table.active_session: SessionId — the pointer..fresh/sessions/<id>/.Editor chrome (status bar rects, menu rects, prompt overlay
rects) also lives on Editor via a separate chrome_layout
struct, so mouse hit-testing on chrome doesn't collide with
session-scoped hit-testing on splits and tabs.
active_session: SessionId is the only piece of session state read
on every render. Switching is atomic from the renderer's perspective:
update the pointer, redraw. All cached state — file tree expansion,
LSP clients, watchers — already lives on the (now-active) session.
In the window model: there is no swap.
setActiveSessionis a single field write —self.active_session = id. Each render reads fromself.sessions[self.active_session]. The warm-swap pattern that the interim implementation uses (§ Implementation status snapshot) is replaced by direct per-session field ownership.
Two storage namespaces exposed to plugins:
// Editor-global (default).
editor.setGlobalState("orchestrator.sessions", JSON.stringify(state));
editor.getGlobalState("orchestrator.sessions"): string | null;
// Session-scoped (opt-in).
editor.setSessionState("my-plugin.foo", value);
editor.getSessionState("my-plugin.foo"): unknown; // current active session
Orchestrator uses only the global namespace. Plugins that genuinely want per-project state (per-language helpers, per-repo lint configs) opt in to session scope.
The default is global because that's the current behavior — plugin top-level scope persists for the lifetime of the editor — and we do not want to silently change the meaning of existing plugins' module state.
Window model (target):
setActiveSession(id)is a single field write —self.active_session = id. Every render reads fromself.sessions[self.active_session]; nothing is moved. The "swap" framing below describes the interim warm-swap implementation that the migration replaces.
What visibly changes during setActiveSession(1 -> 2):
BEFORE AFTER
+--------------------------------------+ +--------------------------------------+
| Session 1: main | | Session 2: feat/auth |
+--------------------------------------+ +--------------------------------------+
| /repo | src/main.rs | | /wt/feat-auth | db/schema.sql |
| - Cargo.toml | fn main() { | | - db/ | CREATE TABLE |
| - src/ | println!.. | | - schema.sql ● | users ( |
| - lib.rs | } | | - src/ | id SERIAL.. |
| - main.rs ● | | | - models/ | uuid UUID |
| - tests/ | rust-analyzer | | - user.ts ● | NOT NULL |
| | (warm) | | - aider.terminal | DEFAULT |
| | | | | uuid_.. |
+--------------------------------------+ +--------------------------------------+
| NORMAL Ln 12 main.rs | | NORMAL Ln 8 schema.sql |
+--------------------------------------+ +--------------------------------------+
CHANGES: UNCHANGED:
file tree root Editor.terminal_manager (every PTY)
ignore matcher Editor.theme, .config, .keybindings
buffer set + tabs plugin runtime + plugin_global_state
active LSPs (now session 2's) session 1's LSPs (kept warm)
split layout session 1's watchers
status bar buffer state Orchestrator's session list/collisions
editor.setActiveSession(id) performs:
Session.layout_snapshot.Editor.active_session = id.active_session_changed to plugins.LSPs, watchers, and plugin global state are never touched. The inactive session's LSPs continue running; if a tool finishes indexing while the user is in another session, it is ready immediately on the next dive.
The renderer reads editor.active_session() once at the top of each
frame. There is no per-subsystem "switch" call — the switch is the
pointer write, and every read from then on routes through the
session.
| Event | Effect |
|---|---|
createSession({ root, label }) | Construct a new Session, walk file tree, build ignore matcher, lazily start LSPs on first buffer open. Return SessionId. Does not switch active. |
setActiveSession(id) | Atomic swap (above). |
closeSession(id) | Shut down LSPs, drop watchers, free per-session caches. If id == active_session, refuse with error (caller must switch first). Buffers attached to this session and not to any other are closed. |
| Editor shutdown | Persist session list (root, label, layout snapshot) to .fresh/sessions.json. Terminal PTYs and agent processes are torn down per existing rules. |
| Editor startup | Rehydrate session list. Inactive sessions are lazy — LSPs and file watchers do not start until the session is first activated. Only the active session is fully spun up. |
A typical lifecycle from a user's perspective:
t=0 Editor starts
Editor.sessions = { 1: "main" (active) }
plugin_global_state.orchestrator = { sessions: {}, collisions: {} }
t=1 User: <Leader>o, n, "feat/auth", "aider --message ..."
git worktree add ../wt-auth feat/auth
createSession({ root: /wt-auth, label: "feat/auth" }) -> id=2
createTerminal({ sessionId: 2, cwd: /wt-auth })
Editor.sessions = { 1: main (active), 2: feat/auth (warm) }
t=2 User: <Leader>o, Enter on session 2
setActiveSession(2) <-- atomic pointer swap
Editor.sessions = { 1: main (warm), 2: feat/auth (active) }
Orchestrator's internal map: untouched
t=3 Agent finishes; transitions to READY (terminal_exit, code 0)
Orchestrator updates its map; status updates in Control Room
t=4 User: <Leader>o, m on session 2 (review skipped)
git -C /repo merge feat/auth
closeSession(2) <-- LSPs torn down, watchers dropped
git worktree remove /wt-auth
Editor.sessions = { 1: main (active) }
The Control Room is a virtual buffer that must render identically regardless of which session is active. Two options:
Session.buffers set; the renderer treats it
as part of editor chrome. Drawn over the active session's UI.panel_ids
contains the Control Room buffer, so it stays addressable after
switches.(A) is cleaner: one buffer, one panel id, no per-session
bookkeeping. It requires a small new affordance in
virtual_buffers.rs — an "editor-global" flag — but the rendering
path already special-cases dock leaves, so this is local.
(B) reuses existing machinery but means every closeSession has to
remember not to evict the Control Room. Strictly more error-prone.
This design picks (A).
The Screen 2 mockup (header line + 8-column SESSIONS table + dual
preview/collision-radar panes + hotkey footer + summary line, all
full-screen) is more than the existing CenteredOverlay (Live
Grep–style centered prompt with input + suggestions + path-driven
preview pane) can render. The two primitives below are designed to
compose to deliver it, and to be independently useful for any
future plugin that needs embedded editor views or full-screen
chrome.
render_content becomes session-pluggableRefactor the existing renderer so its dependencies are explicit
parameters rather than implicit &mut self.* reads. Concretely:
cell_theme_map and pending_hardware_cursor (the two pieces
of per-frame scratch state currently shared with the active
render) become per-call scratch buffers the caller owns.
Calling the renderer twice in one frame for two different
(SplitManager, view_states) pairs no longer clobbers
hit-testing or cursor placement of the first pass.(SplitManager, view_states) into a sub-rect.editor.previewSessionInRect(sessionId) —
one-shot "for the next frame, render this session in the
designated preview pane." Cleared on overlay close.This consumes the warm-swap state from Step 1f (split tree +
view_states stash on Session) directly: the previewed session's
splits are already structured exactly the same way as the active
session's, just parked in Session.splits_stash. Rendering them
needs no transformation, just temporary access.
A virtual buffer can declare itself a full-screen overlay: the
renderer treats it as editor chrome (drawn over the active
session, doesn't mutate splits, doesn't take over the buffer set),
and the plugin owns layout via a Vec<Region> callback. Each
region is either:
{kind: "session_preview", session_id} that
core fulfils via primitive #1.Input flows through the existing defineMode mechanism — the
overlay declares a mode, plugin registers bindings (Up, Down,
Enter, n, d, m, k, r, Esc, etc.), and key events
route through the same dispatcher used by every other buffer
mode. No new input model.
+-----------------------------------------------------------+
| HEADER (plugin region: styled text) |
+-----------------------------------------------------------+
| SESSIONS TABLE (plugin region: 8-column rows w/ styling) |
| |
+---------------------+-------------------------------------+
| PREVIEW (delegate) | COLLISION RADAR (plugin region) |
| | |
| { kind: | |
| "session_preview",| |
| session_id: <sel> | |
| } | |
+---------------------+-------------------------------------+
| FOOTER (plugin region: hotkey hints) |
+-----------------------------------------------------------+
| SUMMARY (plugin region: aggregate counts) |
+-----------------------------------------------------------+
The preview pane shows the highlighted session's full editor UI — splits, terminals, syntax-highlighted buffers, LSP markers, inline decorations — rendered natively. Live PTY output streams in for free because the renderer reads the terminal grid state directly each frame; no plugin TS code in the per-frame path.
cell_theme_map and pending_hardware_cursor out of Editor
into per-call scratch; one new render-call site for the
preview rect; one plugin API.Status:
editor.previewSessionInRect(id)
renders the previewed session's stashed split tree (with
syntax highlighting, terminal grid, decorations) into the
floating overlay's preview pane. The active session's
rendering is unchanged; cell_theme_map and
pending_hardware_cursor were lifted into per-call scratch
so a second render-pass per frame doesn't clobber the active
area's hit-testing.editor.setPromptTitle(...) (header) and
editor.setPromptFooter(...) (footer hotkey row) supply the
styled chrome around the prompt + preview pane. Combined
with Primitive #1's preview delegate, this composes to the
screenshot's header / table / preview / footer layout
without introducing a brand-new buffer-attachment kind.Vec<Region> layout is not implemented. Adding a
side-by-side collision radar pane, multi-region custom
layouts, or non-prompt overlays still requires this work.
Tracked as a follow-up; the floating-prompt path covers the
Orchestrator MVP's needs.This section catalogues every screen the user can see, in the order they typically encounter them. Each entry: a sketch, the user objective the screen exists to satisfy, the flows that lead in and out, and the controls available.
[MVP]+------------------------------------------------------------------+
| TABS: src/main.rs |
+------------------------------------------------------------------+
| |
| +============== ORCHESTRATOR =================================+ |
| | | |
| | No active sessions. | |
| | | |
| | Orchestrator lets you run multiple coding agents in | |
| | parallel git worktrees and switch between them as if | |
| | each were its own Fresh session. | |
| | | |
| | Press n to spawn the first one. | |
| | Press Esc to close. | |
| | | |
| +==========================================================+ |
| |
+------------------------------------------------------------------+
| NORMAL Ln 1 main.rs |
+------------------------------------------------------------------+
Objective. Discoverability. A user who pressed <Leader>o on a
hunch needs to learn (a) what the feature is and (b) the single key
that gets them started, without reading docs.
Entry. <Leader>o from any session, when sessions.size == 0
(only the implicit base session exists).
Exit. n opens the new-session prompt (Screen 4); Esc closes
the dock and returns the user to whatever they were editing.
Controls.
| Key | Action |
|---|---|
n | Open new-session prompt |
Esc | Close Control Room |
[MVP — reduced; see § MVP scope]+----------------------------------------------------------------------------------+
| Fresh v1.0.2 2026-05-18 2.1GB / 32GB |
+- SESSIONS -----------------------------------------------------------------------+
| # LABEL ROOT PATH AGENT STATE DIFF C AGE|
| 1 main /repo (base) - ACTIVE - - - |
| 2 > feat/auth-v2 /wt/feat-auth-v2 aider AWAITING (Y/n) +118-5 1 14m|
| 3 fix/redis-cache /wt/fix-redis-cache claude -p RUNNING +45-12 1 25m|
| 4 UI-refactor /wt/UI-refactor opencode READY +104-4 3 7m|
| 5 feat/auth-v2 /wt/feat-auth-v2-alt aider AWAITING (Y/n) +118-5 1 14m|
| 6 feat/redis /wt/fix-redis-cache-alt aider AWAITING (Y/n) +118-5 1 25m|
| 7 ... ... |
| 11 new-editor /wt/new-editor shell ERRORED -0 1 13m|
| 12 test-user /wt/test-user shell KILLED -0 0 19m|
| 13 cargo-bump /wt/cargo-bump shell SYNCING - 1 25m|
| 14 agent-experiment /wt/agent-experiment shell SYNCING +23-3 1 25m|
| 15 feat-editor /wt/feat-editor shell SYNCING +112-5 1 ...|
+----------------------------------------------------------------------------------+
| PREVIEW (session 2, feat/auth-v2) | COLLISION RADAR 3 detected |
| Aider> Tests failed at src/auth.rs:42. | src/auth.rs conflict-likely |
| > Analysis complete. | session 1 |
| > 1 failed test in src/models/user.ts:42 | session 2 |
| > Marked tests in src/auth.rs | src/models/user.ts conflict-likely|
| AIDER> Do you want me to attempt a fix? | session 2 |
| (Y/n): _ | session 3 |
| | Cargo.toml conflict-likely|
| | session 1, 2, 4 |
+----------------------------------------------------------------------------------+
| Enter:dive n:new d:diff m:merge k:kill r:rename Esc:close Ctrl+n/p:cycle |
+----------------------------------------------------------------------------------+
| NORMAL Ln 12 Col 1 ORCHESTRATOR | 15 sessions, 2 awaiting, 3 collisions |
+----------------------------------------------------------------------------------+
Objectives. This screen has to satisfy three distinct user tasks in one view, ranked by frequency:
A quaternary objective is monitoring agent health passively, but
the design deliberately does not satisfy that here — passive
awareness lives in the status bar (deferred; see "deferred features"
in the design conversation), not in this screen, because this screen
is full-screen and disruptive. The Control Room's own bottom summary
line (15 sessions, 2 awaiting, 3 collisions) provides aggregate
awareness inside the Control Room.
| Column | Meaning | MVP |
|---|---|---|
# | Stable session id (1-indexed; the base session is always 1). Survives across restart, monotonically grows. | MVP |
LABEL | User-facing name. Defaults to the branch name; r lets the user rename. Does not have to match the branch. | MVP (no rename) |
ROOT PATH | Absolute filesystem root of the worktree. The base session shows the repo root annotated (base). | MVP |
AGENT | The shell command form spawned in the session's terminal. - for the base session; shell for a plain shell. | MVP |
STATE | Parsed lifecycle state (see below). | MVP (reduced state set) |
DIFF (+/-) | Lines added/removed compared to the merge base. Includes uncommitted changes; refreshed on a debounce. | MVP |
COMMITS | Number of commits the session has made on its branch since branching from the base. | v1.1+ |
AGE | Wall-clock age since session creation. | MVP |
| State | Meaning | Set by | MVP |
|---|---|---|---|
ACTIVE | This session is the one currently being rendered (active_session). | Editor pointer. | MVP |
RUNNING | Agent process is alive and producing output. | Default. Re-entered when output advances after AWAITING. | MVP |
AWAITING (Y/n) | Output ends in a recognised prompt pattern; agent has stopped. | Regex on terminal output. | MVP |
READY | Agent process exited cleanly (code 0). | terminal_exit event. | MVP |
ERRORED | Agent process exited non-zero. | terminal_exit event. | MVP |
KILLED | User pressed k; orchestrator sent SIGTERM. The row remains as a tombstone until dismissed (see "KILLED retention" in open questions). | User action. | MVP — but no tombstone in MVP; row drops immediately |
SYNCING | A git operation initiated by Orchestrator is in flight in this worktree (merge into base, pull from remote, push). The terminal may be unresponsive during this. | Orchestrator entered git operation. | v1.1+ |
KILLED and READY/ERRORED are terminal states — the agent
process is gone — but the worktree is not automatically removed.
The user must press m (merge then remove) or k again on a
killed/ready row (remove without merge) to drop the worktree and
the session row.
The AGENT column accepts any command. There are three usage
patterns the design accommodates explicitly:
aider, claude -p, opencode --task,
…) — the primary use case. Their interactive prompts drive the
AWAITING state.shell — a plain interactive shell in the worktree. State
inference falls back to running-while-output-moves and
ready-on-exit; there is no AWAITING heuristic for shells. Useful
for "I want a worktree to poke around in by hand."The Orchestrator plugin does not need to know which is which; the state machine is uniform.
[MVP for spawn; v1.1+ for compare/promote-cluster]Rows 2/5, 3/6, 4/10 in the sketch above are deliberately on the
same logical task (feat/auth-v2, fix/redis-cache,
UI-refactor). PRD user story 1 ("spawn 3 different agents to
explore 3 different architectural approaches in parallel") drives
this. Orchestrator must allow:
-alt, -2, …) so git worktree add doesn't conflict.d while two rows are selected (multi-select
with Shift).m on a
selected row in a parallel-attempt cluster offers to kill the
siblings.The LABEL column makes parallel attempts identifiable; renaming
(r) is how the user disambiguates them ("auth-with-uuid",
"auth-with-snowflake").
[v1.1+]2.1GB / 32GB is total Fresh-process RSS over total system RAM.
This exists because the warm-LSP architecture (§ Lifecycle) makes
memory the dominant cost of running many sessions; surfacing it
in the Control Room lets the user see it climb in real time as
they spawn sessions, and gives them a basis for deciding when to
close idle ones. Not load-bearing for any feature; can be hidden
via config.
Entry.
<Leader>o from any session.orchestrator.new or orchestrator.merge, returning
here.Exit.
Enter: dive into the selected session (Screen 3).Esc: close, return to active session's IDE.Common sub-flows.
<Leader>o, scan, close with Esc. No
selection change persisted.n → new-session prompt (Screen 4) → returns here with
the new session selected.Enter → Screen 3.Ctrl+n / Ctrl+p cycles selection
through sessions in id order; preview pane updates live.READY row → d for diff → m
to merge if happy → row enters SYNCING while git merge runs →
on success row is dropped and worktree torn down.d shows a three-way diff (base / row
A / row B).k → confirm →
state moves to KILLED. Row stays as a tombstone; press k
again on the killed row to drop the worktree and remove the row.r on selected row → inline edit in the LABEL cell.Controls.
| Key | Action | When enabled | Phase |
|---|---|---|---|
| Up / Down | Move selection | always | MVP |
| Shift + Up/Down | Extend multi-select | always | v1.1+ |
| Ctrl + n / Ctrl + p | Cycle selection (with wrap) | always | MVP |
| Enter | Dive into selected | session is not the active one | MVP |
| n | New session | always | MVP |
| d | Show diff | selection has changes | MVP (invokes existing review-diff) |
| m | Merge selected into base | state == READY | MVP |
| k | Kill agent (first press) / drop worktree (second press on tombstone) | not the base session | MVP — single press kills+drops |
| r | Rename / re-label session | always | v1.1+ |
| Tab | Cycle preview pane focus (terminal / collisions) | always | v1.1+ |
| Esc | Close Control Room | always | MVP |
| Mouse: click row | Select | always | MVP |
| Mouse: double-click row | Dive | session is not the active one | MVP |
m and k-on-non-tombstone both prompt for confirmation via
showActionPopup because both are destructive (work that hasn't
been pushed lives only in the worktree).
[MVP — falls out of architecture]+------------------------------------------------------------------+
| TABS: schema.sql ● | user.ts ● | aider.terminal |
+------------------------------------------------------------------+
| /wt/feat-auth | db/schema.sql |
| - db/ | CREATE TABLE users ( |
| - schema.sql ● | id SERIAL PRIMARY KEY, |
| - src/ | uuid UUID NOT NULL DEFAULT |
| - models/ | uuid_generate_v4(), << aider |
| - user.ts ● | email VARCHAR(255) UNIQUE NOT NULL,|
| - aider.terminal | created_at TIMESTAMP DEFAULT NOW() |
| | ); |
| | |
+------------------------------------------------------------------+
| TERMINAL: aider |
| > Tests failed on line 42. |
| > Do you want me to attempt to fix them? (Y/n): Y_ |
+------------------------------------------------------------------+
| NORMAL Ln 12 schema.sql | feat/auth | agent: AWAITING |
+------------------------------------------------------------------+
Objective. Provide a normal Fresh editing experience, scoped to one worktree, with the agent's terminal a keystroke away. The user has to be able to forget Orchestrator exists for the duration of their focused work — the IDE must not feel like a sub-mode of Orchestrator.
This screen is "as if Fresh always lived in this worktree." Everything that's normally in a Fresh session — file explorer, splits, LSP, quick-open, command palette, mouse — works unchanged. The only Orchestrator-specific affordances are:
feat/auth) and the
agent's parsed state (AWAITING).<Leader>o returns to Control Room.<Leader>n / <Leader>p cycle to next/previous
session without going through the Control Room.Entry. Enter on a row in the Control Room.
Exit.
<Leader>o → Control Room.<Leader>n / <Leader>p → directly to another session's IDE.Common sub-flows.
Y or whatever → return to editing.<Leader>o.Controls. All standard Fresh keybindings, plus:
| Key | Action |
|---|---|
<Leader> o | Open Control Room |
<Leader> n | Next session (cycle) |
<Leader> p | Previous session (cycle) |
[MVP]+------------------------------------------------------------------+
| TABS: src/main.rs |
+------------------------------------------------------------------+
| |
| +---- New session (1/2) ----+ |
| | Branch name: | |
| | feat/auth-schema_ | |
| +---------------------------+ |
| fix/redis-cache |
| feat/login |
| (existing worktree branches) |
| |
+------------------------------------------------------------------+
| NORMAL |
+------------------------------------------------------------------+
+------------------------------------------------------------------+
| TABS: src/main.rs |
+------------------------------------------------------------------+
| |
| +---- New session (2/2) ----+ |
| | Agent command: | |
| | aider --message "_ | |
| +---------------------------+ |
| claude -p "" |
| opencode --task "" |
| aider |
| (recent commands) |
| |
+------------------------------------------------------------------+
| NORMAL |
+------------------------------------------------------------------+
Objective. Spawn a new agent with as few keystrokes as possible while still letting the user pick the branch and command precisely. Two steps because the two questions are conceptually distinct: where the work happens (branch / worktree) and what runs there (agent command).
Entry. n from the Control Room.
Exit.
Esc at any step: cancel, no worktree created, return to Control
Room.Enter on step 2 with a non-empty command: Orchestrator runs git worktree add, calls createSession, calls createTerminal,
sends the command, and returns to Control Room with the new
session selected.Common sub-flows.
main (configurable base) before the
worktree.Controls.
| Key | Action |
|---|---|
| Type | Edit current step's value |
| Tab / Down | Cycle to next suggestion |
| Shift-Tab / Up | Cycle to previous suggestion |
| Enter | Submit current step |
| Esc | Cancel |
Failure modes. If git worktree add fails (dirty worktree,
locked branch, path collision), Orchestrator surfaces the git error in
a showActionPopup and leaves the user in the Control Room with no
state change.
[v1.1+ — depends on watchPath]+------------------------------------------------------------------+
| Session 1 IDE (file tree | editor) |
| |
| +--- Collision detected ---------------------------+ |
| | | |
| | src/models/user.ts is being modified by: | |
| | - session 2 (feat/auth-v2) | |
| | - session 3 (fix/redis-cache) | |
| | | |
| | Severity: conflict-likely | |
| | | |
| | [Open Control Room] [Show diff] [Dismiss] | |
| +--------------------------------------------------+ |
| |
+------------------------------------------------------------------+
Objective. Make the user aware of an impending merge conflict at the time the second agent first touches the path, when intervention is cheapest, rather than at merge time when the diffs have grown.
This is the only Orchestrator screen that interrupts the user's work unsolicited. It is therefore deliberately conservative: it fires once per collision-pair-per-session-pair, not on every subsequent edit.
Entry. Automatic, fired by the collision matrix when a path's modifying-session set grows from 1 to 2 (or 2 to 3, etc.).
Exit.
Open Control Room: closes popup, opens Control Room with the
collision pane focused on this path.Show diff: closes popup, opens a diff buffer comparing the two
worktrees' versions of the file.Dismiss: closes popup; this collision-pair-on-this-path is
silenced for the rest of the editor session. New collision pairs
on the same path still fire.Controls. Standard showActionPopup controls (Tab to move
between buttons, Enter to activate, Esc = Dismiss).
+---------------+
| Empty (1) |
+-------+-------+
| n
v
+---------------+
| Prompt (4) |
+-------+-------+
| Enter (×2)
v
+-----------+ <Leader>o +---------------+ Enter +-------------+
| Session |<------------| Control Room |---------->| Session IDE |
| IDE (3) |------------>| (2) |<----------| (3) |
+-----------+ +---------------+ <Leader>o+-------------+
^ ^
| |
| (any screen) | (any session, autofire)
| |
+-----+--------------------------+
| Collision popup (5) |
| [Open Control Room] / [diff] |
+---------------------------------+
The Control Room is the hub; every other screen either feeds into it (Empty, Prompt, Collision) or is reached through it (Session IDE).
Additions only. Nothing existing is removed or changed shape.
[MVP]type SessionId = number;
type SessionInfo = { id: SessionId; label: string; root: string; createdAt: number };
editor.listSessions(): SessionInfo[];
editor.activeSession(): SessionId;
editor.createSession(opts: { root: string; label: string }): Promise<SessionId>;
editor.setActiveSession(id: SessionId): void;
editor.closeSession(id: SessionId): Promise<void>;
// Events
editor.on("session_created", handler: string): void;
editor.on("session_closed", handler: string): void;
editor.on("active_session_changed", handler: string): void;
// payload: { previousId: SessionId | null; activeId: SessionId }
[MVP for createTerminal; v1.1+ for openFile]createTerminal gains an optional sessionId and is [MVP].
openFile's sessionId parameter is [v1.1+] — MVP plugins open
files in the active session only.
editor.createTerminal({ sessionId?: SessionId, cwd?: string, ... }): Promise<TerminalResult>;
editor.openFile(path: string, opts?: { sessionId?: SessionId }): Promise<BufferId>;
Existing call sites without sessionId get the active session, so
existing plugins keep working.
[MVP — the small core change]editor.on("terminal_output", handler: string): void;
// payload: { terminalId: number; recentBytes: string; lastLine: string }
editor.on("terminal_exit", handler: string): void;
// payload: { terminalId: number; code: number | null }
Wired by firing plugin events at
crates/fresh-editor/src/app/async_dispatch.rs:427,453.
[v1.1+]Required for the collision radar. MVP ships with a placeholder empty pane in the Control Room where the radar will go.
editor.watchPath(path: string, opts?: {
recursive?: boolean;
sessionId?: SessionId; // tag for collision matrix; not for scoping
}): Promise<WatchHandle>;
editor.unwatchPath(handle: WatchHandle): void;
editor.on("path_changed", handler: string): void;
// payload: { handle: WatchHandle; path: string; kind: "modify"|"create"|"delete" }
Backed by the notify crate. The sessionId field is informational
(passed back in the event payload) so Orchestrator can build a
Map<path, Set<SessionId>> collision matrix without juggling its
own handle-to-session map.
[MVP for global namespace; v1.1+ for session namespace]The global namespace is [MVP] because Orchestrator itself uses it.
The session namespace is [v1.1+] and exists for other plugins
that genuinely want per-project state.
editor.setGlobalState(key: string, value: unknown): boolean;
editor.getGlobalState(key: string): unknown; // undefined if missing
editor.setSessionState(key: string, value: unknown): void; // v1.1+
editor.getSessionState(key: string): unknown; // v1.1+
The global namespace already exists in core as of this
design's authoring — setGlobalState/getGlobalState accept any
JSON-compatible value, are namespaced by the calling plugin's
name automatically, and treat null/undefined as delete. Plugin
isolation is verified by existing tests (Plugin A's keys are
invisible to Plugin B).
Cross-restart persistence is [v1.1+] — values live in the state
snapshot only, so they survive plugin reloads (good enough for
Orchestrator reloading itself) but not editor restarts. Persisting
to .fresh/state/<plugin>.json is the v1.1+ extension.
[v1.1+]MVP invokes the existing review-diff feature
(docs/internal/REVIEW_DIFF_*.md,
docs/internal/SIDE_BY_SIDE_HUNK_NAV_REBINDABLE.md) from
Orchestrator's d action — Fresh already has a side-by-side review
diff with hunk navigation, and Orchestrator reuses it pointed at the
selected session's worktree against the base. The
openDiffView API below is a programmatic entry point with
arbitrary oldText/newText for plugins that need to diff
non-git content; it is [v1.1+].
editor.openDiffView(opts: {
oldText: string; newText: string;
title: string;
mode?: string;
sessionId?: SessionId;
}): Promise<{ bufferId: BufferId }>;
The work is large (§ Risks) but factorable. Each step is a
reviewable PR.
Re-prioritised May 2026. The branch landed the warm-swap migration and a working Orchestrator MVP plugin, but identified the warm-swap pattern itself as a half-finished architecture (
§ Architecture priority). Step 0 below — the session-as-window migration — is the new top priority and blocks all other Orchestrator feature work (single-key prompt hotkeys,d/mactions, AGENT/DIFF columns, full Primitive #2, collision radar). Steps 4 / 7 / v1.1+ items resume after Step 0 lands.
[BLOCKING — top priority]Goal: each Session owns the storage for everything it needs
to render and operate on, exactly like a VS Code window. The
warm-swap pattern goes away. setActiveSession becomes a
pointer write. Render becomes window-pluggable as
Window::render(frame, area, &EditorChrome).
Architectural rule for 0c onward (revised after 0b): state
moves to Window, and so do the methods that mutate it.
Action handlers, edit operations, save/revert, undo/redo,
render, terminal operations — anything whose body primarily
mutates window-scoped state — relocates from impl Editor
onto impl Window. &mut self inside those methods is the
window; there is no "active window" lookup at the call site.
Editor-global state that handlers need (config, theme,
filesystem) is Arc<…> cloned into Window or threaded as
parameters; plugin hooks that handlers want to fire become
return values dispatched by the Editor shim. impl Editor
keeps only window lifecycle, cross-window orchestration,
editor-global mutations, and the top-level dispatcher.
Strong preference:
impl Windowmethods over inline borrows. When you hit a borrow-checker conflict in a handler onimpl Editor, the first thing to try is moving the handler ontoimpl Window— not adding alet __win = self.windows.get_mut(&self.active_window).expect(...);
sub-field-extraction block. The inline-borrow pattern works for one-off cases where two disjoint sub-fields of the active window need to be
&mut-aliased in the same expression (theapply_event_to_buffershape: buffers + splits + event_logs together), but it is not the right tool when an impl method is possible. Reasons, in order of importance:
- The "active" concept leaks into every handler body.
self.windows.get_mut(&self.active_window)only makes sense when the editor has an active concept — it spreads that concept into code that should just operate on a window. Moving the method ontoimpl Windowmakes&mut selfbe the window; the body has no idea whether it's active.- It blocks "operate on a non-active window." Orchestrator diffs and cross-window orchestration want to call the same handler against any window, not just the active one. An
impl Windowmethod makes that free (alpha.handle_X(...),base.handle_X(...)); an inline-borrow handler is permanently active-only.- It's verbose. Each conflict site bloats by 3–6 lines of boilerplate. An
impl Windowmethod has zero boilerplate at the call site (one method call) and inside the body uses normalself.Xfield access.- The borrow problem doesn't actually require it. The inline-borrow pattern is a workaround for the wrong layer holding the method. Putting the method on
impl Windowmakesself.Xandself.Ycleanly splittable by the borrow checker — no workaround needed.The escape hatch — when
impl Windowis genuinely not possible — is when the handler body needs Editor-global state that can't be cheaply threaded as a parameter (buffer_metadata,composite_buffers,plugin_manager, mutating UI state likeprompt/status_messagedeeply intermixed with window mutation, or several of these at once). In that case the inline-borrow pattern is acceptable as a holding measure — but the comment at the site should say "TODO: move to impl Window once <X> is threadable" so the debt is visible.When migrating an existing inline-borrow site to
impl Window: pull the body into aWindowmethod, change&mut self.active_window_mut().Xto&mut self.X, have the method return anything Editor-global (events to log, status messages to set, plugin-hook payloads), and rewrite the Editor caller to one line plus the post-mutation dispatch.
This rule was learned the hard way during 0b: the
accessor-method strategy used there (Editor::split_manager_mut
etc.) returns references bound to &mut self, which makes the
borrow checker treat every such call as locking all of
Editor. Two such accessors can't compose; nor can one
accessor compose with a read of any other Editor field. The
0b code worked around this with inline self.windows.get_mut( &self.active_window)… direct field access at conflict sites,
which is correct but verbose and leaks the "active" concept
into every handler. Putting the methods on impl Window
eliminates the workaround: self.X becomes a normal Rust
field access on the right type, and the borrow checker splits
it cleanly.
Sub-steps, in dependency order:
0a — Move cached_layout (split / tab / file-explorer
parts) onto Session. Status: shipped. Audit
Editor::cached_layout reads/writes; split into
Window.layout_cache (split-leaf rects, tab rects,
file-explorer rects, view-line mappings) and
Editor.chrome_layout (status bar, menu, prompt overlay,
popups, suggestions, settings modal, full-frame cell-theme
map). Mouse hit-testing routes through the right one. Reached
on the active window via Editor::active_layout() /
active_layout_mut(). (One commit on
claude/window-state-migration-RjEwX.)
0b — Convert warm-swap stashes to live fields on Session.
Status: shipped. The fields that today are Option<…>
stashes (splits_stash, file_explorer_stash, lsp_stash,
panel_ids_stash, file_mod_times_stash) become live
Window fields (splits is Option<(SplitManager, HashMap<LeafId, SplitViewState>)> because layout allocation is
deferred to first activation; the rest are direct).
set_active_window is now a pointer write — the swap body is
gone, replaced by seed-buffer/layout allocation on first dive
into a never-activated window. Editor accessors
(split_manager() / _mut(), split_view_states() / _mut(),
file_explorer() / _mut(), lsp() / _mut(), panel_ids()
/ _mut(), file_mod_times() / _mut()) cover the common
case. (Five commits on claude/window-state-migration-RjEwX,
one per field.)
Lessons from 0b — the accessor-method strategy was wrong.
The shipped 0b uses Editor::X() / X_mut() accessor methods
that resolve to the active window's field
(Editor::split_manager_mut(&mut self) -> &mut SplitManager,
etc.). That works at sites that touch one window field at a
time, but breaks at sites that compose two: the method's
return reference is bound to &mut self, so it locks all
of Editor for its lifetime. Concretely: self.X_mut() and
self.Y_mut() (or &self.Z) cannot coexist even when X, Y, Z
are disjoint fields, because the borrow checker only sees two
overlapping &mut self borrows.
Direct field access splits cleanly — self.windows.get_mut(...)
locks self.windows only, leaving self.config / self.theme
/ self.event_logs free. So in 0b's hot-path code, we worked
around the accessor problem by inlining the field access at
conflict sites:
let active_id = self.active_window;
let window = self.windows.get_mut(&active_id).unwrap();
let state = window.buffers.get_mut(&id).unwrap();
let (mgr, vs) = window.splits.as_mut().unwrap();
// state, mgr, vs all live at once: disjoint sub-fields of Window.
This pattern works but it's verbose, repetitive, and at the wrong layer — every action handler that operates on a window shouldn't have to thread "active" through its body or rebuild the same boilerplate.
The right primitive: methods on impl Window. Most action
handlers today live on impl Editor because that's where the
state lived in the legacy single-window codebase. After Step 0,
the state for those handlers (buffers, splits, file_explorer,
lsp, event_logs, terminals, …) lives on Window. The methods
should follow the data: handlers that mutate window-scoped
state move to impl Window, where &mut self is the window
and there is no "active" concept inside the method body. The
borrow problem disappears structurally — self.buffers is a
direct field access on Window, splits cleanly with
self.splits, and Editor isn't involved.
impl Window {
pub fn handle_insert_char(&mut self, ch: char, cfg: &Config) {
let buf_id = self.active_buffer();
let state = self.buffers.get_mut(&buf_id).unwrap();
let cursors = &mut self.splits.as_mut().unwrap().1
.get_mut(&self.active_split()).unwrap().cursors;
// ... mutate state + cursors freely ...
}
}
What stays on impl Editor:
create_window, set_active_window,
close_window).Window method, and fires deferred
plugin hooks based on what changed.Editor-global state that handlers genuinely need (config,
theme, filesystem) is shared via Arc<…> cloned into Window
on construction, or passed as &Config parameters. Plugin
hooks are returned from Window methods as event values so
the Editor shim can dispatch them after the window mutation
returns — keeps plugin_manager off Window.
This shape removes the macro / inline-direct-field-access
workarounds entirely. It also makes "operate on a non-active
window" first-class: Orchestrator's diff helper just calls
alpha.X(...) and base.Y(...) directly, no swap, no
"setActiveWindow before the operation" gymnastics.
0c — Move Editor.buffers onto Session. Status: not
yet shipped — first attempt reverted, recommended approach
revised.
Window.buffers: HashMap<BufferId, EditorState> replaces
today's Window.buffers: HashSet<BufferId> and
Editor.buffers: HashMap<BufferId, EditorState>.
next_buffer_id stays globally unique (allocated via
Arc<AtomicUsize> shared into windows, or a &mut IdAllocator parameter to the few methods that allocate ids).
First attempt: moved the type, then sed-rewrote every
self.buffers.X call site to inline-windows-access. That
left ~50 borrow-checker conflict sites because the inline
expression locks self.windows while the body needs other
window or Editor fields. Reverted to keep the branch
compiling cleanly. The conflict pattern is the same one from
the 0b lessons above — the accessor-method strategy can't
support it.
Recommended approach for the next attempt — three phases:
Move the field. Change Window.buffers from
HashSet<BufferId> to HashMap<BufferId, EditorState>,
delete Editor.buffers, hand the seed buffers to the base
window in editor_init. No call-site rewrites yet — this
step intentionally breaks compilation.
Move the methods. For each impl Editor method whose
body primarily mutates buffer state (action handlers, edit
ops, save/revert, undo/redo), relocate it to impl Window.
&mut self becomes &mut Window and self.buffers is now
direct field access. Editor-global needs become Arc-shared
fields on Window (config, theme, filesystem) or
parameters (&Config, &mut PluginManager if it really
has to fire a hook from inside; usually it shouldn't).
Plugin-hook side effects become return values that the
Editor dispatcher fires after the call.
Editor shim layer. The top-level Editor::handle_action
/ Editor::handle_key / Editor::render becomes a thin
dispatcher that pulls &mut self.windows[&self.active_window]
(direct field access — no accessor method), calls the
appropriate Window method, then handles the returned
plugin hooks / events.
Cross-window operations (Orchestrator diff, find-references-all)
stay on impl Editor because they really do touch multiple
windows. They access them by id with explicit
self.windows.get(&id) — no accessor wrappers needed.
Why this is faster than the macro / per-site refactor.
Both fix-the-symptoms approaches (macros, pre-extracted
&mut Window at every conflict site) leave the methods on
impl Editor and pay borrow-checker tax at every call.
Moving the methods to impl Window fixes the cause — the
methods belong there architecturally, and the borrow problem
goes away because self is the right type. Each method
relocation is mechanical (cut/paste + parameter changes for
Editor-global needs), and the result is shorter, clearer
code at every call site.
0d — Move terminal manager + terminal-buffer indexes onto
Session. terminal_manager, terminal_buffers,
terminal_backing_files all become per-window. PTY threads
are owned by the window that created them. closeWindow
joins those threads. terminal_id allocation stays global
(Arc<AtomicUsize> or similar) for plugin-API stability.
Terminal action handlers move to impl Window as part of 0d
— same recipe as 0c.
0e — Move event_logs (undo per buffer) onto Session.
Shipped. Undo logs followed the buffer storage onto Window
— each window owns its event-log map alongside its buffers, and
the existing undo/redo handlers route through
active_window_mut().event_logs (or split-borrow __win.event_logs
when paired with split-tree access in the same handler).
0f — Move position_history, bookmarks, and similar
session-scoped per-buffer metadata onto Session. Shipped.
Window now owns its back/forward navigation stack
(position_history plus the in_navigation and
suppress_position_history_once companion flags) and its
single-char bookmark register set (bookmarks). Switching
windows preserves each window's nav history and bookmarks
intact — the post-switch user sees their previous back-stack,
not the other window's. Workspace serialization captures the
active window's bookmarks; restore re-creates them on the
active window.
0g — Audit commands. Shipped. Audit pass over the codebase after 0a–0f confirms:
self.<moved-field> references on impl Editor
for any field that 0a–0f moved (buffers, event_logs,
terminal_*, file_mod_times, file_explorer, lsp,
panel_ids, splits, position_history,
in_navigation, suppress_position_history_once,
bookmarks). The few hits in bookmarks.rs are inside
BookmarkState::self.bookmarks (its internal HashMap
field, not the editor's).self.windows.get(&id) / get_mut(&id) calls
exist only where they should: the plugin API surface
(plugin_dispatch.rs, plugin_commands.rs — createWindow,
setWindowState, plugin-driven cross-window dispatch),
window lifecycle (window_actions.rs —
set_active_window, close_window, first-dive seeding),
and a couple of split-borrow inline patterns in handlers
that need disjoint &mut __win.X and &mut __win.Y
sub-borrows in the same call.active_window() / active_window_mut() accessors or
through inline self.windows.get_mut(&self.active_window)
borrows where the borrow checker needs sub-field splitting
(the established pattern from 0c–0f). No method on
impl Editor reaches around the routing.Outstanding debt (do not park indefinitely): ~33 handler
sites still use the inline
let __win = self.windows.get_mut(&self.active_window).expect(...)
impl Window
method. Each one mixes window-scoped state mutation with at
least one editor-global concern (status_message,
plugin_manager, buffer_metadata, composite_buffers,
config reads, mouse / drag UI state, etc.) which is why they
weren't moved during 0c–0f's bulk migration. They should
still be moved. The migration recipe — pull the body into a
Window method, return events / status payloads as values,
have the Editor caller dispatch them after the call — applies
to all of them; it just takes a per-site judgement on what
shape the return value should be.This is genuinely the right cleanup, not a "could be nicer" nice-to-have: every inline-borrow site is a permanent "active-only" lock on a handler that should work against any window for Orchestrator's cross-window orchestration to compose cleanly. Leaving them inline means Orchestrator's diff / find-references-all features will hit the same workarounds we just paid down. Strong preference: drain this list as part of the work that introduces the first cross-window consumer.
0h — Refactor render to Window::render. Shipped, in
the form that turns out to matter. The concrete pain point
0h was meant to solve was the preview path's splits.take()
render_session_preview_into_rect: it
took the previewed window's split stash out, called the
shared SplitRenderer::render_content against it, then
swapped it back. After 0a–0g moved every per-window field
onto Window, the preview path can split-borrow the
previewed window's buffers, event_logs, and splits
sub-fields directly under one &mut Window borrow — the
take/restore is gone, and the foreign-window preview is now
literally the same SplitRenderer::render_content call
against a different Window with a sub-rect, matching the
design's Primitive #1.Editor::render onto impl Window would require
splitting the function's chrome (status bar, prompt,
popups, menus, mouse hit-testing, animations) from its
content (split tree, panels). The chrome is editor-global
and lives on Editor legitimately; the content rendering
already routes through the shared SplitRenderer::render_content
helper that both paths call. Moving Editor::render
itself would just relocate the chrome plumbing onto
Window, which is the wrong direction. Park unless a
consumer (e.g. a multi-window split-screen layout) needs
to render two windows side-by-side in one frame.0i — Remove the warm-swap helpers and Orchestrator's reliance
on them. Shipped. The swap body in set_active_window
went away in 0b (it's a pointer write plus first-dive seed
allocation). What remained for 0i, now done:
attach_buffer_to_active_window no-op shim and
removed every call site (~14 locations across virtual
buffers, terminal, file open, macros, composite buffers,
buffer management). Buffer inserts go directly into
Window.buffers — either as self.buffers.insert(...) from
impl Window methods, or as self.windows.get_mut(&id)?. buffers.insert(...) from the few impl Editor paths that
install state into a specific target window.editor_accessors.rs that
still framed the assertions as "warm-swap restored the
stash" — the assertions remain correct, but they're now
just "the active window owns this state directly."detach_buffer_from_all_windows is kept because it serves
a real purpose (find-and-remove-by-id across windows when
the caller doesn't know which window owns the buffer); its
doc-comment is updated to note that with each buffer in at
most one window, it succeeds at most once.The splits field's Option wrapper remains: a
never-activated window still has no layout until first
dive, and seeding a layout at Window::new time would
require allocating a fresh BufferId from the editor-scoped
allocator before the window is wired into Editor.windows.
The Option accurately models "no layout allocated yet,"
not "stash currently swapped out," so it stays.
0j — Move grouped_subtrees + composite buffers + composite
view states onto Window. Shipped. Three more per-window
fields that were still on Editor for legacy reasons: buffer-
group panel subtrees and the per-split composite view
state. Each belongs to the window that opened the panel.
0k — LSP routing onto per-window async channels +
handlers on impl Window. Phases 1–3 shipped, phase 4
incremental. The LSP subsystem and chrome state are now
correctly placed:
Phase 1 (shipped): Each Window owns its own
AsyncBridge channel. LspManager.set_runtime and the
per-window TerminalManager.set_async_bridge route
responses through the window's bridge. The editor's main
loop drains every window's bridge in addition to the
editor-global one. Cleanup on closeWindow is automatic
(the receiver drops, senders error).
Phase 2 (shipped): All 23 LSP request-tracking maps
next_lsp_request_id (per-window counter) + response
data caches (completion_items, dabbrev_state, code-
action attribution, etc.) live on Window. Per-window
request-id namespaces work because each window's
LspManager talks to its own server connections.Phase 3 (shipped): Chrome (status_message,
plugin_status_message, prompt) is per-window. Only
the active window's chrome renders, so background-window
status / prompts are naturally invisible.
Phase 4 (incremental): Handler bodies move to
impl Window where they're purely window-state
mutations. So far: Window::handle_lsp_inlay_hints and
Window::apply_folding_ranges_response (used by the
Editor wrapper that orchestrates the URI-keyed
stored_folding_ranges editor-global map). The
remaining ~20 LSP handlers stay on impl Editor because
they mix window-state mutation with editor-global
orchestration that doesn't trivially split (theme reads,
plugin hooks, URI-keyed stored maps, server-name
attribution, multi-window reopen sweeps). They access
per-window state through active_window() /
active_window_mut() accessors, which is correct
routing — the move to impl Window is purely
about who owns the method body, and is parked for
individual handlers as a follow-up. The architectural
goal (per-window state + per-window channels) is met.
What stays on Editor. Genuinely editor-global
subsystems: plugin_manager (one runtime), the plugin
async channel (callback delivery), the file-open dialog
state, terminal-input key_translator, render-loop
chrome glue, chrome_layout, theme, grammar registry,
config, the URI-keyed stored_diagnostics /
stored_folding_ranges (URIs can map to buffers in any
window), and the global LSP-message log
(lsp_window_messages, lsp_log_messages,
lsp_server_statuses, lsp_progress). The plugin
manager is &PluginManager (its run_hook already takes
&self) so window handlers fire hooks via a parameter
without leaking the editor reference into Window's
stored state.
Architectural test: if a Window handler body needs to
know its own WindowId to call into editor-level logic,
that's a sign the editor-level logic is in the wrong
place (it should be on impl Window or the data should
be per-window). The user has flagged this — if
implementation surfaces such a case, surface it before
adding the parameter.
After Step 0 lands:
render_session.Session::drop — buffers,
PTYs, undo logs, watchers all evicted.Estimated effort: 5–10× the work that has gone into the branch so far. Multiple commits per sub-step. The mechanical churn is large but the per-commit risk is bounded; tests catch regressions immediately because they exercise the active-session path.
The branch shipped the warm-swap interim (Steps 1a–1h, 2, 3, 5, 6) and Steps 0a–0l of the window-model migration. Where we are right now:
Shipped (compiles cleanly; 19 sessions e2e tests pass):
cached_layout split into Editor.chrome_layout
and Window.layout_cache.Window field; set_active_window is a pointer write.Editor.buffers, the terminal subsystem,
event_logs, position_history + bookmarks, the audit
pass, render preview path, and warm-swap-shim cleanup all
shipped.grouped_subtrees, composite buffers, and
composite view states moved onto Window.status_message, prompt) (phase 3), and incremental
handler relocations onto impl Window (phase 4).buffer_metadata (the per-buffer language /
file_uri / lsp_opened_with / preview / read-only metadata
store) moved onto Window alongside Window.buffers. Two
windows that open the same file now have independent
metadata, matching the parallel-agents promise. Field
migration was mechanical (~130 call sites bulk-rewritten);
~10 borrow-conflict sites resolved with inline __win = self.windows.get_mut(&self.active_window) extractions that
split-borrow buffer_metadata alongside whatever other
window field the caller needed.The "switching windows feels like swapping the entire Fresh
state" promise from § Motivation is true by construction:
every per-session subsystem the user can see (file tree,
ignore matcher, LSPs, watchers, split layout, dock occupancy,
mtime cache, buffers, buffer metadata, terminal PTYs, undo
logs, navigation history, bookmarks, composite panels, async
LSP bridge, chrome) lives directly on Window. Switching is
a single active_window = id pointer write.
Step 0m — Tier-2-and-onward field migration + canonical handler
moves. Every Tier-2 field from the audit at the top of this doc
has shipped onto Window plus several beyond:
Foundation infrastructure: WindowResources bundle of editor-
global Arc-shared services (config, grammar/theme/keybinding/
command registries, fs_manager, authority, time_source,
dir_context), BufferIdAllocator (Arc<AtomicUsize> shared).
A speculative WindowControlEvent + dispatch_to_active_window
pair was originally added for cross-window orchestration from
impl Window handlers, but the migrations that shipped all use
the simpler self.active_window_mut().method(...) pattern
instead, so the unused dispatcher was removed.
Per-window fields moved: preview, terminal_mode,
terminal_mode_resume, seen_byte_ranges,
previous_viewports, same_buffer_scroll_sync,
interactive_replace_state, scroll_sync_manager,
file_explorer_visible/sync_in_progress/width/side,
pending_file_explorer_show_*, file_explorer_decorations,
file_explorer_decoration_cache, hover, search_state,
search_namespace, pending_search_range,
live_grep_last_state, overlay_preview_state,
auto_revert_enabled, file_rapid_change_counts,
goto_line_preview, pending_async_prompt_callback,
pending_quit_unnamed_save, search_case_sensitive/whole_word/ use_regex/confirm_each, scheduled_diagnostic_pull,
scheduled_inlay_hints_request, user_dismissed_lsp_languages,
editor_mode, prompt_histories, pending_close_buffer.
Canonical Window helpers: active_buffer, active_state/_mut,
active_cursors/_mut, active_event_log/_mut,
effective_active_pair, effective_active_split,
set_status_message/clear_status_message, config(),
authority(), alloc_buffer_id().
Methods moved to impl Window: composite-buffer query/mutate
helpers (is_composite_buffer, get_composite/_mut,
set_composite_alignment, close_composite_buffer,
composite_focus_next/prev); buffer-group helpers
(grouped_split_ratio/set_*, is_non_scrollable_buffer);
bookmark methods (set_bookmark, clear_bookmark,
list_bookmarks); preview-tab orchestrators
(promote_buffer_from_preview, promote_active_buffer_from_preview,
promote_current_preview, promote_preview_if_not_in_split,
is_buffer_preview, current_preview); terminal-buffer queries
(is_terminal_buffer, get_terminal_id); pane-buffer invariant
updater (set_pane_buffer); tab-bar scroll
(ensure_active_tab_visible); search overlay clears
(clear_search_highlights, clear_search_overlays,
update_search_highlights); 6 file-explorer leaf delegators
(extend_selection_up/down, toggle_select, select_all,
search_push_char, search_pop_char).
Step 0n — apply_event_to_active_buffer decomposition. The
central event-application pipeline that every buffer-mutating
handler routes through has been hollowed out: each step it used to
inline now lives on impl Window, with the Editor-side method now a
thin orchestrator. Helpers moved in this phase:
schedule_folding_ranges_refresh /
schedule_semantic_tokens_full_refresh — debounced LSP refresh
schedulers, pure per-window state.invalidate_layouts_for_buffer — split-tree walk for
layout/view-transform invalidation.collect_lsp_changes / calculate_event_line_info — pre-edit
LSP position translation and line-shift calculation, both
read-only window-state operations.adjust_other_split_cursors_for_event — cross-split cursor
adjustment within a single window.handle_scroll_event / handle_set_viewport_event /
handle_recenter_event — viewport handlers for view-only events
that bypass the buffer entirely.send_lsp_changes_for_buffer — incremental didChange /
didOpen dispatch with debounce-pull rescheduling.Outstanding work. What remains on impl Editor falls into two
categories:
Genuinely cross-window or editor-global — workspace
serialization, lifecycle (quit/restart/detach), plugin-runtime
dispatch, theme/config reload, top-level render & mouse
routing, recovery service orchestration, cross-window helpers
like detach_buffer_from_all_windows. These stay on Editor by
design.
trigger_plugin_hooks_for_event and the apex
apply_event_to_active_buffer orchestrator. The hook-firing
path calls update_plugin_state_snapshot, which iterates all
windows for the session list and reads true editor-global state
(clipboard, working_dir, authority, stored diagnostics/folding
ranges, config). That makes it cross-window by definition; it
stays on Editor. The apex apply_event_to_active_buffer
remains as a small Editor coordinator that delegates each step
to Window and then fires hooks — moving it fully to impl Window would require either splitting the hook trigger out as
a callback (Editor caller invokes it after Window pre-work) or
inverting the snapshot-update direction. Neither is currently
blocking Orchestrator work; the apex is now ~30 lines of Editor
coordination over Window primitives, down from ~150.
The Step 0g inline-borrow-debt drain (~33 handler sites with
__win = self.windows.get_mut(&self.active_window) boilerplate)
shrinks naturally as more handlers move to impl Window. None
of this blocks Orchestrator MVP — the core architectural promise is
delivered.
Session struct, single forced session [interim — superseded by Step 0]Status: landed on the branch as the warm-swap interim. Step 0 above replaces this with the window model.
Session with the fields above.editor.active_session().root without changing behavior.Editor; add the
Session.buffers: HashSet<BufferId> membership field.getCwd, etc.) read from the active session.This was the bulk of the warm-swap refactor and the riskiest step. It is purely a rearrangement: behavior is identical to today's editor. Step 0 (window model) inverts this step's "buffer storage stays on Editor" choice and lifts the warm-swap stashes into per-session live fields.
[MVP]createSession, setActiveSession, closeSession.§ Dive).editor.listSessions() / activeSession() plugin APIs and
the active_session_changed event.createSession + setActiveSession
exercises the swap end-to-end.[MVP]Smallest core change. Add terminal_output / terminal_exit events
at the two async_dispatch.rs arms.
watchPath plugin API [v1.1+]Wrap notify crate. Surface path_changed event. Required for the
collision radar.
[MVP — already implemented in core; v1.1+ for session and persistence]setGlobalState/getGlobalState are already in core at the time
this design was written, with per-plugin namespacing and
roundtrip/isolation/delete tests. No further work needed for MVP.
setSessionState/getSessionState (per-session scope) and
cross-restart persistence to .fresh/ are implemented.
Persistence flushes:
<wd>/.fresh/sessions.json — { active, next_id, sessions[] }
with each session's id, label, root, and per-session
plugin_state.<wd>/.fresh/state/<plugin>.json — one file per plugin
with that plugin's setGlobalState(...) map.
Reload runs after authority install and before plugins load,
so plugin on-load handlers see the previous run's
getGlobalState(...) values. Persisted sessions reload as
inert shells — first dive re-warms exactly like a freshly
created session.[MVP — minimum viable plugin]A first-party plugin shipping in crates/fresh-editor/plugins/orchestrator/.
Drives the whole feature. Uses only the APIs introduced above. The
MVP plugin implements Screens 1–4 with the reduced column set and
state set defined in § MVP scope.
[v1.1+]A plugin-callable openDiffView with arbitrary content (not just
two refs in a git repo). MVP doesn't need this because the existing
review-diff feature covers the only Orchestrator diff use case
(worktree vs base).
[implemented]Implemented as cold rehydration: persisted sessions load as
inert shells (no LSP, no warm split tree). The first dive
into a previously persisted session re-warms it the same way
a freshly created session is warmed. Storage lives at
<wd>/.fresh/sessions.json and <wd>/.fresh/state/<plugin>.json
(see Step 5 above).
Hot/lazy rehydration of inactive sessions (warm LSPs at boot, warm split layout, warm file watchers) is the v1.1+ extension — useful when the user has many always-on agents and wants zero-latency dive-back at startup.
Step 1 is invasive. Every place that today reads cwd or
project-root state must be re-routed through
editor.active_session(). Compiler enforcement is the mitigation:
move the field off Editor and onto Session early so the
compiler errors point at every call site.
LSP teardown on closeSession. Today LSPs mostly key on
project root, but the manager has assumed-singleton ergonomics in
places. Audit services/lsp/manager.rs before Step 2.
Buffer-to-session attribution edge cases. A buffer opened from a path that lies under no session's root: which session owns it? Proposal: editor-global, attached to no session, opens in a "scratch" surface. Surfaced as a separate concept so it doesn't muddy session semantics.
Plugin reload during a session swap. If the plugin runtime reloads mid-swap, in-flight events are lost. Mitigation: drain the plugin event queue before the swap commits.
Lazy LSP startup may surprise users. First-time activation of
an inactive session has the usual "rust-analyzer is indexing"
pause. Document explicitly. A pre-warm hint
(editor.prewarmSession(id)) could be added later if needed.
Cross-session cursor jumps. "Go to definition" landing in a
file under a different session's root is undefined under this
design. Proposal: open the target buffer in the current session
(attaching the buffer id to its buffers set) rather than
switching sessions — the alternative is a surprise dive.
Memory growth with many warm sessions. N rust-analyzers at
500MB+ each adds up. This is intrinsic to "warm LSPs across
sessions" and acceptable per § Trade-off discussion. A future
editor.suspendSession(id) (kill LSPs, keep buffer text) is a
reasonable escape hatch but not part of v1.
(Carried over from the design conversation that produced this doc; recorded here so the rationale is reviewable.)
Three architectures were considered:
cwd and rebuild file-tree / LSP /
ignore in place. Smallest core change but most fragile UX: every
subsystem rebuild is a separate event the user can see seams in.Session in core. This document. Larger core
change but the swap is atomic and inactive sessions are warm.(C) was rejected because crash isolation is not a requirement and the per-process overhead, while not free, is small relative to LSP cost. (A) was rejected because "Orchestrator lives above sessions" is a load-bearing UX claim that (A) cannot honor — under (A), Orchestrator is the editor reaching into its own root, and every glitch in the in-place rebuild is a Orchestrator glitch. (B) is the architecture that makes the UX claim true by construction.
Should sessions persist across restarts by default? Two schools: VS Code reopens last workspace; vim opens fresh. Default to "rehydrate session list, do not auto-dive into one of them" for now; user lands in a scratch session and picks. Configurable.
Maximum sessions. N=20 worktrees with N rust-analyzers will melt a laptop. A soft cap (configurable, default 8?) with a warning would be friendly. Out of scope for the core abstraction; can be enforced in the Orchestrator plugin.
Session-aware command palette. Should the palette show commands from all sessions, or just the active one? Default: active only, since commands tend to be buffer-scoped.
Cross-session search. Quick-open today scopes to cwd; under sessions, default is active session's root. A "search across all sessions" mode is desirable but post-v1.
Authority composition. A future remote session would carry an
authority alongside its root. The fields nest cleanly
(Session.authority: AuthorityHandle), but the spawning/teardown
sequence interacts with AUTHORITY_DESIGN.md and is deferred.
KILLED retention policy. The Control Room mockup shows
killed rows lingering as tombstones (e.g. a 19-minute-old row).
Two design choices to settle:
k again,
or auto-drop after N minutes? Tentative default: linger
until acknowledged. Tombstones are evidence; auto-drop loses
it.SYNCING semantics — what counts as a sync? Three candidates:
git pull typed by hand). Probably no — Orchestrator
can't reliably detect these.aider to finish writing a batch of files before recomputing
diff stats). Maybe — would smooth the diff readout but adds
complexity. Defer until the diff readout is shown to flicker.shell agent state machine. Plain shell sessions never hit
AWAITING (no recognised prompt pattern). Should they show a
distinct state, or just stay in RUNNING forever until exit?
Tentative: stay in RUNNING. Users who want explicit "the shell
is idle at a prompt" indication can use a wrapper script.
This is not a spec — the Orchestrator plugin gets its own design doc. Included here only to illustrate that the API surface above is sufficient.
const sessions = new Map<SessionId, AgentSession>();
const collisions = new Map<string, Set<SessionId>>();
editor.registerCommand("orchestrator.new", async () => {
const branch = await editor.startPrompt("Branch");
const cmd = await editor.startPrompt("Agent command");
const wt = await git.worktreeAdd(branch);
const id = await editor.createSession({ root: wt.path, label: branch });
const term = await editor.createTerminal({ sessionId: id, cwd: wt.path });
editor.sendTerminalInput(term.terminalId, cmd + "\n");
await editor.watchPath(wt.path, { recursive: true, sessionId: id });
sessions.set(id, { id, branch, terminal: term, state: "running" });
rerenderControlRoom();
});
editor.registerCommand("orchestrator.dive", () => {
editor.setActiveSession(selectedSessionId);
// file tree, LSP, quick-open, splits all retarget. Orchestrator state untouched.
});
editor.on("terminal_output", e => stateMachine.observe(e));
editor.on("terminal_exit", e => stateMachine.observe(e));
editor.on("path_changed", e => collisionMatrix.observe(e));
editor.on("active_session_changed", () => rerenderControlRoom());
The plugin's sessions map and collisions map live in the plugin
module's top-level scope, which under this design is editor-global
and is not affected by setActiveSession. That is the property the
PRD asks for.