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 ADDED
@@ -0,0 +1,3 @@
1
+ """TermPilot: terminal actions for ChatGPT Work with Apps."""
2
+
3
+ __version__ = "0.1.0"
@@ -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
+ )