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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.ace-defaults/herdr/config.yml +26 -0
  3. data/.ace-defaults/herdr/tabs/agent.yml +11 -0
  4. data/.ace-defaults/herdr/workspaces/development.yml +23 -0
  5. data/CHANGELOG.md +22 -0
  6. data/LICENSE +21 -0
  7. data/README.md +54 -0
  8. data/Rakefile +12 -0
  9. data/docs/usage.md +306 -0
  10. data/exe/ace-herdr +17 -0
  11. data/lib/ace/herdr/atoms/answer_digest.rb +18 -0
  12. data/lib/ace/herdr/cli/commands/capture.rb +55 -0
  13. data/lib/ace/herdr/cli/commands/close.rb +53 -0
  14. data/lib/ace/herdr/cli/commands/deliver.rb +67 -0
  15. data/lib/ace/herdr/cli/commands/dispatch.rb +62 -0
  16. data/lib/ace/herdr/cli/commands/list.rb +71 -0
  17. data/lib/ace/herdr/cli/commands/list_presets.rb +47 -0
  18. data/lib/ace/herdr/cli/commands/send.rb +103 -0
  19. data/lib/ace/herdr/cli/commands/support.rb +68 -0
  20. data/lib/ace/herdr/cli/commands/tab.rb +54 -0
  21. data/lib/ace/herdr/cli/commands/tidy.rb +65 -0
  22. data/lib/ace/herdr/cli/commands/wait.rb +88 -0
  23. data/lib/ace/herdr/cli/commands/workspace.rb +52 -0
  24. data/lib/ace/herdr/cli.rb +98 -0
  25. data/lib/ace/herdr/errors.rb +51 -0
  26. data/lib/ace/herdr/models/delivery_record.rb +109 -0
  27. data/lib/ace/herdr/models/dispatch_outcome.rb +29 -0
  28. data/lib/ace/herdr/molecules/delivery_record_store.rb +150 -0
  29. data/lib/ace/herdr/molecules/herdr_executor.rb +264 -0
  30. data/lib/ace/herdr/molecules/pane_tidy_probe.rb +80 -0
  31. data/lib/ace/herdr/molecules/preset_loader.rb +70 -0
  32. data/lib/ace/herdr/molecules/preset_resolver.rb +88 -0
  33. data/lib/ace/herdr/organisms/control_surface.rb +575 -0
  34. data/lib/ace/herdr/organisms/deliverer.rb +358 -0
  35. data/lib/ace/herdr/organisms/dispatcher.rb +130 -0
  36. data/lib/ace/herdr/organisms/tidy.rb +188 -0
  37. data/lib/ace/herdr/version.rb +7 -0
  38. data/lib/ace/herdr.rb +103 -0
  39. 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