feat: display messages as terminal output
Some checks failed
tamq-ci / test (push) Failing after 7s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
tegwick 2026-08-24 22:23:57 +02:00
parent 9000640b2e
commit 9fcfba3c17
16 changed files with 415 additions and 80 deletions

View file

@ -54,6 +54,13 @@ tamq start --command 'htop --tree' flex-auth audit-core
`--cmd` remains an alias for `--command`. Repeated starts reuse existing `--cmd` remains an alias for `--command`. Repeated starts reuse existing
windows and never run another initial command in them. windows and never run another initial command in them.
By default, a routed message is also written as sanitized output to its target
pane. Use `--no-display` when you want durable inbox-only delivery:
```bash
tamq --no-display flex-auth audit-core
```
## Exchange messages manually ## Exchange messages manually
Each managed shell exports its own repository slug as `TAMQ_REPO`. From the Each managed shell exports its own repository slug as `TAMQ_REPO`. From the
@ -80,6 +87,18 @@ send operation. The long form remains available:
tamq send '@audit-core: please review the auth boundary' tamq send '@audit-core: please review the auth boundary'
``` ```
With normal `output` delivery, the target pane visibly receives:
```text
#flex-auth: please review the auth boundary [m-...]
```
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
`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.
In the `audit-core` window, inspect and acknowledge it: In the `audit-core` window, inspect and acknowledge it:
```bash ```bash
@ -107,13 +126,13 @@ it and all later messages pending. Filters never run in the background and
cannot be combined with `--all` or `--json`. cannot be combined with `--all` or `--json`.
Outside a managed window, use `tamq inbox --repo audit-core` and optionally Outside a managed window, use `tamq inbox --repo audit-core` and optionally
`--json`. Manual messages remain durable and pending until acknowledged. They `--json`. Displayed messages remain durable and pending until acknowledged.
are never injected as terminal keystrokes, so they cannot corrupt a command Neither normal output delivery nor `--no-display` injects terminal keystrokes,
being typed in the target pane. so they cannot execute or alter a command being typed in the target pane.
After upgrading from an earlier alpha, recreate the managed session once so After upgrading from an earlier alpha, recreate the managed session once so
existing panes inherit the neutral shell contract, repository command `PATH`, existing panes inherit the neutral shell contract, repository command `PATH`,
and manual broker registration: and terminal-output broker registration:
```bash ```bash
tamq stop tamq stop
@ -161,10 +180,12 @@ later phase.
This keeps tmux-specific topology concerns separate from reusable terminal I/O This keeps tmux-specific topology concerns separate from reusable terminal I/O
observation and message identity. observation and message identity.
Neutral endpoints use manual delivery: messages stay in the durable inbox and Normal endpoints use terminal-output delivery: each message is written once to
never become pane input. The legacy pane-delivery path is available only with the target pane's PTY output and stays pending in the durable inbox until
the explicit `tamq start --tap --command ...` opt-in; it remains subject to the acknowledged. `--no-display` selects inbox-only manual mode. Neither becomes
retry and acknowledgement limitations tracked by `TAMQ-WP-0003`. pane input. The legacy pane-input path is available only with the explicit
`tamq start --tap --command ...` opt-in; it remains subject to the retry and
acknowledgement limitations tracked by `TAMQ-WP-0003`.
The visible endpoint label is `tmux-amq-<PID>`; each boot also receives an The visible endpoint label is `tmux-amq-<PID>`; each boot also receives an
instance nonce so PID reuse cannot collide with prior leases or receipts. instance nonce so PID reuse cannot collide with prior leases or receipts.

View file

@ -19,6 +19,8 @@ tamq does not choose or infer them.
- 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 address commands, comment-safe display, and explicit pull-time
filters. 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 - Explicit opt-in control-mode pane delivery and the full-duplex `tamq tap` PTY
broker for integration experiments. broker for integration experiments.
- Exact `gita` repository validation and direct `@repo: message` routing. - Exact `gita` repository validation and direct `@repo: message` routing.
@ -50,10 +52,10 @@ transport.
| 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 | 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. | | 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. |
| 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 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 | Explicit acknowledgement and the configuration field exist; delivery always marks a successful tmux injection as `injected`, irrespective of `delivery_ack_mode`. | | 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. |
| Coordination-engine interoperability | Not implemented | The adapter contract and implementation remain in `TAMQ-WP-0002`. | | Coordination-engine interoperability | Not implemented | The adapter contract and implementation remain in `TAMQ-WP-0002`. |
## Practical Usability ## Practical Usability
@ -62,8 +64,9 @@ 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 inbox/filter/ack exchange, zero target-pane mutation, service comment-safe target output and inbox/filter/ack exchange, zero target-input
restart, endpoint disappearance, explicit initial-command startup, and cleanup. mutation, service restart, endpoint disappearance, explicit initial-command
startup, and cleanup.
Suitable today: Suitable today:
@ -83,7 +86,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 85 passing tests and 75% statement coverage. Coverage The suite currently has 93 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

@ -15,7 +15,7 @@
| workplan | TAMQ-WP-0004 | finished | — | workplans/TAMQ-WP-0004-operator-installable-local-alpha.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-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 | active | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | workplan | TAMQ-WP-0007 | finished | — | workplans/TAMQ-WP-0007-terminal-output-notifications.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 |
@ -41,8 +41,8 @@
| task | TAMQ-WP-0006-T03 | 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-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 | | task | TAMQ-WP-0006-T05 | done | — | workplans/TAMQ-WP-0006-shell-native-message-routing.md |
| task | TAMQ-WP-0007-T01 | progress | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | task | TAMQ-WP-0007-T01 | done | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md |
| task | TAMQ-WP-0007-T02 | todo | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | task | TAMQ-WP-0007-T02 | done | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md |
| task | TAMQ-WP-0007-T03 | todo | — | 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 | todo | — | 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 | todo | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md | | task | TAMQ-WP-0007-T05 | done | — | workplans/TAMQ-WP-0007-terminal-output-notifications.md |

View file

@ -27,12 +27,13 @@ from .control import ControlModeClient
from .diagnostics import configure from .diagnostics import configure
from .policy import load_profile from .policy import load_profile
from .registry import RegistryError, validate_targets from .registry import RegistryError, validate_targets
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 export replay purge completion db-version config tap".split()
) )
START_OPTIONS = frozenset({"--command", "--cmd", "--tap", "--detach", "--no-service"}) START_OPTIONS = frozenset({"--command", "--cmd", "--tap", "--detach", "--no-service", "--no-display"})
GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"}) GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"})
@ -99,15 +100,15 @@ def stop_service_process() -> int | None:
return pid return pid
def ensure_manual_service() -> bool: def ensure_service_capabilities(required: set[str]) -> bool:
"""Ensure the broker understands neutral endpoints before registration.""" """Restart an older broker once before registering a newer endpoint mode."""
if not ensure_service(): if not ensure_service():
return False return False
try: try:
capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", []) capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", [])
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return False return False
if "manual_delivery" in capabilities: if required.issubset(capabilities):
return True return True
stop_service_process() stop_service_process()
if not ensure_service(): if not ensure_service():
@ -116,7 +117,15 @@ def ensure_manual_service() -> bool:
capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", []) capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", [])
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return False return False
return "manual_delivery" in capabilities return required.issubset(capabilities)
def ensure_manual_service() -> bool:
return ensure_service_capabilities({"manual_delivery"})
def ensure_output_service() -> bool:
return ensure_service_capabilities({"manual_delivery", "terminal_output"})
def preflight_runtime_paths() -> None: def preflight_runtime_paths() -> None:
@ -133,24 +142,9 @@ def attach_session(session: str) -> int:
return subprocess.run(command, check=False).returncode 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: def format_comment_message(row: sqlite3.Row) -> str:
"""Render a durable message so every displayed line remains a shell comment.""" """Render a durable message so every displayed line remains a shell comment."""
sender = _terminal_safe(str(row["sender_repo"])) return format_comment(row["sender_repo"], row["body"], row["message_id"])
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: def build_parser() -> argparse.ArgumentParser:
@ -159,11 +153,12 @@ def build_parser() -> argparse.ArgumentParser:
description="Repository-aware tmux sessions with durable local messaging.", description="Repository-aware tmux sessions with durable local messaging.",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""session shorthand: epilog="""session shorthand:
tamq [--detach] [--command COMMAND] REPO [REPO ...] tamq [--detach] [--command COMMAND] [--no-display] REPO [REPO ...]
With no --command, tamq opens ordinary repository shells. --command is an With no --command, tamq opens ordinary repository shells. --command is an
explicit initial command for newly created windows; it does not opt in to explicit initial command for newly created windows. Messages appear as
terminal observation or message injection. sanitized terminal output by default; --no-display keeps them inbox-only.
Neither mode injects message bytes into pane input.
manual messaging from a managed shell: manual messaging from a managed shell:
@TARGET: MESSAGE... @TARGET: MESSAGE...
@ -180,6 +175,7 @@ manual messaging from a managed shell:
start.add_argument("repos", nargs="*", help="gita-registered repository slugs") start.add_argument("repos", nargs="*", help="gita-registered repository slugs")
start.add_argument("--command", "--cmd", dest="initial_command", default=None, help="explicit initial command for newly created windows (default: ordinary shell)") start.add_argument("--command", "--cmd", dest="initial_command", default=None, help="explicit initial command for newly created windows (default: ordinary shell)")
start.add_argument("--tap", action="store_true", help="explicitly opt in to PTY input observation and pane message injection") start.add_argument("--tap", action="store_true", help="explicitly opt in to PTY input observation and pane message injection")
start.add_argument("--no-display", action="store_true", help="keep messages in the durable inbox without writing target terminal output")
start.add_argument("--detach", action="store_true", help="detach after startup") start.add_argument("--detach", action="store_true", help="detach after startup")
start.add_argument("--no-service", action="store_true", help="open tmux windows without service registration or messaging") start.add_argument("--no-service", action="store_true", help="open tmux windows without service registration or messaging")
attach = subparsers.add_parser("attach", help="attach to the managed tmux session") attach = subparsers.add_parser("attach", help="attach to the managed tmux session")
@ -312,6 +308,9 @@ def main(argv: list[str] | None = None) -> int:
if args.tap and args.no_service: if args.tap and args.no_service:
print("tamq: --tap cannot be combined with --no-service", file=sys.stderr) print("tamq: --tap cannot be combined with --no-service", file=sys.stderr)
return 2 return 2
if args.tap and args.no_display:
print("tamq: --tap cannot be combined with --no-display", file=sys.stderr)
return 2
manager = TmuxManager() manager = TmuxManager()
try: try:
if not args.no_service: if not args.no_service:
@ -320,8 +319,11 @@ def main(argv: list[str] | None = None) -> int:
except (TmuxError, OSError) as exc: except (TmuxError, OSError) as exc:
print(f"tamq: {exc}", file=sys.stderr) print(f"tamq: {exc}", file=sys.stderr)
return 2 return 2
if not args.no_service and not ensure_manual_service(): service_ready = (
print("tamq service failed to start with manual-delivery safety; use --no-service to open repos without messaging", file=sys.stderr) ensure_manual_service() if args.no_display else ensure_output_service()
) if not args.no_service else True
if not service_ready:
print("tamq service failed to start with the required delivery capability; use --no-service to open repos without messaging", file=sys.stderr)
return 1 return 1
try: try:
endpoint = manager.ensure_plan(launch_plan, tap=args.tap) endpoint = manager.ensure_plan(launch_plan, tap=args.tap)
@ -332,7 +334,7 @@ def main(argv: list[str] | None = None) -> int:
registered = False registered = False
if not args.no_service: if not args.no_service:
try: try:
delivery_mode = "pane" if args.tap else "manual" delivery_mode = "pane" if args.tap else ("manual" if args.no_display else "output")
registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos, "delivery_mode": delivery_mode})) registration = asyncio.run(request({"op": "register", "endpoint_id": endpoint.endpoint_id, "instance_id": endpoint.instance_key, "pid": endpoint.pid, "session": endpoint.session, "repos": endpoint.repos, "delivery_mode": delivery_mode}))
if not registration.get("ok"): if not registration.get("ok"):
raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}") raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}")
@ -360,7 +362,7 @@ def main(argv: list[str] | None = None) -> int:
"delivered": delivered, "delivered": delivered,
"service": not args.no_service, "service": not args.no_service,
"messaging": registered, "messaging": registered,
"delivery_mode": "none" if args.no_service else ("pane" if args.tap else "manual"), "delivery_mode": "none" if args.no_service else ("pane" if args.tap else ("manual" if args.no_display else "output")),
} }
print(json.dumps(summary), flush=True) print(json.dumps(summary), flush=True)
if args.detach: if args.detach:

View file

@ -34,6 +34,18 @@ class ControlModeClient:
return False return False
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:
result = subprocess.run(
[*self._command(), "display-message", "-p", "-t", window, "#{pane_tty}"],
text=True,
capture_output=True,
check=False,
)
tty_path = result.stdout.strip()
if result.returncode != 0 or not tty_path:
raise ControlModeError(result.stderr.strip() or f"cannot resolve pane tty: {window}")
return tty_path
def start(self) -> None: def start(self) -> None:
if self.process is not None: if self.process is not None:
return return

View file

@ -13,6 +13,7 @@ from .config import db_path
from .store import Store from .store import Store
from .registry import RegistryError, validate_targets from .registry import RegistryError, validate_targets
from .control import ControlModeClient from .control import ControlModeClient
from .terminal import terminal_frame, write_terminal_output
PROTOCOL_VERSION = "0.1" PROTOCOL_VERSION = "0.1"
@ -59,7 +60,7 @@ class Service:
self.store.close() self.store.close()
async def _delivery_loop(self, stopped: asyncio.Event) -> None: async def _delivery_loop(self, stopped: asyncio.Event) -> None:
"""Continuously inject pending messages into registered live endpoints.""" """Continuously deliver pending messages to registered live endpoints."""
while not stopped.is_set(): while not stopped.is_set():
try: try:
self._deliver_once() self._deliver_once()
@ -78,22 +79,42 @@ class Service:
if not control.session_exists(expected_pid=int(endpoint["pid"])): if not control.session_exists(expected_pid=int(endpoint["pid"])):
self.store.disconnect_endpoint(endpoint["endpoint_id"]) self.store.disconnect_endpoint(endpoint["endpoint_id"])
continue continue
if endpoint["delivery_mode"] != "pane": delivery_mode = endpoint["delivery_mode"]
if delivery_mode == "manual":
continue continue
pending = [row for row in self.store.list(state="pending") if row["endpoint_id"] in (None, endpoint["endpoint_id"]) and row["target_repo"] in repos] pending = [
row for row in self.store.list(state="pending")
if row["endpoint_id"] in (None, endpoint["endpoint_id"])
and row["target_repo"] in repos
and (delivery_mode == "pane" or row["displayed_at"] is None)
]
if not pending: if not pending:
continue continue
try: try:
control.start() if delivery_mode == "pane":
control.start()
for row in pending: for row in pending:
lease = self.store.claim(row["message_id"], endpoint["endpoint_id"]) lease = self.store.claim(row["message_id"], endpoint["endpoint_id"])
if lease is None: if lease is None:
continue continue
try: try:
control.inject(f'{endpoint["session"]}:{row["target_repo"]}', f'#{row["sender_repo"]}: {row["body"]}') target = f'{endpoint["session"]}:{row["target_repo"]}'
if delivery_mode == "output":
tty_path = control.pane_tty(target)
write_terminal_output(
tty_path,
terminal_frame(
row["sender_repo"], row["body"], row["message_id"]
),
)
else:
control.inject(target, f'#{row["sender_repo"]}: {row["body"]}')
except Exception: except Exception:
continue continue
self.store.release(row["message_id"], lease, "injected") if delivery_mode == "output":
self.store.mark_displayed(row["message_id"], lease)
else:
self.store.release(row["message_id"], lease, "injected")
finally: finally:
control.close() control.close()
@ -109,7 +130,7 @@ class Service:
if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]: if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]:
response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION} response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION}
elif op == "ping": elif op == "ping":
response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history", "manual_delivery"]} response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history", "manual_delivery", "terminal_output"]}
elif op == "register": elif op == "register":
required = ("endpoint_id", "pid", "session", "repos") required = ("endpoint_id", "pid", "session", "repos")
if any(key not in request for key in required): if any(key not in request for key in required):

View file

@ -7,7 +7,7 @@ from pathlib import Path
from typing import Iterable from typing import Iterable
from uuid import uuid4 from uuid import uuid4
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
class Store: class Store:
@ -30,7 +30,8 @@ class Store:
endpoint_id TEXT, endpoint_id TEXT,
provenance TEXT, provenance TEXT,
injected_at REAL, injected_at REAL,
acknowledged_at REAL acknowledged_at REAL,
displayed_at REAL
); );
CREATE INDEX IF NOT EXISTS messages_target_state ON messages(target_repo, state); CREATE INDEX IF NOT EXISTS messages_target_state ON messages(target_repo, state);
CREATE TABLE IF NOT EXISTS endpoints ( CREATE TABLE IF NOT EXISTS endpoints (
@ -57,6 +58,11 @@ class Store:
self.db.execute( self.db.execute(
"ALTER TABLE endpoints ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'manual'" "ALTER TABLE endpoints ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'manual'"
) )
message_columns = {
row["name"] for row in self.db.execute("PRAGMA table_info(messages)")
}
if "displayed_at" not in message_columns:
self.db.execute("ALTER TABLE messages ADD COLUMN displayed_at REAL")
self.db.execute( self.db.execute(
"INSERT INTO metadata(key,value) VALUES('schema_version',?) " "INSERT INTO metadata(key,value) VALUES('schema_version',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value", "ON CONFLICT(key) DO UPDATE SET value=excluded.value",
@ -76,7 +82,7 @@ class Store:
delivery_mode: str = "manual", delivery_mode: str = "manual",
) -> None: ) -> None:
import json import json
if delivery_mode not in {"manual", "pane"}: if delivery_mode not in {"manual", "output", "pane"}:
raise ValueError(f"invalid delivery mode: {delivery_mode}") raise ValueError(f"invalid delivery mode: {delivery_mode}")
self.db.execute( self.db.execute(
"UPDATE endpoints SET disconnected_at=strftime('%s','now') " "UPDATE endpoints SET disconnected_at=strftime('%s','now') "
@ -131,8 +137,9 @@ class Store:
raise ValueError("message body exceeds 8 KiB limit") raise ValueError("message body exceeds 8 KiB limit")
message_id = f"m-{uuid4()}" message_id = f"m-{uuid4()}"
self.db.execute( self.db.execute(
"INSERT INTO messages VALUES(?,?,?,?,?,?,?,?,?,?)", "INSERT INTO messages(message_id,sender_repo,target_repo,body,created_at,state,endpoint_id,provenance,injected_at,acknowledged_at,displayed_at) "
(message_id, sender, target, body, time.time(), "pending", endpoint, provenance, None, None), "VALUES(?,?,?,?,?,?,?,?,?,?,?)",
(message_id, sender, target, body, time.time(), "pending", endpoint, provenance, None, None, None),
) )
self.db.commit() self.db.commit()
return message_id return message_id
@ -161,6 +168,19 @@ class Store:
self.set_state(message_id, "acknowledged") self.set_state(message_id, "acknowledged")
return True return True
def mark_displayed(self, message_id: str, lease_id: str) -> bool:
with self.db:
result = self.db.execute(
"DELETE FROM leases WHERE message_id=? AND lease_id=?",
(message_id, lease_id),
)
if result.rowcount:
self.db.execute(
"UPDATE messages SET displayed_at=? WHERE message_id=?",
(time.time(), message_id),
)
return result.rowcount == 1
def claim(self, message_id: str, endpoint_id: str, ttl: float = 30.0) -> str | None: def claim(self, message_id: str, endpoint_id: str, ttl: float = 30.0) -> str | None:
now = time.time() now = time.time()
lease_id = f"lease-{uuid4()}" lease_id = f"lease-{uuid4()}"

67
src/tamq/terminal.py Normal file
View file

@ -0,0 +1,67 @@
from __future__ import annotations
import os
import re
import stat
from pathlib import Path
class TerminalOutputError(RuntimeError):
pass
def terminal_safe(text: str) -> str:
"""Escape terminal controls while retaining printable Unicode verbatim."""
return "".join(
character
if character.isprintable()
else f"\\x{ord(character):02x}"
for character in text
)
def format_comment(sender: str, body: str, message_id: str) -> str:
"""Render a message so every line is visibly comment-prefixed."""
lines = body.splitlines() or [""]
rendered = [f"#{terminal_safe(sender)}: {terminal_safe(lines[0])}"]
rendered.extend(f"# {terminal_safe(line)}" for line in lines[1:])
rendered[-1] += f" [{terminal_safe(message_id)}]"
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."""
content = format_comment(sender, body, message_id).replace("\n", "\r\n")
return f"\r\n{content}\r\n"
def write_terminal_output(tty_path: str | Path, text: str) -> None:
"""Write output to a tmux pane PTY without placing bytes on its stdin."""
try:
path = Path(tty_path).resolve(strict=True)
except OSError as exc:
raise TerminalOutputError(f"terminal device is unavailable: {tty_path}") from exc
if re.fullmatch(r"/dev/pts/[0-9]+", str(path)) is None:
raise TerminalOutputError(f"refusing non-PTY terminal path: {path}")
flags = os.O_WRONLY | os.O_NOCTTY | os.O_NONBLOCK | os.O_CLOEXEC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags)
except OSError as exc:
raise TerminalOutputError(f"cannot open terminal output: {path}") from exc
try:
details = os.fstat(descriptor)
if not stat.S_ISCHR(details.st_mode) or details.st_uid != os.getuid():
raise TerminalOutputError(f"terminal device is not owned by this user: {path}")
payload = text.encode("utf-8")
offset = 0
while offset < len(payload):
written = os.write(descriptor, payload[offset:])
if written <= 0:
raise TerminalOutputError(f"terminal output made no progress: {path}")
offset += written
except OSError as exc:
raise TerminalOutputError(f"cannot write terminal output: {path}") from exc
finally:
os.close(descriptor)

View file

@ -18,7 +18,7 @@ def test_root_help_exposes_repository_shorthand_and_command(capsys):
main(["--help"]) main(["--help"])
assert exc.value.code == 0 assert exc.value.code == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "tamq [--detach] [--command COMMAND] REPO [REPO ...]" in output assert "tamq [--detach] [--command COMMAND] [--no-display] REPO" in output
assert "With no --command" in output assert "With no --command" in output
assert "@TARGET: MESSAGE" in output assert "@TARGET: MESSAGE" in output
@ -44,10 +44,12 @@ def test_start_parser_supports_command_and_cmd_alias():
canonical = parser.parse_args(["start", "--command", "codex --quiet", "a", "b"]) canonical = parser.parse_args(["start", "--command", "codex --quiet", "a", "b"])
compatibility = parser.parse_args(["start", "--cmd", "claude", "a"]) compatibility = parser.parse_args(["start", "--cmd", "claude", "a"])
neutral = parser.parse_args(["start", "a", "b"]) neutral = parser.parse_args(["start", "a", "b"])
inbox_only = parser.parse_args(["start", "--no-display", "a"])
assert canonical.initial_command == "codex --quiet" assert canonical.initial_command == "codex --quiet"
assert canonical.repos == ["a", "b"] assert canonical.repos == ["a", "b"]
assert compatibility.initial_command == "claude" assert compatibility.initial_command == "claude"
assert neutral.initial_command is None assert neutral.initial_command is None
assert inbox_only.no_display is True
def test_repository_first_and_command_first_start_shorthand(): def test_repository_first_and_command_first_start_shorthand():
@ -62,6 +64,11 @@ def test_repository_first_and_command_first_start_shorthand():
"codex", "codex",
"flex-auth", "flex-auth",
] ]
assert normalize_argv(["--no-display", "flex-auth"]) == [
"start",
"--no-display",
"flex-auth",
]
assert normalize_argv(["status"]) == ["status"] assert normalize_argv(["status"]) == ["status"]
assert normalize_argv(["--verbose", "flex-auth", "audit-core"]) == [ assert normalize_argv(["--verbose", "flex-auth", "audit-core"]) == [
"--verbose", "--verbose",
@ -165,7 +172,7 @@ def test_registration_failure_rolls_back_created_windows(monkeypatch, capsys):
monkeypatch.setattr("tamq.cli.TmuxManager", Manager) monkeypatch.setattr("tamq.cli.TmuxManager", Manager)
monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None) monkeypatch.setattr("tamq.cli.preflight_runtime_paths", lambda: None)
monkeypatch.setattr("tamq.cli.ensure_manual_service", lambda: True) monkeypatch.setattr("tamq.cli.ensure_output_service", lambda: True)
monkeypatch.setattr("tamq.cli.request", failed_registration) monkeypatch.setattr("tamq.cli.request", failed_registration)
assert main(["start", "--detach", "a"]) == 1 assert main(["start", "--detach", "a"]) == 1
assert rolled_back == [endpoint] assert rolled_back == [endpoint]
@ -177,3 +184,5 @@ def test_tap_requires_explicit_command_and_service(capsys):
assert "--tap requires an explicit --command" in capsys.readouterr().err assert "--tap requires an explicit --command" in capsys.readouterr().err
assert main(["start", "--tap", "--no-service", "--command", "sh", "a"]) == 2 assert main(["start", "--tap", "--no-service", "--command", "sh", "a"]) == 2
assert "--tap cannot be combined with --no-service" in capsys.readouterr().err assert "--tap cannot be combined with --no-service" in capsys.readouterr().err
assert main(["start", "--tap", "--no-display", "--command", "sh", "a"]) == 2
assert "--tap cannot be combined with --no-display" in capsys.readouterr().err

View file

@ -1,4 +1,4 @@
from tamq.cli import ensure_manual_service, parse_size from tamq.cli import ensure_manual_service, ensure_output_service, parse_size
def test_parse_size(): def test_parse_size():
@ -22,3 +22,23 @@ def test_manual_service_restarts_legacy_broker(monkeypatch):
assert ensure_manual_service() is True assert ensure_manual_service() is True
assert len(starts) == 2 assert len(starts) == 2
assert stops == [True] assert stops == [True]
def test_output_service_requires_terminal_output_capability(monkeypatch):
capabilities = iter([
["manual_delivery"],
["manual_delivery", "terminal_output"],
])
starts = []
stops = []
async def request(payload):
return {"capabilities": next(capabilities)}
monkeypatch.setattr("tamq.cli.ensure_service", lambda: starts.append(True) or True)
monkeypatch.setattr("tamq.cli.stop_service_process", lambda: stops.append(True) or 42)
monkeypatch.setattr("tamq.cli.request", request)
assert ensure_output_service() is True
assert len(starts) == 2
assert stops == [True]

View file

@ -1,4 +1,5 @@
from tamq.control import shell_quote from tamq import control
from tamq.control import ControlModeClient, shell_quote
from tamq.tmux import shell_join from tamq.tmux import shell_join
@ -10,3 +11,19 @@ def test_shell_quote():
def test_tmux_shell_join_quotes_arguments(): def test_tmux_shell_join_quotes_arguments():
assert shell_join(["tamq", "tap", "--repo", "net-kingdom", "--", "codex"]) == "tamq tap --repo net-kingdom -- codex" assert shell_join(["tamq", "tap", "--repo", "net-kingdom", "--", "codex"]) == "tamq tap --repo net-kingdom -- codex"
def test_pane_tty_is_resolved_through_tmux(monkeypatch):
calls = []
def run(command, **kwargs):
calls.append(command)
return type("Result", (), {"returncode": 0, "stdout": "/dev/pts/7\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}",
]]

View file

@ -92,7 +92,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
) )
assert run(str(tamq), "--version").stdout.strip() == "0.1.0" assert run(str(tamq), "--version").stdout.strip() == "0.1.0"
root_help = run(str(tamq), "--help").stdout root_help = run(str(tamq), "--help").stdout
assert "tamq [--detach] [--command COMMAND] REPO [REPO ...]" in root_help assert "tamq [--detach] [--command COMMAND] [--no-display] REPO" in root_help
started = json.loads( started = json.loads(
run( run(
str(tamq), str(tamq),
@ -102,7 +102,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
).stdout ).stdout
) )
assert started["repos"] == ["railiance-platform", "activity-core"] assert started["repos"] == ["railiance-platform", "activity-core"]
assert started["delivery_mode"] == "manual" assert started["delivery_mode"] == "output"
rows = run( rows = run(
*tmux, *tmux,
"list-windows", "list-windows",
@ -168,7 +168,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
assert [item["endpoint_id"] for item in pre_delivery_status["endpoints"]] == [ assert [item["endpoint_id"] for item in pre_delivery_status["endpoints"]] == [
started["instance_id"] started["instance_id"]
] ]
assert pre_delivery_status["endpoints"][0]["delivery_mode"] == "manual" assert pre_delivery_status["endpoints"][0]["delivery_mode"] == "output"
target_before = run( target_before = run(
*tmux, "capture-pane", "-p", "-t", "tamq:activity-core" *tmux, "capture-pane", "-p", "-t", "tamq:activity-core"
@ -183,6 +183,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
) )
deadline = time.monotonic() + 5 deadline = time.monotonic() + 5
inbox = [] inbox = []
target_after = target_before
while time.monotonic() < deadline: while time.monotonic() < deadline:
inbox = [ inbox = [
json.loads(line) json.loads(line)
@ -190,7 +191,10 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
str(tamq), "inbox", "--repo", "activity-core", "--json" str(tamq), "inbox", "--repo", "activity-core", "--json"
).stdout.splitlines() ).stdout.splitlines()
] ]
if inbox: target_after = run(
*tmux, "capture-pane", "-p", "-t", "tamq:activity-core"
).stdout
if inbox and inbox[-1]["displayed_at"] is not None and "#railiance-platform: installed-message" in target_after:
break break
time.sleep(0.05) time.sleep(0.05)
assert inbox, run( assert inbox, run(
@ -199,14 +203,13 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
assert inbox[-1]["sender_repo"] == "railiance-platform" assert inbox[-1]["sender_repo"] == "railiance-platform"
assert inbox[-1]["body"] == "installed-message" assert inbox[-1]["body"] == "installed-message"
assert inbox[-1]["state"] == "pending" assert inbox[-1]["state"] == "pending"
assert inbox[-1]["displayed_at"] is not None
message_id = inbox[-1]["message_id"] message_id = inbox[-1]["message_id"]
assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == ( assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == (
f"#railiance-platform: installed-message [{message_id}]\n" f"#railiance-platform: installed-message [{message_id}]\n"
) )
target_after = run( assert target_after != target_before
*tmux, "capture-pane", "-p", "-t", "tamq:activity-core" assert f"#railiance-platform: installed-message [{message_id}]" in target_after
).stdout
assert target_after == target_before
assert run(str(tamq), "ack", message_id).stdout.strip() == message_id assert run(str(tamq), "ack", message_id).stdout.strip() == message_id
assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == "" assert run(str(tamq), "inbox", "--repo", "activity-core").stdout == ""
@ -289,7 +292,7 @@ def test_isolated_installed_tool_session_smoke(tmp_path):
).stdout ).stdout
) )
assert recovered["instance_id"] != started["instance_id"] assert recovered["instance_id"] != started["instance_id"]
assert recovered["delivery_mode"] == "manual" assert recovered["delivery_mode"] == "output"
recovered_rows = run( recovered_rows = run(
*tmux, *tmux,
"list-windows", "list-windows",

View file

@ -19,6 +19,9 @@ 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):
return f"/dev/pts/{window.rsplit(':', 1)[-1]}"
def close(self): def close(self):
return None return None
@ -46,6 +49,58 @@ def test_service_never_injects_manual_endpoint_messages(tmp_path, monkeypatch):
assert store.list()[0]["state"] == "pending" assert store.list()[0]["state"] == "pending"
def test_service_writes_output_once_and_retains_pending_ack(tmp_path, monkeypatch):
store = Store(tmp_path / "queue.sqlite3")
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output")
message_id = store.add("repo-a", "repo-b", "continue")
writes = []
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
monkeypatch.setattr(
"tamq.service.write_terminal_output",
lambda path, text: writes.append((path, text)),
)
service = Service(store=store)
service._deliver_once()
service._deliver_once()
assert writes == [
(
"/dev/pts/repo-b",
f"\r\n#repo-a: continue [{message_id}]\r\n",
)
]
row = store.list()[0]
assert row["state"] == "pending"
assert row["displayed_at"] is not None
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
def test_failed_terminal_output_remains_undisplayed_and_retryable(tmp_path, monkeypatch):
store = Store(tmp_path / "queue.sqlite3")
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"], "output")
store.add("repo-a", "repo-b", "continue")
attempts = []
monkeypatch.setattr("tamq.service.ControlModeClient", FakeControl)
def fail_once(path, text):
attempts.append((path, text))
if len(attempts) == 1:
raise OSError("temporary failure")
monkeypatch.setattr("tamq.service.write_terminal_output", fail_once)
service = Service(store=store)
service._deliver_once()
assert store.list()[0]["displayed_at"] is None
store.db.execute("UPDATE leases SET expires_at=0")
store.db.commit()
service._deliver_once()
assert len(attempts) == 2
assert store.list()[0]["displayed_at"] is not None
def test_service_disconnects_disappeared_tmux_endpoint(tmp_path, monkeypatch): def test_service_disconnects_disappeared_tmux_endpoint(tmp_path, monkeypatch):
store = Store(tmp_path / "queue.sqlite3") store = Store(tmp_path / "queue.sqlite3")
store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"]) store.register_endpoint("tmux-amq-9-boot", 9, "tamq", ["repo-b"])

View file

@ -41,5 +41,21 @@ def test_store_migrates_legacy_endpoint_rows_to_manual_delivery(tmp_path):
assert store.endpoints()[0]["delivery_mode"] == "manual" assert store.endpoints()[0]["delivery_mode"] == "manual"
assert store.db.execute( assert store.db.execute(
"SELECT value FROM metadata WHERE key='schema_version'" "SELECT value FROM metadata WHERE key='schema_version'"
).fetchone()[0] == "2" ).fetchone()[0] == "3"
assert "displayed_at" in {
row["name"] for row in store.db.execute("PRAGMA table_info(messages)")
}
def test_mark_displayed_releases_lease_without_acknowledging(tmp_path):
store = Store(tmp_path / "queue.sqlite3")
message_id = store.add("a", "b", "hello")
lease_id = store.claim(message_id, "endpoint")
assert lease_id is not None
assert store.mark_displayed(message_id, lease_id) is True
row = store.list()[0]
assert row["state"] == "pending"
assert row["displayed_at"] is not None
assert store.db.execute("SELECT COUNT(*) FROM leases").fetchone()[0] == 0
store.close() store.close()

View file

@ -0,0 +1,45 @@
import os
import pty
import select
import pytest
from tamq.terminal import (
TerminalOutputError,
format_comment,
terminal_frame,
write_terminal_output,
)
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]"
)
assert terminal_frame("repo-a", "hello", "m-1") == (
"\r\n#repo-a: hello [m-1]\r\n"
)
def test_terminal_output_reaches_pty_output_but_not_input():
master, slave = pty.openpty()
try:
tty_path = os.ttyname(slave)
write_terminal_output(tty_path, terminal_frame("repo-a", "hello", "m-1"))
readable, _, _ = select.select([master], [], [], 1)
assert readable == [master]
assert b"#repo-a: hello [m-1]" in os.read(master, 4096)
readable_input, _, _ = select.select([slave], [], [], 0)
assert readable_input == []
finally:
os.close(master)
os.close(slave)
def test_terminal_output_rejects_non_pty_paths(tmp_path):
target = tmp_path / "ordinary-file"
target.write_text("untouched", encoding="utf-8")
with pytest.raises(TerminalOutputError, match="non-PTY"):
write_terminal_output(target, "message")
assert target.read_text(encoding="utf-8") == "untouched"

View file

@ -4,7 +4,7 @@ type: workplan
title: "Target-pane terminal output notifications" title: "Target-pane terminal output notifications"
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
@ -47,7 +47,7 @@ from being emitted repeatedly after broker polls or restarts.
```task ```task
id: TAMQ-WP-0007-T01 id: TAMQ-WP-0007-T01
status: progress status: done
priority: high priority: high
state_hub_task_id: "00972410-bc94-50f7-b7be-8c3c8228a43a" state_hub_task_id: "00972410-bc94-50f7-b7be-8c3c8228a43a"
``` ```
@ -58,11 +58,15 @@ current user, sanitize terminal control bytes, use bounded non-blocking writes,
and document visual interleaving as a display limitation rather than an input and document visual interleaving as a display limitation rather than an input
safety failure. safety failure.
Completed with a dedicated terminal-output module, strict tmux PTY path and
ownership validation, non-blocking output-only writes, printable Unicode
retention, terminal-control escaping, and comment-prefixed CRLF framing.
## Persist one-time display state ## Persist one-time display state
```task ```task
id: TAMQ-WP-0007-T02 id: TAMQ-WP-0007-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "e9bc0a58-3488-5122-a899-26dc1b4b2f88" state_hub_task_id: "e9bc0a58-3488-5122-a899-26dc1b4b2f88"
``` ```
@ -72,11 +76,16 @@ output, record successful display and release its lease atomically, retain its
pending acknowledgement state, and make broker restart/poll behavior pending acknowledgement state, and make broker restart/poll behavior
idempotent after a successful write. idempotent after a successful write.
Completed with SQLite schema v3 and `displayed_at`. Successful output records
the timestamp and releases the lease without changing the pending state;
subsequent polls skip it. Failed writes retain the claim until expiry and then
retry without falsely recording display.
## Make terminal output the normal managed-session mode ## Make terminal output the normal managed-session mode
```task ```task
id: TAMQ-WP-0007-T03 id: TAMQ-WP-0007-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "e59a59b0-af2d-570b-9e35-963216dedfa4" state_hub_task_id: "e59a59b0-af2d-570b-9e35-963216dedfa4"
``` ```
@ -86,11 +95,15 @@ broker capability. Preserve `--tap --command` as explicit pane-input mode,
provide `--no-display` for durable inbox-only operation, and restart an older provide `--no-display` for durable inbox-only operation, and restart an older
broker before registering the new mode. broker before registering the new mode.
Completed with normal `output` registration, `terminal_output` capability
negotiation and legacy-broker restart, explicit `--no-display` manual mode, and
the unchanged explicit `--tap --command` pane-input boundary.
## Prove output without input ## Prove output without input
```task ```task
id: TAMQ-WP-0007-T04 id: TAMQ-WP-0007-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "e51146f4-0e44-5de9-87dd-725fe78c792a" state_hub_task_id: "e51146f4-0e44-5de9-87dd-725fe78c792a"
``` ```
@ -101,11 +114,16 @@ for acknowledgement, emits only once across polls, handles multiline/control
content safely, retries failed output, and retains manual and explicit tap content safely, retries failed output, and retains manual and explicit tap
modes. modes.
Completed with real PTY proof that output reaches the master while the slave
stdin remains unreadable, invalid-device rejection, control-character and
multiline formatting, successful one-time display, failure/lease retry, schema
migration, and real installed tmux capture evidence.
## Install and document the operator workflow ## Install and document the operator workflow
```task ```task
id: TAMQ-WP-0007-T05 id: TAMQ-WP-0007-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "af6100d5-ee2c-5bf4-9086-1578dec770ec" state_hub_task_id: "af6100d5-ee2c-5bf4-9086-1578dec770ec"
``` ```
@ -113,3 +131,9 @@ state_hub_task_id: "af6100d5-ee2c-5bf4-9086-1578dec770ec"
Update help, README, SCOPE, and installed-package acceptance. Install the Update help, README, SCOPE, and installed-package acceptance. Install the
verified build, recreate or re-register a live two-repository session, exchange verified build, recreate or re-register a live two-repository session, exchange
and acknowledge one visible smoke message, and record State Hub evidence. and acknowledge one visible smoke message, and record State Hub evidence.
Completed with updated CLI help, README, and SCOPE; 93 passing tests at 76%
statement coverage; and an installed live `flex-auth`/`audit-core` session in
`output` mode. Both previously pending user messages and a fresh smoke appeared
in the target pane. The smoke alone was acknowledged; the two user messages
remain pending with persisted display timestamps.