ace-runtime 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5d0ae5fb595d866fb47f42ed3ea0672510f5a15a6403ee41519889227929c6fe
4
+ data.tar.gz: 9f2271b76b6b70a18fb8b7f5c694a41bbeb40268a23b8035b9b96959c4b2faaf
5
+ SHA512:
6
+ metadata.gz: a17e805f6629de33133964d6ef890f83acdddec724698a3c6dc09d069521331670284c41f7350976d2d1d85986ca4a0671bef22aca903ef0a60d867bacc14a2e
7
+ data.tar.gz: 0dac8a633d821dcfbdf21ca532b535d8a7aaf16c397062c2ac86ff0e8500b55f2272623f7e4b2a617f4488c3c67b4e1f72c10cf85c52dab34b387d45c61c80ea
@@ -0,0 +1,7 @@
1
+ # ace-runtime default configuration
2
+ # Override in ~/.ace/runtime/config.yml or .ace/runtime/config.yml
3
+
4
+ # Terminal runtime used by ace-runtime send when neither --runtime nor
5
+ # ACE_RUNTIME is set. Leave null for auto-detection from the environment;
6
+ # tmux wins when both tmux and herdr environments are live.
7
+ runtime: null
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-09-28
10
+
11
+ ### Added
12
+
13
+ - Runtime-neutral terminal intent contract (spec 8wq.t.k86.0): duck-typed adapter registry with fail-closed resolution and lazy `ace/runtime/adapters/<name>` entrypoint loading.
14
+ - Side-effect-free environment detection (`Ace::Runtime.detect`) with documented tmux precedence when both runtimes are live.
15
+ - Shared window/tab name sanitization (`Ace::Runtime.sanitize_name`).
16
+ - The 11-operation intent API with opaque adapter-owned target handles, seconds-based wait timeouts, and the typed error model (`UnknownRuntimeError`, `RuntimeUnavailableError`, `TargetNotFoundError`, `WindowConflictError`, `SendRejectedError`, `SendStalledError`, `WaitTimeoutError`).
17
+ - Central send-matrix validation (`Ace::Runtime::Atoms::SendContract`) for `:plain_pane` and `:agent_aware` profiles, including ordered `{message:}/{key:}` items and the callback shapes that submit exactly once.
18
+ - The single neutral callback CLI: `ace-runtime send`.
19
+ - The packaged adapter contract-test suite (`Ace::Runtime::Testing::AdapterContract`) with the `ScriptedRuntime` fixture, documented as the acceptance bar for any adapter.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Michal Czyz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # ace-runtime
2
+
3
+ Runtime-neutral terminal intent contract for ACE (spec 8wq.t.k86.0). One
4
+ duck-typed intent API that terminal runtimes implement and all consumers
5
+ call — the structural change that makes herdr an equal partner to tmux
6
+ instead of a side gem.
7
+
8
+ Adapters live INSIDE the existing wrapper gems: `ace-tmux` and `ace-herdr`
9
+ gain a dependency on `ace-runtime` and ship an adapter entrypoint. This
10
+ gem depends on neither (ADR-033 stability rationale; Captain decision
11
+ 2026-09-27).
12
+
13
+ ## The contract
14
+
15
+ Eleven operations, keyword-arg surfaces mirroring today's consumer calls:
16
+
17
+ | Operation | Surface |
18
+ |-----------|---------|
19
+ | Context | `runtime.context` → `{in_runtime:, session:, window:, pane:}` |
20
+ | Ensure window | `runtime.ensure_window(name:, root:, preset: nil)` — idempotent by normalized name; conflicting root/preset raises `WindowConflictError` |
21
+ | Prepare pane | `runtime.prepare_pane(window:)` — splits when needed; target stays alive after a submitted command exits |
22
+ | Focus | `runtime.focus(window:)` |
23
+ | Send | `runtime.send(pane:, command: nil, items: [])` — ordered `{message: String}` / `{key: String}` entries |
24
+ | Capture | `runtime.capture(pane:, lines: 40)` → text |
25
+ | Wait output | `runtime.wait_output(pane:, pattern:, timeout:)` |
26
+ | Wait agent | `runtime.wait_agent(pane:, states:, timeout:)` |
27
+ | Wait lifecycle | `runtime.wait_lifecycle(condition:, target:, timeout:)` — `window-exits`/`window-active`/`pane-exists`/`pane-exited` observations only |
28
+ | Close | `runtime.close_window(window:)` |
29
+ | List | `runtime.list_windows` / `runtime.list_panes(window:)` |
30
+
31
+ Convenience shapes: `send_command(pane:, command:)`,
32
+ `send_text(pane:, text:)`, `send_keys(pane:, keys:)`.
33
+
34
+ Target identity is opaque adapter-owned handles at every boundary — the
35
+ contract never predicts or formats pane ids (tmux `%id` and herdr
36
+ `w1:p1` stay adapter-internal). Window operations take the (sanitized)
37
+ window name; pane operations take the handle returned by the adapter.
38
+
39
+ Timeouts at the contract level are SECONDS; adapters convert to native
40
+ units internally.
41
+
42
+ ## Resolution and selection
43
+
44
+ ```ruby
45
+ runtime = Ace::Runtime.resolve("tmux") # fail-closed; unknown name raises
46
+ # UnknownRuntimeError (available: ...)
47
+ name = Ace::Runtime.detect(env: ENV) # :tmux | :herdr | nil, no side effects
48
+ ```
49
+
50
+ CLI/runtime selection precedence (highest wins):
51
+
52
+ 1. explicit name (`ace-runtime send --runtime herdr`)
53
+ 2. `ACE_RUNTIME` environment (consumer forks set this plus target context)
54
+ 3. configured runtime — `.ace/runtime/config.yml` key `runtime`
55
+ 4. auto-detection from the environment (`TMUX` / `ACE_TMUX_SESSION`, or
56
+ `HERDR_SESSION` + `HERDR_PANE`). When both are live, tmux wins
57
+ (existing default). Only assign auto launch mode selects headless
58
+ outside any runtime; that decision lives above this contract.
59
+
60
+ An explicitly selected runtime that turns out unknown or unavailable
61
+ fails closed — never a silent switch or headless downgrade.
62
+
63
+ ## Send matrix
64
+
65
+ `send` is the only submission primitive; the granular ops are shapes of
66
+ it. `items` is an ORDERED list — the only shape that preserves
67
+ message/key interleaving. When `command` is present, `items` holds
68
+ exclusively post-command keys; leading keys are a usage error before any
69
+ transport call (the CLI enforces `--cmd` before every `--key`).
70
+
71
+ - **Plain-pane adapters** (`send_profile == :plain_pane`, e.g. tmux):
72
+ messages are raw text (no implicit submission), keys are keystrokes,
73
+ each Enter submits pending text; `command` submits once, then trailing
74
+ keys deliver.
75
+ - **Agent-aware adapters** (`send_profile == :agent_aware`, e.g. herdr):
76
+ `command` or the concatenated messages become ONE prompt that submits
77
+ itself (message-only input DOES submit once on agent panes — intended
78
+ divergence); at most one trailing Enter is dropped and reported via
79
+ the result; keys-only sequences go to agent keys; every other
80
+ interleaving is rejected before any transport call.
81
+
82
+ An invocation with no send content at all is a usage error on both
83
+ profiles.
84
+
85
+ ## Error model
86
+
87
+ All failures subclass `Ace::Runtime::Error`:
88
+
89
+ - `UnknownRuntimeError` — with the sorted available list
90
+ - `RuntimeUnavailableError` — known runtime, unusable right now
91
+ - `TargetNotFoundError` — window/pane target does not exist
92
+ - `WindowConflictError` — same normalized name, incompatible root/preset
93
+ - `SendRejectedError` — pre-send rejection; terminal, no transport call
94
+ - `SendStalledError` — submission accepted but the target never started
95
+ processing; OUTCOME UNCERTAIN, callers must not auto-resend
96
+ - `WaitTimeoutError` — a wait exceeded its deadline (seconds context)
97
+
98
+ `pane-exited` is a runtime observation only — never assignment success,
99
+ receipt confirmation, or prune authorization.
100
+
101
+ ## Adapter contract
102
+
103
+ Adapters are duck-typed (no base class, ace-hitl provider-registry
104
+ pattern). To register:
105
+
106
+ ```ruby
107
+ # lib/ace/runtime/adapters/<name>.rb in the adapter package
108
+ Ace::Runtime.register(:<name>, -> { Ace::Tmux::RuntimeAdapter.new })
109
+ ```
110
+
111
+ The contract resolves an unregistered known name by attempting to load
112
+ that entrypoint lazily — no gem dependency in either direction.
113
+
114
+ The packaged shared suite `Ace::Runtime::Testing::AdapterContract` is
115
+ the acceptance bar for ANY adapter. See `docs/usage.md` for the
116
+ adapter-authoring walkthrough.
117
+
118
+ Note: adapters intentionally define `send` (the intent operation),
119
+ which overrides `Object#send`. Use `__send__` for reflective dispatch
120
+ on adapter objects.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << "test" << "lib"
8
+ t.test_files = FileList["test/**/*_test.rb"]
9
+ end
10
+
11
+ task spec: :test
12
+ task default: :test
data/docs/usage.md ADDED
@@ -0,0 +1,189 @@
1
+ # ace-runtime usage
2
+
3
+ Runtime-neutral terminal intent contract. This document covers the
4
+ `ace-runtime send` callback CLI, direct API send shapes, runtime
5
+ selection, and adapter authoring against the shared contract suite.
6
+
7
+ ## CLI: the neutral callback passthrough
8
+
9
+ The gem ships exactly one command (Fork Callback Rule decision,
10
+ 2026-09-27). It resolves the configured runtime and delegates to
11
+ `runtime.send` with the normative matrix semantics.
12
+
13
+ ```bash
14
+ # Submit a command (typed + Enter once) on the current pane's runtime
15
+ ace-runtime send --pane %1 --cmd 'bundle exec rake test'
16
+
17
+ # Callback form: submits exactly once on BOTH adapters
18
+ ace-runtime send --pane %1 --msg 'Reply with exactly: pong' --key Enter
19
+
20
+ # Explicit runtime selection
21
+ ace-runtime send --runtime herdr --pane w1:p1 --msg 'hello'
22
+
23
+ # Keys-only (valid; no implicit submission)
24
+ ace-runtime send --pane %1 --key C-c
25
+
26
+ # Command with post-submission keystrokes
27
+ ace-runtime send --pane %1 --cmd 'vim .' --key C-w
28
+ ```
29
+
30
+ Rules enforced before any transport call:
31
+
32
+ - `--pane` is required; at least one of `--cmd`, `--msg`, `--key` is
33
+ required (none = usage error).
34
+ - `--key` before `--cmd` is a usage error — `--cmd` must be declared
35
+ before every key. Keys declared after `--cmd` are post-submission
36
+ keystrokes.
37
+ - The CLI builds direct-API items as all supplied messages followed by
38
+ all supplied keys. Direct Ruby callers use `send(items:)` when they
39
+ need arbitrary interleaving (for example `msg, key, msg` on plain
40
+ panes).
41
+ - On agent panes (`:agent_aware` profile) the callback shapes submit
42
+ exactly once: `--msg ... --key Enter` becomes one self-submitting
43
+ prompt with the trailing Enter dropped and reported; interleaved
44
+ items, multiple Enters, `--cmd` with messages, and `--cmd` with
45
+ non-Enter keys are rejected.
46
+
47
+ Exit behavior: contract errors (`UnknownRuntimeError`,
48
+ `RuntimeUnavailableError`, `TargetNotFoundError`, `SendRejectedError`,
49
+ `SendStalledError`, ...) surface as CLI errors with their message;
50
+ `--quiet` suppresses the success line.
51
+
52
+ ## Direct API
53
+
54
+ ```ruby
55
+ runtime = Ace::Runtime.resolve(:tmux) # or let selection resolve for you
56
+ Ace::Runtime.detect(env: ENV) # => :tmux | :herdr | nil (no side effects)
57
+
58
+ # Ordered items preserve interleaving (plain-pane example)
59
+ runtime.send(pane: handle, items: [
60
+ {message: "switch to the topics tab"},
61
+ {key: "C-c"},
62
+ {message: "then restart the worker"}
63
+ ])
64
+
65
+ # Convenience shapes
66
+ runtime.send_command(pane: handle, command: "bundle exec rake test")
67
+ runtime.send_text(pane: handle, text: "plain text, no submission")
68
+ runtime.send_keys(pane: handle, keys: %w[Enter C-c])
69
+
70
+ # Waits: timeouts in SECONDS (adapters convert internally)
71
+ runtime.wait_output(pane: handle, pattern: "1 example, 0 failures", timeout: 30)
72
+ runtime.wait_agent(pane: handle, states: %w[idle working-done], timeout: 600)
73
+ runtime.wait_lifecycle(condition: "pane-exited", target: handle, timeout: 60)
74
+
75
+ # Windows and panes
76
+ handle = runtime.ensure_window(name: "work fs", root: "/repo", preset: nil)
77
+ pane = runtime.prepare_pane(window: "work-fs") # split when needed, retained
78
+ runtime.focus(window: "work-fs")
79
+ runtime.list_windows
80
+ runtime.list_panes(window: "work-fs")
81
+ runtime.close_window(window: "work-fs")
82
+ Ace::Runtime.sanitize_name("my work!") # => "my-work"
83
+ ```
84
+
85
+ `ensure_window` identity is `(resolved session/workspace, sanitized
86
+ name)`; an existing window with an incompatible root or preset raises
87
+ `WindowConflictError` instead of silently reusing the wrong worktree.
88
+
89
+ `wait_lifecycle` accepts only `window-exists`, `window-active`,
90
+ `pane-exists`, `pane-exited`. It reports the condition only — never
91
+ authorizes completion, receipts, or pruning. `pane-exited` in
92
+ particular is an observation, not assignment success or safe prune
93
+ evidence.
94
+
95
+ ## Runtime selection order
96
+
97
+ 1. explicit name (`--runtime tmux|herdr` / programmatic explicit)
98
+ 2. `ACE_RUNTIME` (consumer forks set this plus target context to the
99
+ caller backend)
100
+ 3. configured `runtime:` key under the `ace-runtime` config namespace
101
+ (`.ace/runtime/config.yml`, `~/.ace/runtime/config.yml`)
102
+ 4. auto-detection: `TMUX` or `ACE_TMUX_SESSION` → tmux;
103
+ `HERDR_SESSION` + `HERDR_PANE` → herdr; both live → tmux
104
+ (documented default); neither → nil
105
+
106
+ Explicit selections never downgrade: unknown names fail closed with
107
+ the available list (`UnknownRuntimeError`); a selected-but-unavailable
108
+ runtime raises `RuntimeUnavailableError` — no headless fallback, no
109
+ silent switch. The assign auto launch mode is the only consumer that
110
+ selects headless outside any runtime, and it does so above this
111
+ contract.
112
+
113
+ ## Adapter authoring
114
+
115
+ Adapters are duck-typed — no base class (ace-hitl provider-registry
116
+ pattern). An adapter package:
117
+
118
+ 1. declares a dependency on `ace-runtime` (never the reverse),
119
+ 2. ships an entrypoint `lib/ace/runtime/adapters/<name>.rb`:
120
+
121
+ ```ruby
122
+ Ace::Runtime.register(:herdr, -> { Ace::Herdr::RuntimeAdapter.new })
123
+ ```
124
+
125
+ 3. implements the full published intent API (context, ensure_window,
126
+ prepare_pane, focus, send, send_command, send_text, send_keys,
127
+ capture, wait_output, wait_agent, wait_lifecycle, close_window,
128
+ list_windows, list_panes) and exposes `send_profile`
129
+ (`:plain_pane` or `:agent_aware`),
130
+ 4. normalizes public send input through
131
+ `Ace::Runtime::Atoms::SendContract.normalize!` BEFORE transport so
132
+ rejection never reaches native calls,
133
+ 5. translates native failures at the boundary: unknown target →
134
+ `TargetNotFoundError`, unreachable runtime →
135
+ `RuntimeUnavailableError`, accepted-but-not-processing →
136
+ `SendStalledError` (never auto-resend), deadline exceeded →
137
+ `WaitTimeoutError` with seconds context.
138
+
139
+ Non-negotiables enforced by the contract suite:
140
+
141
+ - target handles stay opaque at every public boundary,
142
+ - `prepare_pane` yields a writable live shell/agent target retained
143
+ after a submitted command exits (bare command panes do not satisfy
144
+ preparation),
145
+ - `detect` and adapter availability checks are side-effect-free; an
146
+ unavailable explicit runtime never becomes headless,
147
+ - installed tests must verify both runtimes; never assert untested
148
+ behavior.
149
+
150
+ ### The acceptance bar
151
+
152
+ `Ace::Runtime::Testing::AdapterContract` is the shared contract-test
153
+ suite and the acceptance bar for ANY adapter. In your adapter's test
154
+ helper:
155
+
156
+ ```ruby
157
+ require "ace/runtime/testing"
158
+
159
+ class TmuxAdapterContractTest < AceTestCase
160
+ include Ace::Runtime::Testing::AdapterContract
161
+ include Ace::Runtime::Testing::AdapterContract::PlainPaneSendMatrix
162
+
163
+ def build_fixture
164
+ Ace::Runtime::Testing::ScriptedRuntime.new
165
+ end
166
+
167
+ def build_adapter(fixture)
168
+ Ace::Tmux::RuntimeAdapter.new(fixture: fixture) # thin bridge to your transport
169
+ end
170
+ end
171
+ ```
172
+
173
+ The suite drives your adapter over `ScriptedRuntime` — an in-memory
174
+ terminal (windows, panes, raw text writes, named keys, capture, agent
175
+ state) that records every mutation op so the suite can assert delivery
176
+ ordering and rejection-before-transport. Wire your adapter's native
177
+ transport to the fixture's primitive ops and translate its fixture
178
+ signals at the boundary:
179
+
180
+ | Fixture signal | Contract error |
181
+ |----------------|----------------|
182
+ | `Testing::FixtureUnavailable` | `RuntimeUnavailableError` |
183
+ | `Testing::FixtureStall` | `SendStalledError` |
184
+ | `Testing::FixtureTargetMissing` | `TargetNotFoundError` |
185
+
186
+ The reference implementation is `test/support/fake_runtime_adapter.rb`
187
+ in the ace-runtime gem; the in-gem suite
188
+ (`test/contract/adapter_contract_test.rb`) proves the battery against
189
+ it for both send profiles.
data/exe/ace-runtime ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "ace/runtime"
6
+
7
+ # No args → show help
8
+ args = ARGV.empty? ? ["--help"] : ARGV
9
+
10
+ # Start CLI with exception-based exit code handling (per ADR-023)
11
+ begin
12
+ exit_code = Ace::Runtime::CLI.start(args)
13
+ exit(exit_code) if exit_code.is_a?(Integer) && exit_code.nonzero?
14
+ rescue Ace::Support::Cli::Error => e
15
+ warn e.message
16
+ exit(e.exit_code)
17
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Atoms
6
+ # Pure environment detection. Reads the provided env only: no
7
+ # adapter loads, no transport calls, no other side effects.
8
+ # tmux wins when both environments are live (existing default,
9
+ # documented in README).
10
+ module Detector
11
+ module_function
12
+
13
+ def detect(env: ENV)
14
+ return :tmux if tmux_live?(env)
15
+ return :herdr if herdr_live?(env)
16
+
17
+ nil
18
+ end
19
+
20
+ def tmux_live?(env)
21
+ nonblank?(env["TMUX"]) || nonblank?(env["ACE_TMUX_SESSION"])
22
+ end
23
+
24
+ def herdr_live?(env)
25
+ nonblank?(env["HERDR_SESSION"]) && nonblank?(env["HERDR_PANE"])
26
+ end
27
+
28
+ def nonblank?(value)
29
+ !value.to_s.strip.empty?
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Atoms
6
+ # Shared window/tab naming policy, extracted from the established
7
+ # tmux sanitizer so every runtime normalizes identically. The
8
+ # ensure_window identity is (current session/workspace, sanitized
9
+ # name); deterministic sanitization is what makes the idempotent
10
+ # by-name lookup stable.
11
+ module NameSanitizer
12
+ FALLBACK = "window"
13
+
14
+ module_function
15
+
16
+ def call(value, fallback: FALLBACK)
17
+ sanitized = sanitize(value)
18
+ return sanitized unless sanitized.empty?
19
+
20
+ fallback_result = sanitize(fallback)
21
+ fallback_result.empty? ? FALLBACK : fallback_result
22
+ end
23
+
24
+ def sanitize(value)
25
+ value.to_s
26
+ .gsub(/[^A-Za-z0-9_-]+/, "-")
27
+ .gsub(/-+/, "-")
28
+ .gsub(/\A-|-+\z/, "")
29
+ end
30
+ private_class_method :sanitize
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Runtime
5
+ module Atoms
6
+ # Central send-shape validation and normalization. Runs BEFORE any
7
+ # adapter transport call: a SendRejectedError guarantees the
8
+ # adapter made no native call. Adapters declare exactly one
9
+ # send_profile (:plain_pane or :agent_aware) and normalize their
10
+ # public send input through this contract; the shared adapter
11
+ # contract suite (Ace::Runtime::Testing::AdapterContract) enforces
12
+ # the normative matrix for both profiles.
13
+ #
14
+ # Normalized request shape:
15
+ # - plain_pane: `command` is submitted once (with Enter); `items`
16
+ # are delivered afterwards in order — messages as raw text
17
+ # (no implicit submission), keys as keystrokes.
18
+ # - agent_aware: `command` is always nil; the prompt (from
19
+ # `command` or the concatenated messages) is the single leading
20
+ # `{message:}` entry and submits itself; remaining items are
21
+ # trailing keys. At most one trailing Enter is dropped and
22
+ # reported via `dropped_trailing_enter`.
23
+ module SendContract
24
+ PROFILES = %i[plain_pane agent_aware].freeze
25
+
26
+ # The four lifecycle observations wait_lifecycle accepts; a wait
27
+ # reports the condition only and never authorizes completion,
28
+ # receipts, or pruning.
29
+ LIFECYCLE_CONDITIONS = %w[window-exists window-active pane-exists pane-exited].freeze
30
+
31
+ Request = Struct.new(:profile, :command, :items, :dropped_trailing_enter, keyword_init: true)
32
+ Result = Struct.new(:dropped_trailing_enter, keyword_init: true)
33
+
34
+ ENTER_PATTERN = /\Aenter\z/i
35
+ PROMPT_JOIN = "\n"
36
+
37
+ module_function
38
+
39
+ def normalize!(command:, items:, profile:)
40
+ unless PROFILES.include?(profile)
41
+ raise ArgumentError, "unknown send profile '#{profile}' (expected one of: #{PROFILES.join(', ')})"
42
+ end
43
+
44
+ normalized_command = normalize_command(command)
45
+ normalized_items = normalize_items(items)
46
+ require_content!(normalized_command, normalized_items)
47
+ command_requires_key_items!(normalized_command, normalized_items)
48
+
49
+ return plain_request(normalized_command, normalized_items) if profile == :plain_pane
50
+
51
+ agent_aware_request(normalized_command, normalized_items)
52
+ end
53
+
54
+ # Convenience shape: send(command:) — submit once.
55
+ def command_request(command:, profile:)
56
+ normalize!(command: command, items: [], profile: profile)
57
+ end
58
+
59
+ # Convenience shape: send(items: keys.map { {key: _1} }).
60
+ def keys_request(keys, profile:)
61
+ items = Array(keys).map { |name| {key: name} }
62
+ normalize!(command: nil, items: items, profile: profile)
63
+ end
64
+
65
+ def normalize_command(command)
66
+ text = command.to_s
67
+ return nil if text.strip.empty?
68
+
69
+ text
70
+ end
71
+
72
+ def normalize_items(items)
73
+ Array(items).each_with_index.map { |item, index| normalize_item(item, index) }
74
+ end
75
+
76
+ private_class_method def self.normalize_item(item, index)
77
+ unless item.is_a?(Hash) && item.size == 1
78
+ raise SendRejectedError,
79
+ "items[#{index}] must be a single-key {message: String} or {key: String} hash"
80
+ end
81
+
82
+ name, value = item.first
83
+ case name.to_s
84
+ when "message"
85
+ unless value.is_a?(String) && !value.empty?
86
+ raise SendRejectedError, "items[#{index}] message must be a non-empty String"
87
+ end
88
+
89
+ {message: value}
90
+ when "key"
91
+ key_name = value.to_s.strip
92
+ raise SendRejectedError, "items[#{index}] key must be a non-empty key name" if key_name.empty?
93
+
94
+ {key: key_name}
95
+ else
96
+ raise SendRejectedError, "items[#{index}] must use :message or :key (got :#{name})"
97
+ end
98
+ end
99
+
100
+ def require_content!(command, items)
101
+ return if command || !items.empty?
102
+
103
+ raise SendRejectedError, "send requires content: a nonblank command or at least one message/key item"
104
+ end
105
+
106
+ def command_requires_key_items!(command, items)
107
+ return unless command
108
+ return if items.all? { |item| item.key?(:key) }
109
+
110
+ raise SendRejectedError,
111
+ "command submits on its own: when command is present, items must be post-command keys only"
112
+ end
113
+
114
+ def plain_request(command, items)
115
+ Request.new(profile: :plain_pane, command: command, items: items, dropped_trailing_enter: false)
116
+ end
117
+
118
+ def agent_aware_request(command, items)
119
+ messages = items.select { |item| item.key?(:message) }
120
+ keys = items.select { |item| item.key?(:key) }
121
+ enter_count = keys.count { |item| enter?(item[:key]) }
122
+ raise SendRejectedError, "agent panes accept at most one Enter per send" if enter_count > 1
123
+
124
+ if command
125
+ non_enter_keys = keys.reject { |item| enter?(item[:key]) }
126
+ unless non_enter_keys.empty?
127
+ raise SendRejectedError, "command on an agent pane only supports a single trailing Enter key"
128
+ end
129
+
130
+ return prompt_request(command, dropped: enter_count == 1)
131
+ end
132
+
133
+ if messages.any?
134
+ if keys.length > 1 || (keys.length == 1 && !enter?(keys.first[:key]))
135
+ raise SendRejectedError,
136
+ "agent panes reject interleaved input: messages may only be followed by a single Enter"
137
+ end
138
+
139
+ return prompt_request(messages.map { |item| item[:message] }.join(PROMPT_JOIN), dropped: enter_count == 1)
140
+ end
141
+
142
+ Request.new(profile: :agent_aware, command: nil, items: keys, dropped_trailing_enter: false)
143
+ end
144
+
145
+ def prompt_request(prompt, dropped:)
146
+ Request.new(
147
+ profile: :agent_aware,
148
+ command: nil,
149
+ items: [{message: prompt}],
150
+ dropped_trailing_enter: dropped
151
+ )
152
+ end
153
+
154
+ def enter?(key_name)
155
+ !key_name.to_s.strip.match(ENTER_PATTERN).nil?
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end