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 +7 -0
- data/.ace-defaults/runtime/config.yml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +120 -0
- data/Rakefile +12 -0
- data/docs/usage.md +189 -0
- data/exe/ace-runtime +17 -0
- data/lib/ace/runtime/atoms/detector.rb +34 -0
- data/lib/ace/runtime/atoms/name_sanitizer.rb +34 -0
- data/lib/ace/runtime/atoms/send_contract.rb +160 -0
- data/lib/ace/runtime/cli/commands/send.rb +120 -0
- data/lib/ace/runtime/cli.rb +69 -0
- data/lib/ace/runtime/errors.rb +60 -0
- data/lib/ace/runtime/molecules/runtime_selector.rb +73 -0
- data/lib/ace/runtime/registry.rb +68 -0
- data/lib/ace/runtime/testing/adapter_contract.rb +597 -0
- data/lib/ace/runtime/testing/scripted_runtime.rb +224 -0
- data/lib/ace/runtime/testing.rb +9 -0
- data/lib/ace/runtime/version.rb +7 -0
- data/lib/ace/runtime.rb +102 -0
- metadata +194 -0
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
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
|