libtmux 0.1.0

Async typed tmux client and object model
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
# libtmux Rust crate design

## Contents

- [Outcome]#outcome
- [Goals]#goals
- [Non-goals]#non-goals
- [Compatibility contract]#compatibility-contract
  - [Version check]#version-check
  - [Cache and cold start]#cache-and-cold-start
- [Evaluated architectures]#evaluated-architectures
  - [Literal Python-shaped port]#literal-python-shaped-port
  - [Runtime-generic core and adapter workspace]#runtime-generic-core-and-adapter-workspace
  - [Tokio-first crate with an internal executor boundary]#tokio-first-crate-with-an-internal-executor-boundary
- [Spike findings]#spike-findings
- [Architecture]#architecture
  - [Core and executor]#core-and-executor
  - [Commands and results]#commands-and-results
  - [IDs, targets, and relationships]#ids-targets-and-relationships
  - [Private snapshots and future refresh]#private-snapshots-and-future-refresh
  - [Native local iteration]#native-local-iteration
  - [Portable filters]#portable-filters
  - [What an expression can name]#what-an-expression-can-name
  - [Object API]#object-api
  - [Errors and collapsed collections]#errors-and-collapsed-collections
  - [Capabilities and future engines]#capabilities-and-future-engines
- [Control mode]#control-mode
- [Module and component map]#module-and-component-map
- [Test architecture]#test-architecture
- [Lint and dependency gates]#lint-and-dependency-gates
- [Compatibility lanes]#compatibility-lanes
- [What downstream can construct]#what-downstream-can-construct
- [Classifying a failure]#classifying-a-failure
- [Cost of gathering the hierarchy]#cost-of-gathering-the-hierarchy
- [Toolchain and packaging]#toolchain-and-packaging
- [Delivery sequence]#delivery-sequence
- [Acceptance criteria]#acceptance-criteria

## Outcome

The repository is a Cargo workspace whose members live under `crates/`, and
all four are published:

| Crate            | What it is                                              |
| ---------------- | ------------------------------------------------------- |
| `libtmux`        | The async tmux client and object model                  |
| `libtmux-macros` | `#[derive(Filterable)]`, for downstream structs         |
| `tmux-mcp`       | A Model Context Protocol server over `libtmux`          |
| `tmux-workspace` | A tmuxp-compatible command and workspace-building library |

`tmux-mcp` and `tmux-workspace` exist to exercise the public API from outside,
the way a real consumer would, and are published so their dependency
requirements are proven to resolve. The dependency runs one way: `libtmux`
knows nothing about either.

`libtmux` presents `Server`, `Session`, `Window`, `Pane`, and `Client`
handles, hierarchy listings, refresh, options, hooks, buffers, key bindings,
and the local typed filtering kernel, keeping Python libtmux's object
vocabulary and a tmux floor of 3.3a. The format catalog, intrinsic
snapshots, and winlink projections back those handles and stay crate-private.
Optional features add control mode, a blocking runtime, portable expression
serialization, tracing, and the real-tmux test guard.

Cargo is authoritative for compilation, dependency resolution, testing,
documentation, and publishing. A `justfile` groups those commands so `just`
alone lists them; it adds no build step and does not participate in the
published crate.

The default transport runs each command as a Tokio subprocess; the
`control-mode` feature adds a persistent control-mode client. Command, target,
result, capability, snapshot, and query values do not depend on the transport,
so either serves the same object APIs.

## Goals

- Reach behavioral feature parity with the pinned Python public API baseline.
- Keep familiar names and hierarchy while using Rust ownership, errors,
  builders, iterators, and async methods naturally.
- Preserve tmux 3.3a compatibility and gate newer format fields and flags by
  detected capabilities.
- Make IDs, targets, fields, option scopes, directions, flags, and query
  cardinality statically meaningful.
- Preserve the bytes emitted by tmux and expose decoding explicitly without
  assuming every tmux release emits untransformed callback bytes.
- Provide isolated real-tmux tests with explicit socket paths and fail-closed,
  platform-scoped cleanup.
- Leave a coherent extension seam for immutable plans, query lowering,
  control mode, serialization, and alternate executors.
- Keep the default crate small enough to audit and pleasant to embed.

## Non-goals

- A JavaScript, N-API, Python, WASM, or C binding.
- A second synchronous API.
- Control mode in the default transport. Normal commands stay one process per
  command whatever the optional `control-mode` feature does.
- A literal port of Python inheritance, arbitrary keyword arguments, mutable
  dictionaries, or exceptions.
- Compatibility methods whose only current behavior is raising
  `DeprecatedError`.
- Reproducing Python's `QueryList` type, its equality and lookup-suffix
  defects, stale fields, or cross-server identity defects.
- A process-global engine or operation registry.
- Runtime independence. `tokio` is a required dependency, not an optional one:
  the crate spawns and supervises child processes, bounds every dispatch with a
  timer, and multiplexes a control connection, so it needs a runtime's process
  reaping and timers rather than only its executor. `async-std` and `smol`
  offer no compatible child supervision, and abstracting over the parts that
  differ would mean either a lowest-common-denominator transport or a second
  one to keep in step. `blocking::Runtime` is how code that is not async calls
  in; it is a `tokio` current-thread runtime, so entering it from inside
  another runtime is a panic rather than a nested executor -- use
  `blocking::Runtime::try_run` where that is possible.

## Compatibility contract

The target compatibility surface is tmux 3.3a and newer on Linux, macOS, and
WSL. Native Windows is unsupported because tmux is unavailable there. The
initial Rust MSRV is 1.85, the first stable compiler supporting Edition 2024.
The Foundation gate verifies Linux with both Rust 1.85 and the pinned
development toolchain; the release slice adds the remaining platform matrix.

### Version check

Every ordinary dispatch checks the connected daemon, including commands,
chains, scoped handles and parked channel clients. An absent daemon checks the
executable that would start it. The check shares the logical request's
timeout, and a native queue guard verifies the same version immediately before
the request's effects. A daemon replaced in between refuses with
`ServerReplaced`, and nothing runs.

The probe is `-N run-shell -C '<marker>#{version}'`. tmux fails to parse the
expanded string as a command and reports `unknown command: <marker><version>`
on stderr with exit status 1. `display-message` would answer too, but it runs
the owner's `after-display-message` hook on every release, so the probe would
have effects before the check had decided anything. `run-shell` has no
`after-` hook, and with `-C` it parses its argument after `exec` returned, so
tmux queues no `command-error` either. The marker is random per probe, so
nothing configured on the daemon can forge a report, and a probe that succeeds
is another program answering in tmux's place.

Every refusal happens before the request is sent:

- A release below the floor is `UnsupportedTmuxVersion`. A daemon older than
  3.2 cannot run the probe at all: 3.1 answers with `run-shell`'s usage, 3.0
  reports `server version is too old for client`, and a 3.1 or older client
  rejects `-N`. Each is refused as "older than 3.2".
- A well-framed report that names no release (a bare `master`, `openbsd-*`,
  `3.3a-dev`) is `UnrecognizedTmuxVersion`. It cannot be compared with the
  floor, so it is never described as too old.
- Output that is not a version report is `InvalidVersionOutput`, and a probe
  that exits without one is `VersionProbeFailed`.
- A `Timeout` raised while the check still ran has `before_dispatch` set.

### Cache and cold start

Live and cached version checks use native `-N`, so a vanished daemon is
reported without restarting the configured executable. That failure clears the
cached version, and the next explicit call can freshly qualify a cold startup.
An owned kill clears the cached version immediately. A cold metadata probe
never grants cached startup permission.

Executable capabilities remain a separate cached description of the configured
binary, and ordinary format catalogs follow the checked daemon. The catalog
keeps 3.2a as its recorded baseline, so introduction floors at or below the
supported minimum are history rather than live gates.

Undated `master` reports remain available diagnostically and cannot pass the
version check. Creating executable reports may use `tmux` or its exact
captured basename; controlled daemon metadata requires the literal `tmux`
prefix.

MCP completion helpers carry the checked daemon's generation into a native
queue guard before printing their markers. A replacement, including one on the
same version, cannot acknowledge the opening frame and authorize its user job.

The configured executable and library-owned metadata and guard commands must
retain tmux's native built-in semantics. Owner-defined `command-alias`
redefinitions of those commands are outside this qualification; their contents
run with tmux's own semantics.

Workspace layout preflight checks the core floor even with no pending layouts,
so extension builders cannot begin after an unsupported endpoint was mistaken
for an absent daemon. The test fixture's private PID bootstrap and an absent
`Server::kill` cleanup have no requested library effects.

The compatibility baseline for the first Rust release is the Python package at
commit
[`c4a980b`](https://github.com/tmux-python/libtmux/tree/c4a980b). The parity
ledger treats every module in its API reference as public, including
`Client`. Internal Python implementation types are ported only when their
behavior is visible through public APIs.

That baseline does not move during implementation. Later Python changes enter
the Rust scope only through an explicit baseline update and parity-ledger diff.

Behavioral parity means equivalent tmux effects, return information,
cardinality, ordering, version gating, and documented failure behavior. Rust
syntax intentionally differs where Python constructs have no sound or
idiomatic equivalent.

## Evaluated architectures

### Literal Python-shaped port

This approach would expose string IDs, dynamic `field__operator` lookups,
mutable property bags, broad target strings, and methods with many optional
arguments. It offers superficial familiarity but loses Rust's type checker,
cannot make live properties async, and preserves accidental behavior. It is
rejected.

### Runtime-generic core and adapter workspace

This approach would begin with separate core and Tokio crates and make every
handle generic over a transport. It keeps dispatch allocation-free, but
`Server<T>` propagates through `Session<T>`, `Window<T>`, `Pane<T>`, queries,
errors, and downstream signatures. The crate/version/feature coordination has
no initial consumer. It is rejected as premature.

### Tokio-first crate with an internal executor boundary

This is the selected design. Public handles are concrete and cheap to clone.
They share an `Arc<Core>`, while `Core` owns a private object-safe executor.
The subprocess executor is Tokio-native. Public values above execution remain
transport-independent.

The design grafts three proven ideas without importing their competing plan
models:

- immutable, inspectable query and command values as the semantic source;
- the familiar `Server -> Session -> Window -> Pane` authoring facade;
- typed fields, portable predicates, traversal, and explicit cardinality.

A later engine layer can embed the same query and command values in one
immutable statement graph. It must not introduce a second semantic model.

## Spike findings

The disposable spike compiled and exercised the selected seams against real
tmux. Its source is not copied into the crate.

- Native async methods in a public transport trait are not object-safe. An
  internal boxed adapter kept the public transport implementation ergonomic
  and every object handle non-generic.
- Direct transport generics compiled but spread through all child handle
  types. A closed transport enum compiled but prevented downstream engines.
- A single predicate trait accepting both closures and typed expressions
  defeated inline closure parameter inference on Rust 1.85. Native
  `.filter(|item| ...)` therefore remains the closure path, while
  `.matching(expr)` accepts portable expressions and named matchers. No
  collection wrapper or duplicate closure method is needed.
- Single-evaluation `q`-quoted tmux formats round-tripped delimiter bytes,
  embedded newlines, and invalid UTF-8 on tmux 3.2a, the oldest release
  measured; the supported releases behave the same. Delimiter
  splitting and separately sampled length prefixes did not work.
- tmux 3.4 and 3.5 wrap that output in `VIS_OCTAL | VIS_CSTYLE | VIS_NOSLASH`,
  so the transport carries two escaping layers rather than one. The layers do
  not collide: `#{q:}` runs first and doubles every backslash, `VIS_NOSLASH`
  stops `vis` adding more, and no member of tmux's `format_quote_shell` set is
  an octal digit or a `VIS_CSTYLE` letter. Each dialect is therefore a separate
  injective grammar, and a decoder that accepts only one of them rejects the
  other's output instead of returning altered bytes.
- Owned snapshots refreshed predictably. Refreshing one clone did not mutate
  another clone, avoiding locks and hidden shared state.
- A real-tmux guard created unique short socket paths, exposed the exact path,
  and removed the daemon and socket on drop.
- List access keeps the reason a listing failed without making raw command
    execution swallow failures.
- A failed command at the start of a tmux semicolon chain prevents later
  commands from executing. A control-mode implementation that predicts one
  result block per separator can wait forever for blocks tmux will never send.
- A bare `;` argv element is always a separator and never an argument, and a
  `\;` element is always a literal semicolon and never a separator. The two are
  distinguishable only at the argv layer, so a separator has to be produced by
  the type that owns the sequence rather than by any argument a caller supplies.
- A subprocess chain cannot attribute its own failure. Failing at the first,
  middle, or last position of a chain returns the same exit code and the same
  stderr; only the count of stdout lines differs, and mutating commands print
  nothing, so a chain of them carries no positional evidence at all. Reporting
  the first command as the failed one is therefore a guess that is wrong
  whenever the failure was later. Without per-command evidence the terminal
  state of every member is `unknown`.
- A creating command's `-P -F` output survives a later failure in the same
  chain, so a fold that captures an id still binds it when a decorate fails.
- A fixture that leaves `default-shell` unset does not get a neutral shell, it
  gets `$SHELL`. Every fixture pane then sources the developer's interactive
  startup files, which makes the suite's timing a property of their dotfiles.
  Measured here: an interactive `zsh` reading a 395-line `.zshrc` drew a prompt
  in ~1 s idle and 9.7 s while other tmux servers on the machine were starting
  shells, against ~10 ms for a pinned shell reading nothing. Startup files that
  enable shared shell history compound it, because each shell then takes one
  machine-wide lock on one history file. Pinning `default-shell` *and*
  `default-command` is what removes it: with only the former tmux still runs
  the shell as a login shell and reads `/etc/profile` and `~/.profile`.
- Control mode does not deliver `split-window`'s `-P` output. Measured on tmux
  3.7b, `new-session -P -F` returns `$1 @1 %1` and `new-window -P -F` returns
  `@2 %2` inside their protocol blocks, while `split-window -P -F` closes its
  block successfully with no lines at all, whether the target window is in the
  attached session or another one. A plan that binds a created id from printed
  output therefore cannot create panes by splitting over control mode, and the
  `{marked}` fold is what a subprocess transport offers instead.
- Control mode is the only transport that separates how many commands from how
  many processes. Measured over one workload: one invocation per operation
  costs six processes and names a failure exactly; folding costs three and
  cannot; control mode costs one process, still reports six outcomes, and was
  the fastest of the five. Folding is therefore the answer for a subprocess
  transport and not an improvement over control mode.
- tmux reports hooks differently depending on how it is asked. `show-hooks`
  lists every hook name it knows, bare when the hook holds nothing and
  `name[index] value` when it holds something -- but only at server and
  session scope. For a window or a pane it reports nothing at all, and
  `show-options` omits them there too while still listing ordinary options.
  Asked about one hook *by name*, tmux lists its slots at every scope. So
  reading one hook by name works everywhere and enumerating them does not,
  which is why `Window` and `Pane` offer `hook` and no listing: a listing
  there could answer nothing but empty, which reads as "none set" rather than
  "not reported".
- Hook indices are sparse and tmux keeps the gaps: setting slots 0 and 3
  leaves 1 and 2 absent rather than closing up. A hook is therefore a map from
  index to command, not a list.
- Whether `<shell> -c "cmd"` becomes `cmd` or stays a shell with a child is an
  optimization POSIX does not require: `zsh` and `bash` exec, `dash` forks. A
  test that reads `#{pane_current_command}` therefore has to say `exec` rather
  than inherit whichever answer the ambient shell gives, and one that wants
  escape sequences in a pane's output has to emit them rather than rely on a
  prompt being colourful.

## Architecture

```text
Server / Session / Window / Pane / Client
                 |
                 +--> operation builders --> Command
                 |
                 +--> Vec snapshots --> native iterators
                                      |
                                      +--> Matcher / FilterExpr
                 |
                 +--> immutable Info snapshots
                                   |
                              Arc<Core>
                                   |
                         private Executor trait
                                   |
                        Tokio subprocess executor
                                   |
                                  tmux
```

### Core and executor

`Core` owns the configured tmux executable, captured launch context, server
identity, default timeout, capability snapshot, and executor. Handles contain
`Arc<Core>` plus owned identity and snapshot data. No public handle is generic
over a runtime or executor.

The private executor is stored as
`Arc<dyn Executor + Send + Sync + 'static>`. It consumes an owned
`CommandRequest` and returns a `Send` future producing a raw result. It never
receives domain objects. Adding a public alternate executor later is additive
because the boundary already exists, but the first release does not freeze an
unused public async trait.

Each `Core` owns its dispatch-request counter. Cloned `Server` values share
that Core and counter; separately constructed servers have separate scopes,
even when they identify the same tmux endpoint. The Core allocates an ID before
executor validation, so a rejected request can carry an ID without spawning a
process. A caller retry is another Core dispatch request and receives another
ID. The ID is not globally unique or a process ID.

The Tokio executor uses argv directly and never invokes a shell. Each spawned
child is transferred to an independently owned supervisor task. That task
captures stdout and stderr concurrently, applies a deadline, and remains
responsible for terminating the child's isolated process group and awaiting the
direct child after deadline expiry, caller cancellation, or explicit shutdown.
Pipe draining precedes `wait`, so the unreaped direct child anchors the numeric
process-group identity until inherited pipes close or the overall deadline
expires. Dropping the caller future signals cancellation; `kill_on_drop` is
only a final safety net and is never the reaping strategy.

One request opts out of reading stdout. tmux 3.1 and older keep a client's
stdout open after the client exits, so a pipe read to its end waits out the
command timeout on every refusal from such a daemon. The version probe's
answer is on stderr and in the exit status, so its request carries
`stdout_discarded`: the child gets the null device in place of the stdout
pipe, a ready empty reader stands in for the stdout reader, and stderr is
still read to its end. Output tmux wrote to the discarded stream is not
counted, so `InvalidVersionOutput::output_len` is the length of the stream the
check read: stderr for the daemon probe, stdout for `tmux -V`.

Deterministic async reaping requires the Tokio runtime to remain alive until
the operation or explicit executor shutdown completes. Runtime teardown cannot
make that async guarantee; it synchronously signals the process group and uses
`kill_on_drop` for the direct-child fallback, without claiming a completed
wait. Callers that own a runtime shut the `Server` down before the runtime.
`TestServer` separately owns its foreground tmux daemon as a synchronous child
so its `Drop` cleanup does not depend on a live Tokio runtime.

Concurrent subprocess requests have no ordering guarantee beyond the atomic
execution of each process. Callers that require ordering await requests in
sequence. A future persistent executor serializes protocol writes internally
while preserving correlation between concurrent callers. Every public handle
is `Send + Sync`; compile-time assertions make that contract executable.

### Commands and results

`Command` stores typed tokens rather than one shell string. Each token carries
both its executable value and a public-or-sensitive diagnostic classification.
Custom `Debug` renderers, executor errors, and tracing use only the redacted
diagnostic view. Every Foundation command token is literal, so a token ending
in `;` gains one escape byte during argv lowering. Existing backslashes do not
suppress that lowering. Shell-free execution alone does not make semicolons
literal because tmux reparses every argv token after process launch.

Foundation Core dispatch accepts one logical command per request.
`CommandResult` stores the Core-scoped dispatch-request identity, a sanitized
command summary, exit status, and raw stdout and stderr bytes. It never copies
executable argv.
Borrowed strict UTF-8 views return
`std::str::Utf8Error`; named lossy views are explicit. Owned decoding errors
are not retained because their diagnostics could expose raw output. A non-zero
status and non-empty stderr remain data at the raw `cmd()` boundary; domain
wrappers decide which tmux responses are failures.

Batching and structural semicolon chains are deferred. When they are added,
independent batches return one result per request. A direct subprocess chain is
one Core dispatch request and one result because subprocess output cannot prove
per-command attribution. A planner that requires individual terminal states
must dispatch independently unless the executor advertises per-command
correlation. Control-mode block identities remain separate from the Core
dispatch-request identity and are retained rather than merged; any command
without evidence is `unknown`, never guessed to be failed or skipped.

Later builders for pane input, environment values, prompts, and buffers must
mark those arguments sensitive at construction. Each owning slice passes
sentinel secrets through its command families and proves that `Debug`, errors,
and tracing omit them.

### IDs, targets, and relationships

`SessionId`, `WindowId`, and `PaneId` validate `$`, `@`, and `%` prefixes.
Targets are scope-specific: pane operations cannot accept window targets.
`ServerIdentity` structurally normalizes the effective socket endpoint,
including the resolved socket root for named and default sockets and the
absolute path captured for explicit sockets. Symlink-sensitive parent
components are preserved rather than collapsed, and dispatch uses the same
captured path as identity. It never uses `Arc` pointer identity. Independently
constructed servers for the same structural endpoint therefore compare
equally, while equal-looking object IDs on different endpoints do not.

Handle equality and hashing use `(ServerIdentity, object ID)` for sessions,
windows, and panes, and `(ServerIdentity, client_name)` for clients. Window and
pane handle identity follows the underlying tmux object, not the discovery
edge. `WindowLinkIdentity` separately represents
`(ServerIdentity, SessionId, window index, WindowId)`.

A strict ownership tree is insufficient because tmux windows are linked.
`WindowLink` is a first-class edge containing session ID, window index, and
window ID. Server-wide window and pane enumeration retains one row per
winlink. Underlying object identity and edge identity remain distinct.

`Pane` retains the winlink context from which it was discovered. Live parent
resolution re-queries tmux so move and link operations do not leave traversal
permanently attached to stale parents.

### Private snapshots and future refresh

The `SessionInfo`, `WindowInfo`, `PaneInfo`, and `ClientInfo` structs,
projections, format plans, and built-in fields are crate-private. Each field
is readable through the handle that filters it: `Pane::get(fields.cursor_x)`
returns `Availability<u32>`, so one name serves filtering and reading and no
per-field method commits the crate to a field's storage shape. Hand-written
getters remain for the common fields.

Inside that private kernel, known IDs, indices, flags, sizes, timestamps, and
enums use typed fields. `TmuxText` retains stored bytes exactly and exposes
byte, strict UTF-8, and explicitly lossy views without an implicit string
conversion. `Availability<T>` distinguishes release-gated `Unsupported`,
development-build `Unproven`, semantic absence, and available values where
tmux makes those states observable. Both unavailable states are omitted from
the format plan:
`Unsupported` requires a detected release below the field floor, while
`Unproven` records that a release-less development build is admitted only to
the conservative baseline catalog. For a supported field, the descriptor
decides whether zero bytes mean `Absent` or an available empty `TmuxText`;
tmux does not reveal whether its callback returned `NULL` or an empty C
string. A scope-inapplicable field is a plan error rather than `Absent`.

`WindowInfo` describes an underlying window. Session ID, index, active state,
and link flags belong to `WindowLink`. Server-wide window rows become owned
`WindowProjection` values containing one link and one intrinsic window.
`PaneProjection` similarly retains the link identity for each pane row. The
same window or pane may therefore appear more than once without conflating
object identity with discovery-edge identity.

Comma-joined tmux callbacks that contain names remain opaque `TmuxText`:
tmux permits commas in those names and provides no escaping. Typed
relationships come from projection rows and IDs, never by splitting those
display values.

Future handles expose synchronous snapshot getters while live relationships
and commands remain async. The planned `refresh(&mut self)` replaces the
receiver's complete snapshot and returns `&mut Self`; `refreshed(&self)`
returns a new handle. Clones remain independent snapshots sharing only
`Core`.

The private descriptor catalog records stable field name, semantic owner,
required context, admitted list profiles, minimum tmux version, decoder, and
empty-value policy. A `FormatPlan` owns the selected descriptors, rendered
template, and parser order together. Each requested field is rendered once as
`#{q:field}=`, and transport bytes are parsed before any newline split or
UTF-8 decode. The decoder removes each quoting backslash and treats only the
template's unescaped `=` as a field terminator; the LF after the final
terminator ends the row. Value newlines are ordinary payload because rows end
at a counted terminator rather than at the next LF. The separator is `=`
rather than `%` because `display-message` expands its template through
`strftime` before `format_expand`, and only a template free of `%` skips that
pass; `=` is in tmux's `format_quote_shell` set, so `#{q:}` still escapes it
inside a value.

`CommandResult` always preserves the exact stdout transport emitted by the
selected tmux, and the plan decodes it through the dialect its version emits.
`TransportDialect::RawQ` covers releases before 3.4 and from 3.6 onward, where
`#{q:}` escaping reaches stdout verbatim. `TransportDialect::Vis` covers 3.4
and 3.5, where `server_client_print` additionally applied
`VIS_OCTAL | VIS_CSTYLE | VIS_NOSLASH`. Both recover arbitrary original non-NUL
callback bytes.

The window boundaries come from upstream commits rather than from observed
behavior: `7e497c7f` and `93b1b781` introduced the transform before tag 3.4,
and `5fd45b38`, "Do not strvis output to terminal from commands", restored
verbatim output before tag 3.6. A `next-X.Y` identifier resolves through the
release it precedes; `master` names no release and selects `RawQ`.

Dialect selection is evidence, not trust. The version probe reads the
configured client executable, but `server_client_print` runs in the daemon that
owns the socket, so the two can disagree. Each dialect therefore rejects the
escapes only the other produces: a mismatched pairing fails loudly at the first
divergent escape instead of decoding into different bytes.

Each decoded field is therefore one callback sample. Dangling escapes,
missing field terminators, missing row LF, and trailing bytes are framing
errors. The parser never splices or retries individual fields or rows.
Tmux still expands fields and rows sequentially, so a byte-exact decoded
listing is not a transactional snapshot of server state.

There is no aggregate `ServerSnapshot`. Future Session, Window, Pane, and
Client listings come from separate tmux commands and cannot form an atomic
capture. Their return floor is an ordered `Vec<T>`, but no public hierarchy
listing exists at the current milestone.

### Native local iteration

The implemented query kernel operates on user-owned `Vec<T>` and slices.
Future hierarchy listings use the same collection floor and preserve tmux
order. Native iterators provide the ordinary local API:

```rust
let tasks = vec![("build", false), ("test", true)];
let pending = tasks.iter().filter(|task| !task.1);
let first = pending.clone().next();
let collected = pending.collect::<Vec<_>>();
```

`QueryIteratorExt` is implemented for all iterators. `matching()` filters
borrowed items, while `matching_owned()` borrows each item for the predicate
and yields the original owned item without requiring `Clone`. Both methods
are lazy and preserve order. `Matcher<T>` has a blanket implementation for
`Fn(&T) -> bool`, but inline closures use native `.filter()` because the
blanket bound cannot infer an untyped closure parameter on the MSRV.

`exactly_one()` inspects at most two items and returns `ExactlyOneError` with
distinct zero and multiple variants. `one_or_none()` returns `None`, one
item, or `MultipleItemsError`. Both methods return the iterator's item type,
whether borrowed or owned. Neither method counts, collects, or
exhausts a potentially infinite iterator. Importing both this extension trait
and `itertools::Itertools` makes the shared `exactly_one` method name
ambiguous; callers in that uncommon case use trait-qualified syntax.

### Portable filters

`FilterExpr<T>` is the only portable predicate value. It is an opaque typed
wrapper over an inert tree of scalar comparisons, ordered `and` and `or`
junctions, `not`, and explicit relation quantifiers. It stores stable field
names and owned literals, never reader closures or executor state. Structural
equality flattens adjacent junctions while retaining operand order; it does
not claim logical equivalence for reordered predicates.

Generated field handles make invalid operations fail to compile on downstream
data through the current public API:

```rust
# // The derive is behind `derive`, which is not a default feature.
# #[cfg(not(feature = "derive"))]
# fn main() -> Result<(), Box<dyn std::error::Error>> { Ok(()) }
# #[cfg(feature = "derive")]
# fn main() -> Result<(), Box<dyn std::error::Error>> {
use libtmux::query::{Filterable as _, QueryIteratorExt as _};

#[derive(libtmux::Filterable)]
#[filterable(target = "task")]
struct Task {
    name: String,
    done: bool,
}

let tasks = vec![Task { name: "build".into(), done: false }];
let fields = Task::filter_fields();
let expression = fields.name.starts_with("build").and(fields.done.eq(false));
let task = tasks.iter().matching(&expression).exactly_one()?;
# let _ = task;
# Ok(())
# }
```

String, boolean, integer, enum, to-one, and to-many handles expose only their
valid operations. To-many relations use `any`, `all`, and `none`; an empty
relation satisfies `all` and `none` but not `any`. To-one relations use `is`
and an absent value does not match. Relation matching is synchronous and may
inspect only relationships already present in the candidate value. Built-in
relation handles therefore land with an explicit hydrated projection rather
than performing hidden tmux I/O or treating unloaded relationships as empty.

Rust membership authoring accepts any `IntoIterator`. The version 1 wire uses
arrays only; membership in an empty array is false and exclusion from it is
true after the candidate meets its field precondition. Python's string-RHS
`in` behavior is not preserved; substring matching uses `contains`. Portable
booleans are JSON booleans, text and enum values are strings, and every
fixed-width integer is a canonical base-10 string. This avoids TypeScript
number precision loss. `isize` and `usize` are excluded because their range is
target-dependent.

Wire decoding stops at 64 expression levels, 4,096 expression nodes, or 4,096
membership values. The limits bound recursive validation and retained input
independently of the deserializer. The schema states the array limits; its
recursive grammar cannot express one budget shared across the whole tree.

`lt`, `lte`, `gt`, and `gte` take one bound rather than a set, and appear in
the schema beside the text operators because an integer rides as text. Which
fields accept them is the crate's business rather than the schema's: a bound
on a text field is refused when it is decoded, the same way an unknown field
is. Ordering compares the decoded integer, not the string, so `"10"` is above
`"9"`. A port reading this grammar has to do the same.

Scalar case-insensitive text operators use Unicode default case folding
without normalization, so `Straße` equals `STRASSE` and a composed `é` does
not equal a decomposed one. Case-insensitive regex uses the Rust `regex`
grammar and its own folding instead, because folding regex syntax as ordinary
text would change the pattern language. The two therefore disagree: `Straße`
equals `STRASSE` as text and does not match `^strasse$` as a regex.

Wire schema version 1 implies that dialect. It is asserted by
[`tests/filter_dialect.rs`](../tests/filter_dialect.rs) against
[`tests/fixtures/dialect-v1/text.json`](../tests/fixtures/dialect-v1/text.json),
which records the answers rather than the library version that happens to
produce them — a port in another language can be held to the same file.
`filter_serde.rs` and the `filter-v1` fixtures lock the wire shape; they say
nothing about what an operator matches, which is why the dialect needs its
own fixture. Pinning dependencies to exact versions is not a substitute: it
freezes a version without checking any of this, and makes the crate
uninstallable alongside a consumer that needs a newer patch.

The optional `derive` feature re-exports `#[derive(Filterable)]` from a
sibling proc-macro package for downstream structs. Built-in libtmux fields
are generated inside the core crate and do not require that feature. The
derive generates typed handles and evaluation for scalar fields and explicit
`Vec<T>` and `Option<T>` relations.

Custom enum fields implement `FilterEnum`, which supplies a closed list of
stable variant names and the current value's name. Generated companion field
types have the same visibility as the source struct and default to
`<Struct>Fields`, with an explicit collision override.

Generated code calls a small `query::__private` runtime ABI. The module is
hidden from ordinary documentation but is compatibility-sensitive because
already-expanded downstream code names it. Core and macro versions are exact,
and compatibility fixtures exercise those signatures; `#[doc(hidden)]` does
not make the boundary private to Rust's linker or type system.

The optional `serde` feature serializes expressions through a private wire
adapter using a versioned tagged grammar with stable field names. Version 1 is
the numeric value `1` delivered by the host serde deserializer. Every signed
and unsigned integer visitor width and an `f64` equal to one are accepted;
accepted values serialize as integer `1`. With `serde_json`, `1`, `1.0`, and
`1e0` are equivalent, and a nearby source such as `1.0000000000000001` can
round to `1.0` before the library sees it. The schema retains exact numeric
`const: 1`, so an exact-decimal validator can reject a source that the host
rounds to one. Other host-decoded integral values are unsupported versions;
non-integral or non-finite floats and nonnumeric values are invalid structure.

The grammar, not Rust method names, is shared with the future TypeScript port.
Unknown versions, fields, operators, quantifiers, and incompatible literal
types are rejected during decoding so `FilterExpr::matches(&T)` remains
infallible. Every object is closed to unknown members. Hosts embed the complete
expression envelope under their own `where` member; `where` is not part of the
expression AST. Any new node, member, or literal representation increments the
schema version.

Duplicate JSON member names are invalid. Rust rejects them while deserializing
the raw text; the future TypeScript and external-input edges must perform the
same duplicate-aware parse before constructing an object because
`JSON.parse` alone collapses duplicates to the last value.

Regular-expression lookups use Rust `regex` syntax deliberately. Python's
`re` syntax includes constructs such as look-around and backreferences that
Rust's linear-time engine rejects. The operator and search semantics remain,
while unsupported pattern syntax is a validated, documented parity-ledger
delta rather than silently changing meaning.

### What an expression can name

An expression names one object's own fields. A `FilterExpr<Pane>` reaches the
`pane_*` catalog and nothing else, so "panes in the session named work" is not
a thing it can say. That is deliberate: the grammar stays per-object so it can
later compile to tmux's own `-f`, which evaluates against one row.

Cross-object questions are asked by narrowing first, which is what tmux does
with a target: `Session::panes` and `Window::panes` choose the rows, and the
expression chooses among them.

Relations are how a parent is asked about, and they need a parent that holds
its children. A `Session` handle does not: it fetches its windows. The shape
that does is `Server::hierarchy`'s, so `SessionTree` and `WindowTree` are
`Filterable`, with a `windows` relation and a `panes` relation respectively.
Their targets are `session_tree` and `window_tree`.

Their field companions are hand-written, because libtmux does not use its own
derive. Each keeps the owned handle's fields under a named field -- `session`
and `window` -- and puts the relation beside it, so a session's own fields and
a question about its contents compose in one expression:

```no_run
# fn expression() {
use libtmux::query::Filterable as _;
use libtmux::{SessionTree, WindowTree};

let sessions = SessionTree::filter_fields();
let windows = WindowTree::filter_fields();

let expression = sessions
    .session
    .session_name
    .starts_with("build")
    .and(sessions.windows.any(windows.window.window_name.eq("editor")));
# let _ = expression;
# }
```

Matching delegates: a predicate naming the relation resolves against the
children, and anything else is handed to the owned handle, which already knows
its own catalog. Validation delegates the same way, so an expression naming a
field the inner type does not have is rejected when it is decoded rather than
evaluating to false.

Local `matching()` never pushes down. A later
`server.query_sessions(expression)` family may compile supported predicates
to tmux `-f` before materialization and evaluate the remainder locally. That
slice must prove equivalence with pure local evaluation before exposing a plan
type. The Python `field__operator` syntax is only an edge parser for CLI, MCP,
or configuration input and never becomes the Rust authoring API.

### Object API

Public modules and object names mirror Python. Properties that execute tmux
become async methods; snapshot properties remain ordinary getters. Simple
operations remain methods with ordinary arguments. Operations with several
optional clauses accept a consuming `#[must_use]` options builder through
`impl Into<Options>`.

```no_run
# async fn walk() -> Result<(), Box<dyn std::error::Error>> {
use libtmux::Server;

let server = Server::new()?;
let session = server.new_session("work").await?;
let window = session.new_window("editor").await?;
let Some(pane) = window.active_pane().await? else {
    return Ok(());
};
pane.send_keys("cargo test").await?;
# Ok(())
# }
```

The same method accepts configured options without adding a second operation:

```no_run
# async fn build(server: &libtmux::Server) -> Result<(), libtmux::Error> {
use libtmux::NewSessionOptions;

let options = NewSessionOptions::new("work")
    .window_name("editor")
    .start_directory("workspace");
let session = server.new_session(options).await?;
# let _ = session;
# Ok(())
# }
```

Options and hooks are exposed as inherent methods on each applicable handle,
not extension traits users must import. Private macros may remove repetitive
wrapper code while keeping generated public methods documented and tested.

Python context-manager cleanup maps to `Server::with_session`,
`Session::with_window`, and `Window::with_pane`. Each accepts an async closure
and waits for cleanup after success or error. An owned task completes creation,
arms cleanup before handing the object to the caller, and keeps cleanup running
after cancellation. Cleanup needs the Tokio runtime to remain active; ordinary
cloneable handles remain non-destructive.

`ScopeError<T, E>` separates creation, operation and cleanup failures.
Combined failures retain both the caller's generic error and the cleanup
`Error`; a cleanup failure alone retains the operation's own successful
result instead of discarding it. Cleanup errors carry `AfterEffect` because
creation already succeeded.

If tmux creates an object but the command fails before yielding a decodable
handle, the scope has no identity to target and cannot compensate for it.

### Errors and collapsed collections

The current public `#[non_exhaustive]` `Error` enum covers Foundation server
configuration, command input, process execution, timeouts, shutdown, version
probing, and sanitized source context. The format kernel uses a private
`FormatCodecError`; future discovery converts its safe metadata into the
public domain error surface only when a public operation needs it. Target
disappearance, missing relationships, and unsupported operation capabilities
also remain future variants.

Local iterator cardinality uses the source-less `ExactlyOneError` and
`MultipleItemsError` values directly; it does not pass through `Error`.
Invalid regexes and portable wire data use the focused, source-less
`FilterExpressionError`. Public errors never retain executable arguments,
raw process output, row bytes, snapshot text, regex patterns, or serialized
filter values.

List-shaped object access offers both shapes, and the short name is the loud one. What follows describes the collapsing
contract:

```no_run
# async fn both(server: &libtmux::Server) -> Result<(), libtmux::Error> {
let loud = server.sessions().await?;
let quiet = server.sessions().await.unwrap_or_default();
# let _ = (loud, quiet);
# Ok(())
# }
```

The first returns an empty `Vec` when the underlying tmux list operation fails
and records the failure through tracing when enabled. The short form returns
`Result<Vec<_>, Error>` and preserves command, framing, and decoding failures.
This pair applies consistently to hierarchy collections. Expression
construction, future explicit remote query execution, and mutations remain
loud.

### Capabilities and future engines

The first `EngineCapabilities` is an immutable wrapper around the exact
detected tmux version. That is the only capability state consumed by the
one-shot Foundation and the private version-gated format rendering. Transport
bytes remain a `CommandResult` contract; original callback-byte recovery is a
separate version-sensitive format concern. The version is reported by the
configured executable and does not assert the build of a daemon already
listening at the selected endpoint.

The initial crate does not expose Statement, Program, planner, registry,
approval, or retry policy APIs. The public query and command values can later
be embedded into one immutable graph. When prepared physical work lands, it
must bind to the exact capability value and distinguish logical operation and
node identity, the current Core-scoped dispatch-request identity,
executor-internal attempt identity, and physical transport correlation. A
caller retry creates a new dispatch request. A future executor-internal retry
keeps the accepted dispatch-request identity and assigns separate attempt
identity; a subprocess PID or control-mode block number is correlation evidence
for an attempt, not the public request ID. Result shape must match the
executor's proven correlation granularity.

A future control-mode engine must issue independent requests separately from
semicolon chains, keep draining protocol frames after caller cancellation,
and give each logical node exactly one terminal state: complete, failed,
skipped, or unknown. It may report per-command states only when retained
protocol evidence supports that attribution.

## Control mode

Managed control acquisition requires a daemon ranking at or above 3.7 final.
`next-3.7` and `3.7-rc*` rank below it and refuse before pinning or `-C`.
The caller reserves client capacity before probing, shares one deadline across
both metadata probes and the handshake, and links the observed socket inode
into a private 0700 carrier. A second probe through that alias must match the first daemon's
version, PID and start time. The control client connects with `-N` through the
alias and owns the carrier until it exits. Replacing the original path cannot
redirect that connection. The nearest usable same-filesystem ancestor keeps
the alias within the platform's Unix socket path limit; allocation or identity
failure refuses acquisition without an unpinned retry.

Feature requirements are carried on commands and folded over a complete
chain before any member is sent. The direct control sender applies the same
literal-argv policy with its pinned daemon version. The guard checks the
command it is given. It does not look inside a command carried in the text of
another command (`if-shell`, `run-shell -C`, `bind-key`, `set-hook`,
`confirm-before`, menus, prompt and `choose-*` templates, brace blocks) or
inside alias bodies, and a second parser for them is out of scope (ADR 0002).
A refused command nested that way is sent as written, and the result depends
on the release:

- The server exits: `break-pane` without `-n` on exactly 3.7 (fixed in
  3.7a); `select-layout` with an invalid string that has no checksum on 3.3
  and 3.3a (3.2a and 3.4 answer with an error and stay up); `refresh-client
  -l` below 3.6a, when an attached client leaves or does not answer the
  clipboard query.
- A silent wrong buffer: `delete-buffer -b NAME` and `set-buffer -b NAME -n`
  below 3.4 act on the most recent buffer when `NAME` does not exist.
- A tmux error and no change: `split-window -p` on exactly 3.4, and `:` or
  `.` in a session or window name on exactly 3.7.

On exact tmux 3.7, name every `break-pane` you nest or bind with `-n NAME`,
including the one the `prefix !` binding runs, or upgrade to 3.7a. A direct
literal `break-pane` is refused there whatever its flags. Sources: the 3.7a
[`CHANGES`](https://github.com/tmux/tmux/blob/3.7a/CHANGES#L3), the 3.7
[`break-pane` code](https://github.com/tmux/tmux/blob/3.7/cmd-break-pane.c#L117-L124),
the 3.7 [`prefix !` binding](https://github.com/tmux/tmux/blob/3.7/key-bindings.c#L358)
and the 3.6a [clipboard query](https://github.com/tmux/tmux/blob/3.6a/CHANGES#L21).
Subprocess operations and polling stay available from 3.3a.

The `control-mode` feature opens one tmux connection and keeps it. A task owns
the pipes; callers hold a `ControlSender` and a `ControlEvents`, which is a
`Stream<Item = Result<Event, Error>>`. Buffered notifications precede one
terminal error. A clean `%exit` is an event; EOF without it is a connection
error. Exhaustion waits for cleanup. Explicit `shutdown` closes early and
returns any terminal error that iteration has not already delivered.

That task is an actor, and this document previously argued against one on the
grounds that a caller-driven connection buffers and drops nothing out of sight.
The argument was backwards. Control mode exists to act on what the server
reports, and a single object holding both directions needs `&mut` for each, so
a task awaiting an event cannot send the command that event implies. One task
multiplexing the connection is what makes the feature usable at all.

What the earlier framing was right about is kept:

- events are handed over with backpressure, never dropped -- a consumer that
  stops reading stops the connection reading from tmux, which is the
  backpressure tmux already applies to a slow client. That pause is available
  only while nothing is waiting for a reply. A reply arrives on the connection
  the events arrive on, so pausing with one outstanding would be waiting for
  something this end had stopped listening for, and it deadlocked: measured
  identically on every release tried, 3.2a through 3.7c, with no error and
  `is_closed` reporting false. While a reply is outstanding the connection keeps
  reading and holds a fixed maximum of what the consumer has not taken. At that
  ceiling it refuses live replies but retains their correlation slots; an
  absolute reply deadline bounds the write through the complete block. Pausing
  resumes the moment no live reply remains;
- the connection lives while either handle is in use and ends when both are
  gone, so a caller who only watches and a caller who only sends are both
  ordinary;
- `attach` returns only once tmux has the client attached, so a change made
  immediately afterwards is reported rather than racing the attach.

Correlation is by arrival order, which is sound because tmux answers commands
in order and blocks do not nest. The block number tmux assigns is carried on
the result rather than used to match, because a command that fails early can
leave a caller waiting for a block tmux will never send.

The protocol is parsed as bytes. tmux escapes only what would break the line
protocol -- bytes below `0x20`, and backslash -- so `%output` carries a pane's
bytes literally and a line is not necessarily UTF-8.

A subscription reports at most once a second, and the number is tmux's rather
than a choice made here: `control.c` arms `subs_timer` with
`struct timeval tv = { .tv_sec = 1 }` when a subscription is added and re-arms
it with the same value each time the timer fires. `ControlSender::subscribe`
says so, because a caller who reads it as a change feed will silently miss
every step a value took between two reports.

## Module and component map

This table spans implemented and planned components. A listed target file is
not a current public module or export until its owning roadmap slice closes.

| Component       | Responsibility                                         | Primary Rust files                      | Python source baseline                    |
| --------------- | ------------------------------------------------------ | --------------------------------------- | ----------------------------------------- |
| Crate facade    | Re-exports, feature gates, package docs                | `src/lib.rs`                            | `src/libtmux/__init__.py`                 |
| Commands        | Redacted arguments, grouping, raw results              | `src/command.rs`                        | `src/libtmux/common.py`                   |
| Versions        | Tmux releases, minimum checks, capabilities            | `src/version.rs`, `src/capabilities.rs` | `src/libtmux/common.py`                   |
| Errors          | Public Foundation/query errors; future domain errors   | `src/error.rs`, `src/query.rs`          | `src/libtmux/exc.py`                      |
| Constants       | Scopes, directions, flags, compatibility constants     | `src/constants.rs`                      | `src/libtmux/constants.py`                |
| Formats         | Typed format descriptors and row parsing               | `src/formats.rs`                        | `src/libtmux/formats.py`, `neo.py`        |
| Snapshots       | Typed object information and winlink projections       | `src/snapshot.rs`                       | `src/libtmux/neo.py`                      |
| Targets         | Validated IDs, typed targets, winlink edges            | `src/target.rs`                         | object modules and `neo.py`               |
| Queries         | Iterator extensions, typed expression AST, cardinality | `src/query.rs`                          | `_internal/query_list.py`, filtering docs |
| Runtime core    | Configuration, capabilities, private executor          | `src/internal/core.rs`                  | command and fetch paths                   |
| Tokio transport | Spawn, capture, timeout, cancellation, reaping         | `src/internal/subprocess.rs`            | `tmux_cmd`                                |
| Server          | Connection and server-wide operations                  | `src/server.rs`                         | `src/libtmux/server.py`                   |
| Session         | Session traversal and operations                       | `src/session.rs`                        | `src/libtmux/session.py`                  |
| Window          | Winlink-aware traversal and operations                 | `src/window.rs`                         | `src/libtmux/window.py`                   |
| Pane            | Pane I/O, layout, popup, mode, and movement operations | `src/pane.rs`                           | `src/libtmux/pane.py`                     |
| Client          | Attached client snapshots and traversal                | `src/client.rs`                         | `src/libtmux/client.py`                   |
| Options         | Typed option scopes, parsing, get/set/unset            | `src/options.rs`                        | `src/libtmux/options.py`                  |
| Hooks           | Hook values and get/set/unset/run operations           | `src/hooks.rs`                          | `src/libtmux/hooks.py`                    |
| Test support    | Isolated sockets, names, retries, temporary objects    | `src/test.rs`, `tests/support/`         | `pytest_plugin.py`, `src/libtmux/test/`   |
| Parity ledger   | Public surface mapping and intentional deltas          | `docs/parity.md`                        | API reference and tests                   |
| Tooling         | Cargo metadata, just recipes, format/lint/CI gates     | crate configuration                     | `pyproject.toml`, workflows               |

The public file names stay close to Python. Runtime-only implementation files
live under private `internal`; no `_internal` module is exported.

## Test architecture

The optional `test-support` feature exports `libtmux::test::TestServer` for
downstream crates. Repository integration tests use the same implementation.

Each guard:

- creates a unique short temporary directory and explicit `-S` socket path;
- exposes the exact socket path and a configured `Server`;
- starts tmux with `-D`, a controlled config, and command-local environment,
  isolates its process group, and retains the foreground daemon child;
- configures every command through the exposed `Server` with tmux's global
  no-start flag so it cannot bootstrap an unowned replacement daemon;
- provides consuming async shutdown that awaits the owned daemon;
- caps graceful observation at five seconds on targets without a safe
  non-reaping child-exit observer, then forces group cleanup and waits;
- reobserves child ownership before each numeric signal phase and permanently
  retires numeric PID, process-group, `Child` signal, and `Child` wait
  operations after `ECHILD` or another untrusted observation failure;
- performs synchronous forced best-effort cleanup in `Drop` without depending
  on a Tokio runtime or claiming that an unreportable failure succeeded;
- retains an opened owned-directory descriptor and removes only the fixed
  socket, configuration, and lock basenames with descriptor-relative cleanup;
- disables lexical recursive temporary-directory cleanup and removes the
  original directory entry through its retained parent descriptor;
- never launches a path-based cleanup client after exposing the socket path.

On Linux, process-group cleanup is followed by an exact-marker sweep. A process
is admitted only when its real UID matches the guard, its live non-zombie
`(pid, uid, start time)` identity is readable, its NUL-delimited environment
contains the exact marker entry, a pidfd opens, and both identity and marker
survive revalidation. Every admitted process is frozen and signaled through
that pidfd. The admitted-pidfd collection owns a best-effort `SIGKILL` fallback:
a later scan, timeout, revalidation, or signal error kills every process already
frozen before the sweep returns failure. The root remains in place because a
best-effort signal is not proof that every target became terminal.

The Linux marker is guard-selection metadata, not authentication or ancestry
proof. A same-UID process that copies the marker is admitted. A genuine
descendant that clears the marker or makes it unreadable before initial
admission is outside the sweep. Initial opacity is skipped because it cannot be
distinguished from another guard's process; opacity after an initial identity
and marker match fails the sweep.

On other Unix targets, successful cleanup proves only process-group signaling
and direct-child waiting. It does not contain descendants that detach with
`setsid` or otherwise leave that group. On every target, observing lost child
ownership makes lifecycle cleanup fail closed: no later numeric process or
group signal and no later `Child` signal or wait is issued, fixed files are
retained, and consuming startup or shutdown reports `ShutdownFailed`. Linux
still attempts its pidfd marker sweep. A successful direct-child wait likewise
permanently retires numeric signaling, so a later containment failure cannot
rearm a stale PID or process group.

The non-reaping observation and the following numeric signal are not one
atomic operation. An uncoordinated external waiter can still reap the leader
between them; this design detects ownership loss at the next observation but
cannot close that final race portably. It also cannot synchronously report a
failure from `Drop` or prove termination of a process stuck indefinitely in
the kernel. Stronger Linux isolation would require an explicit facility such
as a cgroup, PID namespace, or subreaper rather than a stronger claim about the
marker.

Tests use observable polling with deadlines, never fixed sleeps. Later parser
and query tests use hand-written byte fixtures. Behavioral tests execute real
tmux. Foundation transport tests cover timeout cancellation, child reaping, invalid
UTF-8, non-zero status with stdout, stderr without failure, absent daemons,
task abort during a command, cancellation during scoped shutdown, redaction on
every diagnostic surface, daemon PID disappearance, and cleanup after panics.

The compatibility workflow runs the pinned tmux 3.3a floor on Linux. The
format harness builds pinned 3.3a and 3.6 commits,
supplies each binary directly to `TestServer`, and exercises both sides of the
3.6 linked-window callback transition. An unpinned distro tmux cannot
substitute for either endpoint. The harness also builds pinned 3.4 and 3.5a,
the two releases that visually encoded command output, so the `Vis` dialect is
exercised in CI rather than only on a developer's own machine.

The adversarial transport fixture sets one server option to a value carrying a
field terminator, a row terminator, a `#{q:}` special, a multibyte character,
and two bytes that are invalid UTF-8. It then asserts the exact stdout its
dialect emits and decodes that live transport back to the original bytes. It
runs on both sides of both boundaries: 3.4 and 3.5a on the `Vis` lane, 3.6 and
current 3.7b on the `RawQ` lane. A unit fixture additionally round-trips every
nonzero byte value through the `Vis` grammar, and mismatched dialect pairings
assert a loud framing error rather than altered bytes. The release
compatibility matrix will add maintained
tmux releases on Linux and the latest stable release on macOS. Tests for newer
flags assert both supported behavior and the version-gated error or warning
path when their owning slices land.

A parity ledger maps every pinned-baseline Python public method and property to
its Rust method, intentional syntax delta, tmux version gate, and evidence.
The crate is not feature-complete while any baseline capability remains
`planned` or `in progress`; reviewed omissions are recorded as `excluded`.

## Lint and dependency gates

`clippy.toml` carries what the lint levels alone cannot express. `expect`,
`unwrap`, and `panic` are denied workspace-wide, because a library returns
errors rather than deciding to end the caller's process -- but test code
asserts, so the three `allow-*-in-tests` settings exempt it without every
test file repeating an allow attribute. Files with helpers outside a test
function still need one, since clippy's exemption follows test functions
rather than file paths.

`await_holding_lock` and `await_holding_invalid_type` are denied because the
whole public surface is async and the transport supervisor holds a
`std::sync::Mutex`. Nothing holds a guard across an await today; the lints
keep it that way.

`cargo semver-checks` compares the public API against the previous release
and reports which lints a version bump would violate. It is a release gate
rather than a per-PR one, for two reasons. It needs a published baseline, and
until one exists it has to be pointed at a git revision with
`--baseline-rev`. And every version transition inside `0.x.y-prerelease` space
counts as major, which permits everything -- a run today reports `0 checks,
254 skip`. Forcing the comparison with `--release-type minor` across the
commit that closed the hierarchy shapes reports all six correctly, so the tool
does understand this crate; it simply has nothing to say until there is a
release to compare against. There is no `semver` recipe while the crates are
prerelease: `cargo-semver-checks` treats a prerelease-to-prerelease step as a
major change and skips every lint, so it reported `0 checks` and then `no
semver update required`, which reads like a clean bill of health and is not
one.

`deny.toml` gates the dependency tree through `cargo deny`: an allowlist of
the permissive licences the tree actually carries, denial of yanked crates
and unknown registries, and a wildcard ban that exempts intra-workspace path
dependencies. Those carry a version as well as a path, because every crate
here publishes: a path-only dependency cannot be published at all.

## Compatibility lanes

`scripts/test-tmux-format-compat.sh` builds each pinned tmux from source and
runs the whole workspace against it, all targets and all features. The lanes
are 3.3a, 3.4, 3.5a, 3.6b, and 3.7b: the floor, the ceiling, and the releases
in between that are known to differ. 3.4 and 3.5a are the two that wrapped
command output in `VIS_OCTAL|VIS_CSTYLE|VIS_NOSLASH`, and are the reason the
codec has a second dialect at all.

Additional lanes prove refusal before effects: 3.0a and 3.1c daemons behind a
3.3a client, 3.2a, and the unpatched 3.3. A real 3.3a daemon accepts a newer
client, and a replacement between the version check and dispatch is fenced
before mutations.

Running only the codec tests on the middle lanes was tempting and wrong. The
crate's version sensitivity is not confined to the codec: command flags,
control mode, and the stderr wording that separates a missing target from a
refusal all vary by release in principle. Asserting them against one tmux
proves nothing about the others, and the first full run of the floor lane
found a real difference -- `new-session -x -y` sets `default-size` on every
release, but 3.2a still draws a client-less window at 80x23 where 3.6 uses
the default. The crate behaves identically on both; only tmux's rendering
differs, which is why the test asserts the option rather than the geometry.

A ceiling lane matters as much as the floor. Without one, the newest tmux --
the version most people actually run -- would be covered only by whatever the
runner image happens to package, which lags releases by a long way.

## What downstream can construct

Handles and snapshots keep private fields and expose accessors, so adding a
field is not a breaking change. The two shapes `Server::hierarchy` returns are
the exception, because a caller reads `branch.session` and `branch.windows`
directly and an accessor would only be in the way. They are `#[non_exhaustive]`
instead: field access stays, construction and exhaustive destructuring do not,
and a later `clients` relation costs nobody a major version.

`Chooser` and `PaneProgressState` are `#[non_exhaustive]` for the same reason
in the other direction: both enumerate something tmux owns and can extend, so
a downstream `match` needs a `_` arm.

The geometric enums -- `SplitDirection`, `ResizeDirection`, `WindowPlacement`,
`PaneSize` -- are left open deliberately. Their variants are complete by
construction, and exhaustive matching over them is worth more than room to grow
that will not be used.

## Classifying a failure

`Error` has a variant per failure mode; `Error::kind` reduces those to the
decisions a caller actually makes, in the shape of `std::io::Error::kind`.
`ObjectGone`, `Refused`, `Timeout`, `Unreachable`, `UnsupportedVersion`,
`InvalidInput`, `Transport`, and `Decode` each imply a different next step.

Separating `ObjectGone` from `Refused` needs tmux's stderr. tmux exits 1 both
for a target it cannot find and for an argument it does not like, and reports
the first as `can't find <kind>: <target>`. That message is not localized --
tmux has no message catalogue -- and `cmd-find.c` has carried the same four
wordings, plus `no current target`, unchanged from 3.2 through the current
development branch.

Stability of someone else's strings is not something to take on faith, so
`real_tmux_compat_error_missing_target_wording_is_recognized` asserts the
classification against whichever tmux is running, and every compatibility
lane requires that test family to exist. A release that rewords these fails
there rather than silently returning the wrong answer from
`Error::is_object_gone`. A wording the crate does not recognize stays a
refusal, so the cost is the distinction rather than correctness.

One case needs more than the message. A server holding no sessions reports
`no current target` for any command needing one, including `-a` listings that
ask for everything. What that means depends on the request: a server-wide
listing has nothing to list, while a listing or mutation under a target could
not resolve it. The scope, and the request's own `-t`, are what tell them
apart.

How much a miss proves depends on what tmux echoes back, and the echo is
self-describing. tmux returns the part of the target it could not resolve, so
an identity comes back carrying its sigil and a coordinate comes back without
the session it belonged to:

| sent | echoed | what it establishes |
| --- | --- | --- |
| `-t home:@99` | `can't find window: @99` | no window `@99` is reachable |
| `-t home:9` | `can't find window: 9` | `home` holds nothing at index 9 |
| `-t home:nosuch` | `can't find window: nosuch` | `home` holds no window of that name |
| `-t nosuch` | `can't find session: nosuch` | no session of that name |

A coordinate is scoped to one session and is not unique on the server, so its
absence never establishes that an object died. Reading one as an identity
reported index 3 as window `@3` -- a different object, often a live one -- and
`Error::is_object_gone` answered `true`, which is the single predicate a caller
consults before discarding a handle. Those misses are `Error::LinkGone`, which
answers `false`. A session is the exception, because `-t` takes a session's
name and tmux keeps those unique, so a bare word there is still an identity.

The sigil does not settle everything. `unlink-window -t home:@3` answers
`can't find window: @3` whether `@3` is dead or merely linked into some other
session, so the two produce the same string and no reading of it can separate
them. `Window::unlink` and `Window::swap_with` buy that distinction with a
lookup, on the failure path only and only where the answer changes what a
caller should do. They ask the server rather than refreshing the handle: a
window refreshes within its own session, and a window whose link to that
session is gone is the case being told apart, so refreshing would answer the
question with its own premise.

`real_tmux_compat_a_coordinate_miss_is_not_an_object_miss` pins both halves
against whichever tmux the lane runs, for the same reason the wording test
exists, and has passed on every one: 3.2a, 3.4, 3.5a, 3.6b and 3.7b. The split
needs no version gate. 3.2a, the oldest release measured, echoes exactly as
newer releases do. If a future release echoed the whole target or dropped the
sigil, the unrecognized form classifies as `LinkGone` -- the reading that does
not license discarding a live handle -- so the cost of being wrong is a
distinction rather than a destroyed handle.

A listing propagates, which is the whole reason it reads this way -- one
that quietly returned no rows for an unreachable daemon would make
the pair meaningless.

## Cost of gathering the hierarchy

`Server::hierarchy` issues three tmux commands whatever the server holds --
`list-sessions`, `list-windows -a`, `list-panes -a`, run concurrently -- and
stitches the rows locally. Walking down instead costs one command per session
and one per window, which is the shape a caller reaches for first.

`tests/command_budget.rs` asserts the counts by observing what the crate
actually ran, so a change that reintroduces per-object commands fails rather
than merely getting slower. `benches/hierarchy.rs` reports the time:

```console
$ cargo bench --features test-support --bench hierarchy
```

Medians from one developer machine against tmux 3.7d. The ratio is the durable
column; the two before it move with the machine, and did -- an earlier run of
the same bench on tmux 3.7b read 6.7, 15.0 and 26.6ms gathered against 22.3,
78.1 and 282ms walked, twice these and the same shape:

| server | `hierarchy()` | walking down | ratio |
| --- | --- | --- | --- |
| 1 session, 1 window, 2 panes | 3.5 ms | 10.7 ms | 3.0x |
| 2 sessions, 8 windows, 16 panes | 7.3 ms | 41.7 ms | 5.7x |
| 4 sessions, 32 windows, 64 panes | 13.0 ms | 139 ms | 10.7x |

The gathered column is not flat, and saying it is would be wrong: the command
count is constant, but tmux still has to produce and the crate still has to
parse a row per object. What is constant is the per-object process spawn, which
is what dominates the walking column.

Stitching the three listings groups by the numeric part of each tmux ID, which
is `Copy`, so joining them allocates nothing per object.

## Toolchain and packaging

MSRV is a promise about the published crates. `libtmux` and `libtmux-macros`
build and test on 1.85. The consumer crates, `tmux-mcp` and `tmux-workspace`,
exist to exercise the public API from outside. `tmux-mcp` carries its own MSRV
of 1.88, because its dependencies need a compiler the libraries do not, and is
tested on its own lane. Every crate publishes; being published is part of
the consumers' job rather than beside it, since a crate built only inside its
own workspace never proves its dependency requirements resolve.

The consumers also prove the dependency edge runs one way: `libtmux` names
neither of them, and `cargo tree -p libtmux` lists only caseless, regex,
rustix, thiserror, and tokio.

A declared dependency version is the minimum this crate supports, not the
newest published one. Cargo's version-aware resolver picks the newest release
each toolchain can build, so a floor below the latest is the normal state
rather than staleness. A floor is raised only for a fix the crate needs, and
never above a version whose own `rust-version` exceeds the MSRV -- which is
what rules out criterion 0.8 and trybuild 1.0.120 today.

The crate uses Edition 2024, Cargo resolver 3, `rust-version = "1.85"`, and
the repository's MIT license. `rust-toolchain.toml` pins the development
compiler and installs Rustfmt, Clippy, and rust-src. Rustfmt uses stable
settings only.

The Foundation dependency set stays narrow:

- Tokio with only process, I/O, time, synchronization, runtime, and macro
  features;
- thiserror for the public error enum;
- optional tracing instrumentation without subscriber configuration;
- optional tempfile support for the public test guard.

The query slice adds regex support plus independent optional `serde` and
`derive` features. The derive macro lives in the sibling
`libtmux-macros` proc-macro package; normal snapshot querying does not compile
it. Current Cargo verifies the unpublished workspace packages in dependency
order, while Rust 1.85 remains a build-and-test gate rather than a packaging
toolchain.

The manifest includes complete description, repository, documentation,
readme, license, keywords, categories, docs.rs feature metadata, and an
explicit include list. `Cargo.lock` is committed for repository CI
reproducibility.

Rust lints forbid unsafe code and warn on missing docs, unreachable public
items, unused lifetimes, and unused qualifications. Clippy enables `all` and
`pedantic` with narrow documented exceptions; unwrap, expect, and panic are
denied in library code and permitted in tests. CI promotes warnings to errors.

The justfile recipes call the same direct Cargo gates documented for Rust
users, and `just` alone lists them:

```console
$ just check
```

The aggregate gate runs formatting, Clippy, all-target/all-feature tests,
doctests, documentation with warnings denied, the no-default-features build,
bounded feature-powerset checks, and the MSRV lane. `cargo hack` verifies each
public feature independently and the supported combinations on both the
development toolchain and MSRV. Dependency audit and semver checks remain part
of the final compatibility slice; they are not required to build the crate.

## Delivery sequence

The work is split into independently reviewable subprojects:

1. Crate foundation, raw transport, errors, IDs, capabilities, and real-tmux
   test guard.
2. Format catalog, typed snapshots, parsing, winlinks, native iterator
   extensions, portable filters, and downstream derive support.
3. Server, Session, Window, Pane, and Client discovery, traversal, refresh,
   and environment resolution.
4. Full mutation and interaction parity across the object hierarchy.
5. Options, hooks, keys, buffers, menus, prompts, popups, and control-related
   command families.
6. Documentation, examples, compatibility matrix, parity closure, fuzzing,
   semver checks, and final adversarial review.

Each subproject begins from failing behavior tests, ends with the complete
crate gate, and receives an independent API and Rust-quality review. Spike
code is never promoted; implementation is written afresh from the approved
contracts.

## Acceptance criteria

The crate is complete only when:

- the parity ledger accounts for every pinned-baseline Python public
  capability;
- all intentional differences are documented as Rust syntax or verified bug
  corrections rather than missing behavior;
- the full Rust format, Clippy, test, doctest, docs, feature, and MSRV gates
  pass;
- real-tmux tests pass on tmux 3.3a and the maintained compatibility matrix;
- socket isolation and cleanup pass under parallel tests and panic paths;
- timeout, dropped-future, and task-abort tests prove process-group termination
  and direct-child reaping;
- portable filter serialization matches the versioned grammar and local
  evaluation remains the reference semantics for any later pushdown;
- server-wide linked-window and pane enumeration preserves every winlink;
- an adversarial Rust API review has no unresolved critical or important
  findings;
- no shipped file contains spike code, local paths, private data, unstable
  source links, AI signatures, or unowned scaffolding.

What tmux, the platforms and the gates turned out to do along the way, and
what the crate does about each, is catalogued in [findings.md](findings.md).