From e995cab1b851f52ef5db0b02c9259dfdc719e4c2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 24 Aug 2026 20:42:35 +0200 Subject: [PATCH] feat: add shell-native message routing Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc --- README.md | 44 ++++++++++-- SCOPE.md | 19 ++--- WORK-RECORDS.md | 12 ++-- src/tamq/cli.py | 70 ++++++++++++++++++- src/tamq/tmux.py | 68 +++++++++++++++++- tests/test_cli.py | 10 +++ tests/test_install_target.py | 36 +++++++++- tests/test_manual_messaging.py | 63 +++++++++++++++++ tests/test_ptytap_integration.py | 1 + tests/test_tmux_integration.py | 1 + tests/test_tmux_manager.py | 59 ++++++++++++++-- ...MQ-WP-0006-shell-native-message-routing.md | 50 +++++++++---- 12 files changed, 391 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index c553e09..222a3fd 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,24 @@ windows and never run another initial command in them. ## Exchange messages manually Each managed shell exports its own repository slug as `TAMQ_REPO`. From the -`flex-auth` window, queue a message: +`flex-auth` window, queue a message using the repository command installed for +the session: + +```bash +@audit-core: please review the auth boundary +``` + +The spelling without the trailing colon is equivalent: + +```bash +@audit-core please review the auth boundary +``` + +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`. +Tamq refuses to overwrite unrelated commands. The shims only use tamq's durable +send operation. The long form remains available: ```bash tamq send '@audit-core: please review the auth boundary' @@ -70,14 +87,33 @@ tamq inbox tamq ack ``` +Human inbox output is safe to paste into an ordinary shell because every line +is a comment. It includes the durable id needed by `ack`: + +```text +#flex-auth: please review the auth boundary [m-...] +``` + +To explicitly consume pending messages through a command, use an inbox filter: + +```bash +tamq inbox --filter 'cat >> msg.log' +``` + +The command runs once per pending message with the comment form on standard +input. `TAMQ_MESSAGE_ID`, `TAMQ_SENDER_REPO`, and `TAMQ_TARGET_REPO` are set in +its environment. A zero exit acknowledges that message; a non-zero exit leaves +it and all later messages pending. Filters never run in the background and +cannot be combined with `--all` or `--json`. + Outside a managed window, use `tamq inbox --repo audit-core` and optionally `--json`. Manual messages remain durable and pending until acknowledged. They are never injected as terminal keystrokes, so they cannot corrupt a command being typed in the target pane. -After upgrading from the earlier Codex-default alpha, recreate the managed -session once so existing panes are replaced by neutral shells and the broker is -re-registered in manual delivery mode: +After upgrading from an earlier alpha, recreate the managed session once so +existing panes inherit the neutral shell contract, repository command `PATH`, +and manual broker registration: ```bash tamq stop diff --git a/SCOPE.md b/SCOPE.md index 2cc0ada..10e7841 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -16,7 +16,9 @@ tamq does not choose or infer them. - Local SQLite message history, leases, endpoint registrations, delivery state, 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. +- Durable manual send/inbox/acknowledgement with per-window repository identity, + shell-native address commands, comment-safe display, and explicit pull-time + filters. - Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY broker for integration experiments. - Exact `gita` repository validation and direct `@repo: message` routing. @@ -44,11 +46,11 @@ transport. | Intent capability | State | Evidence and remaining gap | | --- | --- | --- | -| Direct repository addressing | Implemented | Exact `gita` validation and `@repo:` parsing are covered by tests. | +| 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. | | 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 | Manual endpoints never inject pending messages into panes. Installed testing proves send/inbox/ack exchange while the target pane remains unchanged; legacy rows migrate to manual mode. | +| Safe manual messaging | Implemented for local alpha | Manual endpoints never inject pending messages into panes. Installed testing proves shell-native send, comment-safe inbox, explicit filter/ack exchange, and an unchanged target pane; legacy rows migrate to manual mode. | | 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 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 | Explicit acknowledgement and the configuration field exist; delivery always marks a successful tmux injection as `injected`, irrespective of `delivery_ack_mode`. | @@ -59,16 +61,17 @@ transport. 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, manual send/inbox/ack exchange, -zero target-pane mutation, service restart, endpoint disappearance, explicit -initial-command startup, and cleanup. +per-window repository identity, stable reuse, shell-native addressing, +comment-safe inbox/filter/ack exchange, zero target-pane mutation, service +restart, endpoint disappearance, explicit initial-command startup, and cleanup. Suitable today: - Local queue, history, export/replay, and diagnostic use. - Interactive local shell or explicitly commanded sessions over one or more gita-registered repositories. -- Durable manual message exchange between managed repository windows. +- Durable manual message exchange between managed repository windows, including + explicit pull-time loggers and filters. - Developing and testing the future coordination-engine adapter against the local socket boundary. @@ -80,7 +83,7 @@ Not yet suitable: and stronger process-supervision evidence. - Cross-host messaging or use as a general-purpose broker. -The suite currently has 77 passing tests and 74% statement coverage. Coverage +The suite currently has 85 passing tests and 75% 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 7469d60..dd61c9b 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -14,7 +14,7 @@ | workplan | TAMQ-WP-0003 | active | — | workplans/TAMQ-WP-0003-delivery-reliability.md | | workplan | TAMQ-WP-0004 | finished | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.md | | workplan | TAMQ-WP-0005 | finished | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md | -| workplan | TAMQ-WP-0006 | active | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | +| workplan | TAMQ-WP-0006 | finished | — | workplans/TAMQ-WP-0006-shell-native-message-routing.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 | @@ -35,8 +35,8 @@ | task | TAMQ-WP-0005-T02 | done | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md | | task | TAMQ-WP-0005-T03 | done | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md | | task | TAMQ-WP-0005-T04 | done | — | workplans/TAMQ-WP-0005-terminal-neutral-manual-messaging.md | -| task | TAMQ-WP-0006-T01 | progress | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | -| task | TAMQ-WP-0006-T02 | todo | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | -| task | TAMQ-WP-0006-T03 | todo | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | -| task | TAMQ-WP-0006-T04 | todo | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | -| task | TAMQ-WP-0006-T05 | todo | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | +| task | TAMQ-WP-0006-T01 | done | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | +| task | TAMQ-WP-0006-T02 | done | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | +| task | TAMQ-WP-0006-T03 | done | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | +| task | TAMQ-WP-0006-T04 | done | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | +| task | TAMQ-WP-0006-T05 | done | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md | diff --git a/src/tamq/cli.py b/src/tamq/cli.py index 701488e..c4a7430 100644 --- a/src/tamq/cli.py +++ b/src/tamq/cli.py @@ -7,6 +7,7 @@ import asyncio import json import os import signal +import sqlite3 import fcntl from uuid import uuid4 import subprocess @@ -132,10 +133,42 @@ def attach_session(session: str) -> int: return subprocess.run(command, check=False).returncode +def _terminal_safe(text: str) -> str: + return "".join( + character + if character.isprintable() + else f"\\x{ord(character):02x}" + for character in text + ) + + +def format_comment_message(row: sqlite3.Row) -> str: + """Render a durable message so every displayed line remains a shell comment.""" + sender = _terminal_safe(str(row["sender_repo"])) + body = str(row["body"]) + lines = body.splitlines() or [""] + rendered = [f"#{sender}: {_terminal_safe(lines[0])}"] + rendered.extend(f"# {_terminal_safe(line)}" for line in lines[1:]) + rendered[-1] += f" [{_terminal_safe(str(row['message_id']))}]" + return "\n".join(rendered) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="tamq", description="Repository-aware tmux sessions with durable local messaging.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""session shorthand: + tamq [--detach] [--command COMMAND] REPO [REPO ...] + + With no --command, tamq opens ordinary repository shells. --command is an + explicit initial command for newly created windows; it does not opt in to + terminal observation or message injection. + +manual messaging from a managed shell: + @TARGET: MESSAGE... + tamq inbox [--filter COMMAND] +""", ) parser.add_argument("--version", "-V", action="version", version=__version__) parser.add_argument("--orwell", action="store_true", help="enable unsafe local diagnostics") @@ -163,6 +196,11 @@ def build_parser() -> argparse.ArgumentParser: inbox.add_argument("--repo", dest="target_repo", help="target repository (default: TAMQ_REPO in a managed window)") inbox.add_argument("--all", action="store_true", help="include non-pending messages") inbox.add_argument("--json", action="store_true", help="emit one JSON object per message") + inbox.add_argument( + "--filter", + dest="filter_command", + help="consume each pending comment through COMMAND and acknowledge it after a zero exit", + ) inspect = subparsers.add_parser("inspect", help="inspect one message") inspect.add_argument("message_id") ack = subparsers.add_parser("ack", help="acknowledge one durable message") @@ -370,12 +408,42 @@ def main(argv: list[str] | None = None) -> int: validate_targets([target]) except RegistryError as exc: print(f"tamq: {exc}", file=sys.stderr); return 2 + if args.filter_command is not None and not args.filter_command.strip(): + print("tamq: --filter command must not be empty", file=sys.stderr) + return 2 + if args.filter_command and (args.all or args.json): + print("tamq: --filter cannot be combined with --all or --json", file=sys.stderr) + return 2 rows = store.list(target, None if args.all else "pending") for row in rows: if args.json: print(json.dumps(dict(row), sort_keys=True)) + elif args.filter_command: + environment = os.environ.copy() + environment.update( + { + "TAMQ_MESSAGE_ID": row["message_id"], + "TAMQ_SENDER_REPO": row["sender_repo"], + "TAMQ_TARGET_REPO": row["target_repo"], + } + ) + result = subprocess.run( + args.filter_command, + shell=True, + input=format_comment_message(row) + "\n", + text=True, + env=environment, + check=False, + ) + if result.returncode: + print( + f"tamq: filter failed for {row['message_id']} with exit status {result.returncode}; message remains pending", + file=sys.stderr, + ) + return 1 + store.acknowledge(row["message_id"]) else: - print(f"{row['message_id']} {row['sender_repo']} -> {row['target_repo']}: {row['body']}") + print(format_comment_message(row)) return 0 if args.command == "history": for row in store.list(args.target_repo, args.state): print(json.dumps(dict(row), sort_keys=True)) diff --git a/src/tamq/tmux.py b/src/tamq/tmux.py index 344fa03..84199b5 100644 --- a/src/tamq/tmux.py +++ b/src/tamq/tmux.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re import shlex import shutil from uuid import uuid4 @@ -9,6 +10,7 @@ from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path +from .config import state_dir from .registry import repository_paths, validate_targets @@ -48,11 +50,65 @@ class TmuxManager: *, tmux_command: Sequence[str] | None = None, tamq_command: Sequence[str] | None = None, + command_dir: Path | None = None, ): self.session = session socket_name = os.environ.get("TAMQ_TMUX_SOCKET") self.tmux_command = tuple(tmux_command or (["tmux", "-L", socket_name] if socket_name else ["tmux"])) - self.tamq_command = tuple(tamq_command or ["tamq"]) + if tamq_command is None: + self.tamq_command = (shutil.which("tamq") or "tamq",) + else: + self.tamq_command = tuple(tamq_command) + configured_command_dir = os.environ.get("TAMQ_COMMAND_DIR") + if command_dir is not None: + self.command_dir = command_dir + elif configured_command_dir: + self.command_dir = Path(configured_command_dir) + elif ( + len(self.tamq_command) == 1 + and Path(self.tamq_command[0]).is_absolute() + and os.access(Path(self.tamq_command[0]).parent, os.W_OK | os.X_OK) + ): + self.command_dir = Path(self.tamq_command[0]).parent + else: + self.command_dir = state_dir() / "commands" / session + + def _install_address_commands(self, repos: Sequence[str]) -> None: + """Install tamq-owned commands without modifying a user's shell files.""" + 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}") + for repo in repos: + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", repo) is None: + raise TmuxError( + f"repository name cannot be exposed as a shell command: {repo!r}" + ) + target = f"@{repo}:" + script = ( + "#!/bin/sh\n" + "# tamq-address-command v1\n" + 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) + + def _window_environment(self, repo: str) -> tuple[str, ...]: + path = os.environ.get("PATH", "") + command_path = f"{self.command_dir}{os.pathsep}{path}" if path else str(self.command_dir) + return "-e", f"TAMQ_REPO={repo}", "-e", f"PATH={command_path}" def _run(self, *args: str, check: bool = True) -> str: result = subprocess.run([*self.tmux_command, *args], text=True, capture_output=True, check=False) @@ -133,7 +189,7 @@ class TmuxManager: first_repo = plan.repos[0] self._run( "new-session", "-d", "-s", self.session, "-n", "__tamq_boot", - "-e", f"TAMQ_REPO={first_repo}", + *self._window_environment(first_repo), "-c", plan.paths[first_repo], ) created_session = True @@ -151,6 +207,12 @@ class TmuxManager: self._run("set-option", "-t", self.session, "@tamq_instance_id", instance_id) self._run("set-option", "-t", self.session, "@tamq_managed", "1") windows = self._run("list-windows", "-t", self.session, "-F", "#{window_name}").splitlines() + addressable_repos = [repo for repo in windows if repo != "__tamq_boot"] + addressable_repos.extend(repo for repo in plan.repos if repo not in addressable_repos) + self._install_address_commands(addressable_repos) + command_path = self._window_environment(plan.repos[0])[-1].removeprefix("PATH=") + self._run("set-environment", "-t", self.session, "PATH", command_path) + self._run("set-option", "-t", self.session, "@tamq_command_dir", str(self.command_dir)) for index, repo in enumerate(plan.repos): if repo in windows: continue @@ -162,7 +224,7 @@ class TmuxManager: else: self._run( "new-window", "-d", "-t", self.session, "-n", repo, - "-e", f"TAMQ_REPO={repo}", "-c", plan.paths[repo], + *self._window_environment(repo), "-c", plan.paths[repo], ) windows.append(repo) created_windows.append(repo) diff --git a/tests/test_cli.py b/tests/test_cli.py index 911b7ae..e3ef84b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,6 +13,16 @@ def test_help_and_version(capsys): assert "usage:" in capsys.readouterr().out +def test_root_help_exposes_repository_shorthand_and_command(capsys): + with pytest.raises(SystemExit) as exc: + main(["--help"]) + assert exc.value.code == 0 + output = capsys.readouterr().out + assert "tamq [--detach] [--command COMMAND] REPO [REPO ...]" in output + assert "With no --command" in output + assert "@TARGET: MESSAGE" in output + + def test_attach_delegates_to_tmux(monkeypatch): calls = [] monkeypatch.delenv("TMUX", raising=False) diff --git a/tests/test_install_target.py b/tests/test_install_target.py index 3b4994c..3991e22 100644 --- a/tests/test_install_target.py +++ b/tests/test_install_target.py @@ -91,6 +91,8 @@ def test_isolated_installed_tool_session_smoke(tmp_path): timeout=60, ) assert run(str(tamq), "--version").stdout.strip() == "0.1.0" + root_help = run(str(tamq), "--help").stdout + assert "tamq [--detach] [--command COMMAND] REPO [REPO ...]" in root_help started = json.loads( run( str(tamq), @@ -176,7 +178,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path): "send-keys", "-t", "tamq:railiance-platform", - f"{tamq} send '@activity-core: installed-message'", + "@activity-core: installed-message", "C-m", ) deadline = time.monotonic() + 5 @@ -191,10 +193,16 @@ def test_isolated_installed_tool_session_smoke(tmp_path): if inbox: break time.sleep(0.05) + assert inbox, run( + *tmux, "capture-pane", "-p", "-t", "tamq:railiance-platform" + ).stdout assert inbox[-1]["sender_repo"] == "railiance-platform" assert inbox[-1]["body"] == "installed-message" assert inbox[-1]["state"] == "pending" message_id = inbox[-1]["message_id"] + assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == ( + f"#railiance-platform: installed-message [{message_id}]\n" + ) target_after = run( *tmux, "capture-pane", "-p", "-t", "tamq:activity-core" ).stdout @@ -209,6 +217,32 @@ def test_isolated_installed_tool_session_smoke(tmp_path): ).stdout.splitlines() ] assert all_messages[-1]["state"] == "acknowledged" + + run( + *tmux, + "send-keys", + "-t", + "tamq:railiance-platform", + "@activity-core filtered-message", + "C-m", + ) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + pending = run( + str(tamq), "inbox", "--repo", "activity-core", "--json" + ).stdout + if "filtered-message" in pending: + break + time.sleep(0.05) + message_log = tmp_path / "msg.log" + run( + str(tamq), "inbox", "--repo", "activity-core", "--filter", + f"cat >> {message_log}", + ) + assert "#railiance-platform: filtered-message [m-" in message_log.read_text( + encoding="utf-8" + ) + assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == "" status = json.loads(run(str(tamq), "status").stdout) assert status["service"] is True assert [item["endpoint_id"] for item in status["endpoints"]] == [started["instance_id"]] diff --git a/tests/test_manual_messaging.py b/tests/test_manual_messaging.py index dc58480..7bc075b 100644 --- a/tests/test_manual_messaging.py +++ b/tests/test_manual_messaging.py @@ -1,6 +1,8 @@ import json +import shlex from tamq.cli import main +from tamq.store import Store async def service_is_down(): @@ -35,3 +37,64 @@ def test_inbox_requires_repository_outside_managed_window(tmp_path, monkeypatch, monkeypatch.delenv("TAMQ_REPO", raising=False) assert main(["inbox"]) == 2 assert "requires --repo" in capsys.readouterr().err + + +def test_human_inbox_is_comment_safe_on_every_line(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None) + store = Store(tmp_path / "state" / "tamq.sqlite3") + message_id = store.add("flex-auth", "audit-core", "first\nsecond\x1b[31m") + store.close() + + assert main(["inbox", "--repo", "audit-core"]) == 0 + assert capsys.readouterr().out == ( + f"#flex-auth: first\n# second\\x1b[31m [{message_id}]\n" + ) + + +def test_inbox_filter_logs_and_acknowledges_successful_messages(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None) + store = Store(tmp_path / "state" / "tamq.sqlite3") + first_id = store.add("flex-auth", "audit-core", "review 'quoted' $value") + second_id = store.add("railiance-platform", "audit-core", "second") + store.close() + log = tmp_path / "messages.log" + + assert main([ + "inbox", "--repo", "audit-core", "--filter", + f"cat >> {shlex.quote(str(log))}", + ]) == 0 + assert log.read_text(encoding="utf-8") == ( + f"#flex-auth: review 'quoted' $value [{first_id}]\n" + f"#railiance-platform: second [{second_id}]\n" + ) + assert main(["inbox", "--repo", "audit-core"]) == 0 + assert capsys.readouterr().out == "" + + +def test_failed_inbox_filter_leaves_current_and_later_messages_pending(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None) + store = Store(tmp_path / "state" / "tamq.sqlite3") + first_id = store.add("a", "audit-core", "first") + second_id = store.add("b", "audit-core", "second") + store.close() + + assert main(["inbox", "--repo", "audit-core", "--filter", "false"]) == 1 + assert first_id in capsys.readouterr().err + assert main(["inbox", "--repo", "audit-core", "--json"]) == 0 + rows = [json.loads(line) for line in capsys.readouterr().out.splitlines()] + assert [row["message_id"] for row in rows] == [first_id, second_id] + assert all(row["state"] == "pending" for row in rows) + + +def test_inbox_filter_rejects_non_pending_and_json_modes(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("TAMQ_STATE_DIR", str(tmp_path / "state")) + monkeypatch.setattr("tamq.cli.validate_targets", lambda repos: None) + assert main(["inbox", "--repo", "audit-core", "--all", "--filter", "cat"]) == 2 + assert "cannot be combined" in capsys.readouterr().err + assert main(["inbox", "--repo", "audit-core", "--json", "--filter", "cat"]) == 2 + assert "cannot be combined" in capsys.readouterr().err + assert main(["inbox", "--repo", "audit-core", "--filter", " "]) == 2 + assert "must not be empty" in capsys.readouterr().err diff --git a/tests/test_ptytap_integration.py b/tests/test_ptytap_integration.py index 2dcf23b..bd87603 100644 --- a/tests/test_ptytap_integration.py +++ b/tests/test_ptytap_integration.py @@ -46,6 +46,7 @@ def test_tap_propagates_terminal_size_resize_and_raw_mouse_input(tmp_path, monke session, tmux_command=("tmux", "-L", socket_name), tamq_command=(sys.executable, "-m", "tamq.cli"), + command_dir=tmp_path / "commands", ) plan = LaunchPlan( ("activity-core",), diff --git a/tests/test_tmux_integration.py b/tests/test_tmux_integration.py index 1fa9eca..4bdde9e 100644 --- a/tests/test_tmux_integration.py +++ b/tests/test_tmux_integration.py @@ -30,6 +30,7 @@ def test_real_tmux_starts_two_repo_windows_and_reuses_them(tmp_path, monkeypatch session, tmux_command=("tmux", "-L", socket_name), tamq_command=(sys.executable, "-m", "tamq.cli"), + command_dir=tmp_path / "commands", ) plan = LaunchPlan( ("railiance-platform", "activity-core"), diff --git a/tests/test_tmux_manager.py b/tests/test_tmux_manager.py index 1a192e7..d2972b3 100644 --- a/tests/test_tmux_manager.py +++ b/tests/test_tmux_manager.py @@ -1,10 +1,13 @@ +import os +import subprocess + from tamq import tmux import pytest def test_tmux_manager_builds_tap_windows(tmp_path, monkeypatch): calls = [] - manager = tmux.TmuxManager("tamq-test") + manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") repo_a = tmp_path / "a" repo_b = tmp_path / "b" repo_a.mkdir() @@ -42,7 +45,7 @@ def test_tmux_manager_builds_tap_windows(tmp_path, monkeypatch): def test_tmux_manager_reuses_session_instance_id(tmp_path, monkeypatch): repo = tmp_path / "a" repo.mkdir() - manager = tmux.TmuxManager("tamq-test") + manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") options = {"@tamq_managed": "1"} monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) @@ -74,7 +77,7 @@ def test_tmux_manager_reuses_session_instance_id(tmp_path, monkeypatch): def test_preflight_rejects_foreign_session(tmp_path, monkeypatch): repo = tmp_path / "a" repo.mkdir() - manager = tmux.TmuxManager("tamq-test") + manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) monkeypatch.setattr(tmux.shutil, "which", lambda command: f"/bin/{command}") @@ -95,7 +98,7 @@ def test_preflight_rejects_foreign_session(tmp_path, monkeypatch): def test_tmux_only_plan_starts_agent_without_tap(tmp_path, monkeypatch): - manager = tmux.TmuxManager("tamq-test") + manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") calls = [] plan = tmux.LaunchPlan(("a",), {"a": str(tmp_path)}, ("codex", "--quiet")) @@ -120,7 +123,8 @@ def test_tmux_only_plan_starts_agent_without_tap(tmp_path, monkeypatch): def test_neutral_plan_starts_shell_without_sending_keystrokes(tmp_path, monkeypatch): - manager = tmux.TmuxManager("tamq-test") + command_dir = tmp_path / "commands" + manager = tmux.TmuxManager("tamq-test", command_dir=command_dir) calls = [] plan = tmux.LaunchPlan(("a", "b"), {"a": str(tmp_path), "b": str(tmp_path)}, ()) @@ -145,12 +149,53 @@ def test_neutral_plan_starts_shell_without_sending_keystrokes(tmp_path, monkeypa new_window = next(call for call in calls if call and call[0] == "new-window") assert "TAMQ_REPO=a" in new_session assert "TAMQ_REPO=b" in new_window + assert f"PATH={command_dir}{os.pathsep}" in " ".join(new_session) + assert f"PATH={command_dir}{os.pathsep}" in " ".join(new_window) + assert (command_dir / "@a").is_file() + assert (command_dir / "@a:").is_file() + assert (command_dir / "@b").is_file() + assert (command_dir / "@b:").is_file() + + +def test_address_commands_preserve_message_arguments(tmp_path): + command_dir = tmp_path / "commands" + manager = tmux.TmuxManager( + "tamq-test", tamq_command=("echo",), command_dir=command_dir, + ) + manager._install_address_commands(["audit-core"]) + + for name in ("@audit-core", "@audit-core:"): + result = subprocess.run( + [str(command_dir / name), "Some message!", "$value", "; literal", "--from"], + text=True, + capture_output=True, + check=True, + ) + assert result.stdout == "send -- @audit-core: Some message! $value ; literal --from\n" + + +def test_address_commands_reject_unsafe_repository_names(tmp_path): + manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") + with pytest.raises(tmux.TmuxError, match="cannot be exposed"): + manager._install_address_commands(["../outside"]) + + +def test_address_commands_do_not_replace_unowned_commands(tmp_path): + command_dir = tmp_path / "commands" + command_dir.mkdir() + existing = command_dir / "@audit-core" + 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() - manager = tmux.TmuxManager("tamq-test") + manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) monkeypatch.setattr(tmux.shutil, "which", lambda command: None if command == "missing-agent" else f"/bin/{command}") @@ -161,7 +206,7 @@ def test_preflight_rejects_missing_agent(tmp_path, monkeypatch): def test_preflight_deduplicates_repositories(tmp_path, monkeypatch): repo = tmp_path / "a" repo.mkdir() - manager = tmux.TmuxManager("tamq-test") + manager = tmux.TmuxManager("tamq-test", command_dir=tmp_path / "commands") monkeypatch.setattr(tmux, "validate_targets", lambda repos: None) monkeypatch.setattr(tmux, "repository_paths", lambda: {"a": str(repo)}) monkeypatch.setattr(tmux.shutil, "which", lambda command: f"/bin/{command}") diff --git a/workplans/TAMQ-WP-0006-shell-native-message-routing.md b/workplans/TAMQ-WP-0006-shell-native-message-routing.md index a590718..6fa6086 100644 --- a/workplans/TAMQ-WP-0006-shell-native-message-routing.md +++ b/workplans/TAMQ-WP-0006-shell-native-message-routing.md @@ -4,7 +4,7 @@ type: workplan title: "Shell-native message routing and explicit inbox filters" domain: communication repo: tmux-amq -status: active +status: finished owner: codex topic_slug: coulomb-social planning_priority: P0 @@ -39,7 +39,9 @@ tamq flex-auth audit-core @flex-auth Another message ``` -These are per-session executable command shims, not shell aliases. They enqueue +These are tamq-owned executable command shims, not shell aliases. They are +placed in the operator command directory that already exposes `tamq`, with an +explicit command-directory override and isolated fallback. They enqueue messages and do nothing else by default. Reading a manual inbox produces comment-safe text: @@ -62,7 +64,7 @@ background, modifies a pane, or becomes a startup default. ```task id: TAMQ-WP-0006-T01 -status: progress +status: done priority: high state_hub_task_id: "f6228d55-37f0-5be6-a868-f681f09d091b" ``` @@ -70,26 +72,35 @@ state_hub_task_id: "f6228d55-37f0-5be6-a868-f681f09d091b" Document repository-first invocation and `--command COMMAND` in `tamq --help`, including the terminal-neutral default and exact opt-in nature of the command. +Completed with a root-help shorthand section that names `--command COMMAND`, +the ordinary-shell default, and the absence of implicit terminal observation or +message injection. + ## Install shell-native repository address commands ```task id: TAMQ-WP-0006-T02 -status: todo +status: done priority: high state_hub_task_id: "432e737c-b34e-56aa-8003-3f61e8ef4faf" ``` -Create a private command directory for the managed session, populate it with -safe `@repo` and `@repo:` entry points for every requested repository, and add -that directory to each newly created window's environment. Invocation must -route through tamq's existing validation and durable send path. Reject unsafe -repository names rather than creating ambiguous executable paths. +Populate the operator command directory that exposes `tamq` with safe `@repo` +and `@repo:` entry points for every requested repository; support an explicit +`TAMQ_COMMAND_DIR` and isolated state fallback, and also add the selected +directory to each newly created window's environment. Invocation must route +through tamq's existing validation and durable send path. Reject unsafe +repository names and refuse to overwrite commands not owned by tamq. + +Completed with executable `@repo` and `@repo:` shims beside the installed tamq +command, `TAMQ_COMMAND_DIR` override and state fallback, tmux PATH propagation, +safe repository-name checks, tamq ownership markers, and collision refusal. ## Make manual inbox output comment-safe ```task id: TAMQ-WP-0006-T03 -status: todo +status: done priority: high state_hub_task_id: "b4ff430d-3b09-56de-9c1a-20afb018964c" ``` @@ -98,11 +109,15 @@ Render human-readable messages as shell comments prefixed with `#sender:` and retain the message id for acknowledgement. Keep structured JSON stable for automation. +Completed with `#sender:` rendering on every body line, durable ids on the +final line, and escaping of non-printing terminal control characters. JSON +output retains the original structured record. + ## Add explicit pull-time inbox filters ```task id: TAMQ-WP-0006-T04 -status: todo +status: done priority: high state_hub_task_id: "d413ad83-06ff-5de1-97aa-256b4f684007" ``` @@ -113,11 +128,15 @@ metadata through environment variables, acknowledge only after a zero exit, and leave failed and subsequent messages pending. Filters are explicit inbox consumers and never background terminal interceptors. +Completed with sequential pull-time filters, bounded metadata environment, +comment-only standard input, success acknowledgement, fail-fast pending +retention, and rejection of ambiguous `--all`/`--json` combinations. + ## Prove and document the ordinary-shell workflow ```task id: TAMQ-WP-0006-T05 -status: todo +status: done priority: high state_hub_task_id: "da957158-fb75-5232-9610-7684ebca45b5" ``` @@ -126,3 +145,10 @@ Cover root help, both address spellings, spaces and shell metacharacters in message bodies, unknown targets, comment-safe inbox output, successful and failed filters, acknowledgement behavior, path isolation, and installed tmux acceptance. Update the operator quickstart and practical command reference. + +Completed with 85 passing tests and 75% statement coverage. The isolated +installed-package test proves root help, real ordinary-shell command lookup +after shell startup, both neutral panes, durable delivery, comment-safe output, +filter acknowledgement, target-pane immutability, restart, recovery, and exact +explicit-command startup. A live user-install smoke repeated shell-native +routing with no target-pane change and left zero pending messages.