ace-tmux 0.14.3 → 0.17.2

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.
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ace
6
+ module Tmux
7
+ module Molecules
8
+ class LocalProcessInspector
9
+ def initialize(command_runner: nil)
10
+ @command_runner = command_runner || method(:run_command)
11
+ end
12
+
13
+ def find_descendant_command(root_pid, allowed_commands:)
14
+ normalized_root = normalize_pid(root_pid)
15
+ allowed = Array(allowed_commands).map { |command| normalize_command(command) }.reject(&:empty?)
16
+ return nil if normalized_root.nil? || allowed.empty?
17
+
18
+ queue = [normalized_root]
19
+ visited = {}
20
+
21
+ until queue.empty?
22
+ parent_pid = queue.shift
23
+ next if visited[parent_pid]
24
+
25
+ visited[parent_pid] = true
26
+
27
+ child_processes(parent_pid).each do |child|
28
+ return child[:command] if allowed.include?(child[:command])
29
+
30
+ queue << child[:pid]
31
+ end
32
+ end
33
+
34
+ nil
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :command_runner
40
+
41
+ def child_processes(parent_pid)
42
+ result = command_runner.call(["ps", "-o", "pid=,ppid=,comm=", "--ppid", parent_pid])
43
+ return [] unless result[:success]
44
+
45
+ result[:stdout].to_s.split("\n").map { |line| parse_process_line(line) }.compact
46
+ rescue Errno::ENOENT
47
+ []
48
+ end
49
+
50
+ def parse_process_line(line)
51
+ pid, _ppid, command = line.to_s.strip.split(/\s+/, 3)
52
+ return nil if pid.to_s.empty? || command.to_s.empty?
53
+
54
+ {
55
+ pid: normalize_pid(pid),
56
+ command: normalize_command(command)
57
+ }
58
+ end
59
+
60
+ def run_command(cmd)
61
+ stdout, stderr, status = Open3.capture3(*cmd)
62
+ {
63
+ success: status.success?,
64
+ stdout: stdout,
65
+ stderr: stderr
66
+ }
67
+ end
68
+
69
+ def normalize_pid(value)
70
+ candidate = value.to_s.strip
71
+ return nil if candidate.empty?
72
+
73
+ Integer(candidate).to_s
74
+ rescue ArgumentError
75
+ nil
76
+ end
77
+
78
+ def normalize_command(value)
79
+ value.to_s.strip.downcase
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ module Molecules
6
+ class RuntimeTargetResolver
7
+ def initialize(executor: Ace::Tmux::Molecules::TmuxExecutor.new, tmux: "tmux", env: ENV)
8
+ @executor = executor
9
+ @tmux = tmux
10
+ @env = env
11
+ end
12
+
13
+ def resolve_session(session: nil)
14
+ explicit = normalize(session)
15
+ return Models::RuntimeTarget.new(session: explicit, source: "explicit") if explicit
16
+
17
+ env_session = normalize(env["ACE_TMUX_SESSION"])
18
+ return Models::RuntimeTarget.new(session: env_session, source: "env") if env_session
19
+
20
+ live_session = resolve_live_session
21
+ return Models::RuntimeTarget.new(session: live_session, source: "live") if live_session
22
+
23
+ raise Ace::Tmux::TargetResolutionError, "Could not resolve tmux session from flags, ACE_TMUX_SESSION, or live tmux context."
24
+ end
25
+
26
+ def resolve_window(session: nil, window: nil)
27
+ session_target = resolve_session(session: session)
28
+
29
+ explicit_window = normalize(window)
30
+ if explicit_window
31
+ return build_window_target(session: session_target.session, raw_window: explicit_window, source: "explicit")
32
+ end
33
+
34
+ env_window = normalize(env["ACE_TMUX_WINDOW"])
35
+ if env_window
36
+ return build_window_target(session: session_target.session, raw_window: env_window, source: "env")
37
+ end
38
+
39
+ live_window = resolve_live_window(session: session_target.session)
40
+ return Models::RuntimeTarget.new(
41
+ session: session_target.session,
42
+ window: live_window[:name],
43
+ raw_window_target: live_window[:id],
44
+ source: "live"
45
+ ) if live_window
46
+
47
+ raise Ace::Tmux::TargetResolutionError, "Could not resolve tmux window from flags, ACE_TMUX_WINDOW, or live tmux context."
48
+ end
49
+
50
+ def resolve_pane(session: nil, window: nil, pane: nil)
51
+ explicit = normalize(pane)
52
+ return build_pane_target(explicit, session: session, window: window, source: "explicit") if explicit
53
+
54
+ env_pane = normalize(env["ACE_TMUX_PANE"])
55
+ return build_pane_target(env_pane, session: session, window: window, source: "env") if env_pane
56
+
57
+ window_target = resolve_window(session: session, window: window)
58
+ live_pane = resolve_live_pane(window_target.window_target)
59
+ return Models::RuntimeTarget.new(
60
+ session: window_target.session,
61
+ window: window_target.window,
62
+ pane: live_pane,
63
+ raw_window_target: window_target.window_target,
64
+ source: "live"
65
+ ) if live_pane
66
+
67
+ raise Ace::Tmux::TargetResolutionError, "Could not resolve tmux pane from flags, ACE_TMUX_PANE, or live tmux context."
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :executor, :tmux, :env
73
+
74
+ def build_window_target(session:, raw_window:, source:)
75
+ identity = resolve_window_reference(session, raw_window)
76
+ unless identity
77
+ return Models::RuntimeTarget.new(
78
+ session: session,
79
+ window: raw_window,
80
+ raw_window_target: raw_window.start_with?("@") ? raw_window : nil,
81
+ source: source
82
+ )
83
+ end
84
+
85
+ Models::RuntimeTarget.new(
86
+ session: session,
87
+ window: identity[:name],
88
+ raw_window_target: identity[:id],
89
+ source: source
90
+ )
91
+ end
92
+
93
+ def build_pane_target(raw_pane, session:, window:, source:)
94
+ if raw_pane.start_with?("%") || raw_window_pane_target?(raw_pane)
95
+ return Models::RuntimeTarget.new(pane: raw_pane, source: source)
96
+ end
97
+
98
+ if (full_target = parse_full_pane_target(raw_pane))
99
+ window_target = resolve_window(session: full_target[:session], window: full_target[:window])
100
+ return Models::RuntimeTarget.new(
101
+ session: window_target.session,
102
+ window: window_target.window,
103
+ pane: full_target[:pane],
104
+ raw_window_target: window_target.window_target,
105
+ source: source
106
+ )
107
+ end
108
+
109
+ shorthand = pane_index_shorthand(raw_pane)
110
+ if shorthand
111
+ window_target = resolve_window(session: session, window: window)
112
+ return Models::RuntimeTarget.new(
113
+ session: window_target.session,
114
+ window: window_target.window,
115
+ pane: shorthand,
116
+ raw_window_target: window_target.window_target,
117
+ source: source
118
+ )
119
+ end
120
+
121
+ raise Ace::Tmux::ValidationError, invalid_pane_target_message(raw_pane)
122
+ end
123
+
124
+ def raw_window_pane_target?(raw_pane)
125
+ raw_pane.match?(/\A@\d+\.\d+\z/)
126
+ end
127
+
128
+ def parse_full_pane_target(raw_pane)
129
+ session_name, rest = raw_pane.split(":", 2)
130
+ return nil unless rest
131
+
132
+ separator = rest.rindex(".")
133
+ return nil unless separator
134
+
135
+ window_name = rest[0...separator]
136
+ pane_index = rest[(separator + 1)..]
137
+ return nil unless pane_index
138
+ return nil if session_name.to_s.empty? || window_name.to_s.empty? || pane_index.to_s.empty?
139
+
140
+ {
141
+ session: session_name,
142
+ window: window_name,
143
+ pane: pane_index
144
+ }
145
+ end
146
+
147
+ def pane_index_shorthand(raw_pane)
148
+ return raw_pane if raw_pane.match?(/\A\d+\z/)
149
+ return raw_pane[1..] if raw_pane.match?(/\A\.\d+\z/)
150
+
151
+ nil
152
+ end
153
+
154
+ def invalid_pane_target_message(raw_pane)
155
+ colon_form = raw_pane.match(/\A([^:\s]+):([^:\s]+):([^:\s]+)\z/)
156
+ if colon_form
157
+ corrected = "#{colon_form[1]}:#{colon_form[2]}.#{colon_form[3]}"
158
+ return "Invalid pane target '#{raw_pane}'. Use '%8', '#{corrected}', '.1', or '--window #{colon_form[2]} --pane #{colon_form[3]}'."
159
+ end
160
+
161
+ if raw_pane.start_with?("@") && !raw_pane.include?(".")
162
+ return "Invalid pane target '#{raw_pane}'. Use '%8', 'default:3.1', '.1', or '--window 3 --pane 1'. '#{raw_pane}' is a tmux window id, not a pane target."
163
+ end
164
+
165
+ "Invalid pane target '#{raw_pane}'. Use '%8', 'default:3.1', '.1', or '--window 3 --pane 1'."
166
+ end
167
+
168
+ def resolve_window_reference(session, raw_window)
169
+ windows = list_window_identities(session)
170
+
171
+ if raw_window.start_with?("@")
172
+ windows.find { |entry| entry[:id] == raw_window }
173
+ elsif raw_window.match?(/\A\d+\z/)
174
+ windows.find { |entry| entry[:index] == raw_window }
175
+ else
176
+ windows.find { |entry| entry[:name] == raw_window }
177
+ end
178
+ end
179
+
180
+ def list_window_identities(session)
181
+ result = executor.capture(
182
+ Atoms::TmuxCommandBuilder.list_windows(
183
+ session,
184
+ format: '#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}' + "\t" + '#{window_active}',
185
+ tmux: tmux
186
+ )
187
+ )
188
+ return [] unless result.success?
189
+
190
+ result.stdout.split("\n").map { |line| parse_window_identity(line) }.compact
191
+ end
192
+
193
+ def parse_window_identity(line)
194
+ id, index, name, active = line.to_s.split("\t", 4)
195
+ return nil if [id, index, name].any? { |value| value.to_s.empty? }
196
+
197
+ {
198
+ id: id,
199
+ index: index,
200
+ name: name,
201
+ active: active == "1"
202
+ }
203
+ end
204
+
205
+ def resolve_live_session
206
+ return nil if normalize(env["TMUX"]).nil?
207
+
208
+ result = executor.capture(Atoms::TmuxCommandBuilder.display_message("#S", tmux: tmux))
209
+ result.success? ? normalize(result.stdout) : nil
210
+ rescue Errno::ENOENT
211
+ nil
212
+ end
213
+
214
+ def resolve_live_window(session:)
215
+ result = executor.capture(
216
+ Atoms::TmuxCommandBuilder.display_message_target(
217
+ "#{session}:",
218
+ '#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}',
219
+ tmux: tmux
220
+ )
221
+ )
222
+ identity = parse_window_identity(result.stdout)
223
+ return identity if identity
224
+
225
+ explicit_session = normalize(env["ACE_TMUX_SESSION"])
226
+ if explicit_session == session
227
+ fallback = active_window_identity(session)
228
+ return fallback if fallback
229
+ end
230
+
231
+ return nil if normalize(env["TMUX"]).nil?
232
+
233
+ fallback = executor.capture(
234
+ Atoms::TmuxCommandBuilder.display_message(
235
+ '#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}',
236
+ tmux: tmux
237
+ )
238
+ )
239
+ parse_window_identity(fallback.stdout)
240
+ rescue Errno::ENOENT
241
+ nil
242
+ end
243
+
244
+ def resolve_live_pane(window_target)
245
+ return nil if normalize(env["TMUX"]).nil?
246
+
247
+ result = executor.capture(
248
+ Atoms::TmuxCommandBuilder.display_message_target(window_target, '#{pane_index}', tmux: tmux)
249
+ )
250
+ result.success? ? normalize(result.stdout) : nil
251
+ rescue Errno::ENOENT
252
+ nil
253
+ end
254
+
255
+ def normalize(value)
256
+ candidate = value.to_s.strip
257
+ candidate.empty? ? nil : candidate
258
+ end
259
+
260
+ def active_window_identity(session)
261
+ windows = list_window_identities(session)
262
+ windows.find { |entry| entry[:active] } || windows.first
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -57,6 +57,7 @@ module Ace
57
57
 
58
58
  def with_socket_target(cmd)
59
59
  return cmd unless tmux_command?(cmd)
60
+ return cmd if in_tmux_session?
60
61
 
61
62
  socket = explicit_socket_path
62
63
  return cmd unless socket
@@ -72,6 +73,13 @@ module Ace
72
73
  Array(cmd).first == "tmux"
73
74
  end
74
75
 
76
+ def in_tmux_session?
77
+ tmux_env = ENV["TMUX"]
78
+ return false if tmux_env.nil? || tmux_env.empty?
79
+
80
+ true
81
+ end
82
+
75
83
  def explicit_socket_path
76
84
  base = ENV["TMUX_TMPDIR"].to_s.strip
77
85
  base = ENV["XDG_RUNTIME_DIR"].to_s.strip if base.empty?
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ module Molecules
6
+ module WaitConditionValidator
7
+ module_function
8
+
9
+ ALLOWED = %w[agent output window-exists window-active pane-exists pane-exited].freeze
10
+
11
+ def validate!(condition:, pattern:)
12
+ normalized = condition.to_s.strip
13
+ raise Ace::Tmux::ValidationError, "--for is required" if normalized.empty?
14
+ raise Ace::Tmux::ValidationError, "Unsupported wait condition: #{condition}" unless ALLOWED.include?(normalized)
15
+
16
+ if normalized == "output" && pattern.to_s.strip.empty?
17
+ raise Ace::Tmux::ValidationError, "--pattern is required when waiting for output"
18
+ end
19
+
20
+ normalized
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end