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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue