feat: add experimental pushy delivery mode
Some checks failed
tamq-ci / test (push) Has been cancelled
Some checks failed
tamq-ci / test (push) Has been cancelled
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
8186550c9a
commit
9ed8b62b45
16 changed files with 441 additions and 31 deletions
49
README.md
49
README.md
|
|
@ -54,13 +54,37 @@ tamq start --command 'htop --tree' flex-auth audit-core
|
|||
`--cmd` remains an alias for `--command`. Repeated starts reuse existing
|
||||
windows and never run another initial command in them.
|
||||
|
||||
By default, a routed message is also written as sanitized output to its target
|
||||
pane. Use `--no-display` when you want durable inbox-only delivery:
|
||||
Select an endpoint delivery mode at startup. `output` is the safe default;
|
||||
`inbox` keeps messages durable without displaying them:
|
||||
|
||||
```bash
|
||||
tamq --no-display flex-auth audit-core
|
||||
tamq --mode output flex-auth audit-core
|
||||
tamq --mode inbox flex-auth audit-core
|
||||
```
|
||||
|
||||
`--no-display` remains a compatibility alias for `--mode inbox`.
|
||||
|
||||
Experimental `pushy` mode submits each routed message to the target pane as
|
||||
input. It is intended for coding-agent interfaces that queue user prompts:
|
||||
|
||||
```bash
|
||||
tamq --mode pushy --command codex flex-auth audit-core
|
||||
```
|
||||
|
||||
The submitted line is sanitized and sender-labelled, then followed by exactly
|
||||
one Enter key:
|
||||
|
||||
```text
|
||||
#flex-auth: please review the auth boundary [m-...]
|
||||
```
|
||||
|
||||
This leading `#` makes an empty ordinary shell prompt treat the line as a
|
||||
comment. Pushy mode cannot determine whether a pane is an agent, a shell, or
|
||||
whether someone is already editing input: it can append to that input and
|
||||
submit the combined line. Use it only for panes whose occupant is known to
|
||||
accept or queue asynchronous prompts. Switching an existing endpoint to pushy
|
||||
may also submit pending messages that have never been displayed.
|
||||
|
||||
## Exchange messages manually
|
||||
|
||||
Each managed shell exports its own repository slug as `TAMQ_REPO`. From the
|
||||
|
|
@ -152,9 +176,11 @@ it and all later messages pending. Filters never run in the background and
|
|||
cannot be combined with `--all` or `--json`.
|
||||
|
||||
Outside a managed window, use `tamq inbox --repo audit-core` and optionally
|
||||
`--json`. Displayed messages remain durable and pending until acknowledged.
|
||||
Neither normal output delivery nor `--no-display` injects terminal keystrokes,
|
||||
so they cannot execute or alter a command being typed in the target pane.
|
||||
`--json`. Output-displayed messages remain durable and pending until
|
||||
acknowledged. Neither `output` nor `inbox` mode injects terminal keystrokes, so
|
||||
they cannot execute or alter a command being typed in the target pane. Pushy
|
||||
mode intentionally crosses that boundary and records accepted submissions as
|
||||
`injected`.
|
||||
|
||||
After upgrading from an earlier alpha, recreate the managed session once so
|
||||
existing panes inherit the neutral shell contract, repository command `PATH`,
|
||||
|
|
@ -208,10 +234,13 @@ observation and message identity.
|
|||
|
||||
Normal endpoints use terminal-output delivery: each message is written once to
|
||||
the target pane's PTY output and stays pending in the durable inbox until
|
||||
acknowledged. `--no-display` selects inbox-only manual mode. Neither becomes
|
||||
pane input. The legacy pane-input path is available only with the explicit
|
||||
`tamq start --tap --command ...` opt-in; it remains subject to the retry and
|
||||
acknowledgement limitations tracked by `TAMQ-WP-0003`.
|
||||
acknowledged. `--mode inbox` selects inbox-only manual mode. Neither becomes
|
||||
pane input. Experimental `--mode pushy` sends one checked tmux command list
|
||||
containing sanitized literal input followed by Enter, then records the message
|
||||
as `injected`. The older full-duplex pane-input path remains available only
|
||||
with the explicit `tamq start --tap --command ...` opt-in. Both input paths
|
||||
remain subject to the retry and acknowledgement limitations tracked by
|
||||
`TAMQ-WP-0003`.
|
||||
The visible endpoint label is `tmux-amq-<PID>`; each boot also receives an
|
||||
instance nonce so PID reuse cannot collide with prior leases or receipts.
|
||||
|
||||
|
|
|
|||
14
SCOPE.md
14
SCOPE.md
|
|
@ -21,6 +21,8 @@ tamq does not choose or infer them.
|
|||
display, a recipient-aware inline composer, and explicit pull-time filters.
|
||||
- Sanitized one-time output notifications through target tmux pane PTYs, with
|
||||
inbox-only delivery as an explicit option and no foreground-process input.
|
||||
- Explicit experimental pushy delivery that submits a sanitized comment and
|
||||
Enter to target pane input for known queue-capable interactive programs.
|
||||
- Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY
|
||||
broker for integration experiments.
|
||||
- Exact `gita` repository validation and direct `@repo: message` routing.
|
||||
|
|
@ -34,8 +36,9 @@ tamq does not choose or infer them.
|
|||
- Owning goal planning, workflow scheduling, or cross-host coordination; those
|
||||
belong to `coordination-engine` and its consumers.
|
||||
- Acting as a network-accessible or multi-host message broker.
|
||||
- Selecting a coding agent, starting one implicitly, or assuming a pane accepts
|
||||
machine-generated terminal input.
|
||||
- Selecting a coding agent, starting one implicitly, or silently assuming a
|
||||
pane accepts machine-generated input; pushy delivery requires explicit mode
|
||||
selection by the operator.
|
||||
- Bypassing `gita` registration or injecting arbitrary pane input outside the
|
||||
supported tap/control-mode boundaries.
|
||||
- Owning tmux, Codex, State Hub, or adjacent repositories' lifecycle.
|
||||
|
|
@ -53,6 +56,7 @@ transport.
|
|||
| Local socket service | Implemented | Peer-credential checks and structured ping/register/send/history/ack/endpoints/disconnect operations are tested. |
|
||||
| Neutral tmux session lifecycle | Implemented for local alpha | Repository-first startup opens untouched shells at exact gita paths, exports per-window identity, and runs no initial command unless `--command` is explicit. Stable reuse, service restart, and cleanup are covered by the installed-package test. |
|
||||
| Safe manual messaging | Implemented for local alpha | Normal endpoints write one sanitized comment above a stable shell input row without injecting stdin; conservative fallback handles the first row and alternate screens. Messages remain pending until acknowledgement. Inbox-only manual mode is explicit with `--no-display`. |
|
||||
| Experimental pushy delivery | Explicit opt-in | `--mode pushy` submits one sanitized sender-labelled comment plus Enter through a checked tmux command. It marks successful delivery `injected`, but cannot identify pane occupants or protect input already being edited. |
|
||||
| Full-duplex input observation | Explicit opt-in | `--tap --command ...` enables the PTY integration path. It is absent from neutral startup and remains covered for geometry, resize, raw mouse input, and lifecycle behavior. |
|
||||
| Bounded retry behavior | Not enforced | Failed output or injection remains pending and becomes claimable after lease expiry, but no attempt counter or terminal failure state applies the configured cap. |
|
||||
| Acknowledgement policy | Partially implemented | Terminal output remains pending until explicit acknowledgement, while legacy pane injection becomes `injected`; the configured `delivery_ack_mode` does not yet govern both paths. |
|
||||
|
|
@ -76,6 +80,8 @@ Suitable today:
|
|||
gita-registered repositories.
|
||||
- Durable manual message exchange between managed repository windows, including
|
||||
explicit pull-time loggers and filters.
|
||||
- Controlled experiments with pushy delivery to coding-agent interfaces known
|
||||
to queue asynchronous user prompts.
|
||||
- Developing and testing the future coordination-engine adapter against the
|
||||
local socket boundary.
|
||||
|
||||
|
|
@ -83,11 +89,13 @@ Not yet suitable:
|
|||
|
||||
- Unattended pane injection where bounded retries and positive recipient
|
||||
acknowledgement are required.
|
||||
- Pushy delivery to arbitrary shells, editors, or panes with unknown input
|
||||
state.
|
||||
- Production-style operation without longer-running crash/terminal soak tests
|
||||
and stronger process-supervision evidence.
|
||||
- Cross-host messaging or use as a general-purpose broker.
|
||||
|
||||
The suite currently has 109 passing tests and 76% statement coverage. Coverage
|
||||
The suite currently has 117 passing tests and 77% statement coverage. Coverage
|
||||
is strongest in durable storage and registry handling, and weakest in the PTY
|
||||
tap and CLI orchestration; PTY statement coverage increased from 23% to 33%,
|
||||
while subprocess behavior is primarily proven by the real-tmux test. The
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
| workplan | TAMQ-WP-0007 | finished | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md |
|
||||
| workplan | TAMQ-WP-0008 | finished | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md |
|
||||
| workplan | TAMQ-WP-0009 | finished | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |
|
||||
| workplan | TAMQ-WP-0010 | active | — | workplans/TAMQ-WP-0010-experimental-pushy-delivery.md |
|
||||
| task | TAMQ-WP-ADHOC-2026-08-24-T01 | done | — | workplans/ADHOC-2026-08-24.md |
|
||||
| task | TAMQ-WP-0001-T01 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
|
||||
| task | TAMQ-WP-0001-T02 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
|
||||
|
|
@ -55,3 +56,6 @@
|
|||
| task | TAMQ-WP-0009-T01 | done | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |
|
||||
| task | TAMQ-WP-0009-T02 | done | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |
|
||||
| task | TAMQ-WP-0009-T03 | done | — | workplans/TAMQ-WP-0009-interactive-recipient-composer.md |
|
||||
| task | TAMQ-WP-0010-T01 | progress | — | workplans/TAMQ-WP-0010-experimental-pushy-delivery.md |
|
||||
| task | TAMQ-WP-0010-T02 | todo | — | workplans/TAMQ-WP-0010-experimental-pushy-delivery.md |
|
||||
| task | TAMQ-WP-0010-T03 | todo | — | workplans/TAMQ-WP-0010-experimental-pushy-delivery.md |
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from .terminal import format_comment
|
|||
SUBCOMMANDS = frozenset(
|
||||
"start attach serve stop status ping inbox history inspect ack send reply export replay purge completion db-version config tap".split()
|
||||
)
|
||||
START_OPTIONS = frozenset({"--command", "--cmd", "--tap", "--detach", "--no-service", "--no-display"})
|
||||
START_OPTIONS = frozenset({"--command", "--cmd", "--tap", "--detach", "--no-service", "--no-display", "--mode"})
|
||||
GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"})
|
||||
|
||||
|
||||
|
|
@ -129,6 +129,10 @@ def ensure_output_service() -> bool:
|
|||
return ensure_service_capabilities({"manual_delivery", "terminal_output"})
|
||||
|
||||
|
||||
def ensure_pushy_service() -> bool:
|
||||
return ensure_service_capabilities({"manual_delivery", "pushy_input"})
|
||||
|
||||
|
||||
def preflight_runtime_paths() -> None:
|
||||
"""Ensure local state/runtime parents are available before tmux mutation."""
|
||||
parents = {db_path().parent, socket_path().parent, pid_path().parent, lock_path().parent}
|
||||
|
|
@ -154,12 +158,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
description="Repository-aware tmux sessions with durable local messaging.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""session shorthand:
|
||||
tamq [--detach] [--command COMMAND] [--no-display] REPO [REPO ...]
|
||||
tamq [--detach] [--command COMMAND] [--mode MODE] REPO [REPO ...]
|
||||
|
||||
With no --command, tamq opens ordinary repository shells. --command is an
|
||||
explicit initial command for newly created windows. Messages appear as
|
||||
sanitized terminal output by default; --no-display keeps them inbox-only.
|
||||
Neither mode injects message bytes into pane input.
|
||||
sanitized terminal output by default. --mode inbox keeps messages durable
|
||||
without display; experimental --mode pushy submits them to target input.
|
||||
|
||||
manual messaging from a managed shell:
|
||||
@TARGET: MESSAGE...
|
||||
|
|
@ -178,7 +182,8 @@ manual messaging from a managed shell:
|
|||
start.add_argument("repos", nargs="*", help="gita-registered repository slugs")
|
||||
start.add_argument("--command", "--cmd", dest="initial_command", default=None, help="explicit initial command for newly created windows (default: ordinary shell)")
|
||||
start.add_argument("--tap", action="store_true", help="explicitly opt in to PTY input observation and pane message injection")
|
||||
start.add_argument("--no-display", action="store_true", help="keep messages in the durable inbox without writing target terminal output")
|
||||
start.add_argument("--mode", choices=("output", "inbox", "pushy"), help="message delivery mode (default: output; pushy is experimental)")
|
||||
start.add_argument("--no-display", action="store_true", help="compatibility alias for --mode inbox")
|
||||
start.add_argument("--detach", action="store_true", help="detach after startup")
|
||||
start.add_argument("--no-service", action="store_true", help="open tmux windows without service registration or messaging")
|
||||
attach = subparsers.add_parser("attach", help="attach to the managed tmux session")
|
||||
|
|
@ -251,7 +256,7 @@ def normalize_argv(argv: list[str]) -> list[str]:
|
|||
if index == len(argv):
|
||||
return argv
|
||||
candidate = argv[index]
|
||||
if candidate in START_OPTIONS or (
|
||||
if candidate in START_OPTIONS or candidate.startswith("--mode=") or (
|
||||
not candidate.startswith("-") and candidate not in SUBCOMMANDS
|
||||
):
|
||||
return [*argv[:index], "start", *argv[index:]]
|
||||
|
|
@ -307,6 +312,15 @@ def main(argv: list[str] | None = None) -> int:
|
|||
finally:
|
||||
store.close()
|
||||
if args.command == "start":
|
||||
if args.no_display and args.mode not in (None, "inbox"):
|
||||
print("tamq: --no-display conflicts with the selected --mode", file=sys.stderr)
|
||||
return 2
|
||||
selected_mode = args.mode or ("inbox" if args.no_display else "output")
|
||||
endpoint_delivery_mode = {
|
||||
"inbox": "manual",
|
||||
"output": "output",
|
||||
"pushy": "pushy",
|
||||
}[selected_mode]
|
||||
if args.tap and args.initial_command is None:
|
||||
print("tamq: --tap requires an explicit --command", file=sys.stderr)
|
||||
return 2
|
||||
|
|
@ -316,6 +330,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if args.tap and args.no_display:
|
||||
print("tamq: --tap cannot be combined with --no-display", file=sys.stderr)
|
||||
return 2
|
||||
if args.tap and args.mode is not None:
|
||||
print("tamq: --tap cannot be combined with --mode", file=sys.stderr)
|
||||
return 2
|
||||
if args.no_service and args.mode is not None:
|
||||
print("tamq: --mode cannot be combined with --no-service", file=sys.stderr)
|
||||
return 2
|
||||
manager = TmuxManager()
|
||||
try:
|
||||
if not args.no_service:
|
||||
|
|
@ -324,9 +344,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||
except (TmuxError, OSError) as exc:
|
||||
print(f"tamq: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
service_ready = (
|
||||
ensure_manual_service() if args.no_display else ensure_output_service()
|
||||
) if not args.no_service else True
|
||||
if args.no_service:
|
||||
service_ready = True
|
||||
elif selected_mode == "inbox":
|
||||
service_ready = ensure_manual_service()
|
||||
elif selected_mode == "pushy":
|
||||
service_ready = ensure_pushy_service()
|
||||
else:
|
||||
service_ready = ensure_output_service()
|
||||
if not service_ready:
|
||||
print("tamq service failed to start with the required delivery capability; use --no-service to open repos without messaging", file=sys.stderr)
|
||||
return 1
|
||||
|
|
@ -339,7 +364,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
registered = False
|
||||
if not args.no_service:
|
||||
try:
|
||||
delivery_mode = "pane" if args.tap else ("manual" if args.no_display else "output")
|
||||
delivery_mode = "pane" if args.tap else endpoint_delivery_mode
|
||||
registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos, "delivery_mode": delivery_mode}))
|
||||
if not registration.get("ok"):
|
||||
raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}")
|
||||
|
|
@ -367,7 +392,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||
"delivered": delivered,
|
||||
"service": not args.no_service,
|
||||
"messaging": registered,
|
||||
"delivery_mode": "none" if args.no_service else ("pane" if args.tap else ("manual" if args.no_display else "output")),
|
||||
"mode": "none" if args.no_service else ("tap" if args.tap else selected_mode),
|
||||
"delivery_mode": "none" if args.no_service else (
|
||||
"pane" if args.tap else endpoint_delivery_mode
|
||||
),
|
||||
}
|
||||
print(json.dumps(summary), flush=True)
|
||||
if args.detach:
|
||||
|
|
|
|||
|
|
@ -93,6 +93,24 @@ class ControlModeClient:
|
|||
# send-keys -l preserves punctuation and whitespace in the message.
|
||||
self.command(f"send-keys -t {window} -l -- {shell_quote(text)}")
|
||||
|
||||
def submit(self, window: str, text: str) -> None:
|
||||
"""Inject literal text and Enter as one checked tmux command list."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
*self._command(),
|
||||
"send-keys", "-t", window, "-l", "--", text,
|
||||
";",
|
||||
"send-keys", "-t", window, "Enter",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise ControlModeError(
|
||||
result.stderr.strip() or f"cannot submit pane input: {window}"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.process is not None:
|
||||
process = self.process
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from .config import db_path
|
|||
from .store import Store
|
||||
from .registry import RegistryError, validate_targets
|
||||
from .control import ControlModeClient
|
||||
from .terminal import terminal_frame, write_terminal_output
|
||||
from .terminal import format_pushy_input, terminal_frame, write_terminal_output
|
||||
|
||||
PROTOCOL_VERSION = "0.1"
|
||||
|
||||
|
|
@ -112,6 +112,15 @@ class Service:
|
|||
alternate_on=display.alternate_on,
|
||||
),
|
||||
)
|
||||
elif delivery_mode == "pushy":
|
||||
control.submit(
|
||||
target,
|
||||
format_pushy_input(
|
||||
row["sender_repo"],
|
||||
row["body"],
|
||||
row["message_id"],
|
||||
),
|
||||
)
|
||||
else:
|
||||
control.inject(target, f'#{row["sender_repo"]}: {row["body"]}')
|
||||
except Exception:
|
||||
|
|
@ -135,7 +144,7 @@ class Service:
|
|||
if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]:
|
||||
response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION}
|
||||
elif op == "ping":
|
||||
response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history", "manual_delivery", "terminal_output"]}
|
||||
response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history", "manual_delivery", "terminal_output", "pushy_input"]}
|
||||
elif op == "register":
|
||||
required = ("endpoint_id", "pid", "session", "repos")
|
||||
if any(key not in request for key in required):
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ class Store:
|
|||
delivery_mode: str = "manual",
|
||||
) -> None:
|
||||
import json
|
||||
if delivery_mode not in {"manual", "output", "pane"}:
|
||||
if delivery_mode not in {"manual", "output", "pane", "pushy"}:
|
||||
raise ValueError(f"invalid delivery mode: {delivery_mode}")
|
||||
self.db.execute(
|
||||
"UPDATE endpoints SET disconnected_at=strftime('%s','now') "
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ def format_comment(sender: str, body: str, message_id: str) -> str:
|
|||
return "\n".join(rendered)
|
||||
|
||||
|
||||
def format_pushy_input(sender: str, body: str, message_id: str) -> str:
|
||||
"""Render one shell-safe input line for explicit pushy delivery."""
|
||||
return (
|
||||
f"#{terminal_safe(sender)}: {terminal_safe(body)} "
|
||||
f"[{terminal_safe(message_id)}]"
|
||||
)
|
||||
|
||||
|
||||
def terminal_frame(
|
||||
sender: str,
|
||||
body: str,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ def test_root_help_exposes_repository_shorthand_and_command(capsys):
|
|||
main(["--help"])
|
||||
assert exc.value.code == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "tamq [--detach] [--command COMMAND] [--no-display] REPO" in output
|
||||
assert "tamq [--detach] [--command COMMAND] [--mode MODE] REPO" in output
|
||||
assert "With no --command" in output
|
||||
assert "@TARGET: MESSAGE" in output
|
||||
|
||||
|
|
@ -45,11 +45,13 @@ def test_start_parser_supports_command_and_cmd_alias():
|
|||
compatibility = parser.parse_args(["start", "--cmd", "claude", "a"])
|
||||
neutral = parser.parse_args(["start", "a", "b"])
|
||||
inbox_only = parser.parse_args(["start", "--no-display", "a"])
|
||||
pushy = parser.parse_args(["start", "--mode", "pushy", "a"])
|
||||
assert canonical.initial_command == "codex --quiet"
|
||||
assert canonical.repos == ["a", "b"]
|
||||
assert compatibility.initial_command == "claude"
|
||||
assert neutral.initial_command is None
|
||||
assert inbox_only.no_display is True
|
||||
assert pushy.mode == "pushy"
|
||||
|
||||
|
||||
def test_repository_first_and_command_first_start_shorthand():
|
||||
|
|
@ -69,6 +71,17 @@ def test_repository_first_and_command_first_start_shorthand():
|
|||
"--no-display",
|
||||
"flex-auth",
|
||||
]
|
||||
assert normalize_argv(["--mode", "pushy", "flex-auth"]) == [
|
||||
"start",
|
||||
"--mode",
|
||||
"pushy",
|
||||
"flex-auth",
|
||||
]
|
||||
assert normalize_argv(["--mode=pushy", "flex-auth"]) == [
|
||||
"start",
|
||||
"--mode=pushy",
|
||||
"flex-auth",
|
||||
]
|
||||
assert normalize_argv(["status"]) == ["status"]
|
||||
assert normalize_argv(["--verbose", "flex-auth", "audit-core"]) == [
|
||||
"--verbose",
|
||||
|
|
@ -179,6 +192,52 @@ def test_registration_failure_rolls_back_created_windows(monkeypatch, capsys):
|
|||
assert "endpoint registration failed: denied" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_pushy_mode_registers_explicit_delivery_mode(monkeypatch, capsys):
|
||||
endpoint = type(
|
||||
"Endpoint",
|
||||
(),
|
||||
{
|
||||
"endpoint_id": "tmux-amq-42",
|
||||
"instance_key": "tmux-amq-42-boot",
|
||||
"pid": 42,
|
||||
"session": "tamq",
|
||||
"repos": ["a", "b"],
|
||||
},
|
||||
)()
|
||||
requests = []
|
||||
|
||||
class Manager:
|
||||
def preflight(self, repos, command):
|
||||
assert repos == ["a", "b"]
|
||||
return "plan"
|
||||
|
||||
def ensure_plan(self, plan, *, tap=True):
|
||||
assert tap is False
|
||||
return endpoint
|
||||
|
||||
def rollback(self, value):
|
||||
pytest.fail("successful registration must not roll back")
|
||||
|
||||
async def register(payload):
|
||||
requests.append(payload)
|
||||
return {"ok": True}
|
||||
|
||||
monkeypatch.setattr("tamq.cli.TmuxManager", Manager)
|
||||
monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None)
|
||||
monkeypatch.setattr("tamq.cli.ensure_pushy_service", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
"tamq.cli.ensure_output_service",
|
||||
lambda: pytest.fail("output capability must not select pushy mode"),
|
||||
)
|
||||
monkeypatch.setattr("tamq.cli.request", register)
|
||||
|
||||
assert main(["start", "--detach", "--mode", "pushy", "a", "b"]) == 0
|
||||
summary = json.loads(capsys.readouterr().out)
|
||||
assert summary["mode"] == "pushy"
|
||||
assert summary["delivery_mode"] == "pushy"
|
||||
assert requests[0]["delivery_mode"] == "pushy"
|
||||
|
||||
|
||||
def test_tap_requires_explicit_command_and_service(capsys):
|
||||
assert main(["start", "--tap", "a"]) == 2
|
||||
assert "--tap requires an explicit --command" in capsys.readouterr().err
|
||||
|
|
@ -186,3 +245,9 @@ def test_tap_requires_explicit_command_and_service(capsys):
|
|||
assert "--tap cannot be combined with --no-service" in capsys.readouterr().err
|
||||
assert main(["start", "--tap", "--no-display", "--command", "sh", "a"]) == 2
|
||||
assert "--tap cannot be combined with --no-display" in capsys.readouterr().err
|
||||
assert main(["start", "--no-display", "--mode", "pushy", "a"]) == 2
|
||||
assert "conflicts with the selected --mode" in capsys.readouterr().err
|
||||
assert main(["start", "--tap", "--mode", "pushy", "--command", "sh", "a"]) == 2
|
||||
assert "--tap cannot be combined with --mode" in capsys.readouterr().err
|
||||
assert main(["start", "--no-service", "--mode", "pushy", "a"]) == 2
|
||||
assert "--mode cannot be combined with --no-service" in capsys.readouterr().err
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
from tamq.cli import ensure_manual_service, ensure_output_service, parse_size
|
||||
from tamq.cli import (
|
||||
ensure_manual_service,
|
||||
ensure_output_service,
|
||||
ensure_pushy_service,
|
||||
parse_size,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_size():
|
||||
|
|
@ -42,3 +47,23 @@ def test_output_service_requires_terminal_output_capability(monkeypatch):
|
|||
assert ensure_output_service() is True
|
||||
assert len(starts) == 2
|
||||
assert stops == [True]
|
||||
|
||||
|
||||
def test_pushy_service_requires_pushy_input_capability(monkeypatch):
|
||||
capabilities = iter([
|
||||
["manual_delivery", "terminal_output"],
|
||||
["manual_delivery", "terminal_output", "pushy_input"],
|
||||
])
|
||||
starts = []
|
||||
stops = []
|
||||
|
||||
async def request(payload):
|
||||
return {"capabilities": next(capabilities)}
|
||||
|
||||
monkeypatch.setattr("tamq.cli.ensure_service", lambda: starts.append(True) or True)
|
||||
monkeypatch.setattr("tamq.cli.stop_service_process", lambda: stops.append(True) or 42)
|
||||
monkeypatch.setattr("tamq.cli.request", request)
|
||||
|
||||
assert ensure_pushy_service() is True
|
||||
assert len(starts) == 2
|
||||
assert stops == [True]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from tamq import control
|
||||
from tamq.control import ControlModeClient, shell_quote
|
||||
import pytest
|
||||
|
||||
from tamq.control import ControlModeClient, ControlModeError, shell_quote
|
||||
from tamq.tmux import shell_join
|
||||
|
||||
|
||||
|
|
@ -27,3 +29,39 @@ def test_pane_tty_is_resolved_through_tmux(monkeypatch):
|
|||
"tmux", "-L", "test", "display-message", "-p", "-t",
|
||||
"tamq:audit-core", "#{pane_tty}|#{cursor_x}|#{cursor_y}|#{pane_height}|#{alternate_on}",
|
||||
]]
|
||||
|
||||
|
||||
def test_submit_sends_literal_input_then_one_enter(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
calls.append(command)
|
||||
return type("Result", (), {"returncode": 0, "stderr": ""})()
|
||||
|
||||
monkeypatch.setattr(control.subprocess, "run", run)
|
||||
client = ControlModeClient("tamq", tmux_command=("tmux", "-L", "test"))
|
||||
|
||||
client.submit("tamq:audit-core", "#flex-auth: What's up? [m-1]")
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
"tmux", "-L", "test",
|
||||
"send-keys", "-t", "tamq:audit-core", "-l", "--",
|
||||
"#flex-auth: What's up? [m-1]",
|
||||
";",
|
||||
"send-keys", "-t", "tamq:audit-core", "Enter",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
def test_submit_surfaces_tmux_rejection(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
control.subprocess,
|
||||
"run",
|
||||
lambda *args, **kwargs: type(
|
||||
"Result", (), {"returncode": 1, "stderr": "missing pane"}
|
||||
)(),
|
||||
)
|
||||
client = ControlModeClient("tamq")
|
||||
with pytest.raises(ControlModeError, match="missing pane"):
|
||||
client.submit("tamq:missing", "message")
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
|
|||
)
|
||||
assert run(str(tamq), "--version").stdout.strip() == "0.1.0"
|
||||
root_help = run(str(tamq), "--help").stdout
|
||||
assert "tamq [--detach] [--command COMMAND] [--no-display] REPO" in root_help
|
||||
assert "tamq [--detach] [--command COMMAND] [--mode MODE] REPO" in root_help
|
||||
started = json.loads(
|
||||
run(
|
||||
str(tamq),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ class FakeControl:
|
|||
def __init__(self, session):
|
||||
self.session = session
|
||||
self.injected = []
|
||||
self.submitted = []
|
||||
self.__class__.instances.append(self)
|
||||
|
||||
def start(self):
|
||||
|
|
@ -20,6 +21,9 @@ class FakeControl:
|
|||
def inject(self, window, text):
|
||||
self.injected.append((window, text))
|
||||
|
||||
def submit(self, window, text):
|
||||
self.submitted.append((window, text))
|
||||
|
||||
def pane_display(self, window):
|
||||
return PaneDisplay(
|
||||
tty_path=f"/dev/pts/{window.rsplit(':', 1)[-1]}",
|
||||
|
|
@ -56,6 +60,41 @@ def test_service_never_injects_manual_endpoint_messages(tmp_path, monkeypatch):
|
|||
assert store.list()[0]["state"] == "pending"
|
||||
|
||||
|
||||
def test_service_pushy_mode_submits_once_and_marks_injected(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "pushy")
|
||||
message_id = store.add("repo-a", "repo-b", "first\nsecond")
|
||||
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
|
||||
service = Service(store=store)
|
||||
|
||||
service._deliver_once()
|
||||
control = FakeControl.instances[-1]
|
||||
service._deliver_once()
|
||||
|
||||
assert control.submitted == [
|
||||
(
|
||||
"tamq:repo-b",
|
||||
f"#repo-a: first\\x0asecond [{message_id}]",
|
||||
)
|
||||
]
|
||||
assert store.list()[0]["state"] == "injected"
|
||||
|
||||
|
||||
def test_failed_pushy_submission_remains_pending(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "pushy")
|
||||
store.add("repo-a", "repo-b", "continue")
|
||||
|
||||
class FailingControl(FakeControl):
|
||||
def submit(self, window, text):
|
||||
raise RuntimeError("tmux rejected input")
|
||||
|
||||
monkeypatch.setattr("tamq.service.ControlModeClient", FailingControl)
|
||||
Service(store=store)._deliver_once()
|
||||
|
||||
assert store.list()[0]["state"] == "pending"
|
||||
|
||||
|
||||
def test_service_writes_output_once_and_retains_pending_ack(tmp_path, monkeypatch):
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
from tamq.terminal import (
|
||||
TerminalOutputError,
|
||||
format_comment,
|
||||
format_pushy_input,
|
||||
terminal_frame,
|
||||
write_terminal_output,
|
||||
)
|
||||
|
|
@ -18,6 +19,12 @@ def test_comment_format_escapes_controls_and_prefixes_every_line():
|
|||
)
|
||||
|
||||
|
||||
def test_pushy_input_is_one_sanitized_shell_comment():
|
||||
assert format_pushy_input("repo-a", "first\nsecond\x1b[31m", "m-1") == (
|
||||
"#repo-a: first\\x0asecond\\x1b[31m [m-1]"
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_frame_scrolls_only_rows_above_the_cursor():
|
||||
assert terminal_frame(
|
||||
"repo-a", "first\nsecond", "m-1", cursor_y=8, pane_height=24
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import pytest
|
|||
|
||||
from tamq.broker import BrokerIdentity, InputBroker
|
||||
from tamq.control import ControlModeClient
|
||||
from tamq.service import Service
|
||||
from tamq.store import Store
|
||||
from tamq.terminal import terminal_frame, write_terminal_output
|
||||
from tamq.tmux import LaunchPlan, TmuxManager
|
||||
|
|
@ -198,3 +199,63 @@ def test_real_tmux_output_preserves_partial_input_line_and_cursor(tmp_path):
|
|||
assert (after.cursor_x, after.cursor_y) == (before.cursor_x, before.cursor_y)
|
||||
finally:
|
||||
manager._run("kill-server", check=False)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is not installed")
|
||||
def test_real_tmux_pushy_mode_submits_one_shell_safe_input(tmp_path, monkeypatch):
|
||||
repo = tmp_path / "audit-core"
|
||||
repo.mkdir()
|
||||
socket_name = f"tamq-pushy-{os.getpid()}-{uuid4().hex[:8]}"
|
||||
session = f"tamq-pushy-{uuid4().hex[:8]}"
|
||||
monkeypatch.setenv("TAMQ_TMUX_SOCKET", socket_name)
|
||||
manager = TmuxManager(
|
||||
session,
|
||||
tmux_command=("tmux", "-L", socket_name),
|
||||
tamq_command=(sys.executable, "-m", "tamq.cli"),
|
||||
command_dir=tmp_path / "commands",
|
||||
)
|
||||
target = f"{session}:audit-core"
|
||||
store = Store(tmp_path / "queue.sqlite3")
|
||||
|
||||
try:
|
||||
endpoint = manager.ensure_plan(
|
||||
LaunchPlan(("audit-core",), {"audit-core": str(repo)}, ())
|
||||
)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
current_command = manager._run(
|
||||
"display-message", "-p", "-t", target, "#{pane_current_command}"
|
||||
)
|
||||
if current_command in {"bash", "sh", "zsh", "fish"}:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert current_command in {"bash", "sh", "zsh", "fish"}
|
||||
|
||||
store.register_endpoint(
|
||||
endpoint.instance_key,
|
||||
endpoint.pid,
|
||||
endpoint.session,
|
||||
endpoint.repos,
|
||||
"pushy",
|
||||
)
|
||||
message_id = store.add("flex-auth", "audit-core", "What's next?\nsecond")
|
||||
service = Service(store=store)
|
||||
service._deliver_once()
|
||||
|
||||
expected = f"#flex-auth: What's next?\\x0asecond [{message_id}]"
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
capture = manager._run("capture-pane", "-p", "-t", target)
|
||||
if expected in capture:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert expected in capture
|
||||
assert store.list()[0]["state"] == "injected"
|
||||
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
|
||||
|
||||
service._deliver_once()
|
||||
repeated_capture = manager._run("capture-pane", "-p", "-t", target)
|
||||
assert repeated_capture.count(expected) == 1
|
||||
finally:
|
||||
store.close()
|
||||
manager._run("kill-server", check=False)
|
||||
|
|
|
|||
71
workplans/TAMQ-WP-0010-experimental-pushy-delivery.md
Normal file
71
workplans/TAMQ-WP-0010-experimental-pushy-delivery.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
id: TAMQ-WP-0010
|
||||
type: workplan
|
||||
title: "Experimental pushy input delivery mode"
|
||||
domain: communication
|
||||
repo: tmux-amq
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: coulomb-social
|
||||
planning_priority: P0
|
||||
planning_order: 15
|
||||
created: "2026-08-24"
|
||||
updated: "2026-08-24"
|
||||
---
|
||||
|
||||
# Experimental pushy input delivery mode
|
||||
|
||||
Add an explicit, experimental mode that submits routed messages to the target
|
||||
pane's input for interactive coding agents that manage queued user prompts.
|
||||
|
||||
## Required operator contract
|
||||
|
||||
`tamq --mode pushy REPO...` opts the whole managed endpoint into pushy
|
||||
delivery. The default remains `--mode output`; `--mode inbox` retains durable
|
||||
inbox-only behavior, and `--no-display` remains a compatibility alias for
|
||||
inbox mode.
|
||||
|
||||
Pushy delivery injects exactly one sanitized, single-line comment of the form
|
||||
`#sender: body [message-id]` and then one Enter key. This is deliberately safe
|
||||
as a no-op when an ordinary shell has an empty prompt, while coding-agent TUIs
|
||||
can receive it as submitted user input. The message becomes `injected` after
|
||||
tmux accepts both operations. Pushy mode may append to input already being
|
||||
edited and is therefore experimental and explicitly opt-in.
|
||||
|
||||
## Add explicit endpoint modes
|
||||
|
||||
```task
|
||||
id: TAMQ-WP-0010-T01
|
||||
status: progress
|
||||
priority: high
|
||||
```
|
||||
|
||||
Add `--mode output|inbox|pushy`, retain compatible `--no-display` behavior,
|
||||
reject conflicting mode flags, register the selected endpoint mode, expose a
|
||||
pushy service capability, and report the selected mode in startup status.
|
||||
|
||||
## Submit safely framed input
|
||||
|
||||
```task
|
||||
id: TAMQ-WP-0010-T02
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Sanitize all controls and collapse multiline messages into escaped text. Use
|
||||
one checked tmux command list to send the literal comment and a distinct Enter
|
||||
operation. Do not reuse the terminal-output path, and mark the durable record
|
||||
`injected` only after tmux accepts both operations.
|
||||
|
||||
## Prove and document the experiment
|
||||
|
||||
```task
|
||||
id: TAMQ-WP-0010-T03
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
Cover CLI conflicts and capability restart, store validation, exact framing,
|
||||
one-time service delivery, and real tmux input submission. Update help, README,
|
||||
and SCOPE with the risk boundary; run full checks, install, and exercise a
|
||||
controlled live pushy session without altering user messages.
|
||||
Loading…
Add table
Add a link
Reference in a new issue