opencode-thrum-pro 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/assets/skills/coordinator-assessing-agent-completion/SKILL.md +17 -20
  2. package/assets/skills/coordinator-deploying-a-node/SKILL.md +2 -2
  3. package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +9 -1
  4. package/assets/skills/coordinator-merging-code/SKILL.md +79 -5
  5. package/assets/skills/coordinator-philosophy-merge-gate/SKILL.md +1 -1
  6. package/assets/skills/coordinator-rolling-out-to-nodes/SKILL.md +2 -2
  7. package/assets/skills/diagnosing-fleet-and-daemon/SKILL.md +1 -1
  8. package/assets/skills/implementer-receiving-dispatch/SKILL.md +1 -1
  9. package/assets/skills/orchestrate/SKILL.md +1 -1
  10. package/assets/skills/persistent-watcher-archetype/SKILL.md +1 -1
  11. package/assets/skills/persistent-watcher-archetype/resources/roster-watch.sh +3 -3
  12. package/assets/skills/project-setup/SKILL.md +7 -12
  13. package/assets/skills/project-setup/resources/implementation-agent.md +2 -2
  14. package/assets/skills/researcher-maintaining-memory/SKILL.md +1 -1
  15. package/assets/skills/thrum/resources/CLI_REFERENCE.md +1 -1
  16. package/assets/skills/thrum/resources/LISTENER_PATTERN.md +2 -2
  17. package/assets/skills/using-monitors/SKILL.md +8 -3
  18. package/assets/skills/using-tmux-ssh/SKILL.md +32 -21
  19. package/assets/skills/using-tmux-ssh/resources/tmux-ssh +1158 -0
  20. package/assets/skills/verify-against-plan/SKILL.md +1 -1
  21. package/dist/installer.js +3 -5
  22. package/package.json +1 -1
  23. package/assets/skills/coordinator-context-monitoring/SKILL.md +0 -685
  24. package/assets/skills/coordinator-post-restart-sweep/SKILL.md +0 -135
@@ -0,0 +1,1158 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # tmux-ssh - run commands on a remote host through one persistent ssh login session.
4
+ # Usage: tmux-ssh --help.
5
+ set -uo pipefail
6
+
7
+ print_help() {
8
+ cat <<'TSSH_HELP_EOF'
9
+ tmux-ssh - run commands on a remote host through one persistent ssh login session.
10
+
11
+ QUICK START (HOST is an ssh alias or [user@]host)
12
+ tmux-ssh HOST "make test" run a command that ENDS: prints its output, exits with its code
13
+ tmux-ssh HOST "tmux new -d -s NAME 'cmd'" start a long job in a remote tmux session, detached
14
+ tmux-ssh HOST --send "vim f" drive a program that stays open (vim, top, REPL, prompt):
15
+ tmux-ssh HOST --read --settle type, look at the screen,
16
+ tmux-ssh HOST --send --key Escape send a named key
17
+ tmux-ssh HOST --timeout 900 "cmd" long command; on exit 124 keep waiting: tmux-ssh HOST --resume
18
+ tmux-ssh HOST --expect 'Password:=secret' "cmd" answer a prompt automatically
19
+ tmux-ssh HOST --abort stuck host: Ctrl-C the command and clear the watch
20
+ tmux-ssh HOST --kill still stuck: drop the ssh session
21
+
22
+ TWO MODES
23
+ run mode tmux-ssh HOST "cmd" waits for cmd to FINISH; for commands that end.
24
+ interactive mode --send / --read for programs that stay open (vim, tmux attached, top, REPLs, prompts).
25
+
26
+ COMMANDS
27
+ tmux-ssh HOST connect if needed; print status
28
+ tmux-ssh HOST "cmd" run mode (see above)
29
+ tmux-ssh HOST --status show the recorded session (prints "not connected" if there is none)
30
+ tmux-ssh HOST --resume keep waiting for a timed-out command (never re-sends it)
31
+ tmux-ssh HOST --abort send Ctrl-C to the watched command, wait briefly, clear the watch
32
+ exit 0: host ready for the next command; 1: the shell did not answer (--read, then --send --key or --kill)
33
+ tmux-ssh HOST --kill close the ssh session and clear all saved state
34
+ tmux-ssh HOST --attach attach your terminal to the session (detach with C-b d)
35
+ tmux-ssh HOST --send "text" type text + Enter into the live session
36
+ tmux-ssh HOST --send --no-enter "text" type without Enter
37
+ tmux-ssh HOST --send --key KEY send a named tmux key (C-c, Escape, Up, Enter, ...)
38
+ tmux-ssh HOST --read [--settle] print the current screen (--settle waits until it stops changing); also works while another tmux-ssh command is still running, as does --status
39
+
40
+ FLAGS
41
+ --timeout SEC max wait for a command, 1-2700 (default 120). --resume reuses the original command's value.
42
+ --lines N return the last N lines of output/screen (default 50)
43
+ --connect-timeout SEC max wait for ssh + remote shell on (re)connect (default 25)
44
+ --no-reconnect refuse instead of reconnecting a dead session
45
+ --interrupt-on-timeout on timeout send Ctrl-C (default: leave the command running)
46
+ --force-run run mode: skip the refusal of interactive commands (vim, top, tmux attach, ...)
47
+ --expect 'PAT=RESP' when PAT appears on screen after the command starts, type RESP + Enter (once per rule).
48
+ PAT is a literal SINGLE-LINE substring; a PAT containing a newline or CR is refused (exit 2).
49
+ --expect-secret 'PAT=@FILE' | 'PAT=env:VAR' like --expect, RESP read from a file or env var (not shown in argv).
50
+ The reply is typed into the pane: a prompt that echoes it will show it in --read output.
51
+ --expect-host NAME OPTIONAL. Refuse unless the host reports hostname NAME (short or FQDN, any case). Use it for
52
+ an alias you could mistype or that may point at the wrong box. Find NAME with: ssh HOST hostname.
53
+ Also settable via TMUX_SSH_EXPECT_HOST.
54
+ -h, --help this text
55
+
56
+ GOTCHAS
57
+ - Run mode is only for commands that FINISH. A command that stays open (vim, top, less, "tmux new" without -d,
58
+ "tmux attach", a prompt with no --expect) never returns: use interactive mode, or "tmux new -d".
59
+ Obvious interactive commands are refused (exit 2) before anything is sent; --force-run overrides.
60
+ - A command or --send text over about 16,000 bytes is refused (exit 2) before anything is sent. Copy the data over with scp and run a short command (scp patch.diff HOST:/tmp/ ; tmux-ssh HOST 'git apply /tmp/patch.diff').
61
+ - The command runs in the remote user's login shell (may be zsh, not bash). Bash-only syntax such as
62
+ "read -p" differs: wrap it as bash -c '...' .
63
+ - Quote the whole remote command once, locally. "$VAR" inside double quotes expands on YOUR machine; use single quotes.
64
+ - After exit 124 the host is "watched": every other command returns 125 until you use --resume, --abort or --kill.
65
+ - --kill closes the ssh session only. Remote processes started in it (e.g. a tmux session) keep running.
66
+ - A dropped ssh loses any command running in it. Run long work under "tmux new -d" or nohup and re-run after a drop.
67
+ - A host that reports THIS machine's own hostname is refused (125); clear it with --kill.
68
+ - A live session keeps the TMUX_SSH_OPTS it started with; to change them run --kill first.
69
+ - --expect that never matched is reported as a warning after the command finishes.
70
+
71
+ EXIT CODES
72
+ 0-123, 126+ the remote command's own exit code.
73
+ 124 timed out; the command may still be running or waiting for input.
74
+ Next: --read (see the screen) | --resume [--timeout N] (keep waiting) | --send --key C-c then --resume | --abort
75
+ 125 not done; the MESSAGE says why. Common: another command is still watched (--resume | --abort | --kill),
76
+ not connected, hostname mismatch, unresolved input (--kill). Nothing new was typed unless the message says
77
+ "completion unknown" (ssh dropped mid-command: do NOT blindly retry).
78
+ 2 usage error (including a refused interactive command).
79
+ 3 input state UNKNOWN (a keystroke failed or ssh dropped during --send). The host is guarded: every
80
+ typing call is refused until --kill; --read, --status and --resume still work.
81
+
82
+ ENVIRONMENT
83
+ TMUX_SSH_DIR where session state lives (default ~/.thrum/tmux-ssh)
84
+ TMUX_SSH_OPTS extra ssh options (e.g. '-i ~/.ssh/key -o IdentitiesOnly=yes'); chars [A-Za-z0-9 _./=:,@~+%-] only
85
+ TMUX_SSH_EXPECT_HOST default for --expect-host
86
+ TSSH_HELP_EOF
87
+ }
88
+
89
+ # tssh_has_done <nonce> <pane-text>: 0 iff a line of the pane text is the
90
+ # completion marker for <nonce>. Pure bash (no pipe, no temp file, no fork):
91
+ # `printf | grep -q` exits early and printf died on SIGPIPE under pipefail on a
92
+ # large pane capture. The leading newline makes the line anchor.
93
+ tssh_has_done() {
94
+ local re=$'\n'"TSSHDONE_${1}_ rc=[0-9]+"
95
+ [[ $'\n'"$2" =~ $re ]]
96
+ }
97
+
98
+ SCRIPT_NAME="tmux-ssh"
99
+ SOCKET="tmux-ssh" # single tmux server for all tmux-ssh sessions
100
+ REF_DIR="${TMUX_SSH_DIR:-$HOME/.thrum/tmux-ssh}"
101
+
102
+ # Defaults (overridable by flags)
103
+ CMD_TIMEOUT=120
104
+ CONNECT_TIMEOUT=25
105
+ LINES=50
106
+ ALLOW_RECONNECT=true
107
+ INTERRUPT_ON_TIMEOUT=false
108
+ CAPTURE_LINES=5000 # scrollback window captured for output slicing
109
+ LOCK_WAIT=${TMUX_SSH_LOCK_WAIT:-30} # seconds to wait for the per-host lock (env override is a TEST seam)
110
+ SEND_ENTER=true
111
+ SEND_KEY=""
112
+ SETTLE=false
113
+ FORCE_RUN=false
114
+ TIMEOUT_GIVEN=false
115
+ WATCH_TIMEOUT=""
116
+ ABORT_WAIT=5 # seconds --abort waits for the DONE marker after Ctrl-C
117
+ EXPECT_HOST="${TMUX_SSH_EXPECT_HOST:-}" # a later --expect-host flag wins
118
+ EXPECT_PATS=()
119
+ EXPECT_RESPS=()
120
+ EXPECT_SECRET=()
121
+ EXPECT_FIRED=()
122
+
123
+ log() { printf '[%s] %s\n' "$SCRIPT_NAME" "$*" >&2; }
124
+ die() { local code="${2:-2}"; printf '[%s] error: %s\n' "$SCRIPT_NAME" "$1" >&2; exit "$code"; }
125
+ usage(){ print_help >&2; }
126
+
127
+ # ---- naming derived deterministically from the host ------------------------
128
+ sanitize() { printf '%s' "$1" | tr -c 'A-Za-z0-9_-' '_'; }
129
+
130
+ # ---- per-host advisory lock (portable: mkdir, no flock dependency) ---------
131
+ LOCK_HELD=""
132
+ acquire_lock() {
133
+ local dir="$1" waited=0 max="$LOCK_WAIT" merr
134
+ while ! merr=$(mkdir "$dir" 2>&1); do
135
+ # mkdir fails when the lock exists (another tmux-ssh holds the host: wait) or when it cannot be created at all
136
+ # (permissions, read-only filesystem, missing parent: waiting never helps). If the directory is not there after a
137
+ # failed mkdir, try once more: the holder may have released it between the two calls.
138
+ if [ ! -d "$dir" ]; then
139
+ merr=$(mkdir "$dir" 2>&1) && break
140
+ [ -d "$dir" ] || die "cannot create the host lock directory $dir: ${merr:-unknown error} (this is not lock contention; check the permissions of $(dirname "$dir"))" 125
141
+ fi
142
+ if [ -f "$dir/pid" ]; then
143
+ local p; p=$(cat "$dir/pid" 2>/dev/null || true)
144
+ if [ -n "$p" ] && ! kill -0 "$p" 2>/dev/null; then
145
+ rm -f "$dir/pid" 2>/dev/null; rmdir "$dir" 2>/dev/null; continue # steal stale
146
+ fi
147
+ fi
148
+ waited=$((waited + 1))
149
+ if [ "$waited" -gt "$((max * 5))" ]; then
150
+ die "could not acquire lock for this host (held by pid $(cat "$dir/pid" 2>/dev/null || echo '?'))" 125
151
+ fi
152
+ sleep 0.2
153
+ done
154
+ printf '%s\n' "$$" > "$dir/pid"
155
+ LOCK_HELD="$dir"
156
+ }
157
+ release_lock() {
158
+ [ -n "$LOCK_HELD" ] || return 0
159
+ rm -f "$LOCK_HELD/pid" 2>/dev/null; rmdir "$LOCK_HELD" 2>/dev/null
160
+ LOCK_HELD=""
161
+ }
162
+ trap release_lock EXIT
163
+
164
+ # ---- reference file --------------------------------------------------------
165
+ read_ref() {
166
+ EXPECTED_HOSTNAME=""
167
+ [ -f "$REF_FILE" ] || return 0
168
+ EXPECTED_HOSTNAME=$(grep '^expected_hostname=' "$REF_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
169
+ }
170
+
171
+ # ---- identity guard ---------------------------------------------------------
172
+ # A stale recorded identity (or a probe that leaked onto a local shell in the pane) can
173
+ # make this script treat the box it was launched FROM as the remote target. These checks are independent of,
174
+ # and layered ON TOP OF, the existing prior-vs-new drift check: that check only catches DRIFT from a previously
175
+ # recorded value; it says nothing about whether that value (or a brand-new first-connect value) was ever
176
+ # trustworthy.
177
+ # local_hostname tries three ways to learn this machine's own hostname,
178
+ # since any single one can be absent (a minimal container may lack
179
+ # `hostname`; `uname -n` and `HostName`/`LocalHostName` can disagree on
180
+ # macOS. Prints nothing and fails if all three do.
181
+ local_hostname() {
182
+ local h
183
+ h=$(hostname 2>/dev/null); [ -n "$h" ] && { printf '%s' "$h"; return 0; }
184
+ h=$(uname -n 2>/dev/null); [ -n "$h" ] && { printf '%s' "$h"; return 0; }
185
+ if command -v scutil >/dev/null 2>&1; then
186
+ h=$(scutil --get LocalHostName 2>/dev/null)
187
+ [ -n "$h" ] && { printf '%s' "$h"; return 0; }
188
+ fi
189
+ return 1
190
+ }
191
+ # resolve_local_hostname memoizes local_hostname for the lifetime of this
192
+ # invocation, into the globals below -- it's called from every identity-guard
193
+ # check, and each underlying lookup is its own subprocess.
194
+ #
195
+ # [CORRECTNESS] MUST be called directly (never as `x=$(resolve_local_hostname)`
196
+ # or inside any other command substitution) -- a command substitution forks a
197
+ # SUBSHELL, and an assignment made inside a subshell is discarded the instant
198
+ # it exits. Calling it that way silently defeats the memoization (every call
199
+ # would re-run local_hostname from scratch, each in its own throwaway
200
+ # subshell) without producing any error. Callers read the result back from
201
+ # $LOCAL_HOSTNAME_CACHE afterward, in the SAME (non-subshell) context.
202
+ LOCAL_HOSTNAME_CACHE=""
203
+ LOCAL_HOSTNAME_CACHE_SET=false
204
+ resolve_local_hostname() {
205
+ [ "$LOCAL_HOSTNAME_CACHE_SET" = true ] && return 0
206
+ LOCAL_HOSTNAME_CACHE=$(local_hostname) || true
207
+ LOCAL_HOSTNAME_CACHE_SET=true
208
+ }
209
+ first_label() { printf '%s' "$1" | cut -d. -f1; }
210
+ lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
211
+ # hostnames_match compares two hostnames tolerant of short-vs-FQDN form
212
+ # (either side's first dot-label may match the other's full value),
213
+ # case-insensitively.
214
+ hostnames_match() {
215
+ local a b al bl
216
+ a="$1"; b="$2"
217
+ [ -n "$a" ] && [ -n "$b" ] || return 1
218
+ al=$(lower "$a"); bl=$(lower "$b")
219
+ [ "$al" = "$bl" ] && return 0
220
+ [ "$(lower "$(first_label "$a")")" = "$bl" ] && return 0
221
+ [ "$al" = "$(lower "$(first_label "$b")")" ] && return 0
222
+ return 1
223
+ }
224
+ # alias_is_explicitly_local reports whether $HOST (optional user@ stripped)
225
+ # is a literal loopback alias -- the one case where "reported hostname ==
226
+ # local hostname" is the CORRECT, intended outcome, not a hazard.
227
+ alias_is_explicitly_local() {
228
+ # ::1 deliberately excluded: HOST's
229
+ # own charset validation rejects ':' with "invalid host" before ANY alias
230
+ # ever reaches this function, and IPv6 aliases are not a fleet use case --
231
+ # so it would be dead code, unreachable via the real CLI.
232
+ case "${1#*@}" in localhost|127.0.0.1) return 0 ;; esac
233
+ return 1
234
+ }
235
+ # An unknown local hostname is refused, not silently passed: if this
236
+ # machine's own identity can't be determined, NEITHER of the checks below
237
+ # can rule out that a reported/recorded value IS this machine, so trusting
238
+ # either would be fail-open on exactly the case they exist to catch.
239
+ die_unknown_local_identity() {
240
+ die "IDENTITY GUARD: cannot determine this machine's own hostname (hostname / uname -n / scutil all failed or returned empty) -- refusing $1 rather than risk it silently being this machine" 125
241
+ }
242
+ # reject_if_local_identity_ref refuses a RECORDED (from an earlier session)
243
+ # hostname that equals this machine's own hostname -- a ref file poisoned by
244
+ # a past instance of this exact bug. Fires before any (re)connect attempt.
245
+ reject_if_local_identity_ref() {
246
+ local recorded="$1" lh
247
+ [ -n "$recorded" ] || return 0
248
+ alias_is_explicitly_local "$HOST" && return 0
249
+ resolve_local_hostname; lh="$LOCAL_HOSTNAME_CACHE"
250
+ [ -n "$lh" ] || die_unknown_local_identity "the ref file for $HOST"
251
+ if hostnames_match "$recorded" "$lh"; then
252
+ die "IDENTITY GUARD: the ref file for $HOST records '$recorded', which matches THIS machine's own hostname ('$lh') -- treating it as POISONED. Clear it: $SCRIPT_NAME $HOST --kill" 125
253
+ fi
254
+ }
255
+ # reject_if_local_identity refuses a FRESHLY-PROBED hostname that equals this
256
+ # machine's own hostname. Runs on every (re)connect, including the very
257
+ # first one -- a first connect is never trusted blindly.
258
+ reject_if_local_identity() {
259
+ local h="$1" lh
260
+ [ -n "$h" ] || return 0
261
+ alias_is_explicitly_local "$HOST" && return 0
262
+ resolve_local_hostname; lh="$LOCAL_HOSTNAME_CACHE"
263
+ [ -n "$lh" ] || die_unknown_local_identity "$HOST's reported hostname '$h'"
264
+ if hostnames_match "$h" "$lh"; then
265
+ die "IDENTITY GUARD: $HOST's reported hostname '$h' matches THIS machine's own hostname ('$lh') -- refusing to treat the local box as the remote target" 125
266
+ fi
267
+ }
268
+ # reject_if_expect_host_mismatch enforces an operator-supplied expectation
269
+ # (--expect-host / TMUX_SSH_EXPECT_HOST), when one was given. Deliberately
270
+ # NOT derived automatically from the alias name: an alias's OS hostname can
271
+ # legitimately differ from its ssh-config name (see the flag's help text for
272
+ # how to look it up and set this explicitly).
273
+ reject_if_expect_host_mismatch() {
274
+ local h="$1"
275
+ [ -n "$EXPECT_HOST" ] || return 0
276
+ hostnames_match "$h" "$EXPECT_HOST" && return 0
277
+ die "IDENTITY GUARD: $HOST's reported hostname '$h' does not match --expect-host/TMUX_SSH_EXPECT_HOST '$EXPECT_HOST' -- refusing" 125
278
+ }
279
+
280
+ # A timeout may leave the remote command running. Persist its exact marker so
281
+ # another invocation can observe that command without reconnecting or typing.
282
+ WATCH_NONCE=""
283
+ read_watch() {
284
+ WATCH_NONCE=""; WATCH_TIMEOUT=""
285
+ [ -f "$WATCH_FILE" ] || return 0
286
+ WATCH_NONCE=$(grep '^nonce=' "$WATCH_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
287
+ WATCH_TIMEOUT=$(grep '^timeout=' "$WATCH_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
288
+ case "$WATCH_TIMEOUT" in *[!0-9]*) WATCH_TIMEOUT="" ;; esac
289
+ case "$WATCH_NONCE" in ''|*[!A-Za-z0-9_]*) WATCH_NONCE=""; return 1 ;; esac
290
+ }
291
+ write_watch() { # returns nonzero (and sets no WATCH_NONCE) unless the watch file was written AND renamed into place
292
+ local n="$1" tmp="${WATCH_FILE}.tmp.$$"
293
+ printf 'nonce=%s\nstarted_at=%s\ntimeout=%s\n' "$n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$CMD_TIMEOUT" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
294
+ mv -f "$tmp" "$WATCH_FILE" 2>/dev/null || { rm -f "$tmp"; return 1; }
295
+ [ -f "$WATCH_FILE" ] || return 1
296
+ WATCH_NONCE="$n"
297
+ }
298
+ # ---- input guard ------------------------------------------------------------
299
+ # A payload-free file armed under the host lock BEFORE any keystroke that changes the remote input, and cleared only
300
+ # after every primitive of that input succeeded (and, for a command, after its watch was published). If any
301
+ # primitive fails, the guard STAYS with the stage and tmux's native rc: what reached the remote input is UNKNOWN, so
302
+ # every later mutating call (run, ensure, probe, setup, --send, --key) is refused before it types anything, and
303
+ # nothing is cleared, killed or reconnected automatically. Observers (--read, --status, --resume) keep working. Only
304
+ # an explicit --kill that kills the exact session (or proves it absent) clears it.
305
+ guard_arm() { # $1=stage : 0 only if the guard file is in place
306
+ local tmp="${GUARD_FILE}.tmp.$$"
307
+ printf 'state=armed\nstage=%s\narmed_at=%s\n' "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
308
+ mv -f "$tmp" "$GUARD_FILE" 2>/dev/null || { rm -f "$tmp"; return 1; }
309
+ [ -f "$GUARD_FILE" ]
310
+ }
311
+ guard_mark() { # $1=stage $2=step $3=native rc : record UNKNOWN (best effort; the armed guard stays either way)
312
+ { printf 'state=UNKNOWN\nstage=%s\nfailed_step=%s\nnative_rc=%s\nmarked_at=%s\n' "$1" "$2" "$3" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
313
+ > "${GUARD_FILE}.tmp.$$" && mv -f "${GUARD_FILE}.tmp.$$" "$GUARD_FILE"; } 2>/dev/null || rm -f "${GUARD_FILE}.tmp.$$"
314
+ }
315
+ guard_clear() { rm -f "$GUARD_FILE" 2>/dev/null; }
316
+ guard_present() { [ -e "$GUARD_FILE" ]; }
317
+ guard_summary() { tr '\n' ' ' < "$GUARD_FILE" 2>/dev/null; }
318
+ refuse_if_guarded() { # pre-input admission for every mutating path
319
+ guard_present || return 0
320
+ die "UNRESOLVED input on $HOST ($(guard_summary| sed 's/ $//')): a previous keystroke's effect is unknown, so NOTHING new is typed. Inspect with $SCRIPT_NAME $HOST --read / --status; when resolved, clear it with $SCRIPT_NAME $HOST --kill" 125
321
+ }
322
+ clear_watch() { rm -f "$WATCH_FILE"; WATCH_NONCE=""; }
323
+ write_ref() {
324
+ mkdir -p "$REF_DIR"
325
+ # Atomic: write to a temp file then rename, so a concurrent reader never sees
326
+ # a half-written ref (and a crash mid-write can't truncate the live file).
327
+ local tmp="${REF_FILE}.tmp.$$"
328
+ printf 'host=%s\nssh_target=%s\nsocket=%s\nsession=%s\nexpected_hostname=%s\nestablished_at=%s\n' \
329
+ "$HOST" "$HOST" "$SOCKET" "$SESSION" "$EXPECTED_HOSTNAME" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
330
+ > "$tmp"
331
+ mv -f "$tmp" "$REF_FILE"
332
+ }
333
+
334
+ # ---- tmux primitives -------------------------------------------------------
335
+ has_session() { tmux -L "$SOCKET" has-session -t "$STGT" 2>/dev/null; }
336
+ pane_cmd() { tmux -L "$SOCKET" list-panes -t "$PTGT" -F '#{pane_current_command}' 2>/dev/null | head -1; }
337
+ capture() { tmux -L "$SOCKET" capture-pane -p -t "$PTGT" -S "-${CAPTURE_LINES}" -J 2>/dev/null; }
338
+ # send_line: type the text, then Enter, checking each primitive. Never clears, kills or reconnects on failure. Returns
339
+ # 1 if the literal failed, 2 if the literal was accepted and Enter failed; SEND_STEP, SEND_RC (tmux's native rc) and
340
+ # SEND_ERR (its stderr) describe the failure. Callers hold an armed input guard around it.
341
+ SEND_ERR=""; SEND_RC=0; SEND_STEP=""
342
+ send_line() {
343
+ SEND_ERR=$(tmux -L "$SOCKET" send-keys -t "$PTGT" -l "$1" 2>&1 >/dev/null); SEND_RC=$?
344
+ if [ "$SEND_RC" -ne 0 ]; then SEND_STEP=literal; return 1; fi
345
+ SEND_ERR=$(tmux -L "$SOCKET" send-keys -t "$PTGT" Enter 2>&1 >/dev/null); SEND_RC=$?
346
+ if [ "$SEND_RC" -ne 0 ]; then SEND_STEP=enter; return 2; fi
347
+ SEND_STEP=""; SEND_ERR=""
348
+ }
349
+ # guarded_line <stage> <text> : arm the guard, send, clear only on full success. rc 0 ok; rc 125 = guard could not be
350
+ # armed (NOTHING was sent); rc 3 = a primitive failed (guard kept, input UNKNOWN).
351
+ guarded_line() {
352
+ guard_arm "$1" || { log "🔴 REFUSED ($1): could not arm the input guard in $REF_DIR; nothing was sent"; return 125; }
353
+ if ! send_line "$2"; then
354
+ guard_mark "$1" "$SEND_STEP" "$SEND_RC"
355
+ log "🔴 INPUT UNKNOWN ($1): tmux $SEND_STEP failed (native rc $SEND_RC: ${SEND_ERR:-no message}). What reached $HOST's input is unknown; the host is now guarded: nothing more is typed until $SCRIPT_NAME $HOST --kill. Do NOT blindly retry."
356
+ return 3
357
+ fi
358
+ [ "${GUARD_KEEP:-0}" = 1 ] || guard_clear
359
+ return 0
360
+ }
361
+ pane_dead() { tmux -L "$SOCKET" list-panes -t "$PTGT" -F '#{pane_dead}' 2>/dev/null | head -1; }
362
+ # the pane is a LIVE ssh client: alive (a remain-on-exit pane keeps reporting its last command when dead) and
363
+ # its foreground process is ssh
364
+ pane_is_ssh() { [ "$(pane_dead)" = 0 ] && [ "$(pane_cmd)" = "ssh" ]; }
365
+ # connected == the pane's foreground process is a live ssh (gate 1)
366
+ is_connected(){ has_session && pane_is_ssh; }
367
+
368
+ # Pattern tests on a captured buffer. NOT `printf | grep -q`: under pipefail, grep -q exits at the first match,
369
+ # and if the buffer is larger than a pipe (a 5000-line capture can be ~1 MB) printf then fails on the closed pipe,
370
+ # the pipeline is nonzero, and a PRESENT marker reads as absent (missed DONE -> false timeout; missed BEGIN ->
371
+ # leaked history; missed --expect prompt). grep without -q reads all of its input, so printf always completes.
372
+ buf_has_re() { printf '%s\n' "$1" | grep -E -- "$2" >/dev/null; }
373
+ buf_has_fixed() { printf '%s\n' "$1" | grep -F -- "$2" >/dev/null; }
374
+
375
+ # Per-command marker token. pid + epoch-seconds + two $RANDOM draws so the
376
+ # anchored DONE regex cannot collide with a concurrent or prior call's sentinel.
377
+ nonce() { printf '%s_%s_%s%s' "$$" "$(date +%s)" "${RANDOM}" "${RANDOM}"; }
378
+
379
+ # ---- probe the remote hostname (gate 2 / establish-time learning) ----------
380
+ # Echoes the discovered hostname on success (rc 0); rc 1 on timeout.
381
+ # The recorded-hostname line is matched ANCHORED (^TSSHHOST_<n>=), which the
382
+ # echoed input line (starts with ':') can never match — so only a live shell's
383
+ # resolved output is ever read.
384
+ probe_hostname() {
385
+ local timeout="${1:-8}" n
386
+ # [SECURITY] gate on a live ssh pane BEFORE sending, mirroring cmd_send/handle_expects. A dead pane
387
+ # never answers, so the probe times out instead of reading anything.
388
+ if ! pane_is_ssh; then
389
+ log "probe: pane is not ssh (connection not confirmed) -- refusing to probe"
390
+ return 1
391
+ fi
392
+ n=$(nonce)
393
+ local grc=0; guarded_line probe "$(printf ': TSSHBEGIN_%s; echo ""; echo "TSSHHOST_%s=$(hostname)"; echo "TSSHDONE_%s_ rc=$?"' "$n" "$n" "$n")" || grc=$?
394
+ [ "$grc" = 0 ] || return "$grc" # 3 = input UNKNOWN (fatal), 125 = nothing sent
395
+ local deadline=$(( $(date +%s) + timeout )) buf
396
+ while [ "$(date +%s)" -lt "$deadline" ]; do
397
+ buf=$(capture)
398
+ if tssh_has_done "$n" "$buf"; then
399
+ local h
400
+ h=$(printf '%s\n' "$buf" | grep -E "^TSSHHOST_${n}=" | tail -1 | sed "s/^TSSHHOST_${n}=//")
401
+ # [SECURITY] This value is interpolated into gate 3's guard string
402
+ # (build_wrapper: [ "$(hostname)" = "<h>" ]). A hostile/compromised remote
403
+ # returning a hostname containing '"' or ';' could break out and, on the
404
+ # local-shell fallback path, execute locally. A real hostname is
405
+ # [A-Za-z0-9._-]+; reject anything else and fail the probe (fail-safe).
406
+ case "$h" in
407
+ ''|*[!A-Za-z0-9._-]*) log "refusing probed hostname with unexpected characters: '$h'"; return 1 ;;
408
+ esac
409
+ printf '%s\n' "$h"
410
+ return 0
411
+ fi
412
+ sleep 0.2
413
+ done
414
+ return 1
415
+ }
416
+
417
+ # ---- build the sentinel-wrapped payload (gate 3 inline host-guard) ---------
418
+ # Resolved DONE line carries the exit code; the echoed input line contains the
419
+ # literal format so it can never false-match the anchored resolved DONE regex.
420
+ # A command that runs `exit` (or `logout`) would close the remote login shell before the DONE line is printed: the pane
421
+ # dies, the run is reported as a dropped connection (rc 125, completion unknown) although it finished. Such a command is
422
+ # typed inside a subshell, where `exit N` just becomes the command's exit code. Other commands run in the login shell
423
+ # itself, so a `cd` or `export` in one still applies to the next. Over-matching only costs that persistence.
424
+ cmd_exits_shell() { [[ "$1" =~ (^|[^A-Za-z0-9_./-])(exit|logout)($|[^A-Za-z0-9_./-]) ]]; }
425
+ # The command is TYPED into the remote login shell (send-keys -l), so that shell's line editor sees it key by key. zsh with
426
+ # oh-my-zsh's url-quote-magic then backslash-quotes ; & | { ... in any word containing "://": `curl http://h/a; echo B` becomes
427
+ # one command, and a command that ends in a URL swallows the appended `; __tsrc=$?` (no DONE line, a timeout). Such a command
428
+ # is sent base64-encoded and decoded on the remote, so no widget can rewrite it. A failed decode is an error, never a no-op.
429
+ cmd_needs_encoding() { case "$1" in *://*) return 0 ;; esac; return 1; }
430
+ encode_cmd() { # prints the typed form of a command that contains a URL
431
+ local b64; b64=$(printf '%s' "$1" | base64 | tr -d '\n')
432
+ printf '%s' '__tsd=$(printf %s '"$b64"' | { base64 -d 2>/dev/null || base64 -D; }) && eval "$__tsd"'
433
+ }
434
+ build_wrapper() {
435
+ local n="$1" cmd="$2"
436
+ if cmd_exits_shell "$cmd"; then cmd="( $cmd"$'\n'")"; fi
437
+ if cmd_needs_encoding "$cmd"; then cmd=$(encode_cmd "$cmd"); fi
438
+ # A final heredoc delimiter must occupy its own line, even when the caller
439
+ # omitted a trailing newline. Keep the wrapper suffix on a separate line.
440
+ printf ': TSSHBEGIN_%s; if [ "$(hostname)" = "%s" ]; then %s\n__tsrc=$?; else __tsrc=125; fi; echo ""; echo "TSSHDONE_%s_ rc=$__tsrc"' \
441
+ "$n" "$EXPECTED_HOSTNAME" "$cmd" "$n"
442
+ }
443
+
444
+ # ---- slice this command's output out of the pane buffer --------------------
445
+ emit_output() {
446
+ local buf="$1" n="$2" sliced
447
+ sliced=$(printf '%s\n' "$buf" | awk -v n="$n" '
448
+ index($0, "TSSHBEGIN_" n) { seen=1; next }
449
+ seen && $0 ~ ("^TSSHDONE_" n "_ rc=[0-9]") { exit }
450
+ seen { print }
451
+ ')
452
+ # Fallback ONLY if the BEGIN anchor scrolled out of the capture window. A command that printed nothing has an
453
+ # empty slice WITH its BEGIN present: falling back then leaked old pane history as its output.
454
+ if [ -z "$sliced" ] && ! buf_has_fixed "$buf" "TSSHBEGIN_${n}"; then
455
+ sliced=$(printf '%s\n' "$buf" | awk -v n="$n" '$0 ~ ("^TSSHDONE_" n "_ rc=[0-9]") { exit } { print }')
456
+ fi
457
+ # Strip trailing blank lines (the wrapper's separator echo), then tail.
458
+ printf '%s\n' "$sliced" | awk '{ L[NR]=$0 } END { last=NR; while (last>0 && L[last] ~ /^[[:space:]]*$/) last--; for (i=1;i<=last;i++) print L[i] }' \
459
+ | tail -n "$LINES"
460
+ }
461
+
462
+ # ---- expect: auto-answer known prompts while a command runs (Tier B) -------
463
+ # Scans only the region AFTER this command's BEGIN marker so a stale prompt in
464
+ # scrollback cannot fire. Each rule is one-shot (tracked in EXPECT_FIRED).
465
+ handle_expects() {
466
+ local buf="$1" n="$2" i pat resp region
467
+ [ "${#EXPECT_PATS[@]}" -eq 0 ] && return 0
468
+ region=$(printf '%s\n' "$buf" | awk -v n="$n" 'index($0,"TSSHBEGIN_" n){seen=1;next} seen{print}')
469
+ for i in "${!EXPECT_PATS[@]}"; do
470
+ [ "${EXPECT_FIRED[$i]:-0}" -eq 1 ] && continue
471
+ pat="${EXPECT_PATS[$i]}"
472
+ # SINGLE-LINE CONTRACT: $pat is one literal substring (from --expect); a value with a newline is not supported.
473
+ if [[ $region == *"$pat"* ]]; then
474
+ # [SECURITY] Gate 1 the response too: if ssh dropped mid-command the pane is dead, and a response
475
+ # (esp. an --expect-secret) must not be typed anywhere. The identity probe can't run mid-command.
476
+ if ! pane_is_ssh; then
477
+ log "expect: pane is no longer ssh (connection dropped) — REFUSING to send response for '$pat' (the connection is gone)"
478
+ continue
479
+ fi
480
+ resp="${EXPECT_RESPS[$i]}"
481
+ local erc=0; guarded_line expect "$resp" || erc=$?
482
+ if [ "$erc" != 0 ]; then EXPECT_SEND_FAILED=$erc; return 1; fi # stop: no further responses or actions
483
+ EXPECT_FIRED[$i]=1
484
+ if [ "${EXPECT_SECRET[$i]:-0}" -eq 1 ]; then log "expect: matched '$pat' → sent secret response"
485
+ else log "expect: matched '$pat' → sent '$resp'"; fi
486
+ fi
487
+ done
488
+ }
489
+
490
+ # ssh command line for the pane; caller TMUX_SSH_OPTS go first (ssh: first -o wins).
491
+ ssh_cmd_line() {
492
+ local o="${TMUX_SSH_OPTS:-}"
493
+ echo "ssh ${o:+$o }-o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o LogLevel=ERROR -- $HOST"
494
+ }
495
+ # the same command as an argv array (SSH_ARGV), so tmux execs ssh DIRECTLY: no $SHELL -c in between. Splitting on
496
+ # spaces is exact: TMUX_SSH_OPTS and HOST are validated to a charset with no quotes or glob characters.
497
+ ssh_argv() { read -r -a SSH_ARGV <<< "$(ssh_cmd_line)"; }
498
+
499
+ # ---- establish or reconnect the ssh session --------------------------------
500
+ establish() {
501
+ read_ref
502
+ local prior="$EXPECTED_HOSTNAME"
503
+ reject_if_local_identity_ref "$prior"
504
+ # [SECURITY] ssh is the pane's OWN process (exec'd by tmux directly, argv form, no shell), never a command typed into a local
505
+ # shell. Keys sent while ssh is still authenticating used to sit in the tty and be read by the LOCAL shell when
506
+ # auth failed (publickey denied -> the probe ran on the fleet box). With no shell in the pane, a failed or
507
+ # dropped ssh leaves a DEAD pane (remain-on-exit) that executes nothing; its last screen stays readable.
508
+ ssh_argv
509
+ if ! has_session; then
510
+ tmux -L "$SOCKET" new-session -d -s "$SESSION" -x 220 -y 50 "${SSH_ARGV[@]}" \; \
511
+ set-option -t "$PTGT" remain-on-exit on >/dev/null 2>&1 || die "tmux new-session failed" 1
512
+ elif ! pane_is_ssh; then
513
+ # dead ssh pane, or a legacy session whose pane is a local shell: replace the pane's process outright (-k)
514
+ tmux -L "$SOCKET" set-option -t "$PTGT" remain-on-exit on >/dev/null 2>&1
515
+ tmux -L "$SOCKET" respawn-pane -k -t "$PTGT" "${SSH_ARGV[@]}" >/dev/null 2>&1 || die "tmux respawn-pane failed" 1
516
+ fi
517
+ has_session || die "ssh to $HOST exited immediately (auth/host-key issue? see: ssh $HOST)" 125
518
+ # Wait for the ssh client to become the pane's foreground process.
519
+ local deadline=$(( $(date +%s) + CONNECT_TIMEOUT ))
520
+ while [ "$(date +%s)" -lt "$deadline" ]; do pane_is_ssh && break; sleep 0.2; done
521
+ if ! pane_is_ssh; then
522
+ capture | awk 'NF' | tail -5 | sed 's/^/ ssh: /' >&2
523
+ die "ssh did not start within ${CONNECT_TIMEOUT}s or has exited (auth/host-key issue? see the ssh lines above; nothing was sent)" 125
524
+ fi
525
+ # Wait for a live remote shell (only a returned sentinel counts; keys only go
526
+ # in AFTER pane==ssh, so this can never learn the LOCAL hostname).
527
+ sleep 1
528
+ local h="" pdeadline=$(( $(date +%s) + CONNECT_TIMEOUT ))
529
+ while [ "$(date +%s)" -lt "$pdeadline" ]; do
530
+ local prc=0; h=$(probe_hostname 8) || prc=$?
531
+ case "$prc" in 3) exit 3 ;; 125) exit 125 ;; esac # fatal send failure: no further probe, setup or user send
532
+ [ "$prc" = 0 ] && [ -n "$h" ] && break
533
+ h=""; sleep 0.5
534
+ done
535
+ if [ -z "$h" ]; then
536
+ capture | awk 'NF' | grep -v 'TSSH' | tail -5 | sed 's/^/ ssh: /' >&2
537
+ if pane_is_ssh; then
538
+ die "connected but no remote shell confirmed within ${CONNECT_TIMEOUT}s (auth prompt? try: $SCRIPT_NAME $HOST --attach)" 125
539
+ fi
540
+ die "ssh to $HOST exited before a remote shell was confirmed (see the ssh lines above; nothing ran on any shell)" 125
541
+ fi
542
+ # never trust a probed hostname -- first connect or not --
543
+ # before it clears the identity guard, and before it can be written as
544
+ # ground truth below.
545
+ reject_if_local_identity "$h"
546
+ reject_if_expect_host_mismatch "$h"
547
+ if [ -n "$prior" ] && [ "$h" != "$prior" ]; then
548
+ die "IDENTITY MISMATCH on (re)connect: recorded '$prior' but got '$h' — refusing to use this session" 125
549
+ fi
550
+ EXPECTED_HOSTNAME="$h"
551
+ # [SECURITY] re-check the live ssh pane one last time, AFTER identity is confirmed but
552
+ # BEFORE this send and before write_ref: a ref must never record a validated identity for a pane that has died.
553
+ if ! pane_is_ssh; then
554
+ die "IDENTITY GUARD: connection to $HOST dropped between identity confirmation and session setup -- refusing before recording anything" 125
555
+ fi
556
+ # Make output clean and deterministic for a driven session.
557
+ local src=0; guarded_line setup 'export PAGER=cat GIT_PAGER=cat 2>/dev/null; true' || src=$?
558
+ [ "$src" = 0 ] || exit "$src" # no ref, no "connected", no user payload after an uncertain setup line
559
+ write_ref
560
+ if [ -n "$prior" ]; then
561
+ log "RECONNECTED to $HOST ($h) — remote shell state (cwd, env, running jobs) was RESET"
562
+ else
563
+ log "connected to $HOST ($h)"
564
+ fi
565
+ }
566
+
567
+ # ---- ensure a usable, identity-confirmed connection ------------------------
568
+ ensure_connected() {
569
+ read_ref
570
+ reject_if_local_identity_ref "$EXPECTED_HOSTNAME"
571
+ if is_connected; then
572
+ local h prc=0; h=$(probe_hostname 12) || prc=$?
573
+ case "$prc" in 3) exit 3 ;; 125) exit 125 ;; esac # fatal send failure: not "busy", not a reconnect
574
+ if [ -n "$h" ]; then
575
+ reject_if_local_identity "$h"
576
+ reject_if_expect_host_mismatch "$h"
577
+ if [ -n "$EXPECTED_HOSTNAME" ] && [ "$h" != "$EXPECTED_HOSTNAME" ]; then
578
+ die "IDENTITY MISMATCH: recorded '$EXPECTED_HOSTNAME' but live shell is '$h' — refusing" 125
579
+ fi
580
+ if [ -z "$EXPECTED_HOSTNAME" ]; then EXPECTED_HOSTNAME="$h"; write_ref; fi
581
+ return 0
582
+ fi
583
+ # pane==ssh but no sentinel: a command is in flight, or the shell is wedged.
584
+ if [ -n "$EXPECTED_HOSTNAME" ]; then
585
+ die "session for $HOST is busy or unresponsive (a command may be running) — retry, or: $SCRIPT_NAME $HOST --attach" 125
586
+ fi
587
+ fi
588
+ # Not connected.
589
+ if [ "$ALLOW_RECONNECT" != "true" ]; then
590
+ die "not connected to $HOST and --no-reconnect is set (the command did NOT run)" 125
591
+ fi
592
+ establish
593
+ }
594
+
595
+ # ---- observe one exact command marker without ever sending input ------------
596
+ observe_watch() {
597
+ local n="$1" permit_actions="${2:-false}"
598
+ local deadline=$(( $(date +%s) + CMD_TIMEOUT )) buf found=0
599
+ while [ "$(date +%s)" -lt "$deadline" ]; do
600
+ buf=$(capture)
601
+ if tssh_has_done "$n" "$buf"; then found=1; break; fi
602
+ # If ssh exits mid-command the pane's foreground process stops being `ssh`
603
+ # (a remote program never changes it — only the ssh client dying does).
604
+ # Bail fast instead of waiting out the whole timeout; completion is unknown.
605
+ if ! pane_is_ssh; then
606
+ # The remote wrapper may have printed DONE immediately before an `exit`
607
+ # closed ssh. Re-capture once and prefer that authoritative exact marker
608
+ # over a false completion-unknown classification.
609
+ buf=$(capture)
610
+ if tssh_has_done "$n" "$buf"; then found=1; break; fi
611
+ emit_output "$buf" "$n"
612
+ clear_watch
613
+ log "connection to $HOST dropped mid-command (ssh exited) — completion unknown; NOT retried"
614
+ return 125
615
+ fi
616
+ if [ "$permit_actions" = "true" ]; then
617
+ EXPECT_SEND_FAILED=0; handle_expects "$buf" "$n"
618
+ if [ "$EXPECT_SEND_FAILED" != 0 ]; then emit_output "$(capture)" "$n"; log "--expect response failed; watch kept, observation stopped"; return 3; fi
619
+ fi
620
+ sleep 0.2
621
+ done
622
+ if [ "$found" -ne 1 ]; then
623
+ buf=$(capture)
624
+ # A completion at the deadline is still authoritative; do not turn it
625
+ # into a timeout merely because it arrived between loop iterations.
626
+ if tssh_has_done "$n" "$buf"; then found=1
627
+ else
628
+ emit_output "$buf" "$n"
629
+ if [ "$permit_actions" = "true" ] && [ "$INTERRUPT_ON_TIMEOUT" = "true" ]; then
630
+ tmux -L "$SOCKET" send-keys -t "$PTGT" C-c
631
+ log "command timed out after ${CMD_TIMEOUT}s — sent Ctrl-C; collect the result with: $SCRIPT_NAME $HOST --resume"
632
+ else
633
+ log "command timed out after ${CMD_TIMEOUT}s — still running remotely, or waiting for input. Next steps:"
634
+ log " $SCRIPT_NAME $HOST --read see the screen"
635
+ log " $SCRIPT_NAME $HOST --resume [--timeout N] keep waiting"
636
+ log " $SCRIPT_NAME $HOST --send --key C-c interrupt it, then --resume to collect"
637
+ log " $SCRIPT_NAME $HOST --abort Ctrl-C and clear the watch"
638
+ fi
639
+ return 124
640
+ fi
641
+ fi
642
+ emit_output "$buf" "$n"
643
+ local rc; rc=$(printf '%s\n' "$buf" | grep -E "^TSSHDONE_${n}_ rc=[0-9]+" | tail -1 | sed 's/.*rc=//')
644
+ if [ "$permit_actions" = "true" ] && [ "${#EXPECT_PATS[@]}" -gt 0 ]; then
645
+ local xi
646
+ for xi in "${!EXPECT_PATS[@]}"; do
647
+ [ "${EXPECT_FIRED[$xi]:-0}" -eq 1 ] || log "warning: --expect pattern '${EXPECT_PATS[$xi]}' never matched; its response was NOT sent"
648
+ done
649
+ fi
650
+ clear_watch
651
+ [ -n "$rc" ] || rc=125
652
+ return "$rc"
653
+ }
654
+
655
+ # ---- run one command, retaining its nonce if observation times out ---------
656
+ run_command() {
657
+ local cmd="$1" n
658
+ # [SECURITY] explicit pane gate, not just reliance
659
+ # on build_wrapper's inline `hostname == EXPECTED_HOSTNAME` guard -- cheap,
660
+ # and consistent with every other send site in this file.
661
+ if ! pane_is_ssh; then
662
+ log "run_command: pane is not ssh (connection not confirmed) -- refusing to send"
663
+ return 125
664
+ fi
665
+ n=$(nonce)
666
+ # The guard is armed before the first keystroke and kept until the watch is published, so there is never a moment
667
+ # where the command may be on the remote input with neither a guard nor a watch recording it.
668
+ local grc=0; GUARD_KEEP=1 guarded_line run "$(build_wrapper "$n" "$cmd")" || grc=$?
669
+ [ "$grc" = 0 ] || return "$grc"
670
+ if ! write_watch "$n"; then
671
+ guard_mark run watch-publish 0
672
+ log "🔴 SUBMITTED, BUT THE WATCH COULD NOT BE WRITTEN ($WATCH_FILE): the command is running on $HOST unobserved; the host is guarded. Read it with --read; clear with --kill. Do NOT blindly retry."
673
+ return 3
674
+ fi
675
+ guard_clear
676
+ observe_watch "$n" true
677
+ }
678
+
679
+ cmd_resume() {
680
+ read_watch || die "saved command watch is invalid; use --kill before starting another command" 125
681
+ [ -n "$WATCH_NONCE" ] || die "no timed-out command watch for $HOST (nothing was sent)" 125
682
+ if [ "$TIMEOUT_GIVEN" != true ] && [ -n "$WATCH_TIMEOUT" ] && [ "$WATCH_TIMEOUT" -ge 1 ] && [ "$WATCH_TIMEOUT" -le 2700 ]; then
683
+ CMD_TIMEOUT="$WATCH_TIMEOUT"; log "resume: waiting up to ${CMD_TIMEOUT}s (the original command's --timeout; override with --timeout N)"
684
+ fi
685
+ # Never ensure/reconnect here: a remote program may own its TTY. This is a
686
+ # capture-pane-only observer and reports a dead pane as unknown.
687
+ observe_watch "$WATCH_NONCE" false
688
+ }
689
+
690
+ # ---- interactive primitives (Tier A) ---------------------------------------
691
+ # send raw keystrokes into the live session. Gate-1 only (a program may own
692
+ # stdin, so the identity probe can't run); refuses on a dead session because a
693
+ # reconnect would silently discard interactive state.
694
+ #
695
+ # [SECURITY] Handshake-gated on EVERY send, not just once at function entry: is_connected() (live pane, foreground
696
+ # ssh) is re-run immediately before the send-keys call below and again immediately after. ANY mismatch REFUSES.
697
+ # The session is NOT killed: a dead pane executes nothing (ssh is the pane's process), and keeping it lets --read
698
+ # show the last screen (the denial, or the output before the drop). --kill or the next connect clears it.
699
+ refuse_send() { # pre-send only: nothing was transmitted
700
+ log "🔴 REFUSING send (pre-send): the ssh connection to $HOST is not live (pane dead or not ssh). Nothing was sent. Read its last screen with: $SCRIPT_NAME $HOST --read ; clear it with --kill"
701
+ }
702
+ cmd_send() {
703
+ # Pre-send gate: checked immediately before transmitting, not carried over
704
+ # from an earlier check — this is what "--send does not auto-reconnect"
705
+ # protects: a dead/dropped session is refused, never reconnected under a running interactive program.
706
+ if ! is_connected; then
707
+ refuse_send
708
+ return 125
709
+ fi
710
+ guard_arm send || { log "🔴 REFUSED (pre-send): could not arm the input guard in $REF_DIR. Nothing was sent."; return 125; }
711
+ local step="" nrc=0 nerr=""
712
+ if [ -n "$SEND_KEY" ]; then
713
+ nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" "$SEND_KEY" 2>&1 >/dev/null); nrc=$?; step=key # named key (NOT literal)
714
+ else
715
+ nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" -l "$COMMAND" 2>&1 >/dev/null); nrc=$?; step=literal
716
+ if [ "$nrc" = 0 ] && [ "$SEND_ENTER" = "true" ]; then nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" Enter 2>&1 >/dev/null); nrc=$?; step=enter; fi
717
+ fi
718
+ if [ "$nrc" != 0 ]; then
719
+ guard_mark send "$step" "$nrc"
720
+ log "🔴 INPUT UNKNOWN (send): tmux $step failed (native rc $nrc: ${nerr:-no message}). What reached $HOST's input is unknown; the host is guarded until --kill. Do NOT blindly retry."
721
+ return 3
722
+ fi
723
+ if [ -n "$SEND_KEY" ]; then log "sent key: $SEND_KEY"; else log "sent: ${COMMAND}$([ "$SEND_ENTER" = "true" ] && echo ' <Enter>')"; fi
724
+ # The keys WERE transmitted; if the connection is gone right after, they may or may not have reached the remote
725
+ # (nothing local can read them: ssh is the pane's process). Distinct rc 3 and wording, because unlike the
726
+ # pre-send 125 a blind retry could repeat the action.
727
+ if ! is_connected; then
728
+ guard_mark send post-send-drop 0
729
+ log "🔴 SENT, THEN THE CONNECTION DROPPED (post-send): the keys were sent to $HOST, then the ssh connection was lost. Remote effect UNKNOWN: check with $SCRIPT_NAME $HOST --read (last screen) and do NOT blindly retry; the host is guarded until --kill."
730
+ return 3
731
+ fi
732
+ guard_clear
733
+ }
734
+
735
+ # read the current screen (read-only; safe even on a dead session, warns if so).
736
+ cmd_read() {
737
+ if [ "$SETTLE" = "true" ]; then
738
+ local prev="" cur stable=0 k
739
+ for k in $(seq 1 150); do
740
+ cur=$(capture | cksum)
741
+ if [ "$cur" = "$prev" ]; then stable=$((stable + 1)); else stable=0; fi
742
+ [ "$stable" -ge 3 ] && break
743
+ prev="$cur"; sleep 0.2
744
+ done
745
+ fi
746
+ read_ref
747
+ is_connected || log "note: the ssh connection is not live (dead pane or no session) — this is the last screen"
748
+ # hide tmux-ssh's own wrapper lines (sentinels and the echoed wrapper); keep everything the remote printed
749
+ capture | { grep -v -e 'TSSHBEGIN_' -e '^TSSHDONE_' -e '^TSSHHOST_' || true; } | awk '{ L[NR]=$0 } END { last=NR; while (last>0 && L[last] ~ /^[[:space:]]*$/) last--; for (i=1;i<=last;i++) print L[i] }' | tail -n "$LINES"
750
+ }
751
+
752
+ # ---- subcommands -----------------------------------------------------------
753
+ cmd_status() {
754
+ read_ref
755
+ local buf=""
756
+ read_watch || printf 'active watch: INVALID (use --kill before starting another command)\n'
757
+ has_session && buf=$(capture)
758
+ printf 'host: %s\n' "$HOST"
759
+ printf 'socket/session: %s / %s\n' "$SOCKET" "$SESSION"
760
+ printf 'ref file: %s%s\n' "$REF_FILE" "$([ -f "$REF_FILE" ] && echo '' || echo ' (absent)')"
761
+ printf 'recorded hostname: %s\n' "${EXPECTED_HOSTNAME:-<none>}"
762
+ printf 'tmux session: %s\n' "$(has_session && echo present || echo ABSENT)"
763
+ printf 'pane foreground: %s\n' "$(pane_cmd || echo '<none>')"
764
+ printf 'input guard: %s\n' "$(guard_present && guard_summary || echo none)"
765
+ # Status is an observer: it must be safe to run while the remote TTY is
766
+ # owned by a command, so it deliberately never calls a probe or send-keys.
767
+ # pane==ssh and the recorded hostname are local observations, not a fresh
768
+ # identity check. Command execution still uses ensure_connected's probes.
769
+ # A DONE may have appeared immediately before the remote command closed ssh;
770
+ # the saved exact nonce remains authoritative even though pane==ssh is false.
771
+ if [ -n "$WATCH_NONCE" ] && tssh_has_done "$WATCH_NONCE" "$buf"; then
772
+ printf 'identity probe: NOT RUN (observer mode)\n'
773
+ printf 'active watch: complete (run --resume to collect rc/output)\n'
774
+ printf 'remote cwd: not queried (observer mode)\n'
775
+ printf 'verdict: COMPLETED — exact saved marker observed\n'
776
+ return 0
777
+ fi
778
+ if is_connected; then
779
+ if [ -n "$EXPECTED_HOSTNAME" ]; then
780
+ printf 'identity probe: NOT RUN (observer mode; recorded=%s)\n' "$EXPECTED_HOSTNAME"
781
+ else
782
+ printf 'identity probe: NOT RUN (observer mode; no recorded hostname)\n'
783
+ fi
784
+ if [ -n "$WATCH_NONCE" ] && tssh_has_done "$WATCH_NONCE" "$buf"; then
785
+ printf 'active watch: complete (run --resume to collect rc/output)\n'
786
+ elif [ -n "$WATCH_NONCE" ]; then
787
+ printf 'active watch: running or no exact completion marker observed\n'
788
+ else
789
+ printf 'active watch: none\n'
790
+ fi
791
+ printf 'remote cwd: not queried (observer mode)\n'
792
+ printf 'verdict: OBSERVED — ssh pane present; identity not reverified\n'
793
+ return 0
794
+ fi
795
+ printf 'not connected\n'
796
+ printf 'verdict: NOT connected (a command would need to (re)connect first)\n'
797
+ return 0
798
+ }
799
+
800
+ # A failed inventory proves absence ONLY when no server can exist: no socket file (ENOENT) or nothing listening
801
+ # (ECONNREFUSED; older tmux says "no server running"). Permission/other connect errors are UNKNOWN: the server and
802
+ # the session may be alive behind them.
803
+ no_server_proof() {
804
+ case "$1" in
805
+ *"no server running"*|*"error connecting to "*"(No such file or directory)"*|*"error connecting to "*"(Connection refused)"*) return 0 ;;
806
+ esac
807
+ return 1
808
+ }
809
+
810
+ cmd_kill() { # caller holds the host lock. Custody (ref, watch, guard) is cleared ONLY after the exact session was
811
+ # killed, or proven absent by a complete inventory; any uncertainty keeps all of it.
812
+ local q qerr inv irc
813
+ qerr=$(tmux -L "$SOCKET" has-session -t "$STGT" 2>&1 >/dev/null); q=$?
814
+ if [ "$q" = 0 ]; then
815
+ if ! qerr=$(tmux -L "$SOCKET" kill-session -t "$STGT" 2>&1 >/dev/null); then
816
+ log "kill-session FAILED (${qerr:-no message}): session $SESSION may still be alive; ref, watch and input guard KEPT"; return 125
817
+ fi
818
+ log "killed session $SESSION (remote processes started in that session keep running)"
819
+ else
820
+ inv=$(tmux -L "$SOCKET" list-sessions -F '#{session_name}' 2>&1); irc=$?
821
+ if [ "$irc" = 0 ] && ! printf '%s\n' "$inv" | grep -Fx -- "$SESSION" >/dev/null; then
822
+ log "no session to kill ($SESSION absent from the inventory)"
823
+ elif [ "$irc" != 0 ] && no_server_proof "$inv"; then
824
+ log "no session to kill (no tmux-ssh server: ${inv})"
825
+ else
826
+ log "cannot verify $SESSION is gone (has-session rc $q: ${qerr:-no message}; inventory rc $irc): ref, watch and input guard KEPT"; return 125
827
+ fi
828
+ fi
829
+ [ -f "$REF_FILE" ] && rm -f "$REF_FILE" && log "removed $REF_FILE"
830
+ clear_watch
831
+ guard_present && guard_clear && log "cleared the input guard"
832
+ return 0
833
+ }
834
+
835
+ # --abort: Ctrl-C the watched command, wait briefly for its DONE marker, then clear the watch. Typing goes through the
836
+ # same input guard and gate-1 (live ssh pane) checks as --send; with an unresolved guard nothing is typed.
837
+ cmd_abort() { # caller holds the host lock
838
+ if guard_present; then
839
+ die "cannot abort: UNRESOLVED input on $HOST ($(guard_summary | sed 's/ $//')); nothing is typed while that is unresolved. Inspect with $SCRIPT_NAME $HOST --read; then clear it with $SCRIPT_NAME $HOST --kill" 125
840
+ fi
841
+ read_watch || die "saved command watch is invalid; use $SCRIPT_NAME $HOST --kill" 125
842
+ if [ -z "$WATCH_NONCE" ]; then
843
+ log "no command is being watched on $HOST; nothing to abort (a program left open on the screen: --read, then --send --key C-c)"
844
+ return 0
845
+ fi
846
+ local n="$WATCH_NONCE" buf rc
847
+ buf=$(capture)
848
+ if tssh_has_done "$n" "$buf"; then
849
+ rc=$(printf '%s\n' "$buf" | grep -E "^TSSHDONE_${n}_ rc=[0-9]+" | tail -1 | sed 's/.*rc=//')
850
+ clear_watch
851
+ log "the command had already finished (rc=${rc:-?}); watch cleared, nothing sent"
852
+ return 0
853
+ fi
854
+ if ! is_connected; then
855
+ clear_watch
856
+ log "the ssh connection to $HOST is not live, so the command is gone with it; nothing was sent; watch cleared"
857
+ return 0
858
+ fi
859
+ guard_arm abort || { log "🔴 REFUSED (abort): could not arm the input guard in $REF_DIR. Nothing was sent; the watch is kept. Use $SCRIPT_NAME $HOST --kill"; return 125; }
860
+ local nerr nrc
861
+ nerr=$(tmux -L "$SOCKET" send-keys -t "$PTGT" C-c 2>&1 >/dev/null); nrc=$?
862
+ if [ "$nrc" != 0 ]; then
863
+ guard_mark abort key "$nrc"
864
+ log "🔴 INPUT UNKNOWN (abort): tmux key failed (native rc $nrc: ${nerr:-no message}). The host is guarded until $SCRIPT_NAME $HOST --kill. Do NOT blindly retry."
865
+ return 3
866
+ fi
867
+ guard_clear
868
+ log "sent Ctrl-C to $HOST; waiting up to ${ABORT_WAIT}s for the command to end"
869
+ local deadline=$(( $(date +%s) + ABORT_WAIT ))
870
+ while :; do
871
+ buf=$(capture)
872
+ if tssh_has_done "$n" "$buf"; then
873
+ rc=$(printf '%s\n' "$buf" | grep -E "^TSSHDONE_${n}_ rc=[0-9]+" | tail -1 | sed 's/.*rc=//')
874
+ clear_watch
875
+ log "aborted: the command ended (rc=${rc:-?}); watch cleared, the next command can run"
876
+ return 0
877
+ fi
878
+ [ "$(date +%s)" -lt "$deadline" ] || break
879
+ sleep 0.2
880
+ done
881
+ clear_watch
882
+ # no marker: shells such as zsh drop the whole line on Ctrl-C. If the shell now answers a probe, the abort worked.
883
+ read_ref
884
+ local h prc=0
885
+ h=$(probe_hostname 8) || prc=$?
886
+ [ "$prc" = 3 ] && return 3
887
+ if [ "$prc" = 0 ] && [ -n "$h" ] && { [ -z "$EXPECTED_HOSTNAME" ] || [ "$h" = "$EXPECTED_HOSTNAME" ]; }; then
888
+ log "aborted: the command was interrupted and the shell on $HOST is ready; the next command can run"
889
+ return 0
890
+ fi
891
+ log "Ctrl-C was sent but the shell did not answer within ${ABORT_WAIT}s+8s (the program may ignore Ctrl-C). Watch cleared. Look with $SCRIPT_NAME $HOST --read; if the screen is still occupied use --send --key (e.g. q, Escape, C-d), or $SCRIPT_NAME $HOST --kill"
892
+ return 1
893
+ }
894
+
895
+ cmd_attach() {
896
+ has_session || die "no session for $HOST to attach to (run: $SCRIPT_NAME $HOST)" 125
897
+ exec tmux -L "$SOCKET" attach -t "$STGT"
898
+ }
899
+
900
+ # ---- expect rule parsing ---------------------------------------------------
901
+ # spec is 'PATTERN=RESPONSE' for --expect, or 'PATTERN=@file' / 'PATTERN=env:VAR'
902
+ # for --expect-secret. Splits on the FIRST '='.
903
+ add_expect() {
904
+ local spec="$1" secret="$2" pat resp src
905
+ case "$spec" in *=*) : ;; *) die "--expect needs 'PATTERN=RESPONSE' (got '$spec')" 2 ;; esac
906
+ pat="${spec%%=*}"; src="${spec#*=}"
907
+ [ -n "$pat" ] || die "--expect pattern must be non-empty" 2
908
+ # Single-line contract: a newline or carriage return in PATTERN is not a supported
909
+ # shape (matching is a literal substring test on the screen text). Refuse here, at
910
+ # argument parsing, so no tmux/ssh call has happened yet. Only PATTERN is checked.
911
+ case "$pat" in
912
+ *$'\n'* | *$'\r'*) die "--expect/--expect-secret PATTERN must be a single line (no newline or carriage return)" 2 ;;
913
+ esac
914
+ if [ "$secret" = "true" ]; then
915
+ case "$src" in
916
+ @*) resp=$(cat "${src#@}" 2>/dev/null) || die "--expect-secret: cannot read file '${src#@}'" 2 ;;
917
+ env:*) local v="${src#env:}"; resp="${!v:-}"; [ -n "$resp" ] || die "--expect-secret: env var '$v' is empty/unset" 2 ;;
918
+ *) die "--expect-secret source must be '@file' or 'env:VAR' (got '$src')" 2 ;;
919
+ esac
920
+ EXPECT_SECRET+=("1")
921
+ else
922
+ resp="$src"
923
+ EXPECT_SECRET+=("0")
924
+ fi
925
+ EXPECT_PATS+=("$pat")
926
+ EXPECT_RESPS+=("$resp")
927
+ EXPECT_FIRED+=("0")
928
+ }
929
+
930
+ # ---- argument parsing ------------------------------------------------------
931
+ HOST=""
932
+ COMMAND=""
933
+ MODE="ensure" # ensure | run | resume | send | read | status | kill | attach
934
+ have_command=false
935
+ RESUME_REQUESTED=false
936
+
937
+ while [ $# -gt 0 ]; do
938
+ case "$1" in
939
+ -h|--help) print_help; exit 0 ;;
940
+ --status) MODE="status"; shift ;;
941
+ --resume) MODE="resume"; RESUME_REQUESTED=true; shift ;;
942
+ --kill) MODE="kill"; shift ;;
943
+ --abort) MODE="abort"; shift ;;
944
+ --force-run) FORCE_RUN=true; shift ;;
945
+ --attach) MODE="attach"; shift ;;
946
+ --send) MODE="send"; shift ;;
947
+ --read) MODE="read"; shift ;;
948
+ --no-reconnect) ALLOW_RECONNECT=false; shift ;;
949
+ --interrupt-on-timeout) INTERRUPT_ON_TIMEOUT=true; shift ;;
950
+ --no-enter) SEND_ENTER=false; shift ;;
951
+ --settle) SETTLE=true; shift ;;
952
+ --key) SEND_KEY="${2:-}"; shift 2 ;;
953
+ --timeout) CMD_TIMEOUT="${2:-}"; TIMEOUT_GIVEN=true; shift 2 ;;
954
+ --lines) LINES="${2:-}"; shift 2 ;;
955
+ --connect-timeout) CONNECT_TIMEOUT="${2:-}"; shift 2 ;;
956
+ --expect) add_expect "${2:-}" false; shift 2 ;;
957
+ --expect-secret) add_expect "${2:-}" true; shift 2 ;;
958
+ --expect-host) EXPECT_HOST="${2:-}"; shift 2 ;;
959
+ --) shift; break ;;
960
+ -*) die "unknown flag: $1" 2 ;;
961
+ *)
962
+ if [ -z "$HOST" ]; then HOST="$1"
963
+ elif [ "$have_command" = false ]; then COMMAND="$1"; have_command=true
964
+ else die "unexpected extra argument: $1 (quote the command as a single argument)" 2
965
+ fi
966
+ shift ;;
967
+ esac
968
+ done
969
+ # Anything after `--` is the command (allows unquoted commands with flags).
970
+ if [ $# -gt 0 ]; then
971
+ if [ "$have_command" = false ]; then COMMAND="$*"; have_command=true
972
+ else die "command already given; unexpected: $*" 2; fi
973
+ fi
974
+
975
+ [ -n "$HOST" ] || { usage; exit 2; }
976
+ # [SECURITY] $HOST becomes an ssh argument (and a session/ref name). A legitimate ssh target is an alias or
977
+ # [user@]host: letters, digits, dot, underscore, hyphen, and one '@', not starting with '-' (it would be read as an
978
+ # ssh option). (IPv6 literals with ':' are intentionally unsupported — use an ssh-config alias.)
979
+ case "$HOST" in
980
+ -*) die "invalid host '$HOST' — a host may not start with '-'" 2 ;;
981
+ *[!A-Za-z0-9._@-]*) die "invalid host '$HOST' — allowed characters: letters, digits, . _ - @ (use an ssh-config alias for anything else)" 2 ;;
982
+ esac
983
+ # TMUX_SSH_OPTS becomes ssh arguments (split on spaces): refuse anything outside a safe set
984
+ # (C locale so the ranges mean ASCII). --status and --kill never type it.
985
+ if [ "$MODE" != "kill" ] && [ "$MODE" != "status" ]; then
986
+ _lc_all_saved="${LC_ALL-}"; LC_ALL=C
987
+ case "${TMUX_SSH_OPTS:-}" in
988
+ *[!A-Za-z0-9\ _./=:,@~+%-]*)
989
+ die "TMUX_SSH_OPTS contains a disallowed character (allowed: letters digits space - _ . / = : , @ ~ + %)" 2 ;;
990
+ esac
991
+ if [ -n "$_lc_all_saved" ]; then LC_ALL="$_lc_all_saved"; else unset LC_ALL; fi
992
+ # every token must be an option: a bare word would become ssh's destination (and the real host the remote
993
+ # command). An ssh option that takes a value (-o X, -i F, -p N ...) consumes the next token as that value.
994
+ _want_val=0
995
+ for _t in ${TMUX_SSH_OPTS:-}; do
996
+ if [ "$_want_val" = 1 ]; then _want_val=0; continue; fi
997
+ case "$_t" in
998
+ -[BbcDEeFIiJLlmOoPpQRSWw]) _want_val=1 ;;
999
+ -?*) ;;
1000
+ *) die "TMUX_SSH_OPTS token '$_t' is not an ssh option (each token must start with '-', or be the value of the option before it)" 2 ;;
1001
+ esac
1002
+ done
1003
+ [ "$_want_val" = 0 ] || die "TMUX_SSH_OPTS ends with an option that needs a value" 2
1004
+ fi
1005
+ if [ -n "$EXPECT_HOST" ]; then
1006
+ case "$EXPECT_HOST" in
1007
+ *[!A-Za-z0-9._-]*) die "--expect-host/TMUX_SSH_EXPECT_HOST must contain only letters, digits, . _ - (got '$EXPECT_HOST')" 2 ;;
1008
+ esac
1009
+ fi
1010
+ for v in CMD_TIMEOUT CONNECT_TIMEOUT LINES; do
1011
+ val="${!v}"
1012
+ case "$val" in ''|*[!0-9]*) die "$v must be a non-negative integer (got '$val')" 2 ;; esac
1013
+ done
1014
+ if [ "$CMD_TIMEOUT" -lt 1 ] || [ "$CMD_TIMEOUT" -gt 2700 ]; then
1015
+ die "--timeout must be between 1 and 2700 seconds (got $CMD_TIMEOUT)" 2
1016
+ fi
1017
+ if [ "$RESUME_REQUESTED" = "true" ] && [ "$MODE" != "resume" ]; then
1018
+ die "--resume cannot be combined with another mode" 2
1019
+ fi
1020
+ if [ "$MODE" = "resume" ]; then
1021
+ [ "$INTERRUPT_ON_TIMEOUT" != "true" ] || die "--resume is capture-only; --interrupt-on-timeout is not allowed" 2
1022
+ [ "${#EXPECT_PATS[@]}" -eq 0 ] || die "--resume is capture-only; --expect/--expect-secret are not allowed" 2
1023
+ [ -z "$SEND_KEY" ] && [ "$SEND_ENTER" = "true" ] || die "--resume is capture-only; send flags are not allowed" 2
1024
+ [ "$have_command" = false ] || die "--resume does not accept a command; use it only to observe the saved watch" 2
1025
+ fi
1026
+ if [ "${#EXPECT_PATS[@]}" -gt 0 ] && { [ "$MODE" = "send" ] || [ "$MODE" = "read" ] || [ "$MODE" = "status" ] || [ "$MODE" = "kill" ] || [ "$MODE" = "attach" ] || [ "$MODE" = "abort" ]; }; then
1027
+ die "--expect/--expect-secret only apply when running a command" 2
1028
+ fi
1029
+ if [ "$MODE" = "abort" ] && [ "$have_command" = true ]; then
1030
+ die "--abort does not accept a command" 2
1031
+ fi
1032
+ if [ "$MODE" = "send" ] && [ "$have_command" = false ] && [ -z "$SEND_KEY" ]; then
1033
+ die "--send needs text (or --key <name>)" 2
1034
+ fi
1035
+
1036
+ # ---- refuse interactive commands in run mode (before anything is sent) ----------
1037
+ # Looks ONLY at the leading command (after optional sudo / env VAR=x / exec / VAR=x), never at pipelines or later
1038
+ # commands. Prints the interactive form and returns 0 when the command would never finish; 1 otherwise.
1039
+ interactive_hint() {
1040
+ local -a w; local i=0 t base sub
1041
+ read -r -a w <<< "$1"
1042
+ [ "${#w[@]}" -gt 0 ] || return 1
1043
+ while [ "$i" -lt "${#w[@]}" ]; do
1044
+ t="${w[$i]}"
1045
+ case "$t" in
1046
+ sudo) i=$((i+1))
1047
+ while [ "$i" -lt "${#w[@]}" ]; do
1048
+ case "${w[$i]}" in
1049
+ -[ughprCrtTU]) i=$((i+2)) ;;
1050
+ -*) i=$((i+1)) ;;
1051
+ *=*) i=$((i+1)) ;;
1052
+ *) break ;;
1053
+ esac
1054
+ done ;;
1055
+ env) i=$((i+1))
1056
+ while [ "$i" -lt "${#w[@]}" ]; do
1057
+ case "${w[$i]}" in -*|*=*) i=$((i+1)) ;; *) break ;; esac
1058
+ done ;;
1059
+ exec) i=$((i+1)) ;;
1060
+ *=*) i=$((i+1)) ;;
1061
+ *) break ;;
1062
+ esac
1063
+ done
1064
+ [ "$i" -lt "${#w[@]}" ] || return 1
1065
+ base="${w[$i]##*/}"
1066
+ case "$base" in
1067
+ vim|vi|nvim|nano|emacs|top|htop|less|more|man|watch)
1068
+ INTERACTIVE_MSG="'$base' stays open and never finishes in run mode. Drive it instead: $SCRIPT_NAME $HOST --send \"$base ...\" ; $SCRIPT_NAME $HOST --read --settle ; $SCRIPT_NAME $HOST --send --key Escape (or q / C-c). To run it anyway add --force-run."
1069
+ return 0 ;;
1070
+ tmux)
1071
+ i=$((i+1)); sub=""
1072
+ while [ "$i" -lt "${#w[@]}" ]; do
1073
+ case "${w[$i]}" in
1074
+ -[LSfcT]) i=$((i+2)) ;;
1075
+ -*) i=$((i+1)) ;;
1076
+ *) sub="${w[$i]}"; break ;;
1077
+ esac
1078
+ done
1079
+ case "$sub" in
1080
+ '') INTERACTIVE_MSG="bare 'tmux' opens an interactive session and never finishes in run mode. Use: $SCRIPT_NAME $HOST \"tmux new -d -s NAME 'cmd'\" (detached), or drive it with --send / --read. To run it anyway add --force-run."
1081
+ return 0 ;;
1082
+ attach|attach-session|a|at)
1083
+ INTERACTIVE_MSG="'tmux $sub' attaches and never finishes in run mode. Drive the attached program with $SCRIPT_NAME $HOST --send / --read, or start work detached: $SCRIPT_NAME $HOST \"tmux new -d -s NAME 'cmd'\". To run it anyway add --force-run."
1084
+ return 0 ;;
1085
+ new|new-session)
1086
+ local k detached=false
1087
+ for k in "${w[@]:$((i+1))}"; do
1088
+ case "$k" in -[A-Za-z]*) case "${k#-}" in *[!A-Za-z]*) ;; *d*) detached=true ;; esac ;; esac
1089
+ done
1090
+ if [ "$detached" = false ]; then
1091
+ INTERACTIVE_MSG="'tmux $sub' without -d attaches and never finishes in run mode. Use: $SCRIPT_NAME $HOST \"tmux new -d -s NAME 'cmd'\" (detached), or drive it with --send / --read. To run it anyway add --force-run."
1092
+ return 0
1093
+ fi ;;
1094
+ esac ;;
1095
+ esac
1096
+ return 1
1097
+ }
1098
+ INTERACTIVE_MSG=""
1099
+ if [ "$MODE" = "ensure" ] && [ "$have_command" = true ] && [ "$FORCE_RUN" != true ]; then
1100
+ if interactive_hint "$COMMAND"; then die "$INTERACTIVE_MSG" 2; fi
1101
+ fi
1102
+
1103
+ # One tmux send-keys carries about 16,300 bytes (measured on tmux 3.6a: 16,300 passes, 16,340 fails with "failed to send
1104
+ # command", 16,360+ with "command too long"; the session target takes part of that). A bigger command used to fail AFTER
1105
+ # arming the input guard (rc 3, INPUT UNKNOWN). Refuse it up front, before any tmux call. Run mode adds the DONE wrapper.
1106
+ # TMUX_SSH_MAX_BYTES overrides the limit (test seam).
1107
+ SEND_MAX_BYTES=${TMUX_SSH_MAX_BYTES:-16000}
1108
+ oversize_check() { # <what> <overhead bytes>
1109
+ local n; n=$(printf '%s' "$COMMAND" | LC_ALL=C wc -c | tr -d ' ')
1110
+ if [ "$1" = command ] && cmd_needs_encoding "$COMMAND"; then n=$(( (n + 2) / 3 * 4 + 100 )); fi # sent base64-encoded: 4/3 the size plus the decoder
1111
+ if [ "$((n + $2))" -gt "$SEND_MAX_BYTES" ]; then
1112
+ die "the $1 is $n bytes; one tmux send carries at most about 16,000 bytes (limit here $SEND_MAX_BYTES, wrapper included). Nothing was sent. Do not split it into several sends: put the text in a file, copy it over (scp FILE $HOST:/tmp/FILE), then run a short command that uses it (e.g. $SCRIPT_NAME $HOST 'git apply /tmp/FILE')." 2
1113
+ fi
1114
+ }
1115
+ if [ "$have_command" = true ]; then
1116
+ case "$MODE" in
1117
+ ensure) oversize_check command 400 ;;
1118
+ send) [ -n "${SEND_KEY:-}" ] || oversize_check "text to send" 0 ;;
1119
+ esac
1120
+ fi
1121
+
1122
+ SANITIZED=$(sanitize "$HOST")
1123
+ SESSION="ts_${SANITIZED}"
1124
+ # Exact targets: a bare -t NAME also matches a unique PREFIX (tmux target-session rules), so ts_host could select
1125
+ # ts_host_extra. "=NAME" restricts to the exact session; "=NAME:" is the same for window/pane targets.
1126
+ STGT="=${SESSION}"
1127
+ PTGT="=${SESSION}:"
1128
+ REF_FILE="$REF_DIR/${SANITIZED}.session"
1129
+ WATCH_FILE="$REF_DIR/${SANITIZED}.watch"
1130
+ GUARD_FILE="$REF_DIR/${SANITIZED}.input-guard"
1131
+ LOCK_DIR="$REF_DIR/${SANITIZED}.lock"
1132
+ EXPECTED_HOSTNAME=""
1133
+
1134
+ case "$MODE" in
1135
+ attach) cmd_attach ;; # exec's away; no lock needed
1136
+ kill) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; cmd_kill; exit $? ;; # local teardown, under the host lock
1137
+ status) mkdir -p "$REF_DIR"; cmd_status; exit $? ;; # observer: no lock, so it works while a long command holds the host (state files are written atomically)
1138
+ abort) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; cmd_abort; exit $? ;;
1139
+ resume) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; cmd_resume; exit $? ;;
1140
+ send) mkdir -p "$REF_DIR"; acquire_lock "$LOCK_DIR"; refuse_if_guarded; cmd_send; exit $? ;;
1141
+ read) mkdir -p "$REF_DIR"; cmd_read; exit $? ;; # read-only capture: no lock, so a long watcher can be observed
1142
+ esac
1143
+
1144
+ # ensure (host only) or run (host + command): both need a live, confirmed session.
1145
+ mkdir -p "$REF_DIR"
1146
+ acquire_lock "$LOCK_DIR"
1147
+ refuse_if_guarded
1148
+ read_watch || die "saved command watch is invalid; use --kill before starting another command" 125
1149
+ if [ -n "$WATCH_NONCE" ]; then
1150
+ die "a previous command is still being watched on $HOST -- NOT resent. Ways out: $SCRIPT_NAME $HOST --resume [--timeout N] (keep waiting) | $SCRIPT_NAME $HOST --abort (Ctrl-C it and clear the watch) | $SCRIPT_NAME $HOST --kill (drop the ssh session; remote processes keep running)" 125
1151
+ fi
1152
+ ensure_connected
1153
+ if [ "$have_command" = true ]; then
1154
+ run_command "$COMMAND"
1155
+ exit $?
1156
+ fi
1157
+ log "ready: $HOST ($EXPECTED_HOSTNAME)"
1158
+ exit 0