ace-herdr 0.1.0 → 0.2.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: 11805892b3ae5bdf74d4bdd555f8e508e7fe3693476d07fa537a41b9f891857e
4
- data.tar.gz: 2aa907e23b109838153215ac004f344045220d2c77cf0f295a99c6ff55a75cb2
3
+ metadata.gz: 7bf428cc5ca891abf1fc4214e10301ab30e50ca66b2cc122f14512152437b0e8
4
+ data.tar.gz: f43847a527924ee7f2b2ba7266f47c18f741a04e012aef255cebfb121c05ad1d
5
5
  SHA512:
6
- metadata.gz: 53e2cbd3f3c97db17b5e14609e54deb210e0d7474886dc4f6fe7d78a33154df1cb7fe680a565aa500d534c0ce06235dd20445f9ae6bb867d5b989732c5a5fa70
7
- data.tar.gz: 9397dfe3b7fb8ac0d9b42f2a43ad7ac56204c7b70197e5f1a6095a00f62f38ecb8d7a0967ca0ab5d68c91864656c86db25a9cc9bc0274fa65c63d74216771e09
6
+ metadata.gz: 83223ba775d983ce92b32031a0e13d99d33cf9c95f2e8067816933051f0dfca77388c65241115e67eaf2d101d2c648cc72af134355a582b8b4e523bf4e2799c9
7
+ data.tar.gz: 5f8dc827d8cad595d2821442386247c764ac7913c6d8371f19621a7f633db824a271a0791f8556bc9359da821f3fcd8cf6358eb12769befdd4ecbc33d4a25103
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to `ace-herdr` will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - `Ace::Herdr::Organisms::RuntimeAdapter` and the lazy `ace/runtime/adapters/herdr` entrypoint implement the shared ace-runtime intent contract. It preserves native agent prompt and wait semantics, polls all four lifecycle observations, and maps Herdr failures to contract errors. Native tab and pane probes support context, focus, and retained pane preparation. The packaged shared adapter contract suite runs against a Herdr-shaped fake executor.
14
+
8
15
  ## [0.1.0] - 2026-09-27
9
16
 
10
17
  ### Added
data/README.md CHANGED
@@ -32,6 +32,7 @@
32
32
  - Dispatch a subagent in one command with deterministic defaults and no LLM decisions.
33
33
  - Wait for agent readiness or completion without scraping pane noise.
34
34
  - Close out finished agent panes (rename/close) and return their result.
35
+ - Use Herdr through the shared `ace-runtime` Ruby intent API, with workspace/session and tab/window mapping handled by the adapter.
35
36
 
36
37
  ## Installation
37
38
 
data/docs/usage.md CHANGED
@@ -3,8 +3,8 @@ doc-type: user
3
3
  title: ace-herdr Usage
4
4
  purpose: Full CLI and configuration reference for ace-herdr: push delivery, agent bootstrap, the terminal-control surface (list, send, capture, wait, presets), and tidy cleanup.
5
5
  ace-docs:
6
- last-updated: 2026-09-28
7
- last-checked: 2026-09-28
6
+ last-updated: 2026-10-02
7
+ last-checked: 2026-10-02
8
8
  ---
9
9
 
10
10
  # Usage
@@ -31,6 +31,16 @@ Control commands print exactly one deterministic JSON line on stdout; `--quiet`
31
31
 
32
32
  herdr *sessions* (server persistence) are intentionally not exposed; the tmux session analogue is the herdr **workspace**.
33
33
 
34
+ ## ace-runtime adapter
35
+
36
+ Install `ace-herdr` alongside `ace-runtime`, then resolve the adapter with `Ace::Runtime.resolve("herdr")`. The contract's `session` is a Herdr **workspace**, `window` is a **tab**, and `pane` is an opaque Herdr pane ID. The adapter does not construct pane IDs. It locates the caller through `HERDR_SESSION` and `HERDR_PANE` and reads the explicit pane with `pane get`; the pane's native workspace is authoritative and a conflicting `HERDR_WORKSPACE_ID` hint is rejected instead of honored. Outside Herdr, `context` reports `in_runtime: false`; operations requiring a caller workspace raise `Ace::Runtime::RuntimeUnavailableError`.
37
+
38
+ The adapter implements `context`, `ensure_window`, `prepare_pane`, `focus`, ordered `send` and its convenience methods, `capture`, `wait_output`, `wait_agent`, `wait_lifecycle`, `close_window`, `list_windows`, and `list_panes`. `ensure_window` scopes the sanitized tab label to the caller workspace. It records the tab's root, preset, and prepared pane under `~/.ace/local/herdr/runtime-tabs/` — keyed by workspace and label, so adapter instances from any working directory or requested root share one record, create through the same lock, and serialize prepared-pane creation through it. Later instances verify an idempotent request and reject a conflicting one; a tab whose ownership cannot be proven is never closed or replaced (a failed create rolls back only tabs the same attempt created). `prepare_pane` splits a retained shell target and returns the native pane ID after verifying it exists and has a shell process.
39
+
40
+ Sends probe for a live agent. Plain panes receive raw text and keys in order; agent panes receive one self-submitting `agent prompt`, with one trailing Enter dropped and reported. Agent waits use native `agent wait` states (`idle`, `working`, `blocked`, `done`). Contract timeout values are seconds and are converted to Herdr milliseconds. Lifecycle waits observe `tab get`, focused tab state, `pane get`, and `pane process-info`; a missing pane keeps `pane-exists` waiting but immediately satisfies `pane-exited`. A pane with only its retained shell also satisfies `pane-exited` because no submitted foreground command remains. This observation does not prove assignment success or authorize cleanup.
41
+
42
+ Native `agent_blocked` becomes `SendRejectedError`; `agent_prompt_stalled` becomes `SendStalledError` and must not be automatically resent. Native wait timeouts become `WaitTimeoutError`, missing targets become `TargetNotFoundError`, and an unavailable binary or socket becomes `RuntimeUnavailableError`.
43
+
34
44
  ## tmux-intent ↔ herdr-command parity
35
45
 
36
46
  Every common terminal-control intent available through `ace-tmux` is available through `ace-herdr` (herdr 0.9.1). Parity is of INTENT and FLAG VOCABULARY, not byte-format: ace-herdr keeps one-line JSON where ace-tmux renders human tables.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Herdr
5
+ module Atoms
6
+ # Shared recursive extraction from herdr's native JSON responses
7
+ # (ids nest under result.*; native shapes vary slightly between
8
+ # commands). Pure functions — no side effects.
9
+ module JsonFind
10
+ module_function
11
+
12
+ # Walk a path of hash keys; nil unless every hop is a Hash. A hop
13
+ # resolves only to a non-empty String.
14
+ def dig_value(json, path)
15
+ current = json
16
+ path.each do |key|
17
+ return nil unless current.is_a?(Hash)
18
+
19
+ current = current[key]
20
+ end
21
+ current.is_a?(String) && !current.empty? ? current : nil
22
+ end
23
+
24
+ # Depth-first search for the first non-empty String under any of
25
+ # the given keys (tolerant extraction of ids from native shapes).
26
+ def find_value(json, keys)
27
+ case json
28
+ when Hash
29
+ keys.each { |key| return json[key] if json[key].is_a?(String) && !json[key].empty? }
30
+ json.each_value do |value|
31
+ found = find_value(value, keys)
32
+ return found if found
33
+ end
34
+ nil
35
+ when Array
36
+ json.each do |value|
37
+ found = find_value(value, keys)
38
+ return found if found
39
+ end
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -22,6 +22,9 @@ module Ace
22
22
  end
23
23
  end
24
24
 
25
+ # Native timeout is distinct from a prompt accepted but not observed.
26
+ class ExecutorTimeoutError < AgentNotReadyError; end
27
+
25
28
  # Target pane does not exist (pane_not_found)
26
29
  class PaneNotFoundError < ExecutorError; end
27
30
 
@@ -60,6 +60,10 @@ module Ace
60
60
  run!([@binary, "pane", "current", "--current"])
61
61
  end
62
62
 
63
+ def pane_get(pane)
64
+ run!([@binary, "pane", "get", pane])
65
+ end
66
+
63
67
  # Inspect a pane's processes (positive proof of process exit for tidy)
64
68
  def pane_process_info(pane)
65
69
  run!([@binary, "pane", "process-info", "--pane", pane])
@@ -73,6 +77,18 @@ module Ace
73
77
  run!(cmd)
74
78
  end
75
79
 
80
+ def tab_get(tab)
81
+ run!([@binary, "tab", "get", tab])
82
+ end
83
+
84
+ def tab_focus(tab)
85
+ run!([@binary, "tab", "focus", tab])
86
+ end
87
+
88
+ def api_snapshot
89
+ run!([@binary, "api", "snapshot"])
90
+ end
91
+
76
92
  # --- terminal-control surface (spec 8wq.t.k84) -----------------------
77
93
 
78
94
  # List workspaces (tmux sessions analogue)
@@ -200,6 +216,25 @@ module Ace
200
216
  # code is kept in the message so CLI failures carry it.
201
217
  def classify(result, cmd)
202
218
  code, message = error_code(result)
219
+ if code
220
+ return classify_code(code, message, result: result, cmd: cmd)
221
+ end
222
+
223
+ # No structured code: first nonempty of stderr/stdout — herdr
224
+ # writes some failures (socket errors) to stdout with an empty
225
+ # stderr, and empty strings are truthy in Ruby, so `||` chaining
226
+ # would hide the detail.
227
+ detail = [result.stderr, result.stdout].reject { |value| value.to_s.empty? }.first
228
+ if detail.to_s.match?(/no herdr server is running|cannot connect|connection refused|socket.*unavailable/i)
229
+ return ExecutorUnavailableError.new(detail)
230
+ end
231
+
232
+ CommandError.new(
233
+ "herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} #{detail}".strip
234
+ )
235
+ end
236
+
237
+ def classify_code(code, message, result:, cmd:)
203
238
  case code
204
239
  when "agent_blocked" then AgentBlockedError.new(tag_code(code, message))
205
240
  when "agent_prompt_stalled" then AgentNotReadyError.new(tag_code(code, message))
@@ -207,11 +242,10 @@ module Ace
207
242
  when "agent_not_found" then AgentNotFoundError.new(tag_code(code, message))
208
243
  when "tab_not_found" then TabNotFoundError.new(tag_code(code, message))
209
244
  when "workspace_not_found" then WorkspaceNotFoundError.new(tag_code(code, message))
210
- when "timeout" then AgentNotReadyError.new(tag_code(code, message))
245
+ when "timeout" then ExecutorTimeoutError.new(tag_code(code, message))
211
246
  else
212
247
  CommandError.new(
213
- "herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} " \
214
- "#{message || result.stderr}".strip
248
+ "herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")}: #{tag_code(code, message)}"
215
249
  )
216
250
  end
217
251
  end
@@ -146,9 +146,10 @@ module Ace
146
146
  # Create a tab from a preset (tmux `window` analogue) in the given
147
147
  # or resolved workspace; --cwd overrides the resolved tab cwd. The
148
148
  # layout is validated before the tab is created.
149
- def create_tab(preset_name, workspace_id: nil, cwd: nil)
149
+ def create_tab(preset_name, workspace_id: nil, cwd: nil, label: nil)
150
150
  workspace_id = resolve_workspace_id(workspace_id)
151
151
  resolved = resolve_tab_preset(preset_name)
152
+ resolved = resolved.merge("label" => label) if label
152
153
  preflight_tab!(resolved)
153
154
  instantiate_tab(
154
155
  resolved, workspace_id: workspace_id,
@@ -248,13 +249,20 @@ module Ace
248
249
  tab_id = dig_value(tab_json, %w[result tab tab_id]) ||
249
250
  raise(TargetResolutionError, "could not read the new tab id from herdr tab create output")
250
251
 
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)
252
+ begin
253
+ pane_specs = Array(tab_spec["panes"])
254
+ root_pane_id = dig_value(tab_json, %w[result root_pane pane_id]) ||
255
+ raise(TargetResolutionError, "could not read the root pane id from herdr tab create output")
256
+ placed = place_panes(tab_spec, pane_specs, tab_cwd, root_pane_id)
257
+
258
+ commands = run_pane_commands(placed)
259
+ agents = start_pane_agents(placed, workspace_id: workspace_id)
260
+ rescue StandardError => e
261
+ # Surface the created tab id and the original error class so
262
+ # the owner can roll back exactly this tab and keep
263
+ # mapping-relevant semantics (e.g. AgentBlockedError).
264
+ raise TabMaterializationError.new(tab_id: tab_id, message: "#{e.class}: #{e.message}")
265
+ end
258
266
 
259
267
  {
260
268
  tab: tab_id,
@@ -386,33 +394,13 @@ module Ace
386
394
  end
387
395
 
388
396
  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
397
+ Atoms::JsonFind.dig_value(json, path)
396
398
  end
397
399
 
398
- # Recursive search mirroring the Dispatcher's tolerant extraction of
399
- # ids from native responses (native shapes nest under result.*)
400
+ # Tolerant extraction of ids from native responses (native shapes
401
+ # nest under result.*); shared atom, same semantics as Dispatcher.
400
402
  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
403
+ Atoms::JsonFind.find_value(json, keys)
416
404
  end
417
405
 
418
406
  # --- send: validation -------------------------------------------------
@@ -0,0 +1,555 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "json"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module Organisms
11
+ # Maps ace-runtime's terminal intents onto herdr's workspace/tab/pane API.
12
+ # Contract session means workspace; contract window means tab.
13
+ class RuntimeAdapter
14
+ POLL_INTERVAL = 0.02
15
+
16
+ def initialize(executor: Molecules::HerdrExecutor.new, env: ENV, clock: Process, sleeper: Kernel, surface: nil,
17
+ identity_dir: nil, poll_interval: POLL_INTERVAL)
18
+ @executor = executor
19
+ @env = env
20
+ @clock = clock
21
+ @sleeper = sleeper
22
+ @surface = surface || ControlSurface.new(executor: executor)
23
+ @prepared = {}
24
+ @identity_dir = identity_dir
25
+ @poll_interval = poll_interval
26
+ end
27
+
28
+ def context
29
+ return {in_runtime: false, session: nil, window: nil, pane: nil} unless inside?
30
+
31
+ with_errors do
32
+ current = @executor.pane_get(@env["HERDR_PANE"]).parsed_json
33
+ native_workspace = find_value(current, %w[workspace_id workspaceId])
34
+ hint = @env["HERDR_WORKSPACE_ID"]
35
+ if hint && native_workspace && hint != native_workspace
36
+ raise Runtime::RuntimeUnavailableError,
37
+ "HERDR_WORKSPACE_ID '#{hint}' does not match the caller pane's workspace " \
38
+ "'#{native_workspace}'; clear the hint or reconnect the pane"
39
+ end
40
+
41
+ {
42
+ in_runtime: true,
43
+ session: native_workspace || hint,
44
+ window: find_value(current, %w[tab_id tabId]),
45
+ pane: @env["HERDR_PANE"] || find_value(current, %w[pane_id paneId])
46
+ }
47
+ end
48
+ end
49
+
50
+ def ensure_window(name:, root:, preset: nil)
51
+ with_errors do
52
+ workspace = workspace_id!
53
+ label = Runtime.sanitize_name(name)
54
+ with_identity_lock(workspace, label) do
55
+ path = identity_path(workspace, label)
56
+ existing = tabs(workspace).find { |tab| tab[:name] == label }
57
+ if existing
58
+ # Ownership must be proven by the record (or, without one,
59
+ # by the pane's native root for a preset-free request); a
60
+ # tab we cannot verify is never closed or replaced.
61
+ unless verifiable_identity?(existing, root: root, preset: preset, path: path)
62
+ raise Runtime::WindowConflictError,
63
+ "window '#{existing[:name]}' conflicts with root or preset"
64
+ end
65
+
66
+ # Adopting a record-less tab: persist its provenance (and
67
+ # keep any prepared-pane pointer) so later instances share it.
68
+ if read_identity(path, existing).nil?
69
+ write_identity(path, id: existing[:id], root: canonical_root(root), preset: preset,
70
+ prepared_pane: pointer_prepared_pane(path, existing[:id]))
71
+ end
72
+ next existing[:id]
73
+ end
74
+
75
+ created_id =
76
+ if preset
77
+ begin
78
+ created = @surface.create_tab(preset, workspace_id: workspace, cwd: File.expand_path(root), label: label)
79
+ rescue TabMaterializationError => e
80
+ # Roll back the tab this call created — exact id from
81
+ # the surface, never a listing guess. Best effort: a
82
+ # rollback failure never replaces the primary error.
83
+ begin
84
+ @executor.tab_close(e.tab_id)
85
+ rescue StandardError => close_error
86
+ warn "ace-herdr: rollback close failed for tab #{e.tab_id}: #{close_error.class}: #{close_error.message}"
87
+ end
88
+ raise
89
+ end
90
+ created.fetch(:tab)
91
+ else
92
+ parsed = @executor.tab_create(workspace_id: workspace, label: label, cwd: File.expand_path(root)).parsed_json
93
+ find_value(parsed, %w[tab tab_id])
94
+ end
95
+ raise TargetResolutionError, "tab create returned no tab id" if created_id.nil?
96
+
97
+ begin
98
+ write_identity(path, id: created_id, root: canonical_root(root), preset: preset)
99
+ rescue StandardError
100
+ begin
101
+ @executor.tab_close(created_id)
102
+ rescue StandardError => close_error
103
+ warn "ace-herdr: rollback close failed for tab #{created_id}: #{close_error.class}: #{close_error.message}"
104
+ end
105
+ raise
106
+ end
107
+ created_id
108
+ end
109
+ end
110
+ end
111
+
112
+ def prepare_pane(window:)
113
+ with_errors do
114
+ tab = resolve_tab!(window)
115
+ # Hold the identity lock through discovery, split, verification,
116
+ # and recording so concurrent instances cannot split twice.
117
+ with_identity_lock(tab[:workspace], tab[:name]) do
118
+ cached = discover_prepared_pane(tab)
119
+ next cached if cached
120
+
121
+ # Split a shell pane: prefer the first pane without a live
122
+ # agent — pane list ordering does not contractually promise
123
+ # a shell root pane in foreign multi-pane tabs. Single-pane
124
+ # tabs skip the probe entirely.
125
+ tab_panes = panes(tab[:workspace]).select { |pane| pane[:tab] == tab[:id] }
126
+ root_pane = tab_panes.first
127
+ if tab_panes.length > 1
128
+ root_pane = tab_panes.find { |pane| !agent_pane?(pane[:pane]) } || tab_panes.first
129
+ end
130
+ raise Runtime::TargetNotFoundError, "tab '#{window}' has no pane" unless root_pane
131
+
132
+ parsed = @executor.pane_split(pane: root_pane[:pane], direction: "right").parsed_json
133
+ id = find_value(parsed, %w[pane pane_id])
134
+ raise TargetResolutionError, "pane split returned no pane id" if id.nil?
135
+
136
+ verify_retained_shell!(id)
137
+ record_prepared_pane(tab, id)
138
+ id
139
+ end
140
+ end
141
+ end
142
+
143
+ def focus(window:)
144
+ with_errors { @executor.tab_focus(resolve_tab!(window)[:id]); true }
145
+ end
146
+
147
+ def send_profile
148
+ :agent_aware
149
+ end
150
+
151
+ def send(pane:, command: nil, items: [])
152
+ # Reject malformed shared shapes before any delivery or mutation;
153
+ # the agent probe below is a native read, never a write.
154
+ Runtime::Atoms::SendContract.normalize!(command: command, items: items, profile: :plain_pane)
155
+ with_errors do
156
+ agent = agent_pane?(pane)
157
+ request = Runtime::Atoms::SendContract.normalize!(
158
+ command: command, items: items, profile: agent ? :agent_aware : :plain_pane
159
+ )
160
+ if agent
161
+ request.items.each do |item|
162
+ item.key?(:message) ? @executor.agent_prompt(pane: pane, text: item[:message]) :
163
+ @executor.agent_send_keys(pane, [item[:key]])
164
+ end
165
+ else
166
+ @executor.pane_run(pane, request.command) if request.command
167
+ request.items.each do |item|
168
+ item.key?(:message) ? @executor.pane_send_text(pane, item[:message]) :
169
+ @executor.pane_send_keys(pane, [item[:key]])
170
+ end
171
+ end
172
+ Runtime::Atoms::SendContract::Result.new(dropped_trailing_enter: request.dropped_trailing_enter)
173
+ end
174
+ end
175
+
176
+ def send_command(pane:, command:)
177
+ send(pane: pane, command: command)
178
+ end
179
+
180
+ def send_text(pane:, text:)
181
+ send(pane: pane, items: [{message: text}])
182
+ end
183
+
184
+ def send_keys(pane:, keys:)
185
+ send(pane: pane, items: Array(keys).map { |key| {key: key} })
186
+ end
187
+
188
+ def capture(pane:, lines: 40)
189
+ with_errors { @surface.capture(pane: pane, lines: lines) }
190
+ end
191
+
192
+ def wait_output(pane:, pattern:, timeout:)
193
+ with_errors(condition: "output", target: pane, timeout: timeout) do
194
+ @surface.wait_output(pane: pane, pattern: pattern, timeout_ms: milliseconds(timeout))
195
+ end
196
+ end
197
+
198
+ def wait_agent(pane:, states:, timeout:)
199
+ with_errors(condition: "agent", target: pane, timeout: timeout) do
200
+ @executor.agent_wait(pane: pane, until_states: states, timeout_ms: milliseconds(timeout))
201
+ true
202
+ end
203
+ end
204
+
205
+ def wait_lifecycle(condition:, target:, timeout:)
206
+ unless Runtime::Atoms::SendContract::LIFECYCLE_CONDITIONS.include?(condition)
207
+ raise ArgumentError, "unknown lifecycle condition '#{condition}'"
208
+ end
209
+
210
+ # Window conditions resolve the caller workspace once per wait so
211
+ # poll iterations do not re-run context and tab-list subprocesses;
212
+ # pane conditions never need it.
213
+ scope = nil
214
+ deadline = @clock.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
215
+ with_errors(condition: condition, target: target, timeout: timeout) do
216
+ loop do
217
+ if condition.start_with?("window-") && scope.nil?
218
+ scope = workspace_id!
219
+ end
220
+ return true if lifecycle_met?(condition, target, workspace: scope)
221
+ raise Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout) if
222
+ @clock.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
223
+
224
+ @sleeper.sleep(@poll_interval)
225
+ end
226
+ end
227
+ end
228
+
229
+ def close_window(window:)
230
+ with_errors { @executor.tab_close(resolve_tab!(window)[:id]); true }
231
+ end
232
+
233
+ def list_windows
234
+ with_errors { tabs(workspace_id!).map { |tab| {name: tab[:name], window: tab[:id], active: tab[:active]} } }
235
+ end
236
+
237
+ def list_panes(window:)
238
+ with_errors do
239
+ tab = resolve_tab!(window)
240
+ panes(tab[:workspace]).select { |pane| pane[:tab] == tab[:id] }
241
+ end
242
+ end
243
+
244
+ private
245
+
246
+ def inside?
247
+ !@env["HERDR_SESSION"].to_s.empty? && !@env["HERDR_PANE"].to_s.empty?
248
+ end
249
+
250
+ def workspace_id!
251
+ raise Runtime::RuntimeUnavailableError, "not inside a herdr workspace" unless inside?
252
+
253
+ context[:session] || raise(Runtime::RuntimeUnavailableError, "herdr workspace is unavailable")
254
+ end
255
+
256
+ def tabs(workspace)
257
+ @surface.list_tabs(workspace_id: workspace).map do |row|
258
+ {id: row[:id], name: row[:title], workspace: row[:workspace], active: row[:focused]}
259
+ end
260
+ end
261
+
262
+ def panes(workspace)
263
+ @surface.list_panes(workspace_id: workspace).map do |row|
264
+ {pane: row[:id], tab: row[:tab], workspace: row[:workspace]}
265
+ end
266
+ end
267
+
268
+ def resolve_tab!(window)
269
+ workspace = workspace_id!
270
+ tab = tabs(workspace).find { |row| row[:id] == window || row[:name] == window }
271
+ tab || raise(Runtime::TargetNotFoundError, "tab '#{window}' not found")
272
+ end
273
+
274
+ def canonical_root(root)
275
+ File.realpath(root)
276
+ rescue Errno::ENOENT
277
+ File.expand_path(root)
278
+ end
279
+
280
+ # Identity records and locks are keyed on the shared (workspace,
281
+ # label) identity in a stable user-level location — instances from
282
+ # any cwd and any requested root agree on the same record, so
283
+ # concurrent creators of the same label serialize and reuse is
284
+ # verifiable across processes.
285
+ def identity_path(workspace, label)
286
+ base = @identity_dir || File.join(Dir.home, ".ace", "local", "herdr", "runtime-tabs")
287
+ File.join(base, "#{Digest::SHA256.hexdigest("#{workspace}\0#{label}")}.json")
288
+ end
289
+
290
+ def with_identity_lock(workspace, label, &_block)
291
+ path = "#{identity_path(workspace, label)}.lock"
292
+ FileUtils.mkdir_p(File.dirname(path))
293
+ File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
294
+ file.flock(File::LOCK_EX)
295
+ yield
296
+ end
297
+ end
298
+
299
+ # Reuse needs the recorded identity to match the request; when no
300
+ # readable record exists, native pane cwd is the only remaining
301
+ # root evidence and a preset can never be verified.
302
+ def verifiable_identity?(tab, root:, preset:, path:)
303
+ recorded = read_identity(path, tab)
304
+ if recorded
305
+ return recorded["root"] == canonical_root(root) && recorded["preset"] == preset
306
+ end
307
+
308
+ native_root = native_pane_cwd(tab)
309
+ native_root && canonical_root(native_root) == canonical_root(root) && preset.nil?
310
+ end
311
+
312
+ # Rollback after a failed create is exact-id only (see ensure_window);
313
+ # listings are never consulted for ownership decisions.
314
+
315
+ def read_identity(path, tab)
316
+ recorded = JSON.parse(File.read(path)) if File.file?(path)
317
+ recorded = nil unless recorded.is_a?(Hash) && recorded["id"] == tab[:id]
318
+ # A pointer-only record (no root/preset provenance — written for
319
+ # a foreign tab's prepared pane) proves no ownership.
320
+ recorded = nil if recorded && !recorded.key?("root") && !recorded.key?("preset")
321
+ recorded
322
+ rescue JSON::ParserError
323
+ raise Runtime::WindowConflictError,
324
+ "window '#{tab[:name]}' has unreadable root or preset identity"
325
+ end
326
+
327
+ def native_pane_cwd(tab)
328
+ native_pane = @surface.list_panes(workspace_id: tab[:workspace]).find { |pane| pane[:tab] == tab[:id] }
329
+ native_pane && native_pane[:cwd]
330
+ end
331
+
332
+ # Cross-instance prepared-pane reuse: the identity record carries
333
+ # the prepared pane id, so another adapter instance (or process)
334
+ # reuses the retained target instead of splitting again. Works for
335
+ # provenance records and pointer-only (foreign tab) records alike.
336
+ def discover_prepared_pane(tab)
337
+ cached = @prepared[tab[:id]]
338
+ return cached if usable_prepared_pane?(cached)
339
+
340
+ pane_id = pointer_prepared_pane(identity_path(tab[:workspace], tab[:name]), tab[:id])
341
+ return nil unless pane_id && usable_prepared_pane?(pane_id)
342
+
343
+ @prepared[tab[:id]] = pane_id
344
+ pane_id
345
+ end
346
+
347
+ def pointer_prepared_pane(path, id)
348
+ raw = JSON.parse(File.read(path))
349
+ raw.is_a?(Hash) && raw["id"] == id ? raw["prepared_pane"] : nil
350
+ rescue JSON::ParserError, Errno::ENOENT
351
+ nil
352
+ end
353
+
354
+ def record_prepared_pane(tab, pane_id)
355
+ @prepared[tab[:id]] = pane_id
356
+ path = identity_path(tab[:workspace], tab[:name])
357
+ begin
358
+ recorded = File.file?(path) ? JSON.parse(File.read(path)) : nil
359
+ rescue JSON::ParserError
360
+ # A corrupt record is repaired under the identity lock rather
361
+ # than silently dropped, so the pointer survives.
362
+ recorded = :corrupt
363
+ end
364
+ if recorded.is_a?(Hash) && recorded["id"] == tab[:id]
365
+ write_identity(path, id: recorded["id"], root: recorded["root"], preset: recorded["preset"],
366
+ prepared_pane: pane_id)
367
+ else
368
+ # Foreign tab (not created through ensure_window) or corrupt
369
+ # record: persist a pointer-only record so later instances
370
+ # reuse this pane instead of splitting again.
371
+ write_pointer_only(path, id: tab[:id], prepared_pane: pane_id)
372
+ end
373
+ rescue StandardError => e
374
+ # The pointer is an optimization; losing it only costs a re-split.
375
+ # Warn and continue — provenance writes in ensure_window stay
376
+ # hard-fail, this one does not.
377
+ warn "ace-herdr: could not persist prepared-pane record for '#{tab[:name]}': #{e.class}: #{e.message}"
378
+ nil
379
+ end
380
+
381
+ def usable_prepared_pane?(pane_id)
382
+ return false unless pane_id
383
+
384
+ @executor.pane_get(pane_id)
385
+ info = find_hash(@executor.pane_process_info(pane_id).parsed_json, %w[result process_info])
386
+ info.is_a?(Hash) && !!find_value_of_type(info, "shell_pid", Integer)
387
+ rescue PaneNotFoundError
388
+ false
389
+ end
390
+
391
+ def verify_retained_shell!(pane_id)
392
+ info = find_hash(@executor.pane_process_info(pane_id).parsed_json, %w[result process_info])
393
+ return if info.is_a?(Hash) && find_value_of_type(info, "shell_pid", Integer)
394
+
395
+ raise Runtime::Error, "prepared pane '#{pane_id}' has no retained shell"
396
+ end
397
+
398
+ def write_identity(path, id:, root:, preset:, prepared_pane: nil)
399
+ payload = {id: id, root: root, preset: preset}
400
+ payload[:prepared_pane] = prepared_pane if prepared_pane
401
+ atomic_write_json(path, payload)
402
+ end
403
+
404
+ def write_pointer_only(path, id:, prepared_pane:)
405
+ atomic_write_json(path, {id: id, prepared_pane: prepared_pane})
406
+ end
407
+
408
+ def atomic_write_json(path, payload)
409
+ temp = "#{path}.#{Process.pid}.tmp"
410
+ File.open(temp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
411
+ file.write(JSON.generate(payload))
412
+ file.flush
413
+ file.fsync
414
+ end
415
+ File.rename(temp, path)
416
+ ensure
417
+ File.delete(temp) if temp && File.exist?(temp)
418
+ end
419
+
420
+ def agent_pane?(pane)
421
+ @executor.agent_get(pane)
422
+ true
423
+ rescue AgentNotFoundError
424
+ false
425
+ end
426
+
427
+ def lifecycle_met?(condition, target, workspace:)
428
+ case condition
429
+ when "window-exists" then tab_exists?(target, workspace: workspace)
430
+ when "window-active" then tab_active?(target, workspace: workspace)
431
+ when "pane-exists" then pane_exists?(target)
432
+ when "pane-exited" then pane_exited?(target)
433
+ end
434
+ end
435
+
436
+ def tab_exists?(target, workspace:)
437
+ @executor.tab_get(find_tab(target, workspace)[:id])
438
+ true
439
+ rescue Runtime::TargetNotFoundError, TabNotFoundError
440
+ false
441
+ end
442
+
443
+ def tab_active?(target, workspace:)
444
+ tab = find_tab(target, workspace)
445
+ @executor.tab_get(tab[:id])
446
+ tab[:active] == true
447
+ rescue Runtime::TargetNotFoundError, TabNotFoundError
448
+ false
449
+ end
450
+
451
+ # Scoped to the workspace resolved once for the enclosing wait.
452
+ def find_tab(target, workspace)
453
+ tabs(workspace).find { |row| row[:id] == target || row[:name] == target } ||
454
+ raise(Runtime::TargetNotFoundError, "tab '#{target}' not found")
455
+ end
456
+
457
+ def pane_exists?(target)
458
+ @executor.pane_get(target)
459
+ true
460
+ rescue PaneNotFoundError
461
+ false
462
+ end
463
+
464
+ # Mirrors PaneTidyProbe.process_evidence semantics: herdr
465
+ # serializes `foreground_processes` with serde skip_serializing_if,
466
+ # so inside a well-formed result.process_info object an OMITTED key
467
+ # is the process-exit proof; an explicit null or non-array value is
468
+ # malformed evidence and stays inconclusive. Anything alive appears
469
+ # as a non-empty array; a list holding only the retained shell
470
+ # counts as exited (no submitted foreground command remains).
471
+ def pane_exited?(target)
472
+ return true unless pane_exists?(target)
473
+
474
+ info = find_hash(@executor.pane_process_info(target).parsed_json, %w[result process_info])
475
+ return false unless info.is_a?(Hash)
476
+
477
+ processes = info["foreground_processes"]
478
+ return false if info.key?("foreground_processes") && !processes.is_a?(Array)
479
+
480
+ # An omitted list is only trusted exit evidence inside a
481
+ # well-formed object that also carries the pane's shell pid;
482
+ # an empty or shell-only hash alone proves nothing.
483
+ shell_pid = find_value_of_type(info, "shell_pid", Integer)
484
+ return false unless shell_pid
485
+
486
+ processes = Array(processes)
487
+ return true if processes.empty?
488
+
489
+ processes.all? { |process| process.is_a?(Hash) && process["pid"] == shell_pid }
490
+ rescue PaneNotFoundError
491
+ true
492
+ end
493
+
494
+ def find_value(value, keys)
495
+ Atoms::JsonFind.find_value(value, keys)
496
+ end
497
+
498
+ # Pinned-path walk that fails closed: nil unless every hop resolves
499
+ def find_hash(value, keys)
500
+ keys.each do |key|
501
+ return nil unless value.is_a?(Hash)
502
+
503
+ value = value[key]
504
+ end
505
+ value
506
+ end
507
+
508
+ def find_value_of_type(value, key, type)
509
+ return unless value.is_a?(Hash)
510
+ return value[key] if value[key].is_a?(type)
511
+
512
+ value.each_value { |child| found = find_value_of_type(child, key, type); return found if found }
513
+ nil
514
+ end
515
+
516
+ def milliseconds(seconds)
517
+ (seconds * 1000).ceil
518
+ end
519
+
520
+ def with_errors(condition: nil, target: nil, timeout: nil)
521
+ yield
522
+ rescue AgentBlockedError => e
523
+ raise Runtime::SendRejectedError, e.message
524
+ rescue ExecutorTimeoutError, Herdr::WaitTimeoutError => e
525
+ raise Runtime::WaitTimeoutError.new(condition: condition || "send", target: target, timeout: timeout || 0)
526
+ rescue AgentNotReadyError => e
527
+ error = condition ? Runtime::WaitTimeoutError.new(condition: condition, target: target, timeout: timeout) :
528
+ Runtime::SendStalledError.new(e.message)
529
+ raise error
530
+ rescue PaneNotFoundError, TabNotFoundError, AgentNotFoundError => e
531
+ raise Runtime::TargetNotFoundError, e.message
532
+ rescue WorkspaceNotFoundError, TargetResolutionError => e
533
+ # Unresolvable caller context, or the runtime responded but the
534
+ # answer was unusable (missing ids)
535
+ raise Runtime::RuntimeUnavailableError, e.message
536
+ rescue TabMaterializationError, ValidationError, CommandError => e
537
+ # Bad request configuration, a failed materialization, or an
538
+ # unmapped native failure keeps the native cause while surfacing
539
+ # under the contract error type.
540
+ raise Runtime::Error, e.message
541
+ rescue ExecutorUnavailableError => e
542
+ raise Runtime::RuntimeUnavailableError, e.message
543
+ rescue Runtime::Error
544
+ # Already a contract error (raised inside the block): pass through
545
+ # untouched — never re-wrap.
546
+ raise
547
+ rescue StandardError => e
548
+ # Adapter boundary: every runtime failure surfaces under the
549
+ # contract error model, with the native cause preserved.
550
+ raise Runtime::Error, "#{e.class}: #{e.message}"
551
+ end
552
+ end
553
+ end
554
+ end
555
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Herdr
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/ace/herdr.rb CHANGED
@@ -15,6 +15,18 @@ module Ace
15
15
 
16
16
  # Raised when a bounded wait exceeds its timeout
17
17
  class WaitTimeoutError < Error; end
18
+
19
+ # Raised when a preset tab was created natively but a later
20
+ # materialization step (splits, commands, agents) failed; carries the
21
+ # created tab id so the owner can roll back exactly that tab.
22
+ class TabMaterializationError < Error
23
+ attr_reader :tab_id
24
+
25
+ def initialize(tab_id:, message:)
26
+ @tab_id = tab_id
27
+ super(message)
28
+ end
29
+ end
18
30
  end
19
31
  end
20
32
 
@@ -23,6 +35,7 @@ require_relative "herdr/errors"
23
35
 
24
36
  # Load all ace-herdr components
25
37
  require_relative "herdr/atoms/answer_digest"
38
+ require_relative "herdr/atoms/json_find"
26
39
  require_relative "herdr/models/delivery_record"
27
40
  require_relative "herdr/models/dispatch_outcome"
28
41
  require_relative "herdr/molecules/herdr_executor"
@@ -33,6 +46,7 @@ require_relative "herdr/molecules/preset_resolver"
33
46
  require_relative "herdr/organisms/deliverer"
34
47
  require_relative "herdr/organisms/dispatcher"
35
48
  require_relative "herdr/organisms/control_surface"
49
+ require_relative "herdr/organisms/runtime_adapter"
36
50
  require_relative "herdr/organisms/tidy"
37
51
  require_relative "herdr/cli"
38
52
 
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/herdr"
4
+ require "ace/runtime"
5
+
6
+ Ace::Runtime.register(:herdr, -> { Ace::Herdr::Organisms::RuntimeAdapter.new })
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-herdr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-28 00:00:00.000000000 Z
10
+ date: 2026-10-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-hitl
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.10'
26
+ - !ruby/object:Gem::Dependency
27
+ name: ace-runtime
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.1'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: ace-support-cli
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -170,6 +184,7 @@ files:
170
184
  - exe/ace-herdr
171
185
  - lib/ace/herdr.rb
172
186
  - lib/ace/herdr/atoms/answer_digest.rb
187
+ - lib/ace/herdr/atoms/json_find.rb
173
188
  - lib/ace/herdr/cli.rb
174
189
  - lib/ace/herdr/cli/commands/capture.rb
175
190
  - lib/ace/herdr/cli/commands/close.rb
@@ -194,8 +209,10 @@ files:
194
209
  - lib/ace/herdr/organisms/control_surface.rb
195
210
  - lib/ace/herdr/organisms/deliverer.rb
196
211
  - lib/ace/herdr/organisms/dispatcher.rb
212
+ - lib/ace/herdr/organisms/runtime_adapter.rb
197
213
  - lib/ace/herdr/organisms/tidy.rb
198
214
  - lib/ace/herdr/version.rb
215
+ - lib/ace/runtime/adapters/herdr.rb
199
216
  homepage: https://github.com/cs3b/ace
200
217
  licenses:
201
218
  - MIT