# Sesh CLI

> Use the `sesh` CLI/TUI to list, find, enter, resume, create, tag, archive, rename, reparent, capture, send-to, delegate, or inspect coding-agent threads across machines. Use when the user asks about sesh command usage, the thread TUI, entering/resuming a thread, cross-machine thread state, the sesh daemon, peers, mycockpit (the cross-machine tmux cockpit), or tickets.

- Skill: `lukastk/sesh-cli` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lukastk/sesh-cli`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lukastk/sesh-cli/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: lukastk (https://skillmd.com/u/lukastk)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lukastk/sesh-cli

---


# sesh

Use this skill when the user wants to **use** `sesh` — the multi-machine coding-agent
session manager — not develop it. `sesh` is one Go binary plus a per-machine daemon.

Mental model: each machine runs a **daemon** that owns a local SQLite store, drives a
tmux "work" server, and maintains a background probe of every local thread's live state.
A thread's runtime identity is its **pane** (a `@sesh-thread-id` marker), so a tmux
session may host many threads (their own windows, or splits) — by default a new thread
gets its own session, but see `--into-session`/`--into-window`/`--into-pane`. Daemons are linked into a **mesh** (peers, over ssh or an
HTTP API) so any machine can see and route to threads on any other. The CLI/TUI is a
thin client over the local daemon's HTTP+JSON surface; `--machine <m>` routes a command
to machine `m`. **`sesh` is mechanism, not UX** — it is explicit and machine-readable
(`--json` everywhere); ergonomic shell glue lives in the user's dotfiles.

Run `sesh help` for the command list and `sesh <command> --help` (or `sesh help <command>
<sub>`) for any command — every command and flag is documented there. Prefer reading
`--help` over guessing. `sesh help-tree` prints the entire command surface (every command
and subcommand, each with a one-line summary) as one indented tree — the fastest way to see
everything at a glance. Invoking a command group with no subcommand (e.g. `sesh thread`)
prints that group's full `--help` (not a partial usage line).

## Thread ids and id-prefixes

Threads are identified by a UUID. Every `--id` accepts an **unambiguous prefix**
(`sesh thread stop --id 1a2b3c4d`; an unknown/ambiguous prefix is a loud error — a FULL
well-formed uuid skips the prefix lookup entirely, so an unknown full uuid errors at the
verb itself via the daemon's 404 instead), and most
verbs infer the **current** thread when you omit `--id` (from the calling pane's live
`@sesh-thread-id` marker first, then `$SESH_THREAD_ID`, or a loud error if neither
resolves). The pane marker wins because it is re-stamped on adopt/reparent while
`$SESH_THREAD_ID` is frozen at launch and can drift stale; on disagreement the pane is
used and a drift note is printed to stderr. **See "Am I really this thread?" below —
outside a pane the answer is UNVERIFIED and may be refused.** Inference happens **only when `--id` is
omitted entirely**: passing an *explicitly empty* `--id ""` (or an empty positional id,
e.g. from an unset shell variable) is a **loud error**, never silently treated as the
current thread — so a stray empty `$VAR` can't make a verb act on the wrong thread. The
same holds for the other selectors that default to "everything"/"the current thread"
(`backup`/`restore --id`, `hooks test --thread`). `delete` and `stop` go further still —
being destructive, they **never** infer at all (an omitted `--id` is also an error), so
they always need an explicit `--id`. The TUI shows the short 8-char form (`i` toggles the
ID column; `y` shows the full UUID, `c` copies it).

## Am I really this thread? (provenance)

Inference has two sources and they are **not** equally trustworthy:

- **pane** — read from the `@sesh-thread-id` marker on the tmux pane the command
  actually runs in. **Verified**: a process elsewhere cannot inherit it.
- **env** — `$SESH_THREAD_ID` alone, when there is no pane. **Unverified**: that variable
  is frozen at launch and inherited by every descendant, so a detached or background
  process (a claude bg job/agent, hosted by a machine-global `claude daemon run` that
  froze whichever pane started it) carries a perfectly *valid* id belonging to an
  *unrelated* thread.

`sesh info` reports which it used — a `source:` line, or `"source"` / `"verified"` in
`--json`. An env-derived answer is announced on stderr, and it is **corroborated against
the calling directory**: if the named thread's cwd is unrelated to where you are standing,
sesh **refuses** instead of guessing. Pass `--id`, or `--allow-unverified` to proceed
anyway (a pseudo-global — every verb that infers accepts it). The refusal names the flag
**that command** takes, which is not always `--id`: `subscribe`/`unsubscribe` take
`--from`, `ticket list --current` and `hooks test` take `--thread`.

> **Agents: a claude Bash call often has NO pane.** A tool call hosted by claude's
> machine-global daemon (any session showing background agents) runs with no `$TMUX_PANE`
> and a `$SESH_THREAD_ID` frozen from whichever thread started that daemon — so
> current-thread inference there is refused, correctly. **Name the thread explicitly in
> scripted work** (`sesh subscribe <child> --from <me>`), and **never discard stderr**:
> `sesh subscribe $ID >/dev/null 2>&1` in a loop is how three subscriptions silently
> failed to exist for an hour on 2026-08-27 while the loop printed "subscribed".

> ⚠️ **Before you do anything destructive to "yourself"** — compacting, sending, stopping,
> archiving — **require verified provenance.** This is not hypothetical: an agent with no
> pane asked `sesh info` who it was, was confidently told it was an unrelated thread, and
> its self-compact runner compacted that thread and injected a foreign handover prompt
> into it.
>
> ```bash
> TID=$(sesh info --json | jq -r 'select(.source == "pane") | .thread.id')
> [ -n "$TID" ] || { echo "not pane-verified — refusing to act on myself"; exit 1; }
> ```

Corroboration is evidence, not proof: an inherited id that happens to name a thread in the
*same* directory tree still resolves. Outside a pane, `--id` is the only certainty.

## Before running commands

**Read-only** (safe to run freely): `list`, `grid`, `info`, `status`, `pane`, `capture`,
`mesh`, `tail`, `transcript`, `subscriptions`, `peer list`, `daemon status`, `master
watchers`, `matrix`, `doctor`, `tmux current|info`, `cwd-label`, `meta get|list`, `hooks list`.

**Mutating** (think first): `new`, `stop`, `delete`, `resume`, `headful`, `send`,
`send-headless`, `rename`, `tag`, `reparent`, `archive`, `notify`, `meta set|unset`,
`adopt`, `subscribe`/`unsubscribe`, `delegate`, `backup`/`restore`/`copy`, `import`,
`ticket *`, `blob add|rm`, `tmux nav|send-text|stage-file|create-*|kill-session`,
`master up|down|ensure`, `peer add|remove`, `daemon start|stop|restart`,
`hooks enable|disable|test`.

`sesh tmux kill-session --target <name> [--machine <m>]` kills one work-server session by
exact name (routes cross-machine; a non-existent session is a loud error) — the mechanism
behind myrig's "kill empty sessions" cleanup.

Extra care: `delete` (drops a record; refuses a live thread unless `--force`, which
orphans the agent — `stop` first), `send`/`send-headless` (injects into a real agent's
conversation), `master down` (tears mycockpit down), `peer remove`, `import`.

## Core concepts

- **Thread** = one coding-agent conversation. A *headed* thread runs the agent live in a
  tmux pane; a *headless* thread is a durable conversation with no pane (turns run
  stateless via `--resume`). The two are not a stored mode — they're inferred at runtime.
- **Two orthogonal state axes** (what the glyphs mean):
  - **head**: `●` headful (a live pane) / `◌` headless (no pane) / `≡` **virtual**
    (a pure grouping node — no agent at all; see *Virtual threads* below) /
    `❯` **shell thread** with a live tmux session, `›` one without (see *Shell
    threads* below). Each KIND draws from a different stroke class — round for an
    agent, a prompt chevron for a shell, stacked lines for a group — so the kind
    reads at a glance rather than by comparing outlines.
  - **busy**: `▶` busy (mid-turn) / `·` idle. **Blank for a shell thread** — it has
    no turn that could be executing, so the axis does not apply.
  - **flag** (last gutter cell): `⚑` **flagged** — this thread needs your
    attention. Auto-set when a turn ends or the agent stalls on a
    question/approval — attended or not (the unattended-only gate was removed
    2026-07-25); NEVER auto-cleared (unflag
    with `f` or `thread flag --off`). `⌁` = auto-flagging **disabled** for
    this thread (e.g. children a parent thread monitors) — deliberately not a
    slashed circle, so it can't be mistaken for the archived `⊘` in the cell
    immediately to its left. A flagged child
    stays VISIBLE under a collapsed parent (fold-piercing) — a flag never
    hides inside a fold.
  So `●·` = headful & idle = **needs input** (waiting for you); `●▶` = working in a pane;
  `◌▶` = a headless turn in flight (wait); `◌·` = idle headless (revivable). A third
  marker shows **descendant activity** (`↓` = a descendant thread — child, grandchild,
  … — is running a turn; blank = none). The running-state glyphs (`▶` and `↓`) render
  **bright green** by default so live activity pops out — on the SELECTED row the tint
  composes with the reverse-video band (the glyph shows as a coloured chip, so ▶/↓/⚑
  keep their colour when selected); tune or clear per glyph via `[[tui.glyph_color]]`
  (names `hold`, `busy`, `descendant`, `flag`). A fourth marker shows attachment (`*` = a tmux
  client is attached), and a fifth shows **archived** (`⊘` = the thread is archived —
  it appears in the default view only while still headful). The TUI's gutter header for
  the core three is `HBD` (head, busy, descendant).
  - **hold** (the LEADING cell, before the head): `⧗` the thread is parked by its
    OWN deadline / `⧖` parked by an ANCESTOR's / blank not parked. The pair is
    the same shape in two states, like `●`/`◌`, and the split is actionable
    rather than decorative: an own hold is removed by clearing it, an inherited
    one **cannot be** — the effective hold is `max(own, ancestors')`, so it needs
    `thread hold --release`. A RELEASED thread is not parked and so carries no
    sigil; the `~<date>` in the HOLD column is what reports a release in force.
    The cell is shared with move mode's `↕`, which wins while a row is being
    moved — that cell was otherwise blank on every row, which is what lets the
    sigil cost no width. NB on-hold threads are hidden from the default `active`
    view, so the sigil is something you see in `all`, `on hold`, and any custom
    view that admits parked threads.
- **Machine = origin + owner.** A thread lives on the machine that spawned it; mutations
  route to that owner (`--machine`, or auto for tickets). Cross-machine reads come from
  the mesh.
- **Archived** is orthogonal to liveness — a parked record, hidden from the active list,
  still resumable.
- **Agents**: `claude`, `codex`, `pi`. Spawn policy (yolo/default/sandbox) comes from
  `[spawn]` config or `--yolo`/`--sandbox`.
- **Parent/child** threads form a tree (a supervisor thread and its sub-agents); the TUI
  renders it collapsibly. **`thread new` defaults to childing the new thread to the current
  one** (see the ⚠️ note under *Creating* — pass `--no-parent` for a standalone/root thread).
  Deleting a thread **promotes its children** to the deleted thread's own parent
  (grandparent; root if it had none) — parent ids never dangle.
- **Shell threads** (`sesh shell …`, glyph `❯`/`›`, `agent_kind` reads `shell`) are
  **tracked tmux SESSIONS**. Where an agent thread's durable content is its conversation,
  a shell thread's is its **working directory**: headful means a live session exists,
  headless means it is a remembered place, and `thread resume` re-creates the session in
  the recorded cwd. Runtime identity is a session-scoped `@sesh-shell-id` marker, so a
  session rename does not lose it and the session name is descriptive only.
  - **They have a runtime but no conversation.** `enter`/nav, `send`, `capture`, `stop`,
    `resume` all work; `fork`, `transcript`, `send-headless` and `--model` refuse loudly.
  - Everything else is the ordinary `thread` surface: list, rename, tag, pin, hold,
    archive, delete, reparent, meta, notify, flag.
  - `shell new --cwd <dir> [--name X]` records and starts one (`--no-start` records the
    place only). `shell enter --cwd <dir>` is **idempotent** on `(cwd, name)` — it enters
    the existing one (restarting a session that went away) or creates it. Several shells
    per cwd are legal but need **distinct names**; `shell new` refuses a duplicate.
  - `shell here` promotes the session you are sitting in; `shell promote --session <name>`
    promotes a named one. `shell sessions` lists every live session on the work server,
    classified `shell` (tracked) / `agent` (hosts agent panes) / `ghost` (untracked — the
    promote target) / `stale` (a marker whose record is gone).
  - `thread send --id <shell> [--pane %12 | --window N]` addresses ONE pane of the
    session (default: its active pane). `shell panes --id X` lists them.
  - `shell info --id X --json` returns the socket path and a ready-to-paste `tmux_prefix`
    — the deliberate **raw-tmux escape hatch**, since sesh does not reimplement tmux.
  - **Stopping a shell kills its whole session**, including any agent-thread panes inside
    it, so it refuses without `--force` when it hosts them. `delete` never kills: it
    refuses while the session lives, and `delete --force` drops the record, clears the
    marker and leaves the session running as a ghost you can re-promote. To get it out of
    the active view while still working in it, **archive** it.
  - In the TUI: **`S`** opens the **shells view** — every live session on every reachable
    machine, classified. It is a list surface like the grid and behaves like one: ↑/↓ (or
    j/k) move and the viewport FOLLOWS the selection, ^j/^k scroll a half-page, `/` filters
    (fuzzy, over session name + machine + path + the agent threads inside — enter applies,
    esc clears), the wheel moves the selection, a click selects a session and a double
    click enters it. `enter` jumps to one, **`P`** promotes it to a tracked shell thread,
    `x` kills it (confirmed; the confirmation names any agent threads that would die with
    it), `R` refreshes, `esc` closes (clearing an active filter first). The cursor is
    ANCHORED to its session across a refresh, so a promote/kill never slides it onto a
    different one.
- **Virtual threads** (`thread new --virtual --name X`, or the `new-virtual` command in the TUI) are
  grouping nodes WITHOUT an
  agent: no pane, no conversation, `agent_kind` reads `virtual`, glyph `≡`. Use one to
  group threads under a parent that isn't (yet) real work: parent/reparent threads under
  it, tag/archive/hold it (a hold on the group parks the whole subtree via inheritance).
  Every agent verb (`send`, `send-headless`, `headful`/`resume`, `capture`, `transcript`,
  fork) refuses loudly; in the TUI, Enter shows a warning instead of entering. Convert it
  into a REAL thread in place with `thread realize --id <id> --agent claude|codex|pi
  [--cwd <dir>]` — the id (and children, tags, holds, ticket bindings) survive, and the
  result is a fresh never-started headless thread: enter it or `send-headless` to start
  the conversation. `--cwd` at realize defaults to the cwd stored at creation (creation
  cwd is optional; one is required by realize time).
- **Tickets** are work items (a name + a prompt) optionally bound to a thread (`needs-input`
  derives from the thread's axes). Single-owner: every ticket command auto-routes to the
  configured ticket owner. CLI:

  ```bash
  sesh ticket create --name <name> [--prompt <text>]      # starts in triage
  sesh ticket list [--thread <id>] [--current] [--all-machines] [--local]   # --current = calling pane's thread; --all-machines fans out across the mesh (emits machine + thread name per ticket)
  sesh ticket get --id <id> [--field prompt] [--json]     # --field: id|name|prompt|status|thread|created|closed|notes (raw)
  sesh ticket find --id <id> [--json]                     # MESH-WIDE lookup: fans out across peers; returns the
                                                          #   ticket + its owning machine + bound-thread context
  sesh ticket set --id <id> [--name <t>] [--prompt <t>] [--notes <t>|--append-note <t>]   # partial text-field update
  sesh ticket set-status --id <id> --status <s> [--thread <id>] [--note <t>]   # active requires --thread; --note appends
  sesh ticket unbind --id <id>                            # detach from the thread (active→ready); "remove from thread"
  sesh ticket send-prompt --id <id> [--no-prepend]        # deliver the prompt to the bound thread's pane
  sesh ticket needs-input --id <id>                       # derived: active && thread headful·idle
  sesh ticket delete --id <id>
  ```

  `ticket get/list/set-status/...` are **local/owner-routed** (they act on one daemon). To
  locate a ticket **without knowing which machine owns it**, `ticket find` fans out across the
  whole mesh and returns the record plus its owning machine and bound-thread `{id,name,parent}`
  in one call — the mechanism behind an API client (e.g. the Obsidian ticket note) that tracks
  a ticket from anywhere. A ticket found nowhere is `found=false` (exit 0), a legitimate state.
  A terminal ticket carries `closed_at_unix` (the done/dropped timestamp; `--field closed`).

  A ticket has a free-text **`notes`** field (the done/scrapped scratchpad — primarily where an
  agent records what it did and which commit closed it). `set --notes` REPLACES it, `set
  --append-note` appends (blank-line separated), and `set-status --note` appends as part of a
  status change — the ergonomic "close AND record what was done" path. Read with `get --field
  notes`. Surfaced (and rendered as **markdown**) in the Obsidian ticket-note top panel — so
  **write notes in markdown** (headings, lists, fenced code, links) for legible consolidation.

  **`send-prompt`** delivers multi-line prompts intact (bracketed paste — newlines are preserved,
  not submitted line-by-line) and by default **prepends the ticket's name + id** so the agent
  knows which ticket it is on. Toggle the default in `<SESH_HOME>/config.toml`
  (`[ticket]\nsend_prepend = false`); override per call with `--prepend` / `--no-prepend`.

  `ticket list --current` is the agent self-check ("what am I assigned?") — it resolves the
  current thread from `$SESH_THREAD_ID`/the pane marker. **Subscriptions** deliver one
  thread's completed turns into another.

  **Status model**: `triage` (unattached, prompt not final) · `ready` (unattached, prompt
  final) · `active` (**attached to a thread** — the only attached state) · `done`/`dropped`
  (terminal). Only `active` requires a binding; `unbind` (or any non-active status) detaches.

  **A ticket lives on the same daemon as its bound thread** (the live `needs-input`/`TKT`
  join is computed per-daemon). To bind a ticket to a thread on **another machine**, the
  ticket is *relocated* to that thread's machine first by **`sesh ticket move`** (which also
  carries the prompt's blobs — see below):

  ```bash
  sesh ticket move --id <id> --to <machine> [--from <machine>]   # default --from: this machine
  ```

  `ticket move` is **daemon-coordinated**: the daemon you invoke it on pulls the record (and
  every `@blob()` its prompt references) from `--from` and pushes them to `--to`, then deletes
  the source — over its own peer transport, so only the invoked machine must reach both ends.
  myrig's `mt-`/`mmt-` ticket commands do this automatically on a cross-machine bind.

## Blobs & files in prompts (`sesh blob`)

A prompt (a ticket prompt, a `thread send`, a headless turn) is **text**, so a file — an
image, a log, anything — is **referenced** by a token and expanded to a real path on
delivery. The store is content-addressed under `<SESH_HOME>/blobs`.

```bash
sesh blob add ~/shot.png            # store a file → prints the token  @blob(9f3ac1b2d4e5)
pngpaste - | sesh blob add --stdin --name shot.png   # store piped bytes (clipboard)
sesh blob ls | get | rm | path      # housekeeping (manual GC via rm; get = raw bytes to stdout)
sesh blob expand                    # stdin→stdout: replace every @blob(<hex>) with its path
```

Paste the printed **`@blob(<hex>)`** token anywhere in a prompt. On **send** (`ticket
send-prompt`, `thread send`, `send-headless`) and on **copy** (the cockpit's copy-prompt),
sesh expands each token to the blob's absolute path on the thread's machine — the agent then
reads the file (image → vision, etc.). A token referencing **no blob is a LOUD error**, never
sent verbatim. Escape a literal with `@@blob(…)`. Every `blob` op takes `--machine` like
tickets; `ticket move` carries a prompt's referenced blobs to the destination automatically.

## Listing directories on a daemon (`sesh fs list`)

A generic, policy-free filesystem primitive the daemon serves over its API: the immediate
**subdirectories** of an allow-listed, **home-rooted** path on the daemon's host. Routes per
`--machine` like tickets, so you enumerate the machine you're targeting (works where the
caller has no local filesystem access — e.g. the Obsidian app on mobile filling its
box/mysetup cwd pickers).

```bash
sesh fs list --path ~/dev                       # box checkout dirs (name<TAB>~-relative path)
sesh fs list --path ~/mysetup --machine macbook --json
```

Dirs only (symlinks not followed). A path **outside the home dir** — or one escaping via
`../` — is refused **loudly** (403), never a silent empty listing.

## Plugins (`sesh plugins`) — daemon command-providers

A plugin manifest at `<SESH_HOME>/plugins/*.toml` declares commands the daemon runs **on
its own host** and how the sesh-ui app surfaces them. The app (especially mobile / a remote
daemon) has no shell on the target, so machine ops go via the daemon. Two capability kinds:

- **list** — a command whose JSON output is mapped to `{id,label,groups,path}` items
  (templated `id`/`label`/`path` over each item's fields; `groups` names a string-array
  field; `items` is a dotted path to the array, empty = root). E.g. boxyard boxes → the
  new-thread cwd picker **with groups**.
- **action** — a command with form `field`s; the values are substituted into the argv as
  **ARGV** (never a shell string → no injection) and the command runs. E.g. create-a-box.

```bash
sesh plugins list --json                                    # manifests + capabilities
sesh plugins run boxyard boxes --machine macbook --json     # a list capability → items
sesh plugins run boxyard create-box --field name=my-box     # an action; values as ARGV
```

Routes per `--machine` like `fs list`, so you drive whichever machine's plugins you need.
Commands come from the manifest **only**, never the client. Bad requests (unknown plugin or
capability, missing required field, nonzero command exit) fail **loudly**. The shipped
example is `examples/plugins/boxyard.toml` (drop it at `<SESH_HOME>/plugins/boxyard.toml` on
a machine with `boxyard` on the daemon's PATH).

## The TUI (`sesh tui`)

`sesh tui` opens the live cross-machine thread grid (`--all-machines` to fan out). It is a
thin client — it **emits** actions by driving the CLI verbs, never reimplementing them.

Launch with a directory boundary when a caller needs a project-local grid:

```bash
sesh tui --cwd . --view all          # exact stored CWD only
sesh tui --cwd-tree . --view all     # that CWD plus path descendants
```

The boundary is **launch-time scope**, independent of both the active built-in/custom
view and `/` fuzzy filtering: Tab still lists every configured view, but no view can
escape the CWD boundary. `--cwd-tree` uses path containment (`/work/app2` is not under
`/work/app`). A directory inside the invoking user's home is compared through each
owner-stamped `cwd_rel`, so `~/mysetup/sesh` matches across Linux and macOS even though
their absolute home paths differ. `--view <name>` chooses only the initial view and
does not remove the others. `--cwd` and `--cwd-tree` are mutually exclusive, and neither
combines with `--cursor`.

**Sidebar mode** (`sesh tui --sidebar`): the persistent-pane variant for a cockpit — a
narrow NAME-only column preset (the state gutter carries the rest; `[tui] columns` and
`[[tui.column]]` moves don't apply, an explicit `--columns` wins), and **entering a
thread does not quit the TUI**: the nav happens and focus hands to the sibling pane in
the same tmux window, so the sidebar stays ambiently visible beside the agent. A
**single mouse click enters a thread** (the sidebar is a jump list — no
select-then-double-click; clicking the ▸/▾ marker still just folds). **Moving the
selection FOLLOWS immediately**: the cockpit previews the selected thread while focus
stays in the sidebar — Enter/click is what commits focus. A local preview costs ~a
tmux switch (one warm daemon call, no subprocess); while one is in flight further
moves coalesce into a single catch-up nav, so held arrows degrade gracefully.
An **Enter/click always beats an in-flight preview**: clicking while a follow is
still running holds the enter until that preview lands, so the thread you picked is
the last one the cockpit is told to show (previously the stale preview could land on
top of the click, so the click "didn't take" and only corrected itself a nav later).
A stalled preview can't swallow the click — past a short grace the enter goes out
anyway.
**esc/q never quit in sidebar mode** — the keymap binds them to `dismiss` there, so
they clear the ✗ error / note lines (which would otherwise persist forever in a pane
that never quits) instead of killing a pane the cockpit depends on. ctrl+c is the
deliberate kill; hide/show is the cockpit toggle's job. A successful nav or follow
also clears a stale error.
Entering a thread from `/` search exits search (query cleared, cursor on the entered
thread) — the sidebar returns to the whole ambient list. While in filter INPUT mode
the sidebar pane can wear a distinct tmux tint (`--sidebar-filter-style`, e.g. a dark
red) as an unmistakable "keystrokes go to the filter, not to actions" cue — restored
on filter exit. A **maximized** sidebar
(pane >= 80 cols — the cockpit zoom toggle) adaptively renders the FULL grid column
set (the same columns the normal grid shows) and swaps back to name-only on restore.
A maximized sidebar does not follow the selection (the preview pane is hidden and a
cross-machine follow would switch windows and drop the zoom) — browse the list, Enter
commits. Follow
crosses machines: the master window switches and the traveling sidebar rides along
(an intent option tells the swap hook to keep focus on the sidebar; an Enter's switch
focuses the attach pane instead). It previews only live headful threads (it never
revives a dead one — Enter still does); the sibling machine resolves live from the
tmux window name ($SESH_TUI_MASTER_MACHINE pins it for static spawners).

The traffic runs BOTH ways: the sidebar's cursor also **tracks the cockpit**, so a
thread switch made from the cockpit side — the cycle keys, the last-window toggle, a
picker, a command that creates a thread and jumps to it — moves the `>` onto that
thread too. It moves only when what the master window shows actually CHANGES, never
merely because the cockpit disagrees with the cursor: arrowing onto a row the follow
policy skips (a headless one) leaves the cockpit where it was, and a disagreement-driven
tracker would yank the cursor back and make browsing impossible. `sesh tmux nav` rings a
bell file (`<home>/nav-bell`) after every successful nav, which the sidebar reads on a
cheap 250ms timer and answers with one authoritative resolve, so a cockpit keypress
moves the cursor immediately; a 3s backstop catches moves sesh never saw (a native
prefix+n switch, a pane selected by hand). A thread the current view does not contain —
on hold, archived while you are on `active`, or dropped by an active filter — leaves the
cursor alone: no jump and no view switch, unlike `goto-uuid`, which is a command you
typed rather than an ambient tracker. Every other key/view/action works exactly as in
the normal grid.

### Commands, the palette, and the keymap

Every action the grid can perform is a named **command** (`flag`, `archive`,
`set-parent`, `new-divider`, …). There are two ways to run one:

- **`p` — the COMMAND PALETTE.** A full-screen fuzzy search over every command:
  type part of its description or its id, `↑/↓` (or `^k/^j`) move, **enter runs it
  on the selected thread**, esc cancels. A mouse click on a row runs it; the wheel
  moves the selection. Each row shows the command's current key, so the palette
  doubles as a discoverable keymap.
- **A key**, for the frequent commands only. The key set is deliberately small —
  everything else is palette-only.

`?` shows the whole keymap in a scrollable popup (one binding per line, keyless
commands included). The bottom line carries only a dim **`? keys · p commands`** hint.

`q`/`esc` quit as they always have. **`ctrl+c` also always quits** and — unlike
every other binding — cannot be rebound or unbound, so no config can leave the TUI
with no way out. In **sidebar mode** the keymap binds `q`/`esc` to `dismiss`
instead (clearing the ✗ error / note lines), because a persistent cockpit pane
must not die to a stray keystroke; a `?` popup inside a sidebar shows that. `quit`
chosen explicitly from the palette still quits, even there.

Keymap (normal mode) — the commands that carry a default key:

```
↑/↓ or j/k   move cursor          ^j / ^k    scroll viewport a half-page
←/→          fold / unfold tree    ^h / ^l    pan columns left/right (when clipped)
mouse wheel  move selection up/down; Shift+wheel (or wheel left/right) pans columns
mouse click  select the clicked row; DOUBLE-click enters it (= enter); click the ▸/▾
             fold marker to collapse/expand that thread's subtree
enter        nav: switch your tmux client to the thread (or attach from a plain shell;
             a headless thread is promoted, a dead one resumed first)
/            filter mode (fuzzy; ↑/↓ or ^k/^j move the selection; ^t cycles the search
             target; ^y EXCLUDES child threads — off by default, i.e. a query searches
             every thread, nested or not; esc applies)
tab          view PICKER: a popup listing every view (active / on hold / archived /
             all / custom [[tui.views]]) opening on the CURRENT one — tab/↑/↓ move
             (wrap), enter or a mouse click applies, esc cancels; the wheel moves
             the selection.
             The default `active` view shows every non-archived thread PLUS archived
             threads that are still headful (a live pane, glyph `⊘`) or RUNNING, and
             hides on-hold threads — i.e.
             `(flagged OR not archived OR headful OR running) AND not on hold`.
             A FLAGGED thread overrides the archived-hiding (attention wins; unflagging
             re-hides it), but HOLD BEATS FLAG — and hold beats running: an on-hold
             thread never shows in active whatever its state, flagged or busy — its ⚑ is
             visible in the `on hold` view. So an archived thread stays visible while its
             agent is working — including a HEADLESS turn (`◌▶`, e.g. from `delegate` or
             `send --headless`) — and drops out once it is quiet. (`tui --cursor` / the
             cockpit prefix+a preselect the current
             thread; if it is hidden by the default view — e.g. a headless archived
             thread, or one on hold — the TUI opens on `all` so the cursor still lands on it)
p            COMMAND PALETTE (fuzzy-run any command — see above)
h            hold: park the thread until the start of tomorrow (it drops out of the
             default view and returns automatically tomorrow); on an already-held
             thread `h` un-holds it — clearing its own hold, or RELEASING it from an
             ancestor's hold (with its own subtree) when that is what parks it
r            rename (line prompt; ←/→ move the cursor, Home/End jump, edit in place)
f            toggle the flag (⚑; flagging a flag-disabled thread re-enables it)
ctrl+f       toggle auto-flagging for the thread (⌁ when disabled; also unflags)
n            toggle notify          i          toggle the ID column
w            toggle the column-width cap (off = every column grows to its content,
             so clipped text — a long name/cwd — becomes fully visible)
u            unpin (remove the manual ordering; the thread rejoins the auto block)
m            MOVE MODE: reposition the selected pinned row — ↑/↓ move it within the
             block, enter/esc commit-and-exit (an unpinned top-level row is pinned first)
I            thread details: a read-only popup of ALL of the selected thread's
             fields (id, agent, model, state axes, cwd, parent, tags, hold,
             tickets, session id, meta…); esc/q closes
y            show full UUID (c copies)         R   force refresh
K            tickets view (the selected thread's tickets — see below)
S            SHELLS view — every live tmux session on every reachable machine,
             classified shell/agent/ghost/stale. Scrolls/filters/clicks like the
             grid (↑↓ j/k, ^j/^k half-page, / filter, wheel, click = select,
             double-click = enter). enter jumps to one, P promotes it to a tracked
             shell thread, x kills it (confirmed), R refreshes, esc closes (an
             active filter first). This is where sessions sesh did NOT create
             become visible.
x            stop      a  archive/unarchive (INSTANT)
U            undo the last archive (LIFO across this session's archives)
?            the keymap popup
q / esc      quit (in SIDEBAR mode: dismiss the ✗ error / note lines instead)
ctrl+c       quit (always available, never rebindable)
```

Palette-only commands (id — what it does):

```
goto-uuid        GO TO a thread by uuid (line prompt; the full uuid or the short
                 8-character form, empty = cancel) — see below
hold-until       hold until an explicit date (line prompt; YYYY-MM-DD, empty = un-hold)
tag-add          add a tag                tag-remove   remove a tag (picker)
set-parent       set parent by PICKING one from a list — see below
set-parent-uuid  set parent by pasting a uuid/prefix (empty = root; self/cycle/unknown
                 are refused with a persistent on-screen warning)
new-virtual      new VIRTUAL group (name prompt; empty cancels). Creates a root
                 grouping thread on the SELECTED row's machine (virtual parents only
                 group same-machine threads) and lands the cursor on it — then
                 `set-parent` children under it. No selection = the local machine.
pin              pin the selected top-level thread to the TOP of the manual-order block
                 (pinned threads render ABOVE the auto-sorted list — position is the
                 marker; there is no pin glyph)
new-divider      new DIVIDER (label prompt; empty = an unlabeled rule). A horizontal
                 line in the pinned block, on the SELECTED row's machine
fork             copy the selected thread into a new HEADLESS thread (same conversation,
                 branched; keeps the source name marked ` (fork)`). It doesn't start
                 anything — enter the copy to continue; the source is untouched.
delete           delete the record (asks y/n)
toggle-offline   show / hide the threads of OFFLINE mesh machines (hidden by default)
dismiss          clear the ✗ error / note lines (esc/q do this in sidebar mode)
```

**Going to a thread by uuid (`goto-uuid`).** A line prompt takes a thread's uuid —
the full 36-character one, or the short prefix the ID column (`i`) shows — and the
CURSOR lands on that thread. It **locates, it does not enter**: `enter` is still what
navs into a thread. If the current view already shows the thread the cursor just
moves; otherwise the grid switches to the **first view in display order** (active →
on hold → archived → all → your `[[tui.views]]`) that shows it, and says so in the
note line — so an archived thread takes you to `archived`, a parked one to `on hold`.
A nested thread's ancestors are expanded so the cursor really lands on it. Every
other outcome is a **loud refusal that changes nothing**: a uuid matching no thread,
a prefix matching several (it names them — type more characters), input that isn't a
uuid at all, or a thread the grid is deliberately hiding — one on an **OFFLINE**
machine (run `toggle-offline`), one on a **peer** while the grid is self-only (start
with `--all-machines`), or one the **active filter** drops (clear the filter). It is
palette-only by default; bind it with `[[tui.key]]` if you want a key.

**Setting a parent interactively (`set-parent`).** Run it on the CHILD: a picker opens
listing the threads it could hang under — type to filter (fuzzy, by name or uuid),
`↑/↓` move, **enter applies**, esc cancels, a mouse click applies directly. The list is
narrowed to choices the daemon will actually accept: the **same machine only** (a
parent is validated against the owner's local store, so cross-machine parenting does
not exist), never the thread itself or any of its **descendants** (a cycle), never a
divider, and not its current parent. A thread that already has a parent also gets a
**`(root — no parent)`** entry at the top, which detaches it. `set-parent-uuid` is the
original paste-a-uuid form and is unchanged.

**Rebinding keys (`[[tui.key]]`).** Any command's key can be changed, added to, or
removed in `~/.sesh/config.toml`:

```toml
[[tui.key]]
command = "fork"          # a command id (as shown by `?` / the palette)
key     = "F"             # a bubbletea key string: "f", "F", "ctrl+f", "up", "alt+enter"

[[tui.key]]
command = "delete"
key     = ""              # unbound — reachable only from the palette
```

The **first** entry naming a command REPLACES its default keys (so this MOVES it
rather than adding a second binding); **further entries for the same command add**
more keys. A configured key WINS over a default that held it, and the displaced
command then renders as keyless — the `?` popup and the palette always show what the
keys actually do. An unknown command id, an unusable key name (a typo like
`ctlr+f`), two entries fighting over one key, or an attempt to rebind `ctrl+c` are all
**loud startup errors** — never a key that silently never fires.

On a **virtual** row (`≡` — a grouping node with no agent), Enter and `f` show a
warning instead of acting; convert it first with `sesh thread realize`. Grouping
commands (hold, tags, rename, set-parent, archive, delete) work normally on it.

The selection is **anchored to the thread**, not the row position: when a background
refresh (the ~3s poll / mesh sync) makes a row appear or disappear above the cursor, the
cursor stays on the *same* thread rather than shifting onto whatever slid into its slot —
so archive/delete/stop never hit the wrong thread. The exception is when your own action removes
the selected thread from the view (archive it, hold it, reparent it away): the cursor
then falls to the neighbour rather than chasing the vanished row.

**Hold** parks a thread you're not working on today. It sets the thread's
`on_hold_until` to an absolute instant and the owning daemon derives a live "on hold"
flag against its clock, so a hold **auto-expires** — `h` defaults to the start of
*tomorrow*, so a parked thread reappears in the default view the next day with no action.
The default `active` view hides on-hold threads; the **`on hold`** view (in the `tab`
cycle) shows the parked ones. The CLI verb is `sesh thread hold` (see below).

Hold is **inherited down the tree**: a thread's effective hold is `max(its own hold,
its ancestors' holds)`, so holding a parent parks its whole subtree (the children show
`↑<date>` in the HOLD column — an inherited hold). Inheritance is resolved per machine
(a cross-machine parent's hold is not inherited), and an **archived** thread is detached
from it: a hold parks *active* work temporarily, archiving is the permanent kind and
already hides the thread everywhere, so an a

…(truncated)
