ace-tmux 0.17.5 → 0.19.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: 47d3f7e66d05f81a0a8a043b082db3e2cd918d9b277b458c34e905c05c143c1e
4
- data.tar.gz: b37138140b2c607cf8e34071a0b5735b9be7f8476778f1120478409452288b54
3
+ metadata.gz: c0536170a8bdb58035764cfe894282b86535192375a8933b85f6900d7a089197
4
+ data.tar.gz: 898348819cbbd6d92189be96e0d3a29d80b2d8582d66e6f56e544bd604a35ae9
5
5
  SHA512:
6
- metadata.gz: b06256b52d87757b7e8240b96ee3303250fae755f1f30ae839b97ad4a530c799fa27bd6a3a2e266d9274c992b4de11f9619feda58dd6159c1937474c149fa943
7
- data.tar.gz: 4c1f2f7bb03d4bbb3a1527bdd041fda9cb95eb3f1890a29771ffb79a43a0cd421a818298cec3ea0406ebd0087b8a76674e57889e74eae17e9c187a5206fed0a0
6
+ metadata.gz: b8cb0173511b48a1d99cc18cea00bb0c61fe556228e9cb281f1123cfb798e5fb619c6b94b5c7264576ba53d3c0b4557833c425c523fb9dfda18207994d6d0348
7
+ data.tar.gz: 240d71c6487b3e81a327c62ac43d169b8530bd5eb4ab34b2dbc8bf0b5c5f733926869fcabc03f3ddc048556a0e392e1c47ba269d6b7e7d9fd09b0d15c88bedee
data/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.0] - 2026-10-05
11
+
12
+ ### Added
13
+
14
+ - Expose native session/pane/shell identity for managed assignment owner verification.
15
+
16
+ ### Changed
17
+
18
+ - Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-runtime ~> 0.2`.
19
+
20
+ ## [0.18.0] - 2026-10-02
21
+
22
+ ### Added
23
+ - 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.
24
+
25
+ ### Fixed
26
+ - Send adapter message text literally, classify missing pane targets, and use bounded polling plus output settling for agent completion waits.
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+
10
32
 
11
33
  ## [0.17.5] - 2026-09-02
12
34
 
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,334 @@
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 process_target(pane)
36
+ value = query(B.display_message_target(pane, "\#{session_id}\t\#{pane_id}\t\#{pane_pid}", tmux: tmux))
37
+ session, id, pid = value.split("\t", -1)
38
+ return nil unless session.to_s.match?(/\A\$[0-9]+\z/) && id.to_s.match?(/\A%[0-9]+\z/) &&
39
+ pid.to_s.match?(/\A[0-9]+\z/)
40
+
41
+ {"session" => session, "pane" => id, "shell_pid" => pid.to_i}
42
+ end
43
+
44
+ def available!
45
+ raise Ace::Tmux::NotInTmuxError unless executor.tmux_available?(tmux: tmux)
46
+
47
+ resolver.resolve_session
48
+ true
49
+ end
50
+
51
+ def window_info(name)
52
+ entry = windows.find { |window| window[:name] == name || window[:id] == name }
53
+ return nil unless entry
54
+
55
+ root = option(entry[:id], "@ace_runtime_root")
56
+ root ||= query(B.display_message_target(entry[:id], "\#{pane_current_path}", tmux: tmux))
57
+ preset = option(entry[:id], "@ace_runtime_preset")
58
+ {id: entry[:id], root: root, preset: preset}
59
+ end
60
+
61
+ def create_window(name:, root:, preset: nil)
62
+ session = resolver.resolve_session.session
63
+ if preset
64
+ loader = Molecules::PresetLoader.new(gem_root: Ace::Tmux.gem_root)
65
+ builder = Molecules::SessionBuilder.new(preset_loader: loader)
66
+ begin
67
+ Organisms::WindowManager.new(executor: executor, session_builder: builder, tmux: tmux)
68
+ .add_window(preset, session: session, root: root, name: name)
69
+ rescue Ace::Tmux::Error
70
+ raise
71
+ rescue StandardError => e
72
+ raise Ace::Tmux::Error, "preset window creation failed: #{e.message}"
73
+ end
74
+ id = window_info(name)[:id]
75
+ else
76
+ id = query(B.new_window(session, name: name, root: root, print_format: "\#{window_id}", tmux: tmux))
77
+ end
78
+ run!(B.set_window_option(id, "@ace_runtime_root", File.expand_path(root), tmux: tmux))
79
+ run!(B.set_window_option(id, "@ace_runtime_preset", preset, tmux: tmux)) if preset
80
+ id
81
+ end
82
+
83
+ def panes_for(window)
84
+ target = resolve_window!(window)
85
+ result = executor.capture(B.list_panes(target, format: PANE_FORMAT, tmux: tmux))
86
+ unless result.success?
87
+ detail = result.stderr.to_s
88
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
89
+
90
+ raise Ace::Tmux::Error, detail
91
+ end
92
+ result.stdout.lines.filter_map do |line|
93
+ parts = line.chomp.split("\t", -1)
94
+ next unless parts.length == 9
95
+
96
+ pane = parts[1]
97
+ {pane: pane, keep_alive: option(pane, "@ace_runtime_prepared", pane: true) == "1",
98
+ alive: query(B.display_message_target(pane, "\#{pane_dead}", tmux: tmux)) != "1"}
99
+ end
100
+ end
101
+
102
+ def split_window(window)
103
+ target = resolve_window!(window)
104
+ query(B.split_window(target, root: window_info(window)[:root], print_format: "\#{pane_id}", tmux: tmux))
105
+ end
106
+
107
+ def set_keep_alive(pane, value)
108
+ run!(B.set_pane_option(pane, "remain-on-exit", value ? "on" : "off", tmux: tmux))
109
+ run!(B.set_pane_option(pane, "@ace_runtime_prepared", value ? "1" : "0", tmux: tmux))
110
+ end
111
+
112
+ def focus_window(window)
113
+ run!(B.select_window(resolve_window!(window), tmux: tmux))
114
+ end
115
+
116
+ def write_text(pane, text)
117
+ # -- terminates option parsing so messages starting with "-" are
118
+ # delivered literally.
119
+ run!([tmux, "send-keys", "-l", "-t", pane, "--", text])
120
+ @pending_text_pane = pane
121
+ end
122
+
123
+ def press_key(pane, key)
124
+ if @pending_text_pane == pane && Atoms::NamedKeyRegistry.normalize(key) == "Enter"
125
+ profile = surface.send(:fetch_pane_profile, pane)
126
+ sleep(Organisms::ControlSurface::INTERACTIVE_SUBMIT_DELAY) if profile[:interactive_cli]
127
+ end
128
+ normalized = Atoms::NamedKeyRegistry.normalize(key)
129
+ run!(B.send_raw_keys(pane, normalized, tmux: tmux))
130
+ @pending_text_pane = nil
131
+ end
132
+
133
+ def capture_output(pane, lines:)
134
+ surface.capture_recent_output(pane: pane, lines: lines)
135
+ rescue Ace::Tmux::Error
136
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" unless pane_exists?(pane)
137
+
138
+ raise
139
+ end
140
+
141
+ def agent_state(pane)
142
+ agent_state_from_output(capture_output(pane, lines: 40))
143
+ end
144
+
145
+ def agent_state_from_output(output)
146
+ busy = Organisms::ControlSurface::INTERACTIVE_BUSY_PATTERNS.any? { |pattern| output.match?(pattern) }
147
+ busy ? "working" : "idle"
148
+ end
149
+
150
+ def wait_agent(pane:, states:, timeout:)
151
+ desired = Array(states).map(&:to_s)
152
+ completion_states = %w[idle done working-done ready]
153
+ supported_states = completion_states + ["working"]
154
+ if desired.empty? || (desired - supported_states).any?
155
+ raise ArgumentError, "agent states must be one or more of: #{supported_states.join(", ")}"
156
+ end
157
+
158
+ interval = (timeout.to_f / 5).clamp(0.02, 0.2)
159
+ # The stability+settle completion wait only supports interactive CLI
160
+ # panes and only observes completion. Shell panes and any requested
161
+ # "working" state must poll the observed state instead. Shell panes
162
+ # only ever observe idle/working, so completion aliases read as idle.
163
+ if (desired - completion_states).empty? && interactive_cli_pane?(pane)
164
+ return surface.wait_for_condition(
165
+ condition: "agent", pane: pane, timeout: timeout,
166
+ interval: interval,
167
+ settle: [timeout.to_f / 2, 1.0].min
168
+ )
169
+ end
170
+
171
+ # Shell panes only ever observe idle/working, so completion
172
+ # aliases read as idle.
173
+ observable = desired.flat_map { |state| %w[idle working].include?(state) ? [state] : %w[idle] }.uniq
174
+ poll_agent_states(pane, observable, interval: interval, timeout: timeout)
175
+ end
176
+
177
+ def validate_key!(key)
178
+ Atoms::NamedKeyRegistry.normalize(key)
179
+ rescue Ace::Tmux::ValidationError => e
180
+ raise Ace::Runtime::SendRejectedError, e.message
181
+ end
182
+
183
+ def lifecycle?(condition, target)
184
+ case condition
185
+ when "window-exists" then !window_info(target).nil?
186
+ when "window-active" then windows.any? { |entry| (entry[:name] == target || entry[:id] == target) && entry[:active] }
187
+ when "pane-exists" then pane_exists?(target)
188
+ when "pane-exited" then pane_exited?(target)
189
+ end
190
+ end
191
+
192
+ def kill_window(window)
193
+ run!([tmux, "kill-window", "-t", resolve_window!(window)])
194
+ end
195
+
196
+ def windows
197
+ session = resolver.resolve_session.session
198
+ output = query(B.list_windows(session, format: WINDOW_FORMAT, tmux: tmux))
199
+ output.lines.filter_map do |line|
200
+ active, id, _session, _index, name, _panes = line.chomp.split("\t", 6)
201
+ {name: name, id: id, active: active == "1"} if id && name
202
+ end
203
+ end
204
+
205
+ private
206
+
207
+ attr_reader :executor, :env, :tmux, :resolver, :surface
208
+
209
+ def interactive_cli_pane?(pane)
210
+ surface.send(:fetch_pane_profile, pane)[:interactive_cli]
211
+ rescue Ace::Tmux::Error
212
+ false
213
+ end
214
+
215
+ # Shell-pane wait: a working observation matches immediately; a
216
+ # completion observation only counts once pane output has held
217
+ # still for the settle window (the documented output-stability
218
+ # heuristic) so a running command never reads as done.
219
+ def poll_agent_states(pane, observable, interval:, timeout:)
220
+ settle = [timeout.to_f / 2, 1.0].min
221
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout.to_f
222
+ current_output = capture_output(pane, lines: 40)
223
+ stable_since = nil
224
+ loop do
225
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
226
+ state = agent_state_from_output(current_output)
227
+ if observable.include?(state)
228
+ return true if state == "working" && now < deadline
229
+
230
+ stable_since ||= now
231
+ return true if now < deadline && now - stable_since >= settle
232
+ else
233
+ stable_since = nil
234
+ end
235
+ raise Ace::Tmux::WaitTimeoutError if now >= deadline
236
+
237
+ sleep(interval)
238
+ fresh_output = capture_output(pane, lines: 40)
239
+ stable_since = nil if fresh_output != current_output
240
+ current_output = fresh_output
241
+ end
242
+ end
243
+
244
+ # Pane absence and a dead pane both satisfy the pane-exited
245
+ # observation; any other query failure is a runtime error, never
246
+ # silent evidence of exit.
247
+ def pane_exited?(target)
248
+ result = executor.capture(B.display_message_target(target, "\#{pane_dead}", tmux: tmux))
249
+ return true if !result.success? && result.stderr.to_s.match?(TARGET_ERROR_PATTERN)
250
+ raise Ace::Tmux::Error, result.stderr.to_s unless result.success?
251
+
252
+ result.stdout.to_s.strip == "1"
253
+ end
254
+
255
+ # A missing pane must not erase an explicitly available session or
256
+ # window from the reported context.
257
+ def context_window(session)
258
+ return nil if env["TMUX"].to_s.empty? && env["ACE_TMUX_WINDOW"].to_s.empty?
259
+
260
+ resolver.resolve_window(session: session).window
261
+ rescue Ace::Tmux::TargetResolutionError
262
+ nil
263
+ end
264
+
265
+ def context_pane(session, window)
266
+ return nil unless window
267
+
268
+ resolver.resolve_pane(session: session, window: window).pane_target
269
+ rescue Ace::Tmux::TargetResolutionError
270
+ nil
271
+ end
272
+
273
+ def pane_exists?(pane)
274
+ result = executor.capture(B.display_message_target(pane, "\#{pane_id}", tmux: tmux))
275
+ return false if !result.success? && result.stderr.to_s.match?(TARGET_ERROR_PATTERN)
276
+ raise Ace::Tmux::Error, result.stderr.to_s unless result.success?
277
+
278
+ !result.stdout.to_s.strip.empty?
279
+ end
280
+
281
+ def resolve_window!(window)
282
+ info = window_info(window)
283
+ raise Ace::Tmux::TargetResolutionError, "window target is unavailable" unless info
284
+
285
+ info[:id]
286
+ end
287
+
288
+ def option(target, name, pane: false)
289
+ command = [tmux, "show-options", "-v"]
290
+ command << "-p" if pane
291
+ # Runtime window marks live in the window scope; without -w tmux
292
+ # reports the option as unset and idempotency checks conflict.
293
+ command << "-w" unless pane
294
+ result = executor.capture(command + ["-t", target, name])
295
+ if result.success?
296
+ value = result.stdout.to_s.strip
297
+ value.empty? ? nil : value
298
+ else
299
+ detail = result.stderr.to_s
300
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
301
+ # tmux exits non-zero for an unset option — with quiet output or
302
+ # "invalid option: NAME" for unset user options.
303
+ return nil if detail.strip.empty? || detail.match?(/invalid option/i)
304
+
305
+ raise Ace::Tmux::Error, detail
306
+ end
307
+ end
308
+
309
+ def query(command)
310
+ result = executor.capture(command)
311
+ unless result.success?
312
+ detail = result.stderr.to_s
313
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
314
+
315
+ raise Ace::Tmux::Error, detail
316
+ end
317
+
318
+ result.stdout.to_s.strip
319
+ end
320
+
321
+ def run!(command)
322
+ result = executor.capture(command)
323
+ unless result.success?
324
+ detail = result.stderr.to_s
325
+ raise Ace::Tmux::TargetResolutionError, "tmux target is unavailable" if detail.match?(TARGET_ERROR_PATTERN)
326
+
327
+ raise Ace::Tmux::Error, detail
328
+ end
329
+
330
+ true
331
+ end
332
+ end
333
+ end
334
+ 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,192 @@
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, process_identity: Ace::Runtime::Molecules::ProcessIdentity.new)
11
+ @backend = backend
12
+ @process_identity = process_identity
13
+ end
14
+
15
+ def send_profile = :plain_pane
16
+
17
+ def context
18
+ backend.context
19
+ end
20
+
21
+ def process_binding(pane:, caller_pid:)
22
+ return nil unless caller_pid.is_a?(Integer) && caller_pid.positive?
23
+
24
+ with_errors do
25
+ target = backend.process_target(pane)
26
+ next nil unless target
27
+
28
+ owner = @process_identity.owner(shell_pid: target["shell_pid"], caller_pid: caller_pid)
29
+ next nil unless owner
30
+
31
+ target.slice("session", "pane").merge("runtime" => "tmux").merge(owner)
32
+ end
33
+ end
34
+
35
+ def ensure_window(name:, root:, preset: nil)
36
+ with_errors do
37
+ backend.available!
38
+ normalized = Ace::Runtime.sanitize_name(name)
39
+ existing = backend.window_info(normalized)
40
+ if existing
41
+ if File.expand_path(existing[:root]) != File.expand_path(root) || existing[:preset] != preset
42
+ raise Ace::Runtime::WindowConflictError, "window '#{normalized}' has a different root or preset"
43
+ end
44
+ return existing[:id]
45
+ end
46
+
47
+ backend.create_window(name: normalized, root: root, preset: preset)
48
+ end
49
+ end
50
+
51
+ def prepare_pane(window:)
52
+ with_errors do
53
+ backend.available!
54
+ raise Ace::Runtime::TargetNotFoundError, "window is unavailable" unless backend.window_info(window)
55
+
56
+ reusable = backend.panes_for(window).find { |pane| pane[:keep_alive] && pane[:alive] }
57
+ return reusable[:pane] if reusable
58
+
59
+ pane = backend.split_window(window)
60
+ backend.set_keep_alive(pane, true)
61
+ pane
62
+ end
63
+ end
64
+
65
+ def focus(window:)
66
+ with_errors { backend.focus_window(window) }
67
+ end
68
+
69
+ def send(pane:, command: nil, items: [])
70
+ request = SEND.normalize!(command: command, items: items, profile: send_profile)
71
+ # Validate the whole key sequence before any transport call so a
72
+ # rejected key can never leave a partially delivered submission.
73
+ request.items.each { |item| backend.validate_key!(item[:key]) if item.key?(:key) }
74
+ with_errors do
75
+ backend.available!
76
+ if request.command
77
+ backend.write_text(pane, request.command)
78
+ backend.press_key(pane, "Enter")
79
+ end
80
+ request.items.each do |item|
81
+ if item.key?(:message)
82
+ backend.write_text(pane, item[:message])
83
+ else
84
+ backend.press_key(pane, item[:key])
85
+ end
86
+ end
87
+ SEND::Result.new(dropped_trailing_enter: false)
88
+ end
89
+ end
90
+
91
+ def send_command(pane:, command:) = send(pane: pane, command: command)
92
+ def send_text(pane:, text:) = send(pane: pane, items: [{message: text}])
93
+ def send_keys(pane:, keys:) = send(pane: pane, items: Array(keys).map { |key| {key: key} })
94
+
95
+ def capture(pane:, lines: 40)
96
+ with_errors { backend.capture_output(pane, lines: lines) }
97
+ end
98
+
99
+ def wait_output(pane:, pattern:, timeout:)
100
+ raise ArgumentError, "pattern must be present" if pattern.nil? || pattern.to_s.empty?
101
+
102
+ wait_until(condition: "output", target: pane, timeout: timeout) do
103
+ backend.capture_output(pane, lines: 40).include?(pattern.to_s)
104
+ end
105
+ end
106
+
107
+ def wait_agent(pane:, states:, timeout:)
108
+ desired = Array(states).map(&:to_s)
109
+ if backend.respond_to?(:wait_agent)
110
+ return with_errors do
111
+ backend.available!
112
+ backend.wait_agent(pane: pane, states: desired, timeout: timeout)
113
+ end
114
+ end
115
+
116
+ wait_until(condition: "agent", target: pane, timeout: timeout) do
117
+ desired.include?(backend.agent_state(pane).to_s)
118
+ end
119
+ rescue Ace::Tmux::WaitTimeoutError
120
+ raise Ace::Runtime::WaitTimeoutError.new(condition: "agent", target: pane, timeout: timeout)
121
+ end
122
+
123
+ def wait_lifecycle(condition:, target:, timeout:)
124
+ unless LIFECYCLE.include?(condition.to_s)
125
+ raise ArgumentError, "lifecycle condition must be one of: #{LIFECYCLE.join(", ")}"
126
+ end
127
+
128
+ wait_until(condition: condition, target: target, timeout: timeout) do
129
+ backend.lifecycle?(condition.to_s, target)
130
+ end
131
+ end
132
+
133
+ def close_window(window:)
134
+ with_errors { backend.kill_window(window) }
135
+ end
136
+
137
+ def list_windows
138
+ with_errors { backend.windows }
139
+ end
140
+
141
+ def list_panes(window:)
142
+ with_errors do
143
+ backend.available!
144
+ next [] unless backend.window_info(window)
145
+
146
+ backend.panes_for(window).map { |entry| {pane: entry[:pane]} }
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ attr_reader :backend
153
+
154
+ def wait_until(condition:, target:, timeout:)
155
+ duration = Float(timeout)
156
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + duration
157
+ loop do
158
+ observed = with_errors do
159
+ backend.available!
160
+ yield
161
+ end
162
+ # The clock is read after the observation so a slow observation
163
+ # cannot accept a match that completed past the deadline.
164
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ return true if observed && now < deadline
166
+ break if now >= deadline
167
+
168
+ sleep([0.02, duration].min)
169
+ end
170
+ raise Ace::Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout)
171
+ end
172
+
173
+ def with_errors
174
+ yield
175
+ rescue Ace::Runtime::Error
176
+ raise
177
+ rescue Errno::ENOENT, Ace::Tmux::NotInTmuxError
178
+ raise Ace::Runtime::RuntimeUnavailableError, "tmux runtime is unavailable"
179
+ rescue Ace::Tmux::TargetResolutionError
180
+ raise Ace::Runtime::TargetNotFoundError, "tmux target is unavailable"
181
+ rescue Ace::Tmux::WaitTimeoutError
182
+ raise
183
+ rescue Ace::Tmux::Error
184
+ raise Ace::Runtime::RuntimeUnavailableError, "tmux runtime is unavailable"
185
+ rescue => e
186
+ raise backend.translate_error(e) if backend.respond_to?(:translate_error)
187
+
188
+ raise
189
+ end
190
+ end
191
+ end
192
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Tmux
5
- VERSION = "0.17.5"
5
+ VERSION = "0.19.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.5
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-05 00:00:00.000000000 Z
10
+ date: 2026-10-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -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.2'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.2'
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: