docs/internal/orchestrator-open-dialog-and-lifecycle.md
Status: Design Document Date: May 2026 Driving feature: A picker UX for browsing/operating on Orchestrator sessions, plus a richer session lifecycle (Stop / Archive / Delete) with cross-machine recovery.
The current Orchestrator: Open picker is built on the legacy
startPrompt infrastructure. It works for "list + filter +
pick one", but it can't host the action surface Orchestrator
needs:
Alt+D or similar to "kill the
highlighted session" without leaking the chord to global
bindings.+ New, × Kill highlighted) get filtered out
by the prompt's fuzzy matcher, so they vanish exactly when
the user has typed something they want to act on.Separately, the term "kill" is too coarse. Users want three distinct lifecycle operations:
Finally, when a session represents real work-in-progress, the user wants to recover it on a different machine — pulling a git branch should be enough to resume.
The new dialog is rendered through the existing
FloatingWidgetPanel infrastructure (the same primitive the
new-session form already uses), composed from text (filter
input), list (sessions), and labeledSection (chrome) widgets.
╭─ ORCHESTRATOR :: Sessions ────────────────────────────────────────────╮
│ ╭─ Filter ───────────────────────────────────────────────────────╮ │
│ │ [filter text ]│ │
│ ╰────────────────────────────────────────────────────────────────╯ │
│ ╭─ Sessions ─────────────╮ ╭─ [2] moshiko ──────────────────────╮ │
│ │ [1] ACT fresh │ │ Root: /home/noam/repos/fresh/ │ │
│ │ ▸ [2] RUN moshiko │ │ .fresh/orchestrator/moshiko │ │
│ │ [3] RUN session-1 │ │ Age: 3m State: RUN │ │
│ │ [4] RUN session-2 │ │ pgid: 12345 pids: 12345, 12387 │ │
│ │ │ │ ────────────────────────────────── │ │
│ │ │ │ Last terminal lines: │ │
│ │ │ │ $ make build │ │
│ │ │ │ compiling... │ │
│ │ │ │ ────────────────────────────────── │ │
│ │ │ │ ▸ Dive into session │ │
│ │ │ │ Stop processes (Alt+S) │ │
│ │ │ │ Archive (Alt+A) │ │
│ │ │ │ Delete permanently (Alt+D) │ │
│ ╰────────────────────────╯ ╰────────────────────────────────────╯ │
│ │
│ ↑↓ nav · Tab focus · Enter activate · Alt+N new · Esc close │
╰────────────────────────────────────────────────────────────────────╯
Delete chosenOnly irreversible actions confirm. Stop and Archive are
both recoverable (relaunch the agent; unarchive the session),
so they fire immediately.
╭─ ORCHESTRATOR :: Sessions ────────────────────────────────────────────╮
│ ╭─ Filter ───────────────────────────────────────────────────────╮ │
│ │ [filter text ]│ │
│ ╰────────────────────────────────────────────────────────────────╯ │
│ ╭─ Sessions ─────────────╮ ╭─ Confirm Delete ───────────────────╮ │
│ │ [1] ACT fresh │ │ │ │
│ │ ▸ [2] RUN moshiko │ │ Delete session [2] moshiko? │ │
│ │ [3] RUN session-1 │ │ │ │
│ │ [4] RUN session-2 │ │ This will: │ │
│ │ │ │ • stop all session processes │ │
│ │ │ │ • run `git worktree remove` │ │
│ │ │ │ • drop the session record │ │
│ │ │ │ │ │
│ │ │ │ Uncommitted changes will be lost. │ │
│ │ │ │ │ │
│ │ │ │ [ Confirm Delete ] [ Cancel ] │ │
│ ╰────────────────────────╯ ╰────────────────────────────────────╯ │
│ │
│ Tab focus · Enter activate · Esc cancel │
╰────────────────────────────────────────────────────────────────────╯
When the highlighted row is an archived session, Archive
becomes Unarchive and Stop is hidden (no live processes):
│ ▸ Dive into session │
│ Unarchive (Alt+A) │
│ Delete permanently (Alt+D) │
Diving into an archived session implicitly unarchives it first (can't activate a closed editor window).
| Action | Touches processes | Touches worktree | Touches editor session | Recoverable | Needs confirm |
|---|---|---|---|---|---|
| Dive | no | no | sets active | n/a | no |
| Stop | SIGTERM → SIGKILL the pgid | no | no | yes (relaunch the agent) | no |
| Archive | stops first | git worktree move to .archived/ graveyard | closeWindow | yes (Unarchive) | no |
| Unarchive | no | git worktree move back to active path | createWindow | yes (Archive again) | no |
| Delete | stops first | git worktree remove + rmdir | closeWindow | no | yes |
| New | spawns | creates | createWindow | n/a | no |
Tab cycles filter → list → Dive → Stop → Archive → Delete
(skipping Stop for archived rows, swapping Archive →
Unarchive).↑ / ↓ on the focused filter input forwards to the list
(smart-key tweak so the user can both type and navigate
without leaving the filter).Enter activates whichever element has focus. On a focused
list row, Enter dives. On a focused button, Enter fires
the button's action.Esc closes the dialog (and cancels confirmation when one
is open).Alt+S / Alt+A / Alt+D / Alt+N are chord shortcuts.
See Keybinding integration for
how they're registered and rendered cross-platform.row() of multi-line childrenThe two-pane wireframe (sessions list next to preview pane)
is the natural composition row(col(…), col(…)) — or
equivalently row(labeledSection(…), labeledSection(…)),
since labeledSection is multi-line by construction. The
current widget renderer doesn't realise that shape: row()'s
inline-collapse path only operates on single-line children;
when it sees multi-line children it flushes each as a block
vertically, so the panes stack instead of sitting
side-by-side.
Phase 1 shipped with a vertically-stacked layout to defer this
work; the rest of the design assumes the proper two-pane shape.
The fix is to extend row()'s second pass to zip multi-line
children per line:
panel_width by default, with an optional explicit
weight (a future widthPct field on Col / Row /
LabeledSection if uneven splits become useful);max(height(block_i)), build
a merged line by concatenating block_left[i].text
padded to its column width + sep + block_right[i].text;col see one block per row() rather than per child.Heights don't have to match — short blocks are padded with spaces on the missing rows; the column they were given stays visually open. Overlays attached to those phantom rows aren't needed because the renderer is generating fresh blank lines.
The change is local to render.rs's Row arm. No new widget
kind, no new spec field for Phase 1's two-pane; widths are
implicit-equal-split. A widget-level widthPct parameter can
arrive later if a phase needs an explicit ratio (e.g. a 40/60
split for the preview).
Stop and the stop-leg of Archive / Delete need to terminate every process the session has spawned, including children the agent forks itself. The terminal layer already runs each session's command under a fresh pty, which gives us a session-leader process with its own process-group id (pgid).
New host-side API surface:
// Plugin API (TypeScript binding via ts-rs).
//
// Sends `signal` to the *process group* led by the terminal's
// pty session leader. Defaults to a graceful escalation:
// SIGTERM, wait `gracePeriodMs`, then SIGKILL anything still
// alive.
editor.signalTerminal(
terminalId: number,
options?: {
signal?: "SIGTERM" | "SIGKILL" | "SIGINT",
gracePeriodMs?: number,
},
): Promise<{ stopped: boolean }>;
Rust-side implementation lives in services/terminal/manager.rs
next to the existing closeTerminal path. It walks the pgid
via kill(-pgid, signal) on Unix; on Windows it walks the
job object that portable_pty already attaches the child to.
git worktree move keeps git's internal bookkeeping
consistent with the on-disk move — the worktree still appears
in git worktree list, just under the new path, which is
fine because it's still a valid worktree the user could
inspect or fall back to manually.
Layout:
<XDG data dir>/orchestrator/<repo-slug>/
├── session-1/ ← active
├── session-2/ ← active
└── .archived/
├── session-3/ ← archived
└── session-4/ ← archived
<repo-slug> is the slugified repository toplevel path
(/home/noam/repos/fresh → home_noam_repos_fresh), matching
what the new-session form already produces.
A local manifest at
<XDG data dir>/orchestrator/<repo-slug>/archived.json records
the archived sessions so the orchestrator plugin can show them
in the "Show archived" view without scanning the filesystem:
{
"version": 1,
"sessions": [
{
"label": "session-3",
"root": "<XDG>/orchestrator/<repo-slug>/.archived/session-3",
"branch": "session-3",
"archived_at": "2026-05-13T11:00:00Z",
"last_state": "ready"
}
]
}
"Show archived" is a toggle in the filter row (default off).
When on, archived rows are interleaved with active rows in
the list, rendered with a dim foreground and ARCH state
badge.
The local manifest is the source of truth on one machine. To recover sessions on another machine the user pushes a special git branch:
refs/heads/<user>/fresh-sessions
<user> is derived in this order:
$FRESH_SESSIONS_USER environment variable, if set.git config user.email
([email protected] → noam).gh auth status when the gh CLI is
configured.$USER as a last resort.The branch is an orphan-ish branch carrying only a single
file at its root, sessions.json:
{
"version": 1,
"machine_id": "chunky.lan",
"updated_at": "2026-05-13T11:00:00Z",
"active": [
{
"label": "session-2",
"branch": "session-2",
"base_ref": "origin/master",
"created_at": "2026-05-13T09:00:00Z"
}
],
"archived": [
{
"label": "session-3",
"branch": "session-3",
"base_ref": "origin/master",
"archived_at": "2026-05-13T10:00:00Z"
}
]
}
Sync behaviour:
git push origin <user>/fresh-sessions. The user-visible
action returns immediately — the push runs in the
background. Failures are non-fatal: the local manifest has
already been updated, and the next successful push
reconciles. A small unobtrusive indicator surfaces failure
state — a ⤒ glyph (or similar) appended to the dialog's
footer when there is unsynced state, plus a one-line
hover/status-bar message naming the last error. The
indicator clears as soon as a subsequent push succeeds.
Users who care can run an explicit "Orchestrator: Sync Now"
command from the palette to retry on demand.Orchestrator: Open first loads in a
fresh editor process, it tries
git fetch origin <user>/fresh-sessions and merges any
entries it doesn't already know about. Sessions whose
branch is missing locally are shown as "remote" rows that
resolve to "Dive" by first running git fetch for that
branch and creating the worktree locally.created_at /
archived_at timestamps decide which side wins on
conflict. Two machines archiving the same session is
idempotent.refs/heads/<user>/fresh-sessions so it doesn't pollute
the default git branch output (Git already hides
namespaced refs in many UIs). Users who want full opt-out
can set fresh.orchestrator.sync = false in their config.This feature builds on top of the local manifest and ships in a later phase (see Implementation phases).
Shortcuts go through the existing keybinding pipeline rather
than being hardcoded in the plugin. The plugin registers
chord defaults under a orchestrator-open plugin mode:
keybindings.load_plugin_chord_default(
KeyContext::Mode("orchestrator-open".into()),
vec![(KeyCode::Char('s'), KeyModifiers::ALT)],
Action::PluginCommand("orchestrator_stop".into()),
);
// …same for 'a' (archive), 'd' (delete), 'n' (new)
What this buys us:
~/.config/fresh/keybindings.json — the resolution path
in KeyBindings::resolve_chord checks user settings before
the plugin defaults, identical to how built-in actions
behave.format_keybinding, which produces Alt+D on Linux /
Windows and ⌥D on macOS without the plugin caring.orchestrator-open mode is active (i.e. the dialog is
open), so Alt+D doesn't shadow anything global.This requires one small host change: the floating-widget-panel
keystroke dispatcher (dispatch_floating_widget_key in
app/input.rs) currently swallows Ctrl/Alt chords. The new
behaviour is to first attempt a mode-chord resolution
against the active editor mode, then fall back to the
existing swallow-don't-leak rule.
orchestratorSessions (small
ranker, substring + prefix bonus). No external action
surface yet — Dive is the only action.row() doesn't yet do horizontal zip
for multi-line children (see Widget renderer:
row() of multi-line children).startPrompt-based picker.row() of multi-line childrenrender.rs's Row arm so block children are zipped
per line instead of flushed vertically.col(filter, list, preview)
fallback back to the wireframed
row(labeledSection(list), labeledSection(preview)).widthPct field can arrive when a phase
demands an explicit ratio.editor.signalTerminal(terminalId, options) host API in
services/terminal/manager.rs.kill(-pgid, signal). Windows: walk the
portable_pty job object.Stop button + Alt+S shortcut to the new signal
API.<XDG>/orchestrator/<repo-slug>/archived.json.git worktree move to / from the .archived/ graveyard.ARCH badge.git worktree remove + rmdir for active sessions; manifest
cleanup for archived sessions.<user> from env / git config / gh / $USER.<user>/fresh-sessions branch
on every lifecycle action.fresh.orchestrator.sync = false config opt-out.<user> collision: two contributors with the same
git config user.email local-part would collide on the
branch namespace. Possibly require the gh username or a
config when ambiguous.