ace-herdr 0.1.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 +7 -0
- data/.ace-defaults/herdr/config.yml +26 -0
- data/.ace-defaults/herdr/tabs/agent.yml +11 -0
- data/.ace-defaults/herdr/workspaces/development.yml +23 -0
- data/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +54 -0
- data/Rakefile +12 -0
- data/docs/usage.md +306 -0
- data/exe/ace-herdr +17 -0
- data/lib/ace/herdr/atoms/answer_digest.rb +18 -0
- data/lib/ace/herdr/cli/commands/capture.rb +55 -0
- data/lib/ace/herdr/cli/commands/close.rb +53 -0
- data/lib/ace/herdr/cli/commands/deliver.rb +67 -0
- data/lib/ace/herdr/cli/commands/dispatch.rb +62 -0
- data/lib/ace/herdr/cli/commands/list.rb +71 -0
- data/lib/ace/herdr/cli/commands/list_presets.rb +47 -0
- data/lib/ace/herdr/cli/commands/send.rb +103 -0
- data/lib/ace/herdr/cli/commands/support.rb +68 -0
- data/lib/ace/herdr/cli/commands/tab.rb +54 -0
- data/lib/ace/herdr/cli/commands/tidy.rb +65 -0
- data/lib/ace/herdr/cli/commands/wait.rb +88 -0
- data/lib/ace/herdr/cli/commands/workspace.rb +52 -0
- data/lib/ace/herdr/cli.rb +98 -0
- data/lib/ace/herdr/errors.rb +51 -0
- data/lib/ace/herdr/models/delivery_record.rb +109 -0
- data/lib/ace/herdr/models/dispatch_outcome.rb +29 -0
- data/lib/ace/herdr/molecules/delivery_record_store.rb +150 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +264 -0
- data/lib/ace/herdr/molecules/pane_tidy_probe.rb +80 -0
- data/lib/ace/herdr/molecules/preset_loader.rb +70 -0
- data/lib/ace/herdr/molecules/preset_resolver.rb +88 -0
- data/lib/ace/herdr/organisms/control_surface.rb +575 -0
- data/lib/ace/herdr/organisms/deliverer.rb +358 -0
- data/lib/ace/herdr/organisms/dispatcher.rb +130 -0
- data/lib/ace/herdr/organisms/tidy.rb +188 -0
- data/lib/ace/herdr/version.rb +7 -0
- data/lib/ace/herdr.rb +103 -0
- metadata +224 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Herdr
|
|
7
|
+
module Organisms
|
|
8
|
+
# Owner of the terminal-control behavior (spec 8wq.t.k84): normalizes
|
|
9
|
+
# herdr's native JSON into the public one-line payloads, routes send
|
|
10
|
+
# input to plain-pane or agent transport, and drives preset
|
|
11
|
+
# instantiation. All native calls go through the executor; command
|
|
12
|
+
# classes render the returned hashes as JSON lines.
|
|
13
|
+
class ControlSurface
|
|
14
|
+
DEFAULT_LINES = 40
|
|
15
|
+
ENTER_KEY = /\Aenter\z/i
|
|
16
|
+
# Preset split directions: herdr-native right/down plus the tmux-ish
|
|
17
|
+
# horizontal/vertical aliases
|
|
18
|
+
DIRECTIONS = {
|
|
19
|
+
"right" => "right", "down" => "down",
|
|
20
|
+
"horizontal" => "right", "vertical" => "down"
|
|
21
|
+
}.freeze
|
|
22
|
+
WORKSPACE_KEYS = %w[workspace_id workspaceId].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(
|
|
25
|
+
executor: Molecules::HerdrExecutor.new,
|
|
26
|
+
preset_loader: Molecules::PresetLoader.new,
|
|
27
|
+
default_agent_kind: "pi",
|
|
28
|
+
agent_start_timeout_ms: 60_000
|
|
29
|
+
)
|
|
30
|
+
@executor = executor
|
|
31
|
+
@preset_loader = preset_loader
|
|
32
|
+
@default_agent_kind = default_agent_kind
|
|
33
|
+
@agent_start_timeout_ms = agent_start_timeout_ms
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Config-driven construction (mirrors Dispatcher.from_config)
|
|
37
|
+
def self.from_config(executor:, config: {})
|
|
38
|
+
timeouts = config["timeouts"] || {}
|
|
39
|
+
new(
|
|
40
|
+
executor: executor,
|
|
41
|
+
default_agent_kind: config["default_agent_kind"] || "pi",
|
|
42
|
+
agent_start_timeout_ms: (timeouts["agent_start"] || 60) * 1000
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# --- send -------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
# Ordered send input (tokens: [{type: :cmd|:msg|:key, value: String}]
|
|
49
|
+
# in CLI declaration order). The pane is probed for a live agent:
|
|
50
|
+
# plain panes get raw pane transport in declaration order, agent
|
|
51
|
+
# panes get prompt semantics. Every rejected shape fails before any
|
|
52
|
+
# transport call.
|
|
53
|
+
def send_input(pane:, tokens:)
|
|
54
|
+
validate_send_shapes!(tokens)
|
|
55
|
+
if agent_pane?(pane)
|
|
56
|
+
send_to_agent(pane, tokens)
|
|
57
|
+
else
|
|
58
|
+
send_to_plain(pane, tokens)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# --- list -------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# Live panes, optionally scoped to a workspace
|
|
65
|
+
def list_panes(workspace_id: nil)
|
|
66
|
+
result_rows(@executor.pane_list(workspace_id: workspace_id).parsed_json, "panes")
|
|
67
|
+
.map { |row| normalize_pane(row) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Live tabs, optionally scoped to a workspace (tmux windows analogue)
|
|
71
|
+
def list_tabs(workspace_id: nil)
|
|
72
|
+
result_rows(@executor.tab_list(workspace_id: workspace_id).parsed_json, "tabs")
|
|
73
|
+
.map { |row| normalize_tab(row) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Live workspaces (tmux sessions analogue)
|
|
77
|
+
def list_workspaces
|
|
78
|
+
result_rows(@executor.workspace_list.parsed_json, "workspaces")
|
|
79
|
+
.map { |row| normalize_workspace(row) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# --- capture ------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
# Raw pane text (visible screen or recent history); no JSON wrapping
|
|
85
|
+
def capture(pane:, source: "recent", lines: DEFAULT_LINES)
|
|
86
|
+
@executor.pane_read(pane, source: source, lines: lines).stdout
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# --- wait: output -------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
# Waits for pane output containing a literal pattern. herdr checks
|
|
92
|
+
# existing content immediately, then polls; timeout fails closed.
|
|
93
|
+
def wait_output(pane:, pattern:, timeout_ms:)
|
|
94
|
+
@executor.pane_wait_output(pane, pattern: pattern, timeout_ms: timeout_ms)
|
|
95
|
+
true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# --- presets ------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
# Merged preset inventory across the cascade, optionally scoped
|
|
101
|
+
def list_presets(type: nil)
|
|
102
|
+
return @preset_loader.list_all unless type
|
|
103
|
+
|
|
104
|
+
unless Molecules::PresetLoader::PRESET_TYPES.include?(type)
|
|
105
|
+
raise ValidationError,
|
|
106
|
+
"Unknown preset type '#{type}' (available: #{Molecules::PresetLoader::PRESET_TYPES.join(', ')})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
{type => @preset_loader.list(type)}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Create a workspace from a preset (tmux `start` analogue); --cwd
|
|
113
|
+
# overrides the resolved root/tab cwd. All tab layouts are validated
|
|
114
|
+
# before any herdr call so a broken preset creates nothing.
|
|
115
|
+
def create_workspace(preset_name, cwd: nil)
|
|
116
|
+
resolved = resolve_workspace_preset(preset_name)
|
|
117
|
+
tabs = Array(resolved["tabs"])
|
|
118
|
+
tabs.each { |tab| preflight_tab!(tab) }
|
|
119
|
+
|
|
120
|
+
result = @executor.workspace_create(
|
|
121
|
+
label: resolved["label"] || preset_name,
|
|
122
|
+
cwd: expand_cwd(cwd || resolved["cwd"]),
|
|
123
|
+
focus: resolved["focus"]
|
|
124
|
+
)
|
|
125
|
+
parsed = result.parsed_json
|
|
126
|
+
workspace_id = dig_value(parsed, %w[result workspace workspace_id]) ||
|
|
127
|
+
raise(TargetResolutionError,
|
|
128
|
+
"could not read the new workspace id from herdr workspace create output")
|
|
129
|
+
inherited_cwd = expand_cwd(cwd || resolved["cwd"])
|
|
130
|
+
|
|
131
|
+
created_tabs = tabs.map do |tab|
|
|
132
|
+
instantiate_tab(
|
|
133
|
+
tab, workspace_id: workspace_id,
|
|
134
|
+
inherited_cwd: inherited_cwd, cli_cwd: expand_cwd(cwd)
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# herdr seeds every new workspace with an initial tab; the preset's
|
|
139
|
+
# declared tabs replace it
|
|
140
|
+
native_tab_id = dig_value(parsed, %w[result tab tab_id])
|
|
141
|
+
@executor.tab_close(native_tab_id) if native_tab_id && tabs.any?
|
|
142
|
+
|
|
143
|
+
{workspace: workspace_id, tabs: created_tabs}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Create a tab from a preset (tmux `window` analogue) in the given
|
|
147
|
+
# or resolved workspace; --cwd overrides the resolved tab cwd. The
|
|
148
|
+
# layout is validated before the tab is created.
|
|
149
|
+
def create_tab(preset_name, workspace_id: nil, cwd: nil)
|
|
150
|
+
workspace_id = resolve_workspace_id(workspace_id)
|
|
151
|
+
resolved = resolve_tab_preset(preset_name)
|
|
152
|
+
preflight_tab!(resolved)
|
|
153
|
+
instantiate_tab(
|
|
154
|
+
resolved, workspace_id: workspace_id,
|
|
155
|
+
inherited_cwd: nil, cli_cwd: expand_cwd(cwd)
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Unknown-preset CLI error listing the available names
|
|
160
|
+
def unknown_preset!(type, name)
|
|
161
|
+
available = @preset_loader.list(type)
|
|
162
|
+
listing = available.empty? ? "none available" : "available: #{available.join(', ')}"
|
|
163
|
+
noun = type == "tabs" ? "tab" : "workspace"
|
|
164
|
+
raise ValidationError, "Unknown #{noun} preset '#{name}' (#{listing})"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# --- presets: resolution -------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def resolve_workspace_preset(name)
|
|
172
|
+
raw = @preset_loader.load("workspaces", name)
|
|
173
|
+
unknown_preset!("workspaces", name) if raw.nil?
|
|
174
|
+
Molecules::PresetResolver.resolve_workspace(
|
|
175
|
+
raw,
|
|
176
|
+
workspace_lookup: @preset_loader.to_lookup("workspaces"),
|
|
177
|
+
tab_lookup: @preset_loader.to_lookup("tabs")
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def resolve_tab_preset(name)
|
|
182
|
+
raw = @preset_loader.load("tabs", name)
|
|
183
|
+
unknown_preset!("tabs", name) if raw.nil?
|
|
184
|
+
Molecules::PresetResolver.resolve_tab(raw, tab_lookup: @preset_loader.to_lookup("tabs"))
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- presets: workspace/tab instantiation ---------------------------------
|
|
188
|
+
|
|
189
|
+
# Static layout validation before any herdr call: panes declared,
|
|
190
|
+
# every split well-formed with a target that is already placed at
|
|
191
|
+
# its position, and every non-root pane placed by exactly one split
|
|
192
|
+
def preflight_tab!(tab_spec)
|
|
193
|
+
label = tab_spec["label"]
|
|
194
|
+
pane_specs = Array(tab_spec["panes"])
|
|
195
|
+
raise ValidationError, "Preset tab '#{label}' declares no panes" if pane_specs.empty?
|
|
196
|
+
return if pane_specs.length == 1 && Array(tab_spec["splits"]).empty?
|
|
197
|
+
|
|
198
|
+
splits = Array(tab_spec["splits"])
|
|
199
|
+
root_label = pane_specs.first["label"]
|
|
200
|
+
placed_labels = [root_label].compact
|
|
201
|
+
|
|
202
|
+
splits.each do |split|
|
|
203
|
+
unless split["pane"] && pane_specs.drop(1).any? { |spec| spec["label"] == split["pane"] }
|
|
204
|
+
raise ValidationError,
|
|
205
|
+
"Preset tab '#{label}': splits entry must name a declared non-root pane " \
|
|
206
|
+
"(pane: <label>), got '#{split['pane']}'"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
target = split["target"] || root_label
|
|
210
|
+
unless target.nil? || placed_labels.include?(target)
|
|
211
|
+
raise ValidationError,
|
|
212
|
+
"Preset tab '#{label}': splits entry targets pane '#{target}' before it is placed " \
|
|
213
|
+
"(order splits so each target is created by an earlier split or is the root pane)"
|
|
214
|
+
end
|
|
215
|
+
normalize_direction(split["direction"])
|
|
216
|
+
placed_labels << split["pane"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
pane_specs.drop(1).each_with_index do |spec, index|
|
|
220
|
+
key = spec["label"] || "panes[#{index + 1}]"
|
|
221
|
+
unless splits.any? { |split| split["pane"] == spec["label"] }
|
|
222
|
+
raise ValidationError,
|
|
223
|
+
"Preset tab '#{label}': pane '#{key}' is not placed " \
|
|
224
|
+
"(add a splits entry with pane: '#{key}')"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
duplicates = splits.map { |split| split["pane"] }
|
|
229
|
+
.tally.select { |_label, count| count > 1 }.keys
|
|
230
|
+
return unless duplicates.any?
|
|
231
|
+
|
|
232
|
+
raise ValidationError,
|
|
233
|
+
"Preset tab '#{label}': panes placed by multiple splits: #{duplicates.join(', ')}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Layout first (tab + splits in declared order), then pane commands,
|
|
237
|
+
# then agents (readiness-gated by agent start; vs0 bootstrap order)
|
|
238
|
+
def instantiate_tab(tab_spec, workspace_id:, inherited_cwd:, cli_cwd: nil)
|
|
239
|
+
# cwd precedence: CLI --cwd beats the preset tab cwd, which beats
|
|
240
|
+
# the inherited workspace root cwd
|
|
241
|
+
tab_cwd = cli_cwd || expand_cwd(tab_spec["cwd"]) || inherited_cwd
|
|
242
|
+
tab_json = @executor.tab_create(
|
|
243
|
+
workspace_id: workspace_id,
|
|
244
|
+
label: tab_spec["label"],
|
|
245
|
+
cwd: tab_cwd,
|
|
246
|
+
focus: tab_spec["focus"]
|
|
247
|
+
).parsed_json
|
|
248
|
+
tab_id = dig_value(tab_json, %w[result tab tab_id]) ||
|
|
249
|
+
raise(TargetResolutionError, "could not read the new tab id from herdr tab create output")
|
|
250
|
+
|
|
251
|
+
pane_specs = Array(tab_spec["panes"])
|
|
252
|
+
root_pane_id = dig_value(tab_json, %w[result root_pane pane_id]) ||
|
|
253
|
+
raise(TargetResolutionError, "could not read the root pane id from herdr tab create output")
|
|
254
|
+
placed = place_panes(tab_spec, pane_specs, tab_cwd, root_pane_id)
|
|
255
|
+
|
|
256
|
+
commands = run_pane_commands(placed)
|
|
257
|
+
agents = start_pane_agents(placed, workspace_id: workspace_id)
|
|
258
|
+
|
|
259
|
+
{
|
|
260
|
+
tab: tab_id,
|
|
261
|
+
panes: placed.map { |pane| pane[:id] },
|
|
262
|
+
commands: commands,
|
|
263
|
+
agents: agents
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A pane spec: {"label" =>, "cwd" =>, "command" =>, "agent" =>};
|
|
268
|
+
# the first declared pane is the tab's root pane, every further pane
|
|
269
|
+
# must be placed by a splits entry
|
|
270
|
+
def place_panes(tab_spec, pane_specs, inherited_cwd, root_pane_id)
|
|
271
|
+
root_spec = pane_specs.first
|
|
272
|
+
placed = [{spec: root_spec, id: root_pane_id, cwd: pane_cwd(root_spec, inherited_cwd)}]
|
|
273
|
+
rename_pane(root_pane_id, root_spec["label"]) if root_spec["label"]
|
|
274
|
+
by_label = {}
|
|
275
|
+
by_label[root_spec["label"]] = placed[0] if root_spec["label"]
|
|
276
|
+
|
|
277
|
+
splits = Array(tab_spec["splits"])
|
|
278
|
+
|
|
279
|
+
splits.each do |split|
|
|
280
|
+
spec = pane_spec_for(pane_specs, split["pane"])
|
|
281
|
+
target_label = split["target"] || pane_specs.first["label"]
|
|
282
|
+
target =
|
|
283
|
+
if target_label
|
|
284
|
+
by_label.fetch(target_label) do
|
|
285
|
+
raise ValidationError,
|
|
286
|
+
"Preset splits entry targets unknown pane '#{target_label}'"
|
|
287
|
+
end
|
|
288
|
+
else
|
|
289
|
+
placed[0]
|
|
290
|
+
end
|
|
291
|
+
direction = normalize_direction(split["direction"])
|
|
292
|
+
new_id = dig_value(
|
|
293
|
+
@executor.pane_split(
|
|
294
|
+
pane: target[:id], direction: direction,
|
|
295
|
+
cwd: pane_cwd(spec, inherited_cwd),
|
|
296
|
+
ratio: split["ratio"], focus: split["focus"]
|
|
297
|
+
).parsed_json,
|
|
298
|
+
%w[result pane pane_id]
|
|
299
|
+
) || raise(TargetResolutionError, "could not read the new pane id from herdr pane split output")
|
|
300
|
+
rename_pane(new_id, spec["label"]) if spec["label"]
|
|
301
|
+
entry = {spec: spec, id: new_id, cwd: pane_cwd(spec, inherited_cwd)}
|
|
302
|
+
placed << entry
|
|
303
|
+
by_label[spec["label"]] = entry if spec["label"]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
placed
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def pane_spec_for(pane_specs, label)
|
|
310
|
+
return pane_specs.first if label.nil?
|
|
311
|
+
|
|
312
|
+
pane_specs.find { |spec| spec["label"] == label } ||
|
|
313
|
+
raise(ValidationError, "Preset splits entry references unknown pane '#{label}'")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def normalize_direction(direction)
|
|
317
|
+
mapped = DIRECTIONS[direction.to_s]
|
|
318
|
+
return mapped if mapped
|
|
319
|
+
|
|
320
|
+
raise ValidationError,
|
|
321
|
+
"Unknown split direction '#{direction}' (available: #{DIRECTIONS.keys.join(', ')})"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def pane_cwd(spec, inherited_cwd)
|
|
325
|
+
expand_cwd(spec["cwd"] || inherited_cwd)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def expand_cwd(path)
|
|
329
|
+
return nil if path.nil? || path.to_s.empty?
|
|
330
|
+
|
|
331
|
+
File.expand_path(path)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def rename_pane(pane_id, label)
|
|
335
|
+
@executor.pane_rename(pane_id, label)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def run_pane_commands(placed)
|
|
339
|
+
placed.count do |pane|
|
|
340
|
+
command = pane[:spec]["command"]
|
|
341
|
+
next false if command.nil? || command.to_s.empty?
|
|
342
|
+
|
|
343
|
+
@executor.pane_run(pane[:id], command)
|
|
344
|
+
true
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# vs0 bootstrap order: export the reverse address, start the agent
|
|
349
|
+
# (native readiness gate), then submit the declared prompt
|
|
350
|
+
def start_pane_agents(placed, workspace_id:)
|
|
351
|
+
placed.filter_map do |pane|
|
|
352
|
+
agent = pane[:spec]["agent"]
|
|
353
|
+
next nil unless agent.is_a?(Hash)
|
|
354
|
+
|
|
355
|
+
pane_id = pane[:id]
|
|
356
|
+
name = agent["name"] || pane[:spec]["label"] || "agent"
|
|
357
|
+
@executor.pane_run(
|
|
358
|
+
pane_id,
|
|
359
|
+
"export HERDR_SESSION=#{Shellwords.escape(workspace_id.to_s)} " \
|
|
360
|
+
"HERDR_PANE=#{Shellwords.escape(pane_id)}"
|
|
361
|
+
)
|
|
362
|
+
@executor.agent_start(
|
|
363
|
+
name: name, kind: agent["kind"] || @default_agent_kind,
|
|
364
|
+
pane: pane_id, timeout_ms: @agent_start_timeout_ms
|
|
365
|
+
)
|
|
366
|
+
prompt = agent["prompt"].to_s
|
|
367
|
+
@executor.agent_prompt(pane: pane_id, text: prompt) unless prompt.empty?
|
|
368
|
+
name
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Caller's workspace wins by default: explicit flag, then the herdr
|
|
373
|
+
# environment of the calling pane, then the pane current projection
|
|
374
|
+
# (same policy as Dispatcher)
|
|
375
|
+
def resolve_workspace_id(workspace_id)
|
|
376
|
+
return workspace_id if workspace_id
|
|
377
|
+
|
|
378
|
+
env = ENV["HERDR_WORKSPACE_ID"].to_s
|
|
379
|
+
return env unless env.empty?
|
|
380
|
+
|
|
381
|
+
json = @executor.pane_current.parsed_json
|
|
382
|
+
find_value(json, WORKSPACE_KEYS) ||
|
|
383
|
+
raise(TargetResolutionError,
|
|
384
|
+
"cannot resolve the caller's herdr workspace " \
|
|
385
|
+
"(pass --workspace, run inside a herdr pane, or start herdr)")
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def dig_value(json, path)
|
|
389
|
+
current = json
|
|
390
|
+
path.each do |key|
|
|
391
|
+
return nil unless current.is_a?(Hash)
|
|
392
|
+
|
|
393
|
+
current = current[key]
|
|
394
|
+
end
|
|
395
|
+
current.is_a?(String) && !current.empty? ? current : nil
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Recursive search mirroring the Dispatcher's tolerant extraction of
|
|
399
|
+
# ids from native responses (native shapes nest under result.*)
|
|
400
|
+
def find_value(json, keys)
|
|
401
|
+
case json
|
|
402
|
+
when Hash
|
|
403
|
+
keys.each { |key| return json[key] if json[key].is_a?(String) && !json[key].empty? }
|
|
404
|
+
json.each_value do |value|
|
|
405
|
+
found = find_value(value, keys)
|
|
406
|
+
return found if found
|
|
407
|
+
end
|
|
408
|
+
nil
|
|
409
|
+
when Array
|
|
410
|
+
json.each do |value|
|
|
411
|
+
found = find_value(value, keys)
|
|
412
|
+
return found if found
|
|
413
|
+
end
|
|
414
|
+
nil
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# --- send: validation -------------------------------------------------
|
|
419
|
+
|
|
420
|
+
def validate_send_shapes!(tokens)
|
|
421
|
+
raise ValidationError, "Provide at least one of --cmd, --msg, or --key" if tokens.empty?
|
|
422
|
+
|
|
423
|
+
tokens.each do |token|
|
|
424
|
+
next unless token[:value].to_s.strip.empty?
|
|
425
|
+
|
|
426
|
+
raise ValidationError, "--#{token[:type]} requires a non-blank value"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
cmds = tokens.select { |token| token[:type] == :cmd }
|
|
430
|
+
raise ValidationError, "Use --cmd at most once per send" if cmds.length > 1
|
|
431
|
+
|
|
432
|
+
cmd_index = tokens.index { |token| token[:type] == :cmd }
|
|
433
|
+
return unless cmd_index
|
|
434
|
+
|
|
435
|
+
raise ValidationError, "Use either --cmd or --msg, not both" if tokens.any? { |token| token[:type] == :msg }
|
|
436
|
+
|
|
437
|
+
first_key = tokens.index { |token| token[:type] == :key }
|
|
438
|
+
return unless first_key && first_key < cmd_index
|
|
439
|
+
|
|
440
|
+
raise ValidationError,
|
|
441
|
+
"--key must be declared after --cmd; leading or interleaved keys with --cmd are rejected"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# A live agent switches text input to prompt semantics;
|
|
445
|
+
# agent_not_found proves a plain pane, other failures propagate.
|
|
446
|
+
def agent_pane?(pane)
|
|
447
|
+
@executor.agent_get(pane)
|
|
448
|
+
true
|
|
449
|
+
rescue AgentNotFoundError
|
|
450
|
+
false
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# --- send: plain pane (raw input in declaration order) -----------------
|
|
454
|
+
|
|
455
|
+
def send_to_plain(pane, tokens)
|
|
456
|
+
cmd = tokens.find { |token| token[:type] == :cmd }
|
|
457
|
+
if cmd
|
|
458
|
+
@executor.pane_run(pane, cmd[:value])
|
|
459
|
+
keys = tokens.select { |token| token[:type] == :key }
|
|
460
|
+
@executor.pane_send_keys(pane, keys.map { |token| token[:value] }) unless keys.empty?
|
|
461
|
+
return {pane: pane, sent: "cmd"}
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
tokens.each do |token|
|
|
465
|
+
case token[:type]
|
|
466
|
+
when :msg then @executor.pane_send_text(pane, token[:value])
|
|
467
|
+
when :key then @executor.pane_send_keys(pane, [token[:value]])
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
{pane: pane, sent: tokens.any? { |token| token[:type] == :msg } ? "text" : "keys"}
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# --- send: agent pane (prompt semantics) -------------------------------
|
|
474
|
+
|
|
475
|
+
def send_to_agent(pane, tokens)
|
|
476
|
+
cmd = tokens.find { |token| token[:type] == :cmd }
|
|
477
|
+
msgs = tokens.select { |token| token[:type] == :msg }
|
|
478
|
+
keys = tokens.select { |token| token[:type] == :key }
|
|
479
|
+
enter_keys = keys.select { |token| enter_key?(token[:value]) }
|
|
480
|
+
|
|
481
|
+
if cmd
|
|
482
|
+
validate_agent_cmd_keys!(keys, enter_keys)
|
|
483
|
+
@executor.agent_prompt(pane: pane, text: cmd[:value])
|
|
484
|
+
return prompt_result(pane, dropped: keys.any?)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
if msgs.any?
|
|
488
|
+
validate_agent_text_keys!(tokens, keys, enter_keys)
|
|
489
|
+
@executor.agent_prompt(pane: pane, text: msgs.map { |token| token[:value] }.join("\n"))
|
|
490
|
+
return prompt_result(pane, dropped: enter_keys.any?)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
raise ValidationError, "On agent panes at most one --key Enter is allowed" if enter_keys.length > 1
|
|
494
|
+
|
|
495
|
+
@executor.agent_send_keys(pane, keys.map { |token| token[:value] })
|
|
496
|
+
{pane: pane, sent: "keys"}
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# With --cmd, agent panes accept at most one trailing Enter (dropped)
|
|
500
|
+
def validate_agent_cmd_keys!(keys, enter_keys)
|
|
501
|
+
return if keys.length == enter_keys.length && enter_keys.length <= 1
|
|
502
|
+
|
|
503
|
+
raise ValidationError,
|
|
504
|
+
"--cmd with keys on an agent pane allows at most one trailing --key Enter"
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# With text, agent panes accept at most one Enter and only as the
|
|
508
|
+
# final token; any other key combination is rejected
|
|
509
|
+
def validate_agent_text_keys!(tokens, keys, enter_keys)
|
|
510
|
+
unless keys.length == enter_keys.length && enter_keys.length <= 1
|
|
511
|
+
raise ValidationError,
|
|
512
|
+
"On agent panes text may only be followed by a single --key Enter"
|
|
513
|
+
end
|
|
514
|
+
return unless enter_keys.length == 1 && !enter_keys.first.equal?(tokens.last)
|
|
515
|
+
|
|
516
|
+
raise ValidationError,
|
|
517
|
+
"On agent panes the single --key Enter must trail the text"
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def prompt_result(pane, dropped:)
|
|
521
|
+
result = {pane: pane, sent: "prompt"}
|
|
522
|
+
result[:dropped_keys] = ["Enter"] if dropped
|
|
523
|
+
result
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def enter_key?(key)
|
|
527
|
+
key.to_s.match?(ENTER_KEY)
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# --- list: normalization ----------------------------------------------
|
|
531
|
+
|
|
532
|
+
def normalize_pane(row)
|
|
533
|
+
{
|
|
534
|
+
id: row["pane_id"],
|
|
535
|
+
tab: row["tab_id"],
|
|
536
|
+
workspace: row["workspace_id"],
|
|
537
|
+
title: row["terminal_title_stripped"] || row["terminal_title"],
|
|
538
|
+
cwd: row["cwd"],
|
|
539
|
+
focused: row["focused"],
|
|
540
|
+
agent_status: row["agent_status"]
|
|
541
|
+
}
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def normalize_tab(row)
|
|
545
|
+
{
|
|
546
|
+
id: row["tab_id"],
|
|
547
|
+
workspace: row["workspace_id"],
|
|
548
|
+
title: row["label"],
|
|
549
|
+
number: row["number"],
|
|
550
|
+
pane_count: row["pane_count"],
|
|
551
|
+
focused: row["focused"]
|
|
552
|
+
}
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def normalize_workspace(row)
|
|
556
|
+
{
|
|
557
|
+
id: row["workspace_id"],
|
|
558
|
+
title: row["label"],
|
|
559
|
+
number: row["number"],
|
|
560
|
+
tab_count: row["tab_count"],
|
|
561
|
+
pane_count: row["pane_count"],
|
|
562
|
+
focused: row["focused"]
|
|
563
|
+
}
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# herdr list responses nest rows under result.<key>; anything else is
|
|
567
|
+
# an explicit empty state, never an error
|
|
568
|
+
def result_rows(parsed, key)
|
|
569
|
+
rows = parsed.is_a?(Hash) && parsed["result"].is_a?(Hash) ? parsed["result"][key] : nil
|
|
570
|
+
rows.is_a?(Array) ? rows : []
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|