mxup 0.2.0

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.
data/lib/mxup/cli.rb ADDED
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Mxup
6
+ # Argv parser + dispatch. Keeps parsing rules in one place; the actual
7
+ # behaviour lives in Runner and the focused modules it drives.
8
+ class CLI
9
+ COMMANDS = %w[up status down restart layout target exec].freeze
10
+
11
+ def run(argv)
12
+ args, options = parse(argv.dup)
13
+ command = extract_command(args)
14
+ dispatch(command, args, options)
15
+ end
16
+
17
+ private
18
+
19
+ def parse(args)
20
+ options = {
21
+ dry_run: false, config: nil, lines: nil, layout: nil,
22
+ target: nil, timeout: nil, force: false, quiet: false,
23
+ profile: nil
24
+ }
25
+
26
+ parser = OptionParser.new do |opts|
27
+ opts.banner = 'Usage: mxup [command] [name] [options]'
28
+ opts.on('-f', '--file FILE', 'Config file path') { |f| options[:config] = f }
29
+ opts.on('--dry-run', 'Preview changes without applying') { options[:dry_run] = true }
30
+ opts.on('--lines N', Integer, 'Output lines (status/exec)') { |n| options[:lines] = n }
31
+ opts.on('--layout NAME', 'Layout to use (for up/layout)') { |l| options[:layout] = l }
32
+ opts.on('-p', '--profile NAME',
33
+ 'Profile to use (for up/status/restart/layout)') { |p| options[:profile] = p }
34
+ opts.on('-t', '--target TARGET',
35
+ 'Target window/pane for exec (e.g. name:window)') { |t| options[:target] = t }
36
+ opts.on('--timeout N', Integer, 'Timeout in seconds (exec)') { |n| options[:timeout] = n }
37
+ opts.on('--force', 'Force exec on a busy pane') { options[:force] = true }
38
+ opts.on('-q', '--quiet', 'Suppress captured output (exec)') { options[:quiet] = true }
39
+ opts.on('-v', '--version', 'Show version') { puts "mxup #{VERSION}"; exit }
40
+ opts.on('-h', '--help', 'Show help') { puts opts; exit }
41
+ end
42
+
43
+ parser.parse!(args)
44
+ [args, options]
45
+ end
46
+
47
+ # Peek at the first non-file positional; if it's a recognised command,
48
+ # pop it. Otherwise default to "up".
49
+ def extract_command(args)
50
+ first = args.first
51
+ return 'up' if first.nil?
52
+ return 'up' if first.include?('.yml') || first.include?('/')
53
+
54
+ COMMANDS.include?(first) ? args.shift : 'up'
55
+ end
56
+
57
+ def dispatch(command, args, options)
58
+ case command
59
+ when 'up', 'status', 'down' then run_basic(command, args, options)
60
+ when 'restart', 'target' then run_restart_or_target(command, args, options)
61
+ when 'layout' then run_layout(args, options)
62
+ when 'exec' then run_exec(args, options)
63
+ else
64
+ abort "Unknown command: #{command}. Use: #{COMMANDS.join(', ')}"
65
+ end
66
+ end
67
+
68
+ def run_basic(command, args, options)
69
+ name = args.shift
70
+ runner = build_runner(options, name)
71
+ case command
72
+ when 'up' then runner.up
73
+ when 'status' then runner.status(lines: options[:lines] || 2)
74
+ when 'down' then runner.down
75
+ end
76
+ end
77
+
78
+ def run_restart_or_target(command, args, options)
79
+ name, window_names = split_restart_spec(args, options)
80
+ runner = build_runner(options, name)
81
+
82
+ if command == 'restart'
83
+ runner.restart(window_names)
84
+ else
85
+ runner.target(window_names)
86
+ end
87
+ end
88
+
89
+ def run_layout(args, options)
90
+ name = args.shift
91
+ target_layout = args.shift
92
+ runner = build_runner(options, name)
93
+ target_layout ? runner.switch_layout(target_layout) : runner.show_layouts
94
+ end
95
+
96
+ def run_exec(args, options)
97
+ target = options[:target] || args.shift
98
+ abort 'Usage: mxup exec -t [name:]WINDOW "command"' if target.nil? || target.empty?
99
+
100
+ command_str = args.shift
101
+ abort 'Usage: mxup exec -t [name:]WINDOW "command"' if command_str.nil?
102
+
103
+ name = target.include?(':') ? target.split(':', 2).first : nil
104
+ build_runner(options, name).exec(
105
+ target, command_str,
106
+ lines: options[:lines] || 50,
107
+ timeout: options[:timeout],
108
+ force: options[:force],
109
+ quiet: options[:quiet]
110
+ )
111
+ end
112
+
113
+ # Parse the first positional of `restart` / `target`. Supports:
114
+ # "session:win1,win2" → explicit session + window list
115
+ # "session" → session name, no windows (if it resolves to a config)
116
+ # "win1" → bare window name (no session prefix)
117
+ def split_restart_spec(args, options)
118
+ spec = args.shift
119
+ if spec.nil?
120
+ return [nil, []]
121
+ elsif spec.include?(':')
122
+ name, windows_str = spec.split(':', 2)
123
+ return [name, windows_str ? windows_str.split(',') : []]
124
+ end
125
+
126
+ # Ambiguous: is `spec` a config name or a window name?
127
+ looks_like_config = File.exist?(File.join(CONFIG_DIR, "#{spec}.yml")) ||
128
+ (options[:config] && spec !~ /,/)
129
+ return [spec, args] if looks_like_config
130
+ [nil, [spec] + args]
131
+ end
132
+
133
+ def build_runner(options, name)
134
+ config = load_config(options[:config], name, options[:profile])
135
+ Runner.new(config, dry_run: options[:dry_run], layout: options[:layout])
136
+ end
137
+
138
+ def load_config(explicit_path, name, profile = nil)
139
+ path = resolve_config(explicit_path, name)
140
+ abort "Config not found. Provide -f path or place config in #{CONFIG_DIR}/" unless path
141
+ Config.new(path, profile: profile)
142
+ rescue ArgumentError => e
143
+ abort e.message
144
+ end
145
+
146
+ # Config resolution order:
147
+ # 1. explicit -f path
148
+ # 2. ~/.config/mxup/<name>.yml
149
+ # 3. ./mxup.yml
150
+ # 4. sole *.yml in ~/.config/mxup/
151
+ def resolve_config(explicit, name)
152
+ return explicit if explicit && File.exist?(explicit)
153
+
154
+ if name
155
+ candidate = File.join(CONFIG_DIR, "#{name}.yml")
156
+ return candidate if File.exist?(candidate)
157
+ end
158
+
159
+ local = File.join(Dir.pwd, 'mxup.yml')
160
+ return local if File.exist?(local)
161
+
162
+ if Dir.exist?(CONFIG_DIR)
163
+ configs = Dir.glob(File.join(CONFIG_DIR, '*.yml'))
164
+ return configs.first if configs.size == 1
165
+ end
166
+
167
+ nil
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'set'
5
+
6
+ module Mxup
7
+ # Parsed mxup YAML config. Pure data; no tmux or filesystem side effects.
8
+ #
9
+ # Profiles (optional): a config may declare a `profiles:` map where each
10
+ # entry is a partial override on top of the base `setup`, `windows`, and
11
+ # `layouts`. A single active profile is resolved at parse time and its
12
+ # overrides are merged in before the rest of the Config is built — so the
13
+ # rest of the system (Launcher, Reconciler, StatusView…) never has to know
14
+ # about profiles.
15
+ class Config
16
+ attr_reader :session, :setup, :windows, :layouts, :layout_names,
17
+ :profile, :profile_names, :default_profile
18
+
19
+ def initialize(path, profile: nil)
20
+ raw = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
21
+ resolve_profile!(raw, profile)
22
+ @session = raw.fetch('session')
23
+ @setup = raw['setup']&.strip
24
+ @windows = parse_windows(raw.fetch('windows'))
25
+ @layouts, @layout_names = parse_layouts(raw['layouts'])
26
+ end
27
+
28
+ def default_layout
29
+ @layout_names.first
30
+ end
31
+
32
+ def groups_for(layout_name)
33
+ return [] if layout_name.nil?
34
+ @layouts.fetch(layout_name)
35
+ end
36
+
37
+ # Returns an ordered list of entries describing how windows should appear
38
+ # in tmux under the given layout. Each entry is one of:
39
+ # { type: :group, name: <group name>, group: PaneGroup }
40
+ # { type: :standalone, name: <window name> }
41
+ def effective_window_order(layout_name)
42
+ groups = groups_for(layout_name)
43
+ grouped = groups.flat_map(&:window_names).to_set
44
+ entries = groups.map { |g| { type: :group, name: g.name, group: g } }
45
+ @windows.each do |w|
46
+ entries << { type: :standalone, name: w.name } unless grouped.include?(w.name)
47
+ end
48
+ entries
49
+ end
50
+
51
+ def window_by_name(name)
52
+ @windows.find { |w| w.name == name }
53
+ end
54
+
55
+ # Returns [group, index_within_group] or nil.
56
+ def find_group_for_window(layout_name, window_name)
57
+ groups_for(layout_name).each do |g|
58
+ idx = g.window_names.index(window_name)
59
+ return [g, idx] if idx
60
+ end
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ # Pick the active profile (if any) and merge its overrides into `raw`.
67
+ # Sets @profile / @profile_names / @default_profile.
68
+ def resolve_profile!(raw, requested)
69
+ profiles = raw['profiles'] || {}
70
+ @profile_names = profiles.keys
71
+ @default_profile = raw['default_profile'] || @profile_names.first
72
+
73
+ if profiles.empty?
74
+ if requested
75
+ raise ArgumentError,
76
+ "--profile '#{requested}' was given, but this config declares no profiles"
77
+ end
78
+ @profile = nil
79
+ return
80
+ end
81
+
82
+ @profile = requested || @default_profile
83
+ unless profiles.key?(@profile)
84
+ raise ArgumentError,
85
+ "Unknown profile '#{@profile}' (available: #{@profile_names.join(', ')})"
86
+ end
87
+
88
+ apply_profile_overrides!(raw, profiles.fetch(@profile))
89
+ end
90
+
91
+ def apply_profile_overrides!(raw, override)
92
+ return unless override.is_a?(Hash)
93
+
94
+ if override.key?('session')
95
+ raise ArgumentError,
96
+ "Profile '#{@profile}' cannot override 'session'; profiles of the " \
97
+ 'same group must share one tmux session name'
98
+ end
99
+
100
+ raw['setup'] = override['setup'] if override.key?('setup')
101
+ raw['layouts'] = override['layouts'] if override.key?('layouts')
102
+
103
+ removed = []
104
+ (override['windows'] || {}).each do |wname, woverride|
105
+ raw['windows'] ||= {}
106
+ if woverride.nil?
107
+ # Explicit null ("dev-kit: ~") drops this window for this profile.
108
+ # `parse_windows` skips absent keys; `prune_layouts!` keeps layouts
109
+ # from referencing the now-missing name.
110
+ raw['windows'].delete(wname)
111
+ removed << wname
112
+ else
113
+ base = raw['windows'][wname] || {}
114
+ raw['windows'][wname] = merge_window(base, woverride)
115
+ end
116
+ end
117
+
118
+ prune_layouts!(raw, removed) if removed.any?
119
+ end
120
+
121
+ # Strip any removed window names from layout groups so parse_layouts
122
+ # doesn't raise "window not found". A group whose panes list becomes
123
+ # empty is dropped from the layout entirely.
124
+ def prune_layouts!(raw, removed)
125
+ return unless raw['layouts'].is_a?(Hash)
126
+ removed_set = removed.to_set
127
+
128
+ raw['layouts'].each_value do |layout_def|
129
+ next unless layout_def.is_a?(Hash)
130
+ layout_def.reject! do |_group_name, group_def|
131
+ next false unless group_def.is_a?(Hash) && group_def['panes'].is_a?(Array)
132
+ group_def['panes'] -= removed_set.to_a
133
+ group_def['panes'].empty?
134
+ end
135
+ end
136
+ end
137
+
138
+ # Shallow merge with a special case: `env` is itself a hash that should
139
+ # be merged (so a profile can tweak one key without redeclaring the rest).
140
+ def merge_window(base, override)
141
+ base.merge(override) do |key, base_val, prof_val|
142
+ if key == 'env' && base_val.is_a?(Hash) && prof_val.is_a?(Hash)
143
+ base_val.merge(prof_val)
144
+ else
145
+ prof_val
146
+ end
147
+ end
148
+ end
149
+
150
+ def parse_windows(hash)
151
+ hash.map do |name, opts|
152
+ opts ||= {}
153
+ Window.new(
154
+ name: name,
155
+ root: File.expand_path(opts.fetch('root')),
156
+ command: opts['command']&.strip,
157
+ env: opts['env'] || {},
158
+ wait_for: WaitSpec.parse(opts['wait_for'])
159
+ )
160
+ end
161
+ end
162
+
163
+ def parse_layouts(raw)
164
+ return [{}, []] if raw.nil?
165
+
166
+ valid_windows = @windows.map(&:name).to_set
167
+ layouts = {}
168
+ order = []
169
+
170
+ raw.each do |layout_name, groups_hash|
171
+ order << layout_name
172
+ groups_hash ||= {}
173
+ seen = Set.new
174
+ groups = []
175
+
176
+ groups_hash.each do |group_name, group_opts|
177
+ group_opts ||= {}
178
+ pane_names = Array(group_opts['panes'])
179
+
180
+ pane_names.each do |pn|
181
+ unless valid_windows.include?(pn)
182
+ raise ArgumentError,
183
+ "Layout '#{layout_name}': window '#{pn}' not found in windows"
184
+ end
185
+ if seen.include?(pn)
186
+ raise ArgumentError,
187
+ "Layout '#{layout_name}': window '#{pn}' appears in multiple groups"
188
+ end
189
+ seen << pn
190
+ end
191
+
192
+ groups << PaneGroup.new(
193
+ name: group_name,
194
+ window_names: pane_names,
195
+ split: group_opts['split'] || 'tiled'
196
+ )
197
+ end
198
+
199
+ layouts[layout_name] = groups
200
+ end
201
+
202
+ [layouts, order]
203
+ end
204
+ end
205
+
206
+ Window = Struct.new(:name, :root, :command, :env, :wait_for, keyword_init: true)
207
+ PaneGroup = Struct.new(:name, :window_names, :split, keyword_init: true)
208
+
209
+ # A readiness check attached to a window.
210
+ WaitSpec = Struct.new(:type, :target, :timeout, :interval, :label, keyword_init: true) do
211
+ CHECK_TYPES = %w[tcp http path script].freeze
212
+
213
+ def self.parse(raw)
214
+ case raw
215
+ when nil
216
+ nil
217
+ when String
218
+ new(type: :tcp, target: raw, timeout: nil, interval: 2, label: raw)
219
+ when Hash
220
+ found = CHECK_TYPES & raw.keys
221
+ unless found.size == 1
222
+ raise ArgumentError,
223
+ "wait_for must specify exactly one of: #{CHECK_TYPES.join(', ')}"
224
+ end
225
+ type = found.first
226
+ target = raw[type]
227
+ default_label = type == 'script' ? 'readiness check' : target
228
+ new(
229
+ type: type.to_sym,
230
+ target: target,
231
+ timeout: raw['timeout'],
232
+ interval: raw['interval'] || 2,
233
+ label: raw['label'] || default_label
234
+ )
235
+ else
236
+ raise ArgumentError, "wait_for must be a string or hash, got #{raw.class}"
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'shellwords'
5
+ require 'tmpdir'
6
+ require 'timeout'
7
+
8
+ module Mxup
9
+ # Implements `mxup exec`: send a command to a pane, block until it finishes,
10
+ # print its output, and exit with its return code. The three interesting
11
+ # parts are:
12
+ #
13
+ # 1. Resolving a logical window name to a real tmux pane target.
14
+ # 2. Using `tmux wait-for -S <marker>` so we know the command finished.
15
+ # 3. Capturing both the output (tmux capture-pane) and the exit code
16
+ # (written to a temp file by the wrapped command).
17
+ class ExecRunner
18
+ def initialize(config, resolver:, dry_run: false,
19
+ out: nil, err: nil, exiter: ->(n) { exit(n) })
20
+ @config = config
21
+ @session = config.session
22
+ @resolver = resolver
23
+ @dry_run = dry_run
24
+ @out_override = out
25
+ @err_override = err
26
+ @exit = exiter
27
+ end
28
+
29
+ def out
30
+ @out_override || $stdout
31
+ end
32
+
33
+ def err
34
+ @err_override || $stderr
35
+ end
36
+
37
+ # target_spec may be:
38
+ # "session:window" — session must match config
39
+ # "window" — logical name from config, or a raw tmux window
40
+ # "window.pane_index" — raw tmux pane address
41
+ def run(target_spec, command, lines: 50, timeout: nil, force: false, quiet: false)
42
+ abort "Session #{@session} is not running." unless Tmux.has_session?(@session)
43
+ abort 'mxup exec: command is required.' if command.nil? || command.strip.empty?
44
+
45
+ window_part = strip_session_prefix(target_spec)
46
+ resolved = resolve_target(window_part)
47
+ abort "Target '#{window_part}' not found in session '#{@session}'." unless resolved
48
+
49
+ full_target = "#{@session}:#{resolved}"
50
+ refuse_if_busy(resolved, full_target) unless force
51
+
52
+ if @dry_run
53
+ out.puts "[dry-run] Would exec on #{full_target}: #{command}"
54
+ return 0
55
+ end
56
+
57
+ send_and_wait(full_target, command, lines: lines, timeout: timeout, quiet: quiet)
58
+ end
59
+
60
+ private
61
+
62
+ def strip_session_prefix(spec)
63
+ return spec unless spec.include?(':')
64
+ sess, rest = spec.split(':', 2)
65
+ if sess != @session
66
+ abort "Target session '#{sess}' does not match config session '#{@session}'."
67
+ end
68
+ rest
69
+ end
70
+
71
+ def resolve_target(window_part)
72
+ # First try: logical config window (handles group membership).
73
+ if @config.windows.any? { |w| w.name == window_part }
74
+ resolved = @resolver.pane_target(window_part)
75
+ return resolved if resolved
76
+ end
77
+
78
+ # Explicit "window.pane_index" form — accept if the window exists.
79
+ if window_part =~ /\A(.+)\.\d+\z/
80
+ raw_win = Regexp.last_match(1)
81
+ return window_part if tmux_window_names.include?(raw_win)
82
+ end
83
+
84
+ # Fallback: raw tmux window name that isn't in the config.
85
+ tmux_window_names.include?(window_part) ? window_part : nil
86
+ end
87
+
88
+ def tmux_window_names
89
+ Tmux.list_windows(@session).map { |w| w[:name] }
90
+ end
91
+
92
+ def refuse_if_busy(resolved, full_target)
93
+ pane = @resolver.pane_for(resolved)
94
+ return unless pane && !SHELLS.include?(pane[:fg_cmd])
95
+
96
+ abort "Target pane #{full_target} is busy (#{pane[:fg_cmd]}). " \
97
+ 'Pass --force to send anyway.'
98
+ end
99
+
100
+ def send_and_wait(full_target, command, lines:, timeout:, quiet:)
101
+ marker = fresh_marker
102
+ exit_file = File.join(Dir.tmpdir, "#{marker}.rc")
103
+ FileUtils.rm_f(exit_file)
104
+
105
+ cmd_clean = command.strip.sub(/;+\s*\z/, '')
106
+ # Wrap in a subshell so `exit`, `set -e`, or a failing command can't
107
+ # terminate the pane's interactive shell (which would strand us waiting
108
+ # for the marker forever).
109
+ wrapped = "( #{cmd_clean} ); " \
110
+ '__mxup_rc=$?; ' \
111
+ "echo $__mxup_rc > #{Shellwords.escape(exit_file)}; " \
112
+ "tmux wait-for -S #{Shellwords.escape(marker)}"
113
+
114
+ unless system("tmux send-keys -t #{Tmux.esc(full_target)} #{Shellwords.escape(wrapped)} Enter")
115
+ abort "Failed to send command to #{full_target}."
116
+ end
117
+
118
+ timed_out = wait_for_marker(marker, timeout)
119
+
120
+ output = `tmux capture-pane -t #{Tmux.esc(full_target)} -p -S -#{lines.to_i}`
121
+ unless quiet
122
+ out.print output
123
+ out.puts unless output.end_with?("\n")
124
+ end
125
+
126
+ if timed_out
127
+ err.puts "[mxup] exec: timed out after #{timeout}s waiting for command on #{full_target}."
128
+ FileUtils.rm_f(exit_file)
129
+ @exit.call(124)
130
+ return
131
+ end
132
+
133
+ rc = read_exit_code(exit_file)
134
+ FileUtils.rm_f(exit_file)
135
+ @exit.call(rc || 0)
136
+ end
137
+
138
+ def wait_for_marker(marker, timeout)
139
+ if timeout.nil?
140
+ system("tmux wait-for #{Tmux.esc(marker)}")
141
+ return false
142
+ end
143
+
144
+ pid = Process.spawn("tmux wait-for #{Tmux.esc(marker)}")
145
+ begin
146
+ Timeout.timeout(timeout) { Process.waitpid(pid) }
147
+ false
148
+ rescue Timeout::Error
149
+ kill_waiter(pid)
150
+ # Release anyone else still blocked on the marker.
151
+ system("tmux wait-for -S #{Tmux.esc(marker)} 2>/dev/null")
152
+ true
153
+ end
154
+ end
155
+
156
+ def kill_waiter(pid)
157
+ Process.kill('TERM', pid)
158
+ rescue StandardError
159
+ # already gone
160
+ ensure
161
+ begin
162
+ Process.waitpid(pid)
163
+ rescue StandardError
164
+ # already reaped
165
+ end
166
+ end
167
+
168
+ def read_exit_code(path)
169
+ return nil unless File.exist?(path)
170
+ val = File.read(path).strip
171
+ val.empty? ? nil : val.to_i
172
+ end
173
+
174
+ def fresh_marker
175
+ "mxup-exec-#{Process.pid}-#{Time.now.to_i}-#{rand(10**9)}"
176
+ end
177
+ end
178
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxup
4
+ # Sends SIGINT to every non-shell pane in a session, then waits for them to
5
+ # exit. Used by `mxup down` before the final kill-session.
6
+ class GracefulStop
7
+ # Interval (seconds) between SIGINT rounds. Overridable for tests that
8
+ # don't want to pay the 1s settle between retries.
9
+ DEFAULT_ROUND_INTERVAL = 1.0
10
+
11
+ class << self
12
+ attr_writer :round_interval
13
+
14
+ def round_interval
15
+ @round_interval || DEFAULT_ROUND_INTERVAL
16
+ end
17
+ end
18
+
19
+ def initialize(session, out: nil, err: nil)
20
+ @session = session
21
+ @out_override = out
22
+ @err_override = err
23
+ end
24
+
25
+ def out
26
+ @out_override || $stdout
27
+ end
28
+
29
+ def err
30
+ @err_override || $stderr
31
+ end
32
+
33
+ def call(timeout: 30)
34
+ out.puts "Stopping session #{@session}..."
35
+ deadline = Time.now + timeout
36
+ round = 0
37
+
38
+ loop do
39
+ break unless Tmux.has_session?(@session)
40
+
41
+ busy = busy_panes
42
+ break if busy.empty?
43
+
44
+ if round.positive?
45
+ remaining = (deadline - Time.now).ceil
46
+ plural = busy.size == 1 ? 'process' : 'processes'
47
+ out.puts " waiting... #{busy.size} #{plural} still running (#{remaining}s left)"
48
+ end
49
+
50
+ busy.each do |pane|
51
+ target = Tmux.pane_target(pane[:name], pane[:pane_index])
52
+ Tmux.send_interrupt(@session, target)
53
+ end
54
+
55
+ if Time.now >= deadline
56
+ names = busy.map { |p| p[:title].to_s.empty? ? p[:name] : p[:title] }.uniq
57
+ err.puts " timeout: #{names.join(', ')} did not exit in #{timeout}s — killing anyway"
58
+ break
59
+ end
60
+
61
+ round += 1
62
+ sleep GracefulStop.round_interval
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def busy_panes
69
+ Tmux.list_panes(@session).reject { |p| SHELLS.include?(p[:fg_cmd]) }
70
+ end
71
+ end
72
+ end