feat: Add auto-submit functionality for simple commands and new wait functions

This commit is contained in:
2026-03-15 19:16:58 +01:00
parent 0ce2af33a8
commit 73ab76bd50
3 changed files with 271 additions and 24 deletions
+7 -2
View File
@@ -113,11 +113,13 @@ La variabile legacy `GNOME_VTE_MCP_SOCKET` resta accettata per compatibilita.
- `open_tab(title=None, cwd=None, command=None)`
- `list_tabs()`
- `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_last_command_result(tab_id)`
- `wait_for_command(delay_seconds)`
- `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_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)`
- `close_tab(tab_id)`
- `close_gui()`
@@ -132,12 +134,15 @@ Lifecycle GUI:
Per la lettura testuale dei comandi:
- `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
- 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
- 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_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
Per `capture_screenshot`:
+44 -20
View File
@@ -684,27 +684,35 @@ PROMPT_COMMAND='__mcp_hal9002_prompt_hook'
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:
submitted_at = datetime.now().astimezone().isoformat(timespec="milliseconds")
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
self._finalize_submission(tab, submitted_at=submitted_at)
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:
tab = self.tabs.get(tab_id)
if tab is None:
@@ -1153,7 +1161,7 @@ PROMPT_COMMAND='__mcp_hal9002_prompt_hook'
self.window.present()
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)
running_status = self._running_command_status(tab)
if running_status["state"] == "running":
@@ -1180,6 +1188,22 @@ PROMPT_COMMAND='__mcp_hal9002_prompt_hook'
tab.pending_submit_id = submission_id
tab.pending_submit_text = command
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)
return {
"tab_id": tab_id,
+220 -2
View File
@@ -5,6 +5,7 @@ import fcntl
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
@@ -25,6 +26,81 @@ COMMAND_END_MARKER = "__MCP_HAL9002_CMD_END__"
ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
OSC_ESCAPE_RE = re.compile(r"\x1b\].*?(?:\x07|\x1b\\)", re.DOTALL)
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()
@@ -58,6 +134,62 @@ def _sanitize_text(text: str) -> str:
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:
return float(value.replace(",", "."))
@@ -320,14 +452,32 @@ def exec_command(
tab_id: str,
command: str,
newline: bool = True,
auto_submit: bool = False,
poll_interval: float = 0.1,
) -> dict[str, Any]:
"""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.
"""
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(
tab_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()
def read_tab(tab_id: str, last_n_lines: int = 200) -> dict[str, Any]:
"""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")
@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()
def close_tab(tab_id: str) -> dict[str, Any]:
"""Close a tab in the prototype GUI."""