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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +11 -3
- data/docs/demo/ace-tmux-getting-started.tape.yml +5 -1
- data/docs/getting-started.md +7 -3
- data/docs/usage.md +168 -10
- data/lib/ace/tmux/atoms/named_key_registry.rb +34 -0
- data/lib/ace/tmux/atoms/tmux_command_builder.rb +28 -1
- data/lib/ace/tmux/atoms/window_name_sanitizer.rb +25 -0
- data/lib/ace/tmux/cli/commands/attach.rb +26 -0
- data/lib/ace/tmux/cli/commands/capture.rb +41 -0
- data/lib/ace/tmux/cli/commands/detach.rb +28 -0
- data/lib/ace/tmux/cli/commands/list.rb +113 -48
- data/lib/ace/tmux/cli/commands/list_presets.rb +67 -0
- data/lib/ace/tmux/cli/commands/send.rb +169 -0
- data/lib/ace/tmux/cli/commands/wait.rb +57 -0
- data/lib/ace/tmux/cli/commands/window.rb +2 -3
- data/lib/ace/tmux/cli.rb +27 -2
- data/lib/ace/tmux/models/runtime_target.rb +46 -0
- data/lib/ace/tmux/molecules/local_process_inspector.rb +84 -0
- data/lib/ace/tmux/molecules/runtime_target_resolver.rb +267 -0
- data/lib/ace/tmux/molecules/tmux_executor.rb +8 -0
- data/lib/ace/tmux/molecules/wait_condition_validator.rb +25 -0
- data/lib/ace/tmux/organisms/control_surface.rb +523 -0
- data/lib/ace/tmux/organisms/session_manager.rb +17 -3
- data/lib/ace/tmux/organisms/window_manager.rb +8 -3
- data/lib/ace/tmux/version.rb +1 -1
- data/lib/ace/tmux.rb +11 -0
- metadata +15 -2
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Tmux
|
|
5
|
+
module Organisms
|
|
6
|
+
class ControlSurface
|
|
7
|
+
DEFAULT_LINES = 40
|
|
8
|
+
DEFAULT_TIMEOUT = 10.0
|
|
9
|
+
DEFAULT_INTERVAL = 0.2
|
|
10
|
+
DEFAULT_SETTLE = 1.0
|
|
11
|
+
INTERACTIVE_SUBMIT_DELAY = 0.15
|
|
12
|
+
INTERACTIVE_CLI_COMMANDS = %w[codex claude pi].freeze
|
|
13
|
+
INTERACTIVE_BUSY_PATTERNS = [
|
|
14
|
+
/esc to interrupt/i,
|
|
15
|
+
/\bWorking \(/,
|
|
16
|
+
/Booting MCP server:/i
|
|
17
|
+
].freeze
|
|
18
|
+
PANE_PROFILE_FORMAT = '#{pane_current_command}' + "\t" + '#{pane_height}' + "\t" + '#{alternate_on}' + "\t" + '#{pane_pid}'
|
|
19
|
+
SESSION_LIST_FORMAT = '#{session_name}' + "\t" + '#{session_attached}' + "\t" + '#{session_windows}'
|
|
20
|
+
WINDOW_LIST_FORMAT = '#{window_active}' + "\t" + '#{window_id}' + "\t" + '#{session_name}' + "\t" + '#{window_index}' + "\t" + '#{window_name}' + "\t" + '#{window_panes}'
|
|
21
|
+
PANE_LIST_FORMAT = '#{pane_active}' + "\t" + '#{pane_id}' + "\t" + '#{session_name}' + "\t" + '#{window_id}' + "\t" + '#{window_index}' + "\t" + '#{window_name}' + "\t" + '#{pane_index}' + "\t" + '#{pane_current_command}' + "\t" + '#{pane_current_path}'
|
|
22
|
+
|
|
23
|
+
def initialize(executor: Ace::Tmux::Molecules::TmuxExecutor.new, resolver: nil, process_inspector: nil, tmux: "tmux")
|
|
24
|
+
@executor = executor
|
|
25
|
+
@tmux = tmux
|
|
26
|
+
@resolver = resolver || Molecules::RuntimeTargetResolver.new(executor: executor, tmux: tmux)
|
|
27
|
+
@process_inspector = process_inspector || Molecules::LocalProcessInspector.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def send_command(session: nil, window: nil, pane: nil, command:)
|
|
31
|
+
raise Ace::Tmux::ValidationError, "--cmd is required" if command.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
send_sequence(session: session, window: window, pane: pane, command: command)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def send_text(session: nil, window: nil, pane: nil, text:)
|
|
37
|
+
raise Ace::Tmux::ValidationError, "--msg is required" if text.to_s.empty?
|
|
38
|
+
|
|
39
|
+
target = resolver.resolve_pane(session: session, window: window, pane: pane)
|
|
40
|
+
send_text_to_target(target.pane_target, text)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def send_key(session: nil, window: nil, pane: nil, key:)
|
|
44
|
+
send_sequence(session: session, window: window, pane: pane, keys: [key])
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def send_sequence(session: nil, window: nil, pane: nil, command: nil, messages: [], keys: [])
|
|
48
|
+
target = resolver.resolve_pane(session: session, window: window, pane: pane)
|
|
49
|
+
pane_profile = fetch_pane_profile(target.pane_target)
|
|
50
|
+
normalized_command = normalize_optional_text(command)
|
|
51
|
+
normalized_messages = Array(messages).map(&:to_s)
|
|
52
|
+
normalized_keys = Array(keys)
|
|
53
|
+
text_pending_submit = false
|
|
54
|
+
|
|
55
|
+
normalized_messages.each do |message|
|
|
56
|
+
send_text_to_target(target.pane_target, message)
|
|
57
|
+
text_pending_submit = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if normalized_command
|
|
61
|
+
send_text_to_target(target.pane_target, normalized_command)
|
|
62
|
+
pause_before_interactive_submit(pane_profile, "Enter")
|
|
63
|
+
send_named_key_to_target(target.pane_target, "Enter")
|
|
64
|
+
text_pending_submit = false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
normalized_keys.each_with_index do |key, index|
|
|
68
|
+
pause_before_interactive_submit(pane_profile, key) if text_pending_submit && index.zero?
|
|
69
|
+
send_named_key_to_target(target.pane_target, key)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def capture_recent_output(session: nil, window: nil, pane: nil, lines: DEFAULT_LINES)
|
|
74
|
+
target = resolver.resolve_pane(session: session, window: window, pane: pane)
|
|
75
|
+
pane_profile = fetch_pane_profile(target.pane_target)
|
|
76
|
+
capture_output_for_target(target.pane_target, pane_profile: pane_profile, lines: lines)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def wait_for_condition(
|
|
80
|
+
condition:,
|
|
81
|
+
session: nil,
|
|
82
|
+
window: nil,
|
|
83
|
+
pane: nil,
|
|
84
|
+
pattern: nil,
|
|
85
|
+
timeout: DEFAULT_TIMEOUT,
|
|
86
|
+
interval: DEFAULT_INTERVAL,
|
|
87
|
+
lines: DEFAULT_LINES,
|
|
88
|
+
settle: DEFAULT_SETTLE,
|
|
89
|
+
baseline_output: nil,
|
|
90
|
+
require_change: false
|
|
91
|
+
)
|
|
92
|
+
normalized = Molecules::WaitConditionValidator.validate!(condition: condition, pattern: pattern)
|
|
93
|
+
deadline = monotonic_now + timeout.to_f
|
|
94
|
+
|
|
95
|
+
if normalized == "agent"
|
|
96
|
+
return wait_for_agent_condition(
|
|
97
|
+
session: session,
|
|
98
|
+
window: window,
|
|
99
|
+
pane: pane,
|
|
100
|
+
timeout_deadline: deadline,
|
|
101
|
+
interval: interval.to_f,
|
|
102
|
+
lines: lines,
|
|
103
|
+
settle: settle.to_f,
|
|
104
|
+
baseline_output: baseline_output,
|
|
105
|
+
require_change: require_change
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
if normalized == "output" && !baseline_output.nil?
|
|
110
|
+
return wait_for_output_condition(
|
|
111
|
+
session: session,
|
|
112
|
+
window: window,
|
|
113
|
+
pane: pane,
|
|
114
|
+
pattern: pattern,
|
|
115
|
+
timeout_deadline: deadline,
|
|
116
|
+
interval: interval.to_f,
|
|
117
|
+
lines: lines,
|
|
118
|
+
baseline_output: baseline_output
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
loop do
|
|
123
|
+
return true if condition_met?(normalized, session: session, window: window, pane: pane, pattern: pattern)
|
|
124
|
+
|
|
125
|
+
raise_wait_timeout(normalized) if monotonic_now >= deadline
|
|
126
|
+
|
|
127
|
+
sleep(interval.to_f)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def attach_session(session: nil)
|
|
132
|
+
target = resolver.resolve_session(session: session)
|
|
133
|
+
executor.exec(Atoms::TmuxCommandBuilder.attach_session(target.session_target, tmux: tmux))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def detach_session(session: nil)
|
|
137
|
+
target = resolver.resolve_session(session: session)
|
|
138
|
+
run!(
|
|
139
|
+
Atoms::TmuxCommandBuilder.detach_client(target.session_target, tmux: tmux),
|
|
140
|
+
"detach clients from #{target.session_target}"
|
|
141
|
+
)
|
|
142
|
+
target.session_target
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def list_sessions
|
|
146
|
+
result = executor.capture(
|
|
147
|
+
Atoms::TmuxCommandBuilder.list_sessions(format: SESSION_LIST_FORMAT, tmux: tmux)
|
|
148
|
+
)
|
|
149
|
+
return [] unless result.success?
|
|
150
|
+
|
|
151
|
+
result.stdout.split("\n").map { |line| parse_session_row(line) }.compact.sort_by { |entry| entry[:session] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def list_windows(session: nil)
|
|
155
|
+
target = resolver.resolve_session(session: session)
|
|
156
|
+
list_window_rows(target.session_target)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def list_panes(session: nil, window: nil, all_panes: false)
|
|
160
|
+
if all_panes
|
|
161
|
+
target = resolver.resolve_session(session: session)
|
|
162
|
+
return window_identities_for_session(target.session_target).flat_map { |entry| pane_rows_for_window(entry[:id]) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
target = resolver.resolve_window(session: session, window: window)
|
|
166
|
+
pane_rows_for_window(target.window_target)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
attr_reader :executor, :resolver, :process_inspector, :tmux
|
|
172
|
+
|
|
173
|
+
def condition_met?(condition, session:, window:, pane:, pattern:)
|
|
174
|
+
case condition
|
|
175
|
+
when "output"
|
|
176
|
+
capture_recent_output(session: session, window: window, pane: pane).include?(pattern)
|
|
177
|
+
when "window-exists"
|
|
178
|
+
target = resolver.resolve_window(session: session, window: window)
|
|
179
|
+
window_rows = list_window_rows(target.session_target)
|
|
180
|
+
window_target_exists?(target, window_rows)
|
|
181
|
+
when "window-active"
|
|
182
|
+
target = resolver.resolve_window(session: session, window: window)
|
|
183
|
+
window_active?(target, list_window_rows(target.session_target))
|
|
184
|
+
when "pane-exists"
|
|
185
|
+
pane_exists_for?(session: session, window: window, pane: pane)
|
|
186
|
+
when "pane-exited"
|
|
187
|
+
pane_exited?(session: session, window: window, pane: pane)
|
|
188
|
+
else
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def wait_for_output_condition(session:, window:, pane:, pattern:, timeout_deadline:, interval:, lines:, baseline_output:)
|
|
194
|
+
baseline_count = pattern_match_count(baseline_output.to_s, pattern)
|
|
195
|
+
|
|
196
|
+
loop do
|
|
197
|
+
current_output = capture_recent_output(session: session, window: window, pane: pane, lines: lines)
|
|
198
|
+
return true if pattern_match_count(current_output, pattern) > baseline_count
|
|
199
|
+
|
|
200
|
+
raise_wait_timeout("output") if monotonic_now >= timeout_deadline
|
|
201
|
+
|
|
202
|
+
sleep(interval)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def window_identities_for_session(session)
|
|
207
|
+
result = executor.capture(
|
|
208
|
+
Atoms::TmuxCommandBuilder.list_windows(session, format: WINDOW_LIST_FORMAT, tmux: tmux)
|
|
209
|
+
)
|
|
210
|
+
return [] unless result.success?
|
|
211
|
+
|
|
212
|
+
result.stdout.split("\n").map { |line| parse_window_row(line) }.compact.sort_by { |entry| [entry[:index], entry[:name]] }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def list_window_rows(session)
|
|
216
|
+
window_identities_for_session(session)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def pane_exists?(pane_target)
|
|
220
|
+
result = executor.capture(
|
|
221
|
+
Atoms::TmuxCommandBuilder.display_message_target(pane_target, '#{pane_id}', tmux: tmux)
|
|
222
|
+
)
|
|
223
|
+
result.success? && !result.stdout.to_s.strip.empty?
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def pane_exists_for?(session:, window:, pane:)
|
|
227
|
+
target = resolver.resolve_pane(session: session, window: window, pane: pane)
|
|
228
|
+
pane_exists?(target.pane_target)
|
|
229
|
+
rescue Ace::Tmux::TargetResolutionError
|
|
230
|
+
false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def pane_exited?(session:, window:, pane:)
|
|
234
|
+
target = resolver.resolve_pane(session: session, window: window, pane: pane)
|
|
235
|
+
result = executor.capture(
|
|
236
|
+
Atoms::TmuxCommandBuilder.display_message_target(target.pane_target, '#{pane_dead}', tmux: tmux)
|
|
237
|
+
)
|
|
238
|
+
!result.success? || result.stdout == "1"
|
|
239
|
+
rescue Ace::Tmux::TargetResolutionError
|
|
240
|
+
true
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def window_target_exists?(target, rows)
|
|
244
|
+
if target.window_target&.start_with?("@")
|
|
245
|
+
rows.any? { |entry| entry[:id] == target.window_target }
|
|
246
|
+
else
|
|
247
|
+
rows.any? { |entry| entry[:name] == target.window }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def window_active?(target, rows)
|
|
252
|
+
if target.window_target&.start_with?("@")
|
|
253
|
+
rows.any? { |entry| entry[:id] == target.window_target && entry[:active] }
|
|
254
|
+
else
|
|
255
|
+
rows.any? { |entry| entry[:name] == target.window && entry[:active] }
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def pane_rows_for_window(window_target)
|
|
260
|
+
result = executor.capture(
|
|
261
|
+
Atoms::TmuxCommandBuilder.list_panes(window_target, format: PANE_LIST_FORMAT, tmux: tmux)
|
|
262
|
+
)
|
|
263
|
+
return [] unless result.success?
|
|
264
|
+
|
|
265
|
+
result.stdout.split("\n").map { |line| parse_pane_row(line) }.compact.sort_by do |entry|
|
|
266
|
+
[entry[:window_index], entry[:pane_index]]
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def parse_session_row(line)
|
|
271
|
+
session, attached, windows = line.to_s.split("\t", 3)
|
|
272
|
+
return nil if session.to_s.empty?
|
|
273
|
+
|
|
274
|
+
{
|
|
275
|
+
session: session,
|
|
276
|
+
attached_clients: integer_or_zero(attached),
|
|
277
|
+
window_count: integer_or_zero(windows)
|
|
278
|
+
}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def parse_window_row(line)
|
|
282
|
+
active, id, session, index, name, panes = line.to_s.split("\t", 6)
|
|
283
|
+
return nil if [id, session, index, name].any? { |value| value.to_s.empty? }
|
|
284
|
+
|
|
285
|
+
{
|
|
286
|
+
active: active == "1",
|
|
287
|
+
id: id,
|
|
288
|
+
session: session,
|
|
289
|
+
index: integer_or_zero(index),
|
|
290
|
+
name: name,
|
|
291
|
+
pane_count: integer_or_zero(panes)
|
|
292
|
+
}
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def parse_pane_row(line)
|
|
296
|
+
active, pane_id, session, window_id, window_index, window_name, pane_index, command, path = line.to_s.split("\t", 9)
|
|
297
|
+
return nil if [pane_id, session, window_id, window_index, window_name, pane_index].any? { |value| value.to_s.empty? }
|
|
298
|
+
|
|
299
|
+
{
|
|
300
|
+
active: active == "1",
|
|
301
|
+
pane_id: pane_id,
|
|
302
|
+
session: session,
|
|
303
|
+
window_id: window_id,
|
|
304
|
+
window_index: integer_or_zero(window_index),
|
|
305
|
+
window_name: window_name,
|
|
306
|
+
pane_index: integer_or_zero(pane_index),
|
|
307
|
+
target: "#{session}:#{window_name}.#{pane_index}",
|
|
308
|
+
command: normalize_optional_text(command) || "-",
|
|
309
|
+
cwd: path_basename(path)
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def integer_or_zero(value)
|
|
314
|
+
Integer(value.to_s.strip)
|
|
315
|
+
rescue ArgumentError
|
|
316
|
+
0
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def path_basename(path)
|
|
320
|
+
value = path.to_s.strip
|
|
321
|
+
return "-" if value.empty?
|
|
322
|
+
|
|
323
|
+
File.basename(value)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def pattern_match_count(output, pattern)
|
|
327
|
+
text = output.to_s
|
|
328
|
+
return 0 if text.empty?
|
|
329
|
+
|
|
330
|
+
Regexp.new(Regexp.escape(pattern.to_s)).match(text) ? text.scan(pattern.to_s).length : 0
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def normalize_optional_text(value)
|
|
334
|
+
text = value.to_s
|
|
335
|
+
return nil if text.strip.empty?
|
|
336
|
+
|
|
337
|
+
text
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def fetch_pane_profile(pane_target)
|
|
341
|
+
result = executor.capture(
|
|
342
|
+
Atoms::TmuxCommandBuilder.display_message_target(pane_target, PANE_PROFILE_FORMAT, tmux: tmux)
|
|
343
|
+
)
|
|
344
|
+
return default_pane_profile unless result.success?
|
|
345
|
+
|
|
346
|
+
parse_pane_profile(result.stdout)
|
|
347
|
+
rescue Errno::ENOENT
|
|
348
|
+
default_pane_profile
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def parse_pane_profile(stdout)
|
|
352
|
+
command, height, alternate_on, pane_pid = stdout.to_s.split("\t", 4)
|
|
353
|
+
normalized_command = normalize_command(command)
|
|
354
|
+
normalized_height = Integer(height.to_s.strip)
|
|
355
|
+
|
|
356
|
+
build_pane_profile(
|
|
357
|
+
command: normalized_command,
|
|
358
|
+
height: normalized_height,
|
|
359
|
+
alternate_on: alternate_on.to_s.strip == "1",
|
|
360
|
+
pane_pid: pane_pid
|
|
361
|
+
)
|
|
362
|
+
rescue ArgumentError
|
|
363
|
+
default_pane_profile(command: command, pane_pid: pane_pid)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def default_pane_profile(command: nil, pane_pid: nil)
|
|
367
|
+
normalized_command = normalize_command(command)
|
|
368
|
+
interactive_command = resolve_interactive_cli_command(command: normalized_command, pane_pid: pane_pid)
|
|
369
|
+
|
|
370
|
+
{
|
|
371
|
+
command: interactive_command || normalized_command,
|
|
372
|
+
height: nil,
|
|
373
|
+
alternate_on: false,
|
|
374
|
+
pane_pid: normalize_pid(pane_pid),
|
|
375
|
+
interactive_cli: !interactive_command.nil?
|
|
376
|
+
}
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def wait_for_agent_condition(session:, window:, pane:, timeout_deadline:, interval:, lines:, settle:, baseline_output:, require_change:)
|
|
380
|
+
target = resolver.resolve_pane(session: session, window: window, pane: pane)
|
|
381
|
+
pane_profile = fetch_pane_profile(target.pane_target)
|
|
382
|
+
unless pane_profile[:interactive_cli]
|
|
383
|
+
raise Ace::Tmux::ValidationError, "--for agent requires an interactive CLI pane such as codex, claude, or pi."
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
previous_output = baseline_output.nil? ? capture_output_for_target(target.pane_target, pane_profile: pane_profile, lines: lines) : baseline_output.to_s
|
|
387
|
+
baseline = previous_output
|
|
388
|
+
change_observed = !require_change
|
|
389
|
+
stable_since = nil
|
|
390
|
+
|
|
391
|
+
loop do
|
|
392
|
+
current_output = capture_output_for_target(target.pane_target, pane_profile: pane_profile, lines: lines)
|
|
393
|
+
busy = interactive_agent_busy?(current_output)
|
|
394
|
+
|
|
395
|
+
if current_output != previous_output
|
|
396
|
+
previous_output = current_output
|
|
397
|
+
change_observed ||= current_output != baseline
|
|
398
|
+
stable_since = nil
|
|
399
|
+
elsif change_observed && !busy
|
|
400
|
+
stable_since ||= monotonic_now
|
|
401
|
+
return true if monotonic_now - stable_since >= settle
|
|
402
|
+
else
|
|
403
|
+
stable_since = nil
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
raise_wait_timeout("agent") if monotonic_now >= timeout_deadline
|
|
407
|
+
|
|
408
|
+
sleep(interval)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def interactive_agent_busy?(output)
|
|
413
|
+
text = output.to_s
|
|
414
|
+
INTERACTIVE_BUSY_PATTERNS.any? { |pattern| text.match?(pattern) }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def build_pane_profile(command:, height:, alternate_on:, pane_pid:)
|
|
418
|
+
interactive_command = resolve_interactive_cli_command(command: command, pane_pid: pane_pid)
|
|
419
|
+
|
|
420
|
+
{
|
|
421
|
+
command: interactive_command || command,
|
|
422
|
+
height: height,
|
|
423
|
+
alternate_on: alternate_on,
|
|
424
|
+
pane_pid: normalize_pid(pane_pid),
|
|
425
|
+
interactive_cli: !interactive_command.nil?
|
|
426
|
+
}
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def resolve_interactive_cli_command(command:, pane_pid:)
|
|
430
|
+
normalized_command = normalize_command(command)
|
|
431
|
+
return normalized_command if INTERACTIVE_CLI_COMMANDS.include?(normalized_command)
|
|
432
|
+
|
|
433
|
+
process_inspector.find_descendant_command(normalize_pid(pane_pid), allowed_commands: INTERACTIVE_CLI_COMMANDS)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def normalize_command(value)
|
|
437
|
+
value.to_s.strip.downcase
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def normalize_pid(value)
|
|
441
|
+
candidate = value.to_s.strip
|
|
442
|
+
return nil if candidate.empty?
|
|
443
|
+
|
|
444
|
+
Integer(candidate).to_s
|
|
445
|
+
rescue ArgumentError
|
|
446
|
+
nil
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def monotonic_now
|
|
450
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def raise_wait_timeout(condition)
|
|
454
|
+
raise Ace::Tmux::WaitTimeoutError, "Timed out waiting for tmux condition '#{condition}'."
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def pause_before_interactive_submit(pane_profile, key)
|
|
458
|
+
return unless pane_profile[:interactive_cli]
|
|
459
|
+
return unless submit_key?(key)
|
|
460
|
+
|
|
461
|
+
sleep(INTERACTIVE_SUBMIT_DELAY)
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def submit_key?(key)
|
|
465
|
+
Atoms::NamedKeyRegistry.normalize(key) == "Enter"
|
|
466
|
+
rescue Ace::Tmux::ValidationError
|
|
467
|
+
false
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def capture_command_for(pane_target, pane_profile:, lines:)
|
|
471
|
+
normalized_lines = [Integer(lines.to_i.abs), 1].max
|
|
472
|
+
return Atoms::TmuxCommandBuilder.capture_pane(pane_target, lines: normalized_lines, tmux: tmux) unless pane_profile[:interactive_cli]
|
|
473
|
+
|
|
474
|
+
pane_height = pane_profile[:height].to_i
|
|
475
|
+
return Atoms::TmuxCommandBuilder.capture_pane(pane_target, lines: normalized_lines, tmux: tmux) unless pane_height.positive?
|
|
476
|
+
|
|
477
|
+
visible_lines = [normalized_lines, pane_height].min
|
|
478
|
+
start_line = [pane_height - visible_lines, 0].max
|
|
479
|
+
end_line = pane_height - 1
|
|
480
|
+
|
|
481
|
+
Atoms::TmuxCommandBuilder.capture_pane_visible(
|
|
482
|
+
pane_target,
|
|
483
|
+
start_line: start_line,
|
|
484
|
+
end_line: end_line,
|
|
485
|
+
include_alternate: pane_profile[:alternate_on],
|
|
486
|
+
tmux: tmux
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def capture_output_for_target(pane_target, pane_profile:, lines:)
|
|
491
|
+
result = executor.capture(
|
|
492
|
+
capture_command_for(pane_target, pane_profile: pane_profile, lines: lines)
|
|
493
|
+
)
|
|
494
|
+
raise Ace::Tmux::Error, "Failed to capture pane #{pane_target}: #{result.stderr}" unless result.success?
|
|
495
|
+
|
|
496
|
+
result.stdout
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def send_text_to_target(target, text)
|
|
500
|
+
run!(
|
|
501
|
+
Atoms::TmuxCommandBuilder.send_raw_keys(target, text, tmux: tmux),
|
|
502
|
+
"send text to #{target}"
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def send_named_key_to_target(target, key)
|
|
507
|
+
normalized_key = Atoms::NamedKeyRegistry.normalize(key)
|
|
508
|
+
run!(
|
|
509
|
+
Atoms::TmuxCommandBuilder.send_raw_keys(target, normalized_key, tmux: tmux),
|
|
510
|
+
"send key to #{target}"
|
|
511
|
+
)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def run!(cmd, action)
|
|
515
|
+
result = executor.run(cmd)
|
|
516
|
+
return true if result
|
|
517
|
+
|
|
518
|
+
raise Ace::Tmux::Error, "Failed to #{action}"
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
@@ -23,6 +23,7 @@ module Ace
|
|
|
23
23
|
@executor = executor
|
|
24
24
|
@session_builder = session_builder
|
|
25
25
|
@tmux = tmux
|
|
26
|
+
@window_targets = {}
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Start a session from a preset
|
|
@@ -37,6 +38,7 @@ module Ace
|
|
|
37
38
|
session = @session_builder.build(preset_name)
|
|
38
39
|
session.name = name if name
|
|
39
40
|
session.root = root if root
|
|
41
|
+
@window_targets = {}
|
|
40
42
|
|
|
41
43
|
if session_exists?(session.name)
|
|
42
44
|
if force
|
|
@@ -61,7 +63,7 @@ module Ace
|
|
|
61
63
|
# Mirrors WindowManager#resolve_window_name: root basename wins over preset name.
|
|
62
64
|
def resolve_first_window_name(preset_window_name, root_override, session_root)
|
|
63
65
|
effective_root = root_override || session_root || Dir.pwd
|
|
64
|
-
File.basename(effective_root)
|
|
66
|
+
Atoms::WindowNameSanitizer.call(File.basename(effective_root), fallback: preset_window_name || "window")
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
def session_exists?(name)
|
|
@@ -88,6 +90,8 @@ module Ace
|
|
|
88
90
|
)
|
|
89
91
|
result = @executor.capture(cmd)
|
|
90
92
|
window_target = result.stdout.strip
|
|
93
|
+
record_window_target(first_window_name, window_target)
|
|
94
|
+
record_window_target(first_window&.name, window_target) if first_window
|
|
91
95
|
|
|
92
96
|
# Set up panes for the first window (it was created with the session)
|
|
93
97
|
setup_panes(session, first_window, window_target) if first_window
|
|
@@ -98,15 +102,18 @@ module Ace
|
|
|
98
102
|
# Skip first window (already created with session)
|
|
99
103
|
session.windows.drop(1).each do |window|
|
|
100
104
|
window_root = window.root || session.root
|
|
105
|
+
window_name = Atoms::WindowNameSanitizer.call(window.name)
|
|
101
106
|
cmd = Atoms::TmuxCommandBuilder.new_window(
|
|
102
107
|
session.name,
|
|
103
|
-
name:
|
|
108
|
+
name: window_name,
|
|
104
109
|
root: window_root,
|
|
105
110
|
print_format: '#{window_id}',
|
|
106
111
|
tmux: @tmux
|
|
107
112
|
)
|
|
108
113
|
result = @executor.capture(cmd)
|
|
109
114
|
window_target = result.stdout.strip
|
|
115
|
+
record_window_target(window_name, window_target)
|
|
116
|
+
record_window_target(window.name, window_target)
|
|
110
117
|
setup_panes(session, window, window_target)
|
|
111
118
|
end
|
|
112
119
|
end
|
|
@@ -263,7 +270,8 @@ module Ace
|
|
|
263
270
|
|
|
264
271
|
def select_startup_window(session, first_window_target: nil)
|
|
265
272
|
target = if session.startup_window
|
|
266
|
-
|
|
273
|
+
startup_window = Atoms::WindowNameSanitizer.call(session.startup_window)
|
|
274
|
+
@window_targets[startup_window] || @window_targets[session.startup_window] || "#{session.name}:#{startup_window}"
|
|
267
275
|
else
|
|
268
276
|
first_window_target || "#{session.name}:#{session.windows.first&.name || 0}"
|
|
269
277
|
end
|
|
@@ -272,6 +280,12 @@ module Ace
|
|
|
272
280
|
@executor.run(cmd)
|
|
273
281
|
end
|
|
274
282
|
|
|
283
|
+
def record_window_target(name, target)
|
|
284
|
+
return if name.to_s.empty? || target.to_s.empty?
|
|
285
|
+
|
|
286
|
+
@window_targets[name] = target
|
|
287
|
+
end
|
|
288
|
+
|
|
275
289
|
def attach_session(session)
|
|
276
290
|
cmd = Atoms::TmuxCommandBuilder.attach_session(session.name, tmux: @tmux)
|
|
277
291
|
@executor.exec(cmd)
|
|
@@ -59,10 +59,15 @@ module Ace
|
|
|
59
59
|
private
|
|
60
60
|
|
|
61
61
|
def resolve_window_name(explicit_name, root, preset_name)
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
raw_name = if explicit_name
|
|
63
|
+
explicit_name
|
|
64
|
+
elsif root
|
|
65
|
+
File.basename(root)
|
|
66
|
+
else
|
|
67
|
+
preset_name
|
|
68
|
+
end
|
|
64
69
|
|
|
65
|
-
|
|
70
|
+
Atoms::WindowNameSanitizer.call(raw_name)
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
def detect_current_session
|
data/lib/ace/tmux/version.rb
CHANGED
data/lib/ace/tmux.rb
CHANGED
|
@@ -13,6 +13,10 @@ module Ace
|
|
|
13
13
|
|
|
14
14
|
# Raised when not running inside tmux
|
|
15
15
|
class NotInTmuxError < Error; end
|
|
16
|
+
|
|
17
|
+
class TargetResolutionError < Error; end
|
|
18
|
+
class WaitTimeoutError < Error; end
|
|
19
|
+
class ValidationError < Error; end
|
|
16
20
|
end
|
|
17
21
|
end
|
|
18
22
|
|
|
@@ -21,13 +25,20 @@ require_relative "tmux/models/pane"
|
|
|
21
25
|
require_relative "tmux/models/layout_node"
|
|
22
26
|
require_relative "tmux/models/window"
|
|
23
27
|
require_relative "tmux/models/session"
|
|
28
|
+
require_relative "tmux/models/runtime_target"
|
|
24
29
|
require_relative "tmux/atoms/tmux_command_builder"
|
|
30
|
+
require_relative "tmux/atoms/named_key_registry"
|
|
25
31
|
require_relative "tmux/atoms/layout_string_builder"
|
|
26
32
|
require_relative "tmux/atoms/preset_resolver"
|
|
33
|
+
require_relative "tmux/atoms/window_name_sanitizer"
|
|
27
34
|
require_relative "tmux/molecules/config_loader"
|
|
35
|
+
require_relative "tmux/molecules/local_process_inspector"
|
|
28
36
|
require_relative "tmux/molecules/preset_loader"
|
|
29
37
|
require_relative "tmux/molecules/tmux_executor"
|
|
38
|
+
require_relative "tmux/molecules/runtime_target_resolver"
|
|
30
39
|
require_relative "tmux/molecules/session_builder"
|
|
40
|
+
require_relative "tmux/molecules/wait_condition_validator"
|
|
41
|
+
require_relative "tmux/organisms/control_surface"
|
|
31
42
|
require_relative "tmux/organisms/session_manager"
|
|
32
43
|
require_relative "tmux/organisms/window_manager"
|
|
33
44
|
require_relative "tmux/cli"
|