feat: Add auto-submit functionality for simple commands and new wait functions
This commit is contained in:
@@ -113,11 +113,13 @@ La variabile legacy `GNOME_VTE_MCP_SOCKET` resta accettata per compatibilita.
|
|||||||
- `open_tab(title=None, cwd=None, command=None)`
|
- `open_tab(title=None, cwd=None, command=None)`
|
||||||
- `list_tabs()`
|
- `list_tabs()`
|
||||||
- `focus_tab(tab_id)`
|
- `focus_tab(tab_id)`
|
||||||
- `exec_command(tab_id, command, newline=True, poll_interval=0.1)`
|
- `exec_command(tab_id, command, newline=True, auto_submit=False, poll_interval=0.1)`
|
||||||
- `read_tab(tab_id, last_n_lines=200)`
|
- `read_tab(tab_id, last_n_lines=200)`
|
||||||
- `read_last_command_result(tab_id)`
|
- `read_last_command_result(tab_id)`
|
||||||
|
- `wait_for_command(delay_seconds)`
|
||||||
- `wait_for_running_command(tab_id, timeout=None, poll_interval=0.1)`
|
- `wait_for_running_command(tab_id, timeout=None, poll_interval=0.1)`
|
||||||
- `wait_for_command_result(tab_id, after_sequence=None, timeout=None, poll_interval=0.1)`
|
- `wait_for_command_result(tab_id, after_sequence=None, timeout=None, poll_interval=0.1)`
|
||||||
|
- `wait_for_prompt(tab_id, timeout=None, poll_interval=0.1, idle_seconds=0.4, prompt_pattern=None)`
|
||||||
- `capture_screenshot(target="window", tab_id=None, path=None, diagnostic_overlay=False)`
|
- `capture_screenshot(target="window", tab_id=None, path=None, diagnostic_overlay=False)`
|
||||||
- `close_tab(tab_id)`
|
- `close_tab(tab_id)`
|
||||||
- `close_gui()`
|
- `close_gui()`
|
||||||
@@ -132,12 +134,15 @@ Lifecycle GUI:
|
|||||||
Per la lettura testuale dei comandi:
|
Per la lettura testuale dei comandi:
|
||||||
|
|
||||||
- `read_tab(...)` restituisce lo scrollback recente, utile per debugging grezzo
|
- `read_tab(...)` restituisce lo scrollback recente, utile per debugging grezzo
|
||||||
- `exec_command(...)` scrive il testo nel terminale, poi resta bloccato indefinitamente finche l'utente non preme `Enter` manualmente nella GUI; il parametro `newline` resta ignorato per compatibilita
|
- `exec_command(...)` scrive il testo nel terminale e, di default, resta bloccato indefinitamente finche l'utente non preme `Enter` manualmente nella GUI; il parametro `newline` resta ignorato in questa modalita per compatibilita
|
||||||
|
- `exec_command(..., auto_submit=True)` invia anche `Enter` da solo, ma e volutamente ristretto a piccoli comandi read-only e senza shell syntax complessa
|
||||||
- dopo eventuali modifiche manuali nella GUI, premi `Enter` tu per sbloccare davvero `exec_command(...)` e inviare il comando alla shell
|
- dopo eventuali modifiche manuali nella GUI, premi `Enter` tu per sbloccare davvero `exec_command(...)` e inviare il comando alla shell
|
||||||
- per comandi che aprono una sessione interattiva delegata come `ssh`, `arca` o una subshell, la tab non viene trattata come bloccata: puoi continuare a scrivere nuovi comandi nella stessa sessione senza aspettare il ritorno della shell locale
|
- per comandi che aprono una sessione interattiva delegata come `ssh`, `arca` o una subshell, la tab non viene trattata come bloccata: puoi continuare a scrivere nuovi comandi nella stessa sessione senza aspettare il ritorno della shell locale
|
||||||
|
- `wait_for_command(delay_seconds)` non osserva il terminale e non prova a capire se il comando e finito: e solo una pausa sincrona esplicita, utile quando il loop e `read_tab(...)` -> attesa arbitraria -> `read_tab(...)`
|
||||||
- `wait_for_running_command(...)` aspetta il completamento del comando che e gia in esecuzione nella tab quando il terminale e occupato e non puoi ancora inviare un nuovo comando
|
- `wait_for_running_command(...)` aspetta il completamento del comando che e gia in esecuzione nella tab quando il terminale e occupato e non puoi ancora inviare un nuovo comando
|
||||||
- se la tab e dentro una sessione interattiva delegata, `wait_for_running_command(...)` fallisce esplicitamente perche il completamento tracciato tornera disponibile solo quando esci da quella sessione
|
- se la tab e dentro una sessione interattiva delegata, `wait_for_running_command(...)` fallisce esplicitamente perche il completamento tracciato tornera disponibile solo quando esci da quella sessione
|
||||||
- `wait_for_command_result(...)` aspetta in modo bloccante il completamento del comando dopo l'`after_sequence` restituito da `exec_command(...)` e restituisce `command`, `cwd`, `cwd_after`, `started_at`, `finished_at`, `duration_seconds`, `exit_code` e `text`; se `timeout` e omesso o `<= 0`, l'attesa e indefinita
|
- `wait_for_command_result(...)` aspetta in modo bloccante il completamento del comando dopo l'`after_sequence` restituito da `exec_command(...)` e restituisce `command`, `cwd`, `cwd_after`, `started_at`, `finished_at`, `duration_seconds`, `exit_code` e `text`; se `timeout` e omesso o `<= 0`, l'attesa e indefinita
|
||||||
|
- `wait_for_prompt(...)` serve per sessioni interattive delegate come `ssh`: aspetta che il transcript smetta di crescere per un breve intervallo e che l'ultima riga assomigli a un prompt
|
||||||
- `read_last_command_result(tab_id)` restituisce l'ultimo comando completato con gli stessi metadati temporali e di path
|
- `read_last_command_result(tab_id)` restituisce l'ultimo comando completato con gli stessi metadati temporali e di path
|
||||||
|
|
||||||
Per `capture_screenshot`:
|
Per `capture_screenshot`:
|
||||||
|
|||||||
+44
-20
@@ -684,27 +684,35 @@ PROMPT_COMMAND='__mcp_hal9002_prompt_hook'
|
|||||||
self._dismiss_onboarding(tab)
|
self._dismiss_onboarding(tab)
|
||||||
if keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_ISO_Enter} and tab.pending_submit_id is not None:
|
if keyval in {Gdk.KEY_Return, Gdk.KEY_KP_Enter, Gdk.KEY_ISO_Enter} and tab.pending_submit_id is not None:
|
||||||
submitted_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
submitted_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||||
after_sequence = self._latest_command_sequence(tab)
|
self._finalize_submission(tab, submitted_at=submitted_at)
|
||||||
delegated = self._looks_like_interactive_handoff(tab.pending_submit_text or "")
|
|
||||||
tab.last_manual_submit_id = tab.pending_submit_id
|
|
||||||
tab.last_manual_submit_text = tab.pending_submit_text
|
|
||||||
tab.last_manual_submit_at = submitted_at
|
|
||||||
if delegated:
|
|
||||||
tab.delegated_session_submission_id = tab.pending_submit_id
|
|
||||||
tab.delegated_session_command = tab.pending_submit_text
|
|
||||||
tab.delegated_session_started_at = submitted_at
|
|
||||||
tab.delegated_session_after_sequence = after_sequence
|
|
||||||
self._clear_current_execution(tab)
|
|
||||||
else:
|
|
||||||
tab.current_execution_submission_id = tab.pending_submit_id
|
|
||||||
tab.current_execution_command = tab.pending_submit_text
|
|
||||||
tab.current_execution_started_at = submitted_at
|
|
||||||
tab.current_execution_after_sequence = after_sequence
|
|
||||||
tab.pending_submit_id = None
|
|
||||||
tab.pending_submit_text = None
|
|
||||||
tab.pending_submit_requested_at = None
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _finalize_submission(self, tab: TabState, *, submitted_at: str) -> int:
|
||||||
|
if tab.pending_submit_id is None:
|
||||||
|
raise RuntimeError(f"tab_id={tab.tab_id} has no pending submit to finalize")
|
||||||
|
|
||||||
|
after_sequence = self._latest_command_sequence(tab)
|
||||||
|
delegated = self._looks_like_interactive_handoff(tab.pending_submit_text or "")
|
||||||
|
tab.last_manual_submit_id = tab.pending_submit_id
|
||||||
|
tab.last_manual_submit_text = tab.pending_submit_text
|
||||||
|
tab.last_manual_submit_at = submitted_at
|
||||||
|
if delegated:
|
||||||
|
tab.delegated_session_submission_id = tab.pending_submit_id
|
||||||
|
tab.delegated_session_command = tab.pending_submit_text
|
||||||
|
tab.delegated_session_started_at = submitted_at
|
||||||
|
tab.delegated_session_after_sequence = after_sequence
|
||||||
|
self._clear_current_execution(tab)
|
||||||
|
else:
|
||||||
|
tab.current_execution_submission_id = tab.pending_submit_id
|
||||||
|
tab.current_execution_command = tab.pending_submit_text
|
||||||
|
tab.current_execution_started_at = submitted_at
|
||||||
|
tab.current_execution_after_sequence = after_sequence
|
||||||
|
|
||||||
|
tab.pending_submit_id = None
|
||||||
|
tab.pending_submit_text = None
|
||||||
|
tab.pending_submit_requested_at = None
|
||||||
|
return after_sequence
|
||||||
|
|
||||||
def _on_terminal_window_title_changed(self, terminal: Vte.Terminal, tab_id: str) -> None:
|
def _on_terminal_window_title_changed(self, terminal: Vte.Terminal, tab_id: str) -> None:
|
||||||
tab = self.tabs.get(tab_id)
|
tab = self.tabs.get(tab_id)
|
||||||
if tab is None:
|
if tab is None:
|
||||||
@@ -1153,7 +1161,7 @@ PROMPT_COMMAND='__mcp_hal9002_prompt_hook'
|
|||||||
self.window.present()
|
self.window.present()
|
||||||
return self._serialize_tab(tab)
|
return self._serialize_tab(tab)
|
||||||
|
|
||||||
def rpc_exec(self, tab_id: str, command: str, newline: bool = True) -> dict[str, Any]:
|
def rpc_exec(self, tab_id: str, command: str, newline: bool = True, auto_submit: bool = False) -> dict[str, Any]:
|
||||||
tab = self._require_tab(tab_id)
|
tab = self._require_tab(tab_id)
|
||||||
running_status = self._running_command_status(tab)
|
running_status = self._running_command_status(tab)
|
||||||
if running_status["state"] == "running":
|
if running_status["state"] == "running":
|
||||||
@@ -1180,6 +1188,22 @@ PROMPT_COMMAND='__mcp_hal9002_prompt_hook'
|
|||||||
tab.pending_submit_id = submission_id
|
tab.pending_submit_id = submission_id
|
||||||
tab.pending_submit_text = command
|
tab.pending_submit_text = command
|
||||||
tab.pending_submit_requested_at = requested_at
|
tab.pending_submit_requested_at = requested_at
|
||||||
|
if auto_submit:
|
||||||
|
tab.terminal.paste_text(payload + "\n")
|
||||||
|
submitted_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||||
|
after_sequence = self._finalize_submission(tab, submitted_at=submitted_at)
|
||||||
|
return {
|
||||||
|
"tab_id": tab_id,
|
||||||
|
"written_text": command,
|
||||||
|
"awaiting_manual_submit": False,
|
||||||
|
"after_sequence": after_sequence,
|
||||||
|
"submission_id": submission_id,
|
||||||
|
"requested_at": requested_at,
|
||||||
|
"submitted_at": submitted_at,
|
||||||
|
"current_sequence": self._latest_command_sequence(tab),
|
||||||
|
"newline_ignored": False,
|
||||||
|
}
|
||||||
|
|
||||||
tab.terminal.paste_text(payload)
|
tab.terminal.paste_text(payload)
|
||||||
return {
|
return {
|
||||||
"tab_id": tab_id,
|
"tab_id": tab_id,
|
||||||
|
|||||||
+220
-2
@@ -5,6 +5,7 @@ import fcntl
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -25,6 +26,81 @@ COMMAND_END_MARKER = "__MCP_HAL9002_CMD_END__"
|
|||||||
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||||
OSC_ESCAPE_RE = re.compile(r"\x1b\].*?(?:\x07|\x1b\\)", re.DOTALL)
|
OSC_ESCAPE_RE = re.compile(r"\x1b\].*?(?:\x07|\x1b\\)", re.DOTALL)
|
||||||
SINGLE_ESCAPE_RE = re.compile(r"\x1b[@-_]")
|
SINGLE_ESCAPE_RE = re.compile(r"\x1b[@-_]")
|
||||||
|
CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b-\x1f\x7f]")
|
||||||
|
DEFAULT_PROMPT_RE = re.compile(r"^.*(?:[$#%>] |\]# )$")
|
||||||
|
AUTO_SUBMIT_BLOCKED_TOKENS = ("\n", ";", "&&", "||", "|", "`", "$(", "<(", ">(", ">", "<", "&")
|
||||||
|
AUTO_SUBMIT_SIMPLE_COMMANDS = {
|
||||||
|
"cat",
|
||||||
|
"date",
|
||||||
|
"df",
|
||||||
|
"du",
|
||||||
|
"echo",
|
||||||
|
"env",
|
||||||
|
"file",
|
||||||
|
"find",
|
||||||
|
"free",
|
||||||
|
"grep",
|
||||||
|
"head",
|
||||||
|
"hostname",
|
||||||
|
"id",
|
||||||
|
"ip",
|
||||||
|
"ls",
|
||||||
|
"netstat",
|
||||||
|
"pgrep",
|
||||||
|
"printenv",
|
||||||
|
"ps",
|
||||||
|
"pwd",
|
||||||
|
"readlink",
|
||||||
|
"rg",
|
||||||
|
"ss",
|
||||||
|
"stat",
|
||||||
|
"tail",
|
||||||
|
"uname",
|
||||||
|
"uptime",
|
||||||
|
"whoami",
|
||||||
|
}
|
||||||
|
AUTO_SUBMIT_BLOCKED_COMMANDS = {
|
||||||
|
"apt",
|
||||||
|
"apt-get",
|
||||||
|
"bash",
|
||||||
|
"chmod",
|
||||||
|
"chown",
|
||||||
|
"cp",
|
||||||
|
"curl",
|
||||||
|
"dd",
|
||||||
|
"doas",
|
||||||
|
"docker",
|
||||||
|
"docker-compose",
|
||||||
|
"install",
|
||||||
|
"kubectl",
|
||||||
|
"make",
|
||||||
|
"mkdir",
|
||||||
|
"mv",
|
||||||
|
"nano",
|
||||||
|
"node",
|
||||||
|
"npm",
|
||||||
|
"perl",
|
||||||
|
"pip",
|
||||||
|
"poetry",
|
||||||
|
"python",
|
||||||
|
"python3",
|
||||||
|
"rm",
|
||||||
|
"rsync",
|
||||||
|
"scp",
|
||||||
|
"sed",
|
||||||
|
"sh",
|
||||||
|
"ssh",
|
||||||
|
"sudo",
|
||||||
|
"su",
|
||||||
|
"systemctl",
|
||||||
|
"tee",
|
||||||
|
"touch",
|
||||||
|
"vi",
|
||||||
|
"vim",
|
||||||
|
"watch",
|
||||||
|
"wget",
|
||||||
|
"zsh",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_LAUNCH_THREAD_LOCK = threading.Lock()
|
_LAUNCH_THREAD_LOCK = threading.Lock()
|
||||||
@@ -58,6 +134,62 @@ def _sanitize_text(text: str) -> str:
|
|||||||
return cleaned.replace("\r", "")
|
return cleaned.replace("\r", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_terminal_text(text: str) -> str:
|
||||||
|
return CONTROL_CHAR_RE.sub("", _sanitize_text(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_tab_text(socket_path: Path, tab_id: str) -> str:
|
||||||
|
path = _tab_log_path(socket_path, tab_id)
|
||||||
|
if not path.exists():
|
||||||
|
raise RuntimeError(f"No transcript log exists yet for tab_id: {tab_id}")
|
||||||
|
return _clean_terminal_text(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _last_visible_line(text: str) -> str:
|
||||||
|
lines = text.splitlines()
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
return lines[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_visible(text: str, prompt_pattern: str | None = None) -> bool:
|
||||||
|
last_line = _last_visible_line(text)
|
||||||
|
if not last_line:
|
||||||
|
return False
|
||||||
|
prompt_re = re.compile(prompt_pattern) if prompt_pattern else DEFAULT_PROMPT_RE
|
||||||
|
return prompt_re.search(last_line) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_submit_guard(command: str) -> None:
|
||||||
|
stripped = command.strip()
|
||||||
|
if not stripped:
|
||||||
|
raise RuntimeError("auto_submit requires a non-empty command")
|
||||||
|
for token in AUTO_SUBMIT_BLOCKED_TOKENS:
|
||||||
|
if token in stripped:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"auto_submit only supports simple read-only commands; found blocked shell syntax {token!r}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(stripped)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError("auto_submit only supports commands that can be parsed as a simple shell word sequence") from exc
|
||||||
|
if not tokens:
|
||||||
|
raise RuntimeError("auto_submit requires a non-empty command")
|
||||||
|
|
||||||
|
executable = os.path.basename(tokens[0])
|
||||||
|
if executable in AUTO_SUBMIT_BLOCKED_COMMANDS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"auto_submit is restricted to small read-only commands; {executable!r} requires manual submit"
|
||||||
|
)
|
||||||
|
if executable not in AUTO_SUBMIT_SIMPLE_COMMANDS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"auto_submit is only enabled for a narrow set of simple read-only commands; {executable!r} is not allowed"
|
||||||
|
)
|
||||||
|
|
||||||
|
if executable == "tail" and any(arg in {"-f", "--follow", "-F"} for arg in tokens[1:]):
|
||||||
|
raise RuntimeError("auto_submit does not allow follow-mode commands; use manual submit instead")
|
||||||
|
|
||||||
|
|
||||||
def _parse_epoch(value: str) -> float:
|
def _parse_epoch(value: str) -> float:
|
||||||
return float(value.replace(",", "."))
|
return float(value.replace(",", "."))
|
||||||
|
|
||||||
@@ -320,14 +452,32 @@ def exec_command(
|
|||||||
tab_id: str,
|
tab_id: str,
|
||||||
command: str,
|
command: str,
|
||||||
newline: bool = True,
|
newline: bool = True,
|
||||||
|
auto_submit: bool = False,
|
||||||
poll_interval: float = 0.1,
|
poll_interval: float = 0.1,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Write command text into an existing tab and block until the user manually presses Enter in the GUI.
|
"""Write command text into an existing tab and block until the user manually presses Enter in the GUI.
|
||||||
|
|
||||||
The tool blocks indefinitely until manual submission is detected, not after command completion.
|
By default the tool blocks indefinitely until manual submission is detected, not after command completion.
|
||||||
|
When `auto_submit=True`, it immediately sends Enter too, but only for small read-only commands.
|
||||||
Use wait_for_command_result() to wait for the command to finish and collect output.
|
Use wait_for_command_result() to wait for the command to finish and collect output.
|
||||||
"""
|
"""
|
||||||
result = call_gui("exec", tab_id=tab_id, command=command, newline=newline)
|
if auto_submit:
|
||||||
|
_auto_submit_guard(command)
|
||||||
|
|
||||||
|
result = call_gui("exec", tab_id=tab_id, command=command, newline=newline, auto_submit=auto_submit)
|
||||||
|
if auto_submit:
|
||||||
|
return {
|
||||||
|
"tab_id": result["tab_id"],
|
||||||
|
"written_text": result["written_text"],
|
||||||
|
"submission_id": result["submission_id"],
|
||||||
|
"requested_at": result["requested_at"],
|
||||||
|
"submitted_at": result["submitted_at"],
|
||||||
|
"submitted_manually": False,
|
||||||
|
"after_sequence": result["after_sequence"],
|
||||||
|
"current_sequence": result["current_sequence"],
|
||||||
|
"newline_ignored": False,
|
||||||
|
}
|
||||||
|
|
||||||
submitted = _wait_for_manual_submit(
|
submitted = _wait_for_manual_submit(
|
||||||
tab_id,
|
tab_id,
|
||||||
str(result["submission_id"]),
|
str(result["submission_id"]),
|
||||||
@@ -346,6 +496,28 @@ def exec_command(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def wait_for_command(delay_seconds: float) -> dict[str, Any]:
|
||||||
|
"""Block synchronously for a fixed amount of time.
|
||||||
|
|
||||||
|
This is a simple sleep helper for workflows driven by repeated `read_tab()` calls.
|
||||||
|
"""
|
||||||
|
if delay_seconds < 0:
|
||||||
|
raise RuntimeError("delay_seconds must be >= 0")
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
started_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||||
|
time.sleep(delay_seconds)
|
||||||
|
finished_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||||
|
return {
|
||||||
|
"state": "waited",
|
||||||
|
"delay_seconds": delay_seconds,
|
||||||
|
"started_at": started_at,
|
||||||
|
"finished_at": finished_at,
|
||||||
|
"elapsed_seconds": round(time.monotonic() - started, 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def read_tab(tab_id: str, last_n_lines: int = 200) -> dict[str, Any]:
|
def read_tab(tab_id: str, last_n_lines: int = 200) -> dict[str, Any]:
|
||||||
"""Read the trailing scrollback text from a tab."""
|
"""Read the trailing scrollback text from a tab."""
|
||||||
@@ -422,6 +594,52 @@ def wait_for_running_command(tab_id: str, timeout: float | None = None, poll_int
|
|||||||
raise RuntimeError(f"Timed out waiting for the running command on tab_id={tab_id} to finish")
|
raise RuntimeError(f"Timed out waiting for the running command on tab_id={tab_id} to finish")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def wait_for_prompt(
|
||||||
|
tab_id: str,
|
||||||
|
timeout: float | None = None,
|
||||||
|
poll_interval: float = 0.1,
|
||||||
|
idle_seconds: float = 0.4,
|
||||||
|
prompt_pattern: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Block until terminal output becomes idle and the trailing line looks like a shell prompt.
|
||||||
|
|
||||||
|
This is intended for delegated interactive sessions such as SSH, where tracked command markers
|
||||||
|
are unavailable. When `prompt_pattern` is omitted, a conservative default prompt regex is used.
|
||||||
|
"""
|
||||||
|
socket_path = ensure_gui()
|
||||||
|
log_path = _tab_log_path(socket_path, tab_id)
|
||||||
|
deadline = None if timeout is None or timeout <= 0 else time.monotonic() + timeout
|
||||||
|
last_signature: tuple[int, int] | None = None
|
||||||
|
last_changed_at: float | None = None
|
||||||
|
|
||||||
|
while deadline is None or time.monotonic() < deadline:
|
||||||
|
if not log_path.exists():
|
||||||
|
raise RuntimeError(f"No transcript log exists yet for tab_id: {tab_id}")
|
||||||
|
|
||||||
|
stat = log_path.stat()
|
||||||
|
signature = (int(stat.st_size), int(stat.st_mtime_ns))
|
||||||
|
now = time.monotonic()
|
||||||
|
if signature != last_signature:
|
||||||
|
last_signature = signature
|
||||||
|
last_changed_at = now
|
||||||
|
|
||||||
|
if last_changed_at is not None and now - last_changed_at >= idle_seconds:
|
||||||
|
text = _read_tab_text(socket_path, tab_id)
|
||||||
|
if _prompt_visible(text, prompt_pattern=prompt_pattern):
|
||||||
|
return {
|
||||||
|
"tab_id": tab_id,
|
||||||
|
"state": "prompt",
|
||||||
|
"idle_seconds": idle_seconds,
|
||||||
|
"last_line": _last_visible_line(text),
|
||||||
|
"matched_prompt_pattern": prompt_pattern or DEFAULT_PROMPT_RE.pattern,
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
raise RuntimeError(f"Timed out waiting for a visible prompt on tab_id={tab_id}")
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def close_tab(tab_id: str) -> dict[str, Any]:
|
def close_tab(tab_id: str) -> dict[str, Any]:
|
||||||
"""Close a tab in the prototype GUI."""
|
"""Close a tab in the prototype GUI."""
|
||||||
|
|||||||
Reference in New Issue
Block a user