Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
408d32df88
commit
74b2f27997
20 changed files with 608 additions and 182 deletions
195
src/tamq/cli.py
195
src/tamq/cli.py
|
|
@ -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}\"
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ class Service:
|
|||
if not control.session_exists(expected_pid=int(endpoint["pid"])):
|
||||
self.store.disconnect_endpoint(endpoint["endpoint_id"])
|
||||
continue
|
||||
if endpoint["delivery_mode"] != "pane":
|
||||
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]
|
||||
if not pending:
|
||||
continue
|
||||
|
|
@ -107,7 +109,7 @@ class Service:
|
|||
if protocol.split(".")[0] != PROTOCOL_VERSION.split(".")[0]:
|
||||
response = {"ok": False, "error": f"incompatible protocol: {protocol}", "protocol": PROTOCOL_VERSION}
|
||||
elif op == "ping":
|
||||
response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history"]}
|
||||
response = {"ok": True, "protocol": PROTOCOL_VERSION, "capabilities": ["register", "send", "history", "manual_delivery"]}
|
||||
elif op == "register":
|
||||
required = ("endpoint_id", "pid", "session", "repos")
|
||||
if any(key not in request for key in required):
|
||||
|
|
@ -116,7 +118,14 @@ class Service:
|
|||
try:
|
||||
validate_targets(list(request["repos"]))
|
||||
endpoint_id = request.get("instance_id", request["endpoint_id"])
|
||||
self.store.register_endpoint(endpoint_id, int(request["pid"]), request["session"], list(request["repos"]))
|
||||
delivery_mode = request.get("delivery_mode", "manual")
|
||||
self.store.register_endpoint(
|
||||
endpoint_id,
|
||||
int(request["pid"]),
|
||||
request["session"],
|
||||
list(request["repos"]),
|
||||
delivery_mode,
|
||||
)
|
||||
response = {"ok": True, "endpoint_id": request["endpoint_id"], "instance_id": endpoint_id, "protocol": PROTOCOL_VERSION}
|
||||
except (RegistryError, ValueError) as exc:
|
||||
response = {"ok": False, "error": str(exc)}
|
||||
|
|
@ -127,6 +136,8 @@ class Service:
|
|||
else:
|
||||
try:
|
||||
validate_targets([request["target_repo"]])
|
||||
if request["sender_repo"] != "local":
|
||||
validate_targets([request["sender_repo"]])
|
||||
endpoint_id = request.get("endpoint_id")
|
||||
if endpoint_id:
|
||||
endpoint = self.store.endpoint(endpoint_id)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from pathlib import Path
|
|||
from typing import Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
class Store:
|
||||
|
|
@ -38,6 +38,7 @@ class Store:
|
|||
pid INTEGER NOT NULL,
|
||||
session TEXT NOT NULL,
|
||||
repos TEXT NOT NULL,
|
||||
delivery_mode TEXT NOT NULL DEFAULT 'manual',
|
||||
connected_at REAL NOT NULL,
|
||||
disconnected_at REAL
|
||||
);
|
||||
|
|
@ -49,23 +50,43 @@ class Store:
|
|||
expires_at REAL NOT NULL
|
||||
);
|
||||
""")
|
||||
self.db.execute("INSERT OR IGNORE INTO metadata(key,value) VALUES('schema_version',?)", (str(SCHEMA_VERSION),))
|
||||
endpoint_columns = {
|
||||
row["name"] for row in self.db.execute("PRAGMA table_info(endpoints)")
|
||||
}
|
||||
if "delivery_mode" not in endpoint_columns:
|
||||
self.db.execute(
|
||||
"ALTER TABLE endpoints ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'manual'"
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO metadata(key,value) VALUES('schema_version',?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(str(SCHEMA_VERSION),),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
self.db.close()
|
||||
|
||||
def register_endpoint(self, endpoint_id: str, pid: int, session: str, repos: list[str]) -> None:
|
||||
def register_endpoint(
|
||||
self,
|
||||
endpoint_id: str,
|
||||
pid: int,
|
||||
session: str,
|
||||
repos: list[str],
|
||||
delivery_mode: str = "manual",
|
||||
) -> None:
|
||||
import json
|
||||
if delivery_mode not in {"manual", "pane"}:
|
||||
raise ValueError(f"invalid delivery mode: {delivery_mode}")
|
||||
self.db.execute(
|
||||
"UPDATE endpoints SET disconnected_at=strftime('%s','now') "
|
||||
"WHERE pid=? AND session=? AND endpoint_id<>? AND disconnected_at IS NULL",
|
||||
(pid, session, endpoint_id),
|
||||
)
|
||||
self.db.execute(
|
||||
"INSERT INTO endpoints(endpoint_id,pid,session,repos,connected_at,disconnected_at) VALUES(?,?,?,?,strftime('%s','now'),NULL) "
|
||||
"ON CONFLICT(endpoint_id) DO UPDATE SET pid=excluded.pid, session=excluded.session, repos=excluded.repos, connected_at=excluded.connected_at, disconnected_at=NULL",
|
||||
(endpoint_id, pid, session, json.dumps(repos)),
|
||||
"INSERT INTO endpoints(endpoint_id,pid,session,repos,delivery_mode,connected_at,disconnected_at) VALUES(?,?,?,?,?,strftime('%s','now'),NULL) "
|
||||
"ON CONFLICT(endpoint_id) DO UPDATE SET pid=excluded.pid, session=excluded.session, repos=excluded.repos, delivery_mode=excluded.delivery_mode, connected_at=excluded.connected_at, disconnected_at=NULL",
|
||||
(endpoint_id, pid, session, json.dumps(repos), delivery_mode),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -84,21 +84,23 @@ class TmuxManager:
|
|||
)
|
||||
return pid, instance_id
|
||||
|
||||
def preflight(self, repos: list[str], command: str = "codex") -> LaunchPlan:
|
||||
def preflight(self, repos: list[str], command: str | None = None) -> LaunchPlan:
|
||||
if not repos:
|
||||
raise TmuxError("at least one repository is required")
|
||||
if not self.tmux_command or shutil.which(self.tmux_command[0]) is None:
|
||||
raise TmuxError("tmux is required; install it and ensure 'tmux' is on PATH")
|
||||
if shutil.which("gita") is None:
|
||||
raise TmuxError("gita is required; install it and ensure 'gita' is on PATH")
|
||||
try:
|
||||
command_parts = tuple(shlex.split(command))
|
||||
except ValueError as exc:
|
||||
raise TmuxError(f"invalid agent command: {exc}") from exc
|
||||
if not command_parts:
|
||||
raise TmuxError("agent command must not be empty")
|
||||
if shutil.which(command_parts[0]) is None:
|
||||
raise TmuxError(f"agent command not found on PATH: {command_parts[0]}")
|
||||
command_parts: tuple[str, ...] = ()
|
||||
if command is not None:
|
||||
try:
|
||||
command_parts = tuple(shlex.split(command))
|
||||
except ValueError as exc:
|
||||
raise TmuxError(f"invalid initial command: {exc}") from exc
|
||||
if not command_parts:
|
||||
raise TmuxError("initial command must not be empty")
|
||||
if shutil.which(command_parts[0]) is None:
|
||||
raise TmuxError(f"initial command not found on PATH: {command_parts[0]}")
|
||||
unique_repos = tuple(dict.fromkeys(repos))
|
||||
try:
|
||||
validate_targets(list(unique_repos))
|
||||
|
|
@ -117,10 +119,12 @@ class TmuxManager:
|
|||
self._existing_session_identity()
|
||||
return LaunchPlan(unique_repos, selected_paths, command_parts)
|
||||
|
||||
def ensure(self, repos: list[str], command: str = "codex") -> Endpoint:
|
||||
def ensure(self, repos: list[str], command: str | None = None) -> Endpoint:
|
||||
return self.ensure_plan(self.preflight(repos, command))
|
||||
|
||||
def ensure_plan(self, plan: LaunchPlan, *, tap: bool = True) -> Endpoint:
|
||||
def ensure_plan(self, plan: LaunchPlan, *, tap: bool = False) -> Endpoint:
|
||||
if tap and not plan.command:
|
||||
raise TmuxError("PTY tap requires an explicit initial command")
|
||||
created_session = False
|
||||
created_windows: list[str] = []
|
||||
try:
|
||||
|
|
@ -129,6 +133,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}",
|
||||
"-c", plan.paths[first_repo],
|
||||
)
|
||||
created_session = True
|
||||
|
|
@ -155,18 +160,22 @@ class TmuxManager:
|
|||
self._run("rename-window", "-t", f"{self.session}:__tamq_boot", repo)
|
||||
windows[windows.index("__tamq_boot")] = repo
|
||||
else:
|
||||
self._run("new-window", "-d", "-t", self.session, "-n", repo, "-c", plan.paths[repo])
|
||||
self._run(
|
||||
"new-window", "-d", "-t", self.session, "-n", repo,
|
||||
"-e", f"TAMQ_REPO={repo}", "-c", plan.paths[repo],
|
||||
)
|
||||
windows.append(repo)
|
||||
created_windows.append(repo)
|
||||
command = (
|
||||
[
|
||||
*self.tamq_command,
|
||||
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
|
||||
]
|
||||
if tap
|
||||
else list(plan.command)
|
||||
)
|
||||
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(command), "C-m")
|
||||
if plan.command:
|
||||
command = (
|
||||
[
|
||||
*self.tamq_command,
|
||||
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
|
||||
]
|
||||
if tap
|
||||
else list(plan.command)
|
||||
)
|
||||
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(command), "C-m")
|
||||
self._run("select-window", "-t", f"{self.session}:{plan.repos[0]}")
|
||||
return Endpoint(
|
||||
self.session,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue