Skip to content

Add a control socket and bundled macterm CLI (read-only verbs)#139

Merged
thdxg merged 1 commit into
mainfrom
claude/mystifying-colden-103489
Jul 5, 2026
Merged

Add a control socket and bundled macterm CLI (read-only verbs)#139
thdxg merged 1 commit into
mainfrom
claude/mystifying-colden-103489

Conversation

@thdxg

@thdxg thdxg commented Jul 4, 2026

Copy link
Copy Markdown
Owner

First of three PRs for #107 (Macterm CLI) — also groundwork for realistic CI benchmark workloads (#129 follow-up). This slice ships the IPC control plane and the read-only command set; mutations (project create/select, tab new, pane split/run, grid, session kill, layout apply/save) come in PR 2, and the benchmark workload integration in PR 3.

What's here

  • Macterm/Control/ControlProtocol.swift — wire types, compiled into both the app and CLI targets so the codec can't drift. One request per connection: newline-terminated JSON both ways, client half-closes after sending. Requests are {v, id, command, args} with noun.verb commands; responses echo id with {ok, data} or a typed error {code, message, action?} where action is a human recovery hint.
  • ControlSocketServer — Unix socket at <App Support>/control.sock (per build flavor; follows MACTERM_BENCHMARK_DATA_DIR). Dedicated thread polling a non-blocking listen fd — never a blocking accept() on a queue shared with stop(). Per-connection dispatch hops to @MainActor. Starts at launch; requests before AppState exists get a starting error (so a readiness poll sees a well-formed response). FD_CLOEXEC keeps spawned shells from holding the socket past quit; socket file is 0600 and unlinked on quit.
  • ControlHandler — translate-and-delegate only: resolves selectors (name / UUID / 1-based index for projects and tabs; duplicates surface as typed ambiguous errors) and calls the same AppState/ProjectStore/ZmxClient paths the UI uses. Reads zmx via appState.zmx so tests stub one seam.
  • CLI/ (MactermCLI tool target → Contents/Resources/bin/macterm) — swift-argument-parser command tree: status, project list, tab list, pane list, session list|info, all with --json. The app prepends the bundled bin dir to PATH and exports MACTERM_SOCKET before any shell spawns, so macterm works inside every pane.

Design notes (from reading cmux, Zentty, Supacode source)

  • Socket-ownership check before connect (cmux #389): refuse a socket not owned by the current user.
  • Env var is a discovery hint, not a pin (cmux #5146): a stale MACTERM_SOCKET export falls through to the well-known release/debug paths instead of bricking every already-spawned shell after an app restart. Only --socket pins.
  • Non-blocking connect + 250ms poll so a dead socket file never hangs the CLI; safe-fail contract (stdout only on success; exit 0/1/2 = ok / app error / unreachable).
  • Security posture: same-user only via filesystem permissions — the same boundary zmx itself uses. No token auth by design; the envelope leaves room for an auth field.

Gotcha worth knowing: the CLI target's PRODUCT_NAME is macterm, but PRODUCT_MODULE_NAME must stay MactermCLI — a macterm module collides case-insensitively with the app's Macterm.swiftmodule in the shared products dir and breaks the hosted test target's @testable import.

Testing

  • mise run format/lint/test green — new suites: ControlProtocolTests (codec round-trips, forward-compat, hand-built CLI JSON), ControlHandlerTests (queries + selector resolution + zmx stubs against real AppState with tempdir stores), ControlSocketServerTests (real socket round-trips, starting before attach, stale-file replacement, overlong-path refusal, unlink on stop).
  • Verified live against the debug app: status, project list, tab list, pane list --json, session list (cross-checked against zmx ls — sessions from another running Macterm instance correctly show as unbound), session info hit/miss, exit-2-with-paths-tried when the app is quit, and socket unlink on quit.

External apps and AI agents need to drive Macterm programmatically
(#107), and the CI benchmark needs to spawn realistic workloads
headlessly. Darwin notifications (BenchmarkControl) carry no payload or
response, so this adds a Unix-socket control plane: newline-delimited
JSON requests dispatched on the main actor into the same AppState paths
the UI uses, and a bundled ArgumentParser CLI at
Contents/Resources/bin/macterm (PATH-injected into every pane along
with MACTERM_SOCKET).

This first slice ships the protocol, server, and read-only verbs —
status, project/tab/pane list, session list/info. Mutations (create,
select, split, run) and the benchmark workload follow in separate PRs.

Design notes drawn from cmux/Zentty/Supacode source: socket-ownership
check before connect, env var as discovery hint (never a hard pin, so a
stale export can't brick shells after an app restart), non-blocking
connect with a short poll, error responses carrying recovery hints, and
a safe-fail CLI contract (stdout only on success; exit 2 when the app
is unreachable).
@github-actions github-actions Bot added area:tests Test changes area:docs Documentation benchmark:regression CI benchmark: significant resource regression vs main labels Jul 4, 2026
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Window-state benchmark

State Metric main@34d98b263 this branch Δ
focused CPU % 1.10 0.87 -21%
Memory (RSS MB) 107.7 107.7 +0%
CPU ms/s (powermetrics) 10.9 8.7 -20%
Wakeups/s (powermetrics) 132.1 192.1 +45% 🔺
unfocused CPU % 1.03 0.80 -23%
Memory (RSS MB) 112.0 112.0 +0%
CPU ms/s (powermetrics) 10.2 7.7 -25%
Wakeups/s (powermetrics) 122.0 178.3 +46% 🔺
minimized CPU % 0.07 0.13 +99%
Memory (RSS MB) 112.2 112.2 +0%
CPU ms/s (powermetrics) 0.8 1.3 +66%
Wakeups/s (powermetrics) 20.4 79.8 +290% 🔺

⚠️ Labeled benchmark:regression

This PR is labeled benchmark:regression because these metrics regressed by ≥25% vs main@34d98b263 (beyond each metric's absolute noise floor):

  • focused — Wakeups/s (powermetrics): 132.1 → 192.1 (+45%)
  • unfocused — Wakeups/s (powermetrics): 122.0 → 178.3 (+46%)
  • minimized — Wakeups/s (powermetrics): 20.4 → 79.8 (+290%)

30s sampling window per state; CPU % is the process CPU-time delta over the window. Runs land on different shared runners, so treat small deltas as noise — 🔺/🔻 marks changes ≥25% that also clear the metric's absolute noise floor (CPU % ≥0.5, Memory (RSS MB) ≥25, CPU ms/s ≥5, Wakeups/s ≥50), and those add the benchmark:regression / benchmark:improvement label.

@thdxg thdxg merged commit 461ec40 into main Jul 5, 2026
7 checks passed
@thdxg thdxg deleted the claude/mystifying-colden-103489 branch July 5, 2026 01:29
thdxg added a commit that referenced this pull request Jul 6, 2026
Post-merge with main's website redesign (#143): a comprehensive Remote
Projects page (requirements, ssh-config aliasing with a ControlMaster
recipe, the sheet + palette flows, behavior table, remote layouts with
zmxPath, troubleshooting keyed to the pane diagnostics, limitations),
and a macterm CLI page adapted from docs/cli.md — the CLI (#139/#142)
had a repo doc but no website page. Existing pages get the remote
cross-references: persistence (remote sessions survive local reboots),
layouts (remote path: + zmxPath), palette (remote specs in path mode),
intro. Also fixes main's new ControlHandlerTests ZmxClient doubles for
the remote closure arity from this branch.
armyers pushed a commit to armyers/CYOTE-arm that referenced this pull request Jul 15, 2026
)

* Paste clipboard images into TUIs instead of dropping them (thdxg#109)

* Fix activity detection for in-place terminal output (thdxg#108)

* fix: consistent slider width for each settings screen (thdxg#116)

* fix: correct off-by-one in search match counter (thdxg#122)

* fix: render CJK IME preedit text (thdxg#119)

* Turn off continuous rendering for panes that are not visible in the app. (thdxg#115)

Co-authored-by: Aryeh <aryeh@studio.local>

* Skip quiet-settle for occluded panes so parked renderers can't fake completion (thdxg#123)

* Bundle zmx and add the session client library (no behavior change) (thdxg#124)

* Poll foreground processes adaptively instead of at a fixed 250ms (thdxg#126)

* Tick libghostty on wakeup instead of a 120Hz timer (thdxg#125)

* Isolate UserDefaults under test so runs can't corrupt the developer's app state (thdxg#128)

* Wrap terminal surfaces in zmx sessions with daemon-side foreground resolution (thdxg#127)

* Silence Swift 6 non-Sendable warnings on AppState's poll-event observers (thdxg#130)

* Benchmark window-state resource usage in CI and compare PRs against main (thdxg#129)

* Persist zmx sessions across quit and reattach on relaunch (thdxg#131)

* Spell out the noise floors in the benchmark footnote (thdxg#132)

* Surface zmx unavailability in Settings and document session persistence (thdxg#133)

* Break the title-replay render loop for good (thdxg#134)

* Make Unload Project stop the project's shells again (keeping layout) (thdxg#135)

* Fix arrow-key shortcuts never matching live keypresses (thdxg#136)

* Centralize project layout files in ~/.config/macterm/projects (thdxg#114) (thdxg#137)

* Add a control socket and bundled macterm CLI (read-only verbs) (thdxg#139)

* Add mutation verbs to the macterm CLI (create, split, run, grid) (thdxg#142)

* Sample the benchmark under a real workload via the macterm CLI (thdxg#141)

* Redesign website landing + docs pages (thdxg#143)

* Add CLI reference to the docs site (thdxg#145)

* Remote projects over SSH backed by zmx (thdxg#104) (thdxg#138)

* Cache build artifacts across CI to cut cold-build time (thdxg#146)

* Fix release export failing on Xcode 26 (mac-application method rejected) (thdxg#147)

* Refresh user docs, README, and landing page for remote projects, CLI, and persistence (thdxg#149)

* Keep main-window hotkeys from firing while another window is key (thdxg#148)

* fix: open existing project when same path is selected (thdxg#150)

* Track the system appearance instead of reading back our own pinned one (thdxg#152)

* chore(deps): bump the actions group with 3 updates (thdxg#151)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Pin the website footer to the viewport bottom on short pages (thdxg#153)

* Gate appcast and Homebrew tap on the release prerelease flag (thdxg#155)

* Add a settings slider for unfocused split pane dimming (thdxg#156)

* Report benchmark results from a workflow_run so fork PRs can be labeled (thdxg#157)

* Stop wide docs code blocks and tables from overflowing the page (thdxg#162)

* Audit remediation + sidebar tab/project drag-and-drop, OSC 2 title fixes (thdxg#158)

* Add a Copy Session ID action for handing a pane's zmx name to an LLM (thdxg#163)

* Make the window-state benchmark tolerant of shared-runner CPU noise (thdxg#164)

* Terminal-state introspection + control-socket verbs (thdxg#165thdxg#167) and a fork-drift guard (thdxg#168) (thdxg#169)

* Re-graft reboot-resurrect onto the sessionName-keyed model (stage 2)

Stage 2 of the upstream v1.20.4 merge: restore the fork's post-reboot
resurrect (capture color scrollback + restartable command, replay into
fresh sessions after a reboot), re-keyed from the old bare-UUID sessionID
to upstream's persisted `sessionName`, and driven through upstream's
off-main zmx seam.

Correctness fix (the reason it couldn't just be re-wired): resurrect's
history/print/send must hit the SAME daemon the panes attach through.
ZmxService now resolves the bundled zmx (Contents/Resources/zmx/zmx)
first and pins ZMX_DIR = ZmxSocketBudget.socketDir() in run(), matching
ZmxClient/ZmxAttach — the old fork ZmxService used homebrew paths and no
ZMX_DIR, so it would have talked to the wrong (or no) daemon.

- ZmxService: bundled binary + ZMX_DIR pin; history/print/send params
  renamed sessionID -> sessionName.
- ResurrectStore + SessionResurrect: keyed by sessionName (macterm-slug-hex
  is a safe filename); seed guard drops the vestigial sessionPersistence
  toggle (upstream always persists) — gates on didReboot + zmx only.
- PaneSnapshot.resurrectCommand + a transient Pane.resurrectCommand, kept
  distinct from Pane.command (layout run:) so a plain-quit reattach never
  re-runs it — only a genuine reboot does.
- AppState: ~15s capture timer started once at launch (independent of the
  poll cadence), capturing off-main via zmx.sessionListSnapshot leaders +
  ProcessInspector.foregroundCommand + ZmxService.history; skipped under
  XCTest. saveWorkspaces threads the cache into the snapshot.
- SplitNode.ensureNSView seeds local non-ephemeral panes post-reboot.
- Settings: re-added the "Resurrect scrollback after a reboot" toggle +
  per-pane restore-MB stepper (upstream @State style).

Verified end-to-end via the forceReboot sim (pkill zmx + relaunch):
capture wrote 44KB-504KB color .vt files keyed by sessionName, and the
seed replayed them into fresh sessions (e.g. 504KB in 172 paced chunks)
with no timeouts. 58 test suites pass; format + lint clean.

* Name zmx sessions after the context, show context in session list (stage 3)

Stage 3 of the upstream v1.20.4 merge: make session naming context-centric
(the fork's unit of work is the Context = Project chosen in the cmd-shift-p
picker, not a git repo), and surface the live context in the control CLI.

Part A - context-name slug. A pane's zmx session name derived its slug from
projectPath.lastPathComponent, so a context named "Billing API" opened at
/code/billing-svc became macterm-billingsvc-<hex>, ignoring the chosen name.
Now the project's context name is threaded as the slug source (Pane.init
already took sessionSlug; the gap was callers passing nil):
- Workspace.init / createTab gain sessionSlug, forwarded to the pane.
- AppState.ensureWorkspace(contextName:) + createTab(sessionSlug:) pass
  project.name at every interactive open/new-tab path.
- ControlHandler.tabNew passes project.name too.
- Splits already inherit the source pane's slug; restored panes keep their
  persisted name verbatim (rename-proof, since context is stable at creation).
The path-basename remains the fallback when no context name is supplied.

Part B - context column in `session list`. ControlSessionInfo gains a
`context` field, populated by joining sessionName -> live pane -> project
name (authoritative and rename-proof, not parsed from the slug). Rendered
as a `ctx:<name>` column (`ctx:-` for an orphan session with no live pane);
also present in --json. Docs updated.

Verified end-to-end: a new tab in context "ecs-managed-instances" (path
basename "code") spawned macterm-ecsmanagedin-<hex>, and `session list`
showed `ctx:ecs-managed-instances` on every attached session including the
old basename-slugged restored ones. 59 test suites pass; format + lint clean.

* Verify bundled zmx has no single-print truncation; document (stage 4)

Stage 4 of the upstream v1.20.4 merge: validate whether the bundled zmx
still needs resurrect's defensive print-chunking.

Empirically probed the bundled thdxg/zmx 0.6.0 with isolated single prints
(throwaway ZMX_DIR, detached `zmx run` session): rendered content scales
linearly past 4KB — 3KB→50/50 lines, 9KB→145/150, 18KB→287/300 — with no
hard ~4096-byte cliff (the <5% loss grows with size, i.e. scrollback-window
trimming, not IPC truncation). So the old stock-zmx bug (a single `print` in
the ~4-8KB band silently dropped after one 4096-byte read) is NOT present in
the binary Macterm ships.

Decision: keep the sub-4KB `chunkOnLines` + pacing anyway. It's belt-and-
suspenders — the `zmxPath` override can point resurrect at an older/stock
daemon that still truncates, and the reboot sim already showed chunked replay
handles 500KB cleanly. No behavior change; comments in ZmxService.print and
SessionResurrect updated to reflect the measured reality (bundled = no cliff,
chunking retained defensively for override/older binaries).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Dejan Žegarac <dejanzegarac4@gmail.com>
Co-authored-by: Mateusz Urban <mateuszurban2@gmail.com>
Co-authored-by: Jungwoong Kim <48355099+jungwngkim@users.noreply.github.com>
Co-authored-by: Juno Choi <junochoi2003@gmail.com>
Co-authored-by: takumaru <49429291+takuma-ru@users.noreply.github.com>
Co-authored-by: aryeh <aryeh@users.noreply.github.com>
Co-authored-by: Aryeh <aryeh@studio.local>
Co-authored-by: Ethan (Taehoon) Lee <48551278+thdxg@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:docs Documentation area:tests Test changes benchmark:regression CI benchmark: significant resource regression vs main

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant