a73x

16c40066

feat: systemd user units; docs: M2 decisions and usage

a73x   2026-08-08 14:08

Commit message
feat: systemd user units; docs: M2 decisions and usage

README.md
Old New
@@ -5,18 +5,17 @@ authoritatively in a daemon and replicated in the client — state sync
5 instead of escape-sequence replay. See `docs/handoff.md` for the design 5 instead of escape-sequence replay. See `docs/handoff.md` for the design
6 and `docs/decisions.md` for decisions made. 6 and `docs/decisions.md` for decisions made.
7 7
8 Status: **M1 — headless engine.** 8 Status: **M2 — the loop.**
9 9
10 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned 10 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned
11 toolchain, override with `make ZIG=...`. 11 toolchain, override with `make ZIG=...`.
12 12
13 make test && make e2e # verify 13 make test && make e2e # verify
14 make build 14 make build
15 ./zig-out/bin/muxd run # daemon, forwards stdin to the PTY 15 ./zig-out/bin/muxd run & # daemon
16 ./zig-out/bin/muxd dump [--vt] # print the authoritative grid 16 ./zig-out/bin/mux # attach a client (Ctrl-\ detaches)
17 ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid
17 18
18 M1 demo: run `muxd run` in one terminal (keystrokes are forwarded blind — 19 Detaching (`Ctrl-\`) leaves the session running; re-running `mux` resumes
19 the grid lives only in the daemon), then `muxd dump` from another terminal 20 it from a state snapshot. systemd user units (socket activation) live in
20 while `nvim`, `top`, or `less` is running inside. The dump matches what a 21 `contrib/`.
21 real terminal would show, including CJK, ZWJ emoji, and SGR styling
22 (`--vt`).
contrib/muxd.service
Old New
@@ -0,0 +1,8 @@
1 [Unit]
2 Description=mux daemon (prototype)
3 Requires=muxd.socket
4
5 [Service]
6 # Adjust the path to your checkout; the prototype is not installed system-wide.
7 ExecStart=%h/code/rad/mux/zig-out/bin/muxd run
8 Restart=no
contrib/muxd.socket
Old New
@@ -0,0 +1,8 @@
1 [Unit]
2 Description=mux daemon socket
3
4 [Socket]
5 ListenStream=%t/muxd.sock
6
7 [Install]
8 WantedBy=sockets.target
docs/decisions.md
Old New
@@ -29,6 +29,43 @@
29 POLLIN; the loop must attempt a read on any revents or it busy-loops at 29 POLLIN; the loop must attempt a read on any revents or it busy-loops at
30 100% CPU. Found during the M1 demo, fixed and verified at 0% idle. 30 100% CPU. Found during the M1 demo, fixed and verified at 0% idle.
31 31
32 ## 2026-08-07 (M2)
33
34 - **Snapshot = canonical VT state serialization.** `TerminalFormatter` with
35 `extra = .all` (palette, modes incl. active screen, cursor, styles), plus
36 a trailing CUP appended by `dumpState`: upstream emits DECSTBM (homes the
37 cursor) and tabstop HTS walks *after* the screen section's CUP, so the
38 dump's final cursor position is wrong without it. The client rebuilds its
39 replica with fullReset + feed. State sync via the engine's canonical
40 form — not a replay of session history.
41 - **Render dump is side-effect-free.** The client paints `dumpVt` with
42 `extra = .none` (content + inline SGR only): the default `.styles` extra
43 emits the full OSC 4 palette, which would overwrite the host terminal's
44 theme on every repaint (~5KB/frame).
45 - **Alt-screen snapshot limitation:** the snapshot carries only the active
46 screen's content. A replica that leaves the alt screen (TUI exits after a
47 reattach) reveals a blank primary screen instead of the shell history.
48 Locked by an engine test; M3 owes a two-screen snapshot or lazy fetch.
49 - **Wire format: 1-byte type + u32 LE length frames, M2 only.** The
50 handoff's "msgpack or protobuf, do not invent one" is owed at M4, where
51 structured payloads (damage regions, cell runs) first appear; every M2
52 payload is a byte blob or two u16s. Recorded so M4 doesn't inherit this
53 by inertia.
54 - **Single interactive client + dump-only observers.** Second `attach` is
55 refused with exit_status{1}; unattached connections may `debug_dump`
56 (keeps `muxd dump` working alongside a live client). M5 replaces this.
57 - **Blocking frame I/O.** One local client on a Unix socket; a stuck client
58 can stall the daemon. Buffered nonblocking I/O is owed by M4 (network).
59 - **Detach chord: Ctrl-\ (0x1c).** No keybinding layer in the prototype.
60 - **Render: home + ED(2) full repaint under CSI ?2026 sync.** Wasteful by
61 design (deltas are M4); sync-output makes it artifact-free on modern
62 terminals.
63 - **Degenerate sizes rejected.** A pty can report 0x0 (`script` with piped
64 stdin); a zero-sized grid trips engine asserts. Client treats <2x2 as
65 "unknown" (falls back 80x24); daemon ignores <2x2 attach/resize.
66 - **systemd socket activation supported** via LISTEN_PID/LISTEN_FDS (fd 3);
67 units in contrib/. Verified with systemd-socket-activate.
68
32 ## Open (owed by later milestones) 69 ## Open (owed by later milestones)
33 70
34 - Resize policy under multiple clients (M5) 71 - Resize policy under multiple clients (M5)
docs/superpowers/plans/2026-08-07-m2-the-loop.md
Old New
@@ -50,7 +50,7 @@ Decisions this plan locks in (recorded in decisions.md, Task 7): wire format is
50 - Create: `src/protocol.zig` 50 - Create: `src/protocol.zig`
51 - Modify: `build.zig` (add protocol module + its test) 51 - Modify: `build.zig` (add protocol module + its test)
52 52
53 - [ ] **Step 1: Write `src/protocol.zig` with failing tests** 53 - [x] **Step 1: Write `src/protocol.zig` with failing tests**
54 54
55 ```zig 55 ```zig
56 //! Wire protocol: length-prefixed frames over a Unix socket. 56 //! Wire protocol: length-prefixed frames over a Unix socket.
@@ -176,7 +176,7 @@ test "size encode/decode round trip" {
176 176
177 Implementation note on the socketpair test: `std.posix.socketpair` returns `[2]fd_t`; if the double-close dance reads poorly, restructure with explicit `close` calls instead of defers — behavior over form. 177 Implementation note on the socketpair test: `std.posix.socketpair` returns `[2]fd_t`; if the double-close dance reads poorly, restructure with explicit `close` calls instead of defers — behavior over form.
178 178
179 - [ ] **Step 2: Add module + test to `build.zig`** 179 - [x] **Step 2: Add module + test to `build.zig`**
180 180
181 Insert before `engine_mod`: 181 Insert before `engine_mod`:
182 182
@@ -190,12 +190,12 @@ Insert before `engine_mod`:
190 190
191 Add `protocol_mod` to the test-step module list. 191 Add `protocol_mod` to the test-step module list.
192 192
193 - [ ] **Step 3: Run tests** 193 - [x] **Step 3: Run tests**
194 194
195 Run: `make test` 195 Run: `make test`
196 Expected: exit 0 (protocol tests pass; existing suites unaffected). 196 Expected: exit 0 (protocol tests pass; existing suites unaffected).
197 197
198 - [ ] **Step 4: Commit** 198 - [x] **Step 4: Commit**
199 199
200 ```bash 200 ```bash
201 git add src/protocol.zig build.zig 201 git add src/protocol.zig build.zig
@@ -209,7 +209,7 @@ git commit -m "feat: length-prefixed frame protocol"
209 **Files:** 209 **Files:**
210 - Modify: `src/engine.zig` 210 - Modify: `src/engine.zig`
211 211
212 - [ ] **Step 1: Add failing tests (append to `src/engine.zig`)** 212 - [x] **Step 1: Add failing tests (append to `src/engine.zig`)**
213 213
214 ```zig 214 ```zig
215 test "Engine: full-state snapshot restores grid, style, and cursor in a fresh engine" { 215 test "Engine: full-state snapshot restores grid, style, and cursor in a fresh engine" {
@@ -282,12 +282,12 @@ test "Engine: alt-screen state survives snapshot into fresh engine" {
282 282
283 Note: the alt-screen test is the sharp edge — `TerminalFormatter` only emits the *active* screen's content plus mode state. If the third assertion (primary content after `?1049l`) fails, that is a real M2 limitation to record in decisions.md (primary screen restored blank after a TUI exits post-reattach); weaken only that assertion (drop it, keep the first), and keep going — M3 owes the fix. 283 Note: the alt-screen test is the sharp edge — `TerminalFormatter` only emits the *active* screen's content plus mode state. If the third assertion (primary content after `?1049l`) fails, that is a real M2 limitation to record in decisions.md (primary screen restored blank after a TUI exits post-reattach); weaken only that assertion (drop it, keep the first), and keep going — M3 owes the fix.
284 284
285 - [ ] **Step 2: Run to verify failure** 285 - [x] **Step 2: Run to verify failure**
286 286
287 Run: `make test` 287 Run: `make test`
288 Expected: compile error — `dumpState`, `cursorPos`, `reset` not defined. 288 Expected: compile error — `dumpState`, `cursorPos`, `reset` not defined.
289 289
290 - [ ] **Step 3: Implement (add to `Engine` in `src/engine.zig`)** 290 - [x] **Step 3: Implement (add to `Engine` in `src/engine.zig`)**
291 291
292 ```zig 292 ```zig
293 /// Full terminal state (palette, modes, cursor, styles, active-screen 293 /// Full terminal state (palette, modes, cursor, styles, active-screen
@@ -318,12 +318,12 @@ Expected: compile error — `dumpState`, `cursorPos`, `reset` not defined.
318 } 318 }
319 ``` 319 ```
320 320
321 - [ ] **Step 4: Run tests** 321 - [x] **Step 4: Run tests**
322 322
323 Run: `make test` 323 Run: `make test`
324 Expected: exit 0. If the alt-screen test's last assertion fails, apply the note from Step 1. 324 Expected: exit 0. If the alt-screen test's last assertion fails, apply the note from Step 1.
325 325
326 - [ ] **Step 5: Commit** 326 - [x] **Step 5: Commit**
327 327
328 ```bash 328 ```bash
329 git add src/engine.zig 329 git add src/engine.zig
@@ -339,7 +339,7 @@ git commit -m "feat: engine full-state snapshot, cursor accessor, reset"
339 - Delete: `src/debug.zig` 339 - Delete: `src/debug.zig`
340 - Modify: `build.zig` 340 - Modify: `build.zig`
341 341
342 - [ ] **Step 1: Write `src/server.zig` (implementation + unit test)** 342 - [x] **Step 1: Write `src/server.zig` (implementation + unit test)**
343 343
344 ```zig 344 ```zig
345 //! muxd's daemon core: one session (engine + pty), one listener, at most 345 //! muxd's daemon core: one session (engine + pty), one listener, at most
@@ -528,7 +528,7 @@ pub const Server = struct {
528 }; 528 };
529 ``` 529 ```
530 530
531 - [ ] **Step 2: Add the integration test (append to `src/server.zig`)** 531 - [x] **Step 2: Add the integration test (append to `src/server.zig`)**
532 532
533 This is the M2 falsification test: a scripted client attaches, types, rebuilds a replica from snapshots, and byte-compares replica vs daemon. 533 This is the M2 falsification test: a scripted client attaches, types, rebuilds a replica from snapshots, and byte-compares replica vs daemon.
534 534
@@ -613,7 +613,7 @@ test "Server: replica rebuilt from snapshots matches the authoritative grid" {
613 } 613 }
614 ``` 614 ```
615 615
616 - [ ] **Step 3: Wire into `build.zig`, remove debug module** 616 - [x] **Step 3: Wire into `build.zig`, remove debug module**
617 617
618 Replace the `debug_mod` block with: 618 Replace the `debug_mod` block with:
619 619
@@ -633,12 +633,12 @@ In `exe_mod` imports: replace `debug` with `server` and add `protocol`. Update t
633 633
634 `main.zig` still references `debug` at this point — Task 4 rewrites it; to keep this task green, apply Task 4's `main.zig` in the same commit if the build breaks, or temporarily stub `main.zig` to `pub fn main() !void {}` (restored in Task 4). Prefer the stub: smaller diff per commit. 634 `main.zig` still references `debug` at this point — Task 4 rewrites it; to keep this task green, apply Task 4's `main.zig` in the same commit if the build breaks, or temporarily stub `main.zig` to `pub fn main() !void {}` (restored in Task 4). Prefer the stub: smaller diff per commit.
635 635
636 - [ ] **Step 4: Run tests** 636 - [x] **Step 4: Run tests**
637 637
638 Run: `make test` 638 Run: `make test`
639 Expected: exit 0. The integration test takes a few seconds (real shell under a pty). A timing flake here is a bug: the loop retries until the *replica* converges, and the final compare uses the daemon's own reply ordering — investigate rather than extending timeouts blindly. 639 Expected: exit 0. The integration test takes a few seconds (real shell under a pty). A timing flake here is a bug: the loop retries until the *replica* converges, and the final compare uses the daemon's own reply ordering — investigate rather than extending timeouts blindly.
640 640
641 - [ ] **Step 5: Commit** 641 - [x] **Step 5: Commit**
642 642
643 ```bash 643 ```bash
644 git add -A 644 git add -A
@@ -652,7 +652,7 @@ git commit -m "feat: daemon server core with snapshot broadcast; replica fidelit
652 **Files:** 652 **Files:**
653 - Rewrite: `src/main.zig` 653 - Rewrite: `src/main.zig`
654 654
655 - [ ] **Step 1: Rewrite `src/main.zig`** 655 - [x] **Step 1: Rewrite `src/main.zig`**
656 656
657 ```zig 657 ```zig
658 //! muxd — daemon entrypoint. `run` hosts the session; `dump` prints the 658 //! muxd — daemon entrypoint. `run` hosts the session; `dump` prints the
@@ -787,12 +787,12 @@ Amended `Server` pieces (use these, not the refusal version, when executing Task
787 787
788 Observers are polled too; an observer that sends `attach` is promoted to `self.client` if the slot is free (else refused with `exit_status{1}` and closed). `debug_dump` works from any connection. The poll fd array becomes: pty, listener, client (or -1), observers[0..4] (or -1). On any observer error/EOF, clear the slot. This is ~30 lines of bookkeeping; keep it inside `serviceClient`-style helpers (`serviceFd(fd) enum { keep, drop }`). 788 Observers are polled too; an observer that sends `attach` is promoted to `self.client` if the slot is free (else refused with `exit_status{1}` and closed). `debug_dump` works from any connection. The poll fd array becomes: pty, listener, client (or -1), observers[0..4] (or -1). On any observer error/EOF, clear the slot. This is ~30 lines of bookkeeping; keep it inside `serviceClient`-style helpers (`serviceFd(fd) enum { keep, drop }`).
789 789
790 - [ ] **Step 2: Run tests + build** 790 - [x] **Step 2: Run tests + build**
791 791
792 Run: `make test && make build` 792 Run: `make test && make build`
793 Expected: exit 0, both binaries build (mux arrives in Task 5; only muxd exists yet). 793 Expected: exit 0, both binaries build (mux arrives in Task 5; only muxd exists yet).
794 794
795 - [ ] **Step 3: Commit** 795 - [x] **Step 3: Commit**
796 796
797 ```bash 797 ```bash
798 git add src/main.zig src/server.zig 798 git add src/main.zig src/server.zig
@@ -807,7 +807,7 @@ git commit -m "feat: muxd speaks the real protocol; observer connections for dum
807 - Create: `src/client.zig`, `src/mux_main.zig` 807 - Create: `src/client.zig`, `src/mux_main.zig`
808 - Modify: `build.zig` 808 - Modify: `build.zig`
809 809
810 - [ ] **Step 1: Write `src/client.zig`** 810 - [x] **Step 1: Write `src/client.zig`**
811 811
812 ```zig 812 ```zig
813 //! mux client: connects, attaches, maintains a replica engine rebuilt 813 //! mux client: connects, attaches, maintains a replica engine rebuilt
@@ -967,7 +967,7 @@ test "render paints replica content with cursor restore" {
967 967
968 Sigaction API drift note: on this std version `sigaction` returns `void` and `handler` is `.{ .handler = f }` with `callconv(.c)`; if the compiler disagrees, check `std.posix.Sigaction` in the 0.15.2 lib source (`~/Downloads/zig-x86_64-linux-0.15.2/lib/std/posix.zig`). 968 Sigaction API drift note: on this std version `sigaction` returns `void` and `handler` is `.{ .handler = f }` with `callconv(.c)`; if the compiler disagrees, check `std.posix.Sigaction` in the 0.15.2 lib source (`~/Downloads/zig-x86_64-linux-0.15.2/lib/std/posix.zig`).
969 969
970 - [ ] **Step 2: Write `src/mux_main.zig`** 970 - [x] **Step 2: Write `src/mux_main.zig`**
971 971
972 ```zig 972 ```zig
973 //! mux — client binary. `mux [--sock PATH]` attaches to the running muxd. 973 //! mux — client binary. `mux [--sock PATH]` attaches to the running muxd.
@@ -1009,7 +1009,7 @@ pub fn main() !u8 {
1009 } 1009 }
1010 ``` 1010 ```
1011 1011
1012 - [ ] **Step 3: Wire into `build.zig`** 1012 - [x] **Step 3: Wire into `build.zig`**
1013 1013
1014 After `server_mod`: 1014 After `server_mod`:
1015 1015
@@ -1040,12 +1040,12 @@ After `server_mod`:
1040 1040
1041 Add `client_mod` to the test-step list. Pass `mux_exe` to the e2e runner as a second artifact arg (`e2e.addArtifactArg(mux_exe);`). 1041 Add `client_mod` to the test-step list. Pass `mux_exe` to the e2e runner as a second artifact arg (`e2e.addArtifactArg(mux_exe);`).
1042 1042
1043 - [ ] **Step 4: Run tests + build** 1043 - [x] **Step 4: Run tests + build**
1044 1044
1045 Run: `make test && make build` 1045 Run: `make test && make build`
1046 Expected: exit 0; `zig-out/bin/mux` and `zig-out/bin/muxd` both exist. 1046 Expected: exit 0; `zig-out/bin/mux` and `zig-out/bin/muxd` both exist.
1047 1047
1048 - [ ] **Step 5: Commit** 1048 - [x] **Step 5: Commit**
1049 1049
1050 ```bash 1050 ```bash
1051 git add src/client.zig src/mux_main.zig build.zig 1051 git add src/client.zig src/mux_main.zig build.zig
@@ -1059,7 +1059,7 @@ git commit -m "feat: mux client with replica grid, renderer, resize, Ctrl-\\ det
1059 **Files:** 1059 **Files:**
1060 - Rewrite: `test/e2e.sh` 1060 - Rewrite: `test/e2e.sh`
1061 1061
1062 - [ ] **Step 1: Rewrite `test/e2e.sh`** 1062 - [x] **Step 1: Rewrite `test/e2e.sh`**
1063 1063
1064 ```sh 1064 ```sh
1065 #!/bin/sh 1065 #!/bin/sh
@@ -1101,12 +1101,12 @@ kill -0 "$DPID" || { echo "e2e FAIL: daemon died on detach"; exit 1; }
1101 echo "e2e OK" 1101 echo "e2e OK"
1102 ``` 1102 ```
1103 1103
1104 - [ ] **Step 2: Run it** 1104 - [x] **Step 2: Run it**
1105 1105
1106 Run: `make e2e` 1106 Run: `make e2e`
1107 Expected: `e2e OK`. 1107 Expected: `e2e OK`.
1108 1108
1109 - [ ] **Step 3: Commit** 1109 - [x] **Step 3: Commit**
1110 1110
1111 ```bash 1111 ```bash
1112 git add test/e2e.sh 1112 git add test/e2e.sh
@@ -1121,7 +1121,7 @@ git commit -m "test: e2e through the real client/daemon loop"
1121 - Create: `contrib/muxd.service`, `contrib/muxd.socket` 1121 - Create: `contrib/muxd.service`, `contrib/muxd.socket`
1122 - Modify: `docs/decisions.md`, `README.md` 1122 - Modify: `docs/decisions.md`, `README.md`
1123 1123
1124 - [ ] **Step 1: Write the units** 1124 - [x] **Step 1: Write the units**
1125 1125
1126 `contrib/muxd.socket`: 1126 `contrib/muxd.socket`:
1127 1127
@@ -1149,7 +1149,7 @@ ExecStart=%h/code/rad/mux/zig-out/bin/muxd run
1149 Restart=no 1149 Restart=no
1150 ``` 1150 ```
1151 1151
1152 - [ ] **Step 2: Manual demo (M2 acceptance)** 1152 - [x] **Step 2: Manual demo (M2 acceptance)**
1153 1153
1154 In terminal A: `make build && ./zig-out/bin/muxd run` 1154 In terminal A: `make build && ./zig-out/bin/muxd run`
1155 In terminal B: `./zig-out/bin/mux` — a live shell appears. Then: 1155 In terminal B: `./zig-out/bin/mux` — a live shell appears. Then:
@@ -1162,7 +1162,7 @@ In terminal B: `./zig-out/bin/mux` — a live shell appears. Then:
1162 1162
1163 Record any artifact (flicker beyond taste, wrong cells, stuck cursor) as a bug before declaring M2 done. Systemd socket-activation check (optional, needs lingering not required for the demo): `systemctl --user enable --now` the units from `contrib/` after copying to `~/.config/systemd/user/`, then `mux` with no daemon pre-started. 1163 Record any artifact (flicker beyond taste, wrong cells, stuck cursor) as a bug before declaring M2 done. Systemd socket-activation check (optional, needs lingering not required for the demo): `systemctl --user enable --now` the units from `contrib/` after copying to `~/.config/systemd/user/`, then `mux` with no daemon pre-started.
1164 1164
1165 - [ ] **Step 3: Update `docs/decisions.md`** — append under a new `## 2026-08-07 (M2)` heading: 1165 - [x] **Step 3: Update `docs/decisions.md`** — append under a new `## 2026-08-07 (M2)` heading:
1166 1166
1167 ```markdown 1167 ```markdown
1168 ## 2026-08-07 (M2) 1168 ## 2026-08-07 (M2)
@@ -1190,7 +1190,7 @@ Record any artifact (flicker beyond taste, wrong cells, stuck cursor) as a bug b
1190 alt screen after rebuild]. 1190 alt screen after rebuild].
1191 ``` 1191 ```
1192 1192
1193 - [ ] **Step 4: Update `README.md`** — replace the Status/usage section: 1193 - [x] **Step 4: Update `README.md`** — replace the Status/usage section:
1194 1194
1195 ```markdown 1195 ```markdown
1196 Status: **M2 — the loop.** 1196 Status: **M2 — the loop.**
@@ -1204,7 +1204,7 @@ Status: **M2 — the loop.**
1204 systemd user units (socket activation) live in `contrib/`. 1204 systemd user units (socket activation) live in `contrib/`.
1205 ``` 1205 ```
1206 1206
1207 - [ ] **Step 5: Commit** 1207 - [x] **Step 5: Commit**
1208 1208
1209 ```bash 1209 ```bash
1210 git add contrib/ docs/decisions.md README.md 1210 git add contrib/ docs/decisions.md README.md