ace-tmux 0.17.5 → 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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +2 -0
- data/lib/ace/runtime/adapters/tmux.rb +5 -0
- data/lib/ace/tmux/atoms/named_key_registry.rb +5 -2
- data/lib/ace/tmux/atoms/tmux_command_builder.rb +3 -1
- data/lib/ace/tmux/molecules/runtime_target_resolver.rb +3 -3
- data/lib/ace/tmux/molecules/tmux_executor.rb +3 -1
- data/lib/ace/tmux/native_runtime_backend.rb +325 -0
- data/lib/ace/tmux/organisms/control_surface.rb +1 -1
- data/lib/ace/tmux/organisms/window_manager.rb +1 -1
- data/lib/ace/tmux/runtime_adapter.rb +177 -0
- data/lib/ace/tmux/version.rb +1 -1
- data/lib/ace/tmux.rb +3 -0
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f49cb24a7e74232499e414200b2b9122a9b4562ad9ed6679280a953454d2f52
|
|
4
|
+
data.tar.gz: 35e3a824e53a52913031f4c25ca99265d1a15ab5fd49d5240d80517c5be7b348
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 73bebcbb321f90292398aba4a6c21873e4638277d8e452a27001f46d6c4d62fe858e5acd5eb53e09093a8e99b68d16613585ae1d20172a8d298a786487d35c55
|
|
7
|
+
data.tar.gz: facc9d92c39f2742bcf922f4cc6c55cb4abd8b498905bd04078c5b55c7fe5fa1754d14edc09c57f578f5dbfacaa6e5de0e58d67f7c6c5f12da1355805235173e
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ 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
|
+
|
|
10
22
|
|
|
11
23
|
## [0.17.5] - 2026-09-02
|
|
12
24
|
|
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.
|
|
@@ -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
|
-
|
|
27
|
+
return normalized if normalized
|
|
28
28
|
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
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
|
data/lib/ace/tmux/version.rb
CHANGED
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.
|
|
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-
|
|
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
|
|
@@ -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:
|