termpilot-plugin 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- termpilot/__init__.py +3 -0
- termpilot/adapters/__init__.py +1 -0
- termpilot/adapters/iterm2.py +589 -0
- termpilot/chatgpt.py +342 -0
- termpilot/errors.py +68 -0
- termpilot/main.py +37 -0
- termpilot/mcp_server.py +153 -0
- termpilot/models.py +203 -0
- termpilot/services/__init__.py +1 -0
- termpilot/services/commands.py +118 -0
- termpilot/services/sessions.py +131 -0
- termpilot_plugin-0.1.0.dist-info/METADATA +345 -0
- termpilot_plugin-0.1.0.dist-info/RECORD +16 -0
- termpilot_plugin-0.1.0.dist-info/WHEEL +4 -0
- termpilot_plugin-0.1.0.dist-info/entry_points.txt +2 -0
- termpilot_plugin-0.1.0.dist-info/licenses/LICENSE +21 -0
termpilot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""External terminal adapters used by TermPilot."""
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""Adapter for the iTerm2 Python scripting API.
|
|
2
|
+
|
|
3
|
+
All iTerm2-specific objects stay in this module. The service layer only sees
|
|
4
|
+
the transport-independent models and domain errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import AsyncIterator, Iterable
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from termpilot.errors import (
|
|
17
|
+
ErrorCode,
|
|
18
|
+
Iterm2UnavailableError,
|
|
19
|
+
OutputUnavailableError,
|
|
20
|
+
SessionNotFoundError,
|
|
21
|
+
SessionNotReadyError,
|
|
22
|
+
ShellIntegrationRequiredError,
|
|
23
|
+
TermPilotError,
|
|
24
|
+
)
|
|
25
|
+
from termpilot.models import (
|
|
26
|
+
MAX_COMMAND_TIMEOUT_SECONDS,
|
|
27
|
+
MAX_LINES,
|
|
28
|
+
MAX_OUTPUT_CHARS,
|
|
29
|
+
Availability,
|
|
30
|
+
CommandRequest,
|
|
31
|
+
CommandResult,
|
|
32
|
+
CommandStatus,
|
|
33
|
+
ExecutionReadiness,
|
|
34
|
+
TerminalSession,
|
|
35
|
+
TerminalSnapshot,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
try: # pragma: no cover - the import branch depends on the host environment.
|
|
39
|
+
import iterm2 as _default_iterm2
|
|
40
|
+
except ImportError: # pragma: no cover - exercised by packaging on non-macOS hosts.
|
|
41
|
+
_default_iterm2 = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
LOGGER = logging.getLogger(__name__)
|
|
45
|
+
ITERM2_CONNECT_TIMEOUT_SECONDS = 5.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _state_name(state: Any) -> str:
|
|
49
|
+
name = getattr(state, "name", None)
|
|
50
|
+
if name:
|
|
51
|
+
return str(name).lower()
|
|
52
|
+
return str(state).rsplit(".", 1)[-1].lower()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def readiness_from_prompt(prompt: Any, iterm2_module: Any) -> ExecutionReadiness:
|
|
56
|
+
"""Translate iTerm2 prompt state into TermPilot's fail-closed states."""
|
|
57
|
+
|
|
58
|
+
if prompt is None:
|
|
59
|
+
return ExecutionReadiness.SHELL_INTEGRATION_REQUIRED
|
|
60
|
+
|
|
61
|
+
state = getattr(prompt, "state", None)
|
|
62
|
+
prompt_state = getattr(iterm2_module, "PromptState", None)
|
|
63
|
+
editing = getattr(prompt_state, "EDITING", object())
|
|
64
|
+
running = getattr(prompt_state, "RUNNING", object())
|
|
65
|
+
finished = getattr(prompt_state, "FINISHED", object())
|
|
66
|
+
unknown = getattr(prompt_state, "UNKNOWN", object())
|
|
67
|
+
|
|
68
|
+
if state == editing or _state_name(state) == "editing":
|
|
69
|
+
return ExecutionReadiness.READY
|
|
70
|
+
if state == running or _state_name(state) == "running":
|
|
71
|
+
return ExecutionReadiness.BUSY
|
|
72
|
+
if state == finished or _state_name(state) == "finished":
|
|
73
|
+
return ExecutionReadiness.BUSY
|
|
74
|
+
if state == unknown or _state_name(state) == "unknown":
|
|
75
|
+
return ExecutionReadiness.UNKNOWN
|
|
76
|
+
return ExecutionReadiness.UNKNOWN
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _value_as_text(value: Any) -> str | None:
|
|
80
|
+
if value is None or value == "":
|
|
81
|
+
return None
|
|
82
|
+
return str(value)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _line_text(lines: Iterable[Any]) -> str:
|
|
86
|
+
result: list[str] = []
|
|
87
|
+
for line in lines:
|
|
88
|
+
result.append(str(getattr(line, "string", line)))
|
|
89
|
+
if getattr(line, "hard_eol", False):
|
|
90
|
+
result.append("\n")
|
|
91
|
+
return "".join(result)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _point_xy(point: Any) -> tuple[int, int] | None:
|
|
95
|
+
x = getattr(point, "x", None)
|
|
96
|
+
y = getattr(point, "y", None)
|
|
97
|
+
if not isinstance(x, int) or isinstance(x, bool):
|
|
98
|
+
return None
|
|
99
|
+
if not isinstance(y, int) or isinstance(y, bool):
|
|
100
|
+
return None
|
|
101
|
+
return x, y
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _prompt_command_buffer_is_empty(prompt: Any) -> bool:
|
|
105
|
+
command_range = getattr(prompt, "command_range", None)
|
|
106
|
+
start = _point_xy(getattr(command_range, "start", None))
|
|
107
|
+
end = _point_xy(getattr(command_range, "end", None))
|
|
108
|
+
return start is not None and start == end
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _line_cell_text(line: Any, start: int, end: int | None = None) -> str:
|
|
112
|
+
string_at = getattr(line, "string_at", None)
|
|
113
|
+
if string_at is None:
|
|
114
|
+
return str(getattr(line, "string", line))[start:end]
|
|
115
|
+
|
|
116
|
+
result: list[str] = []
|
|
117
|
+
x = start
|
|
118
|
+
while end is None or x < end:
|
|
119
|
+
try:
|
|
120
|
+
result.append(str(string_at(x)))
|
|
121
|
+
except IndexError:
|
|
122
|
+
break
|
|
123
|
+
x += 1
|
|
124
|
+
return "".join(result)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _coord_range_text(
|
|
128
|
+
lines: Iterable[Any],
|
|
129
|
+
*,
|
|
130
|
+
start_x: int,
|
|
131
|
+
start_y: int,
|
|
132
|
+
end_x: int,
|
|
133
|
+
end_y: int,
|
|
134
|
+
) -> str:
|
|
135
|
+
result: list[str] = []
|
|
136
|
+
for offset, line in enumerate(lines):
|
|
137
|
+
y = start_y + offset
|
|
138
|
+
line_start = start_x if y == start_y else 0
|
|
139
|
+
line_end = end_x if y == end_y else None
|
|
140
|
+
result.append(_line_cell_text(line, line_start, line_end))
|
|
141
|
+
if y < end_y and getattr(line, "hard_eol", False):
|
|
142
|
+
result.append("\n")
|
|
143
|
+
return "".join(result)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _bound_text(text: str, max_lines: int, max_chars: int) -> tuple[str, int, bool]:
|
|
147
|
+
lines = text.splitlines(keepends=True)
|
|
148
|
+
line_limited = len(lines) > max_lines
|
|
149
|
+
if line_limited:
|
|
150
|
+
text = "".join(lines[-max_lines:])
|
|
151
|
+
char_limited = len(text) > max_chars
|
|
152
|
+
if char_limited:
|
|
153
|
+
text = text[-max_chars:]
|
|
154
|
+
line_count = len(text.splitlines()) if text else 0
|
|
155
|
+
return text, line_count, line_limited or char_limited
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_read_bounds(max_lines: int, max_chars: int) -> None:
|
|
159
|
+
if (
|
|
160
|
+
isinstance(max_lines, bool)
|
|
161
|
+
or not isinstance(max_lines, int)
|
|
162
|
+
or not 0 < max_lines <= MAX_LINES
|
|
163
|
+
):
|
|
164
|
+
raise ValueError(f"max_lines must be an integer between 1 and {MAX_LINES}")
|
|
165
|
+
if (
|
|
166
|
+
isinstance(max_chars, bool)
|
|
167
|
+
or not isinstance(max_chars, int)
|
|
168
|
+
or not 0 < max_chars <= MAX_OUTPUT_CHARS
|
|
169
|
+
):
|
|
170
|
+
raise ValueError(f"max_chars must be an integer between 1 and {MAX_OUTPUT_CHARS}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _validate_timeout(timeout_seconds: float) -> None:
|
|
174
|
+
if not isinstance(timeout_seconds, (int, float)) or isinstance(timeout_seconds, bool):
|
|
175
|
+
raise ValueError("timeout_seconds must be a number")
|
|
176
|
+
if not 0 < timeout_seconds <= MAX_COMMAND_TIMEOUT_SECONDS:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"timeout_seconds must be greater than 0 and at most {MAX_COMMAND_TIMEOUT_SECONDS}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class Iterm2Adapter:
|
|
183
|
+
"""Async access to currently open iTerm2 sessions."""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
*,
|
|
188
|
+
connection: Any | None = None,
|
|
189
|
+
app: Any | None = None,
|
|
190
|
+
iterm2_module: Any | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._connection = connection
|
|
193
|
+
self._app = app
|
|
194
|
+
self._iterm2 = _default_iterm2 if iterm2_module is None else iterm2_module
|
|
195
|
+
|
|
196
|
+
async def _ensure_connected(self) -> Any:
|
|
197
|
+
if self._app is not None:
|
|
198
|
+
return self._app
|
|
199
|
+
if self._iterm2 is None:
|
|
200
|
+
raise Iterm2UnavailableError()
|
|
201
|
+
try:
|
|
202
|
+
if self._connection is None:
|
|
203
|
+
connection_type = getattr(self._iterm2, "Connection", None)
|
|
204
|
+
if connection_type is None:
|
|
205
|
+
raise Iterm2UnavailableError("The iTerm2 Python API has no Connection type.")
|
|
206
|
+
self._connection = await asyncio.wait_for(
|
|
207
|
+
connection_type.async_create(), timeout=ITERM2_CONNECT_TIMEOUT_SECONDS
|
|
208
|
+
)
|
|
209
|
+
get_app = getattr(self._iterm2, "async_get_app", None)
|
|
210
|
+
if get_app is None:
|
|
211
|
+
raise Iterm2UnavailableError("The iTerm2 Python API has no async_get_app function.")
|
|
212
|
+
self._app = await asyncio.wait_for(
|
|
213
|
+
get_app(self._connection, create_if_needed=True),
|
|
214
|
+
timeout=ITERM2_CONNECT_TIMEOUT_SECONDS,
|
|
215
|
+
)
|
|
216
|
+
except TermPilotError:
|
|
217
|
+
raise
|
|
218
|
+
except Exception as exc: # The API exposes several version-specific exception classes.
|
|
219
|
+
LOGGER.debug("Unable to connect to iTerm2", exc_info=True)
|
|
220
|
+
raise Iterm2UnavailableError(
|
|
221
|
+
str(exc) or "Timed out while connecting to the iTerm2 Python API."
|
|
222
|
+
) from exc
|
|
223
|
+
if self._app is None:
|
|
224
|
+
raise Iterm2UnavailableError("iTerm2 is not running or its Python API is disabled.")
|
|
225
|
+
return self._app
|
|
226
|
+
|
|
227
|
+
async def _get_variable(self, obj: Any, name: str) -> Any:
|
|
228
|
+
getter = getattr(obj, "async_get_variable", None)
|
|
229
|
+
if getter is None:
|
|
230
|
+
return None
|
|
231
|
+
try:
|
|
232
|
+
return await getter(name)
|
|
233
|
+
# Metadata is optional; a single missing variable must not hide a session.
|
|
234
|
+
except Exception:
|
|
235
|
+
LOGGER.debug("Unable to read iTerm2 variable %s", name, exc_info=True)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
async def _first_variable(self, obj: Any, names: Iterable[str]) -> str | None:
|
|
239
|
+
if obj is None:
|
|
240
|
+
return None
|
|
241
|
+
for name in names:
|
|
242
|
+
value = _value_as_text(await self._get_variable(obj, name))
|
|
243
|
+
if value is not None:
|
|
244
|
+
return value
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def _current_raw_session(self, app: Any) -> Any | None:
|
|
248
|
+
window = getattr(app, "current_terminal_window", None)
|
|
249
|
+
if window is None:
|
|
250
|
+
window = getattr(app, "current_window", None)
|
|
251
|
+
tab = getattr(window, "current_tab", None) if window is not None else None
|
|
252
|
+
return getattr(tab, "current_session", None) if tab is not None else None
|
|
253
|
+
|
|
254
|
+
def _iter_raw_sessions(self, app: Any) -> Iterable[Any]:
|
|
255
|
+
seen: set[str] = set()
|
|
256
|
+
windows = getattr(app, "terminal_windows", None)
|
|
257
|
+
if windows is None:
|
|
258
|
+
windows = getattr(app, "windows", ())
|
|
259
|
+
for window in windows or ():
|
|
260
|
+
for tab in getattr(window, "tabs", ()) or ():
|
|
261
|
+
sessions = getattr(tab, "all_sessions", None)
|
|
262
|
+
if sessions is None:
|
|
263
|
+
sessions = getattr(tab, "sessions", ())
|
|
264
|
+
for session in sessions or ():
|
|
265
|
+
session_id = getattr(session, "session_id", None)
|
|
266
|
+
if session_id and session_id not in seen:
|
|
267
|
+
seen.add(session_id)
|
|
268
|
+
yield session
|
|
269
|
+
for session in getattr(app, "buried_sessions", ()) or ():
|
|
270
|
+
session_id = getattr(session, "session_id", None)
|
|
271
|
+
if session_id and session_id not in seen:
|
|
272
|
+
seen.add(session_id)
|
|
273
|
+
yield session
|
|
274
|
+
|
|
275
|
+
def _find_raw_session(self, app: Any, session_id: str) -> Any | None:
|
|
276
|
+
getter = getattr(app, "get_session_by_id", None)
|
|
277
|
+
if getter is not None:
|
|
278
|
+
try:
|
|
279
|
+
session = getter(session_id, include_buried=True)
|
|
280
|
+
except TypeError:
|
|
281
|
+
session = getter(session_id)
|
|
282
|
+
if session is not None:
|
|
283
|
+
return session
|
|
284
|
+
return next(
|
|
285
|
+
(
|
|
286
|
+
session
|
|
287
|
+
for session in self._iter_raw_sessions(app)
|
|
288
|
+
if session.session_id == session_id
|
|
289
|
+
),
|
|
290
|
+
None,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
async def _readiness(self, session_id: str) -> ExecutionReadiness:
|
|
294
|
+
getter = getattr(self._iterm2, "async_get_last_prompt", None)
|
|
295
|
+
if getter is None or self._connection is None:
|
|
296
|
+
return ExecutionReadiness.SHELL_INTEGRATION_REQUIRED
|
|
297
|
+
try:
|
|
298
|
+
prompt = await getter(self._connection, session_id)
|
|
299
|
+
except Exception:
|
|
300
|
+
LOGGER.debug("Unable to read the latest prompt for %s", session_id, exc_info=True)
|
|
301
|
+
return ExecutionReadiness.SHELL_INTEGRATION_REQUIRED
|
|
302
|
+
return readiness_from_prompt(prompt, self._iterm2)
|
|
303
|
+
|
|
304
|
+
async def _to_session(self, session: Any, *, is_current: bool) -> TerminalSession:
|
|
305
|
+
tab = getattr(session, "tab", None)
|
|
306
|
+
window = getattr(tab, "window", None)
|
|
307
|
+
session_name = await self._first_variable(session, ("name", "presentationName"))
|
|
308
|
+
if session_name is None:
|
|
309
|
+
session_name = _value_as_text(getattr(session, "name", None))
|
|
310
|
+
tab_title = await self._first_variable(tab, ("title", "titleOverride"))
|
|
311
|
+
window_title = await self._first_variable(window, ("titleOverride", "title"))
|
|
312
|
+
window_number = getattr(window, "window_number", None)
|
|
313
|
+
readiness = await self._readiness(session.session_id)
|
|
314
|
+
return TerminalSession(
|
|
315
|
+
session_id=session.session_id,
|
|
316
|
+
window_id=_value_as_text(getattr(window, "window_id", None)),
|
|
317
|
+
tab_id=_value_as_text(getattr(tab, "tab_id", None)),
|
|
318
|
+
window_title=window_title,
|
|
319
|
+
tab_title=tab_title,
|
|
320
|
+
session_title=session_name,
|
|
321
|
+
hostname=_value_as_text(await self._get_variable(session, "hostname")),
|
|
322
|
+
username=_value_as_text(await self._get_variable(session, "username")),
|
|
323
|
+
cwd=_value_as_text(await self._get_variable(session, "path")),
|
|
324
|
+
tty=_value_as_text(await self._get_variable(session, "tty")),
|
|
325
|
+
window_number=window_number if isinstance(window_number, int) else None,
|
|
326
|
+
is_current=is_current,
|
|
327
|
+
availability=Availability.AVAILABLE,
|
|
328
|
+
execution_readiness=readiness,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
async def list_sessions(self) -> list[TerminalSession]:
|
|
332
|
+
app = await self._ensure_connected()
|
|
333
|
+
current = self._current_raw_session(app)
|
|
334
|
+
current_id = getattr(current, "session_id", None)
|
|
335
|
+
return [
|
|
336
|
+
await self._to_session(session, is_current=session.session_id == current_id)
|
|
337
|
+
for session in self._iter_raw_sessions(app)
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
async def get_current_session(self) -> TerminalSession | None:
|
|
341
|
+
app = await self._ensure_connected()
|
|
342
|
+
current = self._current_raw_session(app)
|
|
343
|
+
if current is None:
|
|
344
|
+
return None
|
|
345
|
+
return await self._to_session(current, is_current=True)
|
|
346
|
+
|
|
347
|
+
async def get_session(self, session_id: str) -> TerminalSession | None:
|
|
348
|
+
app = await self._ensure_connected()
|
|
349
|
+
raw = self._find_raw_session(app, session_id)
|
|
350
|
+
if raw is None:
|
|
351
|
+
return None
|
|
352
|
+
current = self._current_raw_session(app)
|
|
353
|
+
return await self._to_session(
|
|
354
|
+
raw, is_current=getattr(current, "session_id", None) == session_id
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
async def _require_raw_session(self, session_id: str) -> Any:
|
|
358
|
+
app = await self._ensure_connected()
|
|
359
|
+
raw = self._find_raw_session(app, session_id)
|
|
360
|
+
if raw is None:
|
|
361
|
+
raise SessionNotFoundError(session_id)
|
|
362
|
+
return raw
|
|
363
|
+
|
|
364
|
+
async def read_terminal(
|
|
365
|
+
self,
|
|
366
|
+
session_id: str,
|
|
367
|
+
*,
|
|
368
|
+
max_lines: int = 200,
|
|
369
|
+
max_chars: int = 32_768,
|
|
370
|
+
) -> TerminalSnapshot:
|
|
371
|
+
_validate_read_bounds(max_lines, max_chars)
|
|
372
|
+
session = await self._require_raw_session(session_id)
|
|
373
|
+
try:
|
|
374
|
+
screen = await session.async_get_screen_contents()
|
|
375
|
+
lines = [screen.line(index) for index in range(screen.number_of_lines)]
|
|
376
|
+
content, line_count, truncated = _bound_text(_line_text(lines), max_lines, max_chars)
|
|
377
|
+
except (SessionNotFoundError, TermPilotError):
|
|
378
|
+
raise
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
LOGGER.debug("Unable to read screen for %s", session_id, exc_info=True)
|
|
381
|
+
raise TermPilotError(
|
|
382
|
+
ErrorCode.INTERNAL_ERROR, f"Unable to read session '{session_id}'."
|
|
383
|
+
) from exc
|
|
384
|
+
return TerminalSnapshot(
|
|
385
|
+
session_id=session_id,
|
|
386
|
+
content=content,
|
|
387
|
+
line_count=line_count,
|
|
388
|
+
truncated=truncated,
|
|
389
|
+
execution_readiness=await self._readiness(session_id),
|
|
390
|
+
captured_at=datetime.now(UTC),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
@asynccontextmanager
|
|
394
|
+
async def _transaction(self) -> AsyncIterator[None]:
|
|
395
|
+
transaction_type = getattr(self._iterm2, "Transaction", None)
|
|
396
|
+
if transaction_type is None or self._connection is None:
|
|
397
|
+
yield
|
|
398
|
+
return
|
|
399
|
+
async with transaction_type(self._connection):
|
|
400
|
+
yield
|
|
401
|
+
|
|
402
|
+
@asynccontextmanager
|
|
403
|
+
async def _command_monitor(self, session_id: str) -> AsyncIterator[Any]:
|
|
404
|
+
monitor_type = getattr(self._iterm2, "PromptMonitor", None)
|
|
405
|
+
if monitor_type is None or self._connection is None:
|
|
406
|
+
raise ShellIntegrationRequiredError()
|
|
407
|
+
mode_type = getattr(monitor_type, "Mode", None)
|
|
408
|
+
command_end = getattr(mode_type, "COMMAND_END", 3)
|
|
409
|
+
try:
|
|
410
|
+
context = monitor_type(self._connection, session_id, [command_end])
|
|
411
|
+
monitor = await context.__aenter__()
|
|
412
|
+
except TermPilotError:
|
|
413
|
+
raise
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
LOGGER.debug("Unable to monitor command completion for %s", session_id, exc_info=True)
|
|
416
|
+
raise ShellIntegrationRequiredError(
|
|
417
|
+
"Shell Integration could not monitor command completion safely."
|
|
418
|
+
) from exc
|
|
419
|
+
try:
|
|
420
|
+
yield monitor
|
|
421
|
+
finally:
|
|
422
|
+
try:
|
|
423
|
+
await context.__aexit__(None, None, None)
|
|
424
|
+
except Exception:
|
|
425
|
+
LOGGER.debug("Unable to close command monitor for %s", session_id, exc_info=True)
|
|
426
|
+
|
|
427
|
+
async def _wait_for_command_end(self, monitor: Any) -> tuple[int | None, str | None]:
|
|
428
|
+
mode_type = getattr(type(monitor), "Mode", None)
|
|
429
|
+
command_end = getattr(mode_type, "COMMAND_END", 3)
|
|
430
|
+
try:
|
|
431
|
+
while True:
|
|
432
|
+
try:
|
|
433
|
+
event = await monitor.async_get(include_id=True)
|
|
434
|
+
except TypeError:
|
|
435
|
+
event = await monitor.async_get()
|
|
436
|
+
if event is None:
|
|
437
|
+
raise ShellIntegrationRequiredError(
|
|
438
|
+
"This iTerm2 version did not report a command completion event."
|
|
439
|
+
)
|
|
440
|
+
mode, value = event[:2]
|
|
441
|
+
prompt_id = event[2] if len(event) > 2 else None
|
|
442
|
+
if mode == command_end or mode == 3:
|
|
443
|
+
return (int(value) if value is not None else None, prompt_id)
|
|
444
|
+
except TermPilotError:
|
|
445
|
+
raise
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
LOGGER.debug("Unable to monitor command completion", exc_info=True)
|
|
448
|
+
raise TermPilotError(
|
|
449
|
+
ErrorCode.INTERNAL_ERROR, "Unable to monitor command completion."
|
|
450
|
+
) from exc
|
|
451
|
+
|
|
452
|
+
async def _command_output(self, session: Any, prompt_id: str | None) -> str:
|
|
453
|
+
by_id = getattr(self._iterm2, "async_get_prompt_by_id", None)
|
|
454
|
+
if prompt_id and by_id is not None:
|
|
455
|
+
final_prompt = await by_id(self._connection, session.session_id, prompt_id)
|
|
456
|
+
else:
|
|
457
|
+
get_last = getattr(self._iterm2, "async_get_last_prompt", None)
|
|
458
|
+
final_prompt = (
|
|
459
|
+
await get_last(self._connection, session.session_id) if get_last else None
|
|
460
|
+
)
|
|
461
|
+
if final_prompt is None:
|
|
462
|
+
raise OutputUnavailableError()
|
|
463
|
+
output_range = getattr(final_prompt, "output_range", None)
|
|
464
|
+
start = _point_xy(getattr(output_range, "start", None))
|
|
465
|
+
end = _point_xy(getattr(output_range, "end", None))
|
|
466
|
+
if start is None or end is None:
|
|
467
|
+
raise OutputUnavailableError()
|
|
468
|
+
start_x, start_y = start
|
|
469
|
+
end_x, end_y = end
|
|
470
|
+
if end_y < start_y or (end_y == start_y and end_x < start_x):
|
|
471
|
+
raise OutputUnavailableError()
|
|
472
|
+
if start == end:
|
|
473
|
+
return ""
|
|
474
|
+
|
|
475
|
+
line_count = end_y - start_y + (1 if end_x > 0 else 0)
|
|
476
|
+
lines = await session.async_get_contents(start_y, line_count)
|
|
477
|
+
if len(lines) != line_count:
|
|
478
|
+
raise OutputUnavailableError()
|
|
479
|
+
return _coord_range_text(
|
|
480
|
+
lines,
|
|
481
|
+
start_x=start_x,
|
|
482
|
+
start_y=start_y,
|
|
483
|
+
end_x=end_x,
|
|
484
|
+
end_y=end_y,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
async def run_command(self, request: CommandRequest) -> CommandResult:
|
|
488
|
+
_validate_timeout(request.timeout_seconds)
|
|
489
|
+
session = await self._require_raw_session(request.session_id)
|
|
490
|
+
get_last = getattr(self._iterm2, "async_get_last_prompt", None)
|
|
491
|
+
if get_last is None or self._connection is None:
|
|
492
|
+
raise ShellIntegrationRequiredError()
|
|
493
|
+
prompt = await get_last(self._connection, request.session_id)
|
|
494
|
+
readiness = readiness_from_prompt(prompt, self._iterm2)
|
|
495
|
+
if readiness is ExecutionReadiness.SHELL_INTEGRATION_REQUIRED:
|
|
496
|
+
raise ShellIntegrationRequiredError()
|
|
497
|
+
if readiness is not ExecutionReadiness.READY:
|
|
498
|
+
raise SessionNotReadyError(
|
|
499
|
+
f"Session '{request.session_id}' is not at a verified shell command boundary."
|
|
500
|
+
)
|
|
501
|
+
if not _prompt_command_buffer_is_empty(prompt):
|
|
502
|
+
raise SessionNotReadyError(
|
|
503
|
+
f"Session '{request.session_id}' has pending input at the shell prompt."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
async def send_command() -> None:
|
|
507
|
+
try:
|
|
508
|
+
async with self._transaction():
|
|
509
|
+
await session.async_send_text(request.command + "\r", suppress_broadcast=True)
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
LOGGER.debug("Unable to send command to %s", request.session_id, exc_info=True)
|
|
512
|
+
if (
|
|
513
|
+
self._app is not None
|
|
514
|
+
and self._find_raw_session(self._app, request.session_id) is None
|
|
515
|
+
):
|
|
516
|
+
raise SessionNotFoundError(request.session_id) from exc
|
|
517
|
+
raise TermPilotError(
|
|
518
|
+
ErrorCode.INTERNAL_ERROR,
|
|
519
|
+
f"Unable to submit command to session '{request.session_id}'.",
|
|
520
|
+
) from exc
|
|
521
|
+
|
|
522
|
+
if not request.wait_for_completion:
|
|
523
|
+
await send_command()
|
|
524
|
+
return CommandResult(
|
|
525
|
+
request_id=request.request_id,
|
|
526
|
+
session_id=request.session_id,
|
|
527
|
+
command=request.command,
|
|
528
|
+
status=CommandStatus.SUBMITTED,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
async with self._command_monitor(request.session_id) as monitor:
|
|
533
|
+
await send_command()
|
|
534
|
+
exit_code, prompt_id = await asyncio.wait_for(
|
|
535
|
+
self._wait_for_command_end(monitor),
|
|
536
|
+
timeout=request.timeout_seconds,
|
|
537
|
+
)
|
|
538
|
+
except TimeoutError:
|
|
539
|
+
return CommandResult(
|
|
540
|
+
request_id=request.request_id,
|
|
541
|
+
session_id=request.session_id,
|
|
542
|
+
command=request.command,
|
|
543
|
+
status=CommandStatus.TIMED_OUT,
|
|
544
|
+
error_code=ErrorCode.COMMAND_TIMEOUT.value,
|
|
545
|
+
message=(
|
|
546
|
+
f"Stopped waiting after {request.timeout_seconds:g} seconds; "
|
|
547
|
+
"the command may still be running."
|
|
548
|
+
),
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
async with self._transaction():
|
|
553
|
+
output = await self._command_output(session, prompt_id)
|
|
554
|
+
except SessionNotFoundError:
|
|
555
|
+
raise
|
|
556
|
+
except OutputUnavailableError as exc:
|
|
557
|
+
return CommandResult(
|
|
558
|
+
request_id=request.request_id,
|
|
559
|
+
session_id=request.session_id,
|
|
560
|
+
command=request.command,
|
|
561
|
+
status=CommandStatus.ERROR,
|
|
562
|
+
exit_code=exit_code,
|
|
563
|
+
error_code=exc.code.value,
|
|
564
|
+
message=exc.message,
|
|
565
|
+
)
|
|
566
|
+
except Exception:
|
|
567
|
+
LOGGER.debug(
|
|
568
|
+
"Unable to capture command output for %s", request.session_id, exc_info=True
|
|
569
|
+
)
|
|
570
|
+
return CommandResult(
|
|
571
|
+
request_id=request.request_id,
|
|
572
|
+
session_id=request.session_id,
|
|
573
|
+
command=request.command,
|
|
574
|
+
status=CommandStatus.ERROR,
|
|
575
|
+
exit_code=exit_code,
|
|
576
|
+
error_code=ErrorCode.INTERNAL_ERROR.value,
|
|
577
|
+
message="Unable to capture command output.",
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
output, _line_count, truncated = _bound_text(output, MAX_LINES, request.max_output_chars)
|
|
581
|
+
return CommandResult(
|
|
582
|
+
request_id=request.request_id,
|
|
583
|
+
session_id=request.session_id,
|
|
584
|
+
command=request.command,
|
|
585
|
+
status=CommandStatus.COMPLETED,
|
|
586
|
+
exit_code=exit_code,
|
|
587
|
+
output=output,
|
|
588
|
+
truncated=truncated,
|
|
589
|
+
)
|