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.
How do I install this agent skill?
npx skills add https://github.com/lukastk/sesh --skill sesh-cliIs this agent skill safe to install?
- Gen Agent Trust Hubpass
The skill provides detailed documentation for `sesh`, a session management tool for AI agents. It includes capabilities for remote command injection, task scheduling, and cross-machine agent coordination. While the skill is functional and lacks explicit malicious patterns, it introduces a significant attack surface for indirect prompt injection and persistent command execution across machines.
- Socketpass
No alerts
- Snykpass
Risk: LOW · No issues
What does this agent skill do?
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 — for a pane-less process — a headless turn the
local daemon launched, then the agent harness's own session id, then $SESH_THREAD_ID,
or a loud error if nothing 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). The copy goes to the clipboard of the
machine the TUI is running on — pbcopy on macOS, wl-copy/xclip/xsel on Linux,
termux-clipboard-set on termux (needs pkg install termux-api and the Termux:API
Android app). So a TUI opened in a cockpit window for a REMOTE machine copies to that
machine's clipboard, not yours. A TUI with no display in its env (a work-server popup)
takes the graphical session env from the systemd user manager; a failed copy is a loud
✗ line naming the tool's own error.
Am I really this thread? (provenance)
Inference has four 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. - turn — the local daemon confirming that this process sits inside the process tree it
created for a thread's headless turn. Verified for the same reason: the daemon
started that turn, remembers its root process, and checks the live parent links — so a
process outside the tree (anything detached, anything reparented) cannot claim it. This
is how a
schedule spawn --headlessworker identifies itself. - harness — your agent harness's OWN session id (
$CLAUDE_CODE_SESSION_ID), matched against theagent_session_idthe daemon recorded for exactly one thread. Verified, but conditionally: this is a claim you present rather than a fact read from your container, so it is accepted only when your cwd corroborates it AND that thread still has a live pane. This is what identifies a reparented claude agent — one whose tool calls run detached, so neither$TMUX_PANEnor process ancestry reaches its own pane. - env —
$SESH_THREAD_IDalone, when there is none of the above. 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 whoami is the command to reach for. It runs the same resolution with the safe
default: it prints the full uuid only when the identity is verified, and exits
non-zero otherwise. So this is safe by construction —
TID=$(sesh whoami) || exit 1 # or: || { echo "I am not a sesh thread"; exit 1; }
— and it has no --id, because naming a thread would make the answer trivially
"explicit". It is not routable either: a peer would read its own pane and environment
and answer confidently about a different machine, which is the failure mode, not a
limitation. --json adds source/verified/name/cwd; --allow-unverified downgrades it
to info's tolerance. Three refusals, each distinct:
| what happened | what it means |
|---|---|
| the env id is contradicted by your cwd | very likely another thread's id — do not sign or attach as it |
| the env id is uncontradicted but unverified | your cwd neither confirms nor denies it; absence of contradiction is not evidence |
| your harness session names a thread in an unrelated directory | either the harness is reporting other work's conversation, or you are not the agent you appear to be — refuse to act as it |
| nothing resolved | UNRESOLVED, not "you are not a thread" — read the lead the refusal prints (see below) |
sesh info is the diagnostic twin and deliberately keeps the opposite default: it
reports which source it used — a source: line, or "source" / "verified" in --json
— announces an env-derived answer on stderr, and still exits 0 for it, because
refusing to describe a thread is the wrong move when you are diagnosing. It does refuse
one case: an env-derived id whose thread cwd is unrelated to where you are standing.
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.
Do not read an id out of sesh info to act as yourself. That is what whoami is for;
info will hand you an unverified id and a zero exit, and the warning is on stderr where
a 2>/dev/null swallows it.
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 whoami) || { echo "not verified — refusing to act on myself"; exit 1; }
"I could not resolve my identity" is NOT "I have no identity" — do not conclude the
second from the first. This distinction cost a real misattribution. On 2026-09-25 an agent
reported that it was not a sesh thread and should sign as a Claude Code job; on 2026-09-27
it corrected itself — it was thread 1a26989d, with its pane, marker and cwd all
registered, while its $SESH_THREAD_ID named adi-requests in a different project. It was
reparented, so nothing in its environment could say so, and it found out only by capturing a
pane and recognising its own prose. The answer was in the daemon the whole time.
So when whoami refuses: read the lead it prints. A refusal now ends with what the
daemon knows about your directory — the one live thread registered there, if there is
exactly one. That is a lead, not an answer: confirm it with sesh info <id> and by capturing
its pane to see whether the conversation on screen is the one you are having. If it is you,
name that thread explicitly in what you were about to run.
If you genuinely are not a thread, say so — never borrow an id. A cron task or a
detached job with no live thread behind it should sign as what it actually is: the tool or
job and its working directory, e.g. Claude Code job ef96bf74, cwd ~/dev/…/courses/finnish.
Signing with an inherited $SESH_THREAD_ID instead produces a resolvable id pointing at
live, unrelated work — a reply addressed to a thread that never asked, and bookkeeping
attached to the wrong project.
Corroboration is evidence, not proof: an inherited id that happens to name a thread in the
same directory tree still resolves, which is precisely why whoami refuses it and info
does not.
If you are a scheduled or headless worker
A thread created by sesh schedule spawn does not exist when the schedule is written, so
its prompt cannot name it. Ask instead — this works in a headless run (the daemon
verifies you are inside the turn it launched) and in a --headed one (the pane marker):
TID=$("$SESH_BIN" whoami) || exit 1
$SESH_BIN is the launching daemon's own binary, injected into the turn's environment
alongside $SESH_THREAD_ID. Prefer it to a bare sesh: from a login shell that may be a
wrapper function or an older install on the PATH, i.e. a different sesh than the one that
started you. And $SESH_THREAD_ID is still not the contract — it is inherited by
anything you detach, so whoami refuses it alone.
Two things that will still (correctly) refuse: a process the worker detached from its own turn, and anything asking after the turn has ended — the identity does not outlive the work it describes. Both cases want an explicit id, captured while the turn was live.
Outside a pane and outside a daemon-launched turn, an explicit --id is the only
certainty.
Before running commands
Read-only (safe to run freely): list, grid, info, whoami, 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.- Workspace-trust prompts are pre-answered. Every headed claude/codex launch
(new, revive,
--into-pane) first marks the thread's cwd trusted in that agent's own config — claude:projects[<cwd>].hasTrustDialogAcceptedin~/.claude.json(or$CLAUDE_CONFIG_DIR/.claude.json); codex:[projects."<cwd>"] trust_levelinconfig.toml— so the agent comes up at its input prompt and athread sendfired right after spawn lands in the agent, not in a "Quick safety check … trust this folder?" dialog (which would otherwise eat it: Enter there picks "No, exit"). Claude's dialog fires for every fresh git-repo box even under--dangerously-skip-permissions, since its trust lookup stops at the repo root. sesh writes exactly that one key (atomically, never a corrupt or truncated file — an unparseable config is refused loudly instead) and nothing when the cwd is already trusted. It does NOT pre-approve CLAUDE.md external imports; that dialog is separate and only appears for a project CLAUDE.md that imports outside the tree.
- Workspace-trust prompts are pre-answered. Every headed claude/codex launch
(new, revive,
-
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). Tickets are per-daemon: a ticket lives on the machine of its bound thread (or where it was created), and ticket commands act on the local daemon, or on--machine <m>.SESH_TICKET_OWNER, if set, instead routes every ticket command to one owner machine — it is unset on Lukas's fleet. To find a ticket whose machine you don't know, useticket find. 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). The sidebar's OWN navs never feed back into
it: while a preview is still landing the tracker waits, and a resolve that raced one of
the sidebar's navs is discarded — so arrowing faster than the previews land never drags
the cursor back onto a row you already passed. 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.
A VIEW RING flips ONE key between the two or three views you actually
live in instead of Tab-walking the picker: name them in `[tui]
view_ring = ["active", "flagged"]` and bind the `view-ring` command
to a key with `[[tui.key]]` (it has no default). It steps to the next
ring entry, wrapping; from a view the ring does not mention it enters
at the FIRST entry. In the cockpit, Shift+F12 focuses the traveling
sidebar and presses that key for you.
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, schedules, session id, meta…); scrolls when the list is
taller than the pane (↑/↓, j/k, ^j/^k half-page, or the wheel);
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 archived thread neither inherits an
ancestor's hold nor passes one down to its descendants. Its OWN hold still applies and
still reaches its children — what stops at an archived node is only what flows from
above it. (Un-archiving returns the thread to inheriting: this is about what is parked
while archived, not a permanent exemption.)
Releasing a child from its parent's hold. Because the effective hold is a max,
clearing a child's own hold cannot undercut its parent's — so a thread is in exactly
one of three states, and sesh thread hold writes them:
| state | how | effect |
|---|---|---|
| held until T | --until <date> / --until-unix <n> | parked; its subtree inherits |
| released until T | --release [--until <date>] (default: tomorrow) | ancestors' holds do not apply — to it or its own subtree |
| neither | --clear | inherits from its ancestors again |
A release is a dated statement like a hold, so it auto-expires: tomorrow's parking
round parks the thread again like everything else, and no thread is ever silently
exempt forever. Setting either state clears the other — a thread is never both. In the
TUI, h on a thread parked by an ancestor issues the release for you (and the HOLD
column shows ~<date> while a release is in force, while the leading gutter cell
shows ⧗ own / ⧖ inherited so you can see which rows need which); h on a released thread holds it,
which clears the release. --clear and --release fail loudly if the thread is
still on hold afterwards, naming the ancestor responsible — an un-hold that silently
left the thread parked is exactly the bug this replaced.
Manual ordering (pinning + dividers). Threads are otherwise auto-sorted, but you can
pin top-level threads to a manually-ordered block that renders above the
auto-sorted list. The pin command (palette) pins the selected thread to the top of the
block; u unpins it (it rejoins the auto block). Pinned rows carry no marker glyph — their position above the
auto-sorted block is the signal. m enters move mode — ↑/↓
reposition the pinned row within the block, enter/esc exit (a still-unpinned top-level row
is pinned first). Only top-level threads can be pinned; a thread loses its pin when
archived or reparented under another thread. new-divider spawns a divider — a
horizontal rule (with an optional label) you place between pinned threads to group them;
dividers live in the pinned block, are repositioned like any pinned row (m), and are
removed with the delete command, not archived/unpinned. Pinning is a real thread property
(pin_order), synced across the mesh, so the order is the same viewed from any machine.
The CLI verbs are sesh thread pin / sesh thread unpin / sesh thread new --divider
(see below).
delete opens a y/n confirmation — y confirms, any other key cancels.
Archiving is instant (no confirm): a parks the thread immediately and notes
"U to undo"; U un-archives the most recently archived thread (a LIFO stack of
this session's archives, so repeated U walks back through them; an entry whose
owner machine is offline refuses loudly and stays undoable). Move mode shows its own
ambient legend in place of the ? keys · p commands hint.
Offline machines. A machine's threads keep showing in the mesh view (for offline
browsing) even after it disconnects, but every action on them routes to the owning
daemon — which is unreachable — so entering/archiving/holding one would hang on the
routing timeout (~6–15 s) and then fail. So the TUI hides an OFFLINE machine's
last-known threads by default, and if you're pointed at one, an owner-routed key
(enter, archive, hold, stop, rename, tag, set-parent, tickets, …) refuses instantly
with a loud <machine> is offline … message instead of freezing — from the command
palette as well as from a key. The OFFLINE footer line still shows the machine (and how
many threads are hidden); run toggle-offline from the palette to reveal/re-hide
them (e.g. to browse a powered-off machine). Default the reveal on with [tui] show_offline = true or --show-offline. Reachability comes from the mesh sync, so it
can lag a real disconnect by a sync tick or two.
The archived view (in the tab cycle) orders by most recently archived first
(the daemon stamps archived_at on each archive; un-archiving clears it, so re-archiving
re-stamps a fresh time). An opt-in archived column shows that timestamp, and the
gutter marks any archived row with ⊘ (so archived-but-headful threads are recognisable
in the default view too).
Tickets view (K) is a full-screen takeover listing the selected thread's tickets. It
defaults to showing active tickets; tab opens a status picker (triage/ready/active/
done/dropped/all) that narrows the list. Enter drills into one ticket: its full id (the ticket's own uuid, shown read-only at the top — distinct from the truncated thread id) plus its fields (name, prompt) + a small action menu. Enter on
name/prompt edits it in your editor (suspend → save); status opens a picker
(triage/ready/active/done/dropped); thread opens an fzf-style picker to (re)bind the
ticket to another thread (type to filter by name or uuid); send prompt to thread
delivers the prompt to the thread's live pane; delete ticket asks y/n. In the list,
n creates a new ticket (type a name) bound to the thread. ↑/↓ move,
enter/l drill in, h/esc back, q back to the grid. The field editor is
sesh tui --editor <cmd>, else [tui] editor, else $EDITOR (a loud error if none).
Two opt-in columns surface ticket state per thread: ticket_name (the newest open
ticket's name, +N if more) and ticket_input (a ! when an active ticket sits on a
headful·idle thread — i.e. it needs your input).
Columns are configurable (--columns a,b,c or [tui] columns); NAME is blue, CWD
green, and the ticket_input ! red by default (tunable via [[tui.column_color]]).
Each column is capped at a max width by default (full-width NAME/CWD/TKT-NAME at
40/40/30, fixed columns at their built-in width) so one long name/cwd can't blow out
the layout — a clipped cell ends in …. Press w to toggle the cap off and let
every column grow to its content (so you can read a clipped row in full). Configure it:
[tui]
max_column_widths = false # disable the cap entirely (columns always grow to content)
[[tui.column_width]] # raise/lower one column's cap (applied while the cap is on)
name = "name"
max = 60
Wide grids clip and scroll
horizontally (^h/^l, Shift+wheel, or a native wheel-left/right); long grids scroll
vertically (^j/^k move the viewport a half-page; the mouse wheel moves the SELECTION,
viewport following, with ▲/▼ markers). Wheel sensitivity is configurable — how many
notches it takes to move one step (1 = every notch, higher = less sensitive):
[tui]
mouse_scroll_v = 3 # vertical: 3 notches per row (dampens fast trackpad scrolling)
mouse_scroll_h = 2 # horizontal: 2 notches per column
The mouse also clicks: a single left-click selects the row under the pointer, a
double-click enters it (the same as enter — a headless thread is promoted, a dead
one resumed; an offline machine's thread is refused loudly rather than hung on), and a
click on the ▸/▾ fold marker collapses/expands that thread's subtree.
The mouse works in any terminal that forwards mouse events (incl. the prefix+s
tmux popup); while the TUI is up it captures the mouse, so terminal-native drag-select
needs Shift. Horizontal-wheel events aren't emitted by every terminal — Shift+wheel is
the reliable cross-terminal pan. (On Termux, two-finger touch-scroll is captured by the
terminal app for its own scrollback — use a hardware mouse for wheel/click events there.)
Entering, listing, inspecting
sesh tui --all-machines # the live grid (enter to jump to a thread)
sesh thread list --all-machines # flat list across the mesh (--json for scripts)
sesh thread grid --all-machines # list + live head/busy/attachment per thread
sesh mesh # merged cross-machine view + per-peer freshness
sesh info <id> # one thread: record + both axes + tmux locator + tickets
sesh thread status --id <id> --json # just the live runtime axes
sesh thread pane --id <id> # the live pane locator (errors if dead)
sesh thread capture --id <id> --lines 80 # the live PANE TEXT — peek at what an agent
# is showing (e.g. a child stuck on a prompt)
sesh tail <id> -n 50 # last N transcript lines
sesh transcript <id> # whole transcript dump
sesh thread capture is the supervising-from-afar tool: a parent thread can read a
child's screen to see if it stalled on a multiple-choice prompt. It routes cross-machine
(--machine), resolving the pane on the owner.
Creating, lifecycle, navigating
⚠️ PARENT INFERENCE — read this before you create a thread.
sesh thread newdefaults to making the new thread a CHILD of the thread you are running inside. With no--parentand no--no-parent, it infers a parent using the ordinary current-thread precedence (the calling pane's@sesh-thread-idmarker first, then$SESH_THREAD_ID). So an agent that spawns a thread will, by default, create a child of itself. This is correct only when you genuinely mean to delegate a sub-task.The inferred parent is announced on stderr, naming the thread and the provenance it came from (
sesh: parenting under "boxyard-go" (1777a4ac) — inferred from pane …), so a mis-parent is visible immediately rather than a day later. If the id here is unverified and contradicted (see Am I really this thread?), it refuses to infer, says so, and creates a root thread.If the thread is meant to stand alone (a top-level/independent thread), you MUST pass
--no-parent. Otherwise it will be a child. Be explicit:
--parent <id>— child of a specific thread.- (neither flag) — child of the current thread (inferred). Standalone only when run from outside any thread.
--no-parent— force a root thread regardless of context.
AGENT HARNESS POLICY: When an agent creates an ordinary thread with
sesh thread new, it MUST omit--agentunless the user explicitly requested a particular harness. Let the owning daemon apply its configured[defaults] agent; do not hard-code the current agent's harness or choose one on the user's behalf. (--fork-frominherits the source thread's harness before consulting the configured default.)
--cwd accepts a relative path (expanded against the directory where you run the
command) or ~ / ~/… and defaults to the current dir (.) when omitted. A
leading ~ is resolved by the OWNING daemon against THAT machine's home, not the
caller's — so a ~-relative cwd is portable across a --machine spawn (e.g.
--cwd ~/proj --machine macbook lands in macbook's ~/proj). A bare relative path is
only meaningful locally, so for a cross-machine spawn into a dir outside ~ pass an
absolute path.
sesh thread new --name defaulted --cwd ~/proj # uses [defaults] agent; loud if unset
sesh thread new --agent claude --name fix-bug --cwd ~/proj # explicit agent overrides the configured default
sesh thread new --agent pi --cwd ~/proj # --name is OPTIONAL (a nameless thread)
sesh thread new --agent pi --name notes --cwd . --headless # headless; cwd = $PWD
sesh thread new --agent codex --name sub --cwd ./src --parent <id> # a child of a specific thread
sesh thread new --agent claude --name solo --cwd ~/p --no-parent # a ROOT thread (standalone; suppress inference)
sesh thread new --agent claude --name try --cwd ~/p --fork-from <id> # branch a conversation (agent inherits from source if omitted)
sesh thread new --agent pi --name fast --cwd . --headless --model anthropic/claude-haiku-4-5 # pin an agent model
# --model pins an OPAQUE agent model on the thread (no curated list — a bad model fails
# LOUDLY at the agent), applied on spawn, resume, AND every headless turn. Empty = the
# agent's own default. Each agent takes its own spelling: claude `haiku|sonnet|opus|<id>`,
# codex `gpt-5.5|<id>`, pi `provider/id[:thinking]` (e.g. anthropic/claude-opus-4-8).
# Placement — a tmux session may host MANY threads (identity is the pane marker,
# not the session). Default = own new session; otherwise:
sesh thread new --agent pi --name win --cwd . --into-session <name> # a new WINDOW of an existing session
sesh thread new --agent pi --name beside --cwd . --into-window <pane> # a SPLIT beside a pane (or session:window)
exec sesh thread new --agent claude --name here --into-pane "$TMUX_PANE" --exec # run the agent IN the current shell pane
# --into-pane is register-then-exec: sesh records the thread + marks the pane,
# then (with --exec) replaces THIS process with the agent so it takes over the
# pane. cwd defaults to the pane's. Without --exec it prints the launch command.
sesh thread new --virtual --name "project X" # a VIRTUAL grouping node (no agent; cwd optional)
sesh thread realize --id <id> --agent claude --cwd ~/proj # convert a virtual thread into a real one, in place
sesh thread stop --id <id> # end runtime (kills the thread's PANE; a session shared with siblings survives), keep the record (revivable)
sesh thread resume --id <id> # revive a dead thread into a fresh pane (restores convo)
sesh thread headful --id <id> # promote a live HEADLESS thread into a pane
sesh thread headful --id <id> --force # ...and first stop a claude BACKGROUND SESSION that owns the
# conversation (see "A thread that will not come back", below)
sesh thread delete --id <id> # drop the record (refuses a live thread; stop first); children promote to the grandparent
# ── A CLAUDE THREAD THAT WILL NOT COME BACK: a held background session ──────────────
# A claude conversation can be OWNED by a BACKGROUND SESSION. Two ways in, and the
# second is the one that has bitten: `claude --bg` at launch, or the **`/background`**
# slash command (alias `/bg`, "Send this session to the background and free the
# terminal") run INSIDE a live session — it writes a `continued-in` record into the old
# transcript, registers the successor as a background session, and EXITS the pane (which
# is why the thread's tmux window is simply gone afterwards). sesh
# follows a thread's session forward through that chain, so the session it tries to
# resume is exactly the held one. Two things then happen, and they look nothing alike:
#
# * the holder was started by the SAME claude build -> an interactive resume silently
# becomes `claude attach <id>`. The revive LOOKS fine, but the pane is a view onto
# the background session, not a conversation it owns.
# * the holder was started by an OLDER build -> claude refuses, the pane exits at
# once, and `sesh thread headful` fails. Since claude updates near-daily, any
# holder that outlives a release lands here — and the thread is un-revivable until
# the hold is released. (Two threads sat like this for 5 and 10 days.)
#
# The failure NAMES the holder, its state and every remedy. To see it coming instead:
sesh doctor # reports every thread whose conversation a background session owns
claude agents --json # claude's own registry: kind == "background"
claude attach <short-id> # open the background session here, without stopping it
claude stop <short-id> # release it; the conversation is KEPT (`--resume` works after)
sesh thread headful --id <id> --force # do both: stop the holder, then a REAL resume
#
# --force is never implied: stopping a holder whose state is "working" interrupts a turn
# running right now, so neither the TUI's revive nor a scheduled one ever forces.
# NB `sesh thread stop` (which kills the pane) does NOT create a background session, and
# neither does Ctrl-C, /quit, /clear or a claude auto-update — all measured. It is
# `/background` (or `claude --bg`). So if a thread becomes un-revivable, look for a
# `/background` in its transcript, not at how you stopped it.
sesh thread archive --id <id> # park it; --unarchive to restore
sesh thread hold --id <id> --until 2026-07-01 # park until a date (hidden from the default view); auto-expires
sesh thread hold --id <id> --release # release it (+ its subtree) from an ANCESTOR's hold, until tomorrow
sesh thread hold --id <id> --clear # clear both; it inherits from its ancestors again
# Manual ordering: pin a top-level thread ABOVE the auto-sorted list (default: top).
sesh thread pin --id <id> # pin to the top of the manual block
sesh thread pin --id <id> --after <other> # or --before <other> / --bottom / --top / --order <f>
sesh thread unpin --id <id> # remove the manual ordering (rejoins the auto block)
sesh thread new --divider --name "today" # a DIVIDER: a labeled rule in the pinned block (reposition with pin)
# Only top-level threads can be pinned; archiving or reparenting-under-another clears it.
# A divider takes no agent-shaped flags; delete it with `thread delete` (not archive/unpin).
sesh thread rename --id <id> --name <new>
sesh thread tag --id <id> --add wip --remove stale # repeatable --add/--remove
sesh thread reparent --id <id> --parent <p> # or --root to detach
sesh thread notify --id <id> --off # mute this thread's notification hooks
# Adopt a manually-launched agent (a pane on sesh's WORK server) into a thread.
# The conversation id is auto-detected (claude from argv, pi from its RPC socket,
# codex from its rollout) — pass --session-id when it can't be (e.g. a claude
# started with a bare `-r`, which carries no id in its argv):
sesh thread adopt --name here # current pane ($TMUX_PANE)
sesh thread adopt --name here --session-id <conversation-uuid> # explicit id
# HEADLESS adopt: register an EXISTING conversation that is NOT running anywhere
# (e.g. a claude transcript on disk) as a durable headless thread. No pane is used,
# so --agent and --session-id are REQUIRED (nothing to detect them from); --cwd
# defaults to '.'. A later `send-headless` RESUMES that conversation:
sesh thread adopt --name corkboard --agent claude --session-id <conversation-uuid> --cwd ~/dev/corkboard
# Spawn on another machine (real cross-machine spawn over the mesh):
sesh thread new --agent claude --name x --cwd ~/proj --machine macbook
enter/nav is normally done from the TUI; the underlying primitive is sesh tmux nav --to <machine>:<session> (mycockpit + the inner client switch).
The cockpit's flagged ring (master prefix+, / prefix+.) is one call:
SESH_NAV_CLIENT=<master client> sesh tmux nav --cycle-flagged next # or prev
sesh tmux nav --cycle-flagged next --dry-run # JSON plan: outcome, ring, current, target, from
It steps through the flagged threads of the active view (every machine, offline
peers hidden) in the TUI's own render order, wrapping, stepping over headless rows (a
cycle key never revives). Where the cockpit is now is resolved once and used both as
the start point and as prefix+L's from-location. An empty ring refuses loudly and
distinctly: no flagged active threads vs flagged threads are all dead. Without
$SESH_NAV_CLIENT there is no start point: next enters at the first entry, prev at the
last.
Driving an agent, delegating, awaiting
sesh thread send --id <id> --text 'run the tests' # inject into a LIVE pane
sesh thread send --id <id> --text 'fix it' --wait --timeout 5m # ...and block until the turn SETTLES (idle/blocked);
# fails fast (~5s) if the input produces no state change
sesh thread wait --id <id> --until settled --timeout 5m # block until a state: busy|idle|blocked|settled
# (settled = idle-or-blocked; loud error naming the last state on timeout)
sesh thread send-headless --id <id> --text 'summarize' # run a stateless turn on an idle thread
sesh thread send-headless --id <id> --text 'quick check' --model anthropic/claude-haiku-4-5 # override the model for THIS turn only
sesh thread headless-reply --id <id> --json # poll a headless turn's result
sesh await <id> --timeout 5m # block until a turn finishes (mesh-aware)
sesh delegate --agent pi 'summarize this repo' # spawn worker → ask → reply → archive
sesh delegate --agent claude 'run CI' --cwd ~/proj --keep # leave the worker active instead of archiving
sesh subscribe <subscribee> --from <subscriber> # pipe one thread's turns into another
sesh thread send --id <id> --text 'now' --respect-typing 0 # bypass the typing guard for this one paste
The typing guard (every paste into a live pane). A pane paste is
paste-buffer then Enter, so text delivered while a human is mid-line in that
pane is appended to their half-typed prompt and SUBMITTED with it — the live
case was child threads reporting into a supervisor its user was typing in. So
the owning daemon holds every thread-level delivery (thread send, ticket send-prompt, subscription deliveries, scheduled messages) while the thread's
session has seen viewer INPUT within [send] respect_typing (default 60s —
tmux's client_activity, bumped by keystrokes through an attached client, not
by agent output), and pastes it once the pane has been quiet that long. What a
held delivery does is --on-typing: defer (the default — the daemon queues
it per thread, FIFO, prints deferred <id> and delivers later; a delivery still
held at [send] respect_typing_deadline (10m) FAILS loudly and auto-flags the
thread with undelivered message from … as the reason, never pasting anyway),
wait (block the command until the pane is quiet, bounded by --timeout;
the default under --wait), or skip (refuse with a non-zero exit, queue
nothing — for periodic senders). --respect-typing <dur> overrides the window
per call (0 = paste now regardless), --typing-deadline <dur> the bound. A
detached session (nobody viewing) is quiet by definition. Held deliveries live
in daemon memory: a daemon restart drops them, loudly in its log. NB tmux counts
ANY key as input — PgUp while reading holds the guard like typing does.
State authority. A headful thread's busy/idle normally comes from a pane
content-diff heuristic, but pi and claude threads carry an in-agent reporter
(a pi extension / claude hooks, installed via myagent/myrig) that reports turn
starts/ends EXACTLY — the snapshot's state_authority field says which
mechanism decided (reported or heuristic; absent for headless threads).
Reporters use sesh thread report-state — a mechanism verb you normally never
type: stale --seq values are refused, and authority is dropped automatically
when the thread's pane dies. The reporter also passes the agent's live
--agent-session id every turn, which the daemon stamps onto the thread record
(schema 46) — so resume/reopen lands on the session claude/codex is ACTUALLY
in even after a compaction/rewind fork mints a new id, instead of relying on the
fragile leaf resolver. (Background agents — claude's agents feature — run
OUTSIDE the sesh pane, under claude's machine-global daemon, so sesh can't
resume a conversation while one holds it: resume surfaces claude's own
"currently running as a background agent" refusal, and you either attach via
claude agents or branch a copy. Worse, a bg process INHERITS SESH_THREAD_ID
from whatever started that daemon, so it reports under an unrelated thread's id;
the hook now reports nothing from a bg session, and the daemon independently
refuses any --agent-session whose transcript lives under a different working
directory than the thread's — a refusal logged loudly, keeping the stored id.
Before both guards a bg agent could write its conversation onto a stranger's
record, stranding the real thread on a stale transcript.) Two SYMMETRIC staleness bounds drop a report the
pane contradicts (loudly, in the daemon log, degrading to heuristic so it is
visible): a reported-BUSY on a pane byte-stable for 2 minutes (the lost-turn_end
class — claude's Stop hook does not fire on a user interrupt/Esc, which would
otherwise pin busy until the next prompt), and a reported-IDLE on a pane that
has been ANIMATING for 2 minutes (the reporter isn't tracking turns — a session
that predates the reporter hooks, or hooks that stopped firing — which would
otherwise mask a running turn as idle). Blocked (question/approval) reports are
exempt from the busy bound — those panes are legitimately static. codex threads stay heuristic for busy (no
turn-start surface), but their notify hook — wired into the codex config by
sesh at spawn — still reports turn ENDS for flagging, and since schema 46 that
report also carries codex's OWN session id, which the daemon stamps onto the
thread record (codex mints its id on its first turn — without this a headed
codex thread could not be forked while live, and reviving it fell back to a
cwd+time rollout guess that could land on a same-cwd sibling's conversation).
Flags (sesh thread flag). The flag is the "look at this thread" marker:
the daemon auto-flags when a turn ends or the agent stalls on a question /
approval prompt (claude's AskUserQuestion flags with the question as the
reason) — attended or not (no attended gate since 2026-07-25); nothing ever
auto-clears a flag.
thread flag --off clears; --disable suppresses auto-flagging for a thread
(parent-monitored children; also clears any current flag); --enable
re-allows it; --on flags manually AND re-enables a disabled thread (one
rule). Heuristic busy→idle edges flag only for agents opted in via [flags] heuristic_agents = ["codex"] in config.toml (default: none — reporter edges
are exact, the heuristic can mistake your own typing-settle for a turn end).
Scheduled work (sesh schedule)
A schedule is a clock (cron / interval / one-shot) plus an ACTION plus RULES, with its own
recorded state. Two actions: message delivers text into an EXISTING thread; spawn
creates a NEW thread in a directory with a prompt. A schedule lives on the machine that
EXECUTES it — a message schedule on its target's owner (schedule message auto-routes
there), a spawn schedule where its cwd is (--machine to place it elsewhere) — and is never
mesh-replicated: schedule list --all-machines is a live fan-out. The daemon validates
everything at creation and prints back the zone and the first fire, so what you typed is
what will happen.
# a heartbeat: every 20 min, only if the thread has been quiet 10 min, never into a dead pane, at most 30 times
sesh schedule message --id <thread> --every 20m --text 'Continue with the plan. If blocked, say so and stop.' \
--if idle --idle-for 10m --when-headless skip --max-fires 30 --not-after '2026-09-20 18:00'
sesh schedule message --id <thread> --cron '0 9 * * 1-5' --text 'Morning status?' --when-headless revive
sesh schedule spawn --agent pi --cwd ~/dev/proj --cron '0 9 * * 1-5' --prompt 'Review the CI failures since yesterday' --parent <virtual-group>
sesh schedule spawn --agent claude --cwd ~/dev/proj --every 6h --prompt-file ~/prompts/audit.md --on-turn-end stop+archive --max-runtime 30m
sesh schedule list [--all-machines] [--thread <id>] [--json]
sesh schedule show --id <id|prefix|name> # definition, rules, next/last fire, counters
sesh schedule runs --id <ref> [--limit N] # the run history: fired | skipped: <why> | failed: <err>
sesh schedule run-now --id <ref> [--force] # fire it NOW and print the outcome — the way to test one
sesh schedule pause|resume|remove|edit --id <ref> # resume recomputes next fire from now (no backlog)
The clock. Exactly one of --cron '<min hour dom mon dow>' (names, ranges, lists, */n;
a restricted day-of-month AND day-of-week match when either does), --every <dur>
(minimum 10s, anchored at creation or --not-before), or --at '<YYYY-MM-DD HH:MM>'
(fires once, then disables itself). --tz defaults to the OWNING machine's zone and is
RECORDED (a routed create means the owner's zone). A local time that does not exist on
spring-forward day is skipped; a repeated one on fall-back day fires once. Occurrences
missed while the daemon was down are NEVER replayed: --catchup skip (default) rolls
forward and counts them (missed N in the listing, loud in the daemon log); --catchup once fires exactly one catch-up run. --not-after, --max-fires (delivered runs only —
skips do not count) and pause bound a schedule's life; a schedule that fails
[schedules] disable_after_failures (10) times running is DISABLED with the reason
(schedule list shows DISABLED: …; sesh doctor warns).
Message rules — evaluated against the target's live state at fire time, every ending
recorded. On hold ⇒ skipped: on hold (--ignore-hold overrides); archived ⇒ skipped
(--allow-archived); --if a,b,c — all must hold, from the closed list idle busy headful headless attached detached flagged not-flagged archived not-archived blocked not-blocked
(unknown words are refused at creation); --if idle implies a 60s quiet dwell since the
last activity (--idle-for 10m, --idle-for 0 for none — a bare idle read can be stale);
a busy target ⇒ skipped: busy unless --when-busy send. Then the delivery follows the
pane that exists NOW: a live pane gets a paste through the typing guard (a typing viewer
⇒ skipped: viewer typing, never queued — a periodic sender has a next occurrence;
--respect-typing 0 disables); a headless target gets a real headless turn
(--when-headless turn, the default), is REVIVED into a pane first (revive — the
daemon's own resume, so codex-before-its-first-turn is the usual loud N/A), or is left
alone (skip). NB "attached/unattached" in the sense of "is there a live pane" is the
HEAD axis (headful/headless): the daemon can revive a pane, it cannot make a human look.
Spawn rules. Each run is thread new on the schedule's machine — headless by default,
--headed for a real pane (the prompt is pasted once the agent has rendered; a run whose
agent never becomes ready is failed, loudly) — named by --name-template (default
{schedule}-{date}-{time}), optionally under --parent (point it at a virtual group so a
schedule's runs collapse into one node). Run threads have auto-flagging DISABLED
(--flag-on-end re-enables it) — an unattended recurring turn would otherwise flag itself
every time. --if-previous skip (default) skips a run while the previous run's thread is
still GOING — a live pane or a turn in flight, never "the record exists" — or
stop-previous / spawn-anyway; --max-runtime stops an overrunning run and records it
failed. --on-turn-end applies at the run's first turn end: keep (default — the finished
run stays an ordinary headless·idle thread you can read with sesh transcript or
continue), stop, archive, stop+archive (delegate's ephemeral contract), delete. A
run's row records its thread; a daemon restart mid-run reaps a headless run (its turn was
the daemon's goroutine) and keeps watching a headed one. The effective [spawn] mode is
recorded and printed (mode: yolo) — an unattended recurring agent under yolo is a
posture worth knowing.
Seeing them. schedule list per machine (--all-machines fans out); the opt-in TUI
column sched shows a thread's earliest next fire (in 12m, with a count when several);
the I details popup has a schedules row; sesh doctor reports breaker-disabled
schedules, an overdue enabled one (the loop is wedged), and the kill switch. Deleting a
thread removes the message schedules targeting it in the same transaction (the delete
says so). Hooks: schedule_fired / schedule_failed events with
SESH_SCHEDULE_ID/NAME/OUTCOME (skips are not events). [schedules] enabled = false
stops a machine firing anything (run-now still works).
Mesh, peers, mycockpit, daemon
mycockpit — also "the cockpit" / "my cockpit" — is Lukas's cross-machine tmux cockpit:
one tmux server (socket sesh-master, prefix C-a) with one window per machine, each an
auto-reconnecting attach into that machine's work server. sesh builds and drives it
(sesh master …); myrig wraps it in the mmt-* commands. ("The master tmux setup" is the
retired old name.)
It has two levels, and Lukas uses these words: the master level is the cockpit's own
server (sesh-master, prefix C-a, cross-machine — pick a machine, then act), the base
level is one machine's work server (sesh, prefix C-b, this machine). myrig's mmt-*
commands act at the master level, mt-* at the base level.
sesh peer list # registered machines + transport
sesh peer add --machine macbook --ssh lukas@macbook --home /Users/lukas/.sesh \
--api-addr 100.x.y.z:7070 --api-token-file ~/.sesh/api-token # http peer (ssh otherwise)
sesh master up --tmux-conf ~/.sesh/myrig/tmux.master.conf # build the per-machine window cockpit
sesh master attach # attach to it sesh master watchers # who's watching this machine
sesh daemon status # machine, pid, version, uptime, db, socket, schema, mesh_cadence
sesh daemon restart # bounce the daemon (e.g. after a binary update)
sesh doctor # diagnose the install (binary, config, SESH_MACHINE, daemon checks)
codex's shared app-server daemon is kept OFF. codex ≥ 0.157 starts one detached
app-server per codex home from every interactive codex, and it keeps a conversation's
writer lock after its pane is killed — so sesh thread stop then a headless turn / resume
fails "already has an active writer", and thread adopt cannot identify the pane. The sesh
daemon therefore writes [features] daemon_auto_start = false into the codex home's
config.toml at startup and before every codex launch (a surgical edit: every other key and
comment is kept; a config it cannot parse is refused, never rewritten). It overrides an
explicit true — loudly, in the log and in sesh doctor. sesh doctor shows three rows:
codex daemon (the setting), codex daemon pin (an override or edit failure, if any), and
codex app-server — a daemon ALREADY running for that home (started before the pin) still
holds locks until stopped: CODEX_HOME=<home> codex app-server daemon stop (when no codex
pane is mid-turn), then kill its leftover … daemon pid-update-loop updater (pid in
<home>/app-server-daemon/daemon-updater.pid), which daemon stop does not stop.
The target machine's supervised daemon is the sole creator of its work tmux server. If a
master window finds no sessions, it asks the target daemon to create scratch; it does not
run tmux new-session in the local or SSH attach shell. This matters on macOS because tmux
retains its creator's audit session: a server born under SSH cannot read Claude Code's login
Keychain even when a local cockpit later attaches to it. A daemon-born work server keeps the
Aqua service context. Raw interactive SSH is still Keychain-isolated and may require Claude
/login; the cockpit works because its panes run inside the Aqua daemon-born work server.
Status options (for tmux status lines and scripts). The owning daemon stamps every pane
that carries a @sesh-thread-id marker with its thread's record fields as tmux PANE user
options, kept current within a tick: @sesh-name, @sesh-agent, @sesh-tags (comma-joined,
empty when none), @sesh-archived, @sesh-flagged, @sesh-flag-disabled (each 1 or empty).
A status line renders the thread row with format lookups alone — no #() job, no shell per
redraw — e.g. #{?@sesh-name,sesh: #{@sesh-name} [#{=8:@sesh-thread-id}] · #{@sesh-agent},};
an unmarked pane renders nothing. Read them with tmux -L sesh show-options -p -t <pane> -v @sesh-name. They are pane-scoped only (tmux inherits user options during format expansion, so
never read them at window/session scope), and they are data published by the daemon, not
something to set by hand. Design: _dev/STATUS_OPTIONS.md.
"A machine's threads vanished from my TUI." Almost always that machine is
unreachable, not thread-less: offline machines' threads are hidden by default
(the TUI's toggle-offline command reveals them, and the footer names the machine). Check sesh mesh
from another machine — the affected box's own view stays green because outbound sync
keeps working, so diagnose from the OUTSIDE. Then run sesh doctor on it and read the
api line: ok listening on <ip>:<port> is healthy; fail … NOT BOUND means the bind
keeps failing (DNS/interface — the error is quoted); warn SESH_API_ADDR not set means
the daemon has no TCP API at all, so peers cannot reach it — normal only for an
inbound-less leaf like termux, and otherwise a daemon started by hand without its
service environment (fix: supervisorctl restart sesh-daemon; the same warning is
logged at daemon startup).
"The cockpit froze after my laptop slept — I can select threads but nothing opens."
A master window is an ssh attach into that machine's work server, and sleep can leave that
connection dead with no FIN and no RST. ssh notices only when it next has bytes to send,
which an idle attach never does, so the window keeps painting its last pre-sleep frame.
Nav still reports success — the far side's sshd still holds the pty, so the remote tmux
still lists that client, the master-client marker still matches it, and switch-client
returns 0 against a client nobody can see. sesh now passes ServerAliveInterval
keepalives on every ssh it opens, so a dead path is dropped within ~45s and the window's
supervisor re-establishes it by itself. A running cockpit keeps the binary it was
launched with, so after updating sesh you need mmt-kill && mmt-start (or
sesh master down + up) once before the keepalives are actually in force. If a cockpit
is wedged right now, that same restart is the recovery — rebuilding the sidebar
(prefix+r) will not help, because the rot is in the window attaches, not the sidebar.
Mesh sync cadence (demand-driven). The background peer sync runs at full pace (~1s)
only while something is consuming the mesh view — a sesh tui/sesh-ui poll or an
--all-machines read — or when [[hooks]] are configured (hooks observe remote threads
through the cache, so they pin full pace). Otherwise it idles to [mesh] idle_interval
(default 60s; "0s" = never idle) and snaps back instantly on the next read, so opening
the TUI after an idle stretch is fresh within ~a round trip. sesh peer list showing
"synced 45s ago" on a quiet daemon is therefore deliberate idling, not degraded sync —
sesh daemon status reports the pace as mesh_cadence (active / idle / hooks-pinned /
always). Between schema-41 daemons each sync round transfers only the rows that CHANGED
since the last one (delta sync; an unchanged round is ~100 bytes), and against older
daemons an unchanged snapshot is a bodyless 304 (ETag). What the views SHOW is unchanged
by any of this — every machine's full thread set, archived included, still replicates
across the mesh.
Config (~/.sesh/config.toml)
[[session_name]] # name the tmux session from the cwd (first match wins)
match = '^~/dev/.+$'
name = '{path} ({tid8})'
[[cwd_label]] # the TUI CWD column's display transform
match = '^~/mysetup/(?P<rel>.+)$'
label = 'mysetup/{rel}'
[tui]
columns = ["machine","agent","name","cwd","tags","notify"] # opt-in extras incl. sched (next scheduled fire), hold, created, id
all_machines = true # default `sesh tui` to the cross-machine view (= --all-machines)
show_offline = true # show OFFLINE machines' threads by default (else hidden; toggle-offline toggles)
expand_children = true # tree nodes start EXPANDED (default false: children collapsed; = --expand)
[[tui.column]] # MOVE one column relative to an anchor, on top of the base set —
name = "notify" # so you can reposition a column without enumerating them all
before = "machine" # (or `after = "..."`)
[[tui.column_color]] # NAME blue / CWD green / ticket_input red by default; override here
name = "cwd"
color = "green"
[[tui.glyph_color]] # gutter attention glyphs: busy ▶ / descendant ↓ (bright green by default)
name = "busy"
color = "2" # a name, a 0-255 number, or #rrggbb; empty clears the tint
[[tui.key]] # REBIND a command's key (ids come from `?` / the palette)
command = "fork" # first entry for a command REPLACES its defaults (a MOVE);
key = "F" # further entries ADD keys; key = "" unbinds (palette-only).
# Unknown id / unusable key / two entries on one key = loud error.
view_ring = ["active", "flagged"] # ONE key flips between these views (the `view-ring` command;
# bind it with [[tui.key]] — no default key). Names are built-ins
# or [[tui.views]] names; unknown/ambiguous/repeated = loud at
# startup. Keep it in [tui] itself, ABOVE the [[tui.*]] tables.
[[tui.views]] # custom Tab-cycle views over the predicate language
name = "ticketed"
filter = "ticketed and not archived" # keywords incl. headful/headless/busy/idle/archived/onhold/flagged/flagdisabled/ticketed
position = 2 # optional: 1-based slot in the Tab/picker order among the built-ins (active/on hold/archived/all); omit/0 = appended after the built-ins
[defaults]
agent = "pi" # thread new may omit --agent; explicit --agent wins; unset = loud error
notifications = true
[mesh]
idle_interval = "60s" # peer-sync pace while nothing reads the mesh view ("0s" = never idle)
[spawn] # default launch policy (yolo bypasses permission prompts)
mode = "yolo"
[send] # the typing guard on every paste into a live pane
respect_typing = "60s" # hold a delivery until the pane has seen no viewer input this long ("0s" = off)
respect_typing_deadline = "10m" # a delivery still held this long fails loudly + flags the thread
[schedules] # `sesh schedule` policy (the schedules themselves are records, not config)
enabled = true # false = this daemon fires nothing (run-now still works)
disable_after_failures = 10 # a schedule failing this many runs in a row is disabled, loudly (0 = never)
[[hooks]] # event hooks: fire a command on an observed state edge
name = "notify-idle"
event = "busy_changed"
from = "busy"
to = "idle"
command = "~/.mybin/sesh-notify"
[defaults] agent is resolved by the owning daemon, so routed creation with
thread new --machine <m> uses machine <m>'s policy. Valid values are exactly
pi, claude, and codex; an invalid value prevents the daemon from starting
rather than silently choosing another harness. The daemon reads [defaults] at
startup, so changing it requires a daemon restart through the machine's service
manager. This is separate from ui_config.toml's default_agent, which only
preselects a value in sesh-ui's New-thread modal.
A hook command runs through $SHELL -c with the event described in env vars:
SESH_EVENT (+SESH_EVENT_FROM/SESH_EVENT_TO on edges), SESH_THREAD_ID,
SESH_THREAD_NAME, SESH_AGENT, SESH_MACHINE, SESH_CWD, SESH_SESSION,
SESH_TAGS (comma-joined), SESH_HEAD, SESH_BUSY, SESH_ATTACHMENT
(attached/detached), SESH_ATTACHED_ACTIVITY_AGO (seconds since the last
INPUT on a client attached to the thread's session; absent when detached or
unknown), SESH_ATTACHMENT_CHANGED_AGO (seconds since the observing daemon saw
the attachment axis flip — the "just navigated onto it" signal; absent if no
flip observed since daemon start), SESH_NOTIFY (the per-thread gate as
1/0 — the hook fires regardless; honoring the gate is the hook's job),
SESH_FLAGGED (1/0 — the needs-attention flag), SESH_FLAG_REASON
(present only when an auto-flag carries one, e.g. the question the agent
asked), and SESH_STATE_AUTHORITY (reported/heuristic — which mechanism
decided busy; absent when unknown), and on schedule_fired/schedule_failed
SESH_SCHEDULE_ID, SESH_SCHEDULE_NAME, SESH_SCHEDULE_OUTCOME. The event vocabulary includes
flag_changed (from/to flagged/unflagged) — to=flagged is THE toast
edge: the daemon flags exactly when a turn ends or the agent stalls on a
question/approval (attended or not), and on manual flags. The activity/flip ages exist because a HEURISTIC busy→idle edge alone
can't tell a finished turn from the user pausing: typing into a pane or
navigating onto it latches the content-diff busy probe like agent output
would, while raw attachment over-suppresses (cockpit clients park on
sessions) — a notify hook should skip only when attached AND (recent input OR
a recent attachment flip), failing open when the vars are absent. Under
SESH_STATE_AUTHORITY=reported the edge is exact (a real turn boundary).
ui_config.toml — the app's preferences (a SECOND file)
<SESH_HOME>/ui_config.toml is separate from config.toml: it holds preferences for the
sesh-ui app, which the daemon stores and serves over GET/POST /v1/ui-config. sesh
does not otherwise interpret them, and the CLI/TUI ignores the file entirely. It lives in
SESH_HOME so it follows whichever daemon a client connects to (per-machine).
collapse_parents = true # parent threads start COLLAPSED in the app's tree (default true)
cwd_roots = ["~/mysetup", "~/dev"] # "default parent folders" the new-thread modal quick-picks
# from (listed per target machine via GET /v1/fs/list)
transcript_prefetch_secs = 10 # background transcript prefetch cadence; 0 disables
master_command = "mmt-start" # what the app's Master mode runs in a pty ($SHELL -lc); empty = unconfigured
default_agent = "claude" # new-thread modal preselections
default_machine = "macbook"
default_chat_view = "terminal" # terminal | transcript | rpc
[[cwd_label]] # display transform for the cwd quick-pick (same rule language as config.toml)
Common flags & environment
--json— machine-readable output (use it when scripting).--machine <m>— route to a peer (real ssh hop or its HTTP API; not for peer/matrix/master). An http peer is reached THROUGH your local daemon (it reuses the daemon's warm connection to that peer), so routing to an http peer needs the local daemon running — if it is down you get "routing to <m> goes through the LOCAL sesh daemon, which did not answer"; and right after a sesh upgrade, "the LOCAL sesh daemon has no /v1/route" means the daemon was not restarted yet. ssh peers need no local daemon.--all-machines— fan a read out across the mesh.SESH_HOME(default~/.sesh),SESH_MACHINE(this machine's identity — the daemon refuses to run without it),SESH_THREAD_ID(the current thread, for inference),SESH_REMOTE/SESH_API_TOKEN(target a remote daemon's TCP API directly),SESH_ROUTE_MACHINE(target an http peer through the local daemon — what--machinesets for an http peer; rarely set by hand).
Errors are loud by design (an unimplemented or impossible request fails explicitly rather
than degrading to a plausible-but-wrong result) — read the error; it usually tells you the
exact precondition that failed (e.g. a 409 "thread has no live pane" on send to a dead
thread).
How can the creator link this skill?
Add the canonical catalog link to the repository README so users can inspect current installs and available audits. The publishing guide covers the complete discovery path.
<a href="https://skillzs.dev/skills/lukastk/sesh/sesh-cli">View sesh-cli on skillZs</a>