a8b00e12
docs: add design handoff and M1 plan
a73x 2026-08-07 06:48
Commit message
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,2 @@ | |||
| 1 | zig-out/ | ||
| 2 | .zig-cache/ | ||
docs/handoff.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,199 @@ | |||
| 1 | # Handoff: Linux-only multiplexer prototype | ||
| 2 | |||
| 3 | **Status:** design handoff, no code written yet | ||
| 4 | **Audience:** whoever picks up implementation | ||
| 5 | **Scope:** Linux only. No macOS, no iOS, no web, no network. | ||
| 6 | |||
| 7 | --- | ||
| 8 | |||
| 9 | ## 0. What this is and isn't | ||
| 10 | |||
| 11 | We are building a terminal multiplexer where **the terminal engine runs on both ends** — authoritative in a daemon, replicated in the client — instead of the tmux model where a second emulator is nested inside your first one. | ||
| 12 | |||
| 13 | This prototype exists to answer two questions and nothing else: | ||
| 14 | |||
| 15 | 1. Can libghostty serve as an authoritative, headless, serializable grid in a daemon? | ||
| 16 | 2. Does detach/reattach as a *state sync* (rather than an escape-sequence replay) actually feel correct and fast? | ||
| 17 | |||
| 18 | If the answer to either is no, we want to know in weeks, not months. Everything downstream — offload, mesh networking, multiplayer, agent sessions, production controls — is deliberately out of scope and should stay out until these two are settled. | ||
| 19 | |||
| 20 | **Explicitly not in this prototype:** panes/splits, local echo/prediction, config or theming, plugins, keybinding layer, auth, TLS, TCP, checkpoint/restore, session migration, sharing. | ||
| 21 | |||
| 22 | --- | ||
| 23 | |||
| 24 | ## 1. Why incremental | ||
| 25 | |||
| 26 | The temptation is to build the whole daemon + protocol + client and then turn it on. Don't. The riskiest assumption is buried in step 1 (is libghostty's grid extractable?), and a one-shot build would surface that after you've already written a protocol and a client around it. | ||
| 27 | |||
| 28 | Each milestone below is **independently demoable and independently falsifying**. Every one should end with something you can run and look at. If a milestone can't be demoed, it's too big — split it. | ||
| 29 | |||
| 30 | Ordering principle: **prove the engine, then the loop, then the promise, then the scale, then the concurrency.** Each stage assumes the previous one held. | ||
| 31 | |||
| 32 | --- | ||
| 33 | |||
| 34 | ## 2. Target architecture (end state of this prototype) | ||
| 35 | |||
| 36 | ``` | ||
| 37 | muxd (daemon, systemd user service) | ||
| 38 | ├─ session registry | ||
| 39 | ├─ per session: | ||
| 40 | │ ├─ PTY (forkpty, child = user's $SHELL) | ||
| 41 | │ ├─ libghostty grid ← source of truth | ||
| 42 | │ └─ scrollback ring buffer | ||
| 43 | └─ listener: $XDG_RUNTIME_DIR/muxd.sock (Unix domain socket) | ||
| 44 | |||
| 45 | mux (client) | ||
| 46 | ├─ libghostty replica grid | ||
| 47 | ├─ GPU rendering (reuse Ghostty's existing Linux/GTK path) | ||
| 48 | └─ socket client | ||
| 49 | ``` | ||
| 50 | |||
| 51 | Unix socket only. Same user, same machine, no auth. Transport is swappable later; the point now is that the *data model* is right. If the protocol is well-formed over a socket, moving it to QUIC/TLS later is a transport change, not a redesign. | ||
| 52 | |||
| 53 | --- | ||
| 54 | |||
| 55 | ## 3. Milestones | ||
| 56 | |||
| 57 | ### M1 — Headless engine | ||
| 58 | **Goal:** prove libghostty runs server-side with no display and its grid can be read out. | ||
| 59 | |||
| 60 | Build `muxd` far enough to: spawn a PTY, run the user's shell in it, feed PTY output into a libghostty instance, and expose a debug command that dumps the current grid as plain text. | ||
| 61 | |||
| 62 | **Demo:** `muxd-debug dump` prints a screen that matches what the shell actually rendered. Run something non-trivial in it — `htop`, `vim`, `less` on a UTF-8 file with emoji and CJK. | ||
| 63 | |||
| 64 | **Falsifies:** the whole thesis. If libghostty can't be driven headlessly, or the grid can't be extracted cleanly, stop and reassess before writing anything else. | ||
| 65 | |||
| 66 | **Expected pain:** libghostty was built to render, not to serialize. Extracting a snapshot API is the single largest chunk of genuinely new work in this prototype, and it belongs here — first — precisely because it's the biggest unknown. | ||
| 67 | |||
| 68 | **Done when:** grid dump is byte-correct for wide chars, grapheme clusters, and SGR attributes. | ||
| 69 | |||
| 70 | --- | ||
| 71 | |||
| 72 | ### M2 — The loop | ||
| 73 | **Goal:** prove input and output flow end to end through a real client. | ||
| 74 | |||
| 75 | Add the socket listener, a minimal `Attach` / `Input` / `Snapshot` message set, and a `mux` client that renders the replica grid and forwards keystrokes. | ||
| 76 | |||
| 77 | Full snapshot on every update is fine here. It will be wasteful and that's acceptable — deltas are M4. | ||
| 78 | |||
| 79 | **Demo:** type in `mux`, see it echo, run `vim`, edit a file, `:wq`. It should feel indistinguishable from a normal terminal. | ||
| 80 | |||
| 81 | **Falsifies:** the two-sided-engine model. If the replica diverges from the authoritative grid under normal use, the data model is wrong. | ||
| 82 | |||
| 83 | **Done when:** a full interactive session (shell + a TUI app + a pager) works without visual artifacts. | ||
| 84 | |||
| 85 | --- | ||
| 86 | |||
| 87 | ### M3 — The promise | ||
| 88 | **Goal:** detach and reattach as state sync. **This is the milestone that matters most.** | ||
| 89 | |||
| 90 | Client can disconnect and reconnect. Daemon keeps parsing PTY output while nobody is attached. On reattach, client receives a snapshot and reconstructs natively — no replaying a firehose of escape sequences. | ||
| 91 | |||
| 92 | **Demo:** start a long-running command, kill the client mid-run, reconnect, land exactly where you left off with correct screen state and correct scrollback. Then do it while `vim` is open. | ||
| 93 | |||
| 94 | **Falsifies:** the core product promise. If reattach is slow, lossy, or wrong, nothing downstream is worth building. | ||
| 95 | |||
| 96 | **Done when:** reattach is visually instant and state is correct for both line-mode and full-screen-TUI sessions. | ||
| 97 | |||
| 98 | --- | ||
| 99 | |||
| 100 | ### M4 — Deltas | ||
| 101 | **Goal:** prove the protocol will survive a real network later. | ||
| 102 | |||
| 103 | Replace full snapshots with sequence-numbered deltas and damage regions. Client sends `have_seq` on attach; daemon replies with a delta if it can, a full snapshot if the client is too far behind. Add lazy scrollback fetch — snapshot carries the visible grid only, history is requested on scroll. | ||
| 104 | |||
| 105 | **Demo:** instrument bytes-on-wire. Compare M2's full-snapshot volume against M4's for the same session. The gap is the whole point. | ||
| 106 | |||
| 107 | **Falsifies:** network viability. Over a Unix socket everything feels fine; this milestone is what tells you whether the design survives cellular. | ||
| 108 | |||
| 109 | **Done when:** steady-state typing sends bytes proportional to what changed, not to screen size, and reattach-after-a-gap still resolves correctly. | ||
| 110 | |||
| 111 | --- | ||
| 112 | |||
| 113 | ### M5 — Two clients | ||
| 114 | **Goal:** prove the authoritative-daemon model under concurrency, and force the resize decision. | ||
| 115 | |||
| 116 | Two `mux` instances attached to one session simultaneously. Both stay in sync. Client-local view state (scroll position, selection) stays independent. | ||
| 117 | |||
| 118 | **Demo:** two terminals side by side on one session; type in either, both update. Scroll back in one; the other doesn't move. | ||
| 119 | |||
| 120 | **Surfaces the resize question, which you must now answer:** when attached clients have different window sizes, whose dimensions win? tmux's answer — smallest wins, ugly borders — is widely disliked. Options: smallest-wins, authoritative-client, per-client reflow. **Decide this explicitly and write it down**; it leaks into the grid model and is expensive to retrofit. | ||
| 121 | |||
| 122 | **Done when:** two clients converge reliably and divergent view state behaves as a feature rather than a bug. | ||
| 123 | |||
| 124 | --- | ||
| 125 | |||
| 126 | ## 4. Protocol sketch | ||
| 127 | |||
| 128 | Starting point, expected to change: | ||
| 129 | |||
| 130 | ``` | ||
| 131 | client → daemon: | ||
| 132 | Attach { session_id, viewport_size, have_seq } | ||
| 133 | Input { bytes } | ||
| 134 | Resize { cols, rows } | ||
| 135 | FetchScrollback { range } | ||
| 136 | Detach {} | ||
| 137 | |||
| 138 | daemon → client: | ||
| 139 | Snapshot { grid, cursor, seq } | ||
| 140 | Delta { damage_regions, cursor, seq } | ||
| 141 | ScrollbackChunk { range, rows } | ||
| 142 | Bell {} | ||
| 143 | TitleChange { title } | ||
| 144 | ExitStatus { code } | ||
| 145 | ``` | ||
| 146 | |||
| 147 | Wire format: msgpack or protobuf. **Do not invent one.** | ||
| 148 | |||
| 149 | Non-negotiable properties, because they're the ones that are expensive to add later: | ||
| 150 | - **Sequence numbers from M4 onward** — reattach must be able to request a delta | ||
| 151 | - **Damage regions, not full-grid sends** — decides network viability | ||
| 152 | - **Lazy scrollback** — history is fetched, never pushed wholesale | ||
| 153 | |||
| 154 | --- | ||
| 155 | |||
| 156 | ## 5. Known hard parts | ||
| 157 | |||
| 158 | | Area | Why it's hard | Where it lands | | ||
| 159 | |---|---|---| | ||
| 160 | | Grid serialization | libghostty renders; it doesn't ship state over a wire. Needs a new snapshot/delta API. | M1 | | ||
| 161 | | Scrollback reflow on resize | Genuinely hard. Ghostty already solved it locally — reusing that is the main reason we're not writing a grid from scratch. | M3/M5 | | ||
| 162 | | Resize with multiple clients | No good industry answer. Affects the grid model. | M5, decide explicitly | | ||
| 163 | | Daemon lifecycle | systemd user unit + socket activation. Sessions survive logout only with lingering enabled. Cheap now, annoying to retrofit. | M2 | | ||
| 164 | | Prediction / local echo | Deliberately deferred. Over a socket it buys nothing; over a network it's essential and conservative-by-default (predict in line mode, fall back to server-confirmed inside TUIs). | Post-prototype | | ||
| 165 | |||
| 166 | --- | ||
| 167 | |||
| 168 | ## 6. Deferred, with reasons | ||
| 169 | |||
| 170 | - **Panes/splits** — client-side layout once the daemon serves N sessions. Not a daemon concern. Feels core, isn't. | ||
| 171 | - **Local echo/prediction** — zero latency over a Unix socket; adding it now would be optimizing a problem we don't have. | ||
| 172 | - **Network transport, auth, TLS** — transport swap, not redesign. Prove the model first. | ||
| 173 | - **Offload / checkpoint-restore / mesh** — separate problem entirely (process location, not terminal rendering). Do not let it contaminate this prototype. | ||
| 174 | - **Sharing/multiplayer** — M5 proves the underlying mechanism; the product surface comes later. | ||
| 175 | - **Config, theming, plugins, keybindings** — distraction. | ||
| 176 | |||
| 177 | --- | ||
| 178 | |||
| 179 | ## 7. Decision log to maintain | ||
| 180 | |||
| 181 | Keep a running file. At minimum, record decisions on: | ||
| 182 | |||
| 183 | - Resize policy under multiple clients (M5) | ||
| 184 | - Snapshot vs delta threshold — how far behind before a client gets a full snapshot | ||
| 185 | - Scrollback retention limit per session, and eviction behaviour | ||
| 186 | - Daemon lifetime — session persistence across logout and reboot | ||
| 187 | - Wire format choice and versioning strategy | ||
| 188 | |||
| 189 | --- | ||
| 190 | |||
| 191 | ## 8. Kill criteria | ||
| 192 | |||
| 193 | Stop and reassess, rather than pushing through, if: | ||
| 194 | |||
| 195 | - **M1:** libghostty's grid cannot be extracted without invasive forking of the engine | ||
| 196 | - **M3:** reattach cannot be made both fast and correct for full-screen TUI sessions | ||
| 197 | - **M4:** delta traffic doesn't scale down meaningfully versus full snapshots | ||
| 198 | |||
| 199 | These are the three places the design could actually be wrong. Everything else is engineering effort, not risk. | ||
docs/superpowers/plans/2026-08-07-m1-headless-engine.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,1036 @@ | |||
| 1 | # M1 — Headless Engine Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Prove ghostty-vt runs headless in a daemon: `muxd run` hosts `$SHELL` on a PTY feeding an authoritative ghostty-vt grid, and `muxd dump` prints that grid byte-correct for wide chars, grapheme clusters, and SGR attributes. | ||
| 6 | |||
| 7 | **Architecture:** Single-threaded `poll(2)` loop in one `muxd` binary. PTY output feeds a heap-pinned `Engine` (ghostty-vt `Terminal` + `TerminalStream`); a throwaway line-command debug socket serves grid dumps via ghostty-vt's built-in `TerminalFormatter` (plain and VT formats). No forking of ghostty — we consume the upstream Zig package's `ghostty-vt` module, already pinned in the local Zig cache. | ||
| 8 | |||
| 9 | **Tech Stack:** Zig 0.17.0-dev (installed at `~/.local/bin/zig`), ghostty package `1.3.2-dev` pinned at commit `853183e9` (hash `ghostty-1.3.2-dev-5UdBC7VOBgVv0iA-qLRtBnau_zLIv7iGGLdnEiW6fUYU`, already in `~/.cache/zig/p/` — builds offline), libc (forkpty). | ||
| 10 | |||
| 11 | **Constraints from the user:** | ||
| 12 | - Do NOT copy code from `~/code/rad/waystty` — the user considers it non-performant. It may be consulted only as *documentation* of ghostty-vt API signatures. Concretely: our PTY uses blocking fds driven by `poll`, not waystty's nonblocking-fd + sleep-loop pattern, and our engine wrapper does not adopt waystty's `RenderState`-per-frame design. | ||
| 13 | - The pinned ghostty package source is browsable at `~/.cache/zig/p/ghostty-1.3.2-dev-5UdBC7VOBgVv0iA-qLRtBnau_zLIv7iGGLdnEiW6fUYU/` — when a signature in this plan doesn't compile, check `src/lib_vt.zig`, `src/terminal/Terminal.zig`, `src/terminal/stream_terminal.zig`, `src/terminal/formatter.zig` there. The plan was written against that exact source, but ghostty-vt's API is explicitly unstable. | ||
| 14 | |||
| 15 | **Verified API facts (from the pinned package source):** | ||
| 16 | - Module name: `dep.module("ghostty-vt")`; import as `@import("ghostty-vt")`. | ||
| 17 | - `vt.Terminal.init(alloc, .{ .cols, .rows, .max_scrollback }) !Terminal` (by value); `term.deinit(alloc)`; `term.resize(alloc, cols, rows) !void`; `term.plainString(alloc) ![]const u8` (visible screen, trailing whitespace trimmed). | ||
| 18 | - `vt.TerminalStream = Stream(stream_terminal.Handler)`; `TerminalStream.initAlloc(alloc, .{ .terminal = &term })`; `stream.nextSlice(bytes)`; `stream.deinit()`. Handler field is `stream.handler`; effects at `stream.handler.effects` (default `.readonly`, all callbacks null). `effects.write_pty: ?*const fn (*Handler, [:0]const u8) void` — required for DSR/DA responses to reach the child app. | ||
| 19 | - `vt.formatter.TerminalFormatter.init(&term, opts)` where `opts` coerces from `.plain` / `.vt`; `.format(writer: *std.Io.Writer)`. Default `extra = .styles`; `.all` "reconstructs the terminal state as closely as possible". | ||
| 20 | |||
| 21 | --- | ||
| 22 | |||
| 23 | ## File Structure | ||
| 24 | |||
| 25 | ``` | ||
| 26 | mux/ | ||
| 27 | ├── build.zig — muxd exe + unit-test step + e2e step | ||
| 28 | ├── build.zig.zon — pinned ghostty dependency | ||
| 29 | ├── .gitignore | ||
| 30 | ├── README.md — one paragraph + M1 demo instructions (Task 6) | ||
| 31 | ├── docs/ | ||
| 32 | │ ├── handoff.md — the design handoff (already created) | ||
| 33 | │ ├── decisions.md — running decision log (Task 6) | ||
| 34 | │ └── superpowers/plans/2026-08-07-m1-headless-engine.md — this file | ||
| 35 | ├── src/ | ||
| 36 | │ ├── main.zig — CLI (`run` / `dump`), poll loop, stdin forwarding | ||
| 37 | │ ├── engine.zig — Engine: ghostty-vt Terminal + TerminalStream + dumps | ||
| 38 | │ ├── pty.zig — Pty: forkpty/read/write/resize/exit detection | ||
| 39 | │ └── debug.zig — DebugServer: M1-only line-command socket | ||
| 40 | └── test/ | ||
| 41 | └── e2e.sh — end-to-end: muxd run + muxd dump round trip | ||
| 42 | ``` | ||
| 43 | |||
| 44 | Responsibilities: `engine.zig` never touches fds; `pty.zig` never touches the engine; `debug.zig` knows the engine only through `dumpPlain`/`dumpVt`; `main.zig` is the only place that wires them together. This keeps M2 (real protocol) a replacement of `debug.zig` + `main.zig` only. | ||
| 45 | |||
| 46 | --- | ||
| 47 | |||
| 48 | ### Task 1: Scaffold + headless smoke test | ||
| 49 | |||
| 50 | The M1 kill criterion in miniature: if this task's one test compiles and passes, ghostty-vt is drivable headless and the thesis survives. | ||
| 51 | |||
| 52 | **Files:** | ||
| 53 | - Create: `.gitignore`, `build.zig.zon`, `build.zig`, `src/engine.zig` (test only, minimal impl), `src/main.zig` (stub) | ||
| 54 | |||
| 55 | - [ ] **Step 1: Init repo** | ||
| 56 | |||
| 57 | ```bash | ||
| 58 | cd /home/xanderle/code/rad/mux | ||
| 59 | git init | ||
| 60 | printf 'zig-out/\n.zig-cache/\n' > .gitignore | ||
| 61 | git add .gitignore docs/ | ||
| 62 | git commit -m "docs: add design handoff and M1 plan" | ||
| 63 | ``` | ||
| 64 | |||
| 65 | - [ ] **Step 2: Write `build.zig.zon`** | ||
| 66 | |||
| 67 | ```zig | ||
| 68 | .{ | ||
| 69 | .name = .mux, | ||
| 70 | .version = "0.0.1", | ||
| 71 | .fingerprint = 0x0, // placeholder — step 4 replaces it | ||
| 72 | .minimum_zig_version = "0.15.2", | ||
| 73 | .paths = .{ | ||
| 74 | "build.zig", | ||
| 75 | "build.zig.zon", | ||
| 76 | "src", | ||
| 77 | }, | ||
| 78 | .dependencies = .{ | ||
| 79 | .ghostty = .{ | ||
| 80 | .url = "git+https://github.com/ghostty-org/ghostty#853183e911b70ff7b61057f52fc7b47ea4934238", | ||
| 81 | .hash = "ghostty-1.3.2-dev-5UdBC7VOBgVv0iA-qLRtBnau_zLIv7iGGLdnEiW6fUYU", | ||
| 82 | .lazy = true, | ||
| 83 | }, | ||
| 84 | }, | ||
| 85 | } | ||
| 86 | ``` | ||
| 87 | |||
| 88 | The hash matches `~/.cache/zig/p/`, so no network fetch happens. | ||
| 89 | |||
| 90 | - [ ] **Step 3: Write `build.zig`** | ||
| 91 | |||
| 92 | ```zig | ||
| 93 | const std = @import("std"); | ||
| 94 | |||
| 95 | pub fn build(b: *std.Build) void { | ||
| 96 | const target = b.standardTargetOptions(.{}); | ||
| 97 | const optimize = b.standardOptimizeOption(.{}); | ||
| 98 | |||
| 99 | const ghostty_dep = b.lazyDependency("ghostty", .{ | ||
| 100 | .target = target, | ||
| 101 | .optimize = optimize, | ||
| 102 | }); | ||
| 103 | |||
| 104 | const engine_mod = b.createModule(.{ | ||
| 105 | .root_source_file = b.path("src/engine.zig"), | ||
| 106 | .target = target, | ||
| 107 | .optimize = optimize, | ||
| 108 | }); | ||
| 109 | if (ghostty_dep) |dep| { | ||
| 110 | engine_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); | ||
| 111 | } | ||
| 112 | |||
| 113 | const pty_mod = b.createModule(.{ | ||
| 114 | .root_source_file = b.path("src/pty.zig"), | ||
| 115 | .target = target, | ||
| 116 | .optimize = optimize, | ||
| 117 | .link_libc = true, | ||
| 118 | }); | ||
| 119 | |||
| 120 | const debug_mod = b.createModule(.{ | ||
| 121 | .root_source_file = b.path("src/debug.zig"), | ||
| 122 | .target = target, | ||
| 123 | .optimize = optimize, | ||
| 124 | }); | ||
| 125 | debug_mod.addImport("engine", engine_mod); | ||
| 126 | |||
| 127 | const exe_mod = b.createModule(.{ | ||
| 128 | .root_source_file = b.path("src/main.zig"), | ||
| 129 | .target = target, | ||
| 130 | .optimize = optimize, | ||
| 131 | .link_libc = true, | ||
| 132 | }); | ||
| 133 | exe_mod.addImport("engine", engine_mod); | ||
| 134 | exe_mod.addImport("pty", pty_mod); | ||
| 135 | exe_mod.addImport("debug", debug_mod); | ||
| 136 | |||
| 137 | const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); | ||
| 138 | b.installArtifact(exe); | ||
| 139 | |||
| 140 | const test_step = b.step("test", "Run unit tests"); | ||
| 141 | for ([_]*std.Build.Module{ engine_mod, pty_mod, debug_mod }) |mod| { | ||
| 142 | const t = b.addTest(.{ .root_module = mod }); | ||
| 143 | test_step.dependOn(&b.addRunArtifact(t).step); | ||
| 144 | } | ||
| 145 | |||
| 146 | const e2e = b.addSystemCommand(&.{"test/e2e.sh"}); | ||
| 147 | e2e.addArtifactArg(exe); | ||
| 148 | const e2e_step = b.step("e2e", "Run end-to-end test"); | ||
| 149 | e2e_step.dependOn(&e2e.step); | ||
| 150 | } | ||
| 151 | ``` | ||
| 152 | |||
| 153 | Note: `src/pty.zig` and `src/debug.zig` don't exist until Tasks 3–4. For this task, create them as empty files (`touch src/pty.zig src/debug.zig`) so the build graph resolves; `main.zig` stub: | ||
| 154 | |||
| 155 | ```zig | ||
| 156 | pub fn main() !void {} | ||
| 157 | ``` | ||
| 158 | |||
| 159 | - [ ] **Step 4: Write the failing smoke test in `src/engine.zig`** | ||
| 160 | |||
| 161 | ```zig | ||
| 162 | //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal | ||
| 163 | //! and TerminalStream behind the small surface muxd needs. | ||
| 164 | const std = @import("std"); | ||
| 165 | const vt = @import("ghostty-vt"); | ||
| 166 | |||
| 167 | test "ghostty-vt boots headless and text lands in the grid" { | ||
| 168 | const alloc = std.testing.allocator; | ||
| 169 | var term = try vt.Terminal.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 170 | defer term.deinit(alloc); | ||
| 171 | |||
| 172 | var stream: vt.TerminalStream = .initAlloc(alloc, .{ .terminal = &term }); | ||
| 173 | defer stream.deinit(); | ||
| 174 | |||
| 175 | stream.nextSlice("hello"); | ||
| 176 | |||
| 177 | const s = try term.plainString(alloc); | ||
| 178 | defer alloc.free(s); | ||
| 179 | try std.testing.expectEqualStrings("hello", s); | ||
| 180 | } | ||
| 181 | ``` | ||
| 182 | |||
| 183 | - [ ] **Step 5: Run and fix the fingerprint, then verify the test passes** | ||
| 184 | |||
| 185 | Run: `zig build test` | ||
| 186 | Expected: first invocation errors with `invalid fingerprint: 0x0; if this is a new package, use "0x..."` — copy the suggested value into `build.zig.zon` and re-run. | ||
| 187 | Expected: `zig build test` exits 0 (test passed silently). | ||
| 188 | |||
| 189 | If instead the ghostty package itself fails to compile under Zig 0.17-dev, STOP: this is the M1 kill-criterion path. Check what zig version `~/code/rad/waystty` pins before concluding anything (a version mismatch is an environment problem, not a thesis failure). | ||
| 190 | |||
| 191 | - [ ] **Step 6: Commit** | ||
| 192 | |||
| 193 | ```bash | ||
| 194 | git add build.zig build.zig.zon src/ | ||
| 195 | git commit -m "feat: scaffold muxd; prove ghostty-vt drives headless" | ||
| 196 | ``` | ||
| 197 | |||
| 198 | --- | ||
| 199 | |||
| 200 | ### Task 2: Engine wrapper with byte-correctness tests | ||
| 201 | |||
| 202 | **Files:** | ||
| 203 | - Modify: `src/engine.zig` | ||
| 204 | |||
| 205 | - [ ] **Step 1: Write the failing tests (append to `src/engine.zig`)** | ||
| 206 | |||
| 207 | ```zig | ||
| 208 | test "Engine: wide CJK chars dump byte-correct" { | ||
| 209 | const alloc = std.testing.allocator; | ||
| 210 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 211 | defer e.deinit(); | ||
| 212 | |||
| 213 | e.feed("漢字 wide"); | ||
| 214 | const s = try e.dumpPlain(alloc); | ||
| 215 | defer alloc.free(s); | ||
| 216 | try std.testing.expectEqualStrings("漢字 wide", s); | ||
| 217 | } | ||
| 218 | |||
| 219 | test "Engine: ZWJ emoji grapheme cluster dumps byte-correct" { | ||
| 220 | const alloc = std.testing.allocator; | ||
| 221 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 222 | defer e.deinit(); | ||
| 223 | |||
| 224 | // Woman-astronaut: woman + ZWJ + rocket, one grapheme cluster. | ||
| 225 | e.feed("\u{1F469}\u{200D}\u{1F680}x"); | ||
| 226 | const s = try e.dumpPlain(alloc); | ||
| 227 | defer alloc.free(s); | ||
| 228 | try std.testing.expectEqualStrings("\u{1F469}\u{200D}\u{1F680}x", s); | ||
| 229 | } | ||
| 230 | |||
| 231 | test "Engine: SGR attributes survive a vt dump round-trip" { | ||
| 232 | const alloc = std.testing.allocator; | ||
| 233 | var a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 234 | defer a.deinit(); | ||
| 235 | |||
| 236 | a.feed("\x1b[1;31mbold red\x1b[0m plain \x1b[4;38;5;42munderline\x1b[0m"); | ||
| 237 | const dump_a = try a.dumpVt(alloc); | ||
| 238 | defer alloc.free(dump_a); | ||
| 239 | |||
| 240 | // Feed A's styled dump into a fresh engine; it must reproduce the | ||
| 241 | // same grid, and re-dumping must be a fixed point. | ||
| 242 | var b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 243 | defer b.deinit(); | ||
| 244 | b.feed(dump_a); | ||
| 245 | |||
| 246 | const plain_a = try a.dumpPlain(alloc); | ||
| 247 | defer alloc.free(plain_a); | ||
| 248 | const plain_b = try b.dumpPlain(alloc); | ||
| 249 | defer alloc.free(plain_b); | ||
| 250 | try std.testing.expectEqualStrings(plain_a, plain_b); | ||
| 251 | |||
| 252 | const dump_b = try b.dumpVt(alloc); | ||
| 253 | defer alloc.free(dump_b); | ||
| 254 | try std.testing.expectEqualStrings(dump_a, dump_b); | ||
| 255 | } | ||
| 256 | |||
| 257 | test "Engine: DSR cursor-position query response lands in ptyOutput" { | ||
| 258 | const alloc = std.testing.allocator; | ||
| 259 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 260 | defer e.deinit(); | ||
| 261 | |||
| 262 | e.feed("\x1b[6n"); | ||
| 263 | try std.testing.expectEqualStrings("\x1b[1;1R", e.ptyOutput()); | ||
| 264 | e.clearPtyOutput(); | ||
| 265 | try std.testing.expectEqual(@as(usize, 0), e.ptyOutput().len); | ||
| 266 | } | ||
| 267 | |||
| 268 | test "Engine: resize" { | ||
| 269 | const alloc = std.testing.allocator; | ||
| 270 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 271 | defer e.deinit(); | ||
| 272 | try e.resize(120, 40); | ||
| 273 | } | ||
| 274 | ``` | ||
| 275 | |||
| 276 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 277 | |||
| 278 | Run: `zig build test` | ||
| 279 | Expected: compile error — `Engine` not defined. | ||
| 280 | |||
| 281 | - [ ] **Step 3: Implement `Engine` (above the tests in `src/engine.zig`)** | ||
| 282 | |||
| 283 | ```zig | ||
| 284 | pub const Engine = struct { | ||
| 285 | alloc: std.mem.Allocator, | ||
| 286 | term: vt.Terminal, | ||
| 287 | stream: vt.TerminalStream, | ||
| 288 | /// Response bytes the terminal wants written back to the PTY | ||
| 289 | /// (cursor position reports, device attributes, ...). Owner drains | ||
| 290 | /// via ptyOutput()/clearPtyOutput(). | ||
| 291 | pty_out: std.ArrayList(u8), | ||
| 292 | |||
| 293 | pub const Options = struct { | ||
| 294 | cols: u16, | ||
| 295 | rows: u16, | ||
| 296 | max_scrollback: usize = 10_000, | ||
| 297 | }; | ||
| 298 | |||
| 299 | /// Heap-allocates: stream.handler holds a pointer to `term`, so an | ||
| 300 | /// Engine must never move after init. | ||
| 301 | pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine { | ||
| 302 | const self = try alloc.create(Engine); | ||
| 303 | errdefer alloc.destroy(self); | ||
| 304 | |||
| 305 | self.* = .{ | ||
| 306 | .alloc = alloc, | ||
| 307 | .term = try vt.Terminal.init(alloc, .{ | ||
| 308 | .cols = @intCast(opts.cols), | ||
| 309 | .rows = @intCast(opts.rows), | ||
| 310 | .max_scrollback = opts.max_scrollback, | ||
| 311 | }), | ||
| 312 | .stream = undefined, | ||
| 313 | .pty_out = .empty, | ||
| 314 | }; | ||
| 315 | errdefer self.term.deinit(alloc); | ||
| 316 | |||
| 317 | self.stream = .initAlloc(alloc, .{ .terminal = &self.term }); | ||
| 318 | self.stream.handler.effects.write_pty = &onWritePty; | ||
| 319 | return self; | ||
| 320 | } | ||
| 321 | |||
| 322 | pub fn deinit(self: *Engine) void { | ||
| 323 | self.pty_out.deinit(self.alloc); | ||
| 324 | self.stream.deinit(); | ||
| 325 | self.term.deinit(self.alloc); | ||
| 326 | self.alloc.destroy(self); | ||
| 327 | } | ||
| 328 | |||
| 329 | pub fn feed(self: *Engine, bytes: []const u8) void { | ||
| 330 | self.stream.nextSlice(bytes); | ||
| 331 | } | ||
| 332 | |||
| 333 | pub fn ptyOutput(self: *const Engine) []const u8 { | ||
| 334 | return self.pty_out.items; | ||
| 335 | } | ||
| 336 | |||
| 337 | pub fn clearPtyOutput(self: *Engine) void { | ||
| 338 | self.pty_out.clearRetainingCapacity(); | ||
| 339 | } | ||
| 340 | |||
| 341 | /// Visible screen as plain UTF-8 text. Caller frees. | ||
| 342 | pub fn dumpPlain(self: *Engine, alloc: std.mem.Allocator) ![]const u8 { | ||
| 343 | return self.term.plainString(alloc); | ||
| 344 | } | ||
| 345 | |||
| 346 | /// Visible screen with SGR/style sequences preserved. Caller frees. | ||
| 347 | pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 { | ||
| 348 | var aw: std.Io.Writer.Allocating = .init(alloc); | ||
| 349 | defer aw.deinit(); | ||
| 350 | const f = vt.formatter.TerminalFormatter.init(&self.term, .vt); | ||
| 351 | try f.format(&aw.writer); | ||
| 352 | return try aw.toOwnedSlice(); | ||
| 353 | } | ||
| 354 | |||
| 355 | pub fn resize(self: *Engine, cols: u16, rows: u16) !void { | ||
| 356 | try self.term.resize(self.alloc, @intCast(cols), @intCast(rows)); | ||
| 357 | } | ||
| 358 | |||
| 359 | fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void { | ||
| 360 | const stream_ptr: *vt.TerminalStream = @fieldParentPtr("handler", handler); | ||
| 361 | const self: *Engine = @fieldParentPtr("stream", stream_ptr); | ||
| 362 | self.pty_out.appendSlice(self.alloc, data) catch {}; | ||
| 363 | } | ||
| 364 | }; | ||
| 365 | ``` | ||
| 366 | |||
| 367 | API-drift notes for the implementer: if `TerminalFormatter.format` needs a mutable formatter, make `f` a `var`. If the fixed-point assertion in the SGR test fails on `extra`-emitted state (palette OSC 4 lines are expected and deterministic — they should be identical in both dumps), diagnose by printing both dumps with `std.testing.expectEqualStrings`'s diff output before weakening the test; only fall back to `plain_a == plain_b` plus substring checks for `[1m`/`[31m`-family sequences if the formatter output is genuinely non-idempotent, and record that in `docs/decisions.md`. | ||
| 368 | |||
| 369 | - [ ] **Step 4: Update the Task 1 smoke test to use Engine** | ||
| 370 | |||
| 371 | Replace the Task 1 test body with: | ||
| 372 | |||
| 373 | ```zig | ||
| 374 | test "ghostty-vt boots headless and text lands in the grid" { | ||
| 375 | const alloc = std.testing.allocator; | ||
| 376 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 377 | defer e.deinit(); | ||
| 378 | e.feed("hello"); | ||
| 379 | const s = try e.dumpPlain(alloc); | ||
| 380 | defer alloc.free(s); | ||
| 381 | try std.testing.expectEqualStrings("hello", s); | ||
| 382 | } | ||
| 383 | ``` | ||
| 384 | |||
| 385 | - [ ] **Step 5: Run tests to verify they pass** | ||
| 386 | |||
| 387 | Run: `zig build test` | ||
| 388 | Expected: exit 0. | ||
| 389 | |||
| 390 | - [ ] **Step 6: Commit** | ||
| 391 | |||
| 392 | ```bash | ||
| 393 | git add src/engine.zig | ||
| 394 | git commit -m "feat: Engine wrapper with byte-correct dump tests (wide, ZWJ, SGR, DSR)" | ||
| 395 | ``` | ||
| 396 | |||
| 397 | --- | ||
| 398 | |||
| 399 | ### Task 3: PTY module | ||
| 400 | |||
| 401 | Fresh implementation (not waystty's): blocking master fd, `poll`-driven by the caller; exit detection via `std.posix.waitpid` with `W.NOHANG`. | ||
| 402 | |||
| 403 | **Files:** | ||
| 404 | - Modify: `src/pty.zig` (currently empty) | ||
| 405 | |||
| 406 | - [ ] **Step 1: Write the failing tests** | ||
| 407 | |||
| 408 | ```zig | ||
| 409 | const std = @import("std"); | ||
| 410 | const c = @cImport({ | ||
| 411 | @cInclude("pty.h"); | ||
| 412 | @cInclude("stdlib.h"); | ||
| 413 | @cInclude("sys/ioctl.h"); | ||
| 414 | }); | ||
| 415 | |||
| 416 | test "Pty: spawn /bin/sh, echo round trip" { | ||
| 417 | var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); | ||
| 418 | defer pty.deinit(); | ||
| 419 | |||
| 420 | _ = try pty.write("echo m1-pty-ok\n"); | ||
| 421 | |||
| 422 | var out: std.ArrayList(u8) = .empty; | ||
| 423 | defer out.deinit(std.testing.allocator); | ||
| 424 | var buf: [4096]u8 = undefined; | ||
| 425 | |||
| 426 | // Poll-read up to 5s total; a loaded machine can be slow to exec sh. | ||
| 427 | var waited_ms: u64 = 0; | ||
| 428 | while (waited_ms < 5000) { | ||
| 429 | var fds = [_]std.posix.pollfd{ | ||
| 430 | .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 431 | }; | ||
| 432 | const ready = try std.posix.poll(&fds, 100); | ||
| 433 | waited_ms += 100; | ||
| 434 | if (ready == 0) continue; | ||
| 435 | const n = std.posix.read(pty.master, &buf) catch break; | ||
| 436 | if (n == 0) break; | ||
| 437 | try out.appendSlice(std.testing.allocator, buf[0..n]); | ||
| 438 | if (std.mem.indexOf(u8, out.items, "m1-pty-ok") != null) break; | ||
| 439 | } | ||
| 440 | try std.testing.expect(std.mem.indexOf(u8, out.items, "m1-pty-ok") != null); | ||
| 441 | } | ||
| 442 | |||
| 443 | test "Pty: resize is visible via TIOCGWINSZ" { | ||
| 444 | var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); | ||
| 445 | defer pty.deinit(); | ||
| 446 | |||
| 447 | try pty.resize(120, 40); | ||
| 448 | |||
| 449 | var ws: c.struct_winsize = undefined; | ||
| 450 | try std.testing.expectEqual( | ||
| 451 | @as(c_int, 0), | ||
| 452 | c.ioctl(pty.master, c.TIOCGWINSZ, &ws), | ||
| 453 | ); | ||
| 454 | try std.testing.expectEqual(@as(c_ushort, 120), ws.ws_col); | ||
| 455 | try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row); | ||
| 456 | } | ||
| 457 | |||
| 458 | test "Pty: checkExited reports shell exit" { | ||
| 459 | var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" }); | ||
| 460 | defer pty.deinit(); | ||
| 461 | |||
| 462 | try std.testing.expect(pty.checkExited() == null); | ||
| 463 | _ = try pty.write("exit 7\n"); | ||
| 464 | |||
| 465 | var waited_ms: u64 = 0; | ||
| 466 | var code: ?u32 = null; | ||
| 467 | while (waited_ms < 5000) : (waited_ms += 50) { | ||
| 468 | code = pty.checkExited(); | ||
| 469 | if (code != null) break; | ||
| 470 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 471 | } | ||
| 472 | try std.testing.expectEqual(@as(?u32, 7), code); | ||
| 473 | } | ||
| 474 | ``` | ||
| 475 | |||
| 476 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 477 | |||
| 478 | Run: `zig build test` | ||
| 479 | Expected: compile error — `Pty` not defined. | ||
| 480 | |||
| 481 | - [ ] **Step 3: Implement `Pty` (above the tests)** | ||
| 482 | |||
| 483 | ```zig | ||
| 484 | pub const Pty = struct { | ||
| 485 | master: std.posix.fd_t, | ||
| 486 | child: std.posix.pid_t, | ||
| 487 | exit_status: ?u32 = null, | ||
| 488 | |||
| 489 | pub const SpawnOptions = struct { | ||
| 490 | cols: u16, | ||
| 491 | rows: u16, | ||
| 492 | shell: [:0]const u8, | ||
| 493 | }; | ||
| 494 | |||
| 495 | pub fn spawn(opts: SpawnOptions) !Pty { | ||
| 496 | var master: c_int = undefined; | ||
| 497 | var ws: c.struct_winsize = .{ | ||
| 498 | .ws_row = opts.rows, | ||
| 499 | .ws_col = opts.cols, | ||
| 500 | .ws_xpixel = 0, | ||
| 501 | .ws_ypixel = 0, | ||
| 502 | }; | ||
| 503 | |||
| 504 | const pid = c.forkpty(&master, null, null, &ws); | ||
| 505 | if (pid < 0) return error.ForkPtyFailed; | ||
| 506 | |||
| 507 | if (pid == 0) { | ||
| 508 | // Child. xterm-256color: ghostty-vt understands more, but this | ||
| 509 | // terminfo exists everywhere the shell will look. | ||
| 510 | _ = c.setenv("TERM", "xterm-256color", 1); | ||
| 511 | var argv = [_:null]?[*:0]const u8{ opts.shell.ptr, null }; | ||
| 512 | std.posix.execveZ(opts.shell.ptr, &argv, std.c.environ) catch {}; | ||
| 513 | std.process.exit(127); | ||
| 514 | } | ||
| 515 | |||
| 516 | return .{ .master = master, .child = pid }; | ||
| 517 | } | ||
| 518 | |||
| 519 | pub fn read(self: *Pty, buf: []u8) !usize { | ||
| 520 | return std.posix.read(self.master, buf); | ||
| 521 | } | ||
| 522 | |||
| 523 | pub fn write(self: *Pty, data: []const u8) !usize { | ||
| 524 | return std.posix.write(self.master, data); | ||
| 525 | } | ||
| 526 | |||
| 527 | pub fn resize(self: *Pty, cols: u16, rows: u16) !void { | ||
| 528 | var ws: c.struct_winsize = .{ | ||
| 529 | .ws_row = rows, | ||
| 530 | .ws_col = cols, | ||
| 531 | .ws_xpixel = 0, | ||
| 532 | .ws_ypixel = 0, | ||
| 533 | }; | ||
| 534 | if (c.ioctl(self.master, c.TIOCSWINSZ, &ws) < 0) return error.IoctlFailed; | ||
| 535 | } | ||
| 536 | |||
| 537 | /// Non-blocking: exit code if the child has exited, else null. | ||
| 538 | pub fn checkExited(self: *Pty) ?u32 { | ||
| 539 | if (self.exit_status) |s| return s; | ||
| 540 | const res = std.posix.waitpid(self.child, std.posix.W.NOHANG); | ||
| 541 | if (res.pid != self.child) return null; | ||
| 542 | self.exit_status = if (std.posix.W.IFEXITED(res.status)) | ||
| 543 | std.posix.W.EXITSTATUS(res.status) | ||
| 544 | else | ||
| 545 | 128; | ||
| 546 | return self.exit_status; | ||
| 547 | } | ||
| 548 | |||
| 549 | pub fn deinit(self: *Pty) void { | ||
| 550 | std.posix.close(self.master); | ||
| 551 | if (self.exit_status == null) { | ||
| 552 | std.posix.kill(self.child, std.posix.SIG.TERM) catch {}; | ||
| 553 | _ = std.posix.waitpid(self.child, 0); | ||
| 554 | } | ||
| 555 | } | ||
| 556 | }; | ||
| 557 | ``` | ||
| 558 | |||
| 559 | API-drift note: `std.posix.waitpid(pid, W.NOHANG)` returns a `WaitPidResult` with `.pid` and `.status`; on "still running" the returned pid is 0. If the shape differs on this std version, check `std.posix` source under `~/.local/bin/../lib/zig` (or `zig std`). | ||
| 560 | |||
| 561 | - [ ] **Step 4: Run tests to verify they pass** | ||
| 562 | |||
| 563 | Run: `zig build test` | ||
| 564 | Expected: exit 0. | ||
| 565 | |||
| 566 | - [ ] **Step 5: Commit** | ||
| 567 | |||
| 568 | ```bash | ||
| 569 | git add src/pty.zig | ||
| 570 | git commit -m "feat: blocking-fd Pty with spawn/resize/exit detection" | ||
| 571 | ``` | ||
| 572 | |||
| 573 | --- | ||
| 574 | |||
| 575 | ### Task 4: Debug dump socket | ||
| 576 | |||
| 577 | M1-only, replaced wholesale in M2. Protocol: client sends one LF-terminated command (`dump plain` or `dump vt`), server replies with raw payload and closes (EOF-delimited). | ||
| 578 | |||
| 579 | **Files:** | ||
| 580 | - Modify: `src/debug.zig` (currently empty) | ||
| 581 | |||
| 582 | - [ ] **Step 1: Write the failing test** | ||
| 583 | |||
| 584 | ```zig | ||
| 585 | const std = @import("std"); | ||
| 586 | const Engine = @import("engine").Engine; | ||
| 587 | |||
| 588 | test "DebugServer: dump plain round trip over unix socket" { | ||
| 589 | const alloc = std.testing.allocator; | ||
| 590 | |||
| 591 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | ||
| 592 | defer e.deinit(); | ||
| 593 | e.feed("debug-sock-ok"); | ||
| 594 | |||
| 595 | var tmp = std.testing.tmpDir(.{}); | ||
| 596 | defer tmp.cleanup(); | ||
| 597 | var path_buf: [256]u8 = undefined; | ||
| 598 | const dir_path = try tmp.dir.realpath(".", &path_buf); | ||
| 599 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/d.sock", .{dir_path}); | ||
| 600 | defer alloc.free(sock_path); | ||
| 601 | |||
| 602 | var srv = try DebugServer.init(sock_path); | ||
| 603 | defer srv.deinit(); | ||
| 604 | |||
| 605 | const Client = struct { | ||
| 606 | fn go(path: []const u8, out: *std.ArrayList(u8), a: std.mem.Allocator) !void { | ||
| 607 | const stream = try std.net.connectUnixSocket(path); | ||
| 608 | defer stream.close(); | ||
| 609 | var idx: usize = 0; | ||
| 610 | const msg = "dump plain\n"; | ||
| 611 | while (idx < msg.len) idx += try std.posix.write(stream.handle, msg[idx..]); | ||
| 612 | var buf: [4096]u8 = undefined; | ||
| 613 | while (true) { | ||
| 614 | const n = try std.posix.read(stream.handle, &buf); | ||
| 615 | if (n == 0) break; | ||
| 616 | try out.appendSlice(a, buf[0..n]); | ||
| 617 | } | ||
| 618 | } | ||
| 619 | }; | ||
| 620 | |||
| 621 | var reply: std.ArrayList(u8) = .empty; | ||
| 622 | defer reply.deinit(alloc); | ||
| 623 | const t = try std.Thread.spawn(.{}, Client.go, .{ sock_path, &reply, alloc }); | ||
| 624 | |||
| 625 | srv.serviceOne(alloc, e); // blocks in accept until the client connects | ||
| 626 | t.join(); | ||
| 627 | |||
| 628 | try std.testing.expectEqualStrings("debug-sock-ok", reply.items); | ||
| 629 | } | ||
| 630 | ``` | ||
| 631 | |||
| 632 | - [ ] **Step 2: Run tests to verify they fail** | ||
| 633 | |||
| 634 | Run: `zig build test` | ||
| 635 | Expected: compile error — `DebugServer` not defined. | ||
| 636 | |||
| 637 | - [ ] **Step 3: Implement `DebugServer` (above the test)** | ||
| 638 | |||
| 639 | ```zig | ||
| 640 | /// M1-only debug listener. One LF-terminated command per connection: | ||
| 641 | /// "dump plain" | "dump vt" | ||
| 642 | /// Reply is the raw payload, EOF-delimited. Replaced by the real | ||
| 643 | /// protocol in M2. | ||
| 644 | pub const DebugServer = struct { | ||
| 645 | server: std.net.Server, | ||
| 646 | path: []const u8, | ||
| 647 | |||
| 648 | pub fn init(path: []const u8) !DebugServer { | ||
| 649 | std.fs.cwd().deleteFile(path) catch {}; | ||
| 650 | const addr = try std.net.Address.initUnix(path); | ||
| 651 | return .{ .server = try addr.listen(.{}), .path = path }; | ||
| 652 | } | ||
| 653 | |||
| 654 | pub fn deinit(self: *DebugServer) void { | ||
| 655 | self.server.deinit(); | ||
| 656 | std.fs.cwd().deleteFile(self.path) catch {}; | ||
| 657 | } | ||
| 658 | |||
| 659 | /// Pollable listener fd for the daemon's event loop. | ||
| 660 | pub fn fd(self: *const DebugServer) std.posix.fd_t { | ||
| 661 | return self.server.stream.handle; | ||
| 662 | } | ||
| 663 | |||
| 664 | /// Accept one connection, service it synchronously, close it. | ||
| 665 | /// Dumps are small and local; blocking here is fine for a debug tool. | ||
| 666 | pub fn serviceOne(self: *DebugServer, alloc: std.mem.Allocator, eng: *Engine) void { | ||
| 667 | const conn = self.server.accept() catch return; | ||
| 668 | defer conn.stream.close(); | ||
| 669 | |||
| 670 | var buf: [256]u8 = undefined; | ||
| 671 | const n = std.posix.read(conn.stream.handle, &buf) catch return; | ||
| 672 | const line = std.mem.trimRight(u8, buf[0..n], "\r\n"); | ||
| 673 | |||
| 674 | const reply: []const u8 = if (std.mem.eql(u8, line, "dump plain")) | ||
| 675 | eng.dumpPlain(alloc) catch return | ||
| 676 | else if (std.mem.eql(u8, line, "dump vt")) | ||
| 677 | eng.dumpVt(alloc) catch return | ||
| 678 | else | ||
| 679 | "error: unknown command (want: dump plain | dump vt)"; | ||
| 680 | const owned = !std.mem.startsWith(u8, reply, "error:"); | ||
| 681 | defer if (owned) alloc.free(reply); | ||
| 682 | |||
| 683 | var idx: usize = 0; | ||
| 684 | while (idx < reply.len) { | ||
| 685 | idx += std.posix.write(conn.stream.handle, reply[idx..]) catch return; | ||
| 686 | } | ||
| 687 | } | ||
| 688 | }; | ||
| 689 | ``` | ||
| 690 | |||
| 691 | - [ ] **Step 4: Run tests to verify they pass** | ||
| 692 | |||
| 693 | Run: `zig build test` | ||
| 694 | Expected: exit 0. | ||
| 695 | |||
| 696 | - [ ] **Step 5: Commit** | ||
| 697 | |||
| 698 | ```bash | ||
| 699 | git add src/debug.zig | ||
| 700 | git commit -m "feat: M1 debug dump socket (line command, EOF-delimited reply)" | ||
| 701 | ``` | ||
| 702 | |||
| 703 | --- | ||
| 704 | |||
| 705 | ### Task 5: `muxd` main — wire it together | ||
| 706 | |||
| 707 | **Files:** | ||
| 708 | - Modify: `src/main.zig` (replace stub) | ||
| 709 | - Create: `test/e2e.sh` | ||
| 710 | |||
| 711 | - [ ] **Step 1: Write the failing e2e test `test/e2e.sh`** | ||
| 712 | |||
| 713 | ```sh | ||
| 714 | #!/bin/sh | ||
| 715 | # End-to-end: run muxd headless with piped stdin, dump the grid from a | ||
| 716 | # second process, verify shell output landed in the ghostty-vt grid. | ||
| 717 | set -eu | ||
| 718 | MUXD="$1" | ||
| 719 | SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" | ||
| 720 | |||
| 721 | cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK"; } | ||
| 722 | trap cleanup EXIT INT TERM | ||
| 723 | |||
| 724 | { printf 'printf "e2e-%%s\\n" works\n'; sleep 3; } | \ | ||
| 725 | "$MUXD" run --sock "$SOCK" --shell /bin/sh & | ||
| 726 | DPID=$! | ||
| 727 | |||
| 728 | # Wait for the socket, then give the shell a moment to run the command. | ||
| 729 | i=0 | ||
| 730 | while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | ||
| 731 | [ -S "$SOCK" ] || { echo "e2e FAIL: socket never appeared"; exit 1; } | ||
| 732 | sleep 1 | ||
| 733 | |||
| 734 | OUT="$("$MUXD" dump --sock "$SOCK")" | ||
| 735 | case "$OUT" in | ||
| 736 | *e2e-works*) echo "e2e OK" ;; | ||
| 737 | *) echo "e2e FAIL: grid was:"; echo "$OUT"; exit 1 ;; | ||
| 738 | esac | ||
| 739 | ``` | ||
| 740 | |||
| 741 | Then: `chmod +x test/e2e.sh` | ||
| 742 | |||
| 743 | - [ ] **Step 2: Run it to verify it fails** | ||
| 744 | |||
| 745 | Run: `zig build e2e` | ||
| 746 | Expected: failure — `muxd run` is still a stub (unknown args, no socket). | ||
| 747 | |||
| 748 | - [ ] **Step 3: Implement `src/main.zig`** | ||
| 749 | |||
| 750 | ```zig | ||
| 751 | const std = @import("std"); | ||
| 752 | const Engine = @import("engine").Engine; | ||
| 753 | const Pty = @import("pty").Pty; | ||
| 754 | const debug = @import("debug"); | ||
| 755 | |||
| 756 | const usage = | ||
| 757 | \\usage: | ||
| 758 | \\ muxd run [--sock PATH] [--shell PATH] run daemon in foreground; | ||
| 759 | \\ stdin is forwarded to the PTY | ||
| 760 | \\ muxd dump [--vt] [--sock PATH] print the current grid | ||
| 761 | \\ | ||
| 762 | ; | ||
| 763 | |||
| 764 | pub fn main() !u8 { | ||
| 765 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 766 | defer _ = gpa.deinit(); | ||
| 767 | const alloc = gpa.allocator(); | ||
| 768 | |||
| 769 | const args = try std.process.argsAlloc(alloc); | ||
| 770 | defer std.process.argsFree(alloc, args); | ||
| 771 | |||
| 772 | if (args.len < 2) { | ||
| 773 | std.debug.print("{s}", .{usage}); | ||
| 774 | return 2; | ||
| 775 | } | ||
| 776 | |||
| 777 | var sock_arg: ?[]const u8 = null; | ||
| 778 | var shell_arg: ?[]const u8 = null; | ||
| 779 | var vt_mode = false; | ||
| 780 | var i: usize = 2; | ||
| 781 | while (i < args.len) : (i += 1) { | ||
| 782 | const a = args[i]; | ||
| 783 | if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | ||
| 784 | i += 1; | ||
| 785 | sock_arg = args[i]; | ||
| 786 | } else if (std.mem.eql(u8, a, "--shell") and i + 1 < args.len) { | ||
| 787 | i += 1; | ||
| 788 | shell_arg = args[i]; | ||
| 789 | } else if (std.mem.eql(u8, a, "--vt")) { | ||
| 790 | vt_mode = true; | ||
| 791 | } else { | ||
| 792 | std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }); | ||
| 793 | return 2; | ||
| 794 | } | ||
| 795 | } | ||
| 796 | |||
| 797 | const sock_path = if (sock_arg) |s| | ||
| 798 | try alloc.dupe(u8, s) | ||
| 799 | else | ||
| 800 | try defaultSockPath(alloc); | ||
| 801 | defer alloc.free(sock_path); | ||
| 802 | |||
| 803 | if (std.mem.eql(u8, args[1], "run")) return run(alloc, sock_path, shell_arg); | ||
| 804 | if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode); | ||
| 805 | std.debug.print("{s}", .{usage}); | ||
| 806 | return 2; | ||
| 807 | } | ||
| 808 | |||
| 809 | fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { | ||
| 810 | if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| { | ||
| 811 | return std.fmt.allocPrint(alloc, "{s}/muxd-debug.sock", .{dir}); | ||
| 812 | } | ||
| 813 | return std.fmt.allocPrint(alloc, "/tmp/muxd-debug-{d}.sock", .{std.os.linux.getuid()}); | ||
| 814 | } | ||
| 815 | |||
| 816 | fn run(alloc: std.mem.Allocator, sock_path: []const u8, shell_arg: ?[]const u8) !u8 { | ||
| 817 | const shell_z: [:0]const u8 = if (shell_arg) |s| | ||
| 818 | try alloc.dupeZ(u8, s) | ||
| 819 | else | ||
| 820 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); | ||
| 821 | defer alloc.free(shell_z); | ||
| 822 | |||
| 823 | // Grid size: the controlling tty's size if we have one, else 80x24. | ||
| 824 | var cols: u16 = 80; | ||
| 825 | var rows: u16 = 24; | ||
| 826 | if (std.posix.isatty(std.posix.STDIN_FILENO)) { | ||
| 827 | var ws: std.posix.winsize = undefined; | ||
| 828 | if (std.os.linux.ioctl( | ||
| 829 | std.posix.STDIN_FILENO, | ||
| 830 | std.os.linux.T.IOCGWINSZ, | ||
| 831 | @intFromPtr(&ws), | ||
| 832 | ) == 0) { | ||
| 833 | cols = ws.col; | ||
| 834 | rows = ws.row; | ||
| 835 | } | ||
| 836 | } | ||
| 837 | |||
| 838 | const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows }); | ||
| 839 | defer eng.deinit(); | ||
| 840 | |||
| 841 | var pty = try Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell_z }); | ||
| 842 | defer pty.deinit(); | ||
| 843 | |||
| 844 | var srv = try debug.DebugServer.init(sock_path); | ||
| 845 | defer srv.deinit(); | ||
| 846 | |||
| 847 | // Raw mode so keystrokes (arrows, ^C) pass through to the PTY. | ||
| 848 | const stdin_fd = std.posix.STDIN_FILENO; | ||
| 849 | var orig_termios: ?std.posix.termios = null; | ||
| 850 | if (std.posix.isatty(stdin_fd)) { | ||
| 851 | const orig = try std.posix.tcgetattr(stdin_fd); | ||
| 852 | orig_termios = orig; | ||
| 853 | var raw = orig; | ||
| 854 | raw.lflag.ICANON = false; | ||
| 855 | raw.lflag.ECHO = false; | ||
| 856 | raw.lflag.ISIG = false; | ||
| 857 | raw.iflag.IXON = false; | ||
| 858 | raw.iflag.ICRNL = false; | ||
| 859 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | ||
| 860 | } | ||
| 861 | defer if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | ||
| 862 | |||
| 863 | var stdin_open = true; | ||
| 864 | var buf: [64 * 1024]u8 = undefined; | ||
| 865 | while (true) { | ||
| 866 | if (pty.checkExited()) |code| return @intCast(code & 0xff); | ||
| 867 | |||
| 868 | var fds = [_]std.posix.pollfd{ | ||
| 869 | .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 870 | .{ .fd = srv.fd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 871 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 872 | }; | ||
| 873 | // 100ms timeout so child exit is noticed even with no fd activity. | ||
| 874 | _ = std.posix.poll(&fds, 100) catch |err| switch (err) { | ||
| 875 | error.SignalInterrupt => continue, | ||
| 876 | else => return err, | ||
| 877 | }; | ||
| 878 | |||
| 879 | if (fds[0].revents & std.posix.POLL.IN != 0) { | ||
| 880 | const n = std.posix.read(pty.master, &buf) catch 0; | ||
| 881 | if (n > 0) { | ||
| 882 | eng.feed(buf[0..n]); | ||
| 883 | const resp = eng.ptyOutput(); | ||
| 884 | if (resp.len > 0) { | ||
| 885 | writeAll(pty.master, resp); | ||
| 886 | eng.clearPtyOutput(); | ||
| 887 | } | ||
| 888 | } | ||
| 889 | } | ||
| 890 | |||
| 891 | if (fds[1].revents & std.posix.POLL.IN != 0) srv.serviceOne(alloc, eng); | ||
| 892 | |||
| 893 | if (stdin_open and fds[2].revents & std.posix.POLL.IN != 0) { | ||
| 894 | const n = std.posix.read(stdin_fd, &buf) catch 0; | ||
| 895 | if (n == 0) { | ||
| 896 | stdin_open = false; // piped stdin closed; keep running headless | ||
| 897 | } else { | ||
| 898 | writeAll(pty.master, buf[0..n]); | ||
| 899 | } | ||
| 900 | } | ||
| 901 | } | ||
| 902 | } | ||
| 903 | |||
| 904 | fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool) !u8 { | ||
| 905 | _ = alloc; | ||
| 906 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 907 | std.debug.print("muxd dump: cannot connect to {s} (is `muxd run` running?)\n", .{sock_path}); | ||
| 908 | return 1; | ||
| 909 | }; | ||
| 910 | defer stream.close(); | ||
| 911 | |||
| 912 | writeAll(stream.handle, if (vt_mode) "dump vt\n" else "dump plain\n"); | ||
| 913 | |||
| 914 | var buf: [4096]u8 = undefined; | ||
| 915 | while (true) { | ||
| 916 | const n = std.posix.read(stream.handle, &buf) catch break; | ||
| 917 | if (n == 0) break; | ||
| 918 | writeAll(std.posix.STDOUT_FILENO, buf[0..n]); | ||
| 919 | } | ||
| 920 | writeAll(std.posix.STDOUT_FILENO, "\n"); | ||
| 921 | return 0; | ||
| 922 | } | ||
| 923 | |||
| 924 | fn writeAll(fd: std.posix.fd_t, data: []const u8) void { | ||
| 925 | var idx: usize = 0; | ||
| 926 | while (idx < data.len) { | ||
| 927 | idx += std.posix.write(fd, data[idx..]) catch return; | ||
| 928 | } | ||
| 929 | } | ||
| 930 | ``` | ||
| 931 | |||
| 932 | API-drift notes: `std.posix.winsize` field names are `row`/`col`/`xpixel`/`ypixel` on current std (older: `ws_row`/`ws_col`). `poll` error set may not include `SignalInterrupt` (std retries EINTR internally on some versions) — if the compiler says the switch arm is unreachable, drop the switch. `main` returning `!u8` sets the process exit code. | ||
| 933 | |||
| 934 | - [ ] **Step 4: Run unit tests, then e2e** | ||
| 935 | |||
| 936 | Run: `zig build test` | ||
| 937 | Expected: exit 0. | ||
| 938 | Run: `zig build e2e` | ||
| 939 | Expected: prints `e2e OK`. | ||
| 940 | |||
| 941 | - [ ] **Step 5: Commit** | ||
| 942 | |||
| 943 | ```bash | ||
| 944 | git add src/main.zig test/e2e.sh | ||
| 945 | git commit -m "feat: muxd run/dump wired through poll loop; e2e passes" | ||
| 946 | ``` | ||
| 947 | |||
| 948 | --- | ||
| 949 | |||
| 950 | ### Task 6: Demo, decision log, README | ||
| 951 | |||
| 952 | **Files:** | ||
| 953 | - Create: `docs/decisions.md`, `README.md` | ||
| 954 | |||
| 955 | - [ ] **Step 1: Manual demo (the M1 acceptance run)** | ||
| 956 | |||
| 957 | In terminal A: | ||
| 958 | ```bash | ||
| 959 | cd /home/xanderle/code/rad/mux && zig build && ./zig-out/bin/muxd run | ||
| 960 | ``` | ||
| 961 | Terminal A now forwards keystrokes blind (the grid lives only in the daemon). | ||
| 962 | |||
| 963 | In terminal B, after typing each of the following in A, run `./zig-out/bin/muxd dump` and compare against reality: | ||
| 964 | 1. Type `ls -la<Enter>` in A → dump shows the listing. | ||
| 965 | 2. Type `htop<Enter>` in A → dump shows htop's frame (boxes/bars as text); `q` to quit. | ||
| 966 | 3. Type `vim /tmp/m1.txt<Enter>`, `i`, `héllo 漢字 👩🚀`, `<Esc>:wq<Enter>` → dumps during editing show vim's UI including the tildes and statusline. | ||
| 967 | 4. `printf 'á漢👩🚀\n%.0s' $(seq 40) > /tmp/utf8.txt; less /tmp/utf8.txt` → dump matches; also run `./zig-out/bin/muxd dump --vt` and `printf` the output in a real terminal — colors/styles must reproduce. | ||
| 968 | |||
| 969 | Record any mismatch as a bug before declaring M1 done. If a TUI hangs waiting for a terminal query response, the missing piece is an `effects` callback in `engine.zig` (likely `device_attributes` — wire it to return defaults `.{}`; see `stream_terminal.zig` in the pinned package for the exact signature). | ||
| 970 | |||
| 971 | - [ ] **Step 2: Write `docs/decisions.md`** | ||
| 972 | |||
| 973 | ```markdown | ||
| 974 | # Decision log | ||
| 975 | |||
| 976 | ## 2026-08-07 (M1) | ||
| 977 | |||
| 978 | - **Language: Zig.** The engine dependency (ghostty-vt) is a Zig module; a C | ||
| 979 | shim would add surface without adding capability. | ||
| 980 | - **Engine: upstream ghostty package, not a fork.** Pinned at commit | ||
| 981 | `853183e9` (1.3.2-dev), module `ghostty-vt`. The M1 kill criterion | ||
| 982 | ("cannot extract grid without invasive forking") is moot: upstream ships | ||
| 983 | a headless VT library with plain/VT/HTML formatters and a RenderState | ||
| 984 | dirty-tracking API (relevant for M4 deltas). API is documented unstable; | ||
| 985 | the pin is load-bearing. | ||
| 986 | - **No code reuse from waystty** (user decision: not performant). ghostty-vt | ||
| 987 | API knowledge only. muxd uses blocking fds + poll, single thread. | ||
| 988 | - **M1 debug protocol:** one LF-terminated command per connection on | ||
| 989 | `$XDG_RUNTIME_DIR/muxd-debug.sock`, EOF-delimited reply. Throwaway; | ||
| 990 | M2 replaces it and claims `muxd.sock` for the real protocol. | ||
| 991 | - **TERM=xterm-256color** in the child, not xterm-ghostty: terminfo | ||
| 992 | availability beats capability advertising for a prototype. | ||
| 993 | - **Scrollback: engine-native.** ghostty-vt's max_scrollback (10k lines) | ||
| 994 | is the ring buffer; no separate structure in muxd. | ||
| 995 | |||
| 996 | ## Open (owed by later milestones) | ||
| 997 | |||
| 998 | - Resize policy under multiple clients (M5) | ||
| 999 | - Snapshot-vs-delta threshold (M4) | ||
| 1000 | - Scrollback retention/eviction limits (M3/M4) | ||
| 1001 | - Daemon lifetime across logout/reboot (M2) | ||
| 1002 | - Wire format msgpack vs protobuf + versioning (M2/M4) | ||
| 1003 | ``` | ||
| 1004 | |||
| 1005 | - [ ] **Step 3: Write `README.md`** | ||
| 1006 | |||
| 1007 | ```markdown | ||
| 1008 | # mux | ||
| 1009 | |||
| 1010 | Prototype terminal multiplexer: the terminal engine (ghostty-vt) runs | ||
| 1011 | authoritatively in a daemon and replicated in the client — state sync | ||
| 1012 | instead of escape-sequence replay. See `docs/handoff.md` for the design | ||
| 1013 | and `docs/decisions.md` for decisions made. | ||
| 1014 | |||
| 1015 | Status: **M1 — headless engine.** | ||
| 1016 | |||
| 1017 | zig build test && zig build e2e # verify | ||
| 1018 | ./zig-out/bin/muxd run # daemon, forwards stdin to the PTY | ||
| 1019 | ./zig-out/bin/muxd dump [--vt] # print the authoritative grid | ||
| 1020 | ``` | ||
| 1021 | |||
| 1022 | - [ ] **Step 4: Commit** | ||
| 1023 | |||
| 1024 | ```bash | ||
| 1025 | git add docs/decisions.md README.md | ||
| 1026 | git commit -m "docs: M1 decision log, README, demo instructions" | ||
| 1027 | ``` | ||
| 1028 | |||
| 1029 | --- | ||
| 1030 | |||
| 1031 | ## Self-Review | ||
| 1032 | |||
| 1033 | - **Spec coverage:** M1 spec = spawn PTY (Task 3) + run `$SHELL` (Task 3/5) + feed output into libghostty (Tasks 2/5) + debug dump command (Tasks 4/5) + byte-correct wide/grapheme/SGR (Task 2 tests) + demo with htop/vim/less UTF-8 (Task 6). Kill criterion is checked at Task 1 Step 5. Scrollback ring buffer from the architecture diagram is engine-native (decision recorded). | ||
| 1034 | - **Placeholders:** none; all steps carry complete code or exact commands. | ||
| 1035 | - **Type consistency:** `Engine.init/deinit/feed/ptyOutput/clearPtyOutput/dumpPlain/dumpVt/resize` used identically in Tasks 2, 4, 5. `Pty.master/spawn/read/write/resize/checkExited/deinit` consistent across Tasks 3, 5. `DebugServer.init/deinit/fd/serviceOne` consistent across Tasks 4, 5. | ||
| 1036 | - **Known risk, stated where it bites:** ghostty-vt's API is declared unstable; each code step carries an API-drift note pointing at the exact pinned source file to consult. | ||