diff --git a/README.md b/README.md index 2c9c303..1239f2f 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,18 @@ The spelling without the trailing colon is equivalent: @audit-core please review the auth boundary ``` +After receiving a message, use the bare `@` command to answer its sender: + +```bash +@ Thanks, I will take a look. +``` + +Bare `@` selects the sender of the latest durable inbound message for the +current repository, including an already acknowledged message. Self-addressed +messages are ignored. If this window has no prior counterparty, the command +reports an error and queues nothing. The explicit equivalent is `tamq reply +MESSAGE...`. + These are tamq-owned executable commands beside the installed `tamq` command, not shell-specific aliases. Set `TAMQ_COMMAND_DIR` before startup to select a different writable command directory already present on your shell's `PATH`. @@ -94,10 +106,14 @@ With normal `output` delivery, the target pane visibly receives: ``` This uses the pane's tmux-reported `/dev/pts/` device—the same Unix -terminal-output mechanism underlying tools such as `write(1)`. It does not use -`send-keys`, send Enter, or place bytes on the foreground process's stdin. -Output can visually interleave with a prompt and a full-screen program may -redraw over it, so the durable inbox remains authoritative. +terminal-output mechanism underlying tools such as `write(1)`. For an ordinary +shell with screen rows above its cursor, tamq confines scrolling to those rows, +writes the comment immediately above the input row, and restores the cursor. +Thus a partially typed command remains in place. If no safe row exists, tamq +uses ordinary line output; alternate-screen programs receive the conservative +fallback and may redraw over it. Neither path uses `send-keys`, sends Enter, or +places bytes on the foreground process's stdin, so the durable inbox remains +authoritative. In the `audit-core` window, inspect and acknowledge it: diff --git a/SCOPE.md b/SCOPE.md index d8c6282..cfceef1 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -17,8 +17,8 @@ tamq does not choose or infer them. acknowledgements, replay, export, and bounded purging. - Managed neutral-shell tmux lifecycle and explicit initial commands. - Durable manual send/inbox/acknowledgement with per-window repository identity, - shell-native address commands, comment-safe display, and explicit pull-time - filters. + shell-native direct and latest-counterparty reply commands, comment-safe + display, 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 opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY @@ -48,11 +48,11 @@ transport. | Intent capability | State | Evidence and remaining gap | | --- | --- | --- | -| Direct repository addressing | Implemented for local alpha | Exact `gita` validation, per-session `@repo`/`@repo:` executable commands, and long-form parsing are covered without modifying shell configuration. | +| Direct repository addressing | Implemented for local alpha | Exact `gita` validation, per-session `@repo`/`@repo:` commands, bare `@` latest-counterparty replies, and long-form parsing are covered without modifying shell configuration. | | Durable, inspectable local queue | Implemented | SQLite history, manual inbox, leases, endpoint records, inspect/history, JSONL export/replay, acknowledgement, and purge are present. | | 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 to the target PTY output without injecting stdin; messages remain pending until acknowledgement. Inbox-only manual mode is explicit with `--no-display`, and legacy rows migrate to manual mode. | +| 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`. | | 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. | @@ -64,7 +64,8 @@ The terminal-neutral alpha path was exercised successfully on 2026-08-24 with an isolated installed tool. Repository-first startup created two ordinary shells at exact gita paths without sending initial keystrokes. The test proved per-window repository identity, stable reuse, shell-native addressing, -comment-safe target output and inbox/filter/ack exchange, zero target-input +latest-counterparty replies, comment-safe target output above an unchanged +partial input line and cursor, inbox/filter/ack exchange, zero target-input mutation, service restart, endpoint disappearance, explicit initial-command startup, and cleanup. @@ -86,7 +87,7 @@ Not yet suitable: and stronger process-supervision evidence. - Cross-host messaging or use as a general-purpose broker. -The suite currently has 93 passing tests and 76% statement coverage. Coverage +The suite currently has 100 passing tests and 76% 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 19f74c8..866d8cd 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -16,7 +16,7 @@ | workplan | TAMQ-WP-0005 | finished | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md | | workplan | TAMQ-WP-0006 | finished | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | | workplan | TAMQ-WP-0007 | finished | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | -| workplan | TAMQ-WP-0008 | active | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | +| workplan | TAMQ-WP-0008 | finished | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.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 | @@ -47,7 +47,7 @@ | task | TAMQ-WP-0007-T03 | done | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | task | TAMQ-WP-0007-T04 | done | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | task | TAMQ-WP-0007-T05 | done | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | -| task | TAMQ-WP-0008-T01 | progress | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | -| task | TAMQ-WP-0008-T02 | todo | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | -| task | TAMQ-WP-0008-T03 | todo | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | -| task | TAMQ-WP-0008-T04 | todo | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | +| task | TAMQ-WP-0008-T01 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | +| task | TAMQ-WP-0008-T02 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | +| task | TAMQ-WP-0008-T03 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | +| task | TAMQ-WP-0008-T04 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md | diff --git a/src/tamq/cli.py b/src/tamq/cli.py index 05ecec4..aaafa4e 100644 --- a/src/tamq/cli.py +++ b/src/tamq/cli.py @@ -31,7 +31,7 @@ from .terminal import format_comment SUBCOMMANDS = frozenset( - "start attach serve stop status ping inbox history inspect ack send 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"}) GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"}) @@ -162,6 +162,7 @@ def build_parser() -> argparse.ArgumentParser: manual messaging from a managed shell: @TARGET: MESSAGE... + @ MESSAGE... reply to the latest sender for this window tamq inbox [--filter COMMAND] """, ) @@ -206,6 +207,8 @@ manual messaging from a managed shell: send.add_argument("body", nargs="*", help="message body when address is a repo slug") send.add_argument("--endpoint-id") send.add_argument("--from", dest="sender_repo", help="sender repository (default: TAMQ_REPO or local)") + reply = subparsers.add_parser("reply", help="reply to the latest sender for the current repository") + reply.add_argument("body", nargs="+", help="message body") export = subparsers.add_parser("export", help="export history as JSONL") export.add_argument("--output", required=True) export.add_argument("--repo", dest="target_repo") @@ -371,15 +374,31 @@ def main(argv: list[str] | None = None) -> int: store = Store(db_path()) try: advisory = history_advisory(store) - if advisory and args.command in {"start", "serve", "status", "history", "inbox", "send"}: + if advisory and args.command in {"start", "serve", "status", "history", "inbox", "send", "reply"}: print(advisory, file=sys.stderr) - if args.command == "send": - text = " ".join([args.address, *args.body]).strip() - if not text.startswith("@") or ":" not in text: - print("tamq send expects @repo: message", file=sys.stderr); return 2 - target, body = text[1:].split(":", 1); body = body.strip() - if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2 - sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local" + if args.command in {"send", "reply"}: + endpoint_id = None + if args.command == "reply": + sender = os.environ.get("TAMQ_REPO") + if not sender: + print("tamq reply requires a managed window with TAMQ_REPO", file=sys.stderr) + return 2 + body = " ".join(args.body).strip() + target = store.latest_counterparty(sender) + if target is None: + print(f"tamq: no counterparty has sent a message to {sender}", file=sys.stderr) + return 2 + else: + text = " ".join([args.address, *args.body]).strip() + if not text.startswith("@") or ":" not in text: + print("tamq send expects @repo: message", file=sys.stderr); return 2 + target, body = text[1:].split(":", 1); body = body.strip() + if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2 + sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local" + endpoint_id = args.endpoint_id + if not body: + print("tamq reply requires a message body", file=sys.stderr) + return 2 try: validate_targets([target]) if sender != "local": @@ -388,13 +407,13 @@ def main(argv: list[str] | None = None) -> int: print(f"tamq: {exc}", file=sys.stderr); return 2 if asyncio.run(ping()): payload = {"op": "send", "sender_repo": sender, "target_repo": target, "body": body} - if args.endpoint_id: - payload["endpoint_id"] = args.endpoint_id + if endpoint_id: + payload["endpoint_id"] = endpoint_id response = asyncio.run(request(payload)) if not response.get("ok"): print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1 print(response["message_id"]); return 0 - if args.endpoint_id: + if endpoint_id: print("tamq: service is not running", file=sys.stderr); return 1 try: print(store.add(sender, target, body)) diff --git a/src/tamq/control.py b/src/tamq/control.py index cd33dc2..5535804 100644 --- a/src/tamq/control.py +++ b/src/tamq/control.py @@ -10,6 +10,15 @@ class ControlModeError(RuntimeError): pass +@dataclass +class PaneDisplay: + tty_path: str + cursor_x: int + cursor_y: int + pane_height: int + alternate_on: bool + + @dataclass class ControlModeClient: session: str @@ -35,16 +44,32 @@ class ControlModeClient: return expected_pid is None or result.stdout.strip() == str(expected_pid) def pane_tty(self, window: str) -> str: + return self.pane_display(window).tty_path + + def pane_display(self, window: str) -> PaneDisplay: result = subprocess.run( - [*self._command(), "display-message", "-p", "-t", window, "#{pane_tty}"], + [ + *self._command(), "display-message", "-p", "-t", window, + "#{pane_tty}|#{cursor_x}|#{cursor_y}|#{pane_height}|#{alternate_on}", + ], text=True, capture_output=True, check=False, ) - tty_path = result.stdout.strip() - if result.returncode != 0 or not tty_path: + output = result.stdout.strip() + if result.returncode != 0 or not output: raise ControlModeError(result.stderr.strip() or f"cannot resolve pane tty: {window}") - return tty_path + try: + tty_path, cursor_x, cursor_y, pane_height, alternate_on = output.split("|", 4) + return PaneDisplay( + tty_path=tty_path, + cursor_x=int(cursor_x), + cursor_y=int(cursor_y), + pane_height=int(pane_height), + alternate_on=alternate_on == "1", + ) + except ValueError as exc: + raise ControlModeError(f"invalid pane display metadata: {output!r}") from exc def start(self) -> None: if self.process is not None: diff --git a/src/tamq/service.py b/src/tamq/service.py index f57d032..7feacec 100644 --- a/src/tamq/service.py +++ b/src/tamq/service.py @@ -100,11 +100,16 @@ class Service: try: target = f'{endpoint["session"]}:{row["target_repo"]}' if delivery_mode == "output": - tty_path = control.pane_tty(target) + display = control.pane_display(target) write_terminal_output( - tty_path, + display.tty_path, terminal_frame( - row["sender_repo"], row["body"], row["message_id"] + row["sender_repo"], + row["body"], + row["message_id"], + cursor_y=display.cursor_y, + pane_height=display.pane_height, + alternate_on=display.alternate_on, ), ) else: diff --git a/src/tamq/store.py b/src/tamq/store.py index 59e4a2a..ee85b5c 100644 --- a/src/tamq/store.py +++ b/src/tamq/store.py @@ -153,6 +153,15 @@ class Store: where = f" WHERE {' AND '.join(clauses)}" if clauses else "" return list(self.db.execute(f"SELECT * FROM messages{where} ORDER BY created_at", values)) + def latest_counterparty(self, target: str) -> str | None: + row = self.db.execute( + "SELECT sender_repo FROM messages " + "WHERE target_repo=? AND sender_repo<>? " + "ORDER BY created_at DESC, rowid DESC LIMIT 1", + (target, target), + ).fetchone() + return None if row is None else str(row["sender_repo"]) + def set_state(self, message_id: str, state: str) -> None: column = {"injected": "injected_at", "acknowledged": "acknowledged_at"}.get(state) if column: diff --git a/src/tamq/terminal.py b/src/tamq/terminal.py index ea190ed..a702ab2 100644 --- a/src/tamq/terminal.py +++ b/src/tamq/terminal.py @@ -29,9 +29,35 @@ def format_comment(sender: str, body: str, message_id: str) -> str: return "\n".join(rendered) -def terminal_frame(sender: str, body: str, message_id: str) -> str: - """Frame asynchronous output away from the current visual input line.""" +def terminal_frame( + sender: str, + body: str, + message_id: str, + *, + cursor_y: int | None = None, + pane_height: int | None = None, + alternate_on: bool = False, +) -> str: + """Frame output above the cursor when a safe scroll region is available.""" content = format_comment(sender, body, message_id).replace("\n", "\r\n") + line_count = content.count("\r\n") + 1 + if ( + cursor_y is not None + and pane_height is not None + and not alternate_on + and 0 < cursor_y < pane_height + and line_count <= cursor_y + ): + lines = content.split("\r\n") + output = "".join(f"\n\r{line}" for line in lines) + return ( + "\x1b7" + f"\x1b[1;{cursor_y}r" + f"\x1b[{cursor_y};1H" + f"{output}" + "\x1b[r" + "\x1b8" + ) return f"\r\n{content}\r\n" diff --git a/src/tamq/tmux.py b/src/tamq/tmux.py index 84199b5..16591ec 100644 --- a/src/tamq/tmux.py +++ b/src/tamq/tmux.py @@ -78,6 +78,13 @@ class TmuxManager: self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700) if not os.access(self.command_dir, os.W_OK | os.X_OK): raise TmuxError(f"address command directory is not writable: {self.command_dir}") + scripts = { + "@": ( + "#!/bin/sh\n" + "# tamq-address-command v1\n" + f"exec {shell_join(list(self.tamq_command))} reply -- \"$@\"\n" + ) + } for repo in repos: if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None: raise TmuxError( @@ -90,20 +97,24 @@ class TmuxManager: f"exec {shell_join(list(self.tamq_command))} send -- {shlex.quote(target)} \"$@\"\n" ) for name in (f"@{repo}", target): - destination = self.command_dir / name - if destination.exists() or destination.is_symlink(): - if destination.is_symlink() or not destination.is_file(): - raise TmuxError(f"refusing to replace existing command: {destination}") - try: - existing = destination.read_text(encoding="utf-8") - except OSError as exc: - raise TmuxError(f"cannot inspect existing command: {destination}") from exc - if "# tamq-address-command v1" not in existing: - raise TmuxError(f"refusing to replace existing command: {destination}") - temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp" - temporary.write_text(script, encoding="utf-8") - temporary.chmod(0o700) - temporary.replace(destination) + scripts[name] = script + for name in scripts: + destination = self.command_dir / name + if destination.exists() or destination.is_symlink(): + if destination.is_symlink() or not destination.is_file(): + raise TmuxError(f"refusing to replace existing command: {destination}") + try: + existing = destination.read_text(encoding="utf-8") + except OSError as exc: + raise TmuxError(f"cannot inspect existing command: {destination}") from exc + if "# tamq-address-command v1" not in existing: + raise TmuxError(f"refusing to replace existing command: {destination}") + for name, script in scripts.items(): + destination = self.command_dir / name + temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp" + temporary.write_text(script, encoding="utf-8") + temporary.chmod(0o700) + temporary.replace(destination) def _window_environment(self, repo: str) -> tuple[str, ...]: path = os.environ.get("PATH", "") diff --git a/tests/test_control.py b/tests/test_control.py index cac6947..a239ffa 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -18,12 +18,12 @@ def test_pane_tty_is_resolved_through_tmux(monkeypatch): def run(command, **kwargs): calls.append(command) - return type("Result", (), {"returncode": 0, "stdout": "/dev/pts/7\n", "stderr": ""})() + return type("Result", (), {"returncode": 0, "stdout": "/dev/pts/7|12|8|24|0\n", "stderr": ""})() monkeypatch.setattr(control.subprocess, "run", run) client = ControlModeClient("tamq", tmux_command=("tmux", "-L", "test")) assert client.pane_tty("tamq:audit-core") == "/dev/pts/7" assert calls == [[ "tmux", "-L", "test", "display-message", "-p", "-t", - "tamq:audit-core", "#{pane_tty}", + "tamq:audit-core", "#{pane_tty}|#{cursor_x}|#{cursor_y}|#{pane_height}|#{alternate_on}", ]] diff --git a/tests/test_manual_messaging.py b/tests/test_manual_messaging.py index 7bc075b..2dbbd94 100644 --- a/tests/test_manual_messaging.py +++ b/tests/test_manual_messaging.py @@ -32,6 +32,43 @@ def test_manual_send_inbox_and_ack_use_window_repository_identity(tmp_path, monk assert capsys.readouterr().out == "" +def test_bare_reply_targets_latest_inbound_sender(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setenv("TAMQ_REPO", "audit-core") + monkeypatch.setattr("tamq.cli.ping", service_is_down) + monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None) + store = Store(tmp_path / "state" / "tamq.sqlite3") + store.add("flex-auth", "audit-core", "older") + latest = store.add("railiance-platform", "audit-core", "latest") + store.acknowledge(latest) + store.add("audit-core", "audit-core", "self note") + store.close() + + assert main(["reply", "--", "--looks-like-an-option", "thanks"]) == 0 + message_id = capsys.readouterr().out.strip() + + store = Store(tmp_path / "state" / "tamq.sqlite3") + row = next(row for row in store.list() if row["message_id"] == message_id) + assert row["sender_repo"] == "audit-core" + assert row["target_repo"] == "railiance-platform" + assert row["body"] == "--looks-like-an-option thanks" + store.close() + + +def test_bare_reply_requires_managed_identity_and_prior_sender(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setattr("tamq.cli.ping", service_is_down) + monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None) + monkeypatch.delenv("TAMQ_REPO", raising=False) + + assert main(["reply", "hello"]) == 2 + assert "managed window" in capsys.readouterr().err + + monkeypatch.setenv("TAMQ_REPO", "audit-core") + assert main(["reply", "hello"]) == 2 + assert "no counterparty" in capsys.readouterr().err + + def test_inbox_requires_repository_outside_managed_window(tmp_path, monkeypatch, capsys): monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) monkeypatch.delenv("TAMQ_REPO", raising=False) diff --git a/tests/test_service_delivery.py b/tests/test_service_delivery.py index c59333d..aa29a6a 100644 --- a/tests/test_service_delivery.py +++ b/tests/test_service_delivery.py @@ -1,3 +1,4 @@ +from tamq.control import PaneDisplay from tamq.service import Service from tamq.store import Store @@ -19,8 +20,14 @@ class FakeControl: def inject(self, window, text): self.injected.append((window, text)) - def pane_tty(self, window): - return f"/dev/pts/{window.rsplit(':', 1)[-1]}" + def pane_display(self, window): + return PaneDisplay( + tty_path=f"/dev/pts/{window.rsplit(':', 1)[-1]}", + cursor_x=0, + cursor_y=0, + pane_height=24, + alternate_on=False, + ) def close(self): return None diff --git a/tests/test_store.py b/tests/test_store.py index c4cd819..01dc193 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -59,3 +59,15 @@ def test_mark_displayed_releases_lease_without_acknowledging(tmp_path): assert row["displayed_at"] is not None assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0 store.close() + + +def test_latest_counterparty_uses_latest_inbound_message_regardless_of_state(tmp_path): + store = Store(tmp_path / "queue.sqlite3") + store.add("audit-core", "audit-core", "self note") + older = store.add("flex-auth", "audit-core", "first") + store.acknowledge(older) + store.add("railiance-platform", "audit-core", "latest") + + assert store.latest_counterparty("audit-core") == "railiance-platform" + assert store.latest_counterparty("unknown") is None + store.close() diff --git a/tests/test_terminal_output.py b/tests/test_terminal_output.py index 4501ccd..5c14263 100644 --- a/tests/test_terminal_output.py +++ b/tests/test_terminal_output.py @@ -16,6 +16,24 @@ def test_comment_format_escapes_controls_and_prefixes_every_line(): assert format_comment("repo-a", "first\nsecond\x1b[31m", "m-1") == ( "#repo-a: first\n# second\\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 + ) == ( + "\x1b7\x1b[1;8r\x1b[8;1H" + "\n\r#repo-a: first\n\r# second [m-1]" + "\x1b[r\x1b8" + ) + + +def test_terminal_frame_falls_back_when_stable_region_is_not_safe(): + expected = "\r\n#repo-a: hello [m-1]\r\n" + assert terminal_frame("repo-a", "hello", "m-1", cursor_y=0, pane_height=24) == expected + assert terminal_frame( + "repo-a", "hello", "m-1", cursor_y=8, pane_height=24, alternate_on=True + ) == expected assert terminal_frame("repo-a", "hello", "m-1") == ( "\r\n#repo-a: hello [m-1]\r\n" ) diff --git a/tests/test_tmux_integration.py b/tests/test_tmux_integration.py index 4bdde9e..692ff1d 100644 --- a/tests/test_tmux_integration.py +++ b/tests/test_tmux_integration.py @@ -10,6 +10,7 @@ import pytest from tamq.broker import BrokerIdentity, InputBroker from tamq.control import ControlModeClient from tamq.store import Store +from tamq.terminal import terminal_frame, write_terminal_output from tamq.tmux import LaunchPlan, TmuxManager @@ -112,3 +113,88 @@ def test_real_tmux_starts_two_repo_windows_and_reuses_them(tmp_path, monkeypatch store.close() finally: manager._run("kill-server", check=False) + + +@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux is not installed") +def test_real_tmux_output_preserves_partial_input_line_and_cursor(tmp_path): + repo = tmp_path / "audit-core" + repo.mkdir() + socket_name = f"tamq-output-{os.getpid()}-{uuid4().hex[:8]}" + session = f"tamq-output-{uuid4().hex[:8]}" + 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" + plan = LaunchPlan(("audit-core",), {"audit-core": str(repo)}, ()) + + try: + manager.ensure_plan(plan) + 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"} + manager._run( + "send-keys", "-t", target, "-l", "--", "printf 'line-one\\nline-two\\n'" + ) + manager._run("send-keys", "-t", target, "Enter") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + initial_capture = manager._run("capture-pane", "-p", "-t", target) + initial_lines = initial_capture.splitlines() + if "line-one" in initial_lines and "line-two" in initial_lines: + break + time.sleep(0.05) + assert "line-one" in initial_lines and "line-two" in initial_lines + manager._run("send-keys", "-t", target, "-l", "--", "PARTIAL-INPUT") + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + before_capture = manager._run("capture-pane", "-p", "-t", target) + if "PARTIAL-INPUT" in before_capture: + break + time.sleep(0.05) + assert "PARTIAL-INPUT" in before_capture + + control = ControlModeClient( + session, + tmux_command=("tmux", "-L", socket_name), + ) + before = control.pane_display(target) + write_terminal_output( + before.tty_path, + terminal_frame( + "flex-auth", + "stable-message", + "m-stable", + cursor_y=before.cursor_y, + pane_height=before.pane_height, + alternate_on=before.alternate_on, + ), + ) + + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + after_capture = manager._run("capture-pane", "-p", "-t", target) + if "#flex-auth: stable-message [m-stable]" in after_capture: + break + time.sleep(0.05) + after = control.pane_display(target) + assert "#flex-auth: stable-message [m-stable]" in after_capture + before_input = next(line for line in before_capture.splitlines() if "PARTIAL-INPUT" in line) + after_lines = after_capture.splitlines() + assert any("PARTIAL-INPUT" in line for line in after_lines), repr(after_capture) + after_input_index = next( + index for index, line in enumerate(after_lines) if "PARTIAL-INPUT" in line + ) + assert after_lines[after_input_index] == before_input + assert after_lines[after_input_index - 1] == "#flex-auth: stable-message [m-stable]" + assert (after.cursor_x, after.cursor_y) == (before.cursor_x, before.cursor_y) + finally: + manager._run("kill-server", check=False) diff --git a/tests/test_tmux_manager.py b/tests/test_tmux_manager.py index d2972b3..620eca6 100644 --- a/tests/test_tmux_manager.py +++ b/tests/test_tmux_manager.py @@ -155,6 +155,7 @@ def test_neutral_plan_starts_shell_without_sending_keystrokes(tmp_path, monkeypa assert (command_dir / "@a:").is_file() assert (command_dir / "@b").is_file() assert (command_dir / "@b:").is_file() + assert (command_dir / "@").is_file() def test_address_commands_preserve_message_arguments(tmp_path): @@ -173,6 +174,14 @@ def test_address_commands_preserve_message_arguments(tmp_path): ) assert result.stdout == "send -- @audit-core: Some message! $value ; literal --from\n" + result = subprocess.run( + [str(command_dir / "@"), "Some reply!", "$value", "--literal"], + text=True, + capture_output=True, + check=True, + ) + assert result.stdout == "reply -- Some reply! $value --literal\n" + def test_address_commands_reject_unsafe_repository_names(tmp_path): manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") @@ -192,6 +201,18 @@ def test_address_commands_do_not_replace_unowned_commands(tmp_path): assert existing.read_text(encoding="utf-8") == "#!/bin/sh\necho mine\n" +def test_address_commands_do_not_replace_unowned_bare_reply(tmp_path): + command_dir = tmp_path / "commands" + command_dir.mkdir() + existing = command_dir / "@" + existing.write_text("#!/bin/sh\necho mine\n", encoding="utf-8") + manager = tmux.TmuxManager("tamq-test", command_dir=command_dir) + + with pytest.raises(tmux.TmuxError, match="refusing to replace"): + manager._install_address_commands(["audit-core"]) + assert existing.read_text(encoding="utf-8") == "#!/bin/sh\necho mine\n" + + def test_preflight_rejects_missing_agent(tmp_path, monkeypatch): repo = tmp_path / "a" repo.mkdir() diff --git a/workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md b/workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md index 7c7f77e..fe44224 100644 --- a/workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md +++ b/workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md @@ -4,7 +4,7 @@ type: workplan title: "Counterparty reply shorthand and stable terminal output" domain: communication repo: tmux-amq -status: active +status: finished owner: codex topic_slug: coulomb-social planning_priority: P0 @@ -42,7 +42,7 @@ restore the cursor. The current input row and cursor position remain stable. ```task id: TAMQ-WP-0008-T01 -status: progress +status: done priority: high state_hub_task_id: "aad3dea8-b5c9-5b07-97bb-92aba18137d8" ``` @@ -57,7 +57,7 @@ transport, and refuse collision with an unrelated existing `@` command. ```task id: TAMQ-WP-0008-T02 -status: todo +status: done priority: high state_hub_task_id: "f9893aba-e2b4-50da-9632-0f2e7f3b8c45" ``` @@ -72,7 +72,7 @@ row exists above the cursor. Never emit input bytes. ```task id: TAMQ-WP-0008-T03 -status: todo +status: done priority: high state_hub_task_id: "f81290ce-5bf6-5c08-a0b1-efb2fb8b5782" ``` @@ -87,7 +87,7 @@ message becomes visible immediately above them. ```task id: TAMQ-WP-0008-T04 -status: todo +status: done priority: high state_hub_task_id: "944c7fea-2e7c-5fcc-9fc6-1abc6a8cc784" ``` @@ -95,3 +95,14 @@ state_hub_task_id: "944c7fea-2e7c-5fcc-9fc6-1abc6a8cc784" Update help, README, and SCOPE; run complete checks; install the build; exercise `@ MESSAGE` in both directions in a live two-window session; and retain only user messages pending after acknowledging controlled smoke records. + +## Completion evidence + +- `make check`: 100 tests passed, including a real tmux pane proving that a + partial input row and both cursor coordinates remain unchanged. +- Coverage: 76% overall; terminal framing is 84% and control handling is 81%. +- `make install`: installed `tmux-amq==0.1.0` and verified `tamq --version`. +- Live `flex-auth`/`audit-core`: bare `@` routed in both directions. Incoming + comments appeared immediately above `SECOND-PARTIAL` and `THIRD-PARTIAL` + without moving either cursor. Only the three controlled smoke messages were + acknowledged; all pre-existing user messages remain pending.