agent-float-term 0.2.2

Harness Floating Terminal: a persistent F7 shell for unwrapped AI CLIs
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
#!/usr/bin/env expect
# Run from any directory: expect tests/pty_smoke.exp /path/to/agent-float-term
# All keystrokes traverse an attached client's PTY, never tmux send-keys.
# F7 assertions use normal key tables; copy mode and other modal tables are not universal bindings.
set timeout 15
log_user 0
if {$argc != 1} {
    puts stderr "usage: expect tests/pty_smoke.exp /path/to/agent-float-term"
    exit 1
}
set source_binary [file normalize [lindex $argv 0]]
set fixture [file join [file dirname [file normalize [info script]]] fixtures terminal.c]
foreach tool {tmux cc bash} {
    if {[auto_execok $tool] eq ""} {
        puts stderr "PTY smoke test requires $tool"
        exit 1
    }
}
set tmux [lindex [auto_execok tmux] 0]
set shell [lindex [auto_execok bash] 0]
set root ""
set socket ""
set client ""
set extra_clients {}
set stage 0

proc check {condition message} {
    if {![uplevel 1 [list expr $condition]]} { error $message }
}
proc tm {args} {
    global tmux socket
    return [exec $tmux -S $socket {*}$args]
}
proc eventually {script message} {
    set deadline [expr {[clock milliseconds] + 15000}]
    while {[clock milliseconds] < $deadline} {
        if {[uplevel 1 $script]} { return }
        # Drain redraws so a blocked client cannot stall the test's PTY.
        global client extra_clients
        foreach viewer [concat [list $client] $extra_clients] {
            if {$viewer ne ""} {
                expect -i $viewer -timeout 0 -re {.+} {} timeout {} eof {error "tmux client exited"}
            }
        }
        after 50
    }
    error "timed out: $message"
}
proc screen {pane} { return [tm capture-pane -p -t $pane] }
proc contains {pane text} {
    return [expr {[string first $text [screen $pane]] >= 0}]
}
proc clients {} {
    return [llength [split [string trim [tm list-clients -F {#{client_pid}}]]]]
}
proc type {text} {
    global client
    send -i $client -- $text
}
proc f7 {} { type "\033\[18~" }
proc binding {{key F7}} {
    foreach line [split [tm list-keys] \n] {
        if {[regexp {^bind-key +(?:-r +)?-T +root +([^ ]+) +(.*)$} $line ignored actual body] && $actual eq $key} { return "root $actual $body" }
    }
    return ""
}
proc verify_fixtures {pane} {
    global root
    type [format {if [[ $(command -v claude) == '%s/bin/claude' && $(command -v nvim) == '%s/bin/nvim' ]]; then printf 'AFT_PATH_%%s\n' SAFE; fi} $root $root]
    type "\r"
    eventually [list contains $pane AFT_PATH_SAFE] "only private fixture executables are on the command path"
}
proc launch {pane command name} {
    global stage
    incr stage
    type "printf '\\033\[2J\\033\[H'; printf 'AFT_STAGE_%s\\n' $stage; $command\r"
    eventually [list contains $pane "AFT_STAGE_$stage"] "new fixture stage"
    eventually [list contains $pane "AFT_READY $name"] "fixture ready: $command"
}
proc worker_job {pane} {
    set text [screen $pane]
    check {[regexp {AFT_READY claude pid=([0-9]+)} $text ignored parent]} "worker fixture PID missing"
    set job [list $parent]
    foreach {ignored child group} [regexp -all -inline {AFT_WORKER pid=([0-9]+) pgid=([0-9]+)} $text] {
        check {$group eq $parent} "worker left the harness process group"
        check {[regexp -all -inline {\S+} [exec ps -p $child -o ppid=,pgid=]] eq [list $parent $parent]} "worker is not a same-PGID harness descendant"
        lappend job $child
    }
    check {[llength $job] == 4} "expected three ready pipe-stdin workers"
    return $job
}
proc job_alive {job} {
    foreach pid $job { exec kill -0 $pid }
}
proc job_gone {job} {
    foreach pid $job {
        if {![catch {exec kill -0 $pid 2>@1}]} { return 0 }
    }
    return 1
}
proc float_for {owner} {
    set found ""
    foreach line [split [tm list-panes -a -F {#{pane_id}|#{@aft_owner}}] \n] {
        lassign [split $line |] pane parent
        if {$parent eq $owner} {
            check {$found eq ""} "multiple float panes for $owner"
            set found $pane
        }
    }
    check {$found ne ""} "no float for $owner"
    return $found
}
proc write_private {path contents} {
    set out [open $path {WRONLY CREAT TRUNC} 0600]
    puts $out $contents
    close $out
}
proc teardown {} {
    global root socket client extra_clients tmux
    if {$socket ne "" && [file exists $socket]} {
        catch {exec $tmux -S $socket kill-server}
    }
    foreach viewer [concat [list $client] $extra_clients] {
        if {$viewer ne ""} {
            catch {close -i $viewer}
            catch {wait -i $viewer}
        }
    }
    if {$root ne ""} { file delete -force -- $root }
}
trap {teardown; exit 1} {SIGINT SIGTERM SIGHUP}

set failed [catch {
    # Short socket paths also fit macOS's sockaddr_un limit. mktemp creates 0700.
    set root [file normalize [exec mktemp -d /tmp/aft-pty.XXXXXXXX]]
    file attributes $root -permissions 0700
    set inherited_path $env(PATH)
    # Keep Tcl's env-array trace: unsetting the whole array disables export tracking.
    foreach name [array names env] { unset env($name) }
    set env(PATH) $inherited_path
    set env(HOME) [file join $root home]
    set env(XDG_CONFIG_HOME) [file join $root config]
    set env(XDG_DATA_HOME) [file join $root data]
    set env(XDG_STATE_HOME) [file join $root state]
    set env(XDG_RUNTIME_DIR) [file join $root runtime]
    set env(TMUX_TMPDIR) [file join $root sockets]
    set env(SHELL) $shell
    set env(TERM) xterm-256color
    set env(PS1) {AFT_PROMPT> }
    set env(PROMPT_COMMAND) ""
    foreach directory [list $env(HOME) $env(XDG_CONFIG_HOME) $env(XDG_DATA_HOME) $env(XDG_STATE_HOME) $env(XDG_RUNTIME_DIR) $env(TMUX_TMPDIR) [file join $root bin]] {
        file mkdir $directory
        file attributes $directory -permissions 0700
    }
    set socket [file join $env(TMUX_TMPDIR) server]
    set binary [file join $root bin agent-float-term]
    file copy $source_binary $binary
    file attributes $binary -permissions 0700
    set claude [file join $root bin claude]
    # The inspector sees the real OS argv, including --print. Only the fixture's
    # argument guard is adapted so it can keep reading keys in headless-mode tests.
    set adapter [file join $root terminal.c]
    write_private $adapter "#define main fixture_main\n#include \"$fixture\"\n#undef main\nint main(int argc, char **argv) { (void)argc; return fixture_main(1, argv); }"
    exec cc -std=c99 -Wall -Wextra -Werror $adapter -o $claude
    file attributes $claude -permissions 0700
    file copy $claude [file join $root bin nvim]
    set env(PATH) "[file join $root bin]:$env(PATH)"
    set config_dir [file join $env(XDG_CONFIG_HOME) agent-float-term]
    file mkdir $config_dir
    file attributes $config_dir -permissions 0700
    set config [file join $config_dir config.json]
    set valid_config [format {{"shortcut":"F7","shell":"%s","harness_paths":[{"harness":"claude","path":"%s"}]}} \
        [string map {\\ \\\\ \" \\\" \n \\n \r \\r \t \\t} $shell] \
        [string map {\\ \\\\ \" \\\" \n \\n \r \\r \t \\t} $claude]]
    write_private $config $valid_config

    set help [exec $binary --help]
    check {[string first dispatch $help] < 0} "dispatch leaked into public help"
    set bad [catch {exec $binary start claude} output options]
    check {$bad && [lindex [dict get $options -errorcode] 0] eq "CHILDSTATUS" && [lindex [dict get $options -errorcode] 2] == 1} "invalid CLI input must exit 1"
} message options]
# The main scenario follows below; setup errors use the same cleanup path.
if {!$failed} {
    set failed [catch {
        set preview [exec $binary install]
        check {[string first "Preview only" $preview] >= 0} "install did not preview"
        check {![file exists [file join $env(XDG_STATE_HOME) agent-float-term]]} "preview wrote state"
        tm -f /dev/null new-session -d -s outer -c $env(HOME) $shell --noprofile --norc -i
        tm set-option -g default-shell $shell
        tm set-option -g status off
        # A zero timeout can split F7 across the nested PTYs under scheduler load.
        tm set-option -g escape-time 100
        tm set-option -t outer prefix2 C-a
        tm bind-key -n M-n new-window -n root-shortcut
        tm bind-key -T prefix t switch-client -T fixture-modal
        tm bind-key -T fixture-modal c new-window -n modal-shortcut
        set outer [tm display-message -p -t outer: {#{pane_id}}]
        set outer_pid [tm display-message -p -t $outer {#{pane_pid}}]
        # Leave this shadow in place through popup reuse, cleanup, and uninstall.
        tm set-option -p -t $outer @aft_generation STALE_PANE_MARKER

        # Refuse an occupied root key; replacement must be explicit and restorable.
        tm bind-key -n F7 display-message AFT_ORIGINAL_BINDING
        set original [binding]
        check {$original ne ""} "original binding was not captured"
        # Keep the detached control shell alive while testing an incompatible
        # global policy. bind must refuse, not silently change the user's policy.
        tm set-option -t outer destroy-unattached off
        tm set-option -g destroy-unattached on
        check {[catch {exec $binary bind --socket $socket --replace-key 2>@1} refusal]} "bind accepted global destroy-unattached on"
        check {[string first "destroy-unattached is on" $refusal] >= 0} "bind refused for the wrong reason"
        check {[tm show-options -gv destroy-unattached] eq "on"} "bind changed global destroy-unattached"
        check {[binding] eq $original} "incompatible bind modified the key"
        check {[tm list-panes -a -F {#{pane_id}}] eq $outer} "incompatible bind spawned a float"
        check {[tm display-message -p -t $outer {#{pane_pid}}] eq $outer_pid} "incompatible bind replaced the outer shell"
        tm set-option -g destroy-unattached off
        tm set-option -u -t outer destroy-unattached
        check {[catch {exec $binary bind --socket $socket} refusal]} "bind accepted a key collision"
        check {[binding] eq $original} "refused bind modified the key"
        exec $binary bind --socket $socket --replace-key
        check {[tm show-options -gv destroy-unattached] eq "off"} "successful bind changed global destroy-unattached"
        set owned [binding]
        check {$owned ne $original} "bind did not install integration"
        set generation [tm show-options -gqv @aft_generation]
        check {$generation ne "" && $generation ne "STALE_PANE_MARKER"} "bind did not use a global generation"
        check {[string first [format {#{==:#{@aft_float_generation},%s}} $generation] $owned] >= 0} "float hide predicate did not pin the verified generation"
        exec $binary bind --socket $socket
        check {[binding] eq $owned} "bind is not idempotent"

        spawn -noecho $tmux -S $socket attach-session -t outer
        set client $spawn_id
        stty rows 40 columns 120 < $spawn_out(slave,name)
        eventually {expr {[clients] == 1}} "attach outer client"
        type "PS1='AFT_PROMPT> '; unset PROMPT_COMMAND; printf 'AFT_OUTER_%s\\n' READY\r"
        eventually {contains $outer AFT_OUTER_READY} "outer shell ready"
        verify_fixtures $outer
        # A shell builtin reads the exact F7 sequence, without an external process.
        type {printf 'AFT_READ_%s\n' READY; IFS= read -r -s -n 5 key; if [[ $key == $'\e[18~' ]]; then printf 'AFT_SHELL_%s\n' PASS; fi}
        type "\r"
        eventually {contains $outer AFT_READ_READY} "shell builtin waiting for F7"
        f7
        eventually {contains $outer AFT_SHELL_PASS} "ordinary shell F7 passthrough"
        check {[clients] == 1} "shell unexpectedly opened a popup"

        launch $outer nvim nvim
        f7
        eventually {contains $outer "AFT_KEY nvim count=1"} "unmapped foreground F7 passthrough"
        check {[clients] == 1} "unmapped fixture opened a popup"
        type q
        after 200
        launch $outer {claude --print} claude
        f7
        eventually {contains $outer "AFT_KEY claude count=1"} "headless mapped invocation passes F7"
        check {[clients] == 1} "headless invocation opened a popup"
        type q
        after 200

        launch $outer claude claude
        check {[regexp {AFT_READY claude pid=([0-9]+)} [screen $outer] ignored stopped_pid]} "mapped fixture PID missing"
        exec kill -STOP $stopped_pid
        eventually {contains $outer Stopped} "mapped fixture suspended"
        launch $outer nvim nvim
        f7
        eventually {contains $outer "AFT_KEY nvim count=1"} "suspended mapped job does not claim foreground editor"
        check {[clients] == 1} "suspended job opened a popup"
        exec kill -0 $stopped_pid
        type q
        after 200
        type "kill -TERM $stopped_pid; kill -CONT $stopped_pid; wait $stopped_pid 2>/dev/null; printf 'AFT_REAP_%s\\n' DONE\r"
        eventually {contains $outer AFT_REAP_DONE} "suspended fixture reaped"
        # Environment assignment leaves the mapped fixture's OS argv at argc=1.
        launch $outer {AFT_TEST_WORKERS=3 claude} claude
        set worker_job [worker_job $outer]
        set outer_before [screen $outer]
        f7
        eventually {expr {[clients] == 2}} "F7 opens popup with second client"
        set float ""
        foreach pane [split [tm list-panes -a -F {#{pane_id}}] \n] {
            if {$pane ne $outer} { check {$float eq ""} "more than one float"; set float $pane }
        }
        check {$float ne ""} "popup has no persistent pane"
        set float_pid [tm display-message -p -t $float {#{pane_pid}}]
        set float_session [tm display-message -p -t $float {#{session_name}}]
        check {[tm display-message -p -t $float {#{@aft_owner}|#{@aft_float_generation}}] eq "$outer|$generation"} "float ownership used the pane-local generation"
        verify_fixtures $float
        type "cd '$root'; AFT_VALUE=kept; printf 'AFT_FLOAT_%s\\n' READY\r"
        eventually {contains $float AFT_FLOAT_READY} "floating shell accepts input"
        f7
        eventually {expr {[clients] == 1}} "F7 hides popup"
        check {[tm display-message -p -t $float {#{pane_pid}}] eq $float_pid} "hide killed floating shell"
        check {[screen $outer] eq $outer_before} "popup input leaked to outer fixture"
        job_alive $worker_job
        # Session names are presentation, not the ownership/lookup identity.
        tm rename-session -t $float_session {renamed|float}
        set float_session {renamed|float}
        f7
        eventually {expr {[clients] == 2}} "F7 reopens renamed popup"
        check {[float_for $outer] eq $float} "rename created a duplicate float"
        check {[tm display-message -p -t $float {#{pane_pid}}] eq $float_pid} "rename/reopen replaced floating shell"
        check {[tm display-message -p -t $float {#{session_name}}] eq $float_session} "reopen discarded the user's session name"
        set renamed_listing [exec $binary sessions --socket $socket 2>@1]
        check {[string first "$float_session\towner=$outer\tviewers=1\tretained" $renamed_listing] >= 0} "renamed float containing a pipe was not listed: $renamed_listing"
        type {printf 'AFT_STATE_%s:%s\n' "$AFT_VALUE" "$PWD"}
        type "\r"
        eventually {contains $float "AFT_STATE_kept:$root"} "float variable and cwd persist"
        job_alive $worker_job
        set origin_session [tm display-message -p -t $outer {#{session_id}}]
        set origin_window [tm display-message -p -t $outer {#{window_id}}]
        set origin_client [tm show-options -qv -t $float @aft_origin_name]
        # Prefix and command arrive in a single PTY write, not delayed send-keys.
        type "\002c"
        eventually {expr {[clients] == 1 && [llength [split [tm list-windows -t $origin_session -F {#{window_id}}] \n]] == 2}} "prefix+c creates a MAIN window"
        check {[llength [split [tm list-windows -t $float_session -F {#{window_id}}] \n]] == 1} "shortcut created a window inside float"
        type "\002p"
        eventually {expr {[clients] == 2}} "return to original main window restores float automatically"
        check {[float_for $outer] eq $float} "window switching replaced the float"
        type "\001c"
        eventually {expr {[clients] == 1 && [llength [split [tm list-windows -t $origin_session -F {#{window_id}}] \n]] == 3}} "prefix2 creates a MAIN window"
        tm select-window -t $origin_window
        eventually {expr {[clients] == 2}} "restore after prefix2 window switch"
        type "\033n"
        eventually {expr {[clients] == 1 && [llength [split [tm list-windows -t $origin_session -F {#{window_id}}] \n]] == 4}} "root shortcut creates a MAIN window"
        tm select-window -t $origin_window
        eventually {expr {[clients] == 2}} "restore after root shortcut"
        type "\002tc"
        eventually {expr {[clients] == 1 && [llength [split [tm list-windows -t $origin_session -F {#{window_id}}] \n]] == 5}} "rapid custom key-table sequence operates on MAIN"
        tm select-window -t $origin_window
        eventually {expr {[clients] == 2}} "restore after custom key-table shortcut"
        type "\002:"
        eventually {expr {[clients] == 1}} "main command prompt takes focus"
        after 1500
        check {[clients] == 1} "automatic restoration interrupted the main prompt"
        type "new-session -s routed-main\r"
        eventually {expr {[tm display-message -p -c $origin_client {#{session_name}}] eq "routed-main"}} "main prompt creates and selects a MAIN session"
        check {[clients] == 1} "float followed client into a different session"
        tm switch-client -c $origin_client -t $origin_session
        eventually {expr {[clients] == 2}} "return to original session restores the original float"
        check {[float_for $outer] eq $float} "session switching moved or replaced the float"
        tm switch-client -c $origin_client -t routed-main
        eventually {expr {[clients] == 1}} "external session change detaches only the owned viewer"
        tm switch-client -c $origin_client -t $origin_session
        eventually {expr {[clients] == 2}} "restore after external session navigation"
        f7
        eventually {expr {[clients] == 1}} "explicit F7 hides restored float"
        tm switch-client -c $origin_client -t routed-main
        tm switch-client -c $origin_client -t $origin_session
        after 1500
        check {[clients] == 1} "explicitly hidden float restored unexpectedly"
        # Remove only the extra fixture windows/sessions before the old scenarios.
        tm kill-session -t routed-main
        foreach window [split [tm list-windows -t $origin_session -F {#{window_id}}] \n] {
            if {$window ne $origin_window} { tm kill-window -t $window }
        }
        f7
        eventually {expr {[clients] == 2}} "manual reopen after session-switch tests"
        type "nvim\r"
        eventually {contains $float "AFT_READY nvim"} "editor in floating shell"
        check {[regexp {AFT_READY nvim pid=([0-9]+)} [screen $float] ignored editor_pid]} "editor PID missing"
        f7
        eventually {expr {[clients] == 1}} "F7 hides even inside float editor"
        exec kill -0 $editor_pid
        check {![contains $float "AFT_KEY nvim"]} "float editor received reserved F7"
        f7
        eventually {expr {[clients] == 2}} "reopen float editor"
        type q
        after 200
        f7
        eventually {expr {[clients] == 1}} "hide after editor exit"

        # Exiting must close the inner client even if the user's global setting
        # would otherwise switch it into the live AI session.
        tm set-option -g detach-on-destroy off
        f7
        eventually {expr {[clients] == 2}} "open float before exit"
        type "exit\r"
        eventually {expr {[clients] == 1}} "float exit closes popup instead of switching into AI"
        check {[catch {tm has-session -t $float_session}]} "exited float session survived"
        check {[screen $outer] eq $outer_before} "float exit sent input to AI"
        f7
        eventually {expr {[clients] == 2}} "recreate exited float"
        set previous_float_pid $float_pid
        set float [float_for $outer]
        set float_pid [tm display-message -p -t $float {#{pane_pid}}]
        set float_session [tm display-message -p -t $float {#{session_name}}]
        check {$float_pid ne $previous_float_pid} "recreated float reused the old shell PID"
        verify_fixtures $float
        type {printf 'AFT_FRESH_%s\n' "${AFT_VALUE-unset}"; AFT_VALUE=first}
        type "\r"
        eventually {contains $float AFT_FRESH_unset} "new float has fresh shell state"
        type "sleep 120 & printf 'AFT_BG_PID_%s\\n' \$!\r"
        eventually {expr {[regexp {AFT_BG_PID_([0-9]+)} [screen $float] ignored background_pid]}} "float background job started"

        set viewer_before [tm list-clients -t $float_session -F {#{client_pid}}]
        spawn -noecho $tmux -S $socket attach-session -t outer
        set second_client $spawn_id
        lappend extra_clients $second_client
        stty rows 40 columns 120 < $spawn_out(slave,name)
        eventually {expr {[clients] == 3}} "second outer client attached"
        send -i $second_client -- "\033\[18~"
        expect {
            -i $second_client -timeout 15
            -re {already open or opening in another client} {}
            timeout {error "second client did not receive contention refusal"}
            eof {error "second client exited during contention"}
        }
        check {[clients] == 3} "contention created another viewer"
        check {[tm list-clients -t $float_session -F {#{client_pid}}] eq $viewer_before} "second client took over the float"
        type "printf 'AFT_VIEWER_%s\\n' ORIGINAL\r"
        eventually {contains $float AFT_VIEWER_ORIGINAL} "original viewer retains keyboard ownership"
        set first_client $client
        f7
        eventually {expr {[clients] == 2}} "original viewer voluntarily hides"
        set client $second_client
        f7
        eventually {expr {[clients] == 3}} "second client opens the retained float after release"
        type "\002c"
        eventually {expr {[clients] == 2}} "second client's shortcut targets main"
        type "\002p"
        eventually {expr {[clients] == 3}} "automatic restoration follows the NEW opener, not the old client"
        f7
        eventually {expr {[clients] == 2}} "second viewer hides"
        set client $first_client
        f7
        eventually {expr {[clients] == 3}} "original client can reopen after handoff"
        foreach window [split [tm list-windows -t $origin_session -F {#{window_id}}] \n] {
            if {$window ne $origin_window} { tm kill-window -t $window }
        }
        send -i $second_client -- "\002d"
        expect -i $second_client -timeout 15 eof {} timeout {error "second client did not detach"}
        wait -i $second_client
        set extra_clients {}
        f7
        eventually {expr {[clients] == 1}} "hide after contention"
        exec kill -STOP [lindex $worker_job 0]
        after 400
        tm has-session -t $float_session
        type "fg\r"
        eventually {expr {[string first + [exec ps -p [lindex $worker_job 0] -o stat=]] >= 0}} "resume stopped AI in foreground"

        # A nested interactive shell is still a valid ancestry chain, not a wrapper.
        type "q\r"
        eventually {contains $outer "AFT_WORKERS_REAPED count=3"} "normal harness exit reaps all workers"
        eventually {job_gone $worker_job} "original harness and workers exited"
        eventually {catch {tm has-session -t $float_session}} "AI exit destroys hidden float without another F7"
        eventually {job_gone [list $background_pid]} "AI exit terminates the float's background job"
        # A killed parent cannot leave helpers blocked forever on inherited writers.
        launch $outer {AFT_TEST_WORKERS=3 claude} claude
        set killed_job [worker_job $outer]
        f7
        eventually {expr {[clients] == 2}} "open float before AI is killed"
        set killed_float_session [tm display-message -p -t [float_for $outer] {#{session_name}}]
        exec kill -KILL [lindex $killed_job 0]
        eventually {job_gone $killed_job} "workers exit on EOF when their parent dies"
        eventually {expr {[clients] == 1 && [catch {tm has-session -t $killed_float_session}]}} "AI death destroys the VISIBLE float"
        type "$shell --noprofile --norc -i\r"
        type "printf 'AFT_NESTED_%s\\n' READY\r"
        eventually {contains $outer AFT_NESTED_READY} "nested shell ready"
        verify_fixtures $outer
        launch $outer claude claude
        f7
        eventually {expr {[clients] == 2}} "nested shell foreground harness opens float"
        check {[float_for $outer] ne $float} "new invocation reused the previous float"
        set float [float_for $outer]
        set float_session [tm display-message -p -t $float {#{session_name}}]
        f7
        eventually {expr {[clients] == 1}} "hide nested-shell float"
        type q
        eventually {catch {tm has-session -t $float_session}} "nested invocation exit resets its float"
        type "exit\r"
        after 200
        launch $outer claude claude
        f7
        eventually {expr {[clients] == 2}} "new original-pane invocation opens fresh float"
        set float [float_for $outer]
        set float_pid [tm display-message -p -t $float {#{pane_pid}}]
        set float_session [tm display-message -p -t $float {#{session_name}}]
        type "AFT_VALUE=first; printf 'AFT_NEW_%s\\n' READY\r"
        eventually {contains $float AFT_NEW_READY} "replacement float accepts state"
        f7
        eventually {expr {[clients] == 1}} "hide replacement float"

        set second_pane [tm new-window -d -P -F {#{pane_id}} -t outer -n second -c $env(HOME) $shell --noprofile --norc -i]
        tm select-window -t outer:second
        verify_fixtures $second_pane
        launch $second_pane claude claude
        check {[tm display-message -p -t $second_pane {#{pane_current_path}}] eq [tm display-message -p -t $outer {#{pane_current_path}}]} "parent panes do not share cwd"
        f7
        eventually {expr {[clients] == 2}} "second parent opens independent float"
        set second_float [float_for $second_pane]
        set second_float_pid [tm display-message -p -t $second_float {#{pane_pid}}]
        set second_session [tm display-message -p -t $second_float {#{session_name}}]
        check {$second_float ne $float && $second_float_pid ne $float_pid} "same-cwd panes shared a floating shell"
        type {printf 'AFT_SECOND_%s\n' "${AFT_VALUE-unset}"; AFT_VALUE=second}
        type "\r"
        eventually {contains $second_float AFT_SECOND_unset} "second float state is independent"
        f7
        eventually {expr {[clients] == 1}} "hide second float"
        tm select-window -t [tm display-message -p -t $outer {#{window_id}}]
        f7
        eventually {expr {[clients] == 2}} "reopen first pane float"
        type {printf 'AFT_FIRST_%s\n' "$AFT_VALUE"}
        type "\r"
        eventually {contains $float AFT_FIRST_first} "second pane did not overwrite first float state"
        f7
        eventually {expr {[clients] == 1}} "hide first float"

        set foreign [tm new-session -d -P -F {#{session_id}} -s foreign -c $env(HOME) $shell --noprofile --norc -i]
        set foreign_pid [tm display-message -p -t $foreign {#{pane_pid}}]
        tm rename-session -t $second_session {orphan|float}
        set second_session {orphan|float}
        # Simulate a legacy float without invocation metadata. These are preserved
        # for explicit cleanup rather than being adopted by an exit watcher.
        tm set-option -t $second_session @aft_invocation ""
        tm kill-pane -t $second_pane
        set listing [exec $binary sessions --socket $socket 2>@1]
        check {[string first "$second_session\towner=$second_pane\tviewers=0\torphan" $listing] >= 0} "orphan not listed"
        check {[string first "$float_session\towner=$outer\tviewers=0\tretained" $listing] >= 0} "live float not listed"
        check {[string first foreign $listing] < 0} "foreign session claimed as owned"
        check {[catch {exec $binary cleanup --socket $socket --session $float_session --yes 2>@1}]} "cleanup accepted a live parent's float"
        check {[catch {exec $binary cleanup --socket $socket --session foreign --yes 2>@1}]} "cleanup accepted a foreign session"
        set preview [exec $binary cleanup --socket $socket --session $second_session 2>@1]
        check {[string first "Would remove $second_session" $preview] >= 0} "orphan cleanup did not preview"
        check {[tm display-message -p -t $second_float {#{pane_pid}}] eq $second_float_pid} "preview killed the orphan"
        exec $binary cleanup --socket $socket --yes 2>@1
        check {[catch {tm has-session -t $second_session}]} "cleanup retained the selected orphan"
        check {[tm display-message -p -t $foreign {#{pane_pid}}] eq $foreign_pid} "cleanup killed foreign shell"
        check {[tm display-message -p -t $float {#{pane_pid}}] eq $float_pid} "cleanup killed live float"
        check {[tm show-options -gqv @aft_generation] eq $generation} "popup lifecycle changed the global generation"
        check {[tm show-options -pqv -t $outer @aft_generation] eq "STALE_PANE_MARKER"} "popup lifecycle removed the pane-local shadow"
        set doctor [exec $binary doctor --socket $socket]
        check {[string first "Owned key binding: intact" $doctor] >= 0} "pane-local generation broke doctor ownership: $doctor"

        write_private $config {{"width":"invalid"}}
        f7
        eventually {contains $outer "AFT_KEY claude count=1"} "invalid config forwards F7"
        check {[clients] == 1} "invalid config opened a popup"
        write_private $config $valid_config
        # Move only our private copy, leaving the build artifact untouched.
        file rename $binary ${binary}.away
        f7
        eventually {contains $outer "AFT_KEY claude count=2"} "missing executable fallback forwards F7"
        file rename ${binary}.away $binary

        exec $binary install --yes 2>@1
        exec $binary uninstall --yes 2>@1
        check {[binding] eq $original} "uninstall failed to restore owned replacement"
        check {[tm display-message -p -t $outer {#{pane_pid}}] eq $outer_pid} "outer shell was replaced"
        check {[tm display-message -p -t $float {#{pane_pid}}] eq $float_pid} "uninstall killed persistent shell"
        tm has-session -t $float_session

        exec $binary install --yes 2>@1
        exec $binary bind --socket $socket --replace-key 2>@1
        tm bind-key -n F7 display-message AFT_USER_CHANGED
        set user_binding [binding]
        exec $binary uninstall --yes 2>@1
        check {[binding] eq $user_binding} "uninstall overwrote a user-changed F7 binding"

        # Older tmux canonicalizes C-i to Tab; 3.7c keeps distinct bindings.
        # Capture tmux's spelling, and test collision consent on either version.
        tm bind-key -n C-i display-message AFT_ORIGINAL_ALIAS
        set alias_key ""
        foreach line [split [tm list-keys] \n] {
            if {[regexp {^bind-key +(?:-r +)?-T +root +([^ ]+) +display-message AFT_ORIGINAL_ALIAS$} $line ignored alias_key]} { break }
        }
        check {$alias_key in {C-i Tab}} "C-i binding was not serialized as C-i or Tab"
        set original_alias [binding $alias_key]
        set original_tab [binding Tab]
        write_private $config [string map [list {"shortcut":"F7"} {"shortcut":"C-i"}] $valid_config]
        check {[catch {exec $binary bind --socket $socket 2>@1}]} "C-i bypassed the $alias_key key collision"
        check {[binding $alias_key] eq $original_alias} "alias refusal modified $alias_key"
        exec $binary install --yes 2>@1
        exec $binary bind --socket $socket --replace-key 2>@1
        check {[binding $alias_key] ne $original_alias && [binding $alias_key] ne ""} "alias replacement did not bind $alias_key"
        exec $binary uninstall --yes 2>@1
        check {[binding $alias_key] eq $original_alias} "uninstall failed to restore $alias_key"
        check {[binding Tab] eq $original_tab} "alias cleanup modified Tab"
        check {[binding] eq $user_binding} "alias cleanup modified unrelated user F7"
    } message options]
}
if {$failed} {
    puts stderr "PTY smoke FAILED: $message"
    if {[dict exists $options -errorinfo]} { puts stderr [dict get $options -errorinfo] }
    if {$socket ne "" && [file exists $socket]} {
        catch {puts stderr "last AFT error: [tm show-options -gqv @aft_last_error]"}
        catch {puts stderr "clients: [tm list-clients]"}
        catch {
            foreach binding [split [tm list-keys] \n] {
                if {[string first " F7 " $binding] >= 0} { puts stderr "binding: $binding" }
            }
        }
        catch {puts stderr "pane format: [tm display-message -p -t %0 {#{pane_id}|#{pane_pid}|#{pane_tty}|#{session_id}|#{pane_dead}|#{pane_in_mode}}]"}
        catch {
            foreach session [split [tm list-sessions -F {#{session_id}}] \n] {
                puts stderr "session $session: [tm show-options -t $session]"
            }
        }
        catch {
            foreach pane [split [tm list-panes -a -F {#{pane_id}}] \n] {
                puts stderr "pane $pane:\n[screen $pane]"
            }
        }
    }
}
teardown
if {$failed} { exit 1 }
puts "PTY smoke passed: invocation reset, visible/hidden exit and jobs, main shortcuts/custom tables, session restoration, prompt protection, F7 hiding, scope, persistence, contention, legacy cleanup, binding ownership"