a73x

48349aee

feat(shellint): the OSC 133 injection is opt-in

a73x   2026-08-15 13:02

Commit message
feat(shellint): the OSC 133 injection is opt-in

mux is aimed at replacing tmux as a daily driver; an agent driving a
session sits behind that. The injection's two known costs land on the
shell the human lives in — zsh loses ~/.zshenv to a ZDOTDIR shim that has
none, bash loses its DEBUG trap to ours (atuin, bash-preexec, iTerm2) —
and what they buy, a knowable exit code, is read only by muxa. So nobody
pays unless they ask.

MUX_SHELL_INTEGRATION keeps its name and inverts: `=1` and nothing else
turns marks on. Inverting rather than re-spelling is safe in exactly one
direction, and this is it — a stale `=0` still reads as off.

Server.Options.shell_integration defaults false too, not just the daemon's
policy, so the four tests that need marks say so out loud instead of
inheriting the opposite of what ships.

Pinned at both layers and watched failing against the old default first:
the policy table on the pure shellIntegrationEnabled, and a real server on
/bin/bash — a shell shellint HAS scripts for — asserting no shim directory
appears. Field-checked on a live bash session: TRAP=[], ZDOT=[], no
mux-shellint-* beside the socket.

test/agent.sh exports MUX_SHELL_INTEGRATION=1 once; its two degraded-path
scenarios run /bin/sh and are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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"