docs/internal/orchestrator-sessions.md
Purpose: explain how Fresh manages many concurrent editor/agent sessions (the Orchestrator "dock"), how each session's state is persisted and restored across restarts, and the Live/Dormant lifecycle — separating what is shipped from the large body of forward-looking design docs.
This doc is the implementation-anchored counterpart to the design files listed under "Superseded / aspirational docs" at the end. Where those describe phased roadmaps, this records what the code actually does.
The Orchestrator lets one Fresh process hold several independent editor sessions — typically one per git worktree, each often running a coding agent (Claude, aider) in a terminal — and switch between them instantly via a left-column dock or a modal Open dialog.
There are three names for the same thing at three layers, and the disambiguation matters:
Window / WindowId — the editor-internal type. Modelled on VS Code
"windows" specifically to avoid colliding with Fresh's two pre-existing
"session" concepts (workspace-recovery sessions and config-layer sessions).PersistedWindow — the on-disk/in-memory shape produced by boot discovery.Throughout this doc "session" = Window. Do not confuse it with the
client/server session-persistence feature (detach/reattach, design-decisions.md
#8) or fresh session attach CLI subcommands (#3) — those are unrelated
subsystems that happen to share the word.
The Orchestrator itself is a TypeScript plugin. It owns all dock/dialog UI
and the new-session/worktree/agent-resume logic; the Rust core provides
per-Window state ownership, persistence, and a small set of host APIs
(createWindowWithTerminal, setWindowState, authority attach). There is no
SessionLifecycle enum in Rust — lifecycle is expressed implicitly through the
data structures below.
Note: the split-rendering "orchestration" plumbing in the rendering layer is not session UI. It carries shared selection/decoration context. The actual dock UI is in the plugin.
The defining architectural choice (orchestrator-sessions-design.md, "Step 0"):
each Window owns its state outright — buffers, splits, terminals, LSP,
file explorer, position history, and its Authority — rather than keeping
editor-global pools with per-session membership pointers.
Consequences that fall out by construction:
active_window = id), not a
"warm-swap" that stashes and restores N fields. The class of "forgot to swap
field X back" bugs is eliminated.Alternatives considered and rejected (sessions-design.md trade-off section):
Window in core (chosen): larger core migration, atomic
swap, inactive sessions stay warm in memory.The per-subsystem move off Editor onto Window (buffers, LSP, terminal
subsystem, event logs, file explorer, position history, …) is shipped. The
earlier interim "manual switch between two sessions" steps in the design doc are
superseded by this model.
There is no central session-list file. A session is a directory (one session per canonical dir), and the registry is the per-directory workspace cache:
workspaces/ — one file
per directory ever opened, keyed on the encoded canonical root. Each carries
that window's identity (label, session_plugin_state, authority_spec)
plus its buffer/split layout.orchestrator/state/ —
editor-wide plugin global state, one file per plugin (not per-project).Path encoding is reversible percent-encoding. State lives under the platform
data dir (e.g. $XDG_DATA_HOME/fresh/), never the working tree —
regression-tested.
This is the v2 design. It deliberately replaced an earlier global
windows.json (which itself replaced per-cwd windows.json files). The
motivation: a per-cwd store made "yesterday's directories" bleed into today; a
workspace-keyed registry gives one session per dir for free — each file is keyed
on the canonical root, so discovery yields at most one window per dir with no
post-hoc dedup.
Workspace file shapeThe Workspace record is the per-dir file. Beyond the editor-state fields
(split layout, per-split view state, cursors/scroll, bookmarks, file explorer,
histories, folds, terminals), three fields make it the session record:
label — optional display name; defaults to the root basename. Lives here
precisely because windows.json was dropped.session_plugin_state — the window's own per-session plugin state, carrying
the Orchestrator's project_path / shared_worktree keys. Distinct from
plugin_global_state (editor-wide, separate store).authority_spec (SessionAuthoritySpec) — how to rebuild/reconnect this
session's backend on restore; the Local variant is skip-serialized so
ordinary sessions carry no redundant marker. See §6.Writes are atomic (temp file + fsync + rename) so a crash never leaves a torn file. The schema is versioned, with a "version too new" guard on load.
Boot discovery runs from the editor factory before an Editor exists. It:
PersistedWindow per survivor.GC is careful: a local session's file is deleted only on a definitive "this
dir is gone" answer (a not-found result, or a confirmed "not a directory").
Any ambiguous error (permission, IO, unmounted FS) keeps the file rather than
irreversibly losing the session. Remote sessions are never GC'd against the
local filesystem — their root is a path on the remote host, so checking it
locally would silently delete every remote session's file on next boot. This is
regression-tested.
Idempotent, best-effort, and reversible (every retired file keeps a .bak):
windows.json files are folded into one v2 envelope, decoding
project_path from the slug and renumbering id collisions.windows.json's label / session_plugin_state are backfilled
into the matching per-dir workspace files, then it is retired. After this the
workspace cache is the sole registry.The lifecycle is a set of implicit states realized by three mechanisms. There is no single enum.
At boot only the foreground window is restored eagerly. Which one that is, is decided by the launch cwd, not by "last used globally":
root == cwd (highest id).Matching is on root, not project_path — a worktree session carries
project_path == <parent repo> but root == <worktree>, and matching on
project_path resurrected the wrong window. This is regression-tested.
Every other discovered session comes back as an inert shell: a Window
with only an empty seed layout, no splits/LSP, recorded in a pending-materialize
set. It is restored from disk lazily on first dive/preview, which:
plugin_global_state around the per-window restore
so a background window's stale copy can't clobber the live one.The "warm" in-memory layout is intentionally not persisted across restarts — re-warming on first dive (a fast lazy load versus a far slower log replay) is fast enough.
The quit-time counterpart deliberately skips windows still pending materialization: they hold only their empty seed, while the on-disk file is authoritative — saving the seed would clobber the real workspace.
For remote sessions, persistence introduces an explicit Live/Dormant split (PER_SESSION_BACKENDS_DESIGN.md, shipped for local/container/SSH/Kubernetes):
Authority routes every filesystem/spawn primitive.SessionAuthoritySpec is known but not connected. The window
runs on a local placeholder authority (instantly usable, never holds a dead
handle) and is presented as the real backend, disconnected. At boot,
unmaterialized remote sessions are carried in a separate dormant-remote
collection and keep their authority_spec so a later save doesn't downgrade
them to local.Reconnect happens on activate (switch or explicit), not at boot — SSH/Kubernetes
via core connect functions, containers via a session_reattach_requested plugin
hook. Agent terminals re-run after reconnect, inside the real backend, never on
the placeholder.
Orthogonal to session lifecycle: each restored terminal comes back in Live mode so focusing its tab re-enters a live PTY rather than read-only scrollback; Ctrl+Space flips to Scrollback. The PTY itself is ephemeral and re-spawned; only the backing file (scrollback + screen snapshot) is persisted (design-decisions.md #18).
The dock additionally shows a coarse agent state inferred from terminal output (e.g. working/idle, plus richer running/awaiting/ready/errored glyphs derived in the plugin). This is display-only and not part of the persistence model.
All UI is plugin-side (orchestrator.ts). Shipped surfaces:
orchestrator_dock_toggle. Live-switch
with ↑/↓ rebuilds the active window in place.OPEN_MODE = "orchestrator-open") — modal two-pane picker
(session list + preview), with filter, project scope toggle (Alt+P),
"show all worktrees" toggle (Alt+T), "hide trivial" toggle, and multi-select
for bulk lifecycle actions.NEW_SESSION_MODE = "orchestrator-new-form") — see §7.Globally-listed sessions confused users by combining unrelated projects. The
shipped rule (orchestrator-open-dialog-and-lifecycle.md): scope to the current
project by default, go global on demand (Alt+P). Current-project rows sort
first; a project column appears only for cross-project rows. Persisted sessions
are never auto-activated — any "Resume last?" must be explicit and
dismissible. project_path exists purely as this grouping metadata; it is not
used to choose the boot window (§4.1).
Rich session display was the forcing function for a host change (orchestrator-pr-pill-wireframes.md). Three options were weighed:
list widget gained
multi-row item specs so an item can span several rows; selection background
spans all rows; the hit area covers them; visible-row counting is in pills, not
lines.PR data is fetched by running gh pr view <branch> --json per visible session,
throttled with a short TTL and cached on the session; missing gh or no PR
renders a dim fallback.
Rows can be on-disk worktrees found via git worktree list that have no live
window yet. They carry a synthetic negative id and no terminal id; diving
attaches a new session to that root, and the row is dropped from the
in-memory session map the moment a real window opens there.
Shipped via a process-group signal API (editor.signalTerminal, SIGTERM→SIGKILL
on the pgid):
git worktree move to an .archived/ graveyard; recoverable via
Unarchive. No confirm.git worktree remove + rmdir; not recoverable, confirm
required.Cross-machine recovery (archived sessions synced via a
refs/heads/<user>/fresh-sessions git branch) is designed but deferred
(v1.1+).
The core loop (work → list → switch → work) is mechanically solid and per-session state persists. Focus/input routing has open gaps:
KeyContext chrome, like the file explorer) is deferred.Each Window owns its own Authority outright — it is not Clone and is
owned by exactly one window, so backend isolation is enforced by the type system.
The persisted recipe is SessionAuthoritySpec:
Local — host-local; the default and back-compat (skip-serialized).Plugin — devcontainer/docker; reconnectable only by the installing plugin.RemoteAgent — born-attached SSH / Kubernetes; reconnectable by core. The
transport spec covers SSH (user, host, port, identity file, remote path, …) and
kubectl-exec (context, namespace, pod, container, workspace).Terminal/agent argv is composed through the active authority's command wrapper
(direct / prefix for docker exec / SSH with a cd hop / kube), so an agent
always runs inside the session's backend, not on the host. Per-session
trust and env are likewise move-only session-scope handles consumed into
the authority — no shared copy, sharing rejected at compile time. All four phases
(SessionProfile/Dormant restore, reconnect-on-activate, per-session trust,
per-session env) are shipped; warm background remote sessions surviving
restart with keepalives are deferred (v1.1+).
The New Session form fields (Phases 1–5 shipped):
git worktree add under the repo; the new session's root is the
fresh worktree. Unchecked → root is the project path itself
(shared_worktree = true), which also covers non-git directories and
multi-session-sharing-one-tree.orchestrator/.Creation is atomic via the host API:
editor.createWindowWithTerminal({ root, label, cwd: root,
command: launchArgv, resume: resumeArgv });
editor.setWindowState("project_path", effectiveProjectPath);
editor.setWindowState("shared_worktree", sharedWorktree);
createWindowWithTerminal is dispatched into core, which enforces
one-session-per-canonical-directory (reuses an existing window if root is
already open) and persists the command / resume argv onto the terminal. The
two setWindowState keys land in the window's session_plugin_state and are
read back at boot.
Type-aware New Session forms for SSH/Kubernetes backends (NEW_SESSION_DIALOG_WIREFRAMES.md, segmented-tab "Option A") are designed, not shipped — today's form is the local worktree/folder flow.
Goal: a restored session rejoins its agent conversation instead of coming back as a bare shell or re-running the launch command. No agent-specific logic lives in Rust; the core only substitutes argv and runs it through the authority.
The plugin holds a user-overridable agent registry with two strategies:
claude … --session-id <uuid>) and resume with it
(claude --resume <uuid>); --continue as the cwd-latest fallback. Trusted by
construction — no output capture, no parsing.aider --restore-chat-history), ambiguity broken by per-session config
isolation.Launch resolution returns a launch argv plus an optional resume argv: the launch
carries any minted --session-id, the resume is the resolved rejoin argv. The
resume spec is persisted separately from the launch command, as an
agent-resume argv on the terminal's serialized workspace. On restore the order is:
agent-resume → command → plain shell → backing-file, gated by a
terminal.resume_agents master switch (default on). Resume is deferred to
first dive, so reopening the editor doesn't spend tokens on sessions never
looked at. Broader registries, remote-backend resume, and per-resume confirmation
policy are deferred.
The server daemon module is unrelated to the Orchestrator. It is a thin background-server detach helper: PID-file read/write, daemonize (double-fork
setsid on Unix; unsupported on Windows), detached server spawn, and a
process-liveness check. It backs the client/server detach/reattach feature
(design-decisions.md #8), where the session_id is a socket/PID identifier —
not an Orchestrator window. The daemon does not switch or multiplex
Orchestrator sessions; the editor process manages windows itself.Implemented (shipped):
.bak safety.Planned / aspirational (in design docs, not in code):
refs/heads/<user>/fresh-sessions branch.KeyContext chrome (resolves focus gaps F1/F2).These were mined to write this doc. Most are phased design records, not status reports — read them for rationale, treat their later phases as aspirational unless confirmed against code here.
orchestrator-sessions-design.md — the session-as-Window model and
Step-0 migration. Mostly shipped; its early "manual switch" steps are
superseded by the materialization model in §4.orchestrator-new-session-base-path.md — arbitrary project paths, worktree
toggle, input history, global persistence. Phases 1–5 shipped; 6–7 deferred.orchestrator-open-dialog-and-lifecycle.md — picker UX, Stop/Archive/Delete,
project scoping. Phases 1–5 shipped; cross-machine recovery deferred.orchestrator-pr-pill-wireframes.md — two-line PR pill (Option B). Shipped.orchestrator-dock-gaps.md — open dock UX gaps + the "dock as chrome" (P1) /
layer-compositor (P2) principles. Phase 1 shipped; P1/P2 aspirational.orchestrator-bringup-dataflow-review.md — boot dataflow fixes.
§2–5 landed (pick-on-root, non-colliding ids, explorer at window root); unify-
restore-path §6 still TODO.ORCHESTRATOR_DOCK_NNG_FINDINGS.md — usability findings F1–F8. Test results;
several fixes shipped, F1/F2/F3 open.ORCHESTRATOR_DOCK_NNG_USABILITY_GUIDE.md, dock-ux-test-plan.md — test
protocol / checklist. Methodology, not design.agent-resume-design.md — provision/continue strategies. Phases 0–3 shipped.PER_SESSION_BACKENDS_DESIGN.md — SessionProfile, Live/Dormant, per-session
trust/env. Phases 1–4 shipped; warm-background deferred. (Also see
AUTHORITY_DESIGN.md, K8S_AUTHORITY_DESIGN.md.)NEW_SESSION_DIALOG_WIREFRAMES.md — type-aware SSH/Kube form. Designed, not
shipped.design-decisions.md #3 (CLI subcommands) and #8 (dual-socket client/server
persistence) — shipped, but a different "session" subsystem; see §1/§9.