machine B: listen, and dial A
August 31, 2026 · View on GitHub
███████╗██╗ ██╗██╗██████╗ ███████╗ ██╗ ██╗ ██████╗ ███████╗████████╗
╚══███╔╝██║ ██║██║██╔══██╗██╔════╝ ██║ ██║██╔═══██╗██╔════╝╚══██╔══╝
███╔╝ ██║ █╗ ██║██║██████╔╝█████╗█████╗███████║██║ ██║███████╗ ██║
███╔╝ ██║███╗██║██║██╔══██╗██╔══╝╚════╝██╔══██║██║ ██║╚════██║ ██║
███████╗╚███╔███╔╝██║██║ ██║███████╗ ██║ ██║╚██████╔╝███████║ ██║
╚══════╝ ╚══╝╚══╝ ╚═╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝
[UNIVERSAL LOCAL HOST // SYSINFO · FS · EXEC · PTY · TMUX · KV · OS]
"One pipe. One binary. The whole machine — reachable from anywhere."
zwire-host is a single self-contained Rust binary (~500 KB, no Python, no
psutil) that exposes the local machine to any app over one JSON message
protocol. It began as the Chrome native-messaging host for
zwire's HUD; it is now a
universal local endpoint you can talk to from a browser extension and from
tmux, emacs, desktop apps, plugins, shell scripts, and any language — because it
also runs as a Unix-socket daemon speaking newline-delimited JSON, the one
protocol every tool already has.
It streams live system stats (sysinfo), runs PTY terminals
(portable-pty), crawls and watches/tails the filesystem, execs
commands, runs background jobs that notify on completion, lists/kills
processes, drives the real tmux server the user is already running (over
tmux's own wire protocol, no subprocess), brokers a pub/sub event bus that
federates across a mesh of peered hosts, keeps a per-app key/value store,
and does clipboard / notify / open. Every capability is reachable over every
transport, and the whole thing is also a Rust library so sibling hosts (e.g.
zpwrchrome-host) can embed it.
zwire · zpwrchrome · strykelang
Table of Contents
- [0x00] Overview
- [0x01] Transports
- [0x02] Protocol / Commands
- [0x03] CLI
- [0x04] Library use (embed as a dependency)
- [0x05] Chrome install
- [0x06] Build · Cross-Platform · CI
- [0x07] License
[0x00] Overview
Extensions, editors, and plugins can't read the machine or spawn a shell.
zwire-host does the privileged work once and hands it to everyone: a live
statusbar (cpu / mem / net / battery / temp …), an embedded terminal, a
filesystem crawler, a command runner, a small state store, and a client onto the
GUI Automation Bus so one app can call another's typed verbs — and, in the other
direction, it publishes what the browser is currently rendering as typed
state any app on that bus can read. Shipping it as
one static Rust binary means the consuming bundle has zero runtime
dependencies — no system Python, no pip install psutil, nothing to break on
a fresh machine.
[0x01] Transports
All three transports feed the same dispatcher and the same per-connection session, so every command below — including the ones that stream — works over any of them.
| Transport | For | Framing |
|---|---|---|
| Native messaging (default) | Chrome / browser extensions | little-endian u32 length + JSON body, on stdin/stdout |
Local-socket daemon (serve) | tmux, emacs, desktop apps, plugins, any language | newline-delimited JSON (one object per line) |
GUI Automation Bus (App::open("zwire")) | other suite apps, stryke scripts, shell plugins | NDJSON zgui-bridge frames: {"t":"call"…} in, {"t":"reply"…} / {"t":"event"…} out |
A bus connection owns a real session, so a stream (sysinfo_start,
fs_watch, fs_tail, meter_stream), a subscription (sub) and a
terminal (pty_*) keep answering on the socket that asked for them, as
event frames, and hanging up tears them down. Anything the host emits unasked
is an event; anything answering a frame is a reply carrying that frame's
id.
The daemon uses each platform's native local IPC — a Unix domain socket on macOS/Linux and a named pipe on Windows — so it runs everywhere your apps do:
- macOS / Linux —
$ZWIRE_HOST_SOCK, else$XDG_RUNTIME_DIR/zwire-host.sock, else~/.zwire/host.sock. Created0600under a0700dir — owner-only, since it exposesexec/fs/pty. - Windows —
$ZWIRE_HOST_SOCK, else the per-user pipe\\.\pipe\zwire-host-<user>. (--socket <name>overrides the pipe name.)
Requests may carry an id; it is echoed on the matching reply so a client can
multiplex many in-flight requests, streams, and terminals over one connection.
[0x02] Protocol / Commands
Discovery & state
| Message | Reply / effect |
|---|---|
{"cmd":"hello"} | {ok,host,version,os,arch,pid,caps:[…]} — feature-test the host. |
{"cmd":"hostinfo"} | one-shot machine facts: os, arch, kernel, hostname, user, cpus, mem, LAN ip. |
{"cmd":"get"} | the shared theme: {scheme, ui, palette, schemes} — what every themed app reads. |
{"cmd":"theme","scheme":"matrix"} | write the shared theme (any of scheme / ui / palette / schemes): persisted, published to subscribers, broadcast to peers. Also accepted commandless ({"scheme":"matrix"}) — the command form is what a bus client, whose frames always name a verb, can reach. |
{"cmd":"kv_set","app":"myapp","key":"cfg","value":{…}} | write ~/.myapp/kv/cfg.json. |
{"cmd":"kv_get" / "kv_merge" / "kv_del" / "kv_keys",…} | read / shallow-merge / delete / list keys. |
System stats
| Message | Reply / effect |
|---|---|
{"cmd":"sysinfo_once"} | one {sys:{…}} snapshot. |
{"cmd":"sysinfo_start","interval_ms":2000} | stream {sys:{…}} every interval — cpu · mem · swap · disk · net rate · disk I/O rate · load · uptime · battery · temp · host · LAN/WAN ip. |
{"cmd":"sysinfo_stop"} | stop the stream. |
Filesystem (paths accept a leading ~)
| Message | Reply / effect |
|---|---|
{"cmd":"fs_read","path":…} | {ok,b64,text?}. |
{"cmd":"fs_write"/"fs_append","path":…,"text"|"b64":…} | write / append. |
{"cmd":"fs_list","path":…} | one-level {entries:[{name,dir,size}]}. |
{"cmd":"fs_walk","path":…,"depth"?,"ext"?,"dirs_only"?,"contains"?} | recursive crawl → {count,truncated,entries:[{path,name,dir,size}]}. |
{"cmd":"fs_stat" / "fs_mkdir" / "fs_rm","path":…} | stat / mkdir -p / remove (recursive for dirs). |
File browser ops — the wider surface behind a graphical file manager (src/fsx.rs).
These reply {ok:true,data:…} / {ok:false,err:…}; argument names are snake_case.
| Message | Reply / effect |
|---|---|
{"cmd":"fs_list_dir","dir_path":…,"include_hidden"?} | {entries:[{name,path,isDir,size,sizeFormatted,modified,created,ext}],path}, directories first. |
{"cmd":"fs_list_subdirs","dir_path":…,"include_hidden"?} | [{name,path}] — directories only, for a tree pane. |
{"cmd":"fs_get_info","path":…} | kind · recursive size + item count · mtime/ctime/atime · mode octal + ls -l string · uid/gid · symlink target. |
{"cmd":"fs_folder_size","folder_path":…,"timeout_ms"?} | {bytes,files} — bounded recursive walk. |
{"cmd":"fs_disk_usage","path":…} | {total,available,used,usedPct,mount} for the mount holding the path (needs sysinfo-caps). |
{"cmd":"fs_xattrs","path":…} | [{name,size}] extended attributes (Unix). |
{"cmd":"fs_git_status","dir_path":…} | {<abs path>: "<XY>"} porcelain codes; empty outside a repo. |
{"cmd":"fs_hash","path":…,"algos"?} | {path,size,digests:{sha256}} — streamed SHA-256. |
{"cmd":"fs_grep","root":…,"needle":…,"case_insensitive"?,"max_results"?} | [{path,line,text}]; skips dotdirs, binaries and files > 4 MiB. |
{"cmd":"fs_find_duplicates","dir":…,"recursive"?,"min_size_bytes"?} | [{hash,size,paths}] — grouped by content, biggest reclaim first. |
{"cmd":"fs_compare_dirs","dir_a":…,"dir_b":…} | {onlyInA,onlyInB,different}; equal-size files are confirmed by hash. |
{"cmd":"fs_diff","path_a":…,"path_b":…} | [{tag,aLineStart,aLineEnd,bLineStart,bLineEnd,text}] unified text diff. |
{"cmd":"fs_compress","paths":[…],"archive_path":…} | write a deflate .zip. |
{"cmd":"fs_extract","archive_path":…,"dest_dir":…} | read .zip / .tar / .tar.gz / .tgz into a NEW directory (zip-slip guarded). |
{"cmd":"fs_read_file_base64" / "fs_read_head" / "fs_read_head_bytes","file_path":…,"max_bytes"?} | capped whole-file base64 / head as text / head as raw bytes. |
{"cmd":"fs_create_dir" / "fs_create_file","dir_path"|"file_path":…} | create; refuses an existing path. |
{"cmd":"fs_copy_path","src":…,"dest":…} / {"cmd":"fs_duplicate","path":…} | copy a file or tree / make the next free … copy sibling. |
{"cmd":"fs_rename_file","old_path":…,"new_path":…} | rename / move. |
{"cmd":"fs_delete_file","file_path":…} | delete a file, or a directory recursively. |
{"cmd":"fs_move_to_trash","file_path":…} | recoverable delete via the OS trash. |
{"cmd":"fs_secure_delete","file_path":…} | zero the bytes, fsync, then unlink. Refuses directories. |
{"cmd":"fs_touch","file_path":…} | create if absent, then set atime + mtime to now. |
{"cmd":"fs_chmod","path":…,"mode_octal":…} | set permission bits (Unix). |
{"cmd":"fs_symlink_retarget","path":…,"new_target":…} | repoint an existing symlink. |
{"cmd":"fs_home_dir"} | the home directory ~ expands to. |
fs_git_status shells out to git — the one op here that calls an external
program, because git already answers that question exactly and a second
implementation would be a second, drifting answer. Everything else is in-process.
File watching (streaming observers, keyed by id)
| Message | Reply / effect |
|---|---|
{"cmd":"fs_watch","id"?,"path":…,"recursive"?,"interval_ms"?} | stream {"ev":"fs","kind":"created|modified|removed","path":…} on change. |
{"cmd":"fs_tail","id"?,"path":…,"from"?:"start"} | stream {"ev":"line","data":…} as lines are appended (tail -f; survives rotation). |
{"cmd":"watch_stop","id"?} / {"cmd":"watch_list"} | stop an observer / list active ones. |
Exec & OS
| Message | Reply / effect |
|---|---|
{"cmd":"exec","program":…,"args":[…],"cwd"?,"env"?,"stdin"?} | run to completion → {ok,code,stdout,stderr} (base64). |
{"cmd":"open","target":…} | open a path/URL with the OS default handler. |
{"cmd":"clipboard_get"} / {"cmd":"clipboard_set","text":…} | read / write the clipboard. |
{"cmd":"notify","title":…,"body":…} | desktop notification. |
Background jobs (long-running commands; run in the daemon, survive the connection)
| Message | Reply / effect |
|---|---|
{"cmd":"job_start","program":…,"args":[…],"label"?,"notify"?} | spawn a background job → {ok,job:<id>} immediately; fires a desktop notification on completion (notify, default true). |
{"cmd":"job_list"} | non-destructive status of every job → [{id,label,running,code}]. |
{"cmd":"job_result","id":N} | fetch+remove one finished job → {code,stdout,stderr} (base64). |
{"cmd":"job_poll"} | drain all finished jobs at once. |
Process tools
| Message | Reply / effect |
|---|---|
{"cmd":"ps","filter"?,"limit"?} | processes by memory → [{pid,name,mem,cpu}]. |
{"cmd":"kill","pid":N,"signal"?} | signal a process (term default, or kill). |
{"cmd":"which","program":…} | resolve a program to its $PATH location → {path}. |
Pub/sub event bus (the host as a coordination hub across apps)
| Message | Reply / effect |
|---|---|
{"cmd":"sub","topic":…} | subscribe this connection; thereafter receive {"ev":"pub","topic":…,"data":…} frames. |
{"cmd":"unsub","topic":…} | stop receiving a topic. |
{"cmd":"pub","topic":…,"data":…} | fan a message out to every subscriber → {ok,delivered:N}. |
The daemon itself publishes on scheme / ui whenever those change, so a
subscribed app (a HUD, an editor) gets live theme sync without polling.
Transactional automation (a chain of automation-bus calls that unwinds itself on failure)
| Message | Reply / effect |
|---|---|
{"cmd":"txn_begin","txn"?:N} | open a transaction → {ok,txn}. While one is open, every reversible call made on the bus is journaled. |
{"cmd":"txn_commit","txn":N} | close it, discarding the journal — nothing is compensated → {ok,txn,steps}. |
{"cmd":"txn_abort","txn":N} | compensate every journaled step in reverse order, then close → {ok,txn,steps,undo}. Fires the txn-aborted hook event. |
Each verb declares a reversibility class, published as rev on the automation
surface: inverse (a compensation exists), pure (reads only — runs but is not
journaled), or irreversible (the default). Calling an irreversible verb while
a transaction is open is refused at call time with verb not reversible: <id>,
so a chain fails fast at the top instead of stranding itself half-undone at abort
time.
Most of the surface is irreversible, deliberately. The bus is scriptable
everywhere; it is transactional only where a compensation genuinely exists.
- No host command is ever
inverse. The journal records a step's verb and args, never its pre-state, so there is nothing to restore from —fs_write,kv_set,clipboard_set, thehooks_*writers andexecare all irreversible however obvious their opposite looks on paper. A host verb can only bepure, and only when it neither writes, spawns, publishes, nor leaves an OS-visible artifact. - A
browser.*verb isinverseonly when the HUD journal can see it. That journal captures pre-state by observing realchrome.tabs/chrome.windowsevents — created, removed, moved, detached, pinned, muted, url, activated, zoomed, window created — and replays a matching set of inverse ops. Verbs whose effects fall outside it (window state and bounds, tab groups, downloads, bookmarks, the reading list, browsing data, extension management) journal nothing, so classing oneinversewould produce an abort that reports a clean revert having restored nothing.
Every verb that is deliberately left irreversible is listed with its reason in
tests/rev_coverage.rs, and the test there fails if a verb is added to the
surface without being classified or written down — so the table cannot quietly
fall behind the surface it describes.
Compensation for browser.* verbs is replayed by the HUD service worker, because
only it can read the live browser — a browser.* forward call is fire-and-forget
across the native port and its reply carries a delivery count, not a browser
result. The two halves are joined by one thing: the host stamps _txn and _seq
onto every journaled action it forwards, and the worker files that step's
pre-state under the same key. An abort then forwards a single browser.undo
frame carrying the whole reversed step list, so an N-step unwind is one
native-messaging round trip rather than N.
Every forwarded action also carries a unique, monotonic _n. The same action
reaches the worker over more than one transport (the zbus.action subscription
and the kv the stryke_run reply piggybacks), and the worker runs each _n
exactly once — so a chain is never dropped when only one transport is live, and
never doubled when both are.
Suite bus client (src/suite.rs — calling the OTHER apps on the GUI Automation Bus)
The automation bus above makes this host reachable as App::open("zwire"). These
four commands are the mirror leg: they dial another running app's socket
($XDG_RUNTIME_DIR/zgui/<app>.sock, else $TMPDIR/zgui, else /tmp/zgui; the named
pipe \\.\pipe\<app>.sock on Windows) and speak the same NDJSON frames from the client
side. That is what lets the browser drive the rest of the suite — a page trigger, a ⌘K
step or a pane pipeline naming a verb in zcite / zreq / zpdf / … and getting its
return value back.
| Message | Reply / effect |
|---|---|
{"cmd":"suite_list"} | {ok,apps:[…],probed:N} — the apps actually running, each proven by a dial. probed counts socket entries seen, so "nothing running" is distinguishable from "nothing installed". |
{"cmd":"suite_verbs","app":"zcite"} | {ok,result:{app,verbs,state,events}} — that app's typed surface, including each verb's rev class where it publishes one. |
{"cmd":"suite_call","app":"zcite","verb":"item.add","args":{…}} | {ok,result:<value>} — invoke a verb and return its value; {ok:false,err} if the app is not running or refuses. |
{"cmd":"suite_get","app":"zcite","state":"selection"} | {ok,result:<value>} — read one of that app's state queries. |
A socket file is not a running app: the socket directory keeps entries from
processes that died without unlinking, so enumeration dials every candidate rather than
listing the directory. A bus name containing a path separator or .. is refused before
any dial, because the name becomes a filename. One connection per exchange — the peer's
bridge journals a transaction against a held connection, so a shared long-lived
connection would silently enlist unrelated calls in whatever transaction a previous
caller left open.
suite_list / suite_verbs / suite_get are pure; suite_call is
irreversible and is refused inside an open transaction. The write lands in another
process with its own journal, and this host records a verb and its args, never the
peer's pre-state — so an "inverse" here would be a guess. Cross-app rollback is a real
thing with an existing owner: the suite's saga coordinator enlists each participant
under that participant's own transaction and fans abort back out, so every app
compensates through the inverse it declared. A chain that needs all-or-nothing across
apps asks for it through suite_call instead of having this host invent a second
coordinator.
The rendered page as typed state (src/page.rs — the browser answering, not acting)
suite_* above lets the browser reach OUT; browser.* lets a script reach IN and act.
This is the third direction: a script reads what the browser is rendering right now —
after the login, after the JavaScript, inside the session the user is actually in — as
typed state on the same bus, with the same frames it uses for any other app.
{"t":"get","id":1,"state":"page.tables"} // the tables on the active tab, as rows of cells
{"t":"call","id":2,"verb":"page.extract","args":{"selector":"h2 a","attr":"href"}}
{"t":"call","id":3,"verb":"page.assert","args":{"state":"page.text","op":"contains","value":"Order confirmed"}}
| Message | Reply / effect |
|---|---|
{"cmd":"page_states"} | {ok,states:[…],verbs:[…],ops:[…],serving} — the projection catalogue, the verbs, and the assertion vocabulary. |
{"cmd":"page_get","state":"page.links","args":{…}} | {ok,state,value} — one typed projection of the live DOM. args accepts tabId / urls (a regex naming a background tab) / timeout_ms. |
{"cmd":"page_get","state":"page.assert","args":{"state":…,"op":…,"value":…}} | project and test in one call: {ok:true,pass:true}, or {ok:false,pass:false,err} when the page does not satisfy it. |
{"cmd":"page_get","state":"page.witness","args":{"state":…}} | declare a premise of the open transaction — see below. {ok,witness,digest}, or {ok:false} when there is no transaction to gate. |
{"cmd":"page_get","state":"page.batch","args":{"reads":[{"state","args"},…]}} | several projections in one injection: one {ok,value} / {ok:false,err} per read, in order, from a single DOM turn. |
{"cmd":"page_serve"} | the HUD worker claiming its host process as the browser's page endpoint (binds zgui/zwire-page.sock). |
{"cmd":"page_reply","qid":N,…} | the HUD worker delivering one answer. Never sent by anything else. |
Projections: page.url · page.title · page.text · page.links · page.headings ·
page.tables · page.forms · page.meta · page.selection, plus page.extract for
anything the fixed set does not name; page.assert tests one, page.witness pins one as a
premise, and page.batch reads several at once. Assertion ops: contains · not_contains ·
equals · empty · nonempty · count_at_least · count_at_most.
Two things are deliberately absent. There are no page writes — mutation already
exists as browser.* verbs with a compensation journal behind them, and a second,
unjournaled write path would be a way to change the browser that txn_abort could not
unwind. And page.forms publishes a form's shape — action, method, field names and
types — and never a field's value, because autofilled credentials are on the page too.
Every page.* verb is therefore pure: safe to read inside an open transaction, which
is what lets a postcondition decide whether that transaction commits — and a premise decide whether
it may still stand.
Premises: the facts a chain was decided on, re-checked at commit (src/witness.rs)
A postcondition tests the page the chain produced. It cannot see the other window — the one between reading the page and acting on it, during which the user, a timer, a server push or a second agent can change what the chain was reasoning about:
{"t":"begin","id":1,"txn":9001}
{"t":"call","id":2,"verb":"page.witness","args":{"state":"page.tables"},"txn":9001}
{"t":"call","id":3,"verb":"page.witness","args":{"state":"page.links","op":"count_at_least","value":"1"},"txn":9001}
{"t":"call","id":4,"verb":"browser.newTab","args":{},"txn":9001}
{"t":"commit","id":5,"txn":9001}
// ← {"ok":false,"conflict":true,"aborted":true,"steps":1,
// "violations":[{"state":"page.tables","reason":"changed","err":"page.tables changed: 6f… → 91…"}]}
page.witness declares a projection as a premise: with an op it must still satisfy that
predicate at commit, without one it must be byte-identical. txn_commit re-reads the whole premise
set and, if any of them stopped holding, turns the commit into an abort — the journaled inverses
replay and the browser ends where it started. A premise that cannot be re-read at all (browser
closed, tab gone, origin denied) refuses the commit too: "nobody could confirm it" is not "it held".
Premises are declared, not inferred. A chain's own steps navigate, so an implicitly captured read set would conflict with itself on nearly every real chain; an explicit premise states something the author means. A transaction with no premises commits exactly as before — one map removal, no IPC.
Validation is one round trip. Re-reading premises one at a time would let the page move between
the answers, so a set could pass in a state the page was never simultaneously in. The whole set goes
out as a single page.batch, which the HUD worker answers with one chrome.scripting.executeScript
per target tab — a synchronous body, so every projection in it comes from one DOM turn. Reads
addressing different tabs are grouped and injected concurrently; two tabs are two renderer processes,
so per-tab atomicity is what is claimed and cross-tab simultaneity is not.
Mechanically a query is a rendezvous, because the DOM is a process away. Only one
host process is attached to the browser (the long-lived connectNative one), and it is
almost never the process that owns zgui/zwire.sock — so the attached process binds a
second endpoint, zgui/zwire-page.sock, and every other host process forwards to it
with the ordinary bus client. It publishes the query on zbus.query with a correlation
id, the HUD worker projects the tab and answers page_reply, and the waiting call wakes
on that exact id. Nobody polls. A closed browser fails the dial immediately — "not
attached" rather than a five-second wait — and a page read always answers on its own
thread, because in the attached process the query and its answer share one connection.
stryke hooks & scripting (runs stryke via a bundled sidecar — the browser never spawns it directly)
| Message | Reply / effect |
|---|---|
{"cmd":"hooks_events"} | lifecycle-event catalog + action verbs → {events:[…],actions:[…]}. |
{"cmd":"hooks_save","hook":{name,event,enabled,timeout_ms?}} | create/update a hook (scaffolds a starter <id>.st) → {ok,hook}. |
{"cmd":"hooks_list" / "hooks_delete" / "hooks_set_enabled" / "hooks_get_script" / "hooks_set_script" / "hooks_script_path",…} | manage hooks + their stryke scripts. |
{"cmd":"hook_fire","event":…,"payload":{…}} | run every enabled hook bound to event; each script's {actions:[…]} is dispatched (notify/open/exec/pub). |
{"cmd":"hooks_test_run","id":…,"sample":{…}} | dry-run a hook (parses actions, does not dispatch). |
{"cmd":"stryke_run","code":"p 1+1","stdin"?} | run inline stryke (stryke -E) → {ok,stdout,stderr,code,timedOut}. |
{"cmd":"stryke_lsp_start" / "stryke_lsp_send" / "stryke_lsp_stop",…} | drive a per-connection stryke --lsp server; frames arrive as {"ev":"stryke-lsp-rx","message":…}. |
stryke is resolved via ZWIRE_STRYKE → the sibling next to this host (the
bundled sidecar) → $PATH → cargo/Homebrew, so an installed zwire needs no
system stryke.
Host-to-host peering (a mesh of daemons across machines)
Run daemons with TCP peering and the bus federates across machines — a
publish (or a scheme/ui change) on one host reaches subscribers on every
peer — and you can run a request on another host:
# machine A: listen for peers
zwire-host serve --tcp 0.0.0.0:7420 --token SECRET --name laptop
# machine B: listen, and dial A
zwire-host serve --tcp 0.0.0.0:7420 --token SECRET --name desktop --peer A.local:7420
| Message | Reply / effect |
|---|---|
{"cmd":"peers"} | {self, peers:[…]} — connected peers. |
{"cmd":"peer_connect","addr":"host:port"} | dial a new peer at runtime. |
{"cmd":"remote","peer":"host:port","request":{…}} | run a request on another host → {reply:…}. |
Inbound TCP is gated by a shared --token (or $ZWIRE_HOST_TOKEN): a connection
must auth / peer_hello with it before anything privileged. Local Unix-socket
clients are trusted and never need it. Federation is single-hop (a forwarded
event is delivered locally but not re-forwarded), which covers star and
fully-meshed topologies without loops.
PTY terminals (multiplexed by id)
| Message | Reply / effect |
|---|---|
{"cmd":"pty_spawn","id"?,"rows":R,"cols":C,"shell"?,"args"?,"cwd"?,"env"?} | spawn a shell; stream {ev:"output","b64":…} (and pty:id when keyed). |
{"cmd":"pty_write","id"?,"data"|"b64":…} | feed input. |
{"cmd":"pty_resize","id"?,"rows":R,"cols":C} / {"cmd":"pty_kill","id"?} | resize / kill; kill emits {ev:"exit"}. |
Real tmux (src/tmuxops.rs — the multiplexer in the user's terminal, feature ztmux, Unix)
The PTY commands above spawn a shell this host owns. These drive the tmux server the
user is already running, through
ztmux-core: tmux's client/server wire
protocol (OpenBSD imsg framing, protocol version 8) spoken straight to the server
socket — not control mode, and no tmux subprocess per call. The socket probe prefers
a running ztmux server over upstream
tmux when both are present.
Reads answer {ok:true,result:<value>} — uniform so one caller shape reads every
command; ok:true means the call worked, and whether a server is running is the
payload's own running flag. Writes answer {ok:<bool>} with an err when the server
refused. The command names and arguments are the same vocabulary
zterminal uses to drive the same
crate, so a verb learned in one app names the same call in the other.
| Message | Reply / effect |
|---|---|
{"cmd":"tmux_status"} | {ok,running,socket,attached,bin} — is there a server, which socket, and which binary would start one. Ask this before publishing tmux UI. |
{"cmd":"tmux_tree"} | sessions → windows → panes, each pane with a text preview. |
{"cmd":"tmux_sessions"} / {"cmd":"tmux_panes"} | dashboard summary (server + clients + totals) / a flat pane list with each pane's running command. |
{"cmd":"tmux_search"} | every pane's visible text as one searchable corpus. |
{"cmd":"tmux_capture","pane":"%0"} | that pane's visible text. |
{"cmd":"tmux_send","panes":["%0",…],"text":"…","enter"?:true} | type into a pane selection (the broadcast primitive). |
{"cmd":"tmux_focus","session":"…","window"?:"…","pane"?:"…"} | move the user's attention there. |
{"cmd":"tmux_sync","window":"s:0","on":true} | synchronize-panes on that window. {"cmd":"tmux_broadcast_list"} lists the windows and their sync state. |
{"cmd":"tmux_run","args":[…]} / {"cmd":"tmux_command","args":[…]} | any tmux command line: fire-and-forget, or waited with {code,stdout,stderr}. |
{"cmd":"tmux_options"} / {"cmd":"tmux_set_option","scope":"server","name":"…","value":"…"} | read / write server + session + window options. |
{"cmd":"tmux_buffers"} · tmux_buffer · tmux_set_buffer · tmux_delete_buffer · tmux_paste_buffer | the paste-buffer surface — the seam between the browser clipboard and the terminal. |
{"cmd":"tmux_keys"} · tmux_set_key · tmux_unbind_key | the key tables, readable and writable. |
{"cmd":"tmux_export_state"} / {"cmd":"tmux_import_state","state":{…}} | the whole server's options + bindings, out and back in. |
{"cmd":"tmux_snap_save","name":"work","contents"?:true} | native session save (layout, cwd, full command line, optionally pane contents) under <zwire state dir>/tmux-snapshots. |
tmux_snap_list · tmux_snap_detail · tmux_snap_rename · tmux_snap_delete | manage the saved set. |
{"cmd":"tmux_snap_restore","name":"work","relaunch"?:false,"processes"?:"…"} | restore it, starting a server if none is running. |
The reads are pure; every write is irreversible and refused inside an open
transaction — keystrokes land in a live shell, and option / binding / buffer writes
overwrite server state with no pre-state captured anywhere. A build without the feature
(or on Windows, where the crate's Unix socket transport has no counterpart) answers
tmux support not built into this host rather than unknown_cmd, so a caller can tell
a wrong build from a typo. caps() advertises tmux when the support is compiled in.
Legacy zwire scheme/ui (unchanged): {"cmd":"get"} (replies with version +
scheme + ui), {"scheme":"matrix"}, {"ui":{…}} bridge ~/.zwire/hud-scheme
~/.zwire/hud-ui.json.
[0x03] CLI
zwire-host serve & # run the socket daemon
zwire-host call '{"cmd":"hostinfo"}' # one request, one reply
zwire-host call '{"cmd":"fs_walk","path":"~/src","ext":"rs"}'
echo '{"cmd":"exec","program":"git","args":["status"]}' | zwire-host call
zwire-host call --stream '{"cmd":"sysinfo_start"}' # keep printing frames
From any tool that can write a line to the endpoint — no client library
needed. zwire-host call is the portable path; or connect to the socket/pipe
directly:
# macOS / Linux — raw Unix socket
printf '{"cmd":"sysinfo_once"}\n' | nc -U ~/.zwire/host.sock
# any platform — via the bundled client
zwire-host call '{"cmd":"sysinfo_once"}'
[0x04] Library use (embed as a dependency)
The crate is a library too (zwire_host), so sibling hosts can pull it in to
crawl and exec without re-implementing anything:
[dependencies]
zwire-host = { git = "https://github.com/MenkeTechnologies/zwire-host" }
use zwire_host::api;
// crawl the filesystem
for e in api::walk("~/src", Some("rs")) {
println!("{}", e.path.display());
}
// run a command, get bytes back
let out = api::exec("git", ["status", "--porcelain"]).unwrap();
println!("exit {:?}: {}", out.code, out.stdout_str());
Or drive the whole dispatcher yourself over any transport with
zwire_host::{Peer, Session}, or just delegate main to
zwire_host::run(std::env::args().skip(1).collect()).
[0x05] Chrome install
Point a native-messaging host manifest's path at the binary and list the
allowed extension origins:
{ "name": "com.zwire.hud", "type": "stdio",
"path": "/abs/path/to/zwire-host",
"allowed_origins": ["chrome-extension://<id>/"] }
Drop it in the browser's NativeMessagingHosts/ directory (or the profile's).
zwire's scripts/localinstall.sh builds this binary and wires the manifest
automatically when packaging the .app.
[0x06] Build · Cross-Platform · CI
cargo build --release # -> target/release/zwire-host (~500 KB)
cargo test # exercises the protocol over both transports
sysinfo and portable-pty abstract the OS, so the same source builds for
macOS · Linux · Windows. Both transports work on all three: native messaging
everywhere, and the serve/call daemon over Unix domain sockets on macOS/Linux
and named pipes on Windows (via interprocess, a Windows-only dependency).
Battery reporting is native on every platform: pmset on macOS,
/sys/class/power_supply on Linux, and GetSystemPowerStatus on Windows —
absent on machines with no battery (desktops, VMs), where the segment is omitted.
CI runs the four canonical polish gates on Ubuntu + macOS + Windows:
cargo fmt --all --check
cargo clippy --all-targets -- -D warnings
cargo doc --no-deps # RUSTDOCFLAGS=-D warnings
cargo test
[0x07] License
MIT © MenkeTechnologies