Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
398fe316b4
commit
e995cab1b8
12 changed files with 391 additions and 42 deletions
44
README.md
44
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 <message-id>
|
||||
```
|
||||
|
||||
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
|
||||
|
|
|
|||
19
SCOPE.md
19
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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue