009 app
Pairing & app
How a machine pairs with a phone, what the daemon keeps alive, what the server is allowed to see, and how pause and stop actually reach a running loop.
The loop runs on your machine, in your checkout, on your disk. The server and the phone app are a window onto it plus a small set of buttons; they decide nothing. They are also optional: an unpaired machine runs the same loop and says so once at startup, not paired — this run stays local. What happens inside a round is the round page's subject. This page is the window.
Pairing
A fresh machine holds no credential, and nothing should hand one to an anonymous caller. Pairing resolves that by making a six-digit code the credential for exactly one claim inside a thirty-second window. POST /v1/pair/start, /v1/pair/claim and /v1/pair/poll are the only public writes in the system; every other route needs a bearer token. Expired, already claimed, never issued and rate-limited all come back as the same string, pair code is invalid or has expired, because distinguishing them would tell a guesser which codes were recently live. Pairing a machine that is already paired is allowed and warns first: the new token replaces the old identity, and the old one stops being the one the server knows.
# default server: https://looply-api-607184ed6acc.herokuapp.com
looply pair
# self-hosted, and a machine name other than the directory's basename
looply pair --server https://looply.example.internal --name mac-mini- The machine calls
POST /v1/pair/startwith a machine name. The server mints the machine id itself (mch_…) — a caller-supplied id would let one machine start a pairing that resolves to another machine's row — and parks the payload under a random six-digit Redis key, NX-guarded so two machines drawing the same digits regenerate instead of one silently repointing the other's displayed code. - The terminal prints the code in block characters, grouped three and three, because it is read off one screen and typed into another — often at arm's length.
pair/startalso returns aqrPayload—looply://pair?code=followed by the identical six digits — so scanning and typing are one claim path with no second credential expiring on its own schedule. Nothing renders that payload as a QR code yet and the app has no scanner, so typing the digits is the only way in today. - You type the code and a name for the phone into the app, which calls
POST /v1/pair/claim. The server creates the machine row, a device row, an owner grant on that machine, and returns a user token to the phone. A phone that arrived holding a token keeps that one: it gets a grant on the new machine and no second credential. - The machine's own token is never returned to the phone. It waits in Redis under
pair:claim:<machineId>and is collected by the machine's nextPOST /v1/pair/poll, exactly once — a second poll gets nothing, not a replay. It then exists in two places:~/.looply/machine.jsonand the token table's hash column.
| Setting | Value |
|---|---|
| Pair code lifetime | 30s (PAIR_CODE_TTL_S) |
| Terminal re-issues at | 25s (DEFAULT_REISSUE_MS), so a dead code is never left on screen |
| Poll for a claim every | 2s (DEFAULT_POLL_MS) |
looply pair gives up after | 10 minutes (DEFAULT_TIMEOUT_MS) |
| Machine may collect its token for | 300s after the claim (PAIR_CLAIM_TTL_S) |
| Failed claims per IP address | 10 per 300s window; the window is fixed from the first miss, and a success clears the counter |
| Code space | 000000–999999, crypto-backed randomInt, leading zeros kept |
| Identity file | ~/.looply/machine.json, written temp-then-rename at mode 0600, outside the repo so a discarded round cannot delete it |
Two tokens, and why both exist
One claim mints two credentials, because two different things talk to the server and they need opposite permissions. The daemon must append to the ledger and beat presence, but it is not a person, holds no grants, and must never be able to approve a push. The phone must be able to approve and curate, but must never hold the machine's ingest credential — the ledger is what every number in the app is computed from, so a leaked phone must not become a way to fabricate a run that never happened. Both are lpt_ plus 32 random bytes in base64url; only the SHA-256 is stored, so a database dump is not a set of working credentials, and comparison is constant-time. A second machine paired from a phone that already holds a token joins that phone's existing user instead of orphaning itself under a fresh account.
| Machine token (`kind: machine`) | Device token (`kind: user`, bound to a device) |
|---|---|
Issued by POST /v1/pair/poll, collectable once | Issued by POST /v1/pair/claim |
Stored in ~/.looply/machine.json | Stored in the phone's keystore via expo-secure-store |
Bound to a machineId; holds no grants | Bound to a deviceId; grants hang off it (owner, operator, viewer) |
| May post events, heartbeat, register projects under itself, pop and ack its own commands | May read status and events, queue commands, curate items, answer questions, write settings, delete a project |
May not curate, answer, approve, or delete a project — RolesGuard refuses a machine token outright | May not ingest events, heartbeat, or pop a command queue — those routes take a machine token and nothing else |
| Reads its own project without any grant | Needs at least a viewer grant to read anything, over HTTP and over the socket alike |
| Revoked: the daemon says so once, stops retrying, and the run continues locally | Revoked: the app 401s, clears the keystore, and drops back to the pairing screen |
The daemon
looply daemon is the long-lived half. It exists because pairing happens before any run does and because runs can be started from a phone, so something has to be connected while the machine is idle. It has three jobs on one shared clock: beat presence, ship the buffered ledger, and collect whatever the phone queued. Its first start in a directory also registers that directory as a project, which is the one irreversible thing startup does, so it asks first; a directory the machine already knows is never asked about. With no TTY — a service unit, a CI job — there is nobody to answer, so it refuses and points at --yes rather than hanging on a prompt no one can see. A paired looply run or looply scan reads the same cache and registers from it without asking; the consent question belongs to the process you leave running unattended. The server keys projects on (machineId, repoPathHash), so a lost local cache converges instead of duplicating.
looply daemon # asks before registering an unknown directory
looply daemon --yes # register without asking; how a service unit runs
looply daemon --once # one heartbeat, then exit — proves the wiring
looply daemon --config path.yaml # default: .looply/config.yaml| Daemon setting | Default |
|---|---|
| Heartbeat | 15s (DEFAULT_HEARTBEAT_MS), against a 45s presence TTL |
| Ledger flush | 5s (DEFAULT_FLUSH_MS) |
| Command poll | 3s (DEFAULT_COMMAND_POLL_MS) |
| Loop tick | the smallest of those three |
| Upload buffer cap | 5,000 events (DEFAULT_BUFFER_LIMIT) |
| Ingest batch | 500 events per POST from the uploader; the server refuses a batch over 1,000. One pop hands over at most 100 command ids |
| HTTP timeout | 20s from the CLI, 15s from the app |
The server going away degrades looply; it never stops it. Events buffer locally and replay in (runId, seq) order, ingest is idempotent on that pair, and each ack carries per-run cursors so anything the server already holds is dropped locally instead of re-sent. The outage is logged once on the transition rather than once per tick, and once past 5,000 buffered events the oldest are discarded with a single line saying so — the phone will show a gap, the ledger on disk stays complete. A 401 is the one failure that is not retried: revocation cannot be fixed by trying again, so the daemon stops, says the run continues locally, and tells you to pair again.
Presence is one fact and nothing else: the Redis key presence:machine:<id> exists. The heartbeat sets it with a 45-second TTL and the daemon refreshes at 15, so one missed beat is survivable and a dead machine reads as offline in under a minute. Machine.lastOnline in Postgres is display-only and is never consulted to decide onlineness, because a recent timestamp next to an expired key is exactly the stale-not-resting case. A machine may only beat for itself; otherwise a caller could keep a dead machine looking healthy indefinitely. The app draws a third state of its own: a run that claims to be live but has produced no event for 3 minutes shows as not responding, which is a different claim from offline.
What the server holds, and what it never sees
| Held | Never sent |
|---|---|
| Item titles, tags, states, and evidence — a path, an optional line number, an optional quote | Source files |
| Ledger events: gate step names with pass/fail and duration, proof verdict and confidence, round outcome, commit sha, insertion, deletion and file counts | Diffs or patches of your code |
| Run and round rows, questions, commands, reviews, exemptions, grants, settings | Credentials, API keys, environment variables |
Project.repoPathHash, a SHA-256 of the lowercased, slash-normalised path | The repository path itself |
| SHA-256 of every bearer token | Any token in plaintext, after the one call that issues it |
Reads are grant-gated and the rule has one implementation shared by the HTTP guard and the WebSocket gateway — the socket once enforced nothing, so any project id opened a live stream of titles, paths and evidence quotes. Unknown and not-visible give the same refusal, so the check is not an existence oracle for project ids. Worth being plain about the current state of the app: it polls GET /v1/projects/:id/status and the events gap every 5 seconds and does not open a socket at all. The gateway is built and unused.
What the phone can do
A command is a row in Postgres plus its id on a Redis list keyed by machine; the list is delivery order, the row is the record. The daemon pops its own queue in FIFO order, acts, and acks only what it actually carried out. Anything it cannot do stays unacked and comes back on the next poll, so a tap that did nothing shows as still pending rather than as a tick for something that never happened. Spawning counts as started only after a 750ms settling window, because Node reports an unrunnable binary a tick after spawn returns. Two of the same job on one repo is reported as started rather than refused — the phone asked for the job to be in progress, and it is. The verb set is fixed at the seven kinds below and POST /v1/commands refuses anything else with the list; there is no verify among them, because a run that finds nothing pickable performs that pass itself before it picks.
| Command | Minimum role, and what the daemon does with it today |
|---|---|
pause | operator — writes pause to control.json |
stop | operator — writes stop to control.json |
start-run | operator — spawns looply run detached, passing --hours only when the payload carries a positive finite number |
scan | operator — spawns looply scan detached |
skip | operator — not carried out; logged and left queued |
approve | owner — not carried out; logged and left queued |
undo | owner — not carried out; logged and left queued |
Pause and stop cannot be delivered in-process. start-run spawns the run detached so that Ctrl-C on the daemon does not take down a round mid-flight, which means the daemon and the run are two processes over one repo with no shared memory. They share exactly one file, .looply/state/control.json, written temp-then-rename so a reader in the other process never catches half a request. It is written even when no run is in progress: the intent outlives the moment, and a pause that only lands if a round happens to be running is a pause you cannot trust.
{
"kind": "pause",
"requestedAt": "2026-08-16T02:14:09.331Z",
"actor": "human:usr_1"
}The runner reads that file between rounds, next to the signal check and for the same reason: the in-flight round always finishes, and run.ended reports command rather than naming whichever budget happened to expire at the same moment. stop ends the run and is consumed the instant it is acted on, since a stop left on disk would silently end tomorrow's run before its first round with nothing on the phone to explain it. pause holds the run alive on its branch with its queue intact — that is the whole difference between the two — re-reading every 5 seconds and emitting one alert with code run-paused so the phone can tell paused from hung. A paused run still answers Ctrl-C and still ends at its budget, because a hold only the phone can lift is a wedged process the moment the phone is flat. Starting a run clears any leftover request first. The third kind, resume, is written rather than the file being deleted; an unlink racing a reader is how a request gets lost.
Only looply daemon polls for commands. A run started by hand streams its ledger to the server but wires no command handler, so pause and stop from the phone will not reach it unless a daemon is also running in that repo. Two more things are worth being plain about as the code currently stands. The app has buttons for pause and stop and for nothing else in the table above, so start-run, scan, skip, approve and undo can only be queued by a caller talking to the API directly. And those two buttons send no actor while POST /v1/commands requires one, so the taps come back 400; the missing field belongs in the app's command() call.