feat: add experimental pushy delivery mode
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:
tegwick 2026-08-24 23:41:09 +02:00
parent 8186550c9a
commit 9ed8b62b45
16 changed files with 441 additions and 31 deletions

View file

@ -54,13 +54,37 @@ tamq start --command 'htop --tree' flex-auth audit-core
`--cmd` remains an alias for `--command`. Repeated starts reuse existing `--cmd` remains an alias for `--command`. Repeated starts reuse existing
windows and never run another initial command in them. windows and never run another initial command in them.
By default, a routed message is also written as sanitized output to its target Select an endpoint delivery mode at startup. `output` is the safe default;
pane. Use `--no-display` when you want durable inbox-only delivery: `inbox` keeps messages durable without displaying them:
```bash ```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 ## Exchange messages manually
Each managed shell exports its own repository slug as `TAMQ_REPO`. From the 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`. cannot be combined with `--all` or `--json`.
Outside a managed window, use `tamq inbox --repo audit-core` and optionally Outside a managed window, use `tamq inbox --repo audit-core` and optionally
`--json`. Displayed messages remain durable and pending until acknowledged. `--json`. Output-displayed messages remain durable and pending until
Neither normal output delivery nor `--no-display` injects terminal keystrokes, acknowledged. Neither `output` nor `inbox` mode injects terminal keystrokes, so
so they cannot execute or alter a command being typed in the target pane. 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 After upgrading from an earlier alpha, recreate the managed session once so
existing panes inherit the neutral shell contract, repository command `PATH`, 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 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 the target pane's PTY output and stays pending in the durable inbox until
acknowledged. `--no-display` selects inbox-only manual mode. Neither becomes acknowledged. `--mode inbox` selects inbox-only manual mode. Neither becomes
pane input. The legacy pane-input path is available only with the explicit pane input. Experimental `--mode pushy` sends one checked tmux command list
`tamq start --tap --command ...` opt-in; it remains subject to the retry and containing sanitized literal input followed by Enter, then records the message
acknowledgement limitations tracked by `TAMQ-WP-0003`. 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 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. instance nonce so PID reuse cannot collide with prior leases or receipts.

View file

@ -21,6 +21,8 @@ tamq does not choose or infer them.
display, a recipient-aware inline composer, and explicit pull-time filters. display, a recipient-aware inline composer, and explicit pull-time filters.
- Sanitized one-time output notifications through target tmux pane PTYs, with - Sanitized one-time output notifications through target tmux pane PTYs, with
inbox-only delivery as an explicit option and no foreground-process input. 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 - Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY
broker for integration experiments. broker for integration experiments.
- Exact `gita` repository validation and direct `@repo: message` routing. - 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 - Owning goal planning, workflow scheduling, or cross-host coordination; those
belong to `coordination-engine` and its consumers. belong to `coordination-engine` and its consumers.
- Acting as a network-accessible or multi-host message broker. - Acting as a network-accessible or multi-host message broker.
- Selecting a coding agent, starting one implicitly, or assuming a pane accepts - Selecting a coding agent, starting one implicitly, or silently assuming a
machine-generated terminal input. pane accepts machine-generated input; pushy delivery requires explicit mode
selection by the operator.
- Bypassing `gita` registration or injecting arbitrary pane input outside the - Bypassing `gita` registration or injecting arbitrary pane input outside the
supported tap/control-mode boundaries. supported tap/control-mode boundaries.
- Owning tmux, Codex, State Hub, or adjacent repositories' lifecycle. - 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. | | 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. | | 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`. | | 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. | | 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. | | 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. | | 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. gita-registered repositories.
- Durable manual message exchange between managed repository windows, including - Durable manual message exchange between managed repository windows, including
explicit pull-time loggers and filters. 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 - Developing and testing the future coordination-engine adapter against the
local socket boundary. local socket boundary.
@ -83,11 +89,13 @@ Not yet suitable:
- Unattended pane injection where bounded retries and positive recipient - Unattended pane injection where bounded retries and positive recipient
acknowledgement are required. 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 - Production-style operation without longer-running crash/terminal soak tests
and stronger process-supervision evidence. and stronger process-supervision evidence.
- Cross-host messaging or use as a general-purpose broker. - 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 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%, tap and CLI orchestration; PTY statement coverage increased from 23% to 33%,
while subprocess behavior is primarily proven by the real-tmux test. The while subprocess behavior is primarily proven by the real-tmux test. The

View file

@ -18,6 +18,7 @@
| workplan | TAMQ-WP-0007 | finished | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | 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-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-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-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-T01 | done | — | workplans/TAMQ-WP-0001-statehub-bootstrap.md |
| task | TAMQ-WP-0001-T02 | 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-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-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-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 |

View file

@ -34,7 +34,7 @@ from .terminal import format_comment
SUBCOMMANDS = frozenset( SUBCOMMANDS = frozenset(
"start attach serve stop status ping inbox history inspect ack send reply export replay purge completion db-version config tap".split() "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"}) GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"})
@ -129,6 +129,10 @@ def ensure_output_service() -> bool:
return ensure_service_capabilities({"manual_delivery", "terminal_output"}) 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: def preflight_runtime_paths() -> None:
"""Ensure local state/runtime parents are available before tmux mutation.""" """Ensure local state/runtime parents are available before tmux mutation."""
parents = {db_path().parent, socket_path().parent, pid_path().parent, lock_path().parent} 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.", description="Repository-aware tmux sessions with durable local messaging.",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""session shorthand: 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 With no --command, tamq opens ordinary repository shells. --command is an
explicit initial command for newly created windows. Messages appear as explicit initial command for newly created windows. Messages appear as
sanitized terminal output by default; --no-display keeps them inbox-only. sanitized terminal output by default. --mode inbox keeps messages durable
Neither mode injects message bytes into pane input. without display; experimental --mode pushy submits them to target input.
manual messaging from a managed shell: manual messaging from a managed shell:
@TARGET: MESSAGE... @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("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("--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("--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("--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") 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") 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): if index == len(argv):
return argv return argv
candidate = argv[index] 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 not candidate.startswith("-") and candidate not in SUBCOMMANDS
): ):
return [*argv[:index], "start", *argv[index:]] return [*argv[:index], "start", *argv[index:]]
@ -307,6 +312,15 @@ def main(argv: list[str] | None = None) -> int:
finally: finally:
store.close() store.close()
if args.command == "start": 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: if args.tap and args.initial_command is None:
print("tamq: --tap requires an explicit --command", file=sys.stderr) print("tamq: --tap requires an explicit --command", file=sys.stderr)
return 2 return 2
@ -316,6 +330,12 @@ def main(argv: list[str] | None = None) -> int:
if args.tap and args.no_display: if args.tap and args.no_display:
print("tamq: --tap cannot be combined with --no-display", file=sys.stderr) print("tamq: --tap cannot be combined with --no-display", file=sys.stderr)
return 2 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() manager = TmuxManager()
try: try:
if not args.no_service: if not args.no_service:
@ -324,9 +344,14 @@ def main(argv: list[str] | None = None) -> int:
except (TmuxError, OSError) as exc: except (TmuxError, OSError) as exc:
print(f"tamq: {exc}", file=sys.stderr) print(f"tamq: {exc}", file=sys.stderr)
return 2 return 2
service_ready = ( if args.no_service:
ensure_manual_service() if args.no_display else ensure_output_service() service_ready = True
) if not args.no_service else 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: 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) print("tamq service failed to start with the required delivery capability; use --no-service to open repos without messaging", file=sys.stderr)
return 1 return 1
@ -339,7 +364,7 @@ def main(argv: list[str] | None = None) -> int:
registered = False registered = False
if not args.no_service: if not args.no_service:
try: 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})) 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"): if not registration.get("ok"):
raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}") 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, "delivered": delivered,
"service": not args.no_service, "service": not args.no_service,
"messaging": registered, "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) print(json.dumps(summary), flush=True)
if args.detach: if args.detach:

View file

@ -93,6 +93,24 @@ class ControlModeClient:
# send-keys -l preserves punctuation and whitespace in the message. # send-keys -l preserves punctuation and whitespace in the message.
self.command(f"send-keys -t {window} -l -- {shell_quote(text)}") 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: def close(self) -> None:
if self.process is not None: if self.process is not None:
process = self.process process = self.process

View file

@ -13,7 +13,7 @@ from .config import db_path
from .store import Store from .store import Store
from .registry import RegistryError, validate_targets from .registry import RegistryError, validate_targets
from .control import ControlModeClient 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" PROTOCOL_VERSION = "0.1"
@ -112,6 +112,15 @@ class Service:
alternate_on=display.alternate_on, alternate_on=display.alternate_on,
), ),
) )
elif delivery_mode == "pushy":
control.submit(
target,
format_pushy_input(
row["sender_repo"],
row["body"],
row["message_id"],
),
)
else: else:
control.inject(target, f'#{row["sender_repo"]}: {row["body"]}') control.inject(target, f'#{row["sender_repo"]}: {row["body"]}')
except Exception: except Exception:
@ -135,7 +144,7 @@ class Service:
if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]: if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]:
response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION} response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION}
elif op == "ping": 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": elif op == "register":
required = ("endpoint_id", "pid", "session", "repos") required = ("endpoint_id", "pid", "session", "repos")
if any(key not in request for key in required): if any(key not in request for key in required):

View file

@ -82,7 +82,7 @@ class Store:
delivery_mode: str = "manual", delivery_mode: str = "manual",
) -> None: ) -> None:
import json 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}") raise ValueError(f"invalid delivery mode: {delivery_mode}")
self.db.execute( self.db.execute(
"UPDATE endpoints SET disconnected_at=strftime('%s','now') " "UPDATE endpoints SET disconnected_at=strftime('%s','now') "

View file

@ -29,6 +29,14 @@ def format_comment(sender: str, body: str, message_id: str) -> str:
return "\n".join(rendered) 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( def terminal_frame(
sender: str, sender: str,
body: str, body: str,

View file

@ -18,7 +18,7 @@ def test_root_help_exposes_repository_shorthand_and_command(capsys):
main(["--help"]) main(["--help"])
assert exc.value.code == 0 assert exc.value.code == 0
output = capsys.readouterr().out 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 "With no --command" in output
assert "@TARGET: MESSAGE" 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"]) compatibility = parser.parse_args(["start", "--cmd", "claude", "a"])
neutral = parser.parse_args(["start", "a", "b"]) neutral = parser.parse_args(["start", "a", "b"])
inbox_only = parser.parse_args(["start", "--no-display", "a"]) 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.initial_command == "codex --quiet"
assert canonical.repos == ["a", "b"] assert canonical.repos == ["a", "b"]
assert compatibility.initial_command == "claude" assert compatibility.initial_command == "claude"
assert neutral.initial_command is None assert neutral.initial_command is None
assert inbox_only.no_display is True assert inbox_only.no_display is True
assert pushy.mode == "pushy"
def test_repository_first_and_command_first_start_shorthand(): def test_repository_first_and_command_first_start_shorthand():
@ -69,6 +71,17 @@ def test_repository_first_and_command_first_start_shorthand():
"--no-display", "--no-display",
"flex-auth", "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(["status"]) == ["status"]
assert normalize_argv(["--verbose", "flex-auth", "audit-core"]) == [ assert normalize_argv(["--verbose", "flex-auth", "audit-core"]) == [
"--verbose", "--verbose",
@ -179,6 +192,52 @@ def test_registration_failure_rolls_back_created_windows(monkeypatch, capsys):
assert "endpoint registration failed: denied" in capsys.readouterr().err 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): def test_tap_requires_explicit_command_and_service(capsys):
assert main(["start", "--tap", "a"]) == 2 assert main(["start", "--tap", "a"]) == 2
assert "--tap requires an explicit --command" in capsys.readouterr().err 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 "--tap cannot be combined with --no-service" in capsys.readouterr().err
assert main(["start", "--tap", "--no-display", "--command", "sh", "a"]) == 2 assert main(["start", "--tap", "--no-display", "--command", "sh", "a"]) == 2
assert "--tap cannot be combined with --no-display" in capsys.readouterr().err 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

View file

@ -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(): def test_parse_size():
@ -42,3 +47,23 @@ def test_output_service_requires_terminal_output_capability(monkeypatch):
assert ensure_output_service() is True assert ensure_output_service() is True
assert len(starts) == 2 assert len(starts) == 2
assert stops == [True] 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]

View file

@ -1,5 +1,7 @@
from tamq import control 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 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", "tmux", "-L", "test", "display-message", "-p", "-t",
"tamq:audit-core", "#{pane_tty}|#{cursor_x}|#{cursor_y}|#{pane_height}|#{alternate_on}", "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")

View file

@ -92,7 +92,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
) )
assert run(str(tamq), "--version").stdout.strip() == "0.1.0" assert run(str(tamq), "--version").stdout.strip() == "0.1.0"
root_help = run(str(tamq), "--help").stdout 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( started = json.loads(
run( run(
str(tamq), str(tamq),

View file

@ -9,6 +9,7 @@ class FakeControl:
def __init__(self, session): def __init__(self, session):
self.session = session self.session = session
self.injected = [] self.injected = []
self.submitted = []
self.__class__.instances.append(self) self.__class__.instances.append(self)
def start(self): def start(self):
@ -20,6 +21,9 @@ class FakeControl:
def inject(self, window, text): def inject(self, window, text):
self.injected.append((window, text)) self.injected.append((window, text))
def submit(self, window, text):
self.submitted.append((window, text))
def pane_display(self, window): def pane_display(self, window):
return PaneDisplay( return PaneDisplay(
tty_path=f"/dev/pts/{window.rsplit(':', 1)[-1]}", 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" 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): def test_service_writes_output_once_and_retains_pending_ack(tmp_path, monkeypatch):
store = Store(tmp_path / "queue.sqlite3") store = Store(tmp_path / "queue.sqlite3")
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output") store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output")

View file

@ -7,6 +7,7 @@ import pytest
from tamq.terminal import ( from tamq.terminal import (
TerminalOutputError, TerminalOutputError,
format_comment, format_comment,
format_pushy_input,
terminal_frame, terminal_frame,
write_terminal_output, 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(): def test_terminal_frame_scrolls_only_rows_above_the_cursor():
assert terminal_frame( assert terminal_frame(
"repo-a", "first\nsecond", "m-1", cursor_y=8, pane_height=24 "repo-a", "first\nsecond", "m-1", cursor_y=8, pane_height=24

View file

@ -9,6 +9,7 @@ import pytest
from tamq.broker import BrokerIdentity, InputBroker from tamq.broker import BrokerIdentity, InputBroker
from tamq.control import ControlModeClient from tamq.control import ControlModeClient
from tamq.service import Service
from tamq.store import Store from tamq.store import Store
from tamq.terminal import terminal_frame, write_terminal_output from tamq.terminal import terminal_frame, write_terminal_output
from tamq.tmux import LaunchPlan, TmuxManager 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) assert (after.cursor_x, after.cursor_y) == (before.cursor_x, before.cursor_y)
finally: finally:
manager._run("kill-server", check=False) 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)

View 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.