This document specifies the supported way to drive amux agents from another
process. It is a behavioral contract: the naming, discovery, input, and state
conventions described here are what external orchestrators may rely on, and the
pinning test in internal/tmux/session_contract_test.go exists to force a
conversation (doc + release notes) whenever any of it changes.
The workspace-management CLI was removed on purpose in PR #204 (commit
c76dce7c, "chore: remove unsupported CLI and OpenClaw"), which deleted
everything under internal/cli/ and the amux agent … subcommands. amux
remains a terminal UI that must be launched directly on a real TTY.
Two command-line verbs exist today, both for the Program Status Protocol
(their contract is the "Reporting status" section below): amux report
(prints one OSC 7501 sequence; designed to run inside a hosted pane) and
amux wait (blocks until a pane reports a matching state; the consumer for
supervising scripts and agents). They manage no workspace, session, or
workspace state — the removal's scope stands. Any other argument prints an
error and exits non-zero (cmd/amux/main.go).
External orchestration is nonetheless a real, current workflow: the maintainer
drives amux agents from outside the process. The only supported control
surface for that is the tmux layer underneath amux. amux hosts every agent in a
tmux session, tags those sessions with @amux_* options, and reattaches to them
across restarts. An external tool talks to the same tmux server, targeting
those sessions with stock tmux commands (list-sessions, send-keys,
show-options). This document is the contract for doing that.
Compatibility: the session-name grammar and the @amux_* tag keys below are
treated as a public seam. A change to SessionName, sanitize, or the tag keys
must update this document and call out the break in release notes. It is not an
internal refactor you can make silently — the pinning test will fail first.
amux builds every session name with tmux.SessionName(parts...)
(internal/tmux/tmux.go). Each part is trimmed, dropped if empty, sanitize-d,
and the surviving parts are joined with -. If nothing survives, the name is
the bare string amux.
sanitize (internal/tmux/options.go) does, per part:
- lowercase the whole part;
- keep
a–z,0–9,-, and_as-is; - replace every other byte with
-; - trim leading and trailing
-.
The interactive-session call sites (grep SessionName( under internal/) all
pass "amux" as the first part, the workspace ID as the second, and a per-tab
identifier as the third:
amux-<workspaceID>-<tabPart>
<workspaceID>isdata.Workspace.ID(): the workspace's persisted store key when one exists, elsehex(sha256(repo+root)[:8])(internal/data/workspace.go) — 16 lowercase hex characters for anything minted by current amux, and in practice never contains-(sanitize would rewrite anything else anyway).
Metadata updates are field transactions, not whole-record writes: each
mutator (rename, env, scripts, shelve/restore/archive flags, debounced
tab state) loads the record fresh under the workspace lock and rewrites only
its own fields. External tooling that edits workspace.json directly should
do the same — read-modify-write of only the field it owns.
~/.amux/config.json has a deliberately asymmetric contract: startup falls
back to defaults on malformed content, but amux's own saves refuse an
unreadable or non-object document rather than rewriting a partial view (a
missing or empty file is treated as an empty config and written normally).
When a Settings save fails, the confirmed values remain live for the session
and the failed section stays pending — the next Settings confirmation retries
it without needing new edits (UI sections together, assistants separately,
and Esc preserves a pending obligation while dropping fresh unconfirmed
edits). There is no background retry; the next confirmation is the retry.
The project-level registries (project-env.json, project-scripts.json,
trusted-scripts.json) share the preserve-on-unreadable half: an existing
file that cannot be read refuses the save/approval and keeps its bytes, a
missing file initializes, and corrupt JSON is still replaced by the
authoritative write — the recovery config.json deliberately rejects.
(project-scripts.json is a live read layer — the per-project script
defaults the runner consults after repo config and the workspace record both
miss. Nothing in the UI writes it yet; edit the file by hand. User-authored,
so never trust-gated.) A recorded script approval can be revoked from the
i workspace-status dialog: U opens a typed-confirm gate (untrust), the
delete is re-attempted against the durable registry inside that transaction —
never trusted from the displayed snapshot — and a failed or corrupt
trusted-scripts.json refuses the revoke instead of dropping bytes it
cannot inspect.
External tooling editing the file should keep it a JSON object — a file amux
cannot parse at save time is preserved untouched, never repaired or
truncated.
<tabPart>identifies the pane within the workspace. For an interactive agent tab it is the tab IDtab-<prefix>-<counter>(internal/ui/center/model_tab.go,formatTabID), where<prefix>is 4 random bytes hex-encoded per amux process and<counter>is a base-36 in-process counter. Viewer and other panes use different literals (e.g.viewer).
There is a second session family that does not fit this grammar: hosted
run script sessions. They are named by literal concatenation in
internal/process/run_session.go (runSessionBaseName), not by
SessionName:
amux-ws-<workspaceID>-run (primary run session)
amux-ws-<workspaceID>-run-<N> (concurrent runs, N = 2, 3, …)
Run sessions carry @amux_workspace and @amux_type=run tags but no
@amux_tab — they host a detached command, not a tab.
Lifecycle teardown ordering (for orchestrators that drive deletion):
workspace delete/shelve seizes a per-workspace admission gate, cancels and
drains every local lifecycle process (in-flight setup-workspace sequences,
detached on-done hooks), kills hosted run sessions and the local run slot,
then runs archive and removes the worktree — in that order. A child writing
into the tree never outlives the tree's removal, and while the gate is held
the runner rejects new lifecycle starts (ErrWorkspaceTeardown). External
tooling must not remove a workspace's directory without the same ordering.
Workspace creation takes an optional base ref: the create dialog's second
field is the ref the new branch is created from (branch, tag, commit,
origin/…, HEAD). Empty resolves to the repo's detected default branch
(ResolveBase, falling back to HEAD when detection fails); non-empty is
validated with ValidateBaseRef before anything is dispatched. The base ref
is not the workspace's branch name — the branch still derives from the
workspace name, and attach-vs-create behavior is unchanged.
Creation rollback is ownership-scoped: the git create path reports
whether the call created the branch (worktree add -b) or attached to a
pre-existing one, and a failed create removes its worktree under the
per-repo git lock while deleting the branch only when that call created
it. Attaching an existing branch gives the create ownership of the worktree
cleanup, never of the ref — a pre-existing branch survives a rolled-back
create untouched.
A delete interrupted mid-flight (crash, kill) leaves a durable .deleting
tombstone in the workspace's metadata dir. Startup recovery retries the
finish automatically on the next load; the i workspace-status dialog
surfaces the state read-only in the meantime — a cleanup section reports
interrupted (worktree still present, workspace usable) or pending
(worktree gone, retry on next load) and points at the warn-level
workspace delete / startup recovery log lines for detail. The tombstone
is a boolean, so the section never reports a stage.
Worked example — workspace ID 9f8e7d6c5b4a3210, tab ID tab-1a2b3c4d-5:
SessionName("amux", "9f8e7d6c5b4a3210", "tab-1a2b3c4d-5")
=> "amux-9f8e7d6c5b4a3210-tab-1a2b3c4d-5"
runSessionBaseName(...) => "amux-ws-9f8e7d6c5b4a3210-run"
Guaranteed (relied on by amux itself and pinned by the contract test):
- Every amux session name begins with the literal prefix
amux-(or is exactlyamux). - For tab sessions, the workspace ID is the first
--delimited segment afteramux-and is recovered byactivity.WorkspaceIDFromSessionName(internal/app/activity/fetch.go): stripamux-, split on-, take the first field. This round-trips because minted workspace IDs are dashless hex — see the caveat below. - For run sessions name-parsing is unreliable: the same function returns
wsforamux-ws-<id>-run, not the workspace ID. Recover the workspace from the@amux_workspacetag instead — that is the supported field, and it is how amux itself finds run sessions (tmux.FindRunSessionsfilters on@amux_workspace+@amux_type=run, never the name).
Not guaranteed — do not build these into an orchestrator:
- The internal structure or value of
<tabPart>. Because the tab-ID prefix is randomized per amux process, tab session names are not predictable and not stable across an amux restart for newly created tabs. (An existing session keeps its name for as long as its tmux session lives, because amux reattaches by discovering the live session and reading its@amux_tabtag — it does not regenerate the name.) You cannot construct a tab's session name a priori; you must discover it (next section). - Session ordering, or that any given session exists at all — agent sessions are created and killed over a workspace's life.
- Round-trip of a workspace-ID value that itself contains
-.WorkspaceIDFromSessionNamereturns only the first segment, so a dash in the ID position would truncate. Real workspace IDs are dashless hex, so this does not occur in practice; it is pinned as a known caveat, not a bug to "fix" in the parser.
Do not construct tab names; enumerate the live sessions on amux's tmux server and filter by prefix and tags. Attaches are fenced per tab: only the newest in-flight attach's outcome is applied, so a client from an abandoned or superseded attach is closed instead of replacing the live terminal — an orchestrator that triggers its own attach does not need to serialize against amux's.
Resolve the server name the way amux does (tmux.DefaultOptions,
internal/tmux/tmux.go):
- server:
$AMUX_TMUX_SERVERif set and non-empty, elseamux; - config:
$AMUX_TMUX_CONFIGif set and non-empty, else/dev/null.
amux itself sets these env vars from config
(UI.TmuxServer / UI.TmuxConfigPath, internal/app/app_init.go), so an
orchestrator launched in the same environment as amux should honor whatever is
already set and only fall back to the defaults above. (AMUX_SKIP_LINT /
AMUX_SKIP_HARNESS are contributor git-hook escapes documented in
CONTRIBUTING.md — the app ignores them.)
List sessions and their tags in one call, mirroring amux's own reader
(internal/tmux/tags.go):
tmux -L "${AMUX_TMUX_SERVER:-amux}" list-sessions \
-F '#{session_id}|#{@amux_workspace}|#{@amux_tab}|#{@amux_type}|#{session_name}'Field order matters: #{session_id} and the tag fields lead, #{session_name}
trails. tmux accepts | in session names, so split only the first N
separators and treat the remainder as the name — a name-first layout lets a
foreign session named e.g. victim|1 forge tag values and redirect
name-targeted commands at victim. When driving kills/reads, target the
$N id column, not the name.
Keep the lines whose session_name starts with amux-. The @amux_workspace
tag is the authoritative workspace ID for a session — for tab sessions it
matches the name-derived value, and for amux-ws-*-run sessions it is the
only reliable source (name parsing yields ws, not the ID).
Managed panes are started through a private hand-off so that environment
values never appear in process argv, pane_start_command, or tmux command
logs. pane_start_command on an amux pane records
<amux-binary> --internal-pane-launch run <payload-path>
rather than the configured command. The payload is a JSON file inside a
single-use amux-pane-launch-* directory under the OS temp dir (0700 dir,
0600 file), carrying the working directory, the final sh -lc command, and
the environment assignments. The amux binary inside the pane validates,
consumes, and unlinks it before exec, so it is gone within milliseconds of
pane start. Payloads that outlive their five-minute budget are no longer
consumable and are swept by a later amux startup — leftover
amux-pane-launch-* directories are safe to delete. The flag is private:
it is not a general command runner and carries no public compatibility
promise.
amux does not use tmux send-keys internally — it attaches a PTY to each
session and writes bytes directly. An external orchestrator, which is not
attached, drives a session with tmux send-keys against the same server:
# Target the session by EXACT name. The '=' prefix stops tmux from
# prefix-matching e.g. "...-tab-1a2b3c4d-1" onto "...-tab-1a2b3c4d-10".
tmux -L "${AMUX_TMUX_SERVER:-amux}" send-keys -t '=amux-<ws>-<tabPart>' -l 'your text here'The = exact-match prefix on the target is not optional: amux uses it for every
session-scoped send-keys/has-session/kill-session for exactly this reason
(sessionTarget in internal/tmux/options.go). (Note: tmux's set-option and
show-options reject the = prefix, so tag reads/writes use the bare name —
see exactSessionOptionTarget in the same file. Prefix collisions are still
avoided there because names carry both workspace and tab IDs.)
The interactive equivalent for seeding a brand-new agent is the agent
picker's ctrl+t: it opens an optional first-task field whose text amux
queues and types itself once the agent's TUI reports readiness (first
private-mode set) — the wait-for-readiness dance below, automated on the
typed-input path. Orchestrators keep the send-keys approach; the field is
a TUI convenience, not a new control surface.
Two hard-won caveats apply when the target is a raw-mode agent (Claude Code,
Codex, and friends run in raw mode). amux earned these the hard way and pins
them with the close-the-loop e2e tests — internal/e2e/closeloop_test.go drives
a real keystroke through amux's input path into the raw-mode agent in
internal/e2e/fakeagent, asserting the bytes (including a literal carriage
return, 0x0D, and control bytes like Ctrl+Q/0x11) arrive intact. A
sibling test (sidebar_control_keys_test.go) proves the same delivery for a
focused sidebar terminal — Ctrl+Q and Ctrl+] reach it as 0x11/0x1D,
while the center pane keeps Ctrl+] for its own tab cycling:
-
Submit with a literal carriage return, not the named
Enterkey. A raw-mode TUI never sees the named key translated to0x0D. Send the CR explicitly:tmux -L "$srv" send-keys -t '=<session>' -H 0D
-
Separate the text from the submit. Send the line first, then the CR as a second command with a brief pause between them; a CR fused onto the end of the text can be lost before the agent is ready to read it. amux inserts a small delay between the keystrokes and the Enter in its own send path for the same reason.
The same one-line discipline applies inside amux's own editors: the env,
scripts, and Settings text fields accept terminal bracketed paste but only
append the first line of the payload to the focused field — later lines
and control bytes are dropped, and a paste never submits, cancels, or toggles
a row. An orchestrator that pastes multi-line text into an amux dialog should
split and submit lines itself. (One exception by design: the output
viewers' / search field treats a pasted newline as enter, accepting the
first pasted line as the query — convenient when pasting a copied error
line, surprising if the payload is multi-line.)
Paste payloads bound for a pane (not a field) are sanitized before the
bracketed-paste wrapper: CRLF normalizes to LF, newlines and tabs survive,
and every other control rune is stripped — an embedded \x1b[201~ cannot
end bracketed mode early inside a 2004-honoring agent, so hostile
clipboard content can't turn the remainder into live keystrokes.
When a lifecycle script fails, O opens its captured output and /
searches it — literal, case-insensitive, retained buffer only — which is
usually the fastest way to find the error line an orchestrator needs to
react to. n/N step between matches. The same search works in R for
run-script output. O also tails a running setup-workspace: the setup
section streams the in-flight tail while the sequence runs and settles to
the recorded transcript when it finishes, so a create/restore's provisioning
is observable instead of silent.
Path pickers (add project, transcript browser) resolve explicit typed paths
before row selection: an absolute path, ~, ~/…, ./…, ../…, or any
input containing a separator makes enter resolve that path and tab
navigate a directory, even while unrelated rows are listed. An orchestrator
can therefore paste a full path and press enter without clearing the input
or the listing first; a plain name still fuzzy-filters and selects rows.
Every git invocation amux issues runs in its own process group (Unix; leader-only kill on Windows) with a bounded output drain, so deadline cancellation terminates spawned descendants and cannot be stalled by a lingering child holding the output pipes. The cleanup allowance after a deadline is short (sub-second scale), not exact — orchestrators should treat the deadline as "deadline plus bounded teardown," not a hard wall-clock contract.
amux stores per-session metadata as tmux session options (@amux_*). Read one
with tmux show-options -t <bare-name> -v <key> (bare name, no = — see above)
or read them all with tmux show-options -t <bare-name>. The keys, set at
session creation (sessionTagArgs, internal/tmux/command.go) and updated at
runtime (internal/tmux/tags.go, internal/ui/center/model_input_lifecycle.go,
internal/app/app_tmux_activity_result.go):
| Key | Meaning | Value format |
|---|---|---|
@amux |
marks the session as amux-managed | literal 1 |
@amux_workspace |
workspace ID | 16-char hex |
@amux_tab |
tab ID | tab-<prefix>-<counter> |
@amux_type |
session type | string (e.g. agent, viewer) |
@amux_assistant |
assistant/agent name | string |
@amux_created_at |
session creation time | Unix seconds |
@amux_instance |
amux instance ID | string |
@amux_session_owner |
owning amux instance | string |
@amux_session_lease_ms |
ownership lease timestamp | Unix milliseconds |
@amux_owner_heartbeat_ms |
last heartbeat from the owning amux process | Unix milliseconds |
@amux_last_output_at |
last observed agent output | Unix milliseconds |
@amux_last_input_at |
last input amux delivered | Unix milliseconds |
@amux_agent_state |
semantic agent state (idle/working/done/blocked/error) | idle | working | done | blocked | error |
@amux_workspace_name |
workspace display name | string, sanitized |
@amux_project |
project display name (repo dir basename) | string, sanitized |
Pane options (read with list-panes -a -F '#{@amux_reported_state}' and
kin; they live and die with the pane):
| pane option | meaning | values |
|---|---|---|
@amux_reported_state |
the pane's self-reported state, folded per the protocol | idle | working | done | blocked | error |
@amux_reported_kind |
with blocked: what is waited on |
permission | question | auth |
@amux_reported_app |
the reporter's identity | string, protocol app grammar |
@amux_reported_message |
one-line context (base64-decoded) | string, sanitized |
@amux_reported_at |
when amux accepted the report | Unix milliseconds |
@amux_workspace_name and @amux_project are display-only labels for
external tooling (tmux ls, list-sessions -F) so a session is identifiable
without resolving IDs against amux's store. They are never identity keys —
@amux_workspace remains the only workspace identity — and amux never reads
them back. They snapshot the names as of the last session attach: a
workspace rename does not re-tag a live session, so the value may be stale
until the next attach/reattach refreshes it. Values pass through a
control-byte strip and a 128-rune cap before becoming tag values.
Assistant launch settings (command plus the interrupt fields from
~/.amux/config.json) are captured when amux dispatches a launch, reattach, or
restart — a settings save changes only launches requested afterwards, never an
in-flight spawn. Attaching to an existing session never re-runs the pane
command: the session's process owns its argv from creation.
interrupt_delay_ms only spaces the Ctrl-C signals inside one interrupt
sequence; it is unrelated to the text-then-CR submit pacing above. An
explicit 0 is a real setting and is serialized on save — it does not
silently revert to the built-in default.
Note the unit split: @amux_created_at is in seconds; the activity/lease
timestamps are in milliseconds. amux parses these back with
activity.ParseLastOutputAtTag (internal/app/activity/fetch.go). The owner and
lease tags coordinate ownership between concurrent amux instances; an external
orchestrator should treat them as read-only telemetry and not forge them.
Attach exclusivity follows the same coordination: amux NEVER detaches a
session's existing clients — every attach it issues is plain tmux attach
without -d (tmux.NewClientCommand and tmux.AttachOnlyClientCommand, the
only two attach renderers in the codebase). A second viewer — another amux
instance sharing the workspace, or an external orchestrator — always attaches
alongside the first. This is deliberate: a -d attach anywhere starts a
detach war between restart loops (each instance re-attaches with -d, kicks
the other, gets kicked back), and even a "detach only when client-free" probe
loses the race against a concurrent creator, whose fresh session is briefly
client-free between new-session and its own attach. Creator exclusivity
never needs -d: fresh creates mint unique session names, so there is no one
to detach. An orchestrator that wants a session to itself should kill the
session and create its own rather than expect amux to evict viewers.
@amux_agent_state is amux's own idle/working/done classification (the same
hysteresis that drives the working indicator and the bell-on-done), published
per session so an external orchestrator does not have to re-derive it from the
raw timestamp tags above. It is read-only telemetry, written best-effort and
only when the state actually changes (not on every scan), so a missing or
momentarily stale value should be tolerated the same way as the other tags.
Transitions driven purely by elapsed time publish too: when done's
freshness window (~30s) expires on a quiet session, the tag advances to
idle rather than sticking at done forever. On amux start or an
owner-handoff between instances, each session's first observation publishes
its current value (correcting any stale tag) but never replays an on-done
hook — the hook only fires on a working→done edge the current owner watched
happen. Within one scan every eligible session is attempted once even if an
earlier dispatch fails: each failure reports independently and completed
edges are never retried. Only the scan owner writes these tags; follower
instances consume the shared workspace snapshot instead.
Every actor able to reach amux's tmux server ($AMUX_TMUX_SERVER socket, or a
default-socket tmux server it shares) is inside amux's trust boundary. tmux
offers no per-session authentication: a same-server actor can read every
@amux_* tag, forge them on sessions it creates, squat on amux's predictable
session names, and drive amux's sessions directly. Sharing one tmux server
across trust domains is therefore unsupported — give each domain its own
server name (-L) and socket.
amux's defenses are namespace hygiene, not authentication:
- Attach paths verify ownership tags before binding (
ptyio.SessionOwned): a live session under an amux name must carry@amux=1and, when present, a matching@amux_workspace. This stops untagged squatters and plain name collisions — it does not stop a squatter who forges the tags, which is always possible on a shared server. - Discovery paths (
FindRunSessions, workspace/session tag queries) filter on the same tags, so untagged foreign sessions are invisible to amux's own bookkeeping. - Sessions carrying
@amux=1with no@amux_workspace/@amux_instanceare accepted as pre-tagging-era amux sessions for backward compatibility — the same acceptance a forger can trivially satisfy. Compatibility, not a check. - Name-prefix kills (
KillSessionsWithPrefix) deliberately reclaim anything holding an amux-prefixed name; on a shared server this is the only meaningful reclamation mechanism.
Residual race: between the ownership check and the tmux client command's own
has-session || new-session sequence, a squatter could still claim the name;
closing that window needs the ownership test inside the generated tmux shell
script and is documented rather than implemented.
The repository itself is the other side of the boundary: amux treats a
delivered .git as hostile. Every git invocation runs with repo hooks,
core.fsmonitor, and GPG signing neutralized and inherited GIT_* state
dropped. Two redirect vectors in the same class are also pinned or refused:
git commands run with --work-tree pinned to the directory they were
invoked on, so a delivered core.worktree cannot relocate
merge/abort/writeback writes (or the reads that would mask them) into an
arbitrary directory — the flag beats file config, and GIT_WORK_TREE, its
env equivalent, is stripped with the other GIT_* vars; and a commondir
file inside a checkout's .git directory — which git only ever writes
under <common>/worktrees/<id>/, never in a checkout — is treated as
forged state and refused outright. Repo-configured drivers that would
execute code during amux's own git operations are refused rather than run:
an attributed merge.*.driver
blocks the merge, and a repo-configured filter.* driver (clean/smudge/
process/command) attributed by the current index OR the
checked-out/incoming tree blocks workspace create/attach, add -A
writeback, merges, and merge --abort, naming the driver in the refusal —
check-attr --source reads the attribute stack of the tree a worktree add or merge actually writes, so a branch can not hide a binding behind a
clean-looking current tree. Filter drivers are also neutralized on every git invocation —
empty -c filter.<name>.*= overrides suppress any driver configured in
repo-local config, its include chain, or config.worktree — because read
paths like git status/git diff execute clean filters on stat-dirty
files and cannot be refused without breaking the app. Merge drivers
enumerate all config scopes (a repo's attributes can name a global driver);
filter enumeration stays repo-scoped so globally configured filters
(git-lfs's clean/smudge) are untouched; AMUX_ALLOW_GIT_HOOKS=1 is the
single opt-out for repos the user does trust.
Every workspace gets AMUX_PORT/AMUX_PORT_RANGE from a durable registry at
~/.amux/port-reservations.json, shared by every amux process pointed at that
state home. The contract an orchestrator can rely on:
- Owner: the registry owns ranges; app processes and tmux sessions are only
consumers. A reservation is committed under the workspace's persisted
metadata ID (
@amux_workspaceon its sessions) before the session's env is built — never under a path-derived ID, which drifts when roots move. - Stability: re-spawns, restarts, and concurrent instances all read the committed interval verbatim. Intervals minted under earlier port settings keep their original bounds — current settings shape only new reservations.
- No automatic reclamation: reservations survive release, workspace
teardown, quits, and crashes. A stale release can never hand a live
session's range to another workspace. There is no TTL and no
liveness-based reuse. The
iworkspace-status dialog enumerates — from the same registry — how many committed reservations are owned by workspace IDs absent from the live model, retained shelves, in-flight creates/mutations, and every tagged session. A shelved record is still an owner: its range survives until the shelf is restored or fully deleted. From that dialog,Ropens a typed-confirm release: the user types the displayed positive count — the bound count is enforced at the consumer, so an empty or mismatched entry never reaches the registry — and on confirm the orphan probe is re-run fresh (the displayed set is never trusted — re-derivation is fail-closed, so staleness can only under-release), and exactly the still-orphaned entries are deleted under the registry flock. Release is never automatic; the only path to deletion is this user-confirmed action. - Exhaustion/corruption fail closed: a saturated space reports a typed exhaustion error; malformed, overlapping, or newer-schema bytes are left untouched and surfaced as errors — never silently accepted or rewritten.
- Missing metadata degrades, never blocks — and never overlaps: a
workspace object with no persisted ID (a transient store error during load,
or a never-saved record) logs a warning and mints a transient hold into
the same registry under a
transient-<pid>-<rootHash>key — a purely in-memory fallback would be invisible to other instances, letting a second instance's durable mint (or another degraded pick) select the same interval. Transient holds differ from durable reservations: they release when their owning amux process dies (dead-owner holds are swept on the next registry write), and a workspace delete or shelve releases the calling process's own hold (its sessions are gone, so the range is free). They never appear in theireclaim enumeration — they are per-process, not orphans — and a workspace that later gains a stored ID mints a normal durable reservation alongside its transient hold, so the sessions bound to the transient range are never shadowed. The residual window is documented rather than closed: a session that outlives its degraded owner's process may hold a range the sweep has already released — the same class of gap the registry carries for any process it cannot name. The durable path resumes on the next successful load. - Do not delete the registry while any amux session exists: removing it orphans the ranges live sessions still hold, and the next launch would mint overlapping reservations. There is intentionally no reset command.
First-upgrade adoption. A tmux session left behind by a pre-registry amux
holds a range the new registry cannot see, so while the registry file is
missing each launch performs a guarded adoption: startup inspects the
configured tmux server and defers adoption if any session it cannot prove
foreign might hold an untracked range (any amux--named or @amux-tagged
session whose @amux_instance is absent or shares this state home's
namespace). Deferral is never fatal — amux opens normally, warns once, and
allocates per-process exactly as pre-registry versions did; the registry is
created on a later launch while no ambiguous amux sessions remain. To opt in
immediately, quit amux and stop all amux tmux sessions once, then relaunch.
The check never runs after the registry exists and never kills sessions,
and never deletes metadata. Two limitations are on the operator: the guard can
only inspect the configured tmux server — sessions on custom tmux -L
servers or AMUX_TMUX_SERVER sockets are invisible to it — and mixed-version
running is unsupported until adoption completes: an old binary still
allocating in memory would keep handing out ranges the new registry cannot
see. tmux-discovery failure during adoption also defers rather than guessing.
The sidebar's Project tab reads directories asynchronously: switching
workspaces, expanding a directory, and pressing r/enter/l all return
immediately and populate rows as reads complete. A directory still loading
shows a muted … marker; a failed read shows a short sanitized error with an
r-to-retry hint — an unreadable directory never masquerades as an empty one.
Orchestration-relevant guarantees: a refresh preserves expansion and cursor by path (a deleted directory's subtree simply drops out), the view never mixes two workspaces' contents (late results from a previous workspace are discarded), and reads are bounded so a wedged mount delays its directories rather than the whole UI. There is no configuration or session surface for the queue; keyboard and mouse actions are unchanged.
The / filter narrows the loaded tree to matching entries plus their
ancestors without mutating Expanded state — clearing restores the identical
view. It matches only what has been read: children of a never-expanded
directory cannot match, so filtering is a lens on the loaded tree, not a
filesystem search.
Tools driving amux via tmux keys can rely on the diff viewer reaching every
display row: w toggles wrap; with wrap on, j/k/wheel/page keys and the
footer position counter all operate in display rows (wrapped segments), so a
long line's tail is always scrollable to. n/p still navigate hunks in
source-line order. Resizing or toggling w re-anchors the top of the
viewport to the same source line where possible. / search selection
scrolls to the display segment intersecting the selected hit rather than the
source line's first segment — a match deep in a wrapped tail is brought
into view, with the enclosing hunk header retained only when it fits with
that segment.
A program hosted in an amux pane can tell amux what it is doing — instead of
amux guessing from output heuristics — with the Program Status Protocol
(OSC 7501). amux is a protocol consumer: it installs a
pipe-pane tap on every hosted session at creation (both agent tabs and
detached run sessions), the tap maintains the protocol's record set from
the pane's raw output stream, and the folded result lands as the
@amux_reported_* pane options above. Reported state overrides the
output-heuristic classification in the dashboard (a blocked report rings
the bell and shows a blocked badge even while output still scrolls), and
@amux_agent_state publishes the merged view.
The one-line recipe for any program or wrapper script running inside an amux pane (this is the protocol's own shell function, and it works in any terminal — well-behaved terminals ignore unknown OSCs):
status() {
printf '\e]7501;state=%s:msg=%s\e\\' "$1" "$(printf '%s' "$2" | base64 | tr -d '\n')"
}
status working "Refactoring the parser"
# ... later, when the agent needs its human:
status blocked "Which migration should I generate?" # kind defaults absent
status done "All tests green"amux report is the same printf with validation, for programs that prefer
an explicit call:
amux report --state blocked --kind question --app my-tool --message "needs a decision"
amux report --state done --message "finished"
amux report --clear # removes every reported record for this programState vocabulary and semantics follow the published spec (revision 0.4):
- states:
idle,working,done,blocked,error; unknown states are ignored whole (never rendered as idle); msgis base64 of UTF-8 text (single line, hard size caps);kind(permission/question/auth) qualifiesblocked;appidentifies the reporter; reports may address sub-records withid(a blocked worker under a working root is the canonical case) and remove them withstate=clear;- lifetime:
working/blockedrecords drop when the pane's process exits or a new shell prompt begins (OSC 133 A);done/errorsurvive until the pane dies, the program reports again, orstate=cleararrives. Viewing a workspace acks the presentation (the badge clears in that amux instance) but does not rewrite the stored records.
The consumer for supervising scripts and agents is amux wait:
# Block until any pane reports blocked, error, or done (10s timeout):
amux wait --state blocked,error,done --timeout 10s
# Scoped to one workspace or session, matching on any state:
amux wait --state any --workspace my-feature
amux wait --state done --session amux-my-feature-1It exits 0 on the first match (printing session=… state=… kind=… app=… message=…), 1 when tmux itself is unusable (missing or too old — waiting
cannot fix that), and 2 on timeout or a usage error. A stored
working/blocked report on a pane whose process has exited never
matches — transient states die with their process, and a supervising
script must not act on a pane that can no longer respond. Inside a pane it
derives the tmux server from $TMUX automatically.
Design notes for external tooling: reports live and die with their pane —
the tap holds no state on disk, and killing a session clears its reports.
The tap binds at creation to the exact pane it reads (its writes address
that pane id, so a later split-window cannot skew reports onto the new
active pane), is best-effort at creation (a session hosts fine without
it), and -o keeps reinstall idempotent. amux injects AMUX_ENV=1 and
AMUX_BIN_PATH=<amux binary> into every hosted pane, so a wrapper can use
"$AMUX_BIN_PATH" report … even if amux is not on the pane's PATH.
External tooling that relaunches amux by path can rely on the self-update
install never vacating the installed pathname: the running executable is
backed up by copy (never moved), the verified replacement is renamed over the
live path in a single same-directory rename, and the directory is synced
around the transition. A kill before the rename leaves the old binary; a kill
after it leaves the new binary. If the post-replacement directory sync fails,
amux keeps the complete old backup at the reported path and prints the quoted
mv recovery command — it never silently rolls the live binary back.
This is a single-process contract: concurrent self-updates are not serialized against each other, and no update scheme on a POSIX filesystem can guarantee the new entry survives a power loss that lands between the rename and the sync — the backup exists so that failure mode is recoverable.
If the tmux contract above proves insufficient for a concrete orchestration need,
a minimal command surface — something like amux send and amux ls — could be
reintroduced. The deleted implementation is recoverable from git show c76dce7c^
(the parent of the removal commit), including the old agent send execute path
and session listing, to be trimmed down rather than rewritten. If this is ever
done, treat the "Sending input" and "Reading state" sections above as the
behavioral spec for the new commands so the two surfaces stay consistent.
This is not recommended by default. The CLI was removed precisely because a second supported surface is a recurring cost: it needs its own tests, its own docs, and its own backward-compatibility guarantees, in perpetuity, on top of the TUI. The tmux seam already provides discovery, input, and state with zero extra maintained surface. Revisit this only when a specific orchestrator requirement is demonstrably unmet by tmux — name that requirement in the proposal — not merely because a CLI would be more convenient than raw tmux commands.