feat: make sessions terminal neutral
Some checks failed
tamq-ci / test (push) Failing after 6s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
tegwick 2026-08-24 20:10:08 +02:00
parent 408d32df88
commit 74b2f27997
20 changed files with 608 additions and 182 deletions

View file

@ -25,6 +25,14 @@ from .ptytap import PtyTap
from .control import ControlModeClient
from .diagnostics import configure
from .policy import load_profile
from .registry import RegistryError, validate_targets
SUBCOMMANDS = frozenset(
"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"})
GLOBAL_FLAGS = frozenset({"--orwell", "--verbose"})
def parse_size(value: str) -> int:
@ -70,6 +78,46 @@ def ensure_service() -> bool:
return False
def stop_service_process() -> int | None:
try:
pid = int(pid_path().read_text(encoding="utf-8"))
except (FileNotFoundError, ValueError):
return None
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pid_path().unlink(missing_ok=True)
return None
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
break
time.sleep(0.05)
return pid
def ensure_manual_service() -> bool:
"""Ensure the broker understands neutral endpoints before registration."""
if not ensure_service():
return False
try:
capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", [])
except (OSError, json.JSONDecodeError):
return False
if "manual_delivery" in capabilities:
return True
stop_service_process()
if not ensure_service():
return False
try:
capabilities = asyncio.run(request({"op": "ping"})).get("capabilities", [])
except (OSError, json.JSONDecodeError):
return False
return "manual_delivery" in capabilities
def preflight_runtime_paths() -> None:
"""Ensure local state/runtime parents are available before tmux mutation."""
parents = {db_path().parent, socket_path().parent, pid_path().parent, lock_path().parent}
@ -87,7 +135,7 @@ def attach_session(session: str) -> int:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tamq",
description="Tmux Agentic Message Queueing for local repository workers.",
description="Repository-aware tmux sessions with durable local messaging.",
)
parser.add_argument("--version", "-V", action="version", version=__version__)
parser.add_argument("--orwell", action="store_true", help="enable unsafe local diagnostics")
@ -97,7 +145,8 @@ def build_parser() -> argparse.ArgumentParser:
start = subparsers.add_parser("start", help="start or reuse a tamq endpoint")
start.add_argument("repos", nargs="*", help="gita-registered repository slugs")
start.add_argument("--command", "--cmd", dest="agent_command", default="codex", help="agent command for new windows (default: codex)")
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("--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")
attach = subparsers.add_parser("attach", help="attach to the managed tmux session")
@ -110,14 +159,19 @@ def build_parser() -> argparse.ArgumentParser:
history = subparsers.add_parser("history", help="inspect local message history")
history.add_argument("--repo", dest="target_repo")
history.add_argument("--state")
inbox = subparsers.add_parser("inbox", help="show durable messages for a repository without injecting terminal input")
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")
inspect = subparsers.add_parser("inspect", help="inspect one message")
inspect.add_argument("message_id")
ack = subparsers.add_parser("ack", help="acknowledge one injected message")
ack = subparsers.add_parser("ack", help="acknowledge one durable message")
ack.add_argument("message_id")
send = subparsers.add_parser("send", help="queue a direct message")
send.add_argument("address", help="@repo: message")
send.add_argument("body", nargs="*", help="message body when address is a repo slug")
send.add_argument("--endpoint-id")
send.add_argument("--from", dest="sender_repo", help="sender repository (default: TAMQ_REPO or local)")
export = subparsers.add_parser("export", help="export history as JSONL")
export.add_argument("--output", required=True)
export.add_argument("--repo", dest="target_repo")
@ -134,16 +188,41 @@ def build_parser() -> argparse.ArgumentParser:
completion.add_argument("shell", choices=("bash", "zsh", "fish"))
subparsers.add_parser("db-version", help="show SQLite schema version")
subparsers.add_parser("config", help="show effective configuration paths and policy")
tap = subparsers.add_parser("tap", help="run an agent behind the full-duplex PTY tap")
tap = subparsers.add_parser("tap", help="explicitly run a command behind the full-duplex PTY tap")
tap.add_argument("--repo", required=True, help="source gita repository slug")
tap.add_argument("--endpoint", required=True, help="tmux-amq endpoint identity")
tap.add_argument("agent_command", nargs=argparse.REMAINDER, help="agent command after --")
tap.add_argument("wrapped_command", nargs=argparse.REMAINDER, help="command after --")
return parser
def normalize_argv(argv: list[str]) -> list[str]:
"""Allow repository-first startup while retaining explicit subcommands."""
if not argv:
return argv
index = 0
while index < len(argv):
token = argv[index]
if token in GLOBAL_FLAGS or token.startswith("--policy-profile="):
index += 1
continue
if token == "--policy-profile" and index + 1 < len(argv):
index += 2
continue
break
if index == len(argv):
return argv
candidate = argv[index]
if candidate in START_OPTIONS or (
not candidate.startswith("-") and candidate not in SUBCOMMANDS
):
return [*argv[:index], "start", *argv[index:]]
return argv
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
raw_argv = list(sys.argv[1:] if argv is None else argv)
args = parser.parse_args(normalize_argv(raw_argv))
configure(args.orwell, args.verbose)
try:
profile = load_profile(selected=args.policy_profile or os.environ.get("TAMQ_POLICY_PROFILE", "default"))
@ -158,25 +237,12 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "ping":
return 0 if asyncio.run(ping()) else 1
if args.command == "stop":
try:
pid = int(pid_path().read_text(encoding="utf-8"))
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
break
time.sleep(0.05)
pid = stop_service_process()
if pid is not None:
print(f"stopped tamq service {pid}")
return 0
except ProcessLookupError:
pid_path().unlink(missing_ok=True)
print("tamq service is not running", file=sys.stderr)
return 1
except (FileNotFoundError, ValueError):
print("tamq service is not running", file=sys.stderr)
return 1
print("tamq service is not running", file=sys.stderr)
return 1
if args.command == "serve":
try: asyncio.run(Service().run())
except KeyboardInterrupt: return 0
@ -190,11 +256,11 @@ def main(argv: list[str] | None = None) -> int:
print(json.dumps({"config": str(config_path()), "state_dir": str(state_dir()), "database": str(db_path()), "socket": str(socket_path()), "pidfile": str(pid_path()), "lockfile": str(lock_path()), "policy_profile": profile.name, "delivery_ack_mode": profile.delivery_ack_mode}, sort_keys=True))
return 0
if args.command == "tap":
command = list(args.agent_command)
command = list(args.wrapped_command)
if command and command[0] == "--":
command = command[1:]
if not command:
print("tamq tap requires an agent command after --", file=sys.stderr)
print("tamq tap requires a command after --", file=sys.stderr)
return 2
store = Store(db_path())
try:
@ -202,19 +268,25 @@ def main(argv: list[str] | None = None) -> int:
finally:
store.close()
if args.command == "start":
if args.tap and args.initial_command is None:
print("tamq: --tap requires an explicit --command", file=sys.stderr)
return 2
if args.tap and args.no_service:
print("tamq: --tap cannot be combined with --no-service", file=sys.stderr)
return 2
manager = TmuxManager()
try:
if not args.no_service:
preflight_runtime_paths()
launch_plan = manager.preflight(args.repos, args.agent_command)
launch_plan = manager.preflight(args.repos, args.initial_command)
except (TmuxError, OSError) as exc:
print(f"tamq: {exc}", file=sys.stderr)
return 2
if not args.no_service and not ensure_service():
print("tamq service failed to start; use --no-service to open repos without messaging", file=sys.stderr)
if not args.no_service and not ensure_manual_service():
print("tamq service failed to start with manual-delivery safety; use --no-service to open repos without messaging", file=sys.stderr)
return 1
try:
endpoint = manager.ensure_plan(launch_plan, tap=not args.no_service)
endpoint = manager.ensure_plan(launch_plan, tap=args.tap)
except (TmuxError, OSError) as exc:
print(f"tamq: {exc}", file=sys.stderr)
return 2
@ -222,20 +294,22 @@ def main(argv: list[str] | None = None) -> int:
registered = False
if not args.no_service:
try:
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 = "pane" if args.tap else "manual"
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"):
raise RuntimeError(f"endpoint registration failed: {registration.get('error', 'unknown error')}")
registered = True
control = ControlModeClient(endpoint.session)
queue_store = Store(db_path())
try:
control.start()
delivered = InputBroker(queue_store, BrokerIdentity(endpoint.instance_key, endpoint.repos[0])).deliver_pending(
control, window_for_repo=lambda repo: f"{endpoint.session}:{repo}"
)
finally:
control.close()
queue_store.close()
if args.tap:
control = ControlModeClient(endpoint.session)
queue_store = Store(db_path())
try:
control.start()
delivered = InputBroker(queue_store, BrokerIdentity(endpoint.instance_key, endpoint.repos[0])).deliver_pending(
control, window_for_repo=lambda repo: f"{endpoint.session}:{repo}"
)
finally:
control.close()
queue_store.close()
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
manager.rollback(endpoint)
print(f"tamq: {exc}", file=sys.stderr)
@ -248,6 +322,7 @@ def main(argv: list[str] | None = None) -> int:
"delivered": delivered,
"service": not args.no_service,
"messaging": registered,
"delivery_mode": "none" if args.no_service else ("pane" if args.tap else "manual"),
}
print(json.dumps(summary), flush=True)
if args.detach:
@ -256,7 +331,7 @@ def main(argv: list[str] | None = None) -> int:
store = Store(db_path())
try:
advisory = history_advisory(store)
if advisory and args.command in {"start", "serve", "status", "history", "send"}:
if advisory and args.command in {"start", "serve", "status", "history", "inbox", "send"}:
print(advisory, file=sys.stderr)
if args.command == "send":
text = " ".join([args.address, *args.body]).strip()
@ -264,18 +339,44 @@ def main(argv: list[str] | None = None) -> int:
print("tamq send expects @repo: message", file=sys.stderr); return 2
target, body = text[1:].split(":", 1); body = body.strip()
if not target or not body: print("tamq send requires a target and body", file=sys.stderr); return 2
if args.endpoint_id:
if not asyncio.run(ping()):
print("tamq: service is not running", file=sys.stderr); return 1
response = asyncio.run(request({"op": "send", "endpoint_id": args.endpoint_id, "sender_repo": "local", "target_repo": target, "body": body}))
sender = args.sender_repo or os.environ.get("TAMQ_REPO") or "local"
try:
validate_targets([target])
if sender != "local":
validate_targets([sender])
except RegistryError as exc:
print(f"tamq: {exc}", file=sys.stderr); return 2
if asyncio.run(ping()):
payload = {"op": "send", "sender_repo": sender, "target_repo": target, "body": body}
if args.endpoint_id:
payload["endpoint_id"] = args.endpoint_id
response = asyncio.run(request(payload))
if not response.get("ok"):
print(f"tamq: {response.get('error', 'send failed')}", file=sys.stderr); return 1
print(response["message_id"]); return 0
if args.endpoint_id:
print("tamq: service is not running", file=sys.stderr); return 1
try:
print(store.add("local", target, body))
print(store.add(sender, target, body))
except ValueError as exc:
print(f"tamq: {exc}", file=sys.stderr); return 2
return 0
if args.command == "inbox":
target = args.target_repo or os.environ.get("TAMQ_REPO")
if not target:
print("tamq inbox requires --repo outside a managed tamq window", file=sys.stderr)
return 2
try:
validate_targets([target])
except RegistryError as exc:
print(f"tamq: {exc}", 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))
else:
print(f"{row['message_id']} {row['sender_repo']} -> {row['target_repo']}: {row['body']}")
return 0
if args.command == "history":
for row in store.list(args.target_repo, args.state): print(json.dumps(dict(row), sort_keys=True))
return 0
@ -332,7 +433,7 @@ def main(argv: list[str] | None = None) -> int:
def completion_script(shell: str) -> str:
commands = "start attach serve stop status ping history inspect ack send export replay purge completion db-version config tap"
commands = " ".join(sorted(SUBCOMMANDS))
if shell == "bash":
return f"""_tamq_complete() {{
local commands=\"{commands}\"