triaged 0.2.0

Long-running daemon that owns Triage terminal session state and serves a built-in web client and WebSocket API for PIN-paired remote attach.
docs.rs failed to build triaged-0.2.0
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

triaged

Persistent daemon process that manages terminal session state, PTY multiplexing, and canonical VT performance structures for Triage, the attention-routing terminal supervisor.

The daemon runs persistently in the background, keeping terminal scrollbacks, layout grids, and active PTY handles alive even when no clients are attached.

It runs on macOS, Linux, and Windows. The local control plane uses a Unix domain socket on macOS/Linux and a named pipe on Windows, and triaged can register itself to start at login on each platform (see Running as a background service). Terminal sessions run on ConPTY on Windows and on a standard PTY elsewhere.

Installation

cargo install triaged

Building from source needs a nightly toolchain on aarch64. The cera inference engine behind the session summarizer uses unstable NEON intrinsics there, and currently requires 1.99.0-nightly or newer (nightly-2026-07-08 and later). On an older toolchain the build fails inside cera with a wall of E0658 errors rather than a clear message. rustup toolchain install nightly and retry with cargo +nightly install triaged. The unstable intrinsics are gated to aarch64, so x86-64 does not hit this. Prebuilt binaries below need none of this.

Or grab a prebuilt binary instead of compiling: releases after v0.1.6 attach a Triage-cli-<os>-v<version> archive (.tar.gz for macOS/Linux, .zip for Windows) to the Releases page, containing the triaged, triage, and triage-mcp binaries — unpack it and put them on your PATH. The bundled triaged already embeds the web client. Each archive is signed and checksummed — see Verifying a download.

Architecture. Each archive is built on its GitHub-hosted runner: the macOS binaries are Apple Silicon (arm64) (macos-latest), Linux and Windows are x86-64. On a different architecture (e.g. an Intel Mac), install via cargo install instead.

Running the Daemon

Start the persistent supervisor process:

triaged

Running as a background service

Instead of launching triaged by hand, register it to start automatically at login and run in the background:

triaged service install     # register + start now
triaged service status      # is it installed / running?
triaged service stop        # stop it
triaged service start       # start it again
triaged service uninstall   # stop + remove the registration

This installs a per-user service that runs inside your login session — so it can own your interactive terminals and the per-user control socket/pipe — using each platform's native mechanism:

Platform Mechanism Location
macOS LaunchAgent (launchctl) ~/Library/LaunchAgents/com.hyeons-lab.triaged.plist
Linux systemd user unit (systemctl --user) ~/.config/systemd/user/triaged.service
Windows Scheduled Task at logon (schtasks) task triaged

install embeds the path of the triaged binary you ran, so install from the binary you want the service to launch (e.g. the one cargo install triaged placed on your PATH).

Linux: surviving logout. A systemd --user service stops when your last session ends. To keep triaged running after you log out, enable lingering: loginctl enable-linger $USER (the install step prints this reminder).


Web Server & Connecting

triaged embeds an HTTP/WebSocket server that, by default, listens on 0.0.0.0:7777 — all interfaces, so the client can connect from another device on your LAN/tailnet. Access is gated by device-code + PIN pairing (require_pairing, default true), and the daemon logs a warning at startup when bound to an unspecified address. A single TCP port serves three things:

  • Web client — a built-in browser UI is served at /. Open http://127.0.0.1:7777 (or http://<daemon-host>:7777 from another device) to attach to your sessions from a browser, no separate install required.
  • WebSocket API — clients (the web UI, the native desktop/mobile clients, or your own integration) attach over ws://<daemon-host>:7777/ws.
  • Pairing approval page — served at /pair (see Pairing below).

Connecting from another device

The default bind (0.0.0.0:7777) is already reachable from other devices on your network. To restrict the daemon to loopback instead, set a narrower bind address in ~/.config/triage/config.toml:

[remote]
bind = "127.0.0.1:7777"   # loopback only; or a specific tailnet IP
require_pairing = true

Then point a client at http://<daemon-host>:7777 (web UI) or ws://<daemon-host>:7777/ws (API). The daemon serves plain HTTP and WebSocket only — it does not terminate TLS, so front it with a reverse proxy (e.g. Caddy or nginx) if you need https/wss. Because the daemon owns live PTYs and scrollback, you can detach and re-attach from any client without disturbing the running shells.

Pairing approval is host-only by default. Even with a routable bind, the /pair approval page is only served to loopback / same-host connections unless you opt in to tailnet identity approval (see Pairing).

Prebuilt desktop clients

Besides the browser UI, native desktop clients are published as artifacts on each GitHub release. Publishing a new triaged version automatically creates the v<version> tag, compiles the Flutter client on each platform, and attaches the builds to the Releases page:

Platform Asset Contains
macOS Triage-macos-v<version>.zip Triage.app
Windows Triage-windows-v<version>.zip triage_client.exe + DLLs + data/
Linux Triage-linux-v<version>.tar.gz triage_client + lib/ + data/

Download the one for your OS, unpack it, and point it at your daemon's address.

These builds carry no OS code-signing certificate. The macOS client is ad-hoc signed; the Windows and Linux clients are unsigned. macOS and Windows will warn before running them — follow the per-platform steps below, or build from source if you'd rather not bypass those protections.

This is unrelated to the minisign signatures carried by assets from releases after v0.1.6 (see Verifying a download). Developer ID signing and notarization for the macOS client are wired up but conditional — see Release signing.

macOS — unzip, then open the app. If Gatekeeper blocks it (the download quarantine flag), clear the flag and retry:

unzip Triage-macos-v<version>.zip
open Triage.app
# Blocked? Drop the "downloaded from the internet" flag and retry:
xattr -dr com.apple.quarantine Triage.app
open Triage.app

Without the terminal you can instead right-click Triage.appOpenOpen in the dialog (only needed once); if it's still blocked, allow it under System Settings → Privacy & Security → Open Anyway.

Windows — unzip and run triage_client.exe. SmartScreen may show "Windows protected your PC" → choose More info → Run anyway.

Linux — extract and run the binary. A Secret Service provider (e.g. gnome-keyring) must be available for the pairing token to persist:

tar -xzf Triage-linux-v<version>.tar.gz
chmod +x triage_client
./triage_client

Verifying a download

Releases after v0.1.6 attach a minisign signature (.minisig) and a .sha256 checksum to every asset — the desktop clients above and the CLI archives alike; releases through v0.1.6 predate this and have neither. Download the asset and both sidecars into the same directory and run the commands from there: the .sha256 records only the asset's basename, so sha256sum -c fails to find it from anywhere else. With <asset> your archive's filename, and minisign from your package manager (brew install minisign, apt install minisign):

cd <the directory you downloaded into>
minisign -Vm <asset> -P RWRinpvI8phW62LgDacQlEXg1JqBPZxvWKROZWAqmyToxr7Pw0e534yH
sha256sum -c <asset>.sha256
# macOS has no sha256sum; use: shasum -a 256 -c <asset>.sha256

A good signature prints Signature and comment signature verified and a trusted comment of the form triage release vX.Y.Z. The public key above is pinned from .github/minisign.pub; see Release signing for the scheme, key custody, and rotation policy.


Pairing

When require_pairing is enabled (the default), every client must complete a one-time PIN exchange before it can attach. This is a device-authorization-style flow, and the approval step is restricted to the daemon host by default. You can also opt in to approval from allowlisted Tailscale identities.

  1. Challenge. A new client connects to /ws and sends a hello with its client_id (and a stored token, if it has one). With no valid token the daemon treats it as unauthenticated, and the client requests a pairing challenge. The daemon returns a short-lived device_code.
  2. Approve. The client surfaces the device code. By default, open the approval URL on the machine running the daemonhttp://127.0.0.1:7777/pair?device_code=<device_code>. By default the /pair page is served to loopback / same-host connections (is_local_pairing_peer). If pair_approval_tailnet_users is configured, it is also served to remote peers whose authenticated Tailscale login is on that allowlist. (Setting pair_approval_trust_local_peers = false — for a loopback reverse-proxy deployment — drops the loopback/same-host shortcut entirely, so even 127.0.0.1 requests must be on the tailnet allowlist or /pair returns 404.) The page validates the device code and displays a one-time, device-bound PIN with an expiry.
  3. Enter the PIN. That PIN is typed back into the waiting client. The client exchanges it (pair(pin, client_id)) and the daemon — after verifying the PIN is bound to that exact client/device — issues a bearer token.
  4. Attach. The client stores the token and reconnects with hello { client_id, token }; the daemon authenticates it and the session attaches. The token is reused on subsequent launches, so pairing is a one-time step per client.

Pairing can be disabled for trusted, isolated setups by setting require_pairing = false under [remote], in which case clients attach without the PIN exchange.

To approve pairing from your own tailnet devices, add the Tailscale login names that may open /pair:

[remote]
# Bind to this host's Tailscale IP so only tailnet traffic can reach /pair
# (see the security caveats below); avoid 0.0.0.0 with tailnet approval.
bind = "100.x.y.z:7777"
require_pairing = true
pair_approval_tailnet_users = ["you@example.com"]
# Optional. Set false when a loopback reverse proxy fronts the daemon, so
# forwarded requests are NOT auto-trusted as local and must pass the allowlist.
# pair_approval_trust_local_peers = false

When a non-local peer requests /pair, triaged runs tailscale whois --json <peer-ip>:<peer-port> on the daemon host, reads UserProfile.LoginName, and compares it with the allowlist. If the tailscale CLI is missing, the lookup times out, or the login is not allowlisted, /pair remains unavailable to that peer. A successful lookup is cached per peer IP for a few seconds; a failed lookup is cached only briefly, so a transient tailscale hiccup won't lock out a legitimate user for long. triaged logs a startup warning if the allowlist is set but tailscale isn't runnable, or if it is bound to an unspecified address.

Security caveats for tailnet approval. Identity is derived from the peer's TCP connection, so deploy accordingly:

  • Bind to the tailnet interface, not 0.0.0.0. With an all-interfaces bind the daemon trusts the connection's source IP as the identity input; bind to the host's Tailscale IP (e.g. bind = "100.x.y.z:7777") so only traffic that actually arrives over tailscale can reach /pair. triaged warns at startup when an allowlist is configured on an unspecified bind.
  • A loopback reverse proxy bypasses the local-peer check. triaged terminates no TLS, so an HTTPS reverse proxy forwards over loopback — every proxied request then looks like a same-host connection. By default such peers are auto-approved; set pair_approval_trust_local_peers = false so that even loopback peers must pass the tailnet allowlist (the proxy must then forward genuine tailnet source IPs, or enforce the allowlist itself).
  • Tagged nodes share one identity. Tailscale reports every tag-owned (non-user) node with the synthetic login tagged-devices, so it is rejected from the allowlist — list real user logins, not shared/service identities.
  • Allowlisted identities can self-approve. An allowlisted device can both request and approve its own pairing, so list only identities you trust to authorize new devices — the allowlist replaces, it does not add to, the "approval requires host access" guarantee.

Update checks

triaged periodically asks the release host for the latest published version and surfaces an "update available" notice — the triage TUI shows a one-row banner naming the newer version. Nothing is ever downloaded or installed automatically; the check is a notification, and upgrading stays your call (cargo install triaged, or a new release archive, followed by a handover).

Defaults, in ~/.config/triage/config.toml:

[update]
check = true          # set false to disable the check entirely
interval_hours = 6    # how often to poll; must be > 0
channel = "stable"    # the only channel supported today

The config parser rejects unknown keys, so a typo here is a startup error rather than a silently ignored setting.


Zero-Downtime Upgrades (Process Handover)

On Unix-like operating systems (including Linux, WSL, and macOS), triaged supports zero-downtime updates. This allows you to upgrade the daemon binary or restart the service without dropping active terminal sessions or interrupting running foreground shells.

How it Works

The upgrade is performed using a robust, low-level Three-Phase Sync Protocol:

  1. Transfer Phase: The new daemon process is launched and connects to the running old daemon over a Unix Domain Socket, initiating a file descriptor transfer using SCM_RIGHTS (sendmsg/recvmsg). The old daemon passes all active master PTY file descriptors and the bound TCP listening socket directly to the new process.
  2. Adoption & Sync Phase: The new daemon adopts the active descriptors, reconstructs the in-memory virtual terminal grids and scrollback history by replaying the session log files, and starts network supervision.
  3. Teardown Phase: Once adopted, the new daemon writes a synchronization byte back to the old daemon. The old daemon gracefully drops its session references (without closing the underlying shells), closes its Unix socket, and exits, completing a zero-downtime handover.

Initiating a Handover

To upgrade or restart the daemon with zero downtime, run the new binary with the --handover (or -U) flag:

triaged --handover

Windows Support (no zero-downtime handover)

The daemon runs natively on Windows: its local control plane uses a named pipe (\\.\pipe\triage-<user>) in place of the Unix domain socket, and terminal sessions run on ConPTY via portable-pty. Clients (TUI, MCP, GUI) connect the same way they do on macOS and Linux.

The one capability that does not cross over is zero-downtime handover — it relies on low-level file-descriptor passing (SCM_RIGHTS) that is native to POSIX platforms. On Windows, upgrading or restarting the daemon falls back gracefully to Triage's robust Session Restore flow, which saves session metadata and restores shell/workspace layout structures on restart.