diff --git a/README.md b/README.md index c5be7f8..227d07a 100644 --- a/README.md +++ b/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-`; each boot also receives an instance nonce so PID reuse cannot collide with prior leases or receipts. diff --git a/SCOPE.md b/SCOPE.md index f9d4308..7ada7a9 100644 --- a/SCOPE.md +++ b/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 diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index a10f48a..df41b07 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/src/tamq/cli.py b/src/tamq/cli.py index b0a6307..5754171 100644 --- a/src/tamq/cli.py +++ b/src/tamq/cli.py @@ -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: diff --git a/src/tamq/control.py b/src/tamq/control.py index 5535804..c06503a 100644 --- a/src/tamq/control.py +++ b/src/tamq/control.py @@ -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 diff --git a/src/tamq/service.py b/src/tamq/service.py index 7feacec..1ebba9f 100644 --- a/src/tamq/service.py +++ b/src/tamq/service.py @@ -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): diff --git a/src/tamq/store.py b/src/tamq/store.py index ee85b5c..4f2b096 100644 --- a/src/tamq/store.py +++ b/src/tamq/store.py @@ -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') " diff --git a/src/tamq/terminal.py b/src/tamq/terminal.py index a702ab2..ab97f02 100644 --- a/src/tamq/terminal.py +++ b/src/tamq/terminal.py @@ -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, diff --git a/tests/test_cli.py b/tests/test_cli.py index d981dd3..781928c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py index 2f30ac4..831e986 100644 --- a/tests/test_cli_helpers.py +++ b/tests/test_cli_helpers.py @@ -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] diff --git a/tests/test_control.py b/tests/test_control.py index a239ffa..94b15d4 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -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") diff --git a/tests/test_install_target.py b/tests/test_install_target.py index 429bee5..43b5a32 100644 --- a/tests/test_install_target.py +++ b/tests/test_install_target.py @@ -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), diff --git a/tests/test_service_delivery.py b/tests/test_service_delivery.py index aa29a6a..8038743 100644 --- a/tests/test_service_delivery.py +++ b/tests/test_service_delivery.py @@ -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") diff --git a/tests/test_terminal_output.py b/tests/test_terminal_output.py index 5c14263..c0fb2f6 100644 --- a/tests/test_terminal_output.py +++ b/tests/test_terminal_output.py @@ -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 diff --git a/tests/test_tmux_integration.py b/tests/test_tmux_integration.py index 692ff1d..cbf1a6e 100644 --- a/tests/test_tmux_integration.py +++ b/tests/test_tmux_integration.py @@ -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) diff --git a/workplans/TAMQ-WP-0010-experimental-pushy-delivery.md b/workplans/TAMQ-WP-0010-experimental-pushy-delivery.md new file mode 100644 index 0000000..da5a918 --- /dev/null +++ b/workplans/TAMQ-WP-0010-experimental-pushy-delivery.md @@ -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.