@sailorbridge/client
v0.2.41
Published
SailorBridge client CLI: relay access, enrollment descriptors, worker host runtime, and a tmux supervisor for Claude/Codex agents.
Maintainers
Readme
SailorBridge Client
Standalone Node/TypeScript client for SailorBridge: a relay HTTP client, a debug CLI, a worker host runtime, and a tmux supervisor that owns one local Claude or Codex agent process and reports monitor state to the relay. Host pairing login stores a host-scoped relay key for subsequent local host commands.
Install
One channel, one line:
curl -fsSL <origin>/api/client-binary/install.sh | sh
sail --versionUse the exact <origin> shown by the SailorBridge console's copyable install
command; it reflects the deployment this host belongs to.
It installs a self-contained binary into /usr/local/bin under both names
(sail and sailorbridge), after verifying the download against the published
sha256. Both commands work in that same shell; there is no PATH to edit.
/usr/local/bin belongs to root, so re-run the line with sudo sh in place of
sh if the installer says it cannot write there. Linux and macOS are the
supported targets; Windows packaging support is deferred.
The npm package (npm i -g @sailorbridge/client) is a legacy compatibility
rail for machines that already installed that way. It is not offered anywhere
in the product, and new installs must not use it: npm's global prefix is
routinely absent from PATH, a postinstall script cannot change its parent
shell's PATH, and editing somebody's shell profile is not the installer's to do.
Building the client from source requires Perl with Fcntl/flock support, in
addition to Node and Bun; the build lock uses it to serialize atomic releases.
The host service records the PATH of the shell that installed it, because no
systemd user unit or launchd agent inherits one. After changing where your tools
live — an nvm/asdf version switch, a new Homebrew prefix — re-run
sailorbridge host service install so the service picks up the new PATH.
Upgrade a client and reload a running managed host daemon in one step — the same installer line, which replaces the binary the host service runs and restarts that service:
curl -fsSL <origin>/api/client-binary/install.sh | shOn a machine still on the legacy npm rail, sailorbridge upgrade remains the
in-process equivalent (compatibility only, never guidance):
sailorbridge upgradeThe command installs @sailorbridge/client@latest, compares the running and
installed versions, smoke-checks the new CLI with --version, and restarts the
product-managed user systemd unit or LaunchAgent when it is running. The npm
install is killed after 120 seconds; its bounded stdout/stderr is returned in
the error instead of inheriting the host log stream. A failed version read or
smoke check reinstalls and verifies the pre-upgrade global package before
returning the failure. If the host runs in the foreground or under another
supervisor, the command prints the exact manual restart action.
If npm reports an ENOTDIR rename for the global @sailorbridge/client
entry, upgrade checks the path against npm root -g, removes the client
entry only when lstat proves it is not a directory, clears only sibling
.client-* npm residue, and retries once. A second failure keeps npm's bounded
stderr and prints exact, shell-quoted cleanup and retry commands.
The host daemon also keeps itself current: the relay broadcasts the latest
released client version on every register/heartbeat, and host run upgrades
through the binary rail — or, on a legacy npm install, that npm channel — as soon
as it hears about a target. The restart that loads it waits out one random
per-host delay and nothing else: the window it is drawn from is the relay's
restart_jitter_max_seconds (ten minutes if the relay names none), and it exists
only to keep a whole fleet from restarting on the same broadcast. A busy host is
not a reason to wait — a daemon restart leaves every member pane running — and no
member is asked for a checkpoint first. A manual sailorbridge upgrade and a 426
handshake rejection skip the delay entirely. If three service restarts fail to advance the
running version, the host persists a fuse and reports its self-service recovery
through update_error. Other failed attempts are logged and reported to the
relay as update_error, then retried on a cooldown. Opt out with sailorbridge host run
--disable-auto-update or "auto_update": false in
~/.sailorbridge/host-config.json — but hosts below the relay's minimum
supported version are refused at the handshake (HTTP 426) until upgraded, so a
disabled host must be kept current manually.
The daemon also probes its own launch artifact on disk. If an external installer has placed a newer release (or a different release build at the same version) beside the still-loaded process, it does not reinstall or republish the old bytes. A managed daemon enters the same idle/24-hour/checkpoint/restart-fuse state machine and restarts only the exact service definition whose credential fingerprint matches this host. A standalone daemon is never killed by PID; its host row carries a copyable, topology-only restart command until the new process starts and clears the observation.
For a managed host daemon, use the compiled single-binary path instead of a
Node-based global install. Build or download the native sailorbridge binary,
then log in once — login installs and starts the user service itself:
bun run build:binary
./bin/sailorbridge login
./bin/sailorbridge host service statusThe service runs sailorbridge host run from the binary and reads the
host-scoped credential file. The unit/plist never embeds bearer tokens. After
the binary is built or installed, the managed host runtime does not require
Node, Bun, or Python on PATH.
Quickstart
Configure relay credentials:
export SAILORBRIDGE_RELAY_URL=http://127.0.0.1:8765
export SAILORBRIDGE_TOKEN=...
export SAILORBRIDGE_AGENT_NAME=session_agent
export SAILORBRIDGE_CAPTAIN_ID=100
export SAILORBRIDGE_SESSION=session
export SAILORBRIDGE_HOST_ID="$(hostname)"
export SAILORBRIDGE_HOST_DISPLAY_NAME="$(hostname)"Or pair this machine through the product API:
sailorbridge loginlogin connects to https://www.sailorbridge.com. The command prints a short
host pairing code, waits for console/mobile approval, writes a 0600 credentials
file at ~/.sailorbridge/credentials.json, and then installs and starts the
host service so the console can start workers and agents on this machine without
a second command. A pending pairing is also stored with mode 0600: restarting
login within the 10-minute code lifetime resumes the same code. Bare
401/403/404, 429/5xx, and network failures use capped exponential backoff
until that deadline while preserving the same code. Only a successful poll that
reports an expired/rejected/consumed terminal state fails immediately and tells
you to run sailorbridge login for a new code.
Before pairing, login asks what the host is for and prints one preflight
checklist for that answer, so a missing prerequisite surfaces while the user is
still at the keyboard instead of when their first member fails to start:
| Requirement | Member host | Worker-only host |
| --- | --- | --- |
| Node.js >= 20 | required | required |
| sailorbridge and sail on PATH | required | not used |
| git executable | required | not used |
| tmux | required | not used |
| claude or codex CLI, logged in | required (either one) | not used |
Pass --worker-only (or SAILORBRIDGE_WORKER_ONLY=1) to skip the member-host
items; an interactive login asks once, and a non-interactive login checks the
full member-host list. The CLI row is checked twice — once against your shell's
PATH and once against the PATH the installed service will run with — because
members drive their own pane with sail: a service that cannot resolve it
leaves every member's control command failing long after login reported
success. The login preflight checks that git is installed but does
not inspect the current directory; the repository is named when a team is
created. Workers advertise podman, git, kubectl,
perf and bpftrace as capabilities when present; none are required.
Installed engines receive a bounded real prompt. The final stdout answer is
preferred, with stderr accepted only when stdout is empty; a fully empty
successful response is retried once and then reported as a transient empty
response. Authentication failures point to that engine's login command, and
timeouts report the elapsed limit.
A failing item never blocks pairing — the report prints the exact command that fixes it. tmux can be installed with consent (the exact command is shown first); engine CLIs and their logins are always left to the user, since the credential is theirs.
Common commands:
sailorbridge --help
sailorbridge login
sailorbridge upgrade
sailorbridge workflow
sailorbridge run --fold bun test
sailorbridge mailbox peek
sailorbridge mailbox claim
sailorbridge reply --in-reply-to MESSAGE_ID REPLY --body-file ./reply.txt
sailorbridge mailbox ack MESSAGE_ID --lease-token-file ./lease-token
printf '%s\n' 'hello $USER `date` $(pwd)' | sailorbridge mailbox send --to-agents ab_hand --tag BTW
sailorbridge mailbox send --to-roles reviewer --tag PLAN-REVIEW --body-file ./plan.txt
sailorbridge mailbox send --to-agents captain --tag REPORT --body-file ./report.txt
sailorbridge create team --name "My team" --template classic3 --repo-path "$PWD" --host my-host
sailorbridge host register
sailorbridge host consume --once --dry-run-start
sailorbridge host service install --binary /opt/sailorbridge/bin/sailorbridge
sailorbridge host service status
sailorbridge worker list
sailorbridge worker run --worker dev-1 -- uname -s
sailorbridge worker job JOB_ID
sailorbridge worker serve
sailorbridge supervise --cli codex --cwd "$PWD" --i-own-this-agentsail run --fold <cmd...> is for finite, non-interactive test/build commands
whose full output would waste the member's context. It writes complete stdout
and stderr under ~/.sailorbridge/logs/, then prints only a bounded verdict,
failure block and tail with the command's exit code, duration and log path. It
runs argv directly rather than through a shell; name sh -c yourself when shell
syntax is intentional. Interactive programs must be run normally without
--fold.
worker list, worker run, and worker job are member-runtime commands;
worker serve instead uses a worker enrollment credential. The paired host
credential created by login authorizes neither surface.
The package ships only the built CLI/runtime files under dist/src/ plus this
README. It deliberately does not publish src/, tests/, or dist/tests/.
npm run pack:staged performs an ephemeral packaging check without mutating the
live dist/ symlink. It runs only from a clean checkout whose HEAD exactly
matches the freshly fetched origin/main, writes the tarball under a private
temporary directory, and deletes it before returning; its JSON filename is
informational, not a reusable artifact. Use npm run pack:verify for
package-content verification on a feature branch. Releases must use:
npm run publish:releaseFor an operational reinstall from the current checkout, build first and invoke
the same packaging helper used by the pristine-install test. This path is for a
local install only; it does not apply the release gate and must never feed
npm publish. The temporary directory is the recovery boundary: rerun after an
interruption, and the trap removes the local artifact.
package_dir="$(mktemp -d)"
trap 'rm -rf -- "$package_dir"' EXIT INT TERM
bun run build
node --input-type=module - "$package_dir" <<'NODE'
import { createPackage } from "./scripts/create-package.mjs";
process.stdout.write(await createPackage(process.argv[2]));
NODE
npm install -g "$package_dir"/sailorbridge-client-*.tgzThat entrypoint fetches origin/main before any check or packaging work. It
refuses a dirty worktree, a HEAD different from origin/main, or a history
that does not contain the fetched main tip; non-main failures print both SHAs
and the ahead/behind counts. After checks it explicitly rebuilds dist, then
reruns the Git gate immediately before packaging so source changes during the
long check cannot pair with stale output. It removes reusable
sailorbridge-client-*.tgz files from the package root, packs into a randomly
named one-shot tarball in an isolated temporary directory, publishes it, and
removes it after success, failure, SIGINT, or SIGTERM.
The package-root cleanup intentionally runs before fetch/provenance checks, so
starting the wrapper removes those known reusable artifacts even when the
release attempt is subsequently rejected.
Only npm run publish:release is supported. npm lifecycle hooks are not a
security boundary: npm publish <tarball> does not run the source package's
prepublishOnly, and npm publish --ignore-scripts disables it explicitly.
Those direct npm forms can therefore bypass repository checks and must not be
used; the wrapper cannot stop a user who deliberately invokes npm outside it.
An emergency hotfix branch requires a Hotfix-Reason: line in the HEAD commit
message plus the exact same CLI reason. This is self-recorded intent, not
independent approval: the wrapper prints it as a warning, but terminal stderr is
transient and an unpushed commit can still be rewritten. Non-main releases are
forced to the npm next dist-tag so the override cannot replace latest.
git commit -m "fix(client): emergency release" \
-m "Hotfix-Reason: reason for the emergency hotfix"
npm run publish:release -- --allow-non-main "reason for the emergency hotfix"package.json intentionally remains at 0.2.12 while this release-safety
change is not itself published. npm versions are immutable, so the next
owner-approved release must bump the version in its own release commit before
running the wrapper.
Development
bun install
bun run checkBuilds retain immutable releases for at least one hour so in-flight readers remain loadable; older non-current releases are reclaimed.
The package installs a sailorbridge bin after build:
bun run build
node dist/src/cli.js workflow
node dist/src/cli.js mailbox peekBuild a native standalone binary for the current host:
bun run build:binary
./bin/sailorbridge --helpbuild:binary defaults to the native Bun compile target. Release automation can
pass --target through scripts/build-binary.mjs for the supported matrix:
linux-x64 -> bun-linux-x64
linux-arm64 -> bun-linux-arm64
darwin-x64 -> bun-darwin-x64
darwin-arm64 -> bun-darwin-arm64Configuration
Preferred environment:
SAILORBRIDGE_RELAY_URL=http://127.0.0.1:8765
SAILORBRIDGE_TOKEN=...
SAILORBRIDGE_AGENT_NAME=session_agent
SAILORBRIDGE_CAPTAIN_ID=100
SAILORBRIDGE_SESSION=session
SAILORBRIDGE_HOST_ID=mac
SAILORBRIDGE_HOST_DISPLAY_NAME="Mac"
SAILORBRIDGE_HOST_CONFIG_FILE=~/.sailorbridge/host-config.json
SAILORBRIDGE_API_URL=https://www.sailorbridge.com
SAILORBRIDGE_CREDENTIALS_FILE=~/.sailorbridge/credentials.json
SAILORBRIDGE_WORKER_NAME=dev-1
SAILORBRIDGE_WORKDIR=~/sailorbridge-workdir
SAILORBRIDGE_MAX_CONCURRENCY=1SAILORBRIDGE_CAPTAIN_ID is required by worker list, worker run, and
worker job; it must be a numeric string. worker list and worker run also
require SAILORBRIDGE_SESSION so an unbound admin credential cannot
accidentally discover workers across teams. These commands require a
member-runtime credential; a paired host credential is not a member credential
and is rejected before any relay request. Use the console Workers page to list
workers, or run the command inside a member workspace where the product provides
the token, captain id, and session. Agent routing uses the separate
SAILORBRIDGE_AGENT_NAME.
SAILORBRIDGE_HOST_ID and SAILORBRIDGE_HOST_DISPLAY_NAME identify the
captain-scoped local host for Tier 6 console start-request polling. When omitted,
host commands default both values from the OS hostname.
A host keeps no list of "its" repositories: a repository belongs to a team, and
its absolute path on the host is named per team (create ... --repo-path, or the
path field in the console's team wizard). create team --repo-path verifies the
clone through that host and registers it as the team's task repository in the
same team-creation transaction. The host resolves origin/HEAD, verifies that
branch has at least one commit, and registers it as the immutable default branch
for the team pipeline. Task-team member start requests do not carry a workspace
choice: the relay derives and stores a worktree spec from this verified
(team, host) mapping, ignoring any supplied override. Chat-team start requests
keep their explicit fresh/worktree/git modes. The daemon never scans or reports
the directories on a host. Each create subcommand rejects flags outside its
documented command-specific surface before reading credentials or contacting
the network.
SAILORBRIDGE_HOST_CONFIG_FILE redirects the local host config
(~/.sailorbridge/host-config.json by default) that host account writes and
the daemon reads.
SAILORBRIDGE_DOORBELL_NO_PROGRESS_MIN (default 10) is how long a member may
show no progress before its unclaimed doorbell is escalated to the moderators as
DOORBELL-TIMEOUT. Members claim on a turn boundary, so a queued doorbell is
never by itself a delivery failure — the supervisor renews it while the pane
keeps painting, and reports once nothing has moved for the whole window (a pane
nobody can read counts as no progress) or once the renewal itself has run for
6× the window, whichever comes first. That ceiling exists because a pane in an
active turn repaints on the spinner's own clock, which would otherwise renew the
escalation for as long as the member keeps working. Raise the variable for
fleets whose members run longer silent stretches; it scales the ceiling by the
same factor, and delays the escalation by the same amount when a member really
does disappear.
Running members on more than one engine account
By default every member on a host uses that host's own engine directory
(CLAUDE_CONFIG_DIR/CODEX_HOME, else ~/.claude/~/.codex) — one account,
zero configuration. To run members on several logins, register each login
directory under a name of your choosing and point members at it:
sailorbridge host account add C1 ~/.claude-accounts/C1
sailorbridge host account add C2 ~/.claude-accounts/C2
sailorbridge host account set alice C1
sailorbridge host account set bob C2
sailorbridge host account listadd refuses a directory that holds no engine credential, so an account nothing
could start on is rejected while you are still at the keyboard. A member with no
pointer keeps using the host default directory. Moving a member is
host account set MEMBER ACCOUNT (or default to clear it); it takes effect on
that member's next start or relaunch, because the engine reads its credential
at boot. Members sharing an account share one physical credential file, so a
token refresh in any of their sessions is visible to all of them. SailorBridge
stores only the pointer for engine-owned credentials — those credentials never
enter product storage, logs, or the relay, and the console shows the account
NAME read-only. A named custom Codex provider can instead keep its API key in the
account directory's private SailorBridge-owned record:
# New provider-only account: key comes from config.toml's detected env_key.
sailorbridge host account add D1 ~/.codex-accounts/D1 --engine codex --provider-key-from-env
# Rotate from a pipe, inspect without showing the value, or remove it.
printf %s "$DEEPSEEK_API_KEY" | sailorbridge host account provider-key set D1 --stdin
sailorbridge host account provider-key status D1
sailorbridge host account provider-key remove D1The key is never accepted in argv or printed. env_key is detected from the
selected config.toml provider, the record is strict JSON with mode 0600, and
each start/relaunch (including after a daemon restart) reloads it from disk.
Every 6 hours the host daemon also runs a scratch sweep over each member
worktree it owns, reaping <root>/.agent/tmp/ (a git-ignored scratch directory): it
deletes git-untracked files older than 48 hours, then evicts oldest-first until the
directory total is under 512 MiB. It only ever touches untracked files inside that
directory, never follows symlinks, and skips (with a log) any workspace whose
.agent/tmp/ resolves out of bounds or is not a git repository. Set
SAILORBRIDGE_SCRATCH_SWEEP_ENABLED=0 (or false/no/off) to disable it;
it is on by default. A normal member retirement clears that member's eligible
.agent/tmp/ immediately before archiving its workspace.
The same pass reaps Claude/Codex session transcripts older than 48 hours from configured engine-account directories, but only when transcript metadata names the exact workspace whose ownership ledger belongs to this host. Claude sidecar tool results derived from that verified session id are removed with it. A member retirement reaps all such verified sessions immediately. Unknown metadata, unowned workspaces, other projects in a shared account, credentials, and account configuration are never deletion candidates.
The same daemon runs Podman GC every 5 minutes. It removes stopped task/test
containers in the sbtc- namespace (including their anonymous volumes) and volumes/images that Podman
itself reports as dangling after 48 hours. It never uses --force, removes a
running container, removes an attached or named shared volume, or removes a tagged image.
Rootless Docker is a separate, conservative surface. Every five minutes the
daemon probes the active Docker context and its actual DockerRootDir; if
Docker is absent or unreachable, the probe is a silent no-op. The heartbeat
reports aggregate/reclaimable bytes and the five largest images with age and
container-reference facts. Images older than 48 hours with no container
reference are offered in Console → Hosts. SailorBridge never deletes one
automatically: the captain must select it and confirm, and the host rechecks
container references plus the built-in agent-bridge relay/worker/captain
keep-list immediately before docker image rm (without --force).
The same five-minute maintenance cycle bounds every registered Codex account's
logs_2.sqlite: seven-day retention, at most 250,000 rows and 256 MiB of
estimated retained log payload, deleted in 5,000-row transactions with a
100,000-row per-cycle work bound. It uses a short SQLite busy_timeout and a
PASSIVE WAL checkpoint; it never renames, replaces, or unlinks a live
database. A persisted 1 GiB max_page_count bounds a new database; an already
larger database is capped at its current high-water mark and reuses pages freed
by deletion. Account bytes, reusable pages and incomplete/locked maintenance
are reported on the Hosts page and retry automatically.
Each host heartbeat reports filesystem usage plus the five largest owned
member scratch envelopes, combining workspace .agent/, product engine
sandboxes, and positively attributed engine-account sessions. At 90% the Hosts page and every affected team's
moderator receive a warning. At 95% the relay leaves new task cards queued and
the host rejects new member starts; both resume automatically after usage drops
below 95%. Open Console → Hosts → the affected host to see the current
percentage and top scratch consumers. No periodic manual cleanup is part of the
recovery path.
The register/heartbeat report also carries Linux host capacity from /proc:
MemAvailable, load1, and each managed tmux member's process-tree RSS. Each
probe fails soft and independently — an unreadable value is omitted, never
reported as zero. If the whole observation is unavailable the daemon omits the
top-level report, so the relay preserves the last known snapshot rather than
turning one failed cycle into invented capacity.
After its first register/drain and every 6 hours, the daemon also reaps OS
temporary entries older than 48 hours when their top-level names match one of
seven hard-coded prefixes with production producers. Each prefix also declares
the KIND its producer makes, and an entry whose kind does not match is left
alone — a directory wearing a file producer's prefix is somebody else's object,
not residue. Six are directories, removed with their contents:
sailorbridge-env-, sailorbridge-tmux-, sailorbridge-patch-,
sailorbridge-console-retention-, sailorbridge-api-pg-smoke-, and
sb-usage-rescan-. One is a file: sail-listener. (the listener's outgoing
message files), unlinked singly and never removed recursively. It checks the
daemon uid where the platform exposes one; Windows uses the daemon's per-user
temporary root as that boundary. Each loop has match, removal, and elapsed-time
budgets and continues unfinished work on the next loop. It never matches
generic tmp.* or test-fixture prefixes, follows symlinks, blocks host
registration, or lets one unreadable entry stop the daemon. Normal command
paths still remove their temporary entry in finally — and create it inside
the try, with nothing that can throw between the two — so this sweep only
recovers production residue left by SIGKILL or power loss.
sailorbridge worker serve runs the TypeScript host worker against the relay
without importing the Python worker package. It registers the configured worker
name, long-polls /pull, executes jobs inside SAILORBRIDGE_WORKDIR, posts
results with the relay lease token, and streams stdout/stderr for stream jobs.
Use --worker, --workdir, and --max-concurrency or the matching
SAILORBRIDGE_WORKER_NAME, SAILORBRIDGE_WORKDIR, and
SAILORBRIDGE_MAX_CONCURRENCY environment variables. SAILORBRIDGE_MODE=readonly
blocks mutating job kinds locally.
sailorbridge worker list [--format plain|json] discovers registered workers
in the current team. Plain output shows at most 20 and points to JSON for the
complete response. An empty session points to sailorbridge worker ensure
[--host H], which reuses the verified repo host, quota gate, worker-start
request, and host daemon, then waits for online registration; it never creates
from the read-only list command. A registered but offline worker is
automatically relaunched by the host daemon's durable-runtime reconcile;
ensure validates its online verified host and waits without minting a second
seat. sailorbridge worker run [--detach] --worker NAME
[--timeout SEC] [--format plain|json] -- ARGV... submits one bash job and
preserves ARGV as an array end to end; --format after -- belongs to the
remote argv. The timeout is the remote execution budget; synchronous mode
waits that budget plus a 30-second queue/result grace for the persisted
terminal result. Plain output writes remote stdout/stderr to the matching local
streams. A remote negative signal code remains exact in JSON; plain output
states that code and uses the conventional local status 128 + signal.
Synchronous mode then confirms inline delivery so the relay closes the
duplicate JOB-DONE mailbox obligation; timeout, interruption, or confirmation
failure leaves it open. Detached runs and worker job polling never close it.
For long work, --detach returns the submit response immediately; sailorbridge
worker job ID [--format plain|json] reports queued/running or the terminal
result. Worker jobs are infrastructure execution, not product tasks; their
status never completes a SailorBridge task lifecycle.
sailorbridge login creates a host pairing against
https://www.sailorbridge.com. The command displays the one-time code and
polls until approval. Deploy-time 429/5xx/network interruptions do not replace
an unexpired code, and restarting the command resumes its pending pairing.
The stored credential contains a host-scoped relay key
(key_scope: "host" plus host_id) and is used as a fallback when
SAILORBRIDGE_RELAY_URL or SAILORBRIDGE_TOKEN are not set. Worker data
commands reject this host-scoped fallback before any relay request. Raw host
keys are written to the credentials file only. Data commands default to
--format plain; login's plain success output prints the host id, credential
path, and service status. Explicit --format json prints key id and host
metadata, not the token.
sailorbridge login installs this service automatically; it exits 3 when
pairing succeeded but the install did not, and states that the host remains
offline and cannot receive console tasks before naming the command to run by hand.
Pass --no-service (or set SAILORBRIDGE_NO_SERVICE=1) to pair only — the
opt-out for containers, WSL, and CI images without a service manager.
Re-running login refreshes the definition and restarts the daemon so it always
runs with the arguments on disk. A markerless legacy definition is taken over
only when its parsed executable and arguments identify sailorbridge host run:
the old daemon is stopped/unloaded and the definition is renamed to a
.sb-legacy-<UTC timestamp> archive before reinstall. Foreign definitions,
symlinks, and definitions serving another host remain rejected; move a foreign
file aside or pass --service-name to install a scoped
sailorbridge-host-<suffix> unit.
sailorbridge host service install|uninstall|status manages the long-running
host daemon as a per-user service afterwards. Linux uses user systemd with
Restart=on-failure; macOS uses a user LaunchAgent with KeepAlive. The service
definition points at a credentials file and a binary path; it does not inline
tokens. Linux status detects whether linger is enabled. If boot persistence is
not active, install and status print the exact
sudo loginctl enable-linger <user> command for the user to run, but never run
sudo automatically. macOS LaunchAgents have no linger equivalent. Managed and
foreground daemons both write the structured, two-generation rotating event log
reported as log_path by host service install|status; Linux also captures
service stdout/stderr there, so reading host events does not depend on persistent
journald configuration. Successful member relaunch/rebuild events also enter the
ordinary host report as a bounded retry batch. The relay binds them to this host
and exposes the durable cause, exit code, and signal in the captain's member
detail; the local log remains the evidence source before a report succeeds.
Ordinary uninstall retains local credentials and state. Use
sailorbridge host service uninstall --purge for an explicitly confirmed
cleanup of SailorBridge credentials, config, sandboxes, auth state, and logs;
non-interactive callers must add --yes. Purge still preserves repositories,
member worktrees, worker workdirs, external engine-account directories, and the
user's global Claude/Codex configuration.
Relay Contract
This client reimplements the relay HTTP contract in TypeScript. It does not
import the Python SailorBridge engine, SailorBridge Site, or SailorAgent code. Key creation uses
the observed relay contract roles: string[]; the legacy singular role field
is deprecated and intentionally not sent.
Client host commands implement the P5a relay contract from engine/PROTOCOL.md:
registering or heartbeating a host, listing captain-scoped hosts, and claiming a
single agent_start_request. Host-paired credentials are limited to their bound
host id and cannot list sibling hosts. In P5a, sailorbridge host consume requires
--once --dry-run-start; it validates and transitions a claimed request but does
not launch an engine process. Retirement directives are printed with their exact
type and target, left pending without execution or ACK, and point to sail host
run for real handling. A stop_worker preview whose relay capabilities are
not yet available says that the real daemon will keep it pending until those
capabilities appear. sailorbridge host run is the real long-running host
daemon: it claims console start requests, redeems an agent credential,
prepares the relay-derived workspace, and starts the supervised agent.
On daemon restart it asks the product API for every still-active member owned by
the host, rotates the agent credential, and rebuilds the exact relay-reported
tmux target before it resumes polling new start requests. Workers started by the
same daemon are restored from the same durable ledger. Console Relaunch also
queues a new host start, so it does not depend on the old supervisor still polling.
Host-started Claude and Codex agents get terse-output defaults and Claude output
hygiene hooks to reduce routine token spend without lowering reasoning effort or
verification requirements.
Before launching an agent, the host must already be logged in to that engine:
Codex uses $CODEX_HOME/auth.json when set, otherwise ~/.codex/auth.json;
Claude uses $CLAUDE_CONFIG_DIR/.credentials.json when set, otherwise
~/.claude/.credentials.json.
The per-agent sandbox links to that single host auth file; missing engine auth
fails fast instead of starting a CLI that will prompt for credentials.
The host daemon also reads the default Claude subscription OAuth credential
once per day to list provider model metadata. It sends one GET /v1/models
request with a 10-second timeout and no retry, then reports model ids only to
the relay. The OAuth token stays in the provider request header and never
enters relay payloads, logs, errors, or argv. API-key-only and Codex-only hosts
skip this probe; Codex has no machine-readable subscription catalog source.
Worker commands implement both sides of the relay worker contract from
engine/PROTOCOL.md: register, heartbeat, pull, stream upload, result posting,
session-scoped worker listing, direct bash submission, and terminal result
reads. Worker HTTP uses bounded connect/read-idle timeouts for liveness and
leaves result uploads without a total timeout cap.
Mailbox claim is a destructive operation on the relay; mailbox peek is the
non-destructive command. Add --lease to a claim only to include rows already
leased by this client. A supervisor peek leaves workflow-obligation messages
queued and claimable after it surfaces the body-free doorbell. The bound agent's
first mailbox claim leases them, then the agent completes the required action.
Plain claims expose every lease token. HUMAN and MAIL stay leased so
reply --in-reply-to can infer the sender and atomically record the reply plus
source ACK; untagged peer mail is shown and handled as MAIL. If the exact
source cannot be reclaimed, the CLI identifies whether it is missing, settled,
addressed to another agent, or a transcript-only row and prints the next action.
--no-ack leaves the source leased. Direct workflow requests retain their
linked-reply settlement. Task-lane claims with a valid task identity label
mailbox ACK as delivery-only and point to the linked durable task transition.
An unavailable identity instead prints the ACK-clearable degraded recovery path.
ACK lease tokens must stay out of argv: store the surfaced token in a private
file and use mailbox ack MESSAGE_ID --lease-token-file PATH, or pipe exactly
the token to mailbox ack MESSAGE_ID --lease-token-file -. Empty input, a TTY,
duplicate token sources, and piped input unused by a file path are rejected.
--lease-token TOKEN is not supported.
Mailbox send is footgun-free by construction: message bodies are read only from
piped stdin or --body-file, never from positional argv. Every --body-file and
--description-file flag treats - as stdin; task create --description - is the
same stdin shorthand, as is positional reply body -. Non-empty piped input
that another selected source would discard is rejected instead of silently
ignored; commands without a stdin contract reject it before any relay request.
mailbox send and reply reject empty, whitespace-only, or BOM-only input before any
relay request; there is no implicit empty-message mode.
The relay's trusted Console control path separately permits payloadless CLEAR
and WORKFLOW-REFRESH envelopes; this public CLI does not expose that exception.
This prevents the local
shell from expanding $VARS, backticks, or $() before the client starts. A
single --to-agents A sends directly to that agent, except reserved captain,
which uses the session transcript endpoint. Multiple explicit agents use the
session mailbox endpoint. --to-agents
is mutually exclusive with --to-roles and --to-host; combine roles and host
when filtering registered agents. An interactive TTY with no pipe fails fast
instead of waiting for typed input.
Plaintext bearer tokens are never printed by default. Enrollment descriptors contain a plaintext token and are printed only when the caller explicitly runs the enrollment command.
Driver Contract
Phase 1 exposes only the tmux transport for Claude and Codex agents. SDK, headless, and PTY driver code remains in the package for a later release, but the public CLI and host-start surfaces fail fast if callers request those transports or request postponed engines such as opencode, copilot, or a custom CLI id. There is no silent downgrade to tmux or PTY.
The client provides a shared AgentDriver interface. The public Phase 1 path is
the tmux backend; all drivers share the same transport-level contract: start,
send, on, resume, isAlive, and stop.
The tmux driver creates a dedicated tmux session by default and only destroys
sessions it created. Existing-session mode can send or interrupt, but it does
not launch a process because environment secrets must not be typed into an
interactive pane. Host-started agents use readable session names such as
sb-<team>-<role> and append the captain number only when that default already
exists on the host.
Tmux launch environment is handed directly to the new pane through a private,
one-shot FIFO. The pane imports it immediately before exec; bearer values
never enter tmux argv, the tmux environment store, pane input, or a regular
credential file.
SDK drivers are optional implementation code and are release-gated from the public Phase 1 CLI. A base install or tmux-only caller does not need to instantiate them. The pinned SDK versions are:
@anthropic-ai/[email protected]@openai/[email protected]
Anthropic's public Claude Code SDK docs still reference
@anthropic-ai/claude-code in some places; the implementation is grounded in
the @anthropic-ai/[email protected] npm package declarations. Unit
tests use injected fake SDK facades to verify our event mapping and option
assembly; they do not prove live provider behavior. Real SDK smoke tests exist
but are skipped by default. Set SAILORBRIDGE_REAL_SDK_SMOKE=1 to run them.
Productized supervisors default to unattended execution so a BYO agent does not
stop on local permission prompts: Claude uses bypassPermissions and Codex uses
danger-full-access plus approvalPolicy: "never" / yolo. Explicit
--permission-mode, --sandbox-mode, --approval-policy, or --yolo false
opt back into a more interactive or constrained launch; the tmux transport
translates those flags into first-party CLI argv. An explicit -- COMMAND...
is always used verbatim; combining it with safety flags fails fast because those
flags would not be applied to the verbatim argv. --yolo true is also mutually
exclusive with explicit Codex sandbox or approval-policy constraints.
SDK capabilities, PTY support, opencode/copilot adapters, and custom CLI ids are deferred to the next release. Their implementation files may exist in this package, but Phase 1 help, docs, and validation do not expose them as supported product surface.
Mailbox consumption, CLEAR, and WORKFLOW-REFRESH handling are supervisor
responsibilities (see Supervisor Contract). The driver only manages transport
lifecycle, liveness inspection, and text delivery.
Supervisor Contract
The TypeScript supervisor owns one local agent process, chooses a transport, leases that agent's mailbox, and reports only real session/profile changes back to the relay.
Ownership is exclusive: do not run this supervisor and the Python SailorBridge
listener against the same agent_name. A single agent inbox and runtime control
channel must have one owner, otherwise mailbox leases can be stolen. Use
sailorbridge supervise only for agents not already driven by another
listener/supervisor.
Context auto-checkpoint (tmux transport, claude/codex): the supervisor reads
the context percentage from the pane every ~20s and, at or above the agent's
configured context_checkpoint_percent (console setting; 0 disables,
default 75), posts one self-addressed SAVE-CONTEXT. The mailbox loop then
cues the save_ctx skill, waits for SAVE-CONTEXT-DONE, and replays
/clear plus the resume_ctx cue. Threshold changes made in the console
take effect live through the reload_context_autopilot resolve command.
Semantics match the engine listener's autopilot (same thresholds, same
armed-below/fire-once water-mark).
On that same ~20s observation cadence, current clients report the percentage
actually read from the pane (or the engine's self-describing token source) to
the relay's dedicated context-telemetry endpoint. An unavailable reading is
reported as null; the conservative +5 fallback bias remains checkpoint-only
and is never shown as live usage. This display telemetry does not participate
in checkpoint, relaunch, liveness, or scheduling decisions.
Idle pre-save (tmux transport, claude by default): the same loop watches for a
member that has gone quiet. Once the pane is visibly idle and the newest real
mailbox activity is older than idle_presave_min (console setting; 0
disables, empty takes the engine default — claude 50 minutes, codex off) but
still younger than the engine's prompt-cache TTL, the supervisor re-samples pane
and activity and then posts one SAVE-CONTEXT carrying
source=listener-idle-presave. That checkpoint is ACK-only: the handoff is
written, but nothing clears, resumes, or relaunches — a member that merely sat
idle keeps its live context. One archive per span of activity, a 30-minute
cooldown after each attempt, and every unknown (capture failure, busy pane,
missing or future activity timestamps) is a no-op. Tables are pinned against the
engine listener by tooling/e2e/idle-presave-parity.test.ts.
Default transport selection:
| CLI | Default transport | Default command |
| --- | --- | --- |
| claude | tmux | claude |
| codex | tmux | codex |
claude/codex default to tmux so the agent runs the real first-party CLI
(covered by the captain's Claude/Codex subscription and attachable). When tmux
is the resolved transport and no --tmux-session is given, host-started agents
use readable names such as sb-<team>-<role>; same-host collisions try the
default name, then the captain number, then the agent name, followed by numeric
suffixes -1 through -100. If every candidate is occupied, startup fails
explicitly. Direct sailorbridge supervise has no team/role context, so it
falls back to a readable sb-<agent> name.
--tmux-session overrides all derived names.
When tmux is the resolved transport but no tmux binary is found, the client
fails fast or asks before installing it:
- default path (no
--transport) → resolves to tmux and fails fast with a platform install hint. - explicit
--transport tmuxon an interactive TTY → prompts:[1]install tmux (macOSbrew/ Linuxapt-get/dnf/pacman, asking for confirmation and showing the exact command — includingsudo— before running it), or[2]cancel. - explicit
--transport tmuxwithout a TTY (e.g. CI) → fails fast with the platform install hint. It never installs silently or downgrades the transport.
--transport tmux launches through the tmux driver, using an explicit
--tmux-session when given or otherwise deriving the readable default described
above; pass --tmux-session for direct-supervise collision control. It is the
attach/mirror option because tmux reports attach: true. Explicit
--transport auto is rejected so the public surface stays tmux-only; sdk,
headless, and pty fail fast as next-release transports.
Supervisor relaunch decisions are based only on local process liveness:
driver.on("exit") or isAlive().processAlive === false. Reply liveness is
not reported as relay presence and never triggers relaunch by itself.
CLEAR and WORKFLOW-REFRESH are monitor-handled control messages. They are
claimed and acknowledged by the supervisor after successful control cue delivery;
they are not normal work prompts. For the current tmux transport, CLEAR sends
/clear. Future SDK transports must expose a real session reset or document
their best-effort semantics before they become public.
The supervisor owns mailbox leases for its agent and long-polls with
wait=true and peek=false. Delivery is serial and control-first. If driver
delivery fails, the message is not acknowledged so the relay lease can expire and
requeue it. Surface telemetry is observability-only;
acknowledgement and driver delivery failures remain real errors. Shutdown
aborts the in-flight long poll and never acknowledges a partially delivered
message. Persistent relay errors back off instead of spinning. The independent
resolve consumer is also a real long poll: the relay holds an empty queue for
30 seconds and notifies it when a command is enqueued; an empty timeout has a
2-second local gap. Both mailbox and resolve keep running with capped backoff
across 429/503 responses.
The CLI entrypoint is explicit about ownership:
sailorbridge supervise --cli codex --cwd /repo --i-own-this-agent--i-own-this-agent is required as a runtime collision guard. For tmux launches,
the built-in Claude/Codex default commands include the matching no-prompt flags
unless explicit safety flags request a constrained command. Explicit command argv
is used verbatim and cannot be combined with safety flags.
The runtime fetches the relay monitor record before start/relaunch and uses
session_handle only when should_resume is true and the selected driver
reports resume: true. After every successful start/rebuild it patches the
driver's live handle back to the relay; for tmux this is the final
collision-resolved session name consumed by roster tmux_session_name.
The liveness loop no longer patches relay presence. Relaunch is attempted only from driver.on("exit") or
isAlive().processAlive === false, with capped exponential backoff and a
give-up state after repeated failures. replyAlive never causes a relaunch by itself.
