@three-way/daemon
v0.2.2
Published
Threeway wake-up daemon (macOS): hibernating WS listener + notification
Downloads
544
Readme
@three-way/daemon
Threeway wake-up daemon (macOS): a hibernating WebSocket listener that posts a
notification when a message for your coding agent arrives. Installed and
managed by the three-way CLI via
launchd — you normally do not install or run this package directly.
Requirements
Node.js >= 22, macOS (launchd + osascript notifications).
Commands
The join installs a durable copy at ~/.threeway/bin/threeway-daemon.mjs and
usually a threeway-daemon shim on your PATH. Either form works:
| Command | What it does |
| --- | --- |
| threeway-daemon status | The health check. Exits non-zero when nothing is being delivered. |
| threeway-daemon install | Write the launchd plist and load it (starts at login, kept alive). |
| threeway-daemon uninstall | Unload the service and remove the plist. |
| threeway-daemon run | Run in the foreground — what launchd invokes. |
status prints a verdict line first, then the detail behind it:
threeway daemon: running, relay authenticated
plist: ~/Library/LaunchAgents/com.threeway.daemon.plist (installed)
launchd: state = running | pid = 4242
relay: authenticated (last confirmed 2026-09-10T14:02:11.000Z)
--- last 10 log line(s) (~/.threeway/daemon.log; shared with the hook — growth is not liveness) ---The verdict distinguishes five states, because a launchd job can be loaded and dead, or running perfectly and delivering nothing:
running, relay authenticated— healthy; exit 0.NOT DELIVERING— the relay refused this machine's credential. The process is alive and has stopped reconnecting. Exit 1.NOT RUNNING— there is no plist, launchd has no such job, or launchd holds the job but reportsstate = not running. That last one is the common shape after a node upgrade moves the binary the plist is pinned to: the job loads, fails to exec, and gets throttled dead whilelaunchctl printstill succeeds.threeway-daemon installre-pins and reloads it. Exit 1.NOT CONFIRMED— running, but the daemon has not seen the relay accept this credential for hours. It refreshes that record on every answered keepalive, so a stale one means it is not talking to anything. Exit 1.running, relay state not yet recorded— installed and loaded, but no connect or refusal has been observed yet. Exit 0.
The log: ~/.threeway/daemon.log
One plain-text file, <iso-time> <level> <message> per line, rotated to
daemon.log.old at 1 MB (one generation kept). $THREEWAY_DIR/daemon.log
when THREEWAY_DIR is set.
It is shared, despite the name: this daemon, the three-way turn-start
inbox hook, and three-way listen all append to it, and launchd sends the
daemon process's own stdout/stderr there too. So a growing file says nothing
about whether the daemon is alive — only threeway-daemon status does.
What goes in it: connects and disconnects with their reconnect delay, wake events (sender handle and message id — never a message body), notification failures, why the hook stayed silent in a directory, and relay refusals. What never goes in it: bearer tokens and message contents.
Read the end of it first:
tail -50 ~/.threeway/daemon.log
grep -i refused ~/.threeway/daemon.logA revoked credential
A person can revoke this machine's agent from the Threeway dashboard, at which point the relay answers 401/403 to everything this machine presents.
The daemon treats that as terminal, once it is confirmed, and confirmation is deliberately slow. It probes the relay after each socket death, and the loop stops only when two of those probes answer 401, at least a minute apart, spanning at least five minutes — in practice about five minutes of continuous refusal, because the reconnect backoff (1s, 2s, 4s … capped at 60s) puts the first six deaths inside the first 31 seconds and they all count as one observation. A shorter window would have been satisfied by that opening burst alone, which is a relay deploy, not a revocation.
Three more narrowings, for the same reason — nobody is watching this decision:
- 401 only. The relay answers 401 for every refused credential; a 403 on this route came from something in between (a WAF, a proxy, a captive portal), so it neither confirms nor clears.
- A probe that cannot reach the relay is never counted. That is a network verdict, not an auth one.
- Old refusals decay, and a successful connect clears the count outright — including the verdicts of probes still in flight from before it.
When it does stop, it records the verdict in ~/.threeway/auth-state.json
(no token, no hash) so threeway-daemon status and the inbox hook can both
report it, writes one error line to the log, and parks: it deliberately does
not exit, because the plist sets KeepAlive and exiting would have launchd
relaunch it every 10 seconds to fail the same way.
A parked daemon is not a dead end. It re-probes the relay every 15 minutes
and starts itself again if the credential is accepted — a revoke that gets
undone, or a verdict that was wrong, recovers on its own. threeway-daemon
install does the same thing immediately.
The real recovery is a fresh invite plus npx @three-way/cli join --force
<invite-url> on this machine — that rewrites the credentials and bootstraps a
new daemon process. --force is the whole command here. A machine in this
state still has a credentials.json, and a bare join <invite-url> on an
already-joined machine prints (invite URL ignored — this machine is already
joined; --force redeems it) and keeps the dead credential.
--force refuses when another project would be stranded. Every other
project registered on this machine keeps the credential it has, which belongs
to the identity --force replaces, so the join checks them before it redeems
anything: it names each one and the repair for it, and exits without writing.
--strand-others alongside --force joins anyway and prints the same list —
including, where a stranded directory is gone and is not a recorded root of its
own, that no repair command reaches it. It does not refuse when this
machine's credential has already been refused by the relay (a revoked agent,
recorded in auth-state.json): those projects are broken already, so the join
proceeds and just names them and their repairs.
(A revoked per-project credential is a different, narrower thing, and the fix
for that one is three-way disable then three-way enable in the affected
directory — provided that directory is an enabled root. If it is only covered
by a parent root, the first command does the job on its own: disable there
asks the relay about that directory's own credential — the question enable
skips — and replaces a revoked one, leaving the registration in place and the
parent root alone. It still exits 1 and still says the directory is not a root,
because the root it was asked to disable is untouched and the hook keeps
speaking there, so read the sentence it prints rather than the exit code: it
says either that the credential was replaced or confirmed — done — or that
nothing was changed, and then names what stopped it (an unreachable relay and a
deleted directory are two of several), which is what has to change before
re-running here does anything. Note also that disabling a root drops the
registrations of any subdirectory enabled separately beneath it, which enable
does not put back. When neither half of that pair can run, a plain
npx @three-way/cli join in the affected directory — no invite argument —
is the remaining route, after a session in that directory has
reported the refusal: what join reads is the record the hook writes to
auth-state.json when the relay turns that credential down, so a revoke
learned from the dashboard alone is not yet something join can act on (until
then it keeps the per-project token the directory has and says so). Once the
record is there, join puts this machine's own agent token back over the refused
per-project one and narrates the replacement. It does not need --force, and
it does not touch this machine's identity.)
License
MIT
