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

@ -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))

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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"

View file

@ -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", "")