machine B: listen, and dial A

August 31, 2026 · View on GitHub

███████╗██╗    ██╗██╗██████╗ ███████╗    ██╗  ██╗ ██████╗ ███████╗████████╗
╚══███╔╝██║    ██║██║██╔══██╗██╔════╝    ██║  ██║██╔═══██╗██╔════╝╚══██╔══╝
  ███╔╝ ██║ █╗ ██║██║██████╔╝█████╗█████╗███████║██║   ██║███████╗   ██║
 ███╔╝  ██║███╗██║██║██╔══██╗██╔══╝╚════╝██╔══██║██║   ██║╚════██║   ██║
███████╗╚███╔███╔╝██║██║  ██║███████╗    ██║  ██║╚██████╔╝███████║   ██║
╚══════╝ ╚══╝╚══╝ ╚═╝╚═╝  ╚═╝╚══════╝    ╚═╝  ╚═╝ ╚═════╝ ╚══════╝   ╚═╝

CI License: MIT platforms docs

[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

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.

TransportForFraming
Native messaging (default)Chrome / browser extensionslittle-endian u32 length + JSON body, on stdin/stdout
Local-socket daemon (serve)tmux, emacs, desktop apps, plugins, any languagenewline-delimited JSON (one object per line)
GUI Automation Bus (App::open("zwire"))other suite apps, stryke scripts, shell pluginsNDJSON 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. Created 0600 under a 0700 dir — owner-only, since it exposes exec/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

MessageReply / 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

MessageReply / 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 ~)

MessageReply / 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.

MessageReply / 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)

MessageReply / 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

MessageReply / 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)

MessageReply / 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

MessageReply / 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)

MessageReply / 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)

MessageReply / 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, the hooks_* writers and exec are all irreversible however obvious their opposite looks on paper. A host verb can only be pure, and only when it neither writes, spawns, publishes, nor leaves an OS-visible artifact.
  • A browser.* verb is inverse only when the HUD journal can see it. That journal captures pre-state by observing real chrome.tabs / chrome.windows events — 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 one inverse would 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.

MessageReply / 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"}}
MessageReply / 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)

MessageReply / 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
MessageReply / 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)

MessageReply / 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.

MessageReply / 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_bufferthe paste-buffer surface — the seam between the browser clipboard and the terminal.
{"cmd":"tmux_keys"} · tmux_set_key · tmux_unbind_keythe 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_deletemanage 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