Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a03397-4d51-7fd1-8ff2-946eb22ea2bc
This commit is contained in:
parent
397a2b4d58
commit
68475922bb
15 changed files with 437 additions and 50 deletions
|
|
@ -204,7 +204,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if args.command == "start":
|
||||
manager = TmuxManager()
|
||||
try:
|
||||
preflight_runtime_paths()
|
||||
if not args.no_service:
|
||||
preflight_runtime_paths()
|
||||
launch_plan = manager.preflight(args.repos, args.agent_command)
|
||||
except (TmuxError, OSError) as exc:
|
||||
print(f"tamq: {exc}", file=sys.stderr)
|
||||
|
|
@ -213,7 +214,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
print("tamq service failed to start; use --no-service to open repos without messaging", file=sys.stderr)
|
||||
return 1
|
||||
try:
|
||||
endpoint = manager.ensure_plan(launch_plan)
|
||||
endpoint = manager.ensure_plan(launch_plan, tap=not args.no_service)
|
||||
except (TmuxError, OSError) as exc:
|
||||
print(f"tamq: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
|
|
|||
|
|
@ -16,16 +16,29 @@ class ControlModeClient:
|
|||
tmux_command: Sequence[str] | None = None
|
||||
process: subprocess.Popen[str] | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self.process is not None:
|
||||
return
|
||||
def _command(self) -> tuple[str, ...]:
|
||||
socket_name = os.environ.get("TAMQ_TMUX_SOCKET")
|
||||
command = tuple(
|
||||
return tuple(
|
||||
self.tmux_command
|
||||
or (["tmux", "-L", socket_name] if socket_name else ["tmux"])
|
||||
)
|
||||
|
||||
def session_exists(self, expected_pid: int | None = None) -> bool:
|
||||
result = subprocess.run(
|
||||
[*self._command(), "display-message", "-p", "-t", self.session, "#{pid}"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return expected_pid is None or result.stdout.strip() == str(expected_pid)
|
||||
|
||||
def start(self) -> None:
|
||||
if self.process is not None:
|
||||
return
|
||||
self.process = subprocess.Popen(
|
||||
[*command, "-C", "attach-session", "-t", self.session],
|
||||
[*self._command(), "-C", "attach-session", "-t", self.session],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from .broker import InputBroker
|
||||
|
|
@ -18,14 +21,70 @@ class PtyTap:
|
|||
self.broker = broker
|
||||
self.on_line = on_line
|
||||
|
||||
def _observe_input(self, buffer: bytearray, data: bytes) -> None:
|
||||
buffer.extend(data)
|
||||
while True:
|
||||
delimiters = [index for marker in (b"\r", b"\n") if (index := buffer.find(marker)) >= 0]
|
||||
if not delimiters:
|
||||
return
|
||||
index = min(delimiters)
|
||||
raw = bytes(buffer[:index])
|
||||
del buffer[: index + 1]
|
||||
line = raw.decode("utf-8", errors="replace")
|
||||
routed = self.broker.inspect_line(line)
|
||||
if routed and self.on_line:
|
||||
self.on_line(line)
|
||||
|
||||
def run(self) -> int:
|
||||
pid, master = pty.fork()
|
||||
stdin_fd = sys.stdin.fileno()
|
||||
stdout_fd = sys.stdout.fileno()
|
||||
master, slave = pty.openpty()
|
||||
copy_winsize(stdin_fd, slave)
|
||||
pid = os.fork()
|
||||
if pid == 0:
|
||||
os.execvp(self.command[0], self.command)
|
||||
try:
|
||||
os.close(master)
|
||||
os.setsid()
|
||||
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
|
||||
for fd in (0, 1, 2):
|
||||
os.dup2(slave, fd)
|
||||
if slave > 2:
|
||||
os.close(slave)
|
||||
os.execvp(self.command[0], self.command)
|
||||
except BaseException as exc:
|
||||
os.write(2, f"tamq tap: cannot start {self.command[0]}: {exc}\n".encode())
|
||||
os._exit(127)
|
||||
os.close(slave)
|
||||
input_buffer = bytearray()
|
||||
saved_terminal = termios.tcgetattr(stdin_fd) if os.isatty(stdin_fd) else None
|
||||
saved_handlers: dict[int, signal.Handlers] = {}
|
||||
input_open = True
|
||||
|
||||
def resize(_signum=None, _frame=None) -> None:
|
||||
copy_winsize(stdin_fd, master)
|
||||
|
||||
def forward(signum, _frame) -> None:
|
||||
try:
|
||||
os.killpg(pid, signum)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
status = 0
|
||||
try:
|
||||
if saved_terminal is not None:
|
||||
tty.setraw(stdin_fd)
|
||||
saved_handlers[signal.SIGWINCH] = signal.signal(signal.SIGWINCH, resize)
|
||||
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
||||
saved_handlers[signum] = signal.signal(signum, forward)
|
||||
resize()
|
||||
while True:
|
||||
readable, _, _ = select.select([master, sys.stdin], [], [])
|
||||
sources = [master]
|
||||
if input_open:
|
||||
sources.append(stdin_fd)
|
||||
try:
|
||||
readable, _, _ = select.select(sources, [], [])
|
||||
except InterruptedError:
|
||||
continue
|
||||
if master in readable:
|
||||
try:
|
||||
data = os.read(master, 65536)
|
||||
|
|
@ -33,23 +92,51 @@ class PtyTap:
|
|||
break
|
||||
if not data:
|
||||
break
|
||||
os.write(sys.stdout.fileno(), data)
|
||||
if sys.stdin in readable:
|
||||
data = os.read(sys.stdin.fileno(), 65536)
|
||||
write_all(stdout_fd, data)
|
||||
if input_open and stdin_fd in readable:
|
||||
data = os.read(stdin_fd, 65536)
|
||||
if not data:
|
||||
break
|
||||
os.write(master, data)
|
||||
input_buffer.extend(data)
|
||||
while b"\n" in input_buffer:
|
||||
raw, _, rest = input_buffer.partition(b"\n")
|
||||
input_buffer = bytearray(rest)
|
||||
line = raw.decode("utf-8", errors="replace")
|
||||
routed = self.broker.inspect_line(line)
|
||||
if routed and self.on_line:
|
||||
self.on_line(line)
|
||||
input_open = False
|
||||
try:
|
||||
os.killpg(pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
continue
|
||||
write_all(master, data)
|
||||
self._observe_input(input_buffer, data)
|
||||
except BaseException:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
raise
|
||||
finally:
|
||||
for signum, handler in saved_handlers.items():
|
||||
signal.signal(signum, handler)
|
||||
if saved_terminal is not None:
|
||||
termios.tcsetattr(stdin_fd, termios.TCSAFLUSH, saved_terminal)
|
||||
os.close(master)
|
||||
try:
|
||||
_, status = os.waitpid(pid, 0)
|
||||
return os.waitstatus_to_exitcode(status)
|
||||
except ChildProcessError:
|
||||
return 0
|
||||
status = 0
|
||||
return os.waitstatus_to_exitcode(status)
|
||||
|
||||
|
||||
def copy_winsize(source_fd: int, target_fd: int) -> bool:
|
||||
"""Copy terminal rows/columns and pixel dimensions when a source tty exists."""
|
||||
try:
|
||||
size = fcntl.ioctl(source_fd, termios.TIOCGWINSZ, b"\0" * 8)
|
||||
fcntl.ioctl(target_fd, termios.TIOCSWINSZ, size)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def write_all(fd: int, data: bytes) -> None:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
written = os.write(fd, view)
|
||||
if written == 0:
|
||||
raise OSError("terminal write returned zero bytes")
|
||||
view = view[written:]
|
||||
|
|
|
|||
|
|
@ -74,10 +74,13 @@ class Service:
|
|||
def _deliver_once(self) -> None:
|
||||
for endpoint in self.store.endpoints():
|
||||
repos = json.loads(endpoint["repos"])
|
||||
control = ControlModeClient(endpoint["session"])
|
||||
if not control.session_exists(expected_pid=int(endpoint["pid"])):
|
||||
self.store.disconnect_endpoint(endpoint["endpoint_id"])
|
||||
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
|
||||
control = ControlModeClient(endpoint["session"])
|
||||
try:
|
||||
control.start()
|
||||
for row in pending:
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ class TmuxManager:
|
|||
def ensure(self, repos: list[str], command: str = "codex") -> Endpoint:
|
||||
return self.ensure_plan(self.preflight(repos, command))
|
||||
|
||||
def ensure_plan(self, plan: LaunchPlan) -> Endpoint:
|
||||
def ensure_plan(self, plan: LaunchPlan, *, tap: bool = True) -> Endpoint:
|
||||
created_session = False
|
||||
created_windows: list[str] = []
|
||||
try:
|
||||
|
|
@ -158,11 +158,15 @@ class TmuxManager:
|
|||
self._run("new-window", "-d", "-t", self.session, "-n", repo, "-c", plan.paths[repo])
|
||||
windows.append(repo)
|
||||
created_windows.append(repo)
|
||||
tap = [
|
||||
*self.tamq_command,
|
||||
"tap", "--repo", repo, "--endpoint", endpoint_id, "--", *plan.command,
|
||||
]
|
||||
self._run("send-keys", "-t", f"{self.session}:{repo}", shell_join(tap), "C-m")
|
||||
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