feat: add conversational replies and stable output
Some checks failed
tamq-ci / test (push) Failing after 6s

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:03:30 +02:00
parent 704eeea5c1
commit a4ba6e32e5
17 changed files with 363 additions and 59 deletions

View file

@ -77,6 +77,18 @@ The spelling without the trailing colon is equivalent:
@audit-core please review the auth boundary @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, These are tamq-owned executable commands beside the installed `tamq` command,
not shell-specific aliases. Set `TAMQ_COMMAND_DIR` before startup to select a not shell-specific aliases. Set `TAMQ_COMMAND_DIR` before startup to select a
different writable command directory already present on your shell's `PATH`. 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/<number>` device—the same Unix This uses the pane's tmux-reported `/dev/pts/<number>` device—the same Unix
terminal-output mechanism underlying tools such as `write(1)`. It does not use terminal-output mechanism underlying tools such as `write(1)`. For an ordinary
`send-keys`, send Enter, or place bytes on the foreground process's stdin. shell with screen rows above its cursor, tamq confines scrolling to those rows,
Output can visually interleave with a prompt and a full-screen program may writes the comment immediately above the input row, and restores the cursor.
redraw over it, so the durable inbox remains authoritative. 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: In the `audit-core` window, inspect and acknowledge it:

View file

@ -17,8 +17,8 @@ tamq does not choose or infer them.
acknowledgements, replay, export, and bounded purging. acknowledgements, replay, export, and bounded purging.
- Managed neutral-shell tmux lifecycle and explicit initial commands. - Managed neutral-shell tmux lifecycle and explicit initial commands.
- Durable manual send/inbox/acknowledgement with per-window repository identity, - Durable manual send/inbox/acknowledgement with per-window repository identity,
shell-native address commands, comment-safe display, and explicit pull-time shell-native direct and latest-counterparty reply commands, comment-safe
filters. display, 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 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
@ -48,11 +48,11 @@ transport.
| Intent capability | State | Evidence and remaining gap | | 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. | | 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. | | 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 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. | | 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. |
@ -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 an isolated installed tool. Repository-first startup created two ordinary
shells at exact gita paths without sending initial keystrokes. The test proved shells at exact gita paths without sending initial keystrokes. The test proved
per-window repository identity, stable reuse, shell-native addressing, 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 mutation, service restart, endpoint disappearance, explicit initial-command
startup, and cleanup. startup, and cleanup.
@ -86,7 +87,7 @@ Not yet suitable:
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 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 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

@ -16,7 +16,7 @@
| workplan | TAMQ-WP-0005 | finished | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md | | 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-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-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-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 |
@ -47,7 +47,7 @@
| task | TAMQ-WP-0007-T03 | done | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | 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-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-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-T01 | done | — | 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-T02 | done | — | 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-T03 | done | — | 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-T04 | done | — | workplans/TAMQ-WP-0008-reply-shorthand-stable-output.md |

View file

@ -31,7 +31,7 @@ from .terminal import format_comment
SUBCOMMANDS = frozenset( 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"}) START_OPTIONS = frozenset({"--command", "--cmd", "--tap", "--detach", "--no-service", "--no-display"})
GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"}) GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"})
@ -162,6 +162,7 @@ def build_parser() -> argparse.ArgumentParser:
manual messaging from a managed shell: manual messaging from a managed shell:
@TARGET: MESSAGE... @TARGET: MESSAGE...
@ MESSAGE... reply to the latest sender for this window
tamq inbox [--filter COMMAND] 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("body", nargs="*", help="message body when address is a repo slug")
send.add_argument("--endpoint-id") send.add_argument("--endpoint-id")
send.add_argument("--from", dest="sender_repo", help="sender repository (default: TAMQ_REPO or local)") 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 = subparsers.add_parser("export", help="export history as JSONL")
export.add_argument("--output", required=True) export.add_argument("--output", required=True)
export.add_argument("--repo", dest="target_repo") export.add_argument("--repo", dest="target_repo")
@ -371,15 +374,31 @@ def main(argv: list[str] | None = None) -> int:
store = Store(db_path()) store = Store(db_path())
try: try:
advisory = history_advisory(store) 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) print(advisory, file=sys.stderr)
if args.command == "send": if args.command in {"send", "reply"}:
text = " ".join([args.address, *args.body]).strip() endpoint_id = None
if not text.startswith("@") or ":" not in text: if args.command == "reply":
print("tamq send expects @repo: message", file=sys.stderr); return 2 sender = os.environ.get("TAMQ_REPO")
target, body = text[1:].split(":", 1); body = body.strip() if not sender:
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2 print("tamq reply requires a managed window with TAMQ_REPO", file=sys.stderr)
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local" 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: try:
validate_targets([target]) validate_targets([target])
if sender != "local": if sender != "local":
@ -388,13 +407,13 @@ def main(argv: list[str] | None = None) -> int:
print(f"tamq: {exc}", file=sys.stderr); return 2 print(f"tamq: {exc}", file=sys.stderr); return 2
if asyncio.run(ping()): if asyncio.run(ping()):
payload = {"op": "send", "sender_repo": sender, "target_repo": target, "body": body} payload = {"op": "send", "sender_repo": sender, "target_repo": target, "body": body}
if args.endpoint_id: if endpoint_id:
payload["endpoint_id"] = args.endpoint_id payload["endpoint_id"] = endpoint_id
response = asyncio.run(request(payload)) response = asyncio.run(request(payload))
if not response.get("ok"): if not response.get("ok"):
print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1 print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1
print(response["message_id"]); return 0 print(response["message_id"]); return 0
if args.endpoint_id: if endpoint_id:
print("tamq: service is not running", file=sys.stderr); return 1 print("tamq: service is not running", file=sys.stderr); return 1
try: try:
print(store.add(sender, target, body)) print(store.add(sender, target, body))

View file

@ -10,6 +10,15 @@ class ControlModeError(RuntimeError):
pass pass
@dataclass
class PaneDisplay:
tty_path: str
cursor_x: int
cursor_y: int
pane_height: int
alternate_on: bool
@dataclass @dataclass
class ControlModeClient: class ControlModeClient:
session: str session: str
@ -35,16 +44,32 @@ class ControlModeClient:
return expected_pid is None or result.stdout.strip() == str(expected_pid) return expected_pid is None or result.stdout.strip() == str(expected_pid)
def pane_tty(self, window: str) -> str: def pane_tty(self, window: str) -> str:
return self.pane_display(window).tty_path
def pane_display(self, window: str) -> PaneDisplay:
result = subprocess.run( 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, text=True,
capture_output=True, capture_output=True,
check=False, check=False,
) )
tty_path = result.stdout.strip() output = result.stdout.strip()
if result.returncode != 0 or not tty_path: if result.returncode != 0 or not output:
raise ControlModeError(result.stderr.strip() or f"cannot resolve pane tty: {window}") 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: def start(self) -> None:
if self.process is not None: if self.process is not None:

View file

@ -100,11 +100,16 @@ class Service:
try: try:
target = f'{endpoint["session"]}:{row["target_repo"]}' target = f'{endpoint["session"]}:{row["target_repo"]}'
if delivery_mode == "output": if delivery_mode == "output":
tty_path = control.pane_tty(target) display = control.pane_display(target)
write_terminal_output( write_terminal_output(
tty_path, display.tty_path,
terminal_frame( 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: else:

View file

@ -153,6 +153,15 @@ class Store:
where = f" WHERE {' AND '.join(clauses)}" if clauses else "" where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
return list(self.db.execute(f"SELECT * FROM messages{where} ORDER BY created_at", values)) 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: def set_state(self, message_id: str, state: str) -> None:
column = {"injected": "injected_at", "acknowledged": "acknowledged_at"}.get(state) column = {"injected": "injected_at", "acknowledged": "acknowledged_at"}.get(state)
if column: if column:

View file

@ -29,9 +29,35 @@ def format_comment(sender: str, body: str, message_id: str) -> str:
return "\n".join(rendered) return "\n".join(rendered)
def terminal_frame(sender: str, body: str, message_id: str) -> str: def terminal_frame(
"""Frame asynchronous output away from the current visual input line.""" 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") 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" return f"\r\n{content}\r\n"

View file

@ -78,6 +78,13 @@ class TmuxManager:
self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700) self.command_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
if not os.access(self.command_dir, os.W_OK | os.X_OK): 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}") 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: for repo in repos:
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None: if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None:
raise TmuxError( raise TmuxError(
@ -90,20 +97,24 @@ class TmuxManager:
f"exec {shell_join(list(self.tamq_command))} send -- {shlex.quote(target)} \"$@\"\n" f"exec {shell_join(list(self.tamq_command))} send -- {shlex.quote(target)} \"$@\"\n"
) )
for name in (f"@{repo}", target): for name in (f"@{repo}", target):
destination = self.command_dir / name scripts[name] = script
if destination.exists() or destination.is_symlink(): for name in scripts:
if destination.is_symlink() or not destination.is_file(): destination = self.command_dir / name
raise TmuxError(f"refusing to replace existing command: {destination}") if destination.exists() or destination.is_symlink():
try: if destination.is_symlink() or not destination.is_file():
existing = destination.read_text(encoding="utf-8") raise TmuxError(f"refusing to replace existing command: {destination}")
except OSError as exc: try:
raise TmuxError(f"cannot inspect existing command: {destination}") from exc existing = destination.read_text(encoding="utf-8")
if "# tamq-address-command v1" not in existing: except OSError as exc:
raise TmuxError(f"refusing to replace existing command: {destination}") raise TmuxError(f"cannot inspect existing command: {destination}") from exc
temporary = self.command_dir / f".{name}.{uuid4().hex}.tmp" if "# tamq-address-command v1" not in existing:
temporary.write_text(script, encoding="utf-8") raise TmuxError(f"refusing to replace existing command: {destination}")
temporary.chmod(0o700) for name, script in scripts.items():
temporary.replace(destination) 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, ...]: def _window_environment(self, repo: str) -> tuple[str, ...]:
path = os.environ.get("PATH", "") path = os.environ.get("PATH", "")

View file

@ -18,12 +18,12 @@ def test_pane_tty_is_resolved_through_tmux(monkeypatch):
def run(command, **kwargs): def run(command, **kwargs):
calls.append(command) 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) monkeypatch.setattr(control.subprocess, "run", run)
client = ControlModeClient("tamq", tmux_command=("tmux", "-L", "test")) client = ControlModeClient("tamq", tmux_command=("tmux", "-L", "test"))
assert client.pane_tty("tamq:audit-core") == "/dev/pts/7" assert client.pane_tty("tamq:audit-core") == "/dev/pts/7"
assert calls == [[ assert calls == [[
"tmux", "-L", "test", "display-message", "-p", "-t", "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}",
]] ]]

View file

@ -32,6 +32,43 @@ def test_manual_send_inbox_and_ack_use_window_repository_identity(tmp_path, monk
assert capsys.readouterr().out == "" 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): def test_inbox_requires_repository_outside_managed_window(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state"))
monkeypatch.delenv("TAMQ_REPO", raising=False) monkeypatch.delenv("TAMQ_REPO", raising=False)

View file

@ -1,3 +1,4 @@
from tamq.control import PaneDisplay
from tamq.service import Service from tamq.service import Service
from tamq.store import Store from tamq.store import Store
@ -19,8 +20,14 @@ class FakeControl:
def inject(self, window, text): def inject(self, window, text):
self.injected.append((window, text)) self.injected.append((window, text))
def pane_tty(self, window): def pane_display(self, window):
return f"/dev/pts/{window.rsplit(':', 1)[-1]}" 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): def close(self):
return None return None

View file

@ -59,3 +59,15 @@ def test_mark_displayed_releases_lease_without_acknowledging(tmp_path):
assert row["displayed_at"] is not None assert row["displayed_at"] is not None
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0 assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
store.close() 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()

View file

@ -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") == ( assert format_comment("repo-a", "first\nsecond\x1b[31m", "m-1") == (
"#repo-a: first\n# second\\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") == ( assert terminal_frame("repo-a", "hello", "m-1") == (
"\r\n#repo-a: hello [m-1]\r\n" "\r\n#repo-a: hello [m-1]\r\n"
) )

View file

@ -10,6 +10,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.store import Store from tamq.store import Store
from tamq.terminal import terminal_frame, write_terminal_output
from tamq.tmux import LaunchPlan, TmuxManager 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() store.close()
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_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)

View file

@ -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 / "@a:").is_file()
assert (command_dir / "@b").is_file() assert (command_dir / "@b").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): 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" 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): def test_address_commands_reject_unsafe_repository_names(tmp_path):
manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") 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" 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): def test_preflight_rejects_missing_agent(tmp_path, monkeypatch):
repo = tmp_path / "a" repo = tmp_path / "a"
repo.mkdir() repo.mkdir()

View file

@ -4,7 +4,7 @@ type: workplan
title: "Counterparty reply shorthand and stable terminal output" title: "Counterparty reply shorthand and stable terminal output"
domain: communication domain: communication
repo: tmux-amq repo: tmux-amq
status: active status: finished
owner: codex owner: codex
topic_slug: coulomb-social topic_slug: coulomb-social
planning_priority: P0 planning_priority: P0
@ -42,7 +42,7 @@ restore the cursor. The current input row and cursor position remain stable.
```task ```task
id: TAMQ-WP-0008-T01 id: TAMQ-WP-0008-T01
status: progress status: done
priority: high priority: high
state_hub_task_id: "aad3dea8-b5c9-5b07-97bb-92aba18137d8" state_hub_task_id: "aad3dea8-b5c9-5b07-97bb-92aba18137d8"
``` ```
@ -57,7 +57,7 @@ transport, and refuse collision with an unrelated existing `@` command.
```task ```task
id: TAMQ-WP-0008-T02 id: TAMQ-WP-0008-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "f9893aba-e2b4-50da-9632-0f2e7f3b8c45" state_hub_task_id: "f9893aba-e2b4-50da-9632-0f2e7f3b8c45"
``` ```
@ -72,7 +72,7 @@ row exists above the cursor. Never emit input bytes.
```task ```task
id: TAMQ-WP-0008-T03 id: TAMQ-WP-0008-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "f81290ce-5bf6-5c08-a0b1-efb2fb8b5782" state_hub_task_id: "f81290ce-5bf6-5c08-a0b1-efb2fb8b5782"
``` ```
@ -87,7 +87,7 @@ message becomes visible immediately above them.
```task ```task
id: TAMQ-WP-0008-T04 id: TAMQ-WP-0008-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "944c7fea-2e7c-5fcc-9fc6-1abc6a8cc784" 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 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 `@ MESSAGE` in both directions in a live two-window session; and retain only
user messages pending after acknowledging controlled smoke records. 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.