48349aee
feat(shellint): the OSC 133 injection is opt-in
a73x 2026-08-15 13:02
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -112,6 +112,13 @@ the `mechanism` that answered — an exit code is real only under `marks` | |||
| 112 | (OSC 133 shell integration, injected at spawn), absent under the `pgid` and | 112 | (OSC 133 shell integration, injected at spawn), absent under the `pgid` and |
| 113 | `settle` fallbacks. | 113 | `settle` fallbacks. |
| 114 | 114 | ||
| 115 | Marks are **opt-in**: start the daemon with `MUX_SHELL_INTEGRATION=1` in its | ||
| 116 | environment, or `muxa` gets the fallbacks and no exit code. The injection is | ||
| 117 | off by default because it is not free on a shell you actually live in — under | ||
| 118 | zsh it points `ZDOTDIR` at a shim directory that has no `.zshenv`, and under | ||
| 119 | bash its `DEBUG` trap displaces yours (atuin, bash-preexec, iTerm2). A shell | ||
| 120 | `shellint` has no scripts for is unaffected either way. | ||
| 121 | |||
| 115 | Multiple clients may attach to one session; the grid follows the most | 122 | Multiple clients may attach to one session; the grid follows the most |
| 116 | recently active client — typing, attaching, or resizing claims it (latest | 123 | recently active client — typing, attaching, or resizing claims it (latest |
| 117 | wins). A session survives logout (this assumes systemd-logind's default | 124 | wins). A session survives logout (this assumes systemd-logind's default |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -3499,3 +3499,68 @@ the unit layer, the review rounds, or fifteen prior milestones could have | |||
| 3499 | surfaced it, because none of them ever spawned a second pty from a | 3499 | surfaced it, because none of them ever spawned a second pty from a |
| 3500 | process that already held one. The first test that ran two sessions on | 3500 | process that already held one. The first test that ran two sessions on |
| 3501 | one daemon found it on its first execution. | 3501 | one daemon found it on its first execution. |
| 3502 | |||
| 3503 | ## 2026-08-15 (shell integration becomes opt-in) | ||
| 3504 | |||
| 3505 | **The target changed, and this default changed with it.** mux is being | ||
| 3506 | aimed at replacing tmux as a daily driver; an agent driving a session is a | ||
| 3507 | nice-to-have behind that. Under the old target the OSC 133 injection was | ||
| 3508 | on by default, and the reasoning was sound for it — marks are what make an | ||
| 3509 | exit code knowable, and every fallback below them is a guess. Under the | ||
| 3510 | new one the same default is a tax every session pays for a feature only | ||
| 3511 | `muxa` reads. | ||
| 3512 | |||
| 3513 | **What it costs, on the shell you live in.** Both costs were shipped | ||
| 3514 | knowingly and written down at the time (roadmap, agent surface field | ||
| 3515 | limitations) as items to fix later; what changed is the recognition that | ||
| 3516 | they are not agent-surface limitations at all — they hit the human on | ||
| 3517 | every session: | ||
| 3518 | |||
| 3519 | - zsh: the shim directory `ZDOTDIR` points at has no `.zshenv`, so the | ||
| 3520 | user's is silently not read. `shellint.zig`'s own comment has said so | ||
| 3521 | since the feature landed. | ||
| 3522 | - bash: the shim's `DEBUG` trap displaces the user's — that is atuin, | ||
| 3523 | bash-preexec and iTerm2 integration, all silently off. | ||
| 3524 | |||
| 3525 | **Inverted, not re-spelled.** `MUX_SHELL_INTEGRATION` keeps its name and | ||
| 3526 | flips meaning: `=1` and nothing else turns the injection on; unset, `=0`, | ||
| 3527 | `true`, `yes` are all off. Reusing the variable with the opposite sense is | ||
| 3528 | safe in exactly one direction, and this is that direction — a stale `=0` | ||
| 3529 | in someone's profile still reads as off. The policy is a pure function | ||
| 3530 | (`shellIntegrationEnabled`) so it can be asserted without a daemon to set | ||
| 3531 | an environment for, which is the same shape `announceKeyFrom` and | ||
| 3532 | `pickKey` already have. | ||
| 3533 | |||
| 3534 | **The default moved in the library too, not just the binary.** | ||
| 3535 | `Server.Options.shell_integration` is now `false`. The alternative — a | ||
| 3536 | library default of `true` with the daemon overriding it — would have left | ||
| 3537 | the product's real policy visible in exactly one place, and every test | ||
| 3538 | inheriting the opposite of what ships. Four tests that need marks now say | ||
| 3539 | `.shell_integration = true` explicitly, which is four assumptions written | ||
| 3540 | down rather than inherited. | ||
| 3541 | |||
| 3542 | **Pinned at both layers, and the pin was watched failing first.** | ||
| 3543 | `shellIntegrationEnabled` has the policy table (unset → off; `1` → on; | ||
| 3544 | `0`/`""`/`true`/`yes` → off). `Server: the injection is off unless the | ||
| 3545 | caller asks for it` builds a real server on `/bin/bash` — a shell | ||
| 3546 | `shellint` HAS scripts for, because one it has none for would pass this | ||
| 3547 | whichever way the default points — and asserts no shim directory exists. | ||
| 3548 | All three assertions were run against the old default first and failed | ||
| 3549 | there; the server one caught a second test (`the shim directory is | ||
| 3550 | private`) that had been relying on the default without saying so. | ||
| 3551 | |||
| 3552 | **Verified on a real bash session, not only in tests.** A daemon started | ||
| 3553 | with no `MUX_SHELL_INTEGRATION` at all, `--shell /bin/bash`, asked what its | ||
| 3554 | shell actually holds: `TRAP=[]` (no DEBUG trap), `ZDOT=[]`, `ORIG=[]`, and | ||
| 3555 | a `PROMPT_COMMAND` carrying only the box's own xterm-title setter. No | ||
| 3556 | `mux-shellint-*` directory beside the socket. Suites: units 417 pass, e2e | ||
| 3557 | 26 scenarios / 35 convergence points, `make agent` 10/10 — the agent suite | ||
| 3558 | now exports `MUX_SHELL_INTEGRATION=1` once at the top, which is the | ||
| 3559 | requirement made visible rather than assumed. Its two degraded-path | ||
| 3560 | scenarios (settle, quiet2) run `/bin/sh` and are untouched by that export, | ||
| 3561 | so they still pin the fallbacks they were written for. | ||
| 3562 | |||
| 3563 | **The cost, named:** `muxa run` against a daemon nobody opted in for now | ||
| 3564 | answers from `pgid`/`settle` and carries no exit code. That is legible | ||
| 3565 | rather than silent — every reply already names its `mechanism`, and | ||
| 3566 | `marks` simply stops being the one you get by default. | ||
docs/roadmap.md
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,14 @@ better data than any of this ranking, and the proof is that the three | |||
| 13 | items which headed this list a week ago got there by hurting, not by | 13 | items which headed this list a week ago got there by hurting, not by |
| 14 | being predicted, and shipped as M13. | 14 | being predicted, and shipped as M13. |
| 15 | 15 | ||
| 16 | **Target, stated 2026-08-15:** replace tmux as a daily driver. An agent | ||
| 17 | driving a session is a nice-to-have behind that, not a peer. The first | ||
| 18 | consequence was immediate — shell integration became opt-in, because its | ||
| 19 | two known costs land on the human's shell and only `muxa` reads what they | ||
| 20 | buy (decisions.md, 2026-08-15). The queue below is ranked against the old | ||
| 21 | framing and is being re-ranked against this one; the tracker | ||
| 22 | (`git-collab issue list`) carries the live order. | ||
| 23 | |||
| 16 | ## M9 — prediction (speculative local echo) — complete | 24 | ## M9 — prediction (speculative local echo) — complete |
| 17 | 25 | ||
| 18 | **Verdict:** cleared. A predicted keystroke paints in **0.1ms** while the | 26 | **Verdict:** cleared. A predicted keystroke paints in **0.1ms** while the |
| @@ -245,6 +253,12 @@ and only `mechanism == "marks"` carries a real exit code. | |||
| 245 | **Field limitations, shipped knowingly** (each is a roadmap item, none is a | 253 | **Field limitations, shipped knowingly** (each is a roadmap item, none is a |
| 246 | blocker): | 254 | blocker): |
| 247 | 255 | ||
| 256 | *(Both shell-shim costs below were defused on 2026-08-15 by making the | ||
| 257 | injection **opt-in** — `MUX_SHELL_INTEGRATION=1` — once the target became | ||
| 258 | "replace tmux as a daily driver". Nobody pays them unless they ask for | ||
| 259 | marks. The fixes are still owed, because anyone who does ask still pays; | ||
| 260 | they are no longer on the default path. See decisions.md, that date.)* | ||
| 261 | |||
| 248 | - **zsh loses `~/.zshenv`** under the `ZDOTDIR` shim — the shim directory | 262 | - **zsh loses `~/.zshenv`** under the `ZDOTDIR` shim — the shim directory |
| 249 | has none. A ghostty-style `.zshenv` shim is the fix. | 263 | has none. A ghostty-style `.zshenv` shim is the fix. |
| 250 | - **The bash shim's DEBUG trap displaces the user's**, silently: that is | 264 | - **The bash shim's DEBUG trap displaces the user's**, silently: that is |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -362,6 +362,21 @@ pub fn main() !u8 { | |||
| 362 | } | 362 | } |
| 363 | } | 363 | } |
| 364 | 364 | ||
| 365 | /// Whether the session shell gets the OSC 133 injection, decided from | ||
| 366 | /// `MUX_SHELL_INTEGRATION` alone. Pure so the policy can be asserted without | ||
| 367 | /// a daemon to set an environment for. | ||
| 368 | /// | ||
| 369 | /// Opt-IN: `=1` and nothing else. It was an opt-out through the agent | ||
| 370 | /// surface and M18, on the reasoning that marks are what make an exit code | ||
| 371 | /// knowable; the daily-driver reading is the opposite one, because the shim | ||
| 372 | /// costs a zsh user their `~/.zshenv` and displaces a bash user's DEBUG trap | ||
| 373 | /// (atuin, bash-preexec) on every session, while only `muxa` reads what it | ||
| 374 | /// buys. Inverting rather than adding a second spelling means a stale `=0` | ||
| 375 | /// still reads as off. | ||
| 376 | fn shellIntegrationEnabled(env: ?[]const u8) bool { | ||
| 377 | return std.mem.eql(u8, env orelse "", "1"); | ||
| 378 | } | ||
| 379 | |||
| 365 | fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { | 380 | fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { |
| 366 | // Address and key are settled before anything binds: a mistyped address | 381 | // Address and key are settled before anything binds: a mistyped address |
| 367 | // or an unreadable key must not first leave a session socket and a live | 382 | // or an unreadable key must not first leave a session socket and a live |
| @@ -454,12 +469,9 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { | |||
| 454 | 469 | ||
| 455 | // Read from the DAEMON's environment, necessarily: muxd forks the | 470 | // Read from the DAEMON's environment, necessarily: muxd forks the |
| 456 | // session shell, so by the time anyone could pass a flag through a | 471 | // session shell, so by the time anyone could pass a flag through a |
| 457 | // client the shell has been running for a while. `MUX_SHELL_INTEGRATION=0` | 472 | // client the shell has been running for a while. |
| 458 | // and nothing else — any other value, including unset, means on. | 473 | const shell_integration = shellIntegrationEnabled( |
| 459 | const shell_integration = !std.mem.eql( | 474 | std.posix.getenv("MUX_SHELL_INTEGRATION"), |
| 460 | u8, | ||
| 461 | std.posix.getenv("MUX_SHELL_INTEGRATION") orelse "", | ||
| 462 | "0", | ||
| 463 | ); | 475 | ); |
| 464 | 476 | ||
| 465 | var srv = Server.init(alloc, .{ | 477 | var srv = Server.init(alloc, .{ |
| @@ -1349,6 +1361,26 @@ test "stopCmd: a socket path with nothing on it is exit 0, not a failure" { | |||
| 1349 | try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock)); | 1361 | try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock)); |
| 1350 | } | 1362 | } |
| 1351 | 1363 | ||
| 1364 | test "shellIntegrationEnabled: an unset environment means off" { | ||
| 1365 | // The daily-driver default. The injection is not free — under zsh the | ||
| 1366 | // ZDOTDIR shim costs the user their ~/.zshenv, and under bash the DEBUG | ||
| 1367 | // trap displaces theirs (atuin, bash-preexec) — and what it buys is a | ||
| 1368 | // knowable exit code, which only `muxa` reads. A user who never asked | ||
| 1369 | // for it must not pay for it. | ||
| 1370 | try std.testing.expect(!shellIntegrationEnabled(null)); | ||
| 1371 | } | ||
| 1372 | |||
| 1373 | test "shellIntegrationEnabled: `1` and nothing else turns it on" { | ||
| 1374 | try std.testing.expect(shellIntegrationEnabled("1")); | ||
| 1375 | // Every other spelling is off, including the one that used to mean off | ||
| 1376 | // when this variable was an opt-OUT: a stale `=0` in someone's profile | ||
| 1377 | // still reads as off, which is the safe direction for an inversion. | ||
| 1378 | try std.testing.expect(!shellIntegrationEnabled("0")); | ||
| 1379 | try std.testing.expect(!shellIntegrationEnabled("")); | ||
| 1380 | try std.testing.expect(!shellIntegrationEnabled("true")); | ||
| 1381 | try std.testing.expect(!shellIntegrationEnabled("yes")); | ||
| 1382 | } | ||
| 1383 | |||
| 1352 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 1384 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
| 1353 | // unreferenced decl must at least compile (the silent-module-loss hazard, | 1385 | // unreferenced decl must at least compile (the silent-module-loss hazard, |
| 1354 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | 1386 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -384,12 +384,14 @@ pub const Server = struct { | |||
| 384 | shell: [:0]const u8, | 384 | shell: [:0]const u8, |
| 385 | cols: u16 = 80, | 385 | cols: u16 = 80, |
| 386 | rows: u16 = 24, | 386 | rows: u16 = 24, |
| 387 | /// Inject the OSC 133 mark scripts into the session shell. On by | 387 | /// Inject the OSC 133 mark scripts into the session shell. OFF by |
| 388 | /// default: marks are what make an exit code knowable, and every | 388 | /// default: the shim is not free — under zsh it costs the user their |
| 389 | /// fallback below them is a guess. `muxd run` turns it off for | 389 | /// `~/.zshenv`, under bash it displaces their DEBUG trap — and what |
| 390 | /// `MUX_SHELL_INTEGRATION=0`, and a shell shellint has no scripts | 390 | /// it buys, a knowable exit code, is read only by `muxa`. A caller |
| 391 | /// for is unaffected either way. | 391 | /// that wants marks says so; `muxd run` says so for |
| 392 | shell_integration: bool = true, | 392 | /// `MUX_SHELL_INTEGRATION=1`. A shell shellint has no scripts for is |
| 393 | /// unaffected either way. | ||
| 394 | shell_integration: bool = false, | ||
| 393 | /// Extra variables for the session shell, set after the injection's | 395 | /// Extra variables for the session shell, set after the injection's |
| 394 | /// own so a caller can override one. The shell-integration tests | 396 | /// own so a caller can override one. The shell-integration tests |
| 395 | /// point HOME at a temp directory with it: a shim that sources the | 397 | /// point HOME at a temp directory with it: a shim that sources the |
| @@ -5543,6 +5545,9 @@ const IntegratedSession = struct { | |||
| 5543 | self.srv = try Server.init(alloc, .{ | 5545 | self.srv = try Server.init(alloc, .{ |
| 5544 | .sock_path = self.sock_path, | 5546 | .sock_path = self.sock_path, |
| 5545 | .shell = shell, | 5547 | .shell = shell, |
| 5548 | // Asked for explicitly: the injection is off by default, and | ||
| 5549 | // every test below this line is about what the injection does. | ||
| 5550 | .shell_integration = true, | ||
| 5546 | .extra_env = &.{ | 5551 | .extra_env = &.{ |
| 5547 | .{ .key = "HOME", .value = self.home }, | 5552 | .{ .key = "HOME", .value = self.home }, |
| 5548 | // Emptied, and that is not cosmetic. shellint.prepare copies | 5553 | // Emptied, and that is not cosmetic. shellint.prepare copies |
| @@ -5594,6 +5599,26 @@ const bash_rc_with_prompt_member = | |||
| 5594 | \\ | 5599 | \\ |
| 5595 | ; | 5600 | ; |
| 5596 | 5601 | ||
| 5602 | test "Server: the injection is off unless the caller asks for it" { | ||
| 5603 | const alloc = std.testing.allocator; | ||
| 5604 | // bash, because shellint HAS scripts for bash: a shell it has none for | ||
| 5605 | // would pass this whichever way the default points, and prove nothing. | ||
| 5606 | std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest; | ||
| 5607 | |||
| 5608 | var tmp = try TmpDir.make(); | ||
| 5609 | defer tmp.cleanup(); | ||
| 5610 | const sock_path = try std.fmt.allocPrint(alloc, "{s}/nomarks.sock", .{tmp.path()}); | ||
| 5611 | defer alloc.free(sock_path); | ||
| 5612 | |||
| 5613 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/bash" }); | ||
| 5614 | defer srv.deinit(); | ||
| 5615 | |||
| 5616 | // No shim directory at all — the user's ZDOTDIR and DEBUG trap are their | ||
| 5617 | // own. `IntegratedSession` is the other side of this pin: it asks for the | ||
| 5618 | // injection and asserts the directory exists. | ||
| 5619 | try std.testing.expect(srv.shellint_dir == null); | ||
| 5620 | } | ||
| 5621 | |||
| 5597 | test "Server: bash emits one mark pair per command, and none at an idle prompt" { | 5622 | test "Server: bash emits one mark pair per command, and none at an idle prompt" { |
| 5598 | const alloc = std.testing.allocator; | 5623 | const alloc = std.testing.allocator; |
| 5599 | std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest; | 5624 | std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest; |
| @@ -5712,7 +5737,11 @@ test "Server: the shim directory is private, and teardown takes it with it" { | |||
| 5712 | var dir_buf: [256]u8 = undefined; | 5737 | var dir_buf: [256]u8 = undefined; |
| 5713 | var dir: []const u8 = undefined; | 5738 | var dir: []const u8 = undefined; |
| 5714 | { | 5739 | { |
| 5715 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/bash" }); | 5740 | var srv = try Server.init(alloc, .{ |
| 5741 | .sock_path = sock_path, | ||
| 5742 | .shell = "/bin/bash", | ||
| 5743 | .shell_integration = true, | ||
| 5744 | }); | ||
| 5716 | defer srv.deinit(); | 5745 | defer srv.deinit(); |
| 5717 | dir = try std.fmt.bufPrint(&dir_buf, "{s}", .{srv.shellint_dir.?}); | 5746 | dir = try std.fmt.bufPrint(&dir_buf, "{s}", .{srv.shellint_dir.?}); |
| 5718 | 5747 | ||
| @@ -5754,7 +5783,11 @@ test "Server: an entry already at the daemon's pid name costs neither the marks | |||
| 5754 | 5783 | ||
| 5755 | // Scoped so deinit runs before the survival check below. | 5784 | // Scoped so deinit runs before the survival check below. |
| 5756 | { | 5785 | { |
| 5757 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/bash" }); | 5786 | var srv = try Server.init(alloc, .{ |
| 5787 | .sock_path = sock_path, | ||
| 5788 | .shell = "/bin/bash", | ||
| 5789 | .shell_integration = true, | ||
| 5790 | }); | ||
| 5758 | defer srv.deinit(); | 5791 | defer srv.deinit(); |
| 5759 | 5792 | ||
| 5760 | // The shim name carries a random suffix past the pid, so the planted | 5793 | // The shim name carries a random suffix past the pid, so the planted |
test/agent.sh
| Old | New | ||
|---|---|---|---|
| @@ -50,6 +50,13 @@ export SHELL | |||
| 50 | # happens to have one exported would silently change which key every QUIC | 50 | # happens to have one exported would silently change which key every QUIC |
| 51 | # scenario below presents — and they would still pass, against the wrong key. | 51 | # scenario below presents — and they would still pass, against the wrong key. |
| 52 | unset MUX_KEY_FILE | 52 | unset MUX_KEY_FILE |
| 53 | # The OSC 133 injection is opt-IN as of this commit, and marks are the whole | ||
| 54 | # subject of this suite. Every daemon below is started from this environment, | ||
| 55 | # so the one export covers them all; the two scenarios that pin the DEGRADED | ||
| 56 | # path (settle, quiet2) use /bin/sh, which shellint has no scripts for, and | ||
| 57 | # are unaffected by it. | ||
| 58 | MUX_SHELL_INTEGRATION=1 | ||
| 59 | export MUX_SHELL_INTEGRATION | ||
| 53 | 60 | ||
| 54 | # Unix-socket daemons, one per scenario that owns its session's shell. | 61 | # Unix-socket daemons, one per scenario that owns its session's shell. |
| 55 | SOCK_MARKS="$TMP/marks.sock" | 62 | SOCK_MARKS="$TMP/marks.sock" |