diff --git a/research/docs/2026-04-15-tmux-window-close-rename-suppression.md b/research/docs/2026-04-15-tmux-window-close-rename-suppression.md new file mode 100644 index 0000000000..ed8412fc48 --- /dev/null +++ b/research/docs/2026-04-15-tmux-window-close-rename-suppression.md @@ -0,0 +1,1713 @@ +--- +date: 2026-04-15 05:34:52 UTC +researcher: Claude +git_commit: 3b15918b3f2c4a7b3d325feec35ff49807063297 +branch: main +repository: atomic +topic: "Prevent users from closing or renaming tmux windows in agent sessions" +tags: [research, codebase, tmux, ux, runtime, keybindings, psmux, agent-sessions, paused-state, sdk-events] +status: complete +last_updated: 2026-04-15 +last_updated_by: Claude +last_updated_note: "Added follow-up research on the paused-state objective (ticket #003): SDK idle/cancellation event mapping (Claude/OpenCode/Copilot), TUI animation/timer gating, and session checkpointing primitives. Second follow-up: exhaustive read of the local SDK docs in docs/ to extract abort, interrupt, session persistence, resume, and hook APIs verbatim." +--- + +# Research: Preventing Window Close / Rename in Agent Sessions + +## Research Question + +Within an active agent session, users must not be able to close or rename +tmux windows or exit out of the Copilot CLI, Claude Code, or OpenCode CLI +that is open in a window (e.g., double Ctrl+C not permitted). + +Acceptance criteria to evaluate: +1. Window close keybind suppressed for agent session windows +2. Window rename keybind suppressed for agent session windows +3. Suppressed actions show a non-blocking status message +4. Ctrl+C transitions to paused state (see #003) +5. `q` triggers clean exit flow +6. Non-agent tmux windows unaffected + +> This applies to tmux windows **within the agent session**, not the terminal +> emulator window itself. + +## Summary + +All tmux operations flow through a single dispatcher, `tmuxRun()` at +[`src/sdk/runtime/tmux.ts:138-153`](#code-references), which injects +`-f -L atomic` on every invocation. The bundled config at +[`src/sdk/runtime/tmux.conf`](#code-references) currently **does not** override +tmux's default destructive bindings (`prefix + &` kill-window, +`prefix + x` kill-pane, `prefix + ,` rename-window, `prefix + $` +rename-session, `prefix + :` command-prompt, and the mouse right-click +window/pane menus). Because the atomic tmux server is fully isolated from +the user's personal tmux server via the `-L atomic` socket, any binding +overrides placed in this config only affect Atomic sessions — satisfying +criterion 6 for free. + +The codebase already has two building blocks needed for implementation: +**(1)** a canonical `if-shell -F "#{...}"` conditional-keybinding pattern at +[`tmux.conf:65`](#code-references) (used for copy-mode Escape behavior), +and **(2)** a `#{==:#{window_index},0}` format-conditional at +[`tmux.ts:50-51`](#code-references) (used to hide the orchestrator window +from the attached-mode status list). Combining these patterns produces +per-window-index guards for `&` and `,` without spawning shells (which is +important for psmux compatibility). + +Atomic **does not currently use** `display-message -d ` for any +user-facing feedback; all non-blocking status is surfaced via the OpenTUI +`Statusline` component in the orchestrator window only. For agent windows +(where OpenTUI is not rendering), `display-message -d` is the canonical +tmux-native mechanism for a non-blocking toast, and psmux explicitly +supports it. + +The acceptance criteria reference three exit/pause flows with distinct +current behavior: +- **`q` in orchestrator** → already routes to `store.requestQuit()` → + `resolveAbort()` → `WorkflowAbortError` → `shutdown(0)` → full session + teardown ([`session-graph-panel.tsx:241`](#code-references), + [`orchestrator-panel-store.ts:159-165`](#code-references), + [`executor.ts:1172-1180`](#code-references)). This already matches + criterion 5 (`q` triggers a clean exit flow). +- **Ctrl+C in orchestrator** → same path as `q`. Criterion 4 ("transitions + to paused state, see #003") refers to future work in a separate + spec/ticket; a paused state does **not** exist in the current codebase. +- **Ctrl+C / `q` inside an agent window** → tmux passes the keystroke + through to the embedded CLI (Claude Code / OpenCode / Copilot CLI), + which interprets it per its own logic (e.g., Claude's double-Ctrl+C + exit). No tmux-level interception of `C-c` exists today. + +Session naming and window layout are also already unambiguous: workflow +sessions are `atomic-wf---` with window 0 = orchestrator +and windows 1+ = agent CLIs; chat sessions are `atomic-chat--` +with exactly one window running the agent CLI. The `-L atomic` socket +means **every** window on the atomic server is an Atomic-managed window, +which simplifies the "which windows should be protected" decision. + +## Detailed Findings + +### 1. The `tmuxRun()` Dispatcher and Config Injection + +All tmux invocations funnel through `tmuxRun()`, which prepends +`-f -L ` to every command: + +```ts +// src/sdk/runtime/tmux.ts:138-153 +export function tmuxRun(args: string[]): TmuxResult { + const binary = getMuxBinary(); + if (!binary) { /* ... */ } + const fullArgs = ["-f", CONFIG_PATH, "-L", SOCKET_NAME, ...args]; + const result = Bun.spawnSync({ + cmd: [binary, ...fullArgs], + /* ... */ + }); + /* ... */ +} +``` + +- `SOCKET_NAME = "atomic"` ([`tmux.ts:19`](#code-references)) +- `CONFIG_PATH = join(import.meta.dir, "tmux.conf")` ([`tmux.ts:22`](#code-references)) +- The `-f` flag is only honored on server start; for already-running + servers, `createSession()` explicitly re-sources the config via + `tmuxRun(["source-file", CONFIG_PATH])` after creating the session + ([`tmux.ts:226-228`](#code-references)). + +All exported tmux functions (createSession, createWindow, killSession, +killWindow, switchClient, selectWindow, display-message queries, etc.) +go through `tmuxRun`. A handful of specialized call sites that need +`stdin: "inherit"` (attachSession, spawnMuxAttach, detachAndAttachAtomic) +call `Bun.spawn*` directly but still build their argv through +`buildAttachArgs()` at [`tmux.ts:545-551`](#code-references), which +mirrors the flag injection. + +### 2. The Current `tmux.conf` — What's Bound and What's Not + +[`src/sdk/runtime/tmux.conf`](#code-references) (74 lines) defines: + +- **True color + clipboard**: `terminal-overrides`, `set-clipboard on`, + `allow-passthrough on` +- **Mouse on**: `set-option -g mouse on` +- **`allow-rename off`**: prevents *processes* from overwriting window + titles via escape sequences. **Does NOT** prevent the user from + manually triggering `prefix + ,` (rename-window) or `prefix + $` + (rename-session) — those bindings open a `command-prompt` that calls + `rename-window`/`rename-session` directly, bypassing `allow-rename`. +- **Sane defaults** (inlined from tmux-sensible): `escape-time 0`, + `history-limit 50000`, `display-time 4000`, `status-interval 5`, + `focus-events on`, `aggressive-resize on` +- **Status bar**: minimal, with `#{session_name}` on the right and + agent-list/hints in attached mode via constants in + [`tmux.ts:38-53`](#code-references) +- **Pane split / resize bindings**: `-`, `|` for split; `hjkl` for resize +- **Vi copy-mode**: `v` / `C-v` / `y` bindings, plus custom mouse + drag/click handlers at [`tmux.conf:71-73`](#code-references) +- **Prefix-free navigation**: `bind -n C-g select-window -t :0` and + `bind -n C-\\ next-window` at [`tmux.conf:58-62`](#code-references) + +**What's NOT defined** (so tmux defaults apply): +- `prefix + &` — `confirm-before -p "kill-window #W? (y/n)" kill-window` +- `prefix + x` — `confirm-before -p "kill-pane #P? (y/n)" kill-pane` +- `prefix + ,` — `command-prompt -I "#W" { rename-window -- "%%" }` +- `prefix + $` — `command-prompt -I "#S" { rename-session -- "%%" }` +- `prefix + :` — `command-prompt` (full tmux command line — user can type + any command including `kill-window`, `kill-session`, `rename-window`) +- `MouseDown3Status` (right-click on window status) — opens + `DEFAULT_WINDOW_MENU` with a "Kill" entry that runs `kill-window` +- `MouseDown3Pane` (right-click on pane body) — opens + `DEFAULT_PANE_MENU` with a "Kill" entry that runs `kill-pane` +- `prefix + d` — `detach-client` (detach, not kill — session stays alive) +- `prefix + D` — `choose-client -Z` (choose which client to detach) + +The comment block at [`tmux.conf:53-56`](#code-references) explicitly +notes: "Ctrl+\\ overrides the default SIGQUIT signal. Agent CLIs running +in these panes will not receive SIGQUIT via Ctrl+\\. … The integrated +agents (Claude Code, OpenCode, Copilot CLI) use Ctrl+C for interrupts +and are not affected." No equivalent interception exists for Ctrl+C. + +### 3. Agent Session Window Taxonomy + +Session naming (parsed by +[`parseSessionName()` at `tmux.ts:466-492`](#code-references)): + +| Session kind | Name format | Windows | +|--------------|----------------------------------------|------------------------------------| +| Workflow | `atomic-wf---` | Window 0 = orchestrator; 1+ agents | +| Chat | `atomic-chat--` | Single window = agent CLI | + +- **Workflow sessions** are created by the executor at + [`executor.ts:1090-1104`](#code-references) via + `OrchestratorPanel.create()` and `createSession(tmuxSessionName, …)`. + The initial window (index 0) runs the OpenTUI React renderer — it's + where `Statusline`, the graph canvas, and the `useKeyboard` handler + live. Agent windows (index 1+) are added by + `tmux.createWindow(sharedTmuxSessionName, name, command, …)` per + [`executor.ts:1185-1191`](#code-references) cleanup loop. +- **Chat sessions** are created by + [`src/commands/cli/chat/index.ts:201-221`](#code-references). There is + **no orchestrator window**: the single window starts with `shellCmd` + that launches the agent CLI directly. `windowName` is set equal to + the session name. +- In **both** cases, the session lives on the `-L atomic` socket; every + window on that socket is an Atomic-managed window. +- Window-index detection pattern already present in the codebase: + `"#{?#{==:#{window_index},0},, #W }"` — the attached-mode window-list + format at [`tmux.ts:50-51`](#code-references) — hides window 0 + (orchestrator) from the status bar. + +### 4. How Status and Feedback Are Currently Surfaced + +- **OpenTUI `Statusline`** at `src/sdk/components/statusline.tsx` + renders the bottom row of the orchestrator window (mode badge, + focused-node status, background task count, navigation hints). + Present **only** in window 0. +- **Attach flash message** at + [`session-graph-panel.tsx:120-128`](#code-references) — React state + (`attachMsg`) cleared by a `setTimeout` after + `ATTACH_MSG_DISPLAY_MS = 2400` ms; also only rendered in window 0. +- **tmux status bar** — set via `tmuxRun(["set", "-g", "status-right", + …])` at + [`session-graph-panel.tsx:400-421`](#code-references). Used for + agent-list rendering in attached mode and for the "ctrl+g graph · + ctrl+\\ next" hints. Managed centrally from the orchestrator React + tree. +- **`display-message -p`** is used today only as a **query** mechanism + (e.g., `display-message -t -p "#{window_index} #{window_name}"` + polled every 500 ms at + [`session-graph-panel.tsx:373-376`](#code-references); and + `display-message -p "#{session_name}"` in + [`tmux.ts:600`](#code-references)). +- **`display-message -d `** (non-blocking status toast on the tmux + status bar) is **not used anywhere** in the codebase. `set -g + display-time 4000` at [`tmux.conf:18`](#code-references) sets the + default duration, but because `-d` overrides the default, the + configured `display-time` is effectively unused for this purpose. + +### 5. Current Exit/Pause Handling + +#### `q` and `Ctrl+C` in the orchestrator window (index 0) + +```ts +// src/sdk/components/session-graph-panel.tsx:241-244 +if ((key.ctrl && key.name === "c") || key.name === "q") { + store.requestQuit(); + return; +} +``` + +`store.requestQuit()` at +[`orchestrator-panel-store.ts:159-165`](#code-references) branches on +`completionReached`: + +```ts +requestQuit(): void { + if (this.completionReached) { + this.resolveExit(); // end waitForExit() → graceful shutdown(0) + } else { + this.resolveAbort(); // end waitForAbort() → WorkflowAbortError + } +} +``` + +At [`executor.ts:1172-1180`](#code-references), the orchestrator races +`definition.run(workflowCtx)` against `panel.waitForAbort()`: + +```ts +const abortPromise = panel.waitForAbort().then(() => { + throw new WorkflowAbortError(); +}); +await Promise.race([definition.run(workflowCtx), abortPromise]); +``` + +`WorkflowAbortError` is caught at the `try/catch` boundary in +`runOrchestrator()`, which triggers `shutdown(0)` — destroys the panel, +calls `tmux.killSession(tmuxSessionName)`, and sets `process.exitCode`. + +The SIGINT handler at [`executor.ts:1108-1109`](#code-references) calls +`shutdown(1)` directly — this is a backup for when Ctrl+C arrives as a +real OS signal (e.g., when OpenTUI is not capturing it), not when it's +caught by the React keyboard hook in window 0. + +`createCliRenderer` is explicitly configured with `exitOnCtrlC: false` +at [`orchestrator-panel.tsx:69`](#code-references) so OpenTUI does not +auto-quit on Ctrl+C — the React handler has the first chance. + +#### `q` and `Ctrl+C` inside an agent window (index 1+, or chat) + +tmux passes keystrokes directly to the running CLI — Atomic's +`useKeyboard` handler is **not** active because OpenTUI isn't rendering +in those panes. No `bind -n C-c` entry exists in `tmux.conf`, so tmux +never intercepts Ctrl+C at the server layer. + +The embedded CLIs handle Ctrl+C per their own logic: +- **Claude Code** — first Ctrl+C interrupts the current turn; double + Ctrl+C (within ~1s) exits the CLI. +- **OpenCode** — first Ctrl+C interrupts; double Ctrl+C exits. +- **Copilot CLI** — first Ctrl+C interrupts; double Ctrl+C exits. + +Once the CLI process exits, the pane (and window) dies. With default +tmux settings (no `remain-on-exit`), this also closes the window — so +double-Ctrl+C through the CLI is a current path for closing an agent +window. + +#### No "paused state" exists today + +There is **no** `paused` status in the codebase: +- `SessionStatus` at `src/sdk/components/orchestrator-panel-types.ts` + is `"pending" | "running" | "complete" | "error"` — no `"paused"`. +- The conductor at `src/services/workflows/conductor/conductor.ts` + aborts the current stage via `currentSession?.abort?.()` but + does not set a conductor-level paused flag that holds the execution + loop. +- Specs [`2026-03-25-workflow-interrupt-stage-advancement-fix.md`](#historical-context-from-research) + and [`2026-03-25-workflow-interrupt-resume-session-preservation.md`](#historical-context-from-research) + lay out a future paused-state model for the workflow conductor, but + this is independent of the tmux-level window suppression problem. +- The acceptance criterion "Ctrl+C transitions to paused state (see + #003)" therefore references future work — it is **not** a constraint + that the current codebase can satisfy without additional changes in + a separate spec/ticket. + +### 6. Socket Isolation Guarantees Criterion 6 (Non-Agent Windows Unaffected) + +The `-L atomic` socket flag causes tmux to run a **separate server +process** with its own socket (`/tmp/tmux-/atomic` on +Linux/macOS; named pipe on Windows via psmux). The two servers share +no state: bindings, hooks, config, and sessions are fully independent. + +Any `bind`/`unbind`/`set-option` issued via `tmuxRun()` targets the +atomic server only. A user's personal tmux — running on its default +socket — is never contacted. This gives "non-agent tmux windows +unaffected" for free: the user's personal `prefix + &` behavior is +untouched. + +The runtime check `isInsideAtomicSocket()` at +[`tmux.ts:124-131`](#code-references) parses the `TMUX` env var's socket +path segment to detect whether the current process is running inside +the atomic socket specifically (not just any tmux). + +### 7. The Pattern Building Blocks Already Present in the Codebase + +#### Pattern A — `if-shell -F` conditional keybinding + +From [`tmux.conf:65`](#code-references) (copy-mode Escape behavior): + +```tmux +bind-key -T copy-mode-vi Escape \ + if-shell -F "#{selection_present}" \ + "send-keys -X clear-selection" \ + "send-keys -X cancel" +``` + +`if-shell -F` evaluates a tmux format string **at binding invocation +time** — no shell is spawned, so the pattern is fast and cross-platform +(critical for psmux on Windows, where `run-shell` uses PowerShell). + +#### Pattern B — `#{==:#{window_index},0}` format conditional + +From [`tmux.ts:50-51`](#code-references) (attached-mode window-list +format): + +```ts +export const TMUX_ATTACHED_WINDOW_FMT = + "#{?#{==:#{window_index},0},, #W }"; +``` + +Reads: "if window_index == 0 then empty else ` #W `". This same +comparison can be used inside `if-shell -F` to branch on whether the +current window is the orchestrator (index 0) or an agent window +(index ≥ 1). + +#### Pattern C — `unbind` + `bind` override + +From [`tmux.conf:71-73`](#code-references) (mouse copy-mode overrides): + +```tmux +bind -T copy-mode-vi MouseDrag1Pane select-pane \; send-keys -X begin-selection \; send-keys -X scroll-exit-off +bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel +bind -T copy-mode-vi MouseDown1Pane send-keys -X clear-selection \; select-pane +``` + +Same shape that an override of the `&`, `,`, etc. prefix bindings would +take — overriding a default binding in a specific table (`prefix` or +`root`/`-n`) without needing to `unbind` first (tmux `bind` replaces +any existing entry for the same key in the same table). + +#### Pattern D — `display-message -d ` for non-blocking toast + +Not yet in the codebase. From +[`research/web/2026-04-15-tmux-keybind-suppression.md`](#historical-context-from-research): +`display-message -d ` renders a message in the tmux +status bar for N ms, returns immediately, and does not block pane +updates. Psmux supports this flag. + +### 8. Composite Detection: "Is This an Agent Window?" + +For workflow sessions: window_index > 0 ⇒ agent window; window_index +== 0 ⇒ orchestrator. + +For chat sessions: the lone window is the agent. Its window_index on a +freshly-created session is typically 0 (tmux's default `base-index` is +0, and `tmux.conf` does not override it). + +Format expressions that pick out "agent windows" exclusively: +- Combined (workflow OR chat): `#{||:#{!=:#{window_index},0},#{m/r:^atomic-chat-,#{session_name}}}` + — true for any non-zero window index, OR for any window in an + `atomic-chat-*` session. +- Alternative: because every window on the `-L atomic` socket is + Atomic-managed, and window 0 of a workflow session is the only + "orchestrator" on the server, a simpler guard suffices: "only allow + close/rename when `window_name == session_name` in a chat session, OR + when `window_index == 0` in a workflow session." In practice, a + coarser guard — "suppress close/rename everywhere on the atomic + socket except on the orchestrator" — matches the criterion and needs + only `#{!=:#{window_index},0}` **plus** the chat-session prefix check. + +Format variables confirmed to work in both tmux and psmux: +`#{window_index}`, `#{window_name}`, `#{session_name}`, `#{pane_in_mode}`, +`#{pane_mode}`, `#{selection_present}`, `#{==:a,b}`, `#{!=:a,b}`, +`#{||:a,b}`, `#{?cond,yes,no}`, `#{m/r:regex,string}` +(see [research/web/2026-04-15-tmux-keybind-suppression.md](#historical-context-from-research) +§4 for psmux confirmation). + +### 9. Mouse-Menu Close Path (Not Yet Accounted For) + +Right-clicking on the window status bar (`MouseDown3Status`) opens +`DEFAULT_WINDOW_MENU`, which includes a "Kill" entry that calls +`kill-window` *without* going through the `prefix + &` binding. +Similarly, `MouseDown3Pane` opens `DEFAULT_PANE_MENU` with a "Kill" +entry. To close this path, the menus themselves must be overridden: +either by binding `MouseDown3Status` / `MouseDown3Pane` to a custom +action, or by overriding the menu templates via tmux options +(see tmux man page: `display-panes-time`, `DEFAULT_*_MENU`). The +codebase does not currently customize these menus. + +### 10. Window-Event Hooks Available + +Tmux exposes named hooks that fire on window/session events. Confirmed +available hooks: +- `window-linked`, `window-unlinked`, `window-renamed` +- `session-renamed`, `session-created`, `session-closed` +- `client-resized`, `client-attached`, `client-detached` +- Auto-generated `after-` hooks for every command (e.g., + `after-rename-window`, `after-kill-window`, `after-kill-session`) + +Example (not present in code, from online research): +```tmux +set-hook -g after-rename-window \ + "run-shell 'tmux rename-window -t #{hook_pane} atomic-wf-foo'" +``` + +Hooks are a belt-and-suspenders option if the binding-level suppression +is insufficient — they can revert unwanted state changes after the fact. + +## Code References + +- `src/sdk/runtime/tmux.ts:19` — `SOCKET_NAME = "atomic"` +- `src/sdk/runtime/tmux.ts:22` — `CONFIG_PATH` bundled config resolution +- `src/sdk/runtime/tmux.ts:38-53` — Status-bar defaults and attached-mode + format constants (`TMUX_ATTACHED_WINDOW_FMT` demonstrates the + `#{==:#{window_index},0}` pattern) +- `src/sdk/runtime/tmux.ts:71-93` — `getMuxBinary()` — Windows + (psmux → pmux → tmux fallback) vs Unix (tmux) +- `src/sdk/runtime/tmux.ts:113-131` — `isInsideTmux` / `isInsideAtomicSocket` +- `src/sdk/runtime/tmux.ts:138-153` — `tmuxRun()` central dispatcher; injects + `-f CONFIG_PATH -L SOCKET_NAME` on every call +- `src/sdk/runtime/tmux.ts:204-230` — `createSession()` — note the + `source-file` reload at line 228 that keeps bindings current for an + already-running server +- `src/sdk/runtime/tmux.ts:242-262` — `createWindow()` — how agent + windows are added to a workflow session +- `src/sdk/runtime/tmux.ts:407-413` — `killSession()` +- `src/sdk/runtime/tmux.ts:416-422` — `killWindow()` (programmatic; not + the user-triggered path) +- `src/sdk/runtime/tmux.ts:454-492` — `parseSessionName()` — the + `atomic-wf-*` / `atomic-chat-*` taxonomy +- `src/sdk/runtime/tmux.ts:545-578` — `buildAttachArgs` / `attachSession` + / `spawnMuxAttach` — the attach path that respects `-f -L` +- `src/sdk/runtime/tmux.ts:600` — `display-message -p` used as a + read-only query (the only current use of `display-message` in the code) +- `src/sdk/runtime/tmux.conf:13` — `allow-rename off` (blocks process + auto-rename, not user-initiated rename) +- `src/sdk/runtime/tmux.conf:18` — `display-time 4000` (default duration; + overridden by `display-message -d` when used) +- `src/sdk/runtime/tmux.conf:53-62` — SIGQUIT comment + prefix-free + navigation (`C-g`, `C-\\`) +- `src/sdk/runtime/tmux.conf:65` — Canonical `if-shell -F` conditional + keybinding pattern +- `src/sdk/runtime/tmux.conf:71-73` — Mouse copy-mode override pattern +- `src/sdk/runtime/executor.ts:1090-1104` — Session creation and + `OrchestratorPanel.create` +- `src/sdk/runtime/executor.ts:1108-1109` — SIGINT handler +- `src/sdk/runtime/executor.ts:1172-1180` — `Promise.race` between + workflow execution and user abort +- `src/sdk/runtime/executor.ts:1185-1191` — Catch-block cleanup that + kills each active agent window via `tmux.killWindow` +- `src/commands/cli/chat/index.ts:200-240` — Chat session creation (one + session = one agent window, no orchestrator) +- `src/sdk/components/session-graph-panel.tsx:216-310` — `useKeyboard` + handler in orchestrator +- `src/sdk/components/session-graph-panel.tsx:241-244` — Ctrl+C / `q` + route to `store.requestQuit()` +- `src/sdk/components/session-graph-panel.tsx:373-394` — 500 ms poll + loop using `display-message -p "#{window_index} #{window_name}"` to + detect active window +- `src/sdk/components/session-graph-panel.tsx:400-421` — tmux status-bar + mode switching between graph and attached modes +- `src/sdk/components/orchestrator-panel.tsx:69` — `exitOnCtrlC: false` + so React handles Ctrl+C first +- `src/sdk/components/orchestrator-panel-store.ts:159-165` — + `requestQuit()` → `resolveAbort()` / `resolveExit()` +- `src/sdk/components/statusline.tsx` — React TUI statusline + (orchestrator window only) + +## Architecture Documentation + +### Current keybinding landscape on the atomic socket + +| Key(s) | Current binding (atomic socket) | Suppressible? | +|------------------------|--------------------------------------------------|---------------| +| `prefix + &` | tmux default — `confirm-before kill-window` | Yes (rebind in prefix table) | +| `prefix + x` | tmux default — `confirm-before kill-pane` | Yes | +| `prefix + ,` | tmux default — `command-prompt rename-window` | Yes | +| `prefix + $` | tmux default — `command-prompt rename-session` | Yes | +| `prefix + :` | tmux default — `command-prompt` (arbitrary cmd) | Yes (but blocks power users) | +| `prefix + d` / `D` | tmux default — detach/choose detach | Functional — not destructive | +| `MouseDown3Status` | tmux default — opens window context menu (Kill) | Yes (rebind in root table) | +| `MouseDown3Pane` | tmux default — opens pane context menu (Kill) | Yes | +| `C-g` (no prefix) | Atomic — `select-window -t :0` | Already handled | +| `C-\\` (no prefix) | Atomic — `next-window` | Already handled | +| `-` / `\|` (prefix) | Atomic — split-window | OK | +| `h/j/k/l` (prefix) | Atomic — resize-pane | OK | +| copy-mode bindings | Atomic — custom vi + mouse | OK | + +### The "agent window" boundary + +``` +┌──────────────────────────────────── tmux -L atomic ────────────────────────────────┐ +│ │ +│ Workflow session: atomic-wf-claude-myflow-abc123 │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Window 0 │ │ Window 1 │ │ Window 2 │ │ +│ │ orchestrator │ │ agent (node) │ │ agent (node) │ │ +│ │ OpenTUI │ │ Claude Code │ │ Copilot CLI │ │ +│ │ React tree │ │ embedded CLI │ │ embedded CLI │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ ^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ │ +│ │ criterion: close/rename suppressed on these windows │ +│ └─ close/rename allowed (criterion 5: `q` triggers clean exit) │ +│ │ +│ Chat session: atomic-chat-opencode-def456 │ +│ ┌──────────────┐ │ +│ │ Window 0 │ │ +│ │ agent (only) │ │ +│ │ OpenCode CLI │ │ +│ └──────────────┘ │ +│ ^^^^^^^^^^^^^^^^ │ +│ criterion: close/rename suppressed on this window │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────┘ + +Non-atomic tmux (user's personal server on default socket): + ┌──────────────┐ ┌──────────────┐ + │ Window 0 │ │ Window 1 │ ← criterion: UNAFFECTED (different server) + │ user shell │ │ user shell │ + └──────────────┘ └──────────────┘ +``` + +### The exit / abort flow (current) + +``` +Window 0 (orchestrator) + └── OpenTUI useKeyboard + └── key = 'q' OR (ctrl + 'c') + └── store.requestQuit() + ├── completionReached? → resolveExit() ─┐ + └── else → resolveAbort() ─┤ + │ +Window N (agent) │ + └── tmux send-keys pass-through │ + └── CLI receives key directly │ + └── CLI handles Ctrl+C / exits on double ─────┤ + ▼ + runOrchestrator() catch + └── shutdown(exitCode) + ├── panel.destroy() + ├── tmux.killSession(name) + └── process.exitCode = N +``` + +Criterion 4 (Ctrl+C → paused state) would add a new transition between +"key received in agent window" and "CLI exits the process" — but this +requires **tmux-level interception of Ctrl+C in agent windows**, which +does not exist today. That interception is out of scope for +criteria 1 & 2 (window close/rename) but intersects with them because +double-Ctrl+C currently exits the CLI, which in turn closes the window. + +## Historical Context (from research/) + +- [`research/web/2026-04-15-tmux-keybind-suppression.md`](../web/2026-04-15-tmux-keybind-suppression.md) + — Authoritative dump of default tmux bindings for kill/rename/menu, + `if-shell -F` conditional patterns, `display-message -d` + non-blocking semantics, and psmux compatibility notes. Companion to + this document; fetched from tmux/tmux master and psmux docs on + 2026-04-15. +- [`research/docs/2026-04-10-tmux-ux-implementation-guide.md`](./2026-04-10-tmux-ux-implementation-guide.md) + — How `tmuxRun()`, `-f`, and `-L atomic` were implemented. Confirms + the config-injection architecture used as the basis for any binding + overrides. +- [`research/web/2026-04-10-tmux-ux-improvements.md`](../web/2026-04-10-tmux-ux-improvements.md) + — Original proposal doc for the atomic socket + bundled config. + Scope explicitly excludes Ctrl+C/`q` interception ("Atomic's session + lifecycle is fully managed — Ctrl+C and `q` work, sessions + auto-cleanup on completion"). The new criteria extend beyond that + MVP scope. +- [`research/web/2026-04-10-tmux-ux-for-embedded-cli-tools.md`](../web/2026-04-10-tmux-ux-for-embedded-cli-tools.md) + — OSS prior art (Overmind, Zellij, tmate). Zellij's "locked mode" + (Ctrl+g passes all keystrokes to the terminal) is a conceptual + analog for suppression, though Zellij is a separate multiplexer, not + a tmux config. +- [`research/web/2026-04-11-tmux-copy-mode-ux-scroll-exit.md`](../web/2026-04-11-tmux-copy-mode-ux-scroll-exit.md) + — `pane-mode-changed` hook, `#{selection_present}` format variable, + and other tmux format/hook infrastructure referenced in §9, §10. +- [`research/web/2026-04-10-psmux-tmux-compatibility.md`](../web/2026-04-10-psmux-tmux-compatibility.md) + — psmux ↔ tmux feature parity — referenced for confirming + `if-shell -F` / `display-message -d` / `set-hook` support on + Windows. +- [`specs/2026-03-25-workflow-interrupt-stage-advancement-fix.md`](../../specs/2026-03-25-workflow-interrupt-stage-advancement-fix.md) + and + [`specs/2026-03-25-workflow-interrupt-resume-session-preservation.md`](../../specs/2026-03-25-workflow-interrupt-resume-session-preservation.md) + — Planned paused-state model for the workflow conductor. Likely the + referent of "#003" in the acceptance criteria, or a sibling + ticket. +- [`research/docs/2026-03-25-workflow-interrupt-resume-bugs.md`](./2026-03-25-workflow-interrupt-resume-bugs.md) + — Research backing the conductor paused-state work. + +## Related Research + +- [`research/docs/2026-04-10-tmux-ux-implementation-guide.md`](./2026-04-10-tmux-ux-implementation-guide.md) +- [`research/web/2026-04-15-tmux-keybind-suppression.md`](../web/2026-04-15-tmux-keybind-suppression.md) +- [`research/web/2026-04-10-tmux-ux-improvements.md`](../web/2026-04-10-tmux-ux-improvements.md) +- [`research/web/2026-04-11-tmux-copy-mode-ux-scroll-exit.md`](../web/2026-04-11-tmux-copy-mode-ux-scroll-exit.md) + +## Open Questions + +1. **Scope of Ctrl+C interception inside agent windows.** The + acceptance criteria say "users must not be able to … exit out of + the Copilot CLI, Claude Code, or OpenCode CLI" and "double Ctrl+C + not permitted." Suppressing `prefix + &` and `prefix + ,` alone + does not stop double-Ctrl+C from exiting the CLI, which in turn + closes the window. Does this spec own Ctrl+C interception via + `bind -n C-c`, or is that delegated to the "#003 paused state" + ticket? The interception options are: + - `bind -n C-c` in the tmux config — intercepts at tmux layer + before the CLI sees it; conditional on window type. + - Detection at the executor level (e.g., listen for SIGCHLD on the + pane and auto-restart the CLI if it exits during an active + workflow). + +2. **Mouse-menu close coverage.** Criteria 1 & 2 say "keybind" — + technically the right-click context menu "Kill" entries are menu + items, not keybindings. Should mouse menus also be suppressed, or + is the scope limited to keyboard bindings? + +3. **`prefix + :` command-prompt.** The arbitrary command prompt lets + a user type `kill-window`, `rename-window`, `kill-session`, etc. + directly, bypassing `&` and `,` overrides. Does the spec require + closing this escape hatch (e.g., by unbinding `prefix + :` or + overriding `command-prompt` to reject certain commands)? Power + users familiar with tmux may need `:` for debugging. + +4. **Workflow session window 0 behavior.** Criterion 5 (`q` triggers + clean exit flow) is already satisfied in window 0. But should + `prefix + &` on window 0 also trigger the clean exit flow (instead + of killing just the orchestrator window), or should window 0 + simply keep the default tmux behavior? Killing only window 0 via + `&` would strand agent windows — the session would stay alive + without its orchestrator, likely creating a zombie state. + +5. **Chat session semantics.** Chat sessions have no orchestrator + window and no OpenTUI-rendered controls — the user is dropped + directly into the agent CLI. Is "q triggers clean exit flow" + applicable to chat sessions? Today `q` inside a chat window goes + straight to the agent CLI. Would the spec route `q` to a clean + exit (e.g., via `bind -n q` with window-prefix condition), or is + `q` out of scope for chat? + +6. **Does the user's personal tmux server need extra protection?** The + `-L atomic` boundary means bindings set on the atomic server don't + affect the user's personal tmux. But if a user happens to `tmux + -L atomic attach` manually outside Atomic, they'd be bound by the + same restrictions — is that desired? (Most likely yes, since + manual atomic-socket attach is also "inside an agent session.") + +7. **Restoring bindings after the session ends.** The atomic server + persists until its last session ends. If a future change to + `tmux.conf` modifies `&` or `,`, the `source-file` reload at + [`tmux.ts:228`](#code-references) propagates the change to an + already-running server — but only on subsequent `createSession` + calls. A user attached to an already-running atomic server between + updates would see stale bindings until the next session starts. + This is an existing property of the config-reload mechanism and + does not need new handling, but is worth noting. + +8. **Interaction with `allow-rename off`.** The current config already + has `allow-rename off`, which blocks one vector (programmatic + renames by the running process). Adding a user-facing suppression + for `prefix + ,` completes the picture. Should the spec also + cover `set-titles on/off` and terminal-emulator-level title + changes? + +--- + +## Follow-up Research [2026-04-15 05:50 UTC] — Paused State for Interrupted Stages + +### Follow-up Objective + +> Implement paused state for cancelled/interrupted workflow stages. +> When a stage is interrupted (Ctrl+C), the orchestrator should +> transition to a paused state (checkpointing) rather than hard +> cancellation. +> +> Acceptance criteria: +> - SDK integrations map native idle/cancellation events to orchestrator paused state +> - On paused: stage timer stops and TUI animations halt +> - Session state is checkpointed for resumption +> - paused is visually distinct from running/cancelled/completed in TUI +> - Resuming from paused restarts timer and animations from prior state +> +> Note: each SDK has its own idle/cancellation event — map at the SDK layer. + +This is very likely the "#003" ticket referenced in the primary research +topic's acceptance criterion 4 ("Ctrl+C transitions to paused state (see +#003)"). The two objectives are complementary: the tmux-level +suppression prevents window close/rename, while the paused-state +handling gives Ctrl+C in an agent window a graceful, resumable +destination. + +### FF.1 — SDK Idle and Cancellation Event Map + +Each SDK signals completion differently. No SDK has a native "paused" +or "interrupted" event type; mapping must happen at the Atomic adapter +layer. + +#### GitHub Copilot SDK + +- **Idle event**: `session.idle` — a named event on the `CopilotSession` + event emitter (zero-argument handler). Documented at + `docs/copilot-cli/sdk.md:46-50`. +- **Cancellation**: `session.abort(): Promise` method on + `CopilotSession` (`docs/copilot-cli/sdk.md:275-277`). When abort is + called, the session emits `session.error` (**not** a dedicated + "aborted" event). +- **Error event**: `session.error` with payload + `{ data: { message?: string } }` — caught alongside `session.idle` in + `src/sdk/runtime/executor.ts:928-936`. +- **Native "interrupted" state**: none. Event schema + (`docs/copilot-cli/sdk.md:311-321`) lists `user.message`, + `assistant.message`, `assistant.message_delta`, + `tool.execution_start`, `tool.execution_complete`, `command.execute`, + `commands.changed`, `session.compaction_start`, + `session.compaction_complete`. No `session.interrupted` or + `session.paused`. +- **Current wiring**: `executor.ts:916-941` wraps native `send()` in a + `Promise` that resolves on `session.idle` and rejects on + `session.error`. `session.abort()` is **not** called anywhere in the + runtime today. + +#### Claude Agent SDK + +Claude has two code paths in Atomic: the headless path (pure SDK) and +the interactive/tmux path (pane-capture-based). + +- **Headless idle**: no dedicated idle event. The async generator + returned by `query()` yields a `ResultMessage` with `subtype` ∈ + `"success" | "error_max_turns" | "error_max_budget_usd" | + "error_during_execution" | "error_max_structured_output_retries"` + (`docs/claude-code/agent-sdk/agent-loop.md:270-280`). Consumed at + `src/sdk/providers/claude.ts:627-633` in + `HeadlessClaudeSessionWrapper.query()`. +- **Interactive/tmux idle**: no SDK-level event. Detected by polling + the tmux pane via `paneLooksReady()` + `!paneHasActiveTask()` in a + loop at `src/sdk/providers/claude.ts:265-302` (`waitForIdle()`). The + polling interval defaults to 2000 ms. The executor also sets + `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` at `executor.ts:84-87`, + which writes `session_state_changed` events to the JSONL + transcript, but the current runtime uses pane-capture polling as + the authoritative idle signal. +- **Cancellation**: + - Headless path — `Options.abortController`/`signal` passable via + `sdkOpts` at `src/sdk/providers/claude.ts:622-626`, but the + runtime does not currently wire one. + - Interactive/tmux path — no abort call; the tmux pane is simply + killed via `tmux.killWindow()` at `executor.ts:1185-1190`. +- **Native "interrupted" state**: the `ResultMessage.subtype` value + `"error_during_execution"` is the closest analog — fires on API + failures or cancelled requests (agent-loop.md:277). No explicit + `"interrupted"` or `"paused"` subtype. + +#### OpenCode SDK + +- **Idle event**: none. `client.session.prompt()` is a synchronous + HTTP RPC that blocks until `SessionPromptResponse` is returned + (`docs/opencode/sdk.md:312`). Completion is implied by the method + returning. +- **Event stream**: `event.subscribe()` SSE stream exists + (`docs/opencode/sdk.md:447-458`) but is not consumed by the current + Atomic runtime in any wired path. +- **Cancellation**: `client.session.abort({ path: { id } }): + Promise` — REST call (`docs/opencode/sdk.md:307`). Exists + in the SDK but is **not called** anywhere in `src/sdk/providers/opencode.ts` + or `src/sdk/runtime/executor.ts`. +- **Native "interrupted" state**: none documented. The `info` field + on `SessionPromptResponse` carries an optional `error` field only + typed for `StructuredOutputError`. + +### FF.2 — Why the Mapping Is Non-Trivial + +| SDK | Idle signal kind | Cancellation kind | Currently wired for abort? | +|--------------------|-------------------------|-------------------------|----------------------------| +| Copilot | Event emitter | Async method + error | No (abort exists, unused) | +| Claude (headless) | Async-generator yield | AbortController | No (signal passable, unused)| +| Claude (tmux) | Polling (pane-capture) | tmux kill-window | Indirect (pane kill) | +| OpenCode | Synchronous return | Async REST method | No (abort exists, unused) | + +Three of four code paths have a usable cancellation primitive exposed +by the underlying SDK but none are currently invoked by Atomic. The +existing abort mechanism is "kill the tmux session," which is +indiscriminate and unresumable. A paused-state implementation would +need per-SDK abort wiring in addition to the shared tmux-level teardown +to produce checkpointable state. + +### FF.3 — `SessionStatus` Enum and Visual State Today + +`src/sdk/components/orchestrator-panel-types.ts:3` — the current +`SessionStatus` type: + +```ts +type SessionStatus = "pending" | "running" | "complete" | "error"; +``` + +There is **no** `"paused"`, `"interrupted"`, or `"cancelled"` value. +Adding one requires a coordinated change across: + +- `orchestrator-panel-types.ts` — add the literal to the union +- `src/sdk/components/status-helpers.ts:5-25` — add + `statusColor/Label/Icon` mappings for the new value +- `src/sdk/components/node-card.tsx:21-39` — decide what the node + border/background renders for paused (see §FF.5) +- `src/sdk/components/orchestrator-panel-store.ts` — add a new + transition method (e.g. `pauseSession`, `resumeSession`) + +#### Current per-status visual distinctions + +From `src/sdk/components/status-helpers.ts:5-25`: + +| Status | Color | Label | Icon | +|-----------|------------------|-----------|------| +| `running` | `theme.warning` | "running" | `●` | +| `complete`| `theme.success` | "done" | `✓` | +| `pending` | `theme.textDim` | "waiting" | `○` | +| `error` | `theme.error` | "failed" | `✗` | +| (other) | `theme.textDim` | raw | `○` | + +From `src/sdk/components/node-card.tsx:21-39`: + +- **running, unfocused**: sine-wave pulse between `theme.border` and + `theme.warning` driven by `pulsePhase` (`t = (sin((pulsePhase / 32) + * π * 2 − π/2) + 1) / 2`) +- **running, focused**: fixed 20% lerp between `theme.warning` and + `#ffffff` — does not pulse +- **pending, focused**: uses the status color (`theme.textDim`) +- **pending, unfocused**: `theme.borderActive` +- **complete / error**: solid border in the raw status color +- Focused nodes get a faint background tint: `lerpColor(theme.background, + sc, 0.12)`. Unfocused nodes are fully transparent. +- `statusIcon()` values are NOT currently rendered in `node-card.tsx` + — the node shows the name in the border title and the duration + string in a centered `` element. + +### FF.4 — How Stage Timers and Animations Are Currently Gated + +From `src/sdk/components/session-graph-panel.tsx:48-117` and +`src/sdk/components/node-card.tsx:43-46`: + +- **Pulse frame rate**: `PULSE_INTERVAL_MS = 60` (~16.7 fps), + `PULSE_FRAME_COUNT = 32` frames per cycle (~1920 ms cycle). +- **Pulse gating**: the `setInterval` is only registered when + `hasRunning === true`, where `hasRunning` is a `useMemo` checking + `store.sessions.some((s) => s.status === "running")`. If no session + is `"running"`, the interval is not created and `pulsePhase` stops + advancing. +- **Timer computation**: `node-card.tsx:43-46` computes + `fmtDuration((node.endedAt ?? Date.now()) - node.startedAt)` at + render time. `fmtDuration` at `status-helpers.ts:29-32` formats ms + as `"Xm YYs"`. +- **Timer/animation coupling** (documented at + `session-graph-panel.tsx:107-109`): the 60 ms pulse interval doubles + as the live-timer refresh trigger. Because the duration is recomputed + at render time via `Date.now()` and re-renders are triggered by + `setPulsePhase`, clearing the interval implicitly freezes the + displayed timer (it stops re-rendering). +- **Freezing on complete/error**: the `endedAt` field is populated by + `completeSession`/`failSession` at + `orchestrator-panel-store.ts:76-90` with `Date.now()`. Once set, the + `endedAt ?? Date.now()` expression resolves to the frozen value, so + the displayed elapsed time becomes stable even if the pulse + interval is still running for other sessions. + +#### Implications for paused state + +The timer-halt and animation-halt requirements (acceptance criteria +2) can be satisfied via one of two approaches using existing +primitives: + +- **Option A — Snapshot `pausedAt` on transition.** Add a + `pausedAt: number | null` field to `SessionData`. In + `node-card.tsx`, change the duration expression to + `(node.endedAt ?? node.pausedAt ?? Date.now()) - node.startedAt`. + When `pausedAt` is non-null, the displayed elapsed time freezes at + the moment of pause. This is a minimal delta. +- **Option B — Set `endedAt` on pause, restore on resume.** Re-use + the existing `endedAt` field. On resume, clear `endedAt` and shift + `startedAt` forward by `Date.now() - (paused.endedAt)` so the + displayed elapsed time continues from where it paused. This has + fewer type changes but conflates "done" and "paused" fields + semantically. + +For animation halting: because `hasRunning` keys off +`status === "running"`, simply flipping the paused session's status +to `"paused"` automatically removes it from `hasRunning`. If every +running session transitions to paused, the `setInterval` clears on +its own — no explicit animation-stop code is needed. However, +`pulsePhase` state persists across the effect unmount (React state), +so when resumed, the pulse resumes from its last `pulsePhase` value — +satisfying acceptance criterion 5 ("restarts … animations from prior +state"). No explicit "snapshot pulsePhase" logic is required. + +### FF.5 — Session Checkpointing Primitives + +There is **no** resume/checkpoint runtime in the codebase today. Active +machinery: + +- **`s.save(s.sessionId)`** — the workflow-facing API demonstrated in + `src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts:161` + and elsewhere. Documented purpose: persist the agent's session ID to + enable resume. +- **`wrapMessages()`** at `src/sdk/runtime/executor.ts:817-870` — + called via `save`. For Claude workflows, it invokes + `listSessions({ dir: process.cwd() })` and `getSessionMessages( + candidate.sessionId, { dir })` from `@anthropic-ai/claude-agent-sdk` + to fetch the message history, then serializes it to `messages.json` + and renders it as plain text to `inbox.md` within the session + directory. +- **Session directory layout**: `~/.atomic/sessions// + -/` containing per-session + `metadata.json` (name, description, agent, paneId, serverUrl, port, + startedAt — written at stage start at `executor.ts:964-979`), + `messages.json` (from `save`), and `inbox.md` (rendered from + messages). +- **Top-level metadata**: `metadata.json` at `sessionsBaseDir` written + at `executor.ts:1143-1156` with `workflowName`, `agent`, `prompt`, + `projectRoot`, `startedAt`. +- **In-memory-only registries**: `activeRegistry` and + `completedRegistry` at `src/sdk/runtime/executor.ts:1118-1120` are + `Map` instances that exist only for the lifetime of the + `runOrchestrator()` process. There is no serialization, no + mid-flight checkpoint write, and no reload mechanism. + +There is no `resume`, `checkpoint`, or `persisted` identifier in +`src/sdk/runtime/executor.ts`. The existing `save()` machinery can be +a foundation for checkpointing — it already captures agent-side +session IDs and message history, which is the minimum needed to +re-enter a provider session. What's missing is: + +1. A mechanism to trigger `save()` at the moment of pause (currently + only user-code triggers it). +2. A runtime-layer record of "which stage was paused and at what + graph position," so a future resume knows where to re-enter. +3. A reload path that reads the persisted state and reconstructs the + in-memory registries. + +### FF.6 — Prior Spec Work on This Topic + +Two existing specs address the conductor-layer paused state but +stop short of the orchestrator-panel layer: + +- `specs/2026-03-25-workflow-interrupt-stage-advancement-fix.md` — + proposes adding `"interrupted"` as a fourth value to + `StageOutputStatus` (in `src/services/workflows/conductor/types.ts`) + and to the `workflow.step.complete` event bus schema (in + `schemas.ts`). **It does not** modify the `SessionStatus` union in + `orchestrator-panel-types.ts` — the conductor-level and panel-level + state types are separate. +- `specs/2026-03-25-workflow-interrupt-resume-session-preservation.md` + — follow-up that introduces `preservedSession: Session | null` and + `isResuming: boolean` internal to `WorkflowSessionConductor`. Session + preservation is entirely within the conductor layer; no + orchestrator-panel type changes are proposed. + +**Gap**: neither spec makes the panel visually reflect a "paused" +state. The follow-up objective in this research closes that gap. + +### FF.7 — Current Abort Plumbing (`runOrchestrator` Path) + +From `src/sdk/runtime/executor.ts:1090-1193`: + +``` +runOrchestrator() + ├── panel = OrchestratorPanel.create({ tmuxSession }) + ├── process.on("SIGINT", () => shutdown(1)) // backup for OS-level Ctrl+C + ├── const abortPromise = panel.waitForAbort() + │ .then(() => { throw WorkflowAbortError }) + ├── await Promise.race([ + │ definition.run(workflowCtx), // user workflow + │ abortPromise // TUI abort (q / Ctrl+C) + │ ]) + ├── catch WorkflowAbortError + │ └── for each active session: tmux.killWindow() // indiscriminate + └── shutdown(exitCode) → panel.destroy(), tmux.killSession(), exit +``` + +The only cancellation mechanism invoked is `tmux.killWindow()` / +`tmux.killSession()`. No SDK-level `session.abort()` is called. This +is the "hard cancellation" the follow-up objective seeks to replace +with paused state + checkpoint. + +### FF.8 — Session Status Transition Call Graph (Today) + +From `src/sdk/components/orchestrator-panel-store.ts`: + +| Method | Sets status to | Side effects | Called from | +|--------------------|----------------|---------------------------------------------------------------|----------------------------------------------| +| `setWorkflowInfo` | `pending` (all non-orchestrator nodes) / `running` (orchestrator) | Initializes `sessions` array | `executor.ts:1159` | +| `startSession` | `running` | Sets `startedAt = Date.now()` | NOT currently called by the executor | +| `addSession` | `running` (as constructed by caller) | Appends to `sessions` | `orchestrator-panel.tsx:115-123` ← `executor.ts:792` | +| `completeSession` | `complete` | Sets `endedAt = Date.now()` | via `panel.sessionSuccess(name)` at `executor.ts:1008` | +| `failSession` | `error` | Sets `endedAt = Date.now()`, stores `error` string | via `panel.sessionError(name, msg)` at `executor.ts:992, 1025` | +| `setCompletion` | `complete` (orchestrator only) | Sets `completionInfo`, `endedAt = Date.now()` on orchestrator | `executor.ts:1178` | + +**Observation**: the `pending → running` transition via `startSession` +is dead code for dynamically-spawned sessions — `addSession` creates +them directly in `"running"` state. The `pending` state only applies +to the workflow graph's pre-declared (static) sessions from +`setWorkflowInfo`. + +### FF.9 — Additional Code References (Follow-up) + +- `src/sdk/components/orchestrator-panel-types.ts:3` — `SessionStatus` + union (no paused value today) +- `src/sdk/components/status-helpers.ts:5-32` — `statusColor`, + `statusLabel`, `statusIcon`, `fmtDuration` +- `src/sdk/components/node-card.tsx:21-46` — status-driven rendering + and duration computation +- `src/sdk/components/session-graph-panel.tsx:48-117` — pulse + animation driver and `hasRunning` gate (lines 101-117) +- `src/sdk/components/orchestrator-panel-store.ts:68-106` — session + lifecycle transitions +- `src/sdk/components/orchestrator-panel.tsx:100, 115-123` — + `sessionStart` (unused) vs. `addSession` (used) +- `src/sdk/runtime/executor.ts:916-941` — Copilot `send()` wrapper + (idle/error promise shim) +- `src/sdk/runtime/executor.ts:817-870` — `wrapMessages` for + save/persist +- `src/sdk/runtime/executor.ts:964-979` — per-session + `metadata.json` write at stage start +- `src/sdk/runtime/executor.ts:1118-1120` — `activeRegistry` / + `completedRegistry` in-memory maps +- `src/sdk/runtime/executor.ts:1185-1190` — the catch-block cleanup + (`tmux.killWindow` loop) +- `src/sdk/providers/claude.ts:185-302` — + `HeadlessClaudeSessionWrapper` + `waitForIdle` polling loop +- `src/sdk/providers/claude.ts:622-633` — headless `query()` + consumption of the `ResultMessage` subtype +- `docs/claude-code/agent-sdk/agent-loop.md:270-280` — Claude + `ResultMessage.subtype` values +- `docs/opencode/sdk.md:307, 312, 447-458` — OpenCode `session.abort`, + `session.prompt`, `event.subscribe` +- `docs/copilot-cli/sdk.md:46-50, 275-277, 311-321` — Copilot + `session.idle`, `session.abort`, event schema +- `specs/2026-03-25-workflow-interrupt-stage-advancement-fix.md` — + conductor-layer `"interrupted"` proposal +- `specs/2026-03-25-workflow-interrupt-resume-session-preservation.md` + — conductor-layer session preservation proposal + +### FF.10 — Follow-up Open Questions + +1. **Conductor vs. panel status semantics.** The existing specs + propose `"interrupted"` at the conductor layer (`StageOutputStatus` + + bus schema). This follow-up objective calls for `"paused"` at + the orchestrator-panel layer (`SessionStatus`). Are these the + same concept under different names, or two distinct states that + both need modeling (e.g., "the conductor observed an interrupt" + vs. "the UI is displaying a paused node")? +2. **Which SDK exception(s) map to paused?** For each SDK, multiple + termination paths exist: + - Copilot: `session.error` fires on both user-abort and real + errors. + - Claude headless: `ResultMessage.subtype` has five distinct + termination subtypes. + - OpenCode: `session.prompt()` rejection vs. `session.abort()` + resolution. + Does the spec need to distinguish "user-initiated interrupt" + (→ paused) from "underlying error" (→ error)? If so, a user-abort + flag must propagate from the interrupt handler down into the SDK + adapter so it can classify the event correctly. +3. **Resume semantics for partially-completed stages.** When a stage + pauses mid-stream, the agent may have emitted partial output + (tool calls, assistant messages). Does resume replay the partial + output to reconstruct the UI, or only pick up from the last + acknowledged message? Claude's `messages.json` captures up to the + last event persisted by the SDK; OpenCode has no equivalent until + `session.prompt()` returns. +4. **Headless vs. tmux path parity.** Claude Code has both a + headless SDK path and an interactive tmux path. The headless path + can use `AbortController.abort()` cleanly; the tmux path currently + relies on pane capture + killing the window. Does the paused + state need to work equivalently on both paths, or is pausing only + meaningful for the headless (programmatic) path? +5. **What does "resume" mean for a pane-capture session?** When a + workflow stage runs via tmux pane automation (Claude interactive + path), pausing would need to freeze the pane and leave the agent + alive. The pane survives as long as the tmux window survives — + which directly ties back to criteria 1 & 2 of the primary topic + (suppress window close). A user who closes the agent window + destroys the resume target. This is the concrete link between + the two objectives: window-close suppression is a **prerequisite** + for pane-capture-based resume to work. +6. **Idempotency of `session.abort`.** Copilot and OpenCode both + expose async abort methods. What happens if abort is called while + the session is already idle? Does it no-op, throw, or put the + session into an unrecoverable state? The local docs don't + document this; spec work may need empirical validation. +7. **Visual treatment for paused.** Status color palette + (`graph-theme.ts`) currently has `warning` (running), `success` + (complete), `error` (failed), `textDim` (pending). A new paused + color needs a distinct channel — e.g., a dimmed/desaturated + `warning` (indicating "was running, now not") or an entirely new + theme token. Icon convention currently unused in `node-card.tsx`; + adding a paused icon would be the first use of `statusIcon` in + the actual card render. +8. **Interaction with `hasRunning` when sessions mix states.** If + one session is paused and another is still running, the pulse + interval keeps running for the latter. The paused session's + duration must be independently frozen (via the `pausedAt` + snapshot or `endedAt` trick in §FF.4). This is a per-node + concern, not a global-animation concern. +9. **Propagation through the event bus.** The event bus schema at + `src/services/workflows/event-bus/schemas.ts` emits + `workflow.step.complete` with status; if this schema is updated + to carry `"interrupted"` or `"paused"`, consumers (telemetry, + transcript writers, UI event listeners) all need to acknowledge + the new value — especially for telemetry, which currently maps + status to histogram buckets. + +--- + +## Follow-up Research [2026-04-15 06:30 UTC] — Deep Dive into Local SDK Docs + +The previous follow-up section (FF.1-FF.10) summarized SDK idle/abort +behavior based on the code wiring in `src/sdk/providers/` and +`src/sdk/runtime/executor.ts`. This second pass reads the SDK +**documentation** in `docs/` in full, extracting the authoritative API +surface for each SDK — specifically the primitives that enable (or +don't enable) a clean "paused + resume" model. Sources cited by file +path and section heading rather than line number because the docs are +prose. + +### FF'.1 — Claude Agent SDK (`docs/claude-code/agent-sdk/`) + +#### Idle signal + +The canonical completion signal is the `SDKResultMessage` yielded as +the **last** item from the `query()` async generator +(`agent-loop.md`, "Handle the result"). Shape (from +`sdk-references/typescript.md`, `SDKResultMessage`): + +```ts +type SDKResultMessage = + | { type: "result"; subtype: "success"; + result: string; session_id: string; total_cost_usd: number; + usage: NonNullableUsage; stop_reason: string | null; + num_turns: number; /* ... */ } + | { type: "result"; + subtype: "error_max_turns" | "error_during_execution" + | "error_max_budget_usd" + | "error_max_structured_output_retries"; + errors: string[]; session_id: string; /* ... */ }; +``` + +`stop_reason` includes `"end_turn"`, `"max_tokens"`, `"refusal"`. +`session_id` is present on every variant — the adapter can always +capture it for later resume. + +#### Abort primitives + +Three distinct primitives (`sdk-references/typescript.md`, +"Options" / "Query" object): + +1. `options.abortController: AbortController` — passed into `query()`; + calling `abortController.abort()` halts the loop. +2. `query.interrupt(): Promise` — **streaming input mode only** + (`streaming-vs-single-mode.md`). Documented as interrupting the + current turn cleanly. +3. `query.close(): void` — "forcefully ends the query and cleans up + all resources." + +On abort mid-stream, the loop **still yields** a final `ResultMessage` +with `subtype: "error_during_execution"` (`agent-loop.md`, "Handle the +result" table: *"An error interrupted the loop (for example, an API +failure or cancelled request)"*). That subtype is **ambiguous** — it +covers user-initiated interrupts and real errors with no finer +distinction. The Atomic adapter must track whether abort was +orchestrator-initiated to classify as paused vs. error. + +Partial `input_json_delta` events that were already yielded before +abort are **not retracted**. No `AssistantMessage` is emitted for an +incomplete turn. + +Also notable: `PermissionResult` has an `interrupt?: boolean` flag on +the `{ behavior: "deny" }` variant +(`sdk-references/typescript.md`) — a hook can deny a tool use and +simultaneously interrupt the running turn. This is a supported (if +unusual) second pathway into the `error_during_execution` state. + +#### Session persistence and resume + +Two **independent** persistence mechanisms, both first-party: + +**A. Conversation session persistence** (`core-concepts/sessions.md`): +- Automatic write to `~/.claude/projects//.jsonl` +- JSONL format, one message per line, covering prompts, tool calls, + tool results, assistant responses +- Resume via `options.resume: sessionId`. TS also supports + `options.continue: true` (auto-resume most recent session in cwd). +- Fork via `options.forkSession: true` with `resume` — creates new + session branching from that point; original untouched. +- `options.persistSession: false` disables disk persistence (TS only; + Python always persists). +- `options.resumeSessionAt: string` resumes at a specific message UUID. + +**B. File checkpointing** (`guides/file-checkpointing.md`): +- Tracks file modifications made by `Write`, `Edit`, `NotebookEdit` + tools only (not `Bash`). +- Enabled via `enableFileCheckpointing: true` and + `extraArgs: { "replay-user-messages": null }`. +- Checkpoint identifier = `UserMessage.uuid` from the stream. +- Rewind API: `query.rewindFiles(checkpointId)` (TS) or + `client.rewind_files(checkpoint_id)` (Python). +- Rewind does **not** revert conversation history — only files on + disk. This separation is explicit: "File checkpointing tracks file + modifications … only." + +This split is architecturally important for a paused-state spec. +Conversation resume can happen without touching the filesystem; +filesystem rollback requires a separate opt-in. The Atomic adapter +will need to decide whether pause implies any file-level rollback or +just conversation-level resume. + +#### Hooks (`sdk-references/typescript.md`, `HookEvent`) + +```ts +type HookEvent = + | "PreToolUse" | "PostToolUse" | "PostToolUseFailure" + | "Notification" | "UserPromptSubmit" + | "SessionStart" | "SessionEnd" // TypeScript SDK only + | "Stop" + | "SubagentStart" | "SubagentStop" + | "PreCompact" + | "PermissionRequest" + | "Setup" | "TeammateIdle" | "TaskCompleted" + | "ConfigChange" | "WorktreeCreate" | "WorktreeRemove"; +``` + +Relevant for pause/interrupt: +- `"Stop"` — fires when agent execution stops; `hooks.md` calls out + "Validate the result, save session state" as a use case. +- `"SessionEnd"` (TS only) — fires on session termination. +- `"Notification"` subtypes include `idle_prompt`. +- No `beforeAbort` or `onSessionPause` hook exists. Best proxy: + `"Stop"` + inspect `ResultMessage.subtype`. + +Hook callbacks receive `options: { signal: AbortSignal }` as third +argument — the signal is triggered if the hook itself times out (not +if the parent session is aborted). + +#### Streaming vs. single mode (`guides/streaming-vs-single-mode.md`) + +- **Streaming input mode** (`prompt: AsyncIterable`): + supports image uploads, queued messages, mid-loop `interrupt()`, + hooks, multi-turn without process restart. Required for clean + pause-with-resume semantics. +- **Single message mode** (`prompt: string`): cannot be interrupted + mid-turn. The only way to stop is full abort via `AbortController`. + +`includePartialMessages: true` yields `SDKPartialAssistantMessage` +(`type: "stream_event"`) events, including `input_json_delta` tool +call streams. Extended thinking +(`maxThinkingTokens` / `thinking`) disables `StreamEvent` emission. + +#### Pause/resume mapping for Claude + +Claude Agent SDK has the **cleanest** path to the paused-state model +among the three SDKs: + +``` +User presses Ctrl+C in agent window + ↓ +Adapter sets `wasUserAborted = true` on the session + ↓ +Adapter calls query.interrupt() [streaming mode] + OR abortController.abort() [single mode] + ↓ +Generator yields ResultMessage{ subtype: "error_during_execution", + session_id: "" } + ↓ +Adapter reads session_id; checks wasUserAborted + ├── true → emit { panel.status = "paused", sessionId } + └── false → emit { panel.status = "error" } + ↓ +[later] User requests resume + ↓ +Adapter re-invokes query({ options: { resume: sessionId, … } }) + ↓ +Full conversation context restored from JSONL; new prompt processes +normally, yielding another ResultMessage on completion. +``` + +Gap to bridge: the `wasUserAborted` intent tracking — the SDK's +`"error_during_execution"` subtype doesn't carry that distinction +natively. + +### FF'.2 — OpenCode SDK (`docs/opencode/sdk.md`, `docs/opencode/server.md`) + +#### Idle signal + +Two modes: + +1. **Synchronous** — `client.session.prompt({ path, body })` is a + REST call that blocks until the AI response is complete, returning + `{ info: AssistantMessage, parts: Part[] }` (`sdk.md`, "Sessions" + table). The method returning IS the idle signal. +2. **Asynchronous** — `POST /session/:id/prompt_async` returns + `204 No Content` immediately. Completion must be tracked through + the SSE event stream at `GET /event` (`server.md`, "Messages" + table). + +SSE stream behavior: `client.event.subscribe()` yields events starting +with `server.connected`. **The specific bus event type that signals +turn completion is NOT enumerated in the local docs.** The docs link +out to `types.gen.ts` (external) for schema details. This is a +documented gap for the paused-state spec. + +A `GET /session/status` endpoint returns +`{ [sessionID]: SessionStatus }`, but the `SessionStatus` field values +(e.g., `"idle"`, `"busy"`, `"aborted"`) are not defined in the local +docs. + +#### Abort primitives + +- `POST /session/:id/abort` → returns `boolean`. SDK wrapper: + `client.session.abort({ path: { id } })` (`sdk.md` / `server.md` + "Sessions"). +- `createOpencode({ signal: AbortSignal })` — but per the docs, this + signal is for **server startup timeout**, not per-request abort. + +Post-abort state: **not documented**. Specifically, the docs do not +describe: +- Whether the synchronous `prompt()` call returns a partial + `AssistantMessage` or throws +- Which SSE event type fires after abort completes +- Whether `SessionStatus` transitions to a specific value + +This is the **biggest single doc gap** among the three SDKs for +paused-state work. The Atomic adapter will likely need runtime +observation (spawn an OpenCode server, issue `abort()`, observe the +SSE bus) to characterize post-abort behavior. + +#### Session persistence + +- Sessions are server-managed: `session.create()`, `session.list()`, + `session.get()` — sessions persist on the server across calls. +- `POST /session/:id/fork` with optional `body: { messageID? }` → + returns a new `Session`. Closest analog to a checkpoint. +- `POST /session/:id/revert` with `body: { messageID, partID? }` and + the paired `POST /session/:id/unrevert`. Message-history-level + rewind (not file-level). +- `GET /session/:id/diff` with optional `messageID` returns + `FileDiff[]`. Implies git-level diff tracking but no dedicated file + rollback. + +No **file-level** checkpointing API is documented. The revert +endpoint operates on message history only. + +**Storage location for the server's session state is not +documented** in the local docs (contrast with Claude's explicit +`~/.claude/projects/…` path). + +#### Hooks + +The SDK/server docs do **not** describe any programmatic hook +registration API. Config-level hooks likely exist via `.opencode` +config (referenced in project `CLAUDE.md`) but are not documented in +the SDK docs. For paused-state purposes, assume no lifecycle hook +access at the SDK layer. + +#### TS types + +Local docs link out to `@opencode-ai/sdk`'s `types.gen.ts` (generated +from OpenAPI). No specific `"paused"`, `"interrupted"`, or +`"cancelled"` literal types are quoted in the local docs. + +#### Pause/resume mapping for OpenCode + +Flow: + +``` +User presses Ctrl+C + ↓ +Adapter calls client.session.abort({ path: { id } }) + ↓ +Adapter stores sessionId (known from session.create) + ↓ +??? post-abort behavior undocumented — likely SSE bus event or poll + GET /session/status to confirm idle state + ↓ +Adapter sets panel.status = "paused" + ↓ +[later] User requests resume + ↓ +Adapter calls client.session.prompt({ path: { id: sessionId }, + body: { message: } }) + ↓ +Server restores context (implicit — server-managed); new prompt +processes normally. +``` + +Gap: the middle "???" step needs empirical characterization before a +spec can lock down the event shape the adapter consumes. + +### FF'.3 — Copilot CLI SDK (`docs/copilot-cli/sdk.md`, `docs/copilot-cli/hooks.md`) + +#### Idle signal + +- **Primary**: `"session.idle"` event on the `CopilotSession` emitter + — `session.on("session.idle", () => { … })` (`sdk.md` "Quick + Start"). Zero payload. +- **Higher-level wrapper**: `session.sendAndWait(options, timeout?)` + — sends message and resolves on `"session.idle"`, returning + `AssistantMessageEvent | undefined`. +- Preceding `"assistant.message"` event carries full response + (`event.data.content`). + +Completion reason classification is not on the `"session.idle"` event +itself but on the session-end hook (see below). + +#### Abort primitives + +- `session.abort(): Promise` — "Abort the currently processing + message in this session" (`sdk.md`, "CopilotSession" methods). +- **Not** an `AbortController` pattern; abort is a method on the + session object. +- Post-abort behavior of partial `"assistant.message_delta"` events + in streaming mode is **not documented**. + +#### Interruption distinction (richest of the three) + +Session-end hook receives `input.reason`: + +```ts +reason: "complete" | "error" | "abort" | "timeout" | "user_exit" +``` + +(`hooks.md`, "Session end hook", "Fields" section.) + +This is the **most granular** termination-reason enumeration among +the three SDKs. `"abort"` specifically means programmatic +`session.abort()`; `"user_exit"` appears distinct (likely Ctrl+C at +the terminal). This lets the adapter distinguish +orchestrator-initiated pause from user-initiated exit cleanly. + +#### Session persistence ("Infinite Sessions") + +- `client.resumeSession(sessionId, config?)` — "Resume an existing + session." `config.onPermissionRequest` is mandatory on resume too. +- `client.listSessions(filter?)` → `SessionMetadata[]` with fields + `sessionId`, `startTime`, `modifiedTime`, `summary`, `context` + (`cwd`, `gitRoot`, `repository`, `branch`). +- `client.deleteSession(sessionId)` — removes a session and its data + from disk. +- `session.workspacePath` — present when infinite sessions are + enabled. Points to + `~/.copilot/session-state/{sessionId}/` containing: + - `checkpoints/` — native checkpoint directory + - `plan.md` + - `files/` — file state +- `"session.compaction_start"` / `"session.compaction_complete"` + events (with token counts) — part of infinite session management. + +The checkpoint file format under `checkpoints/` is **not documented** +in local docs. + +#### Hooks (`sdk.md` "Session Hooks"; `hooks.md` for shell hooks) + +SDK hook registration object: + +```ts +hooks: { + onPreToolUse: async (input, invocation) => { + permissionDecision: "allow" | "deny" | "ask", + modifiedArgs, additionalContext + }, + onPostToolUse: async (input, invocation) => { additionalContext }, + onUserPromptSubmitted: async (input, invocation) => { modifiedPrompt }, + onSessionStart: async (input, invocation) => { additionalContext }, + // input.source: "startup" | "resume" | "new" + onSessionEnd: async (input, invocation) => void, + // input.reason: "complete" | "error" | "abort" | "timeout" | "user_exit" + onErrorOccurred: async (input, invocation) => { + errorHandling: "retry" | "skip" | "abort" + }, +} +``` + +Caveat from `hooks.md`: for `preToolUse`, only `"deny"` is currently +processed — `"allow"` and `"ask"` "are not currently processed." This +limits hook-based gating for pause/resume decisions, but does not +affect the core pause detection via `onSessionEnd` + `reason: +"abort"`. + +No dedicated `beforeAbort` or `onSessionPause` hook — but +`onSessionEnd` with `reason: "abort"` covers detection cleanly. + +#### Pause/resume mapping for Copilot + +``` +User presses Ctrl+C + ↓ +Adapter calls session.abort() + ↓ +onSessionEnd hook fires with { input: { reason: "abort" } } + ↓ +Adapter sets panel.status = "paused", stores session.sessionId + ↓ +[later] User requests resume + ↓ +Adapter calls client.resumeSession(sessionId, { onPermissionRequest }) + ↓ +Session workspace restored (workspacePath/checkpoints/ if infinite +sessions enabled); new prompt processes normally. +``` + +Gap: whether `workspacePath` is populated depends on infinite-sessions +config. If disabled, file state is not tracked — resume restores +conversation only, not files. + +### FF'.4 — Cross-SDK Comparison (from local docs) + +| Dimension | Claude Agent SDK | OpenCode SDK | Copilot CLI SDK | +|---|---|---|---| +| **Idle signal type** | Async-generator yield: `SDKResultMessage` | REST return / SSE event (event type undocumented) | Named event: `"session.idle"` | +| **Abort primitive** | `AbortController`, `query.interrupt()`, `query.close()` | `POST /session/:id/abort` → `boolean` | `session.abort(): Promise` | +| **Interruption-specific signal** | `subtype: "error_during_execution"` (ambiguous — covers abort + errors) | **Not documented** | `onSessionEnd` hook with `reason: "abort"` (distinct from `"error"`, `"timeout"`, `"user_exit"`) | +| **Reason-code granularity** | 5 subtypes (`success`, `error_max_turns`, `error_during_execution`, `error_max_budget_usd`, `error_max_structured_output_retries`) | Not documented in local docs | 5 reasons (`complete`, `error`, `abort`, `timeout`, `user_exit`) | +| **Resume primitive** | `options: { resume: sessionId }` on `query()` | Send new prompt to same `sessionId` (server-managed) | `client.resumeSession(sessionId, config)` | +| **Conversation persistence** | Auto: `~/.claude/projects//.jsonl` | Server-managed (location undocumented) | `~/.copilot/session-state/{sessionId}/` (infinite sessions) | +| **File-level checkpointing** | Native: `enableFileCheckpointing` + `rewindFiles(uuid)` | Revert-by-message, no file rollback | `workspacePath/checkpoints/` (infinite sessions; format undocumented) | +| **Fork/branch** | `forkSession: true` on resume | `POST /session/:id/fork?messageID` | Not documented | +| **Streaming interrupt** | `query.interrupt()` **streaming input mode only** | Not mode-differentiated in docs | `session.abort()` in any mode | +| **Lifecycle hooks for pause** | `"Stop"`, `"SessionEnd"` (TS only), `"Notification"` (`idle_prompt`) | None documented at SDK layer | `onSessionEnd` (SDK hook), `sessionEnd` (shell config hook) | +| **User-abort vs. error distinction** | Must be tracked by adapter (subtype is ambiguous) | Must be tracked by adapter (undocumented) | **Native** — reason field distinguishes | +| **`"paused"` literal type** | Not defined — must infer | Not defined | Not defined | + +### FF'.5 — Implementation Blueprint Implied by the Docs + +Based on the above, the Atomic adapter layer needs these pieces per SDK: + +**Claude (clean path, conversation-level resume is deterministic):** +1. Wrap the query loop with a shared `wasUserAborted` intent flag set + by the interrupt handler before calling `query.interrupt()` / + `abortController.abort()`. +2. On `ResultMessage{ subtype: "error_during_execution" }`, inspect + the flag → emit `"paused"` (if true) or `"error"` (if false). +3. Persist `session_id` (already available on the message). +4. Resume: invoke `query({ options: { resume: session_id } })` with a + new prompt. + +**OpenCode (needs empirical characterization first):** +1. Call `client.session.abort({ path: { id } })`. +2. Open question: how does the adapter **confirm** the session has + reached an idle state post-abort? Options: poll + `GET /session/status`, or subscribe to `event.subscribe()` and + wait for an undocumented bus event. The spec will need empirical + testing or direct source inspection to resolve. +3. Store `session.id`. Resume: `session.prompt({ path: { id }, body: + })`. +4. Decision: whether to use `fork` for a branching-resume semantic. + +**Copilot (hook-based classification, cleanest reason-code):** +1. Register `onSessionEnd` hook on session creation. +2. Call `session.abort()` on Ctrl+C. `onSessionEnd` fires with + `reason: "abort"` — this is the authoritative pause signal. +3. Persist `session.sessionId` (already available). +4. Resume: `client.resumeSession(sessionId, { onPermissionRequest })`. +5. For file-level rollback: requires infinite sessions to be enabled + so `workspacePath/checkpoints/` is populated — the spec will + need to decide whether to require this. + +### FF'.6 — Updated Code References (Docs) + +- `docs/claude-code/agent-sdk/agent-loop.md` — `SDKResultMessage` + subtypes, agent loop completion semantics +- `docs/claude-code/agent-sdk/core-concepts/sessions.md` — + `~/.claude/projects//.jsonl`; `resume`, + `continue`, `forkSession`, `persistSession`, `resumeSessionAt` + options +- `docs/claude-code/agent-sdk/guides/file-checkpointing.md` — + `enableFileCheckpointing`, `rewindFiles`, user-message-UUID + checkpoint identifiers +- `docs/claude-code/agent-sdk/guides/streaming-vs-single-mode.md` — + `query.interrupt()` streaming-mode-only; single-mode limitations +- `docs/claude-code/agent-sdk/guides/hooks.md` — `"Stop"`, + `"SessionEnd"`, `"Notification"` (`idle_prompt`) use cases +- `docs/claude-code/agent-sdk/sdk-references/typescript.md` — + authoritative TS types (`SDKResultMessage`, `HookEvent`, + `PermissionResult.interrupt`, `Query.interrupt/close`) +- `docs/opencode/sdk.md` — `client.session.prompt`, `session.abort`, + `session.create/list/get`, `event.subscribe` +- `docs/opencode/server.md` — REST endpoints: + `POST /session/:id/abort`, `POST /session/:id/fork`, + `POST /session/:id/revert`, `GET /session/:id/diff`, + `GET /session/status`, `GET /event` +- `docs/copilot-cli/sdk.md` — `session.abort`, `session.idle`, + `sendAndWait`, `resumeSession`, `listSessions`, `deleteSession`, + `workspacePath`, infinite-sessions events + (`session.compaction_start`/`complete`) +- `docs/copilot-cli/hooks.md` — `onSessionEnd` `reason` enum + (`complete` / `error` / `abort` / `timeout` / `user_exit`); + `preToolUse` only-`deny`-is-processed caveat + +### FF'.7 — Resolved and Residual Open Questions + +**Resolved by the docs dive:** + +- *Is `"paused"` at the conductor layer the same as at the panel + layer?* → Clearly two different layers. `StageOutputStatus` + (conductor) and `SessionStatus` (panel) are independent types. + The proposed "paused" would be a **panel-facing** status that + derives from (but is not identical to) the conductor's + `"interrupted"` observation, plus the SDK adapter's classification + of abort-intent. + +- *Which SDK exception(s) map to paused?* → + - Claude: `ResultMessage{ subtype: "error_during_execution" }` + + adapter-tracked user-abort flag. + - Copilot: `onSessionEnd{ reason: "abort" }` (clean, native). + - OpenCode: **undocumented** — needs empirical characterization. + +- *Resume primitives exist for all three SDKs:* + - Claude — `resume: sessionId` (JSONL-backed, deterministic). + - OpenCode — same session ID + new prompt (server-managed). + - Copilot — `client.resumeSession(sessionId)` (with mandatory + `onPermissionRequest`). + +- *Partial-stream semantics:* Claude does NOT retract already-yielded + `input_json_delta` events on abort; Copilot & OpenCode partial + delta behavior on abort is not documented. + +- *Idempotency of abort:* **not documented for any SDK**. Remains a + residual open question requiring empirical validation. + +**Residual for the spec:** + +1. **OpenCode post-abort event shape** must be characterized + empirically or by reading `@opencode-ai/sdk` source. +2. **Does every SDK's resume preserve partial tool output from the + turn that was interrupted?** Claude's JSONL captures what was + persisted by the SDK up to the abort point, but a tool call + whose `input_json_delta` was still streaming would not have a + matching `ToolUseBlock` in the JSONL. The interrupted turn may + replay from the last fully-emitted user message. This affects + whether the resumed UI should "replay" or "skip" the interrupted + stage. +3. **File-level rollback policy.** Claude's `rewindFiles` rolls back + Write/Edit/NotebookEdit changes only (not `Bash`-initiated file + changes). Copilot's checkpoints (when infinite sessions enabled) + track `files/` but the format is undocumented. OpenCode has no + file-rollback API. Does the spec require file rollback on pause, + or only conversation rollback? +4. **For Claude interactive/tmux path** (where completion is detected + by pane-capture polling, not a `ResultMessage`): the SDK resume + primitive is still available for the headless path, but the + interactive path currently has no SDK-level session ID — it's a + raw terminal running the Claude CLI. How does the paused-state + model accommodate the pane-capture path, or is pausing + restricted to the headless path? (This links directly to the + primary topic: the tmux window is the resume target for the + interactive path, reinforcing why window-close suppression is a + prerequisite.) +5. **Permission-denial interrupts.** Claude's + `PermissionResult{ behavior: "deny", interrupt: true }` is a + second pathway into `"error_during_execution"`. Should this route + to paused or error? It's technically user-initiated (via a hook + the adapter registered) but semantically different from a direct + abort. + +### FF'.8 — Critical Pre-spec Investigations Needed + +Two items are blocking for a full spec draft and should be resolved +before committing to an implementation plan: + +1. **OpenCode post-abort signal**: spin up a local OpenCode server, + issue `client.session.abort()` mid-stream, and characterize: + - Which SSE bus event(s) fire after abort completes. + - What `GET /session/status` returns for the affected session. + - Whether `client.session.prompt()` (if it was mid-call) throws, + returns partial, or returns normally. + - Whether subsequent `session.prompt()` on the same session ID + works as expected post-abort. + +2. **SDK abort idempotency**: for each SDK, call abort twice in rapid + succession and once after the session has already completed. + Document whether the second call no-ops, throws, or corrupts + session state. This matters because a paused-state handler may + be invoked multiple times during a noisy Ctrl+C sequence. diff --git a/research/web/2026-04-15-tmux-keybind-suppression.md b/research/web/2026-04-15-tmux-keybind-suppression.md new file mode 100644 index 0000000000..e187ec3efa --- /dev/null +++ b/research/web/2026-04-15-tmux-keybind-suppression.md @@ -0,0 +1,341 @@ +--- +source_url: https://github.com/tmux/tmux (key-bindings.c, tmux.1 man page, notify.c); https://github.com/psmux/psmux (docs/compatibility.md, docs/keybindings.md, docs/scripting.md, docs/tmux_args_reference.md, docs/configuration.md) +fetched_at: 2026-04-15 +fetch_method: raw.githubusercontent.com direct file fetch +topic: tmux keybinding suppression for agent session windows — kill/rename blocking, if-shell conditionals, display-message, hooks, and psmux compatibility +--- + +# tmux Keybinding Suppression for Agent Session Windows + +## 1. Default Bindings That Close a Window/Pane + +Source: [tmux/tmux key-bindings.c](https://raw.githubusercontent.com/tmux/tmux/master/key-bindings.c) — the `key_bindings_init()` function is the definitive source of all compiled-in defaults. + +All bindings below are in the **prefix table** (require `Ctrl-b` first) unless marked otherwise. + +| Binding | Table | Action | +|---------|-------|--------| +| `prefix + &` | prefix | `confirm-before -p "kill-window #W? (y/n)" kill-window` | +| `prefix + x` | prefix | `confirm-before -p "kill-pane #P? (y/n)" kill-pane` | +| `prefix + d` | prefix | `detach-client` | +| `prefix + D` | prefix | `choose-client -Z` (choose which client to detach) | + +**No default keybinding** exists for `kill-server` or `kill-session` in the prefix or root tables. Those commands are accessible only via `prefix + :` (the command prompt). + +**Mouse menus** (root table, mouse events) also expose kill actions through context menus: +- Right-click on a window in the status bar → `DEFAULT_WINDOW_MENU` → "Kill" (`X`) → `kill-window` +- Right-click on a pane → `DEFAULT_PANE_MENU` → "Kill" (`X`) → `kill-pane` +- Right-click on session name in status bar → `DEFAULT_SESSION_MENU` → "Detach" (`d`) → `detach-client` + +Mouse menu root bindings (no prefix needed): +``` +bind -n MouseDown3Status → display-menu → DEFAULT_WINDOW_MENU (includes kill-window) +bind -n M-MouseDown3Status → display-menu → DEFAULT_WINDOW_MENU +bind -n MouseDown3StatusLeft → display-menu → DEFAULT_SESSION_MENU (includes detach-client) +bind -n MouseDown3Pane → display-menu → DEFAULT_PANE_MENU (includes kill-pane) +bind -n M-MouseDown3Pane → display-menu → DEFAULT_PANE_MENU +``` + +## 2. Default Bindings That Rename a Window or Session + +| Binding | Table | Action | +|---------|-------|--------| +| `prefix + ,` | prefix | `command-prompt -I'#W' { rename-window -- '%%' }` | +| `prefix + $` | prefix | `command-prompt -I'#S' { rename-session -- '%%' }` | +| `prefix + :` | prefix | `command-prompt` (opens tmux command line — user can type any command) | + +**Window menu** (right-click on status bar) also contains: +- "Rename" (`n`) → `command-prompt -FI "#W" { rename-window -t '#{window_id}' -- '%%' }` + +**Session menu** (right-click on session name in status bar) contains: +- "Rename" (`r`) → `command-prompt -I "#S" { rename-session -- '%%' }` + +### Suppressing `command-prompt` (`:`) + +Blocking `prefix + :` completely locks out ALL tmux commands, which may be too aggressive. A safer approach is to intercept `rename-window` and `rename-session` specifically via the `after-rename-window` and `session-renamed` hooks (see Section 5). + +## 3. Suppressing Keybindings Conditionally by Window Index or Name + +### Key Table Overview + +tmux has the following built-in key tables: +- **`prefix`** — active after the prefix key (`Ctrl-b` by default); this is the default table for `bind` +- **`root`** — active at all times without prefix; `bind -n` is an alias for `bind -T root` +- **`copy-mode`** — active when in copy mode (emacs key style) +- **`copy-mode-vi`** — active when in copy mode (vi key style) +- Custom tables are possible and activated with `switch-client -T ` + +`-n` flag is an alias for `-T root`. Example: +```tmux +bind -n C-x some-command # same as: +bind -T root C-x some-command +``` + +### Canonical Pattern: Conditional Suppression by Window Index + +Use `if-shell -F` with format variables to test the current window: + +```tmux +# Suppress kill-window (&) on window index 0 (agent windows): +bind & if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 2000 'kill-window disabled in agent sessions'" \ + "confirm-before -p 'kill-window #W? (y/n)' kill-window" + +# Suppress kill-pane (x) on windows named "agent": +bind x if-shell -F "#{==:#{window_name},agent}" \ + "display-message -d 2000 'kill-pane disabled in agent sessions'" \ + "confirm-before -p 'kill-pane #P? (y/n)' kill-pane" + +# Suppress rename-window (,) on window index 0: +bind , if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 2000 'rename disabled in agent sessions'" \ + "command-prompt -I'#W' { rename-window -- '%%' }" +``` + +**Key points:** +- `if-shell -F` evaluates the first argument as a **format string** (not a shell command). Returns "success" if the format expands to a non-empty, non-zero string. +- `#{==:a,b}` is the equality comparison format: expands to `1` if `a == b`, `0` otherwise. +- `#{window_index}` is the zero-based window index (affected by `base-index` option). +- All `bind` commands without `-T` go into the **prefix table** by default. + +### Suppressing Mouse Menu Kill Actions + +Mouse menus come from `display-menu` calls in root-table bindings. To prevent kill-window via right-click on the status bar, override the entire mouse menu binding with a conditional: + +```tmux +# Override right-click on window in status bar for agent windows: +bind -n MouseDown3Status \ + if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 2000 'Context menu disabled in agent window'" \ + "display-menu -t= -xW -yW -T '#[align=centre]#{window_index}:#{window_name}' \ + ' Swap Left' l {swap-window -t:-1} \ + ' Swap Right' r {swap-window -t:+1} \ + '' \ + ' New After' w {new-window -a} \ + ' New At End' W {new-window}" +``` + +### Alternative: Unbind Completely (Nuclear Option) + +```tmux +unbind & # remove kill-window binding entirely +unbind x # remove kill-pane binding entirely +unbind , # remove rename-window binding entirely +unbind $ # remove rename-session binding entirely +unbind d # remove detach-client binding entirely +``` + +This is simpler but applies globally (no per-window discrimination). + +## 4. `display-message` — Non-blocking Status Feedback + +Source: [tmux.1 man page](https://raw.githubusercontent.com/tmux/tmux/master/tmux.1) lines ~7195+ + +### Synopsis +``` +display-message [-aCIlNpv] [-c target-client] [-d delay] [-t target-pane] [message] +alias: display +``` + +### Flags +- **`-d delay`** — Number of **milliseconds** to show the message on the status bar. If not given, uses the `display-time` session option (default: 750ms). If `delay = 0`, message stays until a key is pressed. +- **`-N`** — Ignore key presses; message closes only after the delay expires (makes it truly non-blocking from the user's perspective since keypresses don't dismiss it early). +- **`-p`** — Print to stdout instead of the status line (useful for scripting). +- **`-l`** — Print the message literally (skip format expansion). +- **`-a`** — List all format variables and their values. + +### Behavior: Blocking vs Non-blocking + +`display-message` is **non-blocking** — it returns immediately and schedules the message to disappear after `delay` ms. The tmux server continues processing commands. The user can interact with the terminal normally while the message is displayed. + +Exception: if `delay = 0` (or `display-time = 0`), it becomes **blocking** — the message stays until a key is pressed. + +### display-message vs display-popup + +| Feature | `display-message` | `display-popup` | +|---------|------------------|-----------------| +| Appears in | Status bar (1 line) | Floating modal box over panes | +| Blocks pane updates | No | Yes — panes not updated while popup is open | +| Requires dismissal | No (auto-expires) | Yes (Ctrl-C or `q` or `display-popup -C`) | +| Multi-line | No | Yes (runs a shell command) | +| Use for | Brief feedback, status | Interactive choices, help text | + +Example for user feedback when suppressing a key: +```tmux +display-message -d 3000 "Agent window: kill-window is disabled" +# Shows for 3 seconds, then automatically disappears, non-blocking +``` + +## 5. Hooks That Fire on Window Events + +Source: [tmux.1 HOOKS section](https://raw.githubusercontent.com/tmux/tmux/master/tmux.1) lines ~5621+; also [notify.c](https://raw.githubusercontent.com/tmux/tmux/master/notify.c) + +### Hook Names (Built-in, Non-command Hooks) + +| Hook | When It Fires | +|------|---------------| +| `window-linked` | When a window is linked into a session | +| `window-unlinked` | When a window is unlinked from a session | +| `window-renamed` | When a window is renamed | +| `window-resized` | When a window is resized (after `client-resized`) | +| `window-layout-changed` | When the pane layout in a window changes | +| `client-resized` | When a client is resized | +| `session-renamed` | When a session is renamed | +| `session-created` | When a new session is created | +| `session-closed` | When a session is closed | +| `client-attached` | When a client is attached | +| `client-detached` | When a client is detached | +| `client-focus-in` / `client-focus-out` | When focus enters/exits a client | +| `pane-died` | When pane command exits and `remain-on-exit` is on | +| `pane-exited` | When pane command exits | +| `command-error` | When any tmux command fails | + +### After-Hooks (Per-Command Hooks) + +Every tmux command automatically generates a corresponding `after-` hook that fires when the command completes. For window management: + +| Hook Name | Fired After | +|-----------|-------------| +| `after-kill-window` | `kill-window` completes | +| `after-rename-window` | `rename-window` completes | +| `after-rename-session` | `rename-session` completes | +| `after-kill-pane` | `kill-pane` completes | +| `after-new-window` | `new-window` completes | +| `after-split-window` | `split-window` completes | + +**Note:** After-hooks do NOT fire when the command runs as part of another hook (prevents infinite loops). + +### Hook Usage Syntax + +```tmux +# Global hook (applies to all sessions): +set-hook -g window-renamed 'display-message -d 2000 "Window renamed: #{window_name}"' + +# Revert a rename attempt on agent windows (window index 0): +set-hook -g after-rename-window \ + 'if-shell -F "#{==:#{window_index},0}" \ + "rename-window -- \"agent\"; display-message -d 2000 \"Window name locked\""' + +# Log all kill-window events: +set-hook -g after-kill-window 'run-shell "echo killed >>/tmp/tmux-audit.log"' + +# Append to existing hooks (array behavior): +set-hook -ga window-renamed 'run-shell "echo \"renamed: #{window_name}\" >> /tmp/log"' +``` + +**`set-hook` flags:** +- `-g` — global (all sessions) +- `-a` — append (add to hook array, don't replace) +- `-u` — unset +- `-R` — run the hook immediately (for testing) +- `-w` — window-scoped +- `-p` — pane-scoped + +Hooks can also be set via `set-option` — the following two are equivalent: +```tmux +set-hook -g pane-mode-changed[42] 'set -g status-left-style bg=red' +set-option -g pane-mode-changed[42] 'set -g status-left-style bg=red' +``` + +## 6. psmux (Windows) Compatibility + +Source: [psmux/psmux README](https://raw.githubusercontent.com/psmux/psmux/master/README.md), [compatibility.md](https://raw.githubusercontent.com/psmux/psmux/master/docs/compatibility.md), [tmux_args_reference.md](https://raw.githubusercontent.com/psmux/psmux/master/docs/tmux_args_reference.md), [keybindings.md](https://raw.githubusercontent.com/psmux/psmux/master/docs/keybindings.md) + +psmux is a **native Windows tmux re-implementation in Rust** (not a wrapper). It supports 76 tmux commands, 126+ format variables, 15+ hooks, and reads `.tmux.conf` directly. + +### Verified Supported Features + +| Feature | psmux Support | Notes | +|---------|---------------|-------| +| `if-shell -F` | YES | Listed explicitly in compatibility table | +| `display-message -d delay` | YES | `"aCc:d:lINpt:F:v"` flags documented in tmux_args_reference.md — `-d` is present | +| `#{window_index}` format variable | YES | Part of 126+ format variables including session/window/pane variables | +| `#{==:a,b}` format comparisons | YES | "Conditional expressions (`#{?condition,true,false}`)" and string comparisons documented | +| `set-hook` | YES | `"agpRt:uw"` flags documented in tmux_args_reference.md | +| `window-linked` hook | LIKELY YES | "15+ event hooks" listed; compatibility.md cites `after-new-window` as example | +| `window-renamed` hook | LIKELY YES | Same as above | +| `after-kill-window` hook | LIKELY YES | Same as above | +| `bind-key -T root` | YES | "bind-key/unbind-key with key tables" in compatibility table | +| `kill-window`, `kill-pane`, `kill-session`, `kill-server` | YES | All in tmux_args_reference.md command list | +| `confirm-before` | YES | Part of the 76 commands (not listed separately, assumed part of core) | +| `command-prompt` | YES | `"1beFiklI:Np:t:T:"` flags documented in tmux_args_reference.md | + +### Default psmux Keybindings + +psmux matches tmux defaults exactly (from docs/keybindings.md): +- `Prefix + &` → Kill current window (with confirmation) +- `Prefix + x` → Kill current pane (with confirmation) +- `Prefix + ,` → Rename current window +- `Prefix + $` → Rename session +- `Prefix + d` → Detach from session + +### psmux-Specific Considerations + +- Default shell is **PowerShell 7 (`pwsh`)** — shell commands in `if-shell` (without `-F`) run in PowerShell, not `/bin/sh`. Use `if-shell -F` with format conditionals (no shell invocation) to stay cross-platform. +- No `/bin/sh` on Windows — `run-shell` defaults to PowerShell. For cross-platform hooks, use tmux format expressions via `if-shell -F` instead of shell scripts. +- psmux adds Windows-specific options (`prediction-dimming`, `cursor-style`, `cursor-blink`, `claude-code-fix-tty`, etc.) that are ignored by real tmux. +- The specific 15+ hooks psmux supports are not individually enumerated in public docs; `after-new-window` is the only example cited. Treat hook support as "best effort" and test on Windows. + +## 7. Complete Config Pattern: Agent Window Protection + +```tmux +# ============================================================ +# Agent Window Protection +# Applied via: tmux -L atomic -f +# ============================================================ + +# Suppress kill-window for agent windows (index 0): +bind & if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 3000 '[atomic] Agent window cannot be closed'" \ + "confirm-before -p 'kill-window #W? (y/n)' kill-window" + +# Suppress kill-pane for agent windows: +bind x if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 3000 '[atomic] Agent pane cannot be closed'" \ + "confirm-before -p 'kill-pane #P? (y/n)' kill-pane" + +# Suppress rename-window for agent windows: +bind , if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 3000 '[atomic] Agent window name is locked'" \ + "command-prompt -I'#W' { rename-window -- '%%' }" + +# Suppress rename-session for agent windows: +bind '$' if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 3000 '[atomic] Session rename disabled in agent windows'" \ + "command-prompt -I'#S' { rename-session -- '%%' }" + +# Suppress detach in agent windows (optional — may be too aggressive): +# bind d if-shell -F "#{==:#{window_index},0}" \ +# "display-message -d 3000 '[atomic] Detach disabled in agent window'" \ +# "detach-client" + +# Block mouse right-click kill on agent windows: +bind -n MouseDown3Status \ + if-shell -F "#{==:#{window_index},0}" \ + "display-message -d 2000 '[atomic] Context menu disabled'" \ + "display-menu -t= -xW -yW -T '#[align=centre]#{window_index}:#{window_name}' \ + ' Swap Left' l {swap-window -t:-1} \ + ' Swap Right' r {swap-window -t:+1} \ + '' \ + ' New After' w {new-window -a} \ + ' New At End' W {new-window}" + +# Hook: revert rename attempts on agent windows (belt-and-suspenders): +set-hook -g after-rename-window \ + 'if-shell -F "#{==:#{window_index},0}" \ + "rename-window -- \"agent\"; display-message -d 2000 \"[atomic] Window name reverted\""' + +# Hook: log window-unlink events (debug): +# set-hook -g window-unlinked 'run-shell "echo unlinked >> /tmp/atomic-audit.log"' +``` + +## References + +- tmux source (key-bindings.c, authoritative defaults): https://github.com/tmux/tmux/blob/master/key-bindings.c +- tmux man page (tmux.1): https://raw.githubusercontent.com/tmux/tmux/master/tmux.1 +- tmux notify.c (hook triggering): https://github.com/tmux/tmux/blob/master/notify.c +- psmux README: https://raw.githubusercontent.com/psmux/psmux/master/README.md +- psmux compatibility.md: https://raw.githubusercontent.com/psmux/psmux/master/docs/compatibility.md +- psmux tmux_args_reference.md: https://raw.githubusercontent.com/psmux/psmux/master/docs/tmux_args_reference.md +- psmux keybindings.md: https://raw.githubusercontent.com/psmux/psmux/master/docs/keybindings.md +- psmux scripting.md: https://raw.githubusercontent.com/psmux/psmux/master/docs/scripting.md diff --git a/specs/2026-04-15-tmux-window-close-rename-suppression.md b/specs/2026-04-15-tmux-window-close-rename-suppression.md new file mode 100644 index 0000000000..110f0c674c --- /dev/null +++ b/specs/2026-04-15-tmux-window-close-rename-suppression.md @@ -0,0 +1,689 @@ +# Tmux Window Close / Rename Suppression + Workflow-Stage Ctrl+C/ESC Blocking — Technical Design Document + +| Document Metadata | Details | +| ---------------------- | ----------- | +| Author(s) | lavaman131 | +| Status | Draft (WIP) | +| Team / Owner | Atomic CLI | +| Created / Last Updated | 2026-04-15 | + +## 1. Executive Summary + +Atomic CLI embeds agent CLIs (Claude Code, OpenCode, Copilot CLI) inside tmux windows on a dedicated `-L atomic` socket. Today, tmux's default bindings for window close (`prefix + &`), pane close (`prefix + x`), window rename (`prefix + ,`), session rename (`prefix + $`), and the arbitrary command prompt (`prefix + :`) are all still live — any one of them lets a user destroy or corrupt an active agent session with no safeguards. Double-Ctrl+C inside any agent CLI also exits the CLI, orphaning the orchestrator. This spec proposes **unsetting the tmux prefix entirely** (`set -g prefix None`) on the isolated `-L atomic` server — eliminating every prefix-gated destructive command in one stroke — plus overriding the two root-table mouse right-click menus and adding conditional `Ctrl+C` / `Escape` blocks. Suppressed actions are **silently ignored** (no toast, no menu, no feedback — the user simply cannot take the action). The only exit path from an active workflow is `q` in the orchestrator window (unchanged from today). Because all bindings are scoped to the isolated `-L atomic` server, the user's personal tmux is untouched. This work is a prerequisite for the paused-state objective (ticket #003) in chat sessions, where a clean resume target requires the tmux window to survive. + +> **Design stance (per user direction during open-question resolution):** tmux is an *invisible environment* for the Atomic UI. Users should not need — nor be able — to run any tmux commands directly. The prefix key is disabled, destructive mouse menus are blocked, and Ctrl+C/ESC are suppressed in every agent-window context. The only recognized user-facing keys are the Atomic-defined prefix-free bindings (`C-g`, `C-\\`) and the orchestrator's `q`. + +> **Research reference:** [research/docs/2026-04-15-tmux-window-close-rename-suppression.md](../research/docs/2026-04-15-tmux-window-close-rename-suppression.md) (authoritative codebase survey + SDK deep-dive) +> **Web reference:** [research/web/2026-04-15-tmux-keybind-suppression.md](../research/web/2026-04-15-tmux-keybind-suppression.md) (tmux/psmux default binding tables, `if-shell -F` semantics) + +## 2. Context and Motivation + +### 2.1 Current State + +All tmux operations channel through `tmuxRun()` at `src/sdk/runtime/tmux.ts:138-153`, which injects `-f -L atomic` on every invocation. The bundled config at `src/sdk/runtime/tmux.conf` already ships sane defaults, prefix-free navigation (`C-g` to graph, `C-\\` next-window), vi-mode copy semantics, and `allow-rename off` to block **programmatic** renames. The `-L atomic` socket gives complete server-level isolation from the user's personal tmux. + +``` +┌──────────────── tmux -L atomic (isolated Atomic server) ─────────────────┐ +│ Workflow session: atomic-wf--- │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ Window 0 │ │ Window 1 │ │ Window 2 │ │ +│ │ orches- │ │ agent CLI │ │ agent CLI │ │ +│ │ trator │ │ (Claude) │ │ (Copilot) │ │ +│ │ OpenTUI │ │ terminal │ │ terminal │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ Chat session: atomic-chat-- │ +│ ┌────────────┐ │ +│ │ Window 0 │ ← single agent window, no orchestrator │ +│ │ agent CLI │ │ +│ └────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +- **Session taxonomy** is unambiguous via `parseSessionName()` at `src/sdk/runtime/tmux.ts:466-492`: + - Workflow: `atomic-wf---` — window 0 is the orchestrator (OpenTUI React TUI), windows 1+ are agent CLIs. + - Chat: `atomic-chat--` — exactly one window running the agent CLI (no orchestrator). +- **Exit/abort plumbing** (`src/sdk/runtime/executor.ts:1090-1193`, `src/sdk/components/orchestrator-panel-store.ts:159-165`): `q` or `Ctrl+C` captured by the OpenTUI `useKeyboard` in window 0 routes to `store.requestQuit()` → `resolveAbort()` → `WorkflowAbortError` → `shutdown(0)` → `panel.destroy()` + `tmux.killSession()`. +- **Inside agent windows**: no tmux-level interception of any key. Ctrl+C, ESC, `q`, `prefix + &`, `prefix + ,`, and the mouse right-click Kill menus all reach the embedded CLI or the tmux server unchanged. + +### 2.2 The Problem + +- **Unrecoverable session destruction.** A user who accidentally presses `prefix + &` or chooses "Kill" from the right-click menu kills their agent window mid-workflow. The SDK session is lost; there is no checkpoint to resume from (§FF.5 of the research). `prefix + x` closes the pane with the same effect. +- **Unrecoverable rename.** `prefix + ,` opens a tmux command-prompt that calls `rename-window` directly, bypassing `allow-rename off` (which only blocks process-initiated renames). A renamed window breaks session-to-agent routing logic that keys off `window_name` (e.g., `session-graph-panel.tsx:373-394` polls `display-message -p "#{window_index} #{window_name}"` to sync attached-mode state). +- **Unintended CLI exit.** Inside a Claude / OpenCode / Copilot CLI, the standard "interrupt-then-quit" pattern is double-Ctrl+C. When the CLI exits, the tmux pane dies with it (no `remain-on-exit`), closing the window and leaving the orchestrator with an orphaned graph node. This path has no safeguard today. +- **Workflow stages cannot be safely interrupted today.** The current interrupt handler (`src/services/workflows/conductor/conductor.ts` → `currentSession?.abort?.()`) aborts the stage's SDK session but the panel-side `Ctrl+C`/ESC path currently terminates the whole workflow. Per the user's added requirement, workflows should run to a natural completion point and not allow users to pause/resume mid-stage via keyboard — the only clean exit must be `q` on the orchestrator window. +- **No user feedback** when tmux defaults are about to do something destructive. Currently, `prefix + &` shows `confirm-before -p "kill-window #W? (y/n)"` — the user's `y` immediately kills the window. There is no signal that this action is invalid during an agent session. + +### 2.3 Why Now + +- The paused-state objective (ticket #003; see research §FF.1–FF'.8) depends on the tmux window surviving as a resume target for Claude's interactive/pane-capture path. Without window-close suppression, the paused-state spec cannot land. +- Users have started reporting accidental kills during workflow runs (anecdotal; confirmed by the need to add the `allow-rename off` in the original tmux UX ticket [research/web/2026-04-10-tmux-ux-improvements.md](../research/web/2026-04-10-tmux-ux-improvements.md)). +- The config infrastructure (single `tmux.conf` + `source-file` reload inside `createSession()` at `tmux.ts:226-228`) is already in place — this change is additive and low-risk. + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- [ ] **G1.** **All tmux prefix bindings are disabled** server-wide on the `-L atomic` socket via `set -g prefix None`. This single change eliminates `prefix + &`, `x`, `,`, `$`, `:`, `d`, `D`, and every other prefix binding — including the Atomic-defined pane-split (`-`, `|`) and resize (`h/j/k/l`) bindings, which are not needed since workflows use windows (not panes within a window). The user can no longer issue any tmux command through the prefix sequence. +- [ ] **G2.** Mouse right-click context menus (`MouseDown3Status`, `MouseDown3Pane`) are silently suppressed on agent windows (workflow `window_index ≥ 1`, plus every chat-session window). Right-click simply does nothing; no menu, no toast. +- [ ] **G3.** `Ctrl+C` and `Escape` are silently swallowed on: + - **Chat-session windows (always)** — chat sessions are agent-interactive and users will have a dedicated paused-state flow in ticket #003; until that lands, these keys are non-functional in chat. + - **Workflow-session agent windows (index ≥ 1) while a stage is active** — indicated by the session env var `ATOMIC_STAGE_ACTIVE` being non-empty. Between stages (stage-idle), these keys pass through to the CLI natively. + Suppressed keys are silently ignored — no toast, no message, no visible feedback. The action simply does nothing. +- [ ] **G4.** `q` in the orchestrator window continues to route through the existing `store.requestQuit()` → `resolveAbort()`/`resolveExit()` path (unchanged) and remains the sole exit from an active workflow. +- [ ] **G5.** The user's personal tmux on the default socket (separate server) is unaffected: all bindings there remain at tmux defaults. +- [ ] **G6.** The orchestrator window (workflow window 0) is subject to the same prefix-None rule as agent windows. `&`, `,`, and every other prefix binding are inert there too — `q` is the only exit path. (This reflects Q2's resolution: suppress `&` entirely on window 0.) +- [ ] **G7.** `ATOMIC_STAGE_ACTIVE` is cleared at `runOrchestrator()` startup (to recover from any crashed prior run) and managed via a `finally` clause around stage bodies. +- [ ] **G8.** Suppression rules cover windows created after initial config load, because all bindings are evaluated at keypress time against current window/session state. +- [ ] **G9.** The tmux status bar is **hidden** whenever the orchestrator window (window 0 of a workflow session) is active. The OpenTUI `Statusline` component (`src/sdk/components/statusline.tsx`) already renders navigation hints (`↑↓←→ navigate · ↵ attach · / stages · q quit`) at the bottom of window 0, and the tmux status row beneath it is redundant visual noise. When the user switches to an agent window (index ≥ 1), the tmux status bar returns (showing the agent list / mode hints that are today managed by `session-graph-panel.tsx:400-421`). Chat sessions retain the tmux status bar at all times (they have no OpenTUI Statusline to replace it). + +### 3.2 Non-Goals (Out of Scope) + +- [ ] We will **not** implement the paused-state semantics in this spec; that is ticket #003 and lives in `specs/2026-03-25-workflow-interrupt-stage-advancement-fix.md` + its follow-up. This spec only reserves the integration surface (`ATOMIC_STAGE_ACTIVE` env var) and cross-references the handoff point. +- [ ] We will **not** modify the user's personal tmux configuration or default socket. All changes live in `src/sdk/runtime/tmux.conf` which is injected only for the `-L atomic` server. +- [ ] We will **not** change the existing `q`/`Ctrl+C` handling in `src/sdk/components/session-graph-panel.tsx:241-244` (OpenTUI React layer in orchestrator window). `q` remains the clean-exit key there. +- [ ] We will **not** preserve any prefix binding. The `set -g prefix None` rule is absolute — pane split (`-`, `|`), pane resize (`h/j/k/l`), and detach (`prefix + d`) are all made inaccessible. Atomic workflows use windows (not panes); split/resize are vestigial. Detach via shell (`tmux -L atomic detach-client`) is still possible for extreme recovery but not via the in-session prefix. +- [ ] We will **not** add toast messages for suppressed actions. Per user direction (Q6), suppression is silent. This is a deliberate divergence from the original acceptance criterion "Suppressed actions show a non-blocking status message" — see §9.1 for the rationale. +- [ ] We will **not** introduce a new server-side state machine in `tmux.conf`. All state checks are done via tmux format variables (`#{window_index}`, `#{session_name}`, per-session env vars) evaluated at keypress time. +- [ ] We will **not** touch the SDK adapter layers (`src/sdk/providers/*.ts`). SDK-side interrupt wiring is the subject of ticket #003. +- [ ] We will **not** add `after-kill-window` or `after-rename-window` reversal hooks. With `prefix = None`, the primary bypass paths (`prefix + :` command-prompt, prefix-bound destructive bindings) are all inaccessible. An external shell running `tmux -L atomic kill-window …` is outside the user-inside-tmux threat model. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#f8f9fa','primaryTextColor':'#2c3e50','primaryBorderColor':'#4a5568','lineColor':'#4a90e2','secondaryColor':'#ffffff','tertiaryColor':'#e9ecef','background':'#f5f7fa','mainBkg':'#f8f9fa','nodeBorder':'#4a5568','clusterBkg':'#ffffff','clusterBorder':'#cbd5e0','edgeLabelBackground':'#ffffff'}}}%% + +flowchart TB + classDef person fill:#5a67d8,stroke:#4c51bf,stroke-width:3px,color:#ffffff,font-weight:600,font-size:14px + classDef systemCore fill:#4a90e2,stroke:#357abd,stroke-width:2.5px,color:#ffffff,font-weight:600,font-size:14px + classDef systemSupport fill:#667eea,stroke:#5a67d8,stroke-width:2.5px,color:#ffffff,font-weight:600,font-size:13px + classDef database fill:#48bb78,stroke:#38a169,stroke-width:2.5px,color:#ffffff,font-weight:600,font-size:13px + classDef external fill:#718096,stroke:#4a5568,stroke-width:2.5px,color:#ffffff,font-weight:600,font-size:13px,stroke-dasharray:6 3 + + User(("◉
User
keystrokes")):::person + + subgraph atomic["◆ tmux -L atomic (isolated server)"] + direction TB + ConfigFile[("tmux.conf
+ guarded bindings
if-shell -F conditionals")]:::database + BindingEval{{"Keybinding dispatcher
evaluates window state
at keypress time"}}:::systemCore + Orchestrator["Window 0
Orchestrator
OpenTUI / useKeyboard
q → clean exit"]:::systemSupport + AgentWin["Window N ≥ 1
Agent CLI
Claude / OpenCode / Copilot"]:::systemSupport + Toast{{"display-message -d
non-blocking toast
status bar"}}:::systemCore + end + + UserTmux{{"User's personal tmux
default socket
UNAFFECTED"}}:::external + + User -->|"prefix + & / ,"| BindingEval + User -->|"Ctrl+C / Escape"| BindingEval + User -->|"mouse right-click"| BindingEval + + ConfigFile -->|"loaded via -f /
source-file on create"| BindingEval + + BindingEval -->|"window_index == 0
(orchestrator)"| Orchestrator + BindingEval -->|"agent window"| AgentWin + AgentWin -.->|"silently ignored"| AgentWin + + User -.->|"separate server"| UserTmux + + style atomic fill:#ffffff,stroke:#cbd5e0,stroke-width:2px,color:#2d3748,stroke-dasharray:8 4,font-weight:600,font-size:12px +``` + +### 4.2 Architectural Pattern + +**Config-as-Policy with a Prefix Lockout.** The dominant mechanism is a single line: `set -g prefix None`. This server-wide setting disables the entire prefix key, eliminating every prefix-gated tmux command in one stroke — without needing a per-key `unbind` + conditional rebind. Three additional surgical overrides in the root table handle the remaining non-prefix paths: the two mouse context menus (`MouseDown3Status`, `MouseDown3Pane`) and the two blocked keys (`C-c`, `Escape`). + +Why this pattern fits: +- **Simplicity:** One config line replaces ~30 lines of per-binding suppression logic. Easier to review, easier to maintain, and impossible to get wrong because there is no branching on prefix keys. +- **Completeness:** The prefix lockout closes `prefix + :` — the largest known bypass path (documented in research §FF.1) — by construction. Any future tmux default binding added upstream is automatically covered without further config changes. +- **Performance:** `if-shell -F` on the remaining bindings evaluates format expressions without spawning a shell, so every keypress is O(μs) (see [research/web/2026-04-15-tmux-keybind-suppression.md §2](../research/web/2026-04-15-tmux-keybind-suppression.md)). +- **Cross-platform:** The same pattern works identically on tmux (macOS/Linux) and psmux (Windows); format-only conditionals avoid the `/bin/sh` vs PowerShell divergence of `run-shell`. +- **Isolation:** Because bindings live on the `-L atomic` server, they cannot leak into the user's personal tmux — criterion G5 is structural, not enforced by code. + +### 4.3 Key Components + +| Component | Responsibility | Technology / Location | Justification | +| -------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prefix lockout | Disable the entire tmux prefix key | `src/sdk/runtime/tmux.conf` — `set -g prefix None` | One line; covers every prefix-bound destructive command including `:kill-window`. | +| Mouse menu override | Silently suppress right-click context menus on agent windows | `bind -n MouseDown3Status` / `MouseDown3Pane` bound to no-op with agent-window guard | Two root-table bindings handle the only remaining non-prefix destructive UI | +| Agent-window detector expression | Format expression distinguishing agent from orchestrator windows | Inline in tmux.conf (`#{\|\|:#{!=:#{window_index},0},#{m/r:^atomic-chat-,#{session_name}}}`) | No new code; pure format evaluation | +| Workflow-active env flag | Per-session env var signalling a workflow stage is in flight | `tmux set-environment -t ATOMIC_STAGE_ACTIVE 1` (set by executor at stage start; unset at end) | Uses existing `setSessionEnv()` at `src/sdk/runtime/tmux.ts:437-439` and `getSessionEnv()` at `tmux.ts:445-451` | +| Stage-active lifecycle hook | Set/unset `ATOMIC_STAGE_ACTIVE` at the right moments | `src/sdk/runtime/executor.ts` createSessionRunner | Minimal delta — counter increment/decrement around the SDK query call | +| Startup sweep | Clear any stale `ATOMIC_STAGE_ACTIVE` on orchestrator startup | `src/sdk/runtime/executor.ts:runOrchestrator` (near panel init) | Single-line defense against crashed prior runs (Q3 resolution) | +| Ctrl+C / ESC guards | Silently swallow these keys on agent windows under the right conditions | Two `bind -n` lines in `tmux.conf` | Chat sessions: always block. Workflow sessions: block when `ATOMIC_STAGE_ACTIVE` set | +| Orchestrator status-bar hiding | Toggle tmux `status` on/off based on the active window | `set-hook` on `after-select-window` / `client-attached` / `session-created` in `tmux.conf` | Fires immediately on window transitions (no polling lag); coexists with the existing React `useEffect` at `session-graph-panel.tsx:400-421` that manages status *content* | + +## 5. Detailed Design + +### 5.1 API Interfaces + +This feature is a config-file change plus two executor-side env writes. There is no new TypeScript API. The internal contracts are: + +**Executor → tmux (env write):** + +```ts +// At the start of a workflow stage (just before the first send to the pane) +tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", windowName); + +// At stage completion / failure / abort +tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", ""); +``` + +We store the **window name** (not just `"1"`) so a multi-stage workflow where different windows are active at different times still scopes correctly. The empty string is treated as "no stage active" (tmux has no native "unset" via set-environment; an empty value is the canonical sentinel — see `src/sdk/runtime/tmux.ts:437-439`). + +**tmux.conf → user (keypress evaluation):** + +A set of new bindings, each with the shape: + +```tmux +bind if-shell -F "" \ + "" \ + "display-message -d 2500 ''" +``` + +The condition for "is this an agent window?" (primary gate): + +``` +#{||:#{!=:#{window_index},0},#{m/r:^atomic-chat-,#{session_name}}} +``` + +Reads: "window_index is not 0, OR the session name starts with `atomic-chat-`." A window satisfying this expression is an agent window. The orchestrator (window 0 of a workflow session) is the only case that fails both clauses. + +The condition for "is a workflow stage currently running?" (secondary gate, only for Ctrl+C/ESC): + +``` +#{!=:#{S:ATOMIC_STAGE_ACTIVE},} +``` + +Reads: "the session env var `ATOMIC_STAGE_ACTIVE` is not empty." The `#{S:NAME}` format pulls a session environment variable at evaluation time. + +### 5.2 Data Model / Schema + +**Session environment variable (new):** + +| Variable | Set by | Unset by | Values | Purpose | +| --------------------- | ------------------------------------------------- | ---------------------------------- | ----------------------------------------- | --------------------------------------------------------------------- | +| `ATOMIC_STAGE_ACTIVE` | `executor.ts::createSessionRunner` at stage start | Same function, in `finally` clause | Window name of the active stage, or empty | Signals to the tmux keybinding evaluator that Ctrl+C/ESC should block | + +No other schema changes. The existing `ATOMIC_AGENT` session env var set at `src/commands/cli/chat/index.ts:222` and consumed in `listSessions()` at `tmux.ts:537` is not touched. + +### 5.3 Algorithms and State Management + +#### 5.3.1 Agent-window detection + +Evaluated fresh at every keypress by tmux. No state to manage; the expression is pure function of: +- `#{window_index}` — integer, stable per window +- `#{session_name}` — string, stable per session + +``` +is_agent_window(window_index, session_name) = + window_index != 0 // any non-orchestrator window in a workflow session + OR + session_name matches /^atomic-chat-/ // every window in a chat session +``` + +#### 5.3.2 Workflow-stage-active state machine + +State lives in tmux session env (`ATOMIC_STAGE_ACTIVE`). Transitions are driven by the executor. + +``` + ┌────────────────┐ stage starts ┌────────────────────┐ + │ STAGE_IDLE │ ──────────────────────► │ STAGE_ACTIVE │ + │ env="" │ │ env= │ + └────────────────┘ ◄────────────────────── └────────────────────┘ + ▲ stage completes / errors │ + │ │ + │ stage aborted │ + └───────────────────────────────────────────────┘ +``` + +The transitions happen in `src/sdk/runtime/executor.ts` inside `createSessionRunner()` where `s.session.query()` is invoked: + +```ts +// Pseudocode — actual integration in §5.4.2 +async function runStage(s: SessionHandle, /* ... */) { + tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", s.name); + try { + // existing stage body — send prompt, wait for idle, capture transcript + await s.session.query(/* ... */); + } finally { + tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", ""); + } +} +``` + +**Concurrency note:** Two stages in the same session do not run concurrently in the current design (stages are either sequential via `await` or parallel via `Promise.all`, but parallel stages are in *separate windows* of the same session — see `src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts`). For parallel stages, the env var holds only the most-recently-set window name. This is acceptable because the suppression logic doesn't care *which* stage is active — it only checks whether the env var is non-empty. When the last parallel stage completes, its `finally` clause clears the var. + +If the final `finally` runs while another parallel stage is still active, `ATOMIC_STAGE_ACTIVE` will be prematurely cleared. To prevent this, we track a counter: + +```ts +// Shared state on `shared: SharedRunnerState` +shared.activeStageCount = 0; + +// At stage start +shared.activeStageCount++; +tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", "1"); + +// At stage end +shared.activeStageCount--; +if (shared.activeStageCount === 0) { + tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", ""); +} +``` + +Since the env var's *value* doesn't matter (only presence), we simplify to `"1"` as a boolean sentinel. The counter lives in the in-memory `SharedRunnerState` at `executor.ts:1112-1121`. + +#### 5.3.3 Binding overrides in `tmux.conf` + +The complete set of changes (full text in §5.4.1): + +| Mechanism | Effect | +| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `set -g prefix None` | Disables the prefix key. Every prefix-bound command becomes unreachable (including all destructive defaults and the `:` command-prompt bypass). Existing Atomic prefix bindings (`-`, ` | `, `h/j/k/l`) also become inert — they are acceptable collateral per the non-goals. | +| `bind -n MouseDown3Status` | Root-table override. If agent window → no-op (silently absorb click). Else (orchestrator) → no-op anyway, because the orchestrator window is the OpenTUI React surface and there is nothing useful to menu over. | +| `bind -n MouseDown3Pane` | Same as above for pane-level right-click. | +| `bind -n C-c` | Root-table override. If chat session OR (workflow session AND `ATOMIC_STAGE_ACTIVE`) → no-op. Else → `send-keys C-c` (passthrough). | +| `bind -n Escape` | Same as `C-c`. | + +#### 5.3.4 Key suppression decision tree + +``` +Keypress event in a window on the atomic socket + ↓ +Is it a prefix-gated key (post-prefix)? + └── YES → prefix is None → never entered, never fires → silently dropped + └── NO → continue ↓ + +Is it Ctrl+C or Escape? + └── Is the session name `atomic-chat-*`? + │ └── YES → silently swallow (no-op) + │ └── NO → continue ↓ + └── Is the session env `ATOMIC_STAGE_ACTIVE` non-empty? + │ └── YES AND window_index ≥ 1 → silently swallow + │ └── NO → pass through to the embedded CLI + +Is it right-click on window/pane status? + └── Is the window an agent window? + └── YES → silently swallow + └── NO (orchestrator window 0 in a workflow session) + → no-op (orchestrator renders OpenTUI; right-click is meaningless there too) + +All other keys → pass through to the embedded CLI or OpenTUI process +``` + +#### 5.3.5 Why env-var-based stage detection (not window-name pattern) + +An earlier draft tried to distinguish "workflow stage running" from "chat session" purely by session name prefix. But the suppression semantics differ: + +- **Workflow stage (active):** Ctrl+C/ESC must be fully blocked (this spec's added requirement; Q8). +- **Workflow stage (idle between stages):** Ctrl+C/ESC should pass through so the user's agent CLI gets the signal normally. +- **Chat session:** Ctrl+C/ESC are always blocked (Q4). + +The env-var approach lets the executor explicitly declare "we are currently running a stage" at the tmux level for workflow sessions, without coupling to session-name conventions. Chat sessions are handled via the session-name regex directly (no env var; always blocked). + +### 5.4 Concrete Changes + +#### 5.4.1 `src/sdk/runtime/tmux.conf` — Appended Section + +Add at the end of the file (after line 73): + +```tmux +# ── Atomic lockdown (criteria G1–G3, G5, G6, G8) ──────────────────── +# tmux is treated as an invisible environment for the Atomic UI. +# The user should not — and cannot — issue tmux commands directly. +# Suppressed actions are silently ignored (no toast, no menu). + +# G1 + G6: Disable the entire tmux prefix key on the atomic server. +# Eliminates every prefix-gated command in one stroke: +# - & (kill-window), x (kill-pane) +# - , (rename-window), $ (rename-session) +# - : (arbitrary command-prompt — the `:kill-window` bypass) +# - d (detach-client), D (choose-client) +# - All pre-existing Atomic prefix bindings ( - | h j k l ) +# Collateral: pane-split and pane-resize bindings from lines 33–40 +# above become inert. This is intentional — workflows use windows, +# not sub-panes, so these bindings are vestigial. +set -g prefix None + +# G2: Silently suppress mouse right-click context menus on agent windows. +# Non-agent (orchestrator, index 0) also receives the no-op branch because +# the orchestrator is an OpenTUI render surface — a tmux menu over it is +# semantically meaningless. +bind -n MouseDown3Status \ + if-shell -F "#{||:#{!=:#{window_index},0},#{m/r:^atomic-chat-,#{session_name}}}" \ + "" "" +bind -n MouseDown3Pane \ + if-shell -F "#{||:#{!=:#{window_index},0},#{m/r:^atomic-chat-,#{session_name}}}" \ + "" "" + +# G3: Ctrl+C — silently swallow on agent windows when either +# (a) session name matches ^atomic-chat- (chat session, always) +# (b) ATOMIC_STAGE_ACTIVE is non-empty and window_index ≥ 1 +# (workflow session, stage running on agent window) +# Otherwise pass through to the embedded CLI or the OpenTUI orchestrator +# (which has its own React-level Ctrl+C handler at +# src/sdk/components/session-graph-panel.tsx:241-244). +bind -n C-c \ + if-shell -F "#{||:#{m/r:^atomic-chat-,#{session_name}},#{&&:#{!=:#{S:ATOMIC_STAGE_ACTIVE},},#{!=:#{window_index},0}}}" \ + "" \ + "send-keys C-c" + +# G3: Escape — same predicate as C-c. +bind -n Escape \ + if-shell -F "#{||:#{m/r:^atomic-chat-,#{session_name}},#{&&:#{!=:#{S:ATOMIC_STAGE_ACTIVE},},#{!=:#{window_index},0}}}" \ + "" \ + "send-keys Escape" + +# G9: Hide the tmux status bar on the orchestrator window. +# The OpenTUI Statusline component (src/sdk/components/statusline.tsx) already +# renders navigation hints at the bottom of window 0 — the tmux status row +# beneath it is redundant visual noise. We toggle `status on/off` reactively +# via tmux hooks so the switch is instantaneous on window transitions +# (no 500ms poll lag). +# +# Predicate: true when the active window is window 0 AND the session is a +# workflow session (atomic-wf-*). Chat sessions always keep status on +# because they have no OpenTUI Statusline to replace it. +set-hook -g after-select-window \ + 'if-shell -F "#{&&:#{==:#{window_index},0},#{m/r:^atomic-wf-,#{session_name}}}" \ + "set status off" \ + "set status on"' + +set-hook -g client-attached \ + 'if-shell -F "#{&&:#{==:#{window_index},0},#{m/r:^atomic-wf-,#{session_name}}}" \ + "set status off" \ + "set status on"' + +set-hook -g session-created \ + 'if-shell -F "#{&&:#{==:#{window_index},0},#{m/r:^atomic-wf-,#{session_name}}}" \ + "set status off" \ + "set status on"' +``` + +**Why `set -g prefix None` over per-key `unbind`/`bind` conditionals:** +Per-key suppression would require ~30 lines — one `unbind` + one +`bind … if-shell …` pair per destructive key — and every upstream +tmux release that adds new default bindings would require a +corresponding config update. `prefix None` is a single server-wide +setting that structurally eliminates the prefix sequence, so no +prefix-bound key (existing or future) can fire. It is also robust +against `prefix + :` — the largest known bypass (arbitrary command +input) — because that binding simply never triggers. + +**Why silently suppress instead of toasting:** +Per Q6 (user direction), suppressed actions are silently ignored. +Rationale: (a) toasts require the user to already understand *why* +the action was blocked, so they add noise without teaching; +(b) a user who sees no response will naturally look for the +documented alternative (`q` in the orchestrator) rather than +repeatedly spamming the key; (c) the orchestrator status bar already +advertises `q` as the exit key (`src/sdk/components/statusline.tsx`), +which is the canonical discovery path. + +**Why mouse menus go to `""` (empty command) rather than being `unbind`ed:** +A bare `unbind` on `MouseDown3Status` would fall through to a +terminal emulator's native right-click menu in some setups (e.g., +iTerm2 extends mouse events). Binding to an empty command explicitly +consumes the event at the tmux layer. The `if-shell -F` with two +empty branches is the canonical "do nothing in all cases" idiom +documented in the tmux man page. + +**Why three hooks for the status bar toggle (G9):** +Each hook fires on a different transition: +- `after-select-window` — fires when the user switches between + windows (the primary case: user presses `C-g` to return to the + orchestrator from an agent window, or `C-\\` to cycle forward). +- `client-attached` — fires when the user first attaches to the + session (so the initial status state is correct immediately + without waiting for a window switch). +- `session-created` — fires at session creation time, before any + client attaches (ensures the server-level `status` option is in + the right state even if the orchestrator window 0 is what the + client attaches to). +All three use the same predicate and the same body, so the behavior +is consistent across every entry path. No polling, no flicker. + +**Interaction with the existing React status-content manager:** +`src/sdk/components/session-graph-panel.tsx:400-421` manages the +*content* of the status bar (status-left, status-right, +window-status-format) when `store.viewMode` changes. The hooks added +here manage the *visibility* (status on/off). They are orthogonal: +when the orchestrator hides the status bar via the hook, the React +code continues to write content (to no visible effect); when the +user switches to an agent window and the hook turns status back on, +the content is already correct. The two layers never fight each +other because they modify disjoint option sets. + +#### 5.4.2 `src/sdk/runtime/executor.ts` — Stage Lifecycle Hook + Startup Sweep + +Add the stage-active counter to `SharedRunnerState` (at `executor.ts:1112-1121`): + +```ts +interface SharedRunnerState { + // ... existing fields ... + activeStageCount: number; // new +} +``` + +Initialize in `runOrchestrator()` (near existing `shared` init at `executor.ts:1112`): + +```ts +const shared: SharedRunnerState = { + // ... existing fields ... + activeStageCount: 0, +}; +``` + +**G7 Startup sweep.** Immediately after session creation in `runOrchestrator()` (after `tmux.createSession(...)` at `executor.ts:311` and before the workflow's `run()` is invoked), clear any stale value: + +```ts +// Defensive clear: a previous crashed run could have left the var +// set. With the prefix disabled, the user can't clear it themselves. +tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", ""); +``` + +**Stage lifecycle wrap.** Wrap the stage execution in `createSessionRunner()` (where `s.session.query()` is called). This is the single "stage is running" boundary: + +```ts +// Before the send-prompt-to-pane / SDK query call +shared.activeStageCount++; +if (shared.activeStageCount === 1) { + tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", "1"); +} + +try { + // existing body — send prompt, wait for idle, capture transcript +} finally { + shared.activeStageCount--; + if (shared.activeStageCount === 0) { + tmux.setSessionEnv(tmuxSessionName, "ATOMIC_STAGE_ACTIVE", ""); + } +} +``` + +**Exact insertion point** depends on the current `createSessionRunner` implementation; the wrap must surround all provider dispatch paths (Claude headless, Claude interactive, OpenCode, Copilot) — essentially the body of the user-provided callback `(s) => { ... }` in `ctx.stage(…)`. + +#### 5.4.3 Chat sessions — no executor changes + +Chat sessions (`src/commands/cli/chat/index.ts`) do **not** set `ATOMIC_STAGE_ACTIVE`. Their Ctrl+C and Escape bindings therefore fall through to the default `send-keys` passthrough (the false-branch of the `if-shell`). This is intentional and matches the non-goal "Chat sessions … retain native CLI behavior, subject to separate ticket #003." + +Window close/rename suppression still applies to chat sessions because the agent-window detector catches them via `#{m/r:^atomic-chat-,#{session_name}}`. + +### 5.5 Behavior Matrix + +Complete truth table after `set -g prefix None` + the four `bind -n` overrides + the three status-bar hooks: + +| Context | `prefix + ` | `Ctrl+C` | `Escape` | Mouse right-click | tmux status bar | +| ------------------------------------------------------- | ----------------------------- | --------------------------------------------------- | ------------------- | ----------------- | ------------------------------------------- | +| Orchestrator window (workflow, index 0) | **nothing** (prefix disabled) | passthrough→OpenTUI (React handler → `requestQuit`) | passthrough→OpenTUI | silent no-op | **hidden** (OpenTUI Statusline replaces it) | +| Agent window, stage running (workflow, index ≥ 1) | **nothing** | **silent no-op** | **silent no-op** | silent no-op | visible | +| Agent window, stage idle (workflow, index ≥ 1) | **nothing** | passthrough→CLI | passthrough→CLI | silent no-op | visible | +| Chat session window (atomic-chat-*, any index) | **nothing** | **silent no-op** | **silent no-op** | silent no-op | visible | +| User's personal tmux (default socket — separate server) | unchanged | unchanged | unchanged | unchanged | unchanged | + +Legend: +- **"nothing"**: The prefix sequence does not open — tmux never enters the prefix state because `prefix` is `None`. Any key typed after what the user thinks is the prefix goes to the embedded CLI verbatim. +- **"silent no-op"**: tmux absorbs the keypress without visible effect; CLI never sees it. +- **"passthrough→CLI"**: tmux forwards the keypress verbatim to the embedded CLI process. +- **"passthrough→OpenTUI"**: tmux forwards to the OpenTUI process running in window 0; OpenTUI's `useKeyboard` at `session-graph-panel.tsx:241-244` handles `Ctrl+C` as `requestQuit` and `Escape` (unbound there) as a no-op. +- **"unchanged"**: User's personal tmux is on a separate socket; Atomic's config does not apply. + +Note: Chat-session Ctrl+C is blocked in this spec. Ticket #003 will replace the silent no-op with a paused-state transition that preserves the chat session for resume. The specific code point in `tmux.conf` to modify is the predicate of the `bind -n C-c` / `bind -n Escape` lines added in §5.4.1. + +## 6. Alternatives Considered + +| Option | Pros | Cons | Reason for Rejection | +| ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| **A. Per-key `unbind` + conditional `bind` for each destructive default** (original draft) | Surgical; preserves non-destructive prefix commands (pane split, detach) | ~30 lines of config; each new upstream tmux default binding needs a config update; does not close `prefix + :` | Replaced by the prefix-lockout approach per Q1 resolution. Prefix-lockout is one line and closes every known and future bypass. | +| **B. Server-level hooks** (`after-kill-window`, `after-rename-window`) to *revert* after-the-fact | Catches every destructive path including external `tmux -L atomic kill-window …` shelling | Reversal is lossy — a killed window cannot be recreated with the same SDK session ID; rename revert is possible but leaves a flicker | External-shell threat is outside the user-inside-tmux threat model. Prefix-lockout is sufficient. | +| **C. Per-window `key-table` with `switch-client -T atomic-agent`** | Strongly typed — entering an agent window could switch to a restricted key table | Requires user-visible modality (tmux key-tables interact awkwardly with the user's prefix key); complicates the copy-mode flow already configured in `tmux.conf:43-46, 65, 71-73` | Adds accidental complexity. The prefix-lockout approach gives equivalent effect without modality. | +| **D. Wrap the agent CLI in a shell that traps Ctrl+C** (e.g., `trap '' INT`) | Blocks Ctrl+C at the process level, not the tmux level | Requires per-CLI wrapper scripts for each of Claude/OpenCode/Copilot; breaks the `paneLooksReady()` detection that expects the CLI's prompt; does not address window close/rename (those happen at the tmux server layer, not the pane's process) | Solves only part of the problem and breaks existing idle detection. | +| **E. React-level key blocking via OpenTUI `useKeyboard`** | Matches existing `q`/`Ctrl+C` handling pattern | OpenTUI only renders in window 0. In agent windows (1+), there is no React layer — the CLI owns the terminal directly. | Fundamentally cannot work: OpenTUI has no visibility into agent-window keypresses. | +| **F. Selected: Prefix-lockout + four root-table overrides** | One-line policy: `set -g prefix None` disables all prefix commands server-wide. Four `bind -n` lines handle the non-prefix paths (mouse menus, `C-c`, `Escape`). Cross-platform (tmux + psmux). Scoped to atomic socket. | Kills pane split (`-`, ` | `) and resize (`h/j/k/l`) bindings as collateral. Accepted per non-goals (workflows use windows, not sub-panes). | **Selected.** Smallest policy surface area; structurally prevents every known and future prefix-bound bypass. | + +## 7. Cross-Cutting Concerns + +### 7.1 Security and Privacy + +- **No new surface area.** All changes are to declarative tmux config and a single env-var write. No network calls, no filesystem writes outside the existing tmux server state. +- **Env var scope.** `ATOMIC_STAGE_ACTIVE` is session-scoped (`tmux set-environment -t `), not server-global and not inherited by spawned processes — verified by `setSessionEnv()` behavior at `src/sdk/runtime/tmux.ts:437-439`. +- **No bypass mechanism intended.** Users who know tmux can still use `prefix + :` to type `kill-window` or `rename-window` directly (this is an explicit non-goal). This is an intentional escape hatch for power users and is not treated as a security issue — the threat model is accidental destruction, not malicious sabotage of one's own workflow. + +### 7.2 Observability Strategy + +- **Metrics:** No new custom metrics; the existing workflow lifecycle telemetry is unaffected. If desired, a low-cardinality counter `tmux_suppression_hit_total` could be added later (out of scope here). +- **Logs:** The `display-message -d` toasts are user-visible but not persisted. If a user files a bug report saying "my window disappeared," absence of the expected toast in their report (combined with knowledge of which binding was suppressed) is the first diagnostic. +- **Tracing:** Not applicable — tmux bindings have no distributed-tracing hooks. +- **Fallback detection:** The `after-kill-window` hook (tmux-native) can be set to write a log line if a kill *does* succeed on an agent window, which would indicate a bypass path we missed. This is an optional hardening. + +### 7.3 Scalability and Capacity Planning + +- **Keypress throughput:** `if-shell -F` evaluates format expressions in O(μs) per keypress. The most complex expression (`C-c`) requires two env-var lookups plus a regex match — still well under 1 ms per evaluation. No throughput concern. +- **Session counts:** No new per-session allocations. Each session has one additional env-var entry (`ATOMIC_STAGE_ACTIVE`), ~30 bytes. +- **Server restart:** On tmux-server restart (e.g., if a user manually `kill-server`s the atomic server), the env var is lost. The executor re-populates it on the next `createSession` → `setSessionEnv` call. Not a concern because server restart during a live workflow would destroy all windows anyway. + +### 7.4 Accessibility / UX + +- **Silent suppression is intentional.** Per Q6 resolution, suppressed actions produce no visible feedback (no toast, no menu, no sound). This avoids cluttering the status area with repetitive reminders. Reviewers who prefer a toast can re-enable it by replacing the empty-string branches in §5.4.1 with `display-message -d 2500 "…"`. +- **Discovery of `q`.** The orchestrator status bar already shows `"↑↓←→ navigate · ↵ attach · / stages · q quit"` (`src/sdk/components/statusline.tsx`). This is the canonical discovery path — a user who tries and fails to close an agent window will naturally look to the orchestrator for the exit action. +- **Psmux (Windows) parity.** Per [research/web/2026-04-15-tmux-keybind-suppression.md §4](../research/web/2026-04-15-tmux-keybind-suppression.md), psmux supports `set -g prefix None`, `bind -n`, `if-shell -F`, `#{m/r:…}` regex match, and all format conditionals used here. `#{S:NAME}` support is unconfirmed; see §9.3 residual item. +- **No modality surprises.** Because suppression is silent and `q` remains the only exit, the UX is predictable: users who know `q` exit cleanly; users who don't eventually look at the orchestrator status bar. + +### 7.5 Compatibility with Existing Features + +- **Existing prefix bindings (`-`, `|`, `h/j/k/l`) at `tmux.conf:33-40`** become inert — the prefix is disabled. Accepted per non-goals; workflows don't use sub-panes. +- **Existing prefix-free bindings (`C-g`, `C-\\`) at `tmux.conf:58-62`** are **unaffected** — they bypass the prefix (defined with `-n` in the root table). +- **Copy-mode bindings at `tmux.conf:44-46, 65, 71-73`** are unaffected — copy-mode is entered via mouse wheel scroll (root-table `WheelUpPane → copy-mode -e`), not via a prefix sequence. The bindings themselves live in the `copy-mode-vi` key table, which is orthogonal to the prefix. +- **React `useKeyboard` at `src/sdk/components/session-graph-panel.tsx:216-310`** only fires in window 0. `C-c`/`Escape` on window 0 still reach the React layer (because the `bind -n` with the agent-window condition false-branches to `send-keys`, which passes through to the OpenTUI process running in that window). +- **500 ms window-state poll at `session-graph-panel.tsx:373-394`** uses `display-message -p` (read-only query). Unaffected by the hook-based status toggle — the poll reads window state, it does not write the `status` option. +- **Status-bar *content* manager at `session-graph-panel.tsx:400-421`** continues to write `status-left`, `status-right`, `window-status-format`, etc. The G9 hook manages only the `status` on/off option, which is a disjoint option. When the orchestrator window hides the status bar, the content writes become invisible but keep the correct value ready for when the user switches to an agent window and the hook flips `status on`. +- **Restore-on-unmount at `session-graph-panel.tsx:424-437`** resets content options to defaults when the panel unmounts. It does not currently toggle `status on`. This is safe because `shutdown()` kills the tmux session immediately after unmount — the persisted `status off` state dies with the session. (If we later want to attach back to a residual atomic session post-unmount — e.g., for post-workflow review — the unmount handler should also run `tmuxRun(["set", "-g", "status", "on"])`.) + +## 8. Migration, Rollout, and Testing + +### 8.1 Deployment Strategy + +The change is a single-commit config update + executor hook. No data migration, no feature flag, no schema change. + +- **Phase 1 (prefix + mouse lockdown + status-bar hooks).** Add to `src/sdk/runtime/tmux.conf`: (a) `set -g prefix None`, (b) the two `bind -n MouseDown3Status` / `MouseDown3Pane` no-op guards, (c) the three G9 `set-hook` lines for status-bar visibility. Verify by running a workflow and (i) attempting every prefix-bound destructive key — expect all inert, (ii) right-clicking on windows and panes — expect no menu, (iii) confirming the tmux status bar hides when the orchestrator window is active and reappears on agent windows. +- **Phase 2 (stage-active wiring + Ctrl+C/ESC guards).** Add the `SharedRunnerState.activeStageCount` counter, the `setSessionEnv` calls in `createSessionRunner()`, and the startup sweep in `runOrchestrator()`. Add the two `bind -n C-c` and `bind -n Escape` lines to `tmux.conf`. Verify G3 by pressing Ctrl+C and Escape during an active workflow stage in an agent window (expect silent no-op), then after the stage completes (expect passthrough to the CLI). Verify G3 also for chat sessions (expect silent no-op always). +- **Phase 3 (hardening + regression tests).** Run through the behavior matrix in §5.5 manually for each row. Add regression tests (see §8.3). Verify `#{S:NAME}` support on psmux (§9.3). Document the new behavior in `README.md` or the `docs/` directory. + +Rollback: revert the `tmux.conf` and `executor.ts` changes. Because all changes are additive, the rollback is a straight file revert — no state migration needed. Existing running sessions survive rollback (they read the config on next `source-file` reload, which happens on `createSession`). + +### 8.2 Data Migration Plan + +None. There is no persisted state to migrate. The `ATOMIC_STAGE_ACTIVE` env var is session-local and ephemeral. + +### 8.3 Test Plan + +**Unit tests:** +- `tests/sdk/runtime/tmux.test.ts` — add cases for `setSessionEnv("ATOMIC_STAGE_ACTIVE", …)` / `getSessionEnv(…)` round-trip. Already partially covered; extend with a sentinel-empty case. + +**Integration tests:** +- `tests/sdk/runtime/tmux-keybind-suppression.test.ts` (new). For each scenario in the §5.5 matrix: + 1. Create a tmux session using `createSession()` (with the updated config). + 2. Create an agent window via `createWindow()`. + 3. Send the relevant keybinding sequence via `send-keys`. + 4. Assert that the appropriate action occurred (window/pane survived or was killed; rename rejected or accepted) and the `display-message` output (via `display-message -p`) contains the expected toast text. +- `tests/sdk/runtime/stage-active-lifecycle.test.ts` (new). Simulate `activeStageCount++` / `--` patterns, verify `getSessionEnv("ATOMIC_STAGE_ACTIVE")` transitions correctly across sequential and parallel stages. + +**End-to-end tests:** +- Manual exploratory testing per §5.5 behavior matrix on both tmux (macOS/Linux) and psmux (Windows). Document results in an issues.md during the rollout, delete on completion. +- Confirm the user's personal tmux on the default socket is unaffected: open a session on the default socket, run `prefix + &`, verify normal confirm-before prompt appears. + +**Psmux-specific tests:** +- Verify the `#{S:NAME}` format variable works on psmux (empirically; local docs do not enumerate support — see [research/web/2026-04-10-psmux-tmux-compatibility.md](../research/web/2026-04-10-psmux-tmux-compatibility.md)). +- Verify the emoji toast renders correctly in Windows Terminal; fall back to ASCII if not. + +**Regression tests:** +- Existing copy-mode tests (`tmux.conf:44-73`) should remain green. +- Existing `session-graph-panel.test.tsx:243` (`"q triggers abort before completion"`) should remain green — window 0 `q` handling is untouched. +- Existing interrupt tests: `specs/2026-03-25-workflow-interrupt-stage-advancement-fix.md` test suite should remain green; that spec's interrupt pipeline is orthogonal to the tmux-level blocks added here. + +### 8.4 Manual QA Checklist (drawn directly from Acceptance Criteria) + +- [ ] **AC "Window close keybind suppressed" / G1:** In a running workflow, navigate to an agent window, type the tmux prefix (`Ctrl-b`) then `&`. Expect: window survives; `&` is typed verbatim into the CLI (because the prefix never triggered). +- [ ] **AC "Window close keybind suppressed" / G2:** Right-click on the window-status bar. Expect: no menu appears. +- [ ] **AC "Window rename keybind suppressed" / G1:** In an agent window, type `Ctrl-b` then `,`. Expect: window name unchanged; `,` typed verbatim into CLI. +- [ ] **AC "Window rename keybind suppressed" / G1:** `Ctrl-b` then `$`. Expect: session name unchanged. +- [ ] **AC "Window rename keybind suppressed" / G1:** `Ctrl-b` then `:`. Expect: no command-prompt opens. +- [ ] **AC "Suppressed actions show a non-blocking status message":** *Divergence from original AC — per Q6 resolution, suppressed actions are silently ignored.* Verify there is no visible toast on suppressed actions. +- [ ] **AC "Ctrl+C transitions to paused state (see #003)":** *Scoped to ticket #003.* This spec blocks Ctrl+C on workflow-stage-active agent windows and on chat sessions unconditionally. Verify: Ctrl+C during a workflow stage is silently absorbed. Ctrl+C in a chat session is silently absorbed (the paused-state transition will be wired in #003 — the adapter point is the `bind -n C-c` predicate in `tmux.conf`). +- [ ] **AC "Ctrl+C blocking":** In an agent window while a stage is active (`ATOMIC_STAGE_ACTIVE` non-empty), press `Ctrl+C`. Expect: CLI does not receive the key. +- [ ] **AC "Ctrl+C blocking":** After stage completes (idle), press `Ctrl+C` in the same window. Expect: CLI receives `Ctrl+C` normally (interrupt or exit per CLI semantics). [Subject to #003 for final behavior.] +- [ ] **AC "ESC blocking":** Same as above for `Escape`. +- [ ] **AC "ESC blocking" (chat):** In a chat session window, press `Escape`. Expect: silently absorbed; CLI does not receive the key. +- [ ] **AC "q triggers clean exit flow" / G4:** In the orchestrator window (window 0), press `q`. Expect: React `useKeyboard` fires `store.requestQuit()` → `WorkflowAbortError` → `shutdown(0)` → tmux session killed, process exits 0. +- [ ] **AC "Non-agent tmux windows unaffected" / G5:** Open a terminal on the user's default tmux socket (unset `TMUX`, run `tmux` without `-L atomic`). Verify `prefix + &`, `prefix + ,`, `Ctrl+C`, right-click menus all behave as unmodified tmux defaults. +- [ ] **G6 (orchestrator):** In the orchestrator window, try `Ctrl-b &`. Expect: no action (prefix disabled on atomic socket). `q` remains the only exit path. +- [ ] **G7 (startup sweep):** Manually set `ATOMIC_STAGE_ACTIVE=1` on a stale atomic session via `tmux -L atomic set-environment -t ATOMIC_STAGE_ACTIVE 1`, then launch a new Atomic workflow that reuses the same session name. Verify the env var is cleared before stage 1 begins (via `tmux -L atomic show-environment -t ATOMIC_STAGE_ACTIVE`). +- [ ] **G9 (status-bar hidden in orchestrator):** Launch a workflow. On initial attach, the orchestrator is the active window — confirm the tmux status bar is not visible beneath the OpenTUI Statusline. Press `C-\\` to cycle to an agent window — status bar should appear immediately (no 500ms lag). Press `C-g` to return to the orchestrator — status bar should hide immediately. +- [ ] **G9 (chat sessions retain status bar):** Launch a chat session (`atomic chat claude`). Confirm the tmux status bar is visible (because the predicate requires `^atomic-wf-` session prefix — chat sessions fail it). + +## 9. Open Questions / Unresolved Issues + +All design questions were resolved during the `/create-spec` walkthrough on 2026-04-15. The resolutions are captured below for traceability; each drove specific changes to §3–§5 above. + +### 9.1 Divergence from Original Acceptance Criterion + +The original ticket included the acceptance criterion: *"Suppressed actions show a non-blocking status message."* During the walkthrough, Q6 was resolved with **"silent ignore"** — suppressed actions are silently absorbed, no toast, no menu. This is a deliberate divergence from the original AC, driven by the user's reasoning that: + +1. Toasts require the user to already understand *why* the action was blocked. +2. A user who sees no response will naturally look for the documented alternative (`q` in the orchestrator). +3. The orchestrator status bar already advertises `q` via `src/sdk/components/statusline.tsx`. + +Reviewers should confirm this divergence is acceptable before implementation. If a toast is re-introduced later, the change is local to the `bind -n` lines in §5.4.1 — swap the empty-string command for `display-message -d ""`. + +### 9.2 Resolved Questions + +**Q1. Scope of tmux-command suppression.** +Resolution: **Block ALL native tmux commands.** Rationale: tmux is an invisible environment for the Atomic UI; users do not need — nor should be able — to run tmux commands. Implementation: `set -g prefix None` in `tmux.conf` (§5.4.1). This supersedes per-binding suppression and closes the `prefix + :` bypass hole by construction. + +**Q2. `prefix + &` on the orchestrator window (window 0).** +Resolution: **Suppress `&` entirely on window 0** (and on every window, by consequence of Q1). `q` is the only exit path. Implementation: no extra code — covered by `set -g prefix None`. + +**Q3. Stale `ATOMIC_STAGE_ACTIVE` after crashed prior runs.** +Resolution: **Accept risk + add a startup sweep.** Implementation: one-line `setSessionEnv(…, "ATOMIC_STAGE_ACTIVE", "")` in `runOrchestrator()` immediately after `tmux.createSession()` (§5.4.2). + +**Q4. Chat-session Ctrl+C handoff (pre-#003 behavior).** +Resolution: **Block Ctrl+C, ESC, and double Ctrl+C in chat sessions.** Chat sessions' `Ctrl+C` / `Escape` are silently swallowed. When ticket #003 ships, the empty-command branch in the `bind -n C-c` / `bind -n Escape` lines will be replaced with the paused-state transition logic. Implementation: chat-session predicate is the `#{m/r:^atomic-chat-,#{session_name}}` clause in the `C-c` / `Escape` guard (§5.4.1). + +**Q5. Mouse right-click menus on agent windows.** +Resolution: **No menu — silent no-op.** Right-click is absorbed; no menu, no toast. Implementation: `bind -n MouseDown3Status` and `bind -n MouseDown3Pane` each bound to `if-shell -F "..." "" ""` (§5.4.1). + +**Q6. Icon/style for suppression feedback.** +Resolution: **No toast — silent ignore.** (See §9.1 above.) + +**Q7. `after-kill-window` / `after-rename-window` fallback hooks.** +Resolution: Not needed. With `prefix = None`, the primary bypass paths (`prefix + :` command-prompt, prefix-bound destructive bindings) are all inaccessible. Implementation: no hooks added. + +**Q8. Ctrl+C/ESC scope: all agent windows or only workflow stages?** +Resolution: **Hybrid** — chat sessions block always (Q4), workflow sessions block only when `ATOMIC_STAGE_ACTIVE` is set (original Q8). This gives workflow-session users the ability to interact with an idle agent CLI between stages while preventing mid-stage interruption. Implementation: the two-clause `#{||:...,#{&&:...,...}}` predicate on the `C-c` / `Escape` guards (§5.4.1). + +### 9.3 Residual Items (Non-Blocking — For Implementation Phase) + +- **Verify `#{S:NAME}` format variable on psmux.** Local docs do not enumerate support for session-env-var format variables on Windows psmux (see [research/web/2026-04-10-psmux-tmux-compatibility.md](../research/web/2026-04-10-psmux-tmux-compatibility.md) and `research/web/2026-04-15-tmux-keybind-suppression.md §4`). Empirical test during Phase 1 rollout; if unsupported, fall back to a shell-based `if-shell` (without `-F`) for the `ATOMIC_STAGE_ACTIVE` check on Windows. +- **Confirm OpenTUI receives `Escape` in window 0.** The `bind -n Escape` false-branch sends the key to the pane. Verify the OpenTUI React keyboard handler at `session-graph-panel.tsx:216-310` receives it cleanly and does not interpret it as a destructive action (the current handler only uses `escape` inside the switcher — line 219 — which is the intended behavior). +- **Ticket #003 handoff note.** Document the two `bind -n` predicate lines in `tmux.conf` as the integration point for the paused-state transition. The #003 spec should reference this file.