ace-tmux 0.17.4 → 0.18.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd0841f008a23f5d6bd4e3b8c47ee6ef43ce3a684762180f54675fd32c005275
4
- data.tar.gz: dfd3583ee2bc1d9d4e6f0c513b5f1c11a19bbb0ad7ee37a6197a113a501bbbc0
3
+ metadata.gz: 6f49cb24a7e74232499e414200b2b9122a9b4562ad9ed6679280a953454d2f52
4
+ data.tar.gz: 35e3a824e53a52913031f4c25ca99265d1a15ab5fd49d5240d80517c5be7b348
5
5
  SHA512:
6
- metadata.gz: fdf18ccfaa51fe46d54937501980a7980f41d15ced3cdfdfb59090f3008aad5435596450f59a52984d9da19e3d425f6e47a0f684ddd4486c1bee51f7a82bd450
7
- data.tar.gz: 453856e633c24370ad847d2cd0b22539adb519f5244aab40acd9540f8e53fa86d3068a1290ee220f79f143890ae011703c687169fb574da31321ca3012b389d6
6
+ metadata.gz: 73bebcbb321f90292398aba4a6c21873e4638277d8e452a27001f46d6c4d62fe858e5acd5eb53e09093a8e99b68d16613585ae1d20172a8d298a786487d35c55
7
+ data.tar.gz: facc9d92c39f2742bcf922f4cc6c55cb4abd8b498905bd04078c5b55c7fe5fa1754d14edc09c57f578f5dbfacaa6e5de0e58d67f7c6c5f12da1355805235173e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.18.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - Added the `ace-runtime` tmux adapter, exposing window, pane, send, capture, wait, and listing intents through the shared runtime contract while retaining the existing `ace-tmux` control surface.
14
+
15
+ ### Fixed
16
+ - Send adapter message text literally, classify missing pane targets, and use bounded polling plus output settling for agent completion waits.
17
+ - Agent completion waits poll shell panes and any mixed requested states instead of requiring an interactive CLI pane, and `working` now matches immediately in mixed requests.
18
+ - Shell-pane waits accept only after the monotonic deadline, stability timing starts at the first matching observation, unset option reads are distinguished from transport errors, literal send messages terminate option parsing, and pane capture includes the visible screen.
19
+ - Window option reads use the window scope (`-w`) with trimmed values so idempotent `ensure_window` works across calls, shell-pane completion aliases resolve to the observable idle state, and context reporting no longer drops an explicit session when the pane cannot be resolved.
20
+ - Shell-pane agent waits require pane output to hold still before reporting completion, `pane-exited` distinguishes absent panes from failed queries, and the full send key sequence is validated before any transport with ctrl-chord keys supported.
21
+
22
+
23
+ ## [0.17.5] - 2026-09-02
24
+
25
+ ### Technical
26
+ - Included in the coordinated all-package patch release preparation for ACE monorepo review.
10
27
  ## [0.17.4] - 2026-08-12
11
28
 
12
29
  ### Technical
data/README.md CHANGED
@@ -36,6 +36,8 @@
36
36
 
37
37
  **Control tmux through a shared ACE contract** - run `ace-tmux send`, `capture`, `wait`, `attach`, and `detach` when higher-level ACE tools or operators need live pane/session control without re-implementing tmux wrappers.
38
38
 
39
+ The `ace-runtime` library can also resolve the tmux adapter with `Ace::Runtime.resolve("tmux")`. It keeps tmux targets and environment resolution inside this gem, and exposes the shared window, pane, send, capture, and wait methods to runtime-neutral callers. A named window is reused when its root and preset match; `prepare_pane` creates a retained shell pane when no prepared live pane exists.
40
+
39
41
  **Compose nested pane layouts in YAML** - use `direction` and nested pane containers to model custom split trees beyond tmux built-in layouts, keeping workspace structure version-controlled alongside your project.
40
42
 
41
43
  **Reuse presets through config cascade** - load project presets from `.ace/tmux/`, personal presets from `~/.ace/tmux/`, and gem defaults via `.ace-defaults/tmux/` with deep-merge behavior so teams share a baseline while individuals customize.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/tmux"
4
+
5
+ Ace::Runtime.register(:tmux, -> { Ace::Tmux::RuntimeAdapter.new })
@@ -24,9 +24,12 @@ module Ace
24
24
  raise Ace::Tmux::ValidationError, "Named key is required" if value.empty?
25
25
 
26
26
  normalized = KEYS[value.downcase]
27
- raise Ace::Tmux::ValidationError, "Unsupported named key: #{key}" unless normalized
27
+ return normalized if normalized
28
28
 
29
- normalized
29
+ chord = value.downcase[/\Ac-([a-z])\z/, 1]
30
+ return "C-#{chord.upcase}" if chord
31
+
32
+ raise Ace::Tmux::ValidationError, "Unsupported named key: #{key}"
30
33
  end
31
34
  end
32
35
  end
@@ -211,7 +211,9 @@ module Ace
211
211
 
212
212
  def capture_pane(target, lines: 40, tmux: "tmux")
213
213
  start = -Integer(lines.to_i.abs)
214
- [tmux, "capture-pane", "-p", "-t", target, "-S", start.to_s, "-E", "-1"]
214
+ # No -E: capture ends at the visible bottom. -E -1 would stop a
215
+ # line ABOVE the visible screen and exclude it entirely.
216
+ [tmux, "capture-pane", "-p", "-t", target, "-S", start.to_s]
215
217
  end
216
218
 
217
219
  def capture_pane_visible(target, start_line:, end_line:, include_alternate: false, tmux: "tmux")
@@ -191,13 +191,13 @@ module Ace
191
191
  end
192
192
 
193
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? }
194
+ id, index, name, active = line.to_s.chomp.split("\t", 4)
195
+ return nil if [id, index, name].any? { |value| value.to_s.strip.empty? }
196
196
 
197
197
  {
198
198
  id: id,
199
199
  index: index,
200
- name: name,
200
+ name: name.to_s.strip,
201
201
  active: active == "1"
202
202
  }
203
203
  end
@@ -18,8 +18,10 @@ module Ace
18
18
  # @return [ExecutionResult] Result with stdout, stderr, success?
19
19
  def capture(cmd)
20
20
  stdout, stderr, status = Open3.capture3(*with_socket_target(cmd))
21
+ # stdout stays raw: pane capture must preserve whitespace and the
22
+ # trailing newline; parsing callers trim at their own boundary.
21
23
  ExecutionResult.new(
22
- stdout: stdout.strip,
24
+ stdout: stdout,
23
25
  stderr: stderr.strip,
24
26
  success: status.success?,
25
27
  exit_code: status.exitstatus
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ # Maps runtime intents onto the existing tmux executor and control surface.
6
+ class NativeRuntimeBackend
7
+ B = Atoms::TmuxCommandBuilder
8
+ # tmux target errors are phrased can't-find/unknown-target; bare "no
9
+ # such" also matches socket connection failures and must not classify
10
+ # as a missing target.
11
+ TARGET_ERROR_PATTERN = /can't find|unknown target|not found/i
12
+ WINDOW_FORMAT = Organisms::ControlSurface::WINDOW_LIST_FORMAT
13
+ PANE_FORMAT = Organisms::ControlSurface::PANE_LIST_FORMAT
14
+
15
+ def initialize(executor: Molecules::TmuxExecutor.new, env: ENV, tmux: "tmux", surface: nil)
16
+ @executor = executor
17
+ @env = env
18
+ @tmux = tmux
19
+ @resolver = Molecules::RuntimeTargetResolver.new(executor: executor, tmux: tmux, env: env)
20
+ @surface = surface || Organisms::ControlSurface.new(executor: executor, resolver: @resolver, tmux: tmux)
21
+ end
22
+
23
+ def context
24
+ present = !env["TMUX"].to_s.empty? || !env["ACE_TMUX_SESSION"].to_s.empty?
25
+ return {in_runtime: false, session: nil, window: nil, pane: nil} unless present
26
+
27
+ session = resolver.resolve_session.session
28
+ window = context_window(session)
29
+ pane = context_pane(session, window)
30
+ {in_runtime: true, session: session, window: window, pane: pane}
31
+ rescue Ace::Tmux::TargetResolutionError
32
+ {in_runtime: false, session: nil, window: nil, pane: nil}
33
+ end
34
+
35
+ def available!
36
+ raise Ace::Tmux::NotInTmuxError unless executor.tmux_available?(tmux: tmux)
37
+
38
+ resolver.resolve_session
39
+ true
40
+ end
41
+
42
+ def window_info(name)
43
+ entry = windows.find { |window| window[:name] == name || window[:id] == name }
44
+ return nil unless entry
45
+
46
+ root = option(entry[:id], "@ace_runtime_root")
47
+ root ||= query(B.display_message_target(entry[:id], "\#{pane_current_path}", tmux: tmux))
48
+ preset = option(entry[:id], "@ace_runtime_preset")
49
+ {id: entry[:id], root: root, preset: preset}
50
+ end
51
+
52
+ def create_window(name:, root:, preset: nil)
53
+ session = resolver.resolve_session.session
54
+ if preset
55
+ loader = Molecules::PresetLoader.new(gem_root: Ace::Tmux.gem_root)
56
+ builder = Molecules::SessionBuilder.new(preset_loader: loader)
57
+ begin
58
+ Organisms::WindowManager.new(executor: executor, session_builder: builder, tmux: tmux)
59
+ .add_window(preset, session: session, root: root, name: name)
60
+ rescue Ace::Tmux::Error
61
+ raise
62
+ rescue StandardError => e
63
+ raise Ace::Tmux::Error, "preset window creation failed: #{e.message}"
64
+ end
65
+ id = window_info(name)[:id]
66
+ else
67
+ id = query(B.new_window(session, name: name, root: root, print_format: "\#{window_id}", tmux: tmux))
68
+ end
69
+ run!(B.set_window_option(id, "@ace_runtime_root", File.expand_path(root), tmux: tmux))
70
+ run!(B.set_window_option(id, "@ace_runtime_preset", preset, tmux: tmux)) if preset
71
+ id
72
+ end
73
+
74
+ def panes_for(window)
75
+ target = resolve_window!(window)
76
+ result = executor.capture(B.list_panes(target, format: PANE_FORMAT, tmux: tmux))
77
+ unless result.success?
78
+ detail = result.stderr.to_s
79
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
80
+
81
+ raise Ace::Tmux::Error, detail
82
+ end
83
+ result.stdout.lines.filter_map do |line|
84
+ parts = line.chomp.split("\t", -1)
85
+ next unless parts.length == 9
86
+
87
+ pane = parts[1]
88
+ {pane: pane, keep_alive: option(pane, "@ace_runtime_prepared", pane: true) == "1",
89
+ alive: query(B.display_message_target(pane, "\#{pane_dead}", tmux: tmux)) != "1"}
90
+ end
91
+ end
92
+
93
+ def split_window(window)
94
+ target = resolve_window!(window)
95
+ query(B.split_window(target, root: window_info(window)[:root], print_format: "\#{pane_id}", tmux: tmux))
96
+ end
97
+
98
+ def set_keep_alive(pane, value)
99
+ run!(B.set_pane_option(pane, "remain-on-exit", value ? "on" : "off", tmux: tmux))
100
+ run!(B.set_pane_option(pane, "@ace_runtime_prepared", value ? "1" : "0", tmux: tmux))
101
+ end
102
+
103
+ def focus_window(window)
104
+ run!(B.select_window(resolve_window!(window), tmux: tmux))
105
+ end
106
+
107
+ def write_text(pane, text)
108
+ # -- terminates option parsing so messages starting with "-" are
109
+ # delivered literally.
110
+ run!([tmux, "send-keys", "-l", "-t", pane, "--", text])
111
+ @pending_text_pane = pane
112
+ end
113
+
114
+ def press_key(pane, key)
115
+ if @pending_text_pane == pane && Atoms::NamedKeyRegistry.normalize(key) == "Enter"
116
+ profile = surface.send(:fetch_pane_profile, pane)
117
+ sleep(Organisms::ControlSurface::INTERACTIVE_SUBMIT_DELAY) if profile[:interactive_cli]
118
+ end
119
+ normalized = Atoms::NamedKeyRegistry.normalize(key)
120
+ run!(B.send_raw_keys(pane, normalized, tmux: tmux))
121
+ @pending_text_pane = nil
122
+ end
123
+
124
+ def capture_output(pane, lines:)
125
+ surface.capture_recent_output(pane: pane, lines: lines)
126
+ rescue Ace::Tmux::Error
127
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" unless pane_exists?(pane)
128
+
129
+ raise
130
+ end
131
+
132
+ def agent_state(pane)
133
+ agent_state_from_output(capture_output(pane, lines: 40))
134
+ end
135
+
136
+ def agent_state_from_output(output)
137
+ busy = Organisms::ControlSurface::INTERACTIVE_BUSY_PATTERNS.any? { |pattern| output.match?(pattern) }
138
+ busy ? "working" : "idle"
139
+ end
140
+
141
+ def wait_agent(pane:, states:, timeout:)
142
+ desired = Array(states).map(&:to_s)
143
+ completion_states = %w[idle done working-done ready]
144
+ supported_states = completion_states + ["working"]
145
+ if desired.empty? || (desired - supported_states).any?
146
+ raise ArgumentError, "agent states must be one or more of: #{supported_states.join(", ")}"
147
+ end
148
+
149
+ interval = (timeout.to_f / 5).clamp(0.02, 0.2)
150
+ # The stability+settle completion wait only supports interactive CLI
151
+ # panes and only observes completion. Shell panes and any requested
152
+ # "working" state must poll the observed state instead. Shell panes
153
+ # only ever observe idle/working, so completion aliases read as idle.
154
+ if (desired - completion_states).empty? && interactive_cli_pane?(pane)
155
+ return surface.wait_for_condition(
156
+ condition: "agent", pane: pane, timeout: timeout,
157
+ interval: interval,
158
+ settle: [timeout.to_f / 2, 1.0].min
159
+ )
160
+ end
161
+
162
+ # Shell panes only ever observe idle/working, so completion
163
+ # aliases read as idle.
164
+ observable = desired.flat_map { |state| %w[idle working].include?(state) ? [state] : %w[idle] }.uniq
165
+ poll_agent_states(pane, observable, interval: interval, timeout: timeout)
166
+ end
167
+
168
+ def validate_key!(key)
169
+ Atoms::NamedKeyRegistry.normalize(key)
170
+ rescue Ace::Tmux::ValidationError => e
171
+ raise Ace::Runtime::SendRejectedError, e.message
172
+ end
173
+
174
+ def lifecycle?(condition, target)
175
+ case condition
176
+ when "window-exists" then !window_info(target).nil?
177
+ when "window-active" then windows.any? { |entry| (entry[:name] == target || entry[:id] == target) && entry[:active] }
178
+ when "pane-exists" then pane_exists?(target)
179
+ when "pane-exited" then pane_exited?(target)
180
+ end
181
+ end
182
+
183
+ def kill_window(window)
184
+ run!([tmux, "kill-window", "-t", resolve_window!(window)])
185
+ end
186
+
187
+ def windows
188
+ session = resolver.resolve_session.session
189
+ output = query(B.list_windows(session, format: WINDOW_FORMAT, tmux: tmux))
190
+ output.lines.filter_map do |line|
191
+ active, id, _session, _index, name, _panes = line.chomp.split("\t", 6)
192
+ {name: name, id: id, active: active == "1"} if id && name
193
+ end
194
+ end
195
+
196
+ private
197
+
198
+ attr_reader :executor, :env, :tmux, :resolver, :surface
199
+
200
+ def interactive_cli_pane?(pane)
201
+ surface.send(:fetch_pane_profile, pane)[:interactive_cli]
202
+ rescue Ace::Tmux::Error
203
+ false
204
+ end
205
+
206
+ # Shell-pane wait: a working observation matches immediately; a
207
+ # completion observation only counts once pane output has held
208
+ # still for the settle window (the documented output-stability
209
+ # heuristic) so a running command never reads as done.
210
+ def poll_agent_states(pane, observable, interval:, timeout:)
211
+ settle = [timeout.to_f / 2, 1.0].min
212
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
213
+ current_output = capture_output(pane, lines: 40)
214
+ stable_since = nil
215
+ loop do
216
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
217
+ state = agent_state_from_output(current_output)
218
+ if observable.include?(state)
219
+ return true if state == "working" && now < deadline
220
+
221
+ stable_since ||= now
222
+ return true if now < deadline && now - stable_since >= settle
223
+ else
224
+ stable_since = nil
225
+ end
226
+ raise Ace::Tmux::WaitTimeoutError if now >= deadline
227
+
228
+ sleep(interval)
229
+ fresh_output = capture_output(pane, lines: 40)
230
+ stable_since = nil if fresh_output != current_output
231
+ current_output = fresh_output
232
+ end
233
+ end
234
+
235
+ # Pane absence and a dead pane both satisfy the pane-exited
236
+ # observation; any other query failure is a runtime error, never
237
+ # silent evidence of exit.
238
+ def pane_exited?(target)
239
+ result = executor.capture(B.display_message_target(target, "\#{pane_dead}", tmux: tmux))
240
+ return true if !result.success? && result.stderr.to_s.match?(TARGET_ERROR_PATTERN)
241
+ raise Ace::Tmux::Error, result.stderr.to_s unless result.success?
242
+
243
+ result.stdout.to_s.strip == "1"
244
+ end
245
+
246
+ # A missing pane must not erase an explicitly available session or
247
+ # window from the reported context.
248
+ def context_window(session)
249
+ return nil if env["TMUX"].to_s.empty? && env["ACE_TMUX_WINDOW"].to_s.empty?
250
+
251
+ resolver.resolve_window(session: session).window
252
+ rescue Ace::Tmux::TargetResolutionError
253
+ nil
254
+ end
255
+
256
+ def context_pane(session, window)
257
+ return nil unless window
258
+
259
+ resolver.resolve_pane(session: session, window: window).pane_target
260
+ rescue Ace::Tmux::TargetResolutionError
261
+ nil
262
+ end
263
+
264
+ def pane_exists?(pane)
265
+ result = executor.capture(B.display_message_target(pane, "\#{pane_id}", tmux: tmux))
266
+ return false if !result.success? && result.stderr.to_s.match?(TARGET_ERROR_PATTERN)
267
+ raise Ace::Tmux::Error, result.stderr.to_s unless result.success?
268
+
269
+ !result.stdout.to_s.strip.empty?
270
+ end
271
+
272
+ def resolve_window!(window)
273
+ info = window_info(window)
274
+ raise Ace::Tmux::TargetResolutionError, "window target is unavailable" unless info
275
+
276
+ info[:id]
277
+ end
278
+
279
+ def option(target, name, pane: false)
280
+ command = [tmux, "show-options", "-v"]
281
+ command << "-p" if pane
282
+ # Runtime window marks live in the window scope; without -w tmux
283
+ # reports the option as unset and idempotency checks conflict.
284
+ command << "-w" unless pane
285
+ result = executor.capture(command + ["-t", target, name])
286
+ if result.success?
287
+ value = result.stdout.to_s.strip
288
+ value.empty? ? nil : value
289
+ else
290
+ detail = result.stderr.to_s
291
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
292
+ # tmux exits non-zero for an unset option — with quiet output or
293
+ # "invalid option: NAME" for unset user options.
294
+ return nil if detail.strip.empty? || detail.match?(/invalid option/i)
295
+
296
+ raise Ace::Tmux::Error, detail
297
+ end
298
+ end
299
+
300
+ def query(command)
301
+ result = executor.capture(command)
302
+ unless result.success?
303
+ detail = result.stderr.to_s
304
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
305
+
306
+ raise Ace::Tmux::Error, detail
307
+ end
308
+
309
+ result.stdout.to_s.strip
310
+ end
311
+
312
+ def run!(command)
313
+ result = executor.capture(command)
314
+ unless result.success?
315
+ detail = result.stderr.to_s
316
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
317
+
318
+ raise Ace::Tmux::Error, detail
319
+ end
320
+
321
+ true
322
+ end
323
+ end
324
+ end
325
+ end
@@ -235,7 +235,7 @@ module Ace
235
235
  result = executor.capture(
236
236
  Atoms::TmuxCommandBuilder.display_message_target(target.pane_target, '#{pane_dead}', tmux: tmux)
237
237
  )
238
- !result.success? || result.stdout == "1"
238
+ !result.success? || result.stdout.to_s.strip == "1"
239
239
  rescue Ace::Tmux::TargetResolutionError
240
240
  true
241
241
  end
@@ -76,7 +76,7 @@ module Ace
76
76
 
77
77
  cmd = Atoms::TmuxCommandBuilder.display_message("#S", tmux: @tmux)
78
78
  result = @executor.capture(cmd)
79
- result.success? ? result.stdout : nil
79
+ result.success? ? result.stdout.to_s.strip : nil
80
80
  end
81
81
 
82
82
  def setup_panes(window, window_target, base_root = nil)
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Tmux
5
+ # Runtime-neutral intent surface backed by ace-tmux's existing control API.
6
+ class RuntimeAdapter
7
+ SEND = Ace::Runtime::Atoms::SendContract
8
+ LIFECYCLE = SEND::LIFECYCLE_CONDITIONS
9
+
10
+ def initialize(backend: NativeRuntimeBackend.new)
11
+ @backend = backend
12
+ end
13
+
14
+ def send_profile = :plain_pane
15
+
16
+ def context
17
+ backend.context
18
+ end
19
+
20
+ def ensure_window(name:, root:, preset: nil)
21
+ with_errors do
22
+ backend.available!
23
+ normalized = Ace::Runtime.sanitize_name(name)
24
+ existing = backend.window_info(normalized)
25
+ if existing
26
+ if File.expand_path(existing[:root]) != File.expand_path(root) || existing[:preset] != preset
27
+ raise Ace::Runtime::WindowConflictError, "window '#{normalized}' has a different root or preset"
28
+ end
29
+ return existing[:id]
30
+ end
31
+
32
+ backend.create_window(name: normalized, root: root, preset: preset)
33
+ end
34
+ end
35
+
36
+ def prepare_pane(window:)
37
+ with_errors do
38
+ backend.available!
39
+ raise Ace::Runtime::TargetNotFoundError, "window is unavailable" unless backend.window_info(window)
40
+
41
+ reusable = backend.panes_for(window).find { |pane| pane[:keep_alive] && pane[:alive] }
42
+ return reusable[:pane] if reusable
43
+
44
+ pane = backend.split_window(window)
45
+ backend.set_keep_alive(pane, true)
46
+ pane
47
+ end
48
+ end
49
+
50
+ def focus(window:)
51
+ with_errors { backend.focus_window(window) }
52
+ end
53
+
54
+ def send(pane:, command: nil, items: [])
55
+ request = SEND.normalize!(command: command, items: items, profile: send_profile)
56
+ # Validate the whole key sequence before any transport call so a
57
+ # rejected key can never leave a partially delivered submission.
58
+ request.items.each { |item| backend.validate_key!(item[:key]) if item.key?(:key) }
59
+ with_errors do
60
+ backend.available!
61
+ if request.command
62
+ backend.write_text(pane, request.command)
63
+ backend.press_key(pane, "Enter")
64
+ end
65
+ request.items.each do |item|
66
+ if item.key?(:message)
67
+ backend.write_text(pane, item[:message])
68
+ else
69
+ backend.press_key(pane, item[:key])
70
+ end
71
+ end
72
+ SEND::Result.new(dropped_trailing_enter: false)
73
+ end
74
+ end
75
+
76
+ def send_command(pane:, command:) = send(pane: pane, command: command)
77
+ def send_text(pane:, text:) = send(pane: pane, items: [{message: text}])
78
+ def send_keys(pane:, keys:) = send(pane: pane, items: Array(keys).map { |key| {key: key} })
79
+
80
+ def capture(pane:, lines: 40)
81
+ with_errors { backend.capture_output(pane, lines: lines) }
82
+ end
83
+
84
+ def wait_output(pane:, pattern:, timeout:)
85
+ raise ArgumentError, "pattern must be present" if pattern.nil? || pattern.to_s.empty?
86
+
87
+ wait_until(condition: "output", target: pane, timeout: timeout) do
88
+ backend.capture_output(pane, lines: 40).include?(pattern.to_s)
89
+ end
90
+ end
91
+
92
+ def wait_agent(pane:, states:, timeout:)
93
+ desired = Array(states).map(&:to_s)
94
+ if backend.respond_to?(:wait_agent)
95
+ return with_errors do
96
+ backend.available!
97
+ backend.wait_agent(pane: pane, states: desired, timeout: timeout)
98
+ end
99
+ end
100
+
101
+ wait_until(condition: "agent", target: pane, timeout: timeout) do
102
+ desired.include?(backend.agent_state(pane).to_s)
103
+ end
104
+ rescue Ace::Tmux::WaitTimeoutError
105
+ raise Ace::Runtime::WaitTimeoutError.new(condition: "agent", target: pane, timeout: timeout)
106
+ end
107
+
108
+ def wait_lifecycle(condition:, target:, timeout:)
109
+ unless LIFECYCLE.include?(condition.to_s)
110
+ raise ArgumentError, "lifecycle condition must be one of: #{LIFECYCLE.join(", ")}"
111
+ end
112
+
113
+ wait_until(condition: condition, target: target, timeout: timeout) do
114
+ backend.lifecycle?(condition.to_s, target)
115
+ end
116
+ end
117
+
118
+ def close_window(window:)
119
+ with_errors { backend.kill_window(window) }
120
+ end
121
+
122
+ def list_windows
123
+ with_errors { backend.windows }
124
+ end
125
+
126
+ def list_panes(window:)
127
+ with_errors do
128
+ backend.available!
129
+ next [] unless backend.window_info(window)
130
+
131
+ backend.panes_for(window).map { |entry| {pane: entry[:pane]} }
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ attr_reader :backend
138
+
139
+ def wait_until(condition:, target:, timeout:)
140
+ duration = Float(timeout)
141
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + duration
142
+ loop do
143
+ observed = with_errors do
144
+ backend.available!
145
+ yield
146
+ end
147
+ # The clock is read after the observation so a slow observation
148
+ # cannot accept a match that completed past the deadline.
149
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
+ return true if observed && now < deadline
151
+ break if now >= deadline
152
+
153
+ sleep([0.02, duration].min)
154
+ end
155
+ raise Ace::Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout)
156
+ end
157
+
158
+ def with_errors
159
+ yield
160
+ rescue Ace::Runtime::Error
161
+ raise
162
+ rescue Errno::ENOENT, Ace::Tmux::NotInTmuxError
163
+ raise Ace::Runtime::RuntimeUnavailableError, "tmux runtime is unavailable"
164
+ rescue Ace::Tmux::TargetResolutionError
165
+ raise Ace::Runtime::TargetNotFoundError, "tmux target is unavailable"
166
+ rescue Ace::Tmux::WaitTimeoutError
167
+ raise
168
+ rescue Ace::Tmux::Error
169
+ raise Ace::Runtime::RuntimeUnavailableError, "tmux runtime is unavailable"
170
+ rescue => e
171
+ raise backend.translate_error(e) if backend.respond_to?(:translate_error)
172
+
173
+ raise
174
+ end
175
+ end
176
+ end
177
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Tmux
5
- VERSION = "0.17.4"
5
+ VERSION = "0.18.0"
6
6
  end
7
7
  end
data/lib/ace/tmux.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "tmux/version"
4
4
  require "ace/support/config"
5
+ require "ace/runtime"
5
6
 
6
7
  # Define error hierarchy before loading components (they reference these classes)
7
8
  module Ace
@@ -41,6 +42,8 @@ require_relative "tmux/molecules/wait_condition_validator"
41
42
  require_relative "tmux/organisms/control_surface"
42
43
  require_relative "tmux/organisms/session_manager"
43
44
  require_relative "tmux/organisms/window_manager"
45
+ require_relative "tmux/native_runtime_backend"
46
+ require_relative "tmux/runtime_adapter"
44
47
  require_relative "tmux/cli"
45
48
 
46
49
  module Ace
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-tmux
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.4
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-12 00:00:00.000000000 Z
10
+ date: 2026-10-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.17'
46
+ version: '0.18'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.17'
53
+ version: '0.18'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: ace-support-fs
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +65,20 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '0.3'
68
+ - !ruby/object:Gem::Dependency
69
+ name: ace-runtime
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.1'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.1'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: ace-support-test-helpers
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -178,6 +192,7 @@ files:
178
192
  - docs/handbook.md
179
193
  - docs/usage.md
180
194
  - exe/ace-tmux
195
+ - lib/ace/runtime/adapters/tmux.rb
181
196
  - lib/ace/tmux.rb
182
197
  - lib/ace/tmux/atoms/layout_string_builder.rb
183
198
  - lib/ace/tmux/atoms/named_key_registry.rb
@@ -206,9 +221,11 @@ files:
206
221
  - lib/ace/tmux/molecules/session_builder.rb
207
222
  - lib/ace/tmux/molecules/tmux_executor.rb
208
223
  - lib/ace/tmux/molecules/wait_condition_validator.rb
224
+ - lib/ace/tmux/native_runtime_backend.rb
209
225
  - lib/ace/tmux/organisms/control_surface.rb
210
226
  - lib/ace/tmux/organisms/session_manager.rb
211
227
  - lib/ace/tmux/organisms/window_manager.rb
228
+ - lib/ace/tmux/runtime_adapter.rb
212
229
  - lib/ace/tmux/version.rb
213
230
  homepage: https://github.com/cs3b/ace
214
231
  licenses: