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-idmarker on the tmux pane the command actually runs in. Verified: a process elsewhere cannot inherit it. - env —
$SESH_THREAD_IDalone, 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-globalclaude daemon runthat 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_PANEand a$SESH_THREAD_IDfrozen 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>&1in 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 infowho 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.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 withforthread 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]](nameshold,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 isHBD(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 ismax(own, ancestors'), so it needsthread 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 defaultactiveview, so the sigil is something you see inall,on hold, and any custom view that admits parked threads.
- head:
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 newdefaults to childing the new thread to the current one (see the ⚠️ note under Creating — pass--no-parentfor 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_kindreadsshell) 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, andthread resumere-creates the session in the recorded cwd. Runtime identity is a session-scoped@sesh-shell-idmarker, 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,resumeall work;fork,transcript,send-headlessand--modelrefuse loudly. - Everything else is the ordinary
threadsurface: list, rename, tag, pin, hold, archive, delete, reparent, meta, notify, flag. shell new --cwd <dir> [--name X]records and starts one (--no-startrecords 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 newrefuses a duplicate.shell herepromotes the session you are sitting in;shell promote --session <name>promotes a named one.shell sessionslists every live session on the work server, classifiedshell(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 Xlists them.shell info --id X --jsonreturns the socket path and a ready-to-pastetmux_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
--forcewhen it hosts them.deletenever kills: it refuses while the session lives, anddelete --forcedrops 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:
Sopens 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.enterjumps to one,Ppromotes it to a tracked shell thread,xkills it (confirmed; the confirmation names any agent threads that would die with it),Rrefreshes,esccloses (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.
- They have a runtime but no conversation.
Virtual threads (
thread new --virtual --name X, or thenew-virtualcommand in the TUI) are grouping nodes WITHOUT an agent: no pane, no conversation,agent_kindreadsvirtual, 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 withthread 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 orsend-headlessto start the conversation.--cwdat 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-inputderives from the thread's axes). Single-owner: every ticket command auto-routes to the configured ticket owner. CLI: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 findfans 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 isfound=false(exit 0), a legitimate state. A terminal ticket carriesclosed_at_unix(the done/dropped timestamp;--field closed).A ticket has a free-text
notesfield (the done/scrapped scratchpad — primarily where an agent records what it did and which commit closed it).set --notesREPLACES it,set --append-noteappends (blank-line separated), andset-status --noteappends as part of a status change — the ergonomic "close AND record what was done" path. Read withget --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-promptdelivers 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 --currentis 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). Onlyactiverequires a binding;unbind(or any non-active status) detaches.A ticket lives on the same daemon as its bound thread (the live
needs-input/TKTjoin is computed per-daemon). To bind a ticket to a thread on another machine, the ticket is relocated to that thread's machine first bysesh ticket move(which also carries the prompt's blobs — see below):sesh ticket move --id <id> --to <machine> [--from <machine>] # default --from: this machineticket moveis daemon-coordinated: the daemon you invoke it on pulls the record (and every@blob()its prompt references) from--fromand pushes them to--to, then deletes the source — over its own peer transport, so only the invoked machine must reach both ends. myrig'smt-/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.
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).
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 (templatedid/label/pathover each item's fields;groupsnames a string-array field;itemsis a dotted path to the array, empty = root). E.g. boxyard boxes → the new-thread cwd picker with groups. - action — a command with form
fields; the values are substituted into the argv as ARGV (never a shell string → no injection) and the command runs. E.g. create-a-box.
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:
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:
[[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)