Appearance
Daemon and client protocol
Contract between retype-daemon and its clients. Protocol identifier: retype-ipc-1.
1. Process model
- One daemon per user session. Never a system service, never privileged.
- The daemon is the only writer of the journal and the undo store. It owns the watch registry whenever it is reachable;
retype watchwrites the file only when no daemon answers, because the command does not start one.retype benchwrites the calibration atomically, and the daemon reads it for the next estimate retype daemonruns in the foreground, which is what a systemd user unit or a launchd agent executes- A second daemon exits with
another daemon owns <state dir>. Single instance is enforced by the lock file, never by convention - The CLI and the GUI are equal clients. No method is available to one and not the other
2. Starting and stopping
Clients start the daemon when they need it. Auto-start is the default because a half-running installation is a bug report waiting to happen, and because two writers is the failure it prevents.
Client start sequence:
- Try to connect
- On failure, spawn
retype daemon --detachedand retry with backoff for up to 3 s - If the socket still does not answer, report
E_IPC_UNAVAILABLEwith the spawn log path - Racing clients are harmless: all spawn candidates contend for the lock, one wins, the rest exit, and every client connects to the winner
- The endpoint is published only once the first registration round has started a watcher for every root, or two seconds have passed if a registration is stalled. macOS reports only what happens after its event stream starts, so a rename made in that gap would be lost rather than late
Suppression:
| Control | Effect |
|---|---|
--no-spawn on any command | Fail instead of starting a daemon |
RETYPE_NO_SPAWN=1 | Global suppression, for scripts and CI |
retype daemon stop | Stops the daemon. Any later client that needs one starts it again |
Supervision:
- The daemon reads how it was started from the environment the supervisor sets
- The variables are inherited by every process in the session, so a daemon the CLI started itself clears them: its supervisor would never restart it
- Under a supervisor,
retype daemon stoprefuses withE_IPC_SUPERVISED, telling the user to run the supervisor's stop command, because stopping a supervised daemon is immediately undone by the supervisor --forceoverrides the refusal for the case where the unit is being removedretype daemon startexists for tests, units and scripts that want an explicit start without the foreground
Stop behaviour:
| Trigger | Behaviour |
|---|---|
stop | Refuse new jobs, finish in-flight conversions, flush and fsync the journal, exit 0 |
stop --now | Cancel in-flight jobs, discard partial outputs, restore source extensions, flush, exit 0 |
SIGTERM, SIGINT | Draining stop. A second signal escalates to --now |
| Client disconnect | Nothing. Clients are disposable |
3. Registrations
| Method | Behaviour |
|---|---|
watch.add | Canonicalise the path, reject a missing path, add or update. Idempotent |
watch.remove | Remove by path. Absent is a success |
watch.list | Current registrations with their directory configuration summary |
| Overlap | Registrations are independent; a path inside a registered root is accepted and supplies only its own directory configuration |
| Persistence | The registry is a file, so registrations survive daemon restart and reboot |
Directory configuration is not re-read only on registration. The daemon watches every .retype.toml under a registered root and the global configuration file, so editing either takes effect without re-registering.
4. Transport
| Platform | Endpoint | Authentication |
|---|---|---|
| Linux | <state>/daemon.sock | Directory 0700; SO_PEERCRED, peer uid equals the daemon uid |
| macOS, BSD | <state>/daemon.sock | Directory 0700; LOCAL_PEERCRED |
| Windows | \\.\pipe\retype-<machine>-<user> | Default DACL, owner only; impersonation must succeed |
A Windows pipe can only be impersonated after its client has written, so the first frame is read before the check and handed to the connection loop rather than read twice. A client that connects and sends nothing therefore holds the daemon's single connection slot until it writes or goes away, which is a self-inflicted stall: the DACL admits only the user whose daemon it is.
Windows also hands a child every inheritable handle in its parent, and the pipes a caller uses to read the CLI's output are inheritable by construction. The CLI clears that flag on its own standard handles before it spawns a daemon, so a daemon cannot keep a caller's pipe open after the command exits. The daemon is detached and has no console, and every tool it runs is a console program, so each spawn carries CREATE_NO_WINDOW: without it Windows would give every conversion a console window of its own.
<user> is a short stable per-user token, derived from the user SID where one exists. Windows is usually single-user, and the suffix costs nothing while removing a class of surprises on machines that are not.
Socket paths live in the state directory, with no runtime-directory fallback. Unix socket paths are length-limited (about 104 bytes), so an over-long path is a startup error with a remedy rather than a mystery: the message names the limit and points at RETYPE_STATE_DIR.
No TCP listener exists in any configuration.
5. Framing
Newline-delimited JSON, UTF-8, one object per line, maximum frame 1 MiB. A frame that exceeds the limit closes the connection.
Handshake, required before any other method:
json
{"id":1,"method":"hello","params":{"ipc":"retype-ipc-1","client":"retype-cli/0.1.0-alpha","features":["events"]}}
{"id":1,"ok":true,"result":{"ipc":"retype-ipc-1","version":"0.1.0-alpha","pid":41233,
"machine":"7f3c1a2b","uptime_ms":91000,"features":["events","undo.list"],
"capabilities":{"backends":["magick"]}}}A different major receives E_IPC_VERSION and the connection closes. New fields and new methods do not change the major. A client that does not send hello first receives E_IPC_HANDSHAKE.
Request timeout defaults to 5 s. No method blocks on work that can outlive it.
6. Methods
| Group | Methods |
|---|---|
| Session | hello, status, shutdown |
| Registry | watch.add, watch.remove, watch.list |
| Jobs | jobs.list, jobs.get, jobs.submit, jobs.cancel, jobs.resume, jobs.resolve |
| History | undo, undo.list, why |
| Live | events.subscribe |
| Config | config.reload |
shutdown reads force and now. Without force, a daemon a supervisor started refuses with E_IPC_SUPERVISED and names the supervisor, §2. With now, running jobs are cancelled and waiting ones are recorded as cancelled, the runners are given a moment to write those records, and the answer carries how many were stopped.
status reports supervised: systemd, launchd, or null, read from the environment of the process the supervisor actually runs rather than from the process tree. A daemon the CLI started itself has had those variables cleared, so it reports null.
jobs.submit takes mode:
| Mode | Use | Admission |
|---|---|---|
immediate | retype convert | Bypasses the queue and starts now, still cancellable and still journalled |
queued | A rename | Normal queue admission, bounded parallelism, interactive priority |
jobs.submit reads input, intent, an optional output and force, and answers with the job id once the job is planned and journalled. Everything that can fail before a byte moves fails here, so a caller gets a diagnosis rather than a job that will only fail later. A submission that needs its result polls jobs.get, which answers with the whole record: the outputs, their bytes, their tier and their errors.
jobs.resume continues a partial job, skipping outputs that still match their recorded identity, and reports E_JOB_RESUME_UNAVAILABLE when neither the source path nor the undo record holds the source bytes.
jobs.get, undo and jobs.resume accept a full ULID or a unique prefix. A prefix matching two jobs is E_JOB_AMBIGUOUS with the candidates; a prefix shorter than four characters is E_USAGE_ARGS.
jobs.list returns a summary per job, not the full record: id, state, source, created, updated, intent, parked_reason, error, outputs_done and outputs_total. jobs.get remains the full journal record.
undo.list returns {"records": [...]} with the fields a history view needs: id, intent, created, source_path, source_ext, source_size, outputs, pinned and consume_source.
config.reload answers {"reloaded": true, "warnings": [...]}. A warning is the same diagnostic shape an error carries, with its code, message and optional span and hint.
7. Events
json
{"event":"job","seq":91,"job":{...}}events {"since": 90} -> {"events": [...], "seq": 91}seqis monotonic per daemon lifetime, shared across connections, and starts at one.sinceis the last sequence the client saw, sosince = 0asks for everything retained- The last 1024 events are retained in memory, one per job snapshot, in the order the journal recorded them
- The answer carries the newest sequence, so a client knows where it now stands without a second call
- A
sinceolder than the retention window cannot be filled: the call fails withE_IPC_RESYNCand the client reloads withjobs.list. Asinceahead of the daemon is not an error, it is an empty answer - Events are read by polling
since, not by holding a connection open. One connection at a time is therefore still enough, and a client that would rather stream asks again as soon as an answer arrives - Events are advisory. State is authoritative only in
jobs.listandjobs.get
8. Errors
json
{"id":7,"ok":false,"error":{"code":"E_JOB_STATE","message":"job 01j8z is done","detail":{"state":"done"}}}One error type in retype-core, shared by daemon, CLI and GUI. IPC codes are namespaced E_IPC_*; domain codes keep their specification names. An error carries code, message, optional detail, and, where the producer knows them, span and hint. The category is derived from the code, so a client never trusts a category that disagrees with it.
IPC codes: E_IPC_VERSION, E_IPC_HANDSHAKE, E_IPC_UNAVAILABLE, E_IPC_SUPERVISED, E_IPC_AMBIGUOUS, E_IPC_RESYNC, E_IPC_FRAME, E_IPC_METHOD, E_IPC_AUTH, E_IPC_INTERNAL.
9. Client obligations
- Send
hellofirst, and close on a version mismatch rather than guessing - Treat events as hints and reconcile with
jobs.liston connect and on resync - Never write the journal, the undo store or the watch registry directly while a daemon is reachable
- Use the same methods the GUI uses. A client-specific back door is a protocol bug