opencode-python-sdk 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (70) hide show
  1. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/PKG-INFO +24 -1
  2. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/README.md +21 -0
  3. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/pyproject.toml +6 -0
  4. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/__init__.py +4 -0
  5. opencode_python_sdk-0.1.2/src/opencode/_pty.py +586 -0
  6. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/experimental.py +3 -2
  7. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/pty.py +3 -2
  8. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_live.py +16 -0
  9. opencode_python_sdk-0.1.2/tests/test_pty.py +236 -0
  10. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/.gitignore +0 -0
  11. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/CHANGELOG.md +0 -0
  12. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/LICENSE +0 -0
  13. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/api.md +0 -0
  14. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/.generated.json +0 -0
  15. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_base_client.py +0 -0
  16. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_client.py +0 -0
  17. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_constants.py +0 -0
  18. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_errors.py +0 -0
  19. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_exceptions.py +0 -0
  20. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_models.py +0 -0
  21. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_pagination.py +0 -0
  22. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_qs.py +0 -0
  23. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_resource.py +0 -0
  24. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_server.py +0 -0
  25. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_streaming.py +0 -0
  26. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_types.py +0 -0
  27. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_unions.py +0 -0
  28. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/_version.py +0 -0
  29. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/py.typed +0 -0
  30. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/__init__.py +0 -0
  31. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/agent.py +0 -0
  32. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/command.py +0 -0
  33. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/config.py +0 -0
  34. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/credential.py +0 -0
  35. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/debug.py +0 -0
  36. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/event.py +0 -0
  37. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/file.py +0 -0
  38. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/form.py +0 -0
  39. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/generate.py +0 -0
  40. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/integration.py +0 -0
  41. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/location.py +0 -0
  42. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/mcp.py +0 -0
  43. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/message.py +0 -0
  44. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/migration.py +0 -0
  45. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/model.py +0 -0
  46. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/permission.py +0 -0
  47. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/plugin.py +0 -0
  48. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/project.py +0 -0
  49. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/provider.py +0 -0
  50. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/reference.py +0 -0
  51. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/rpc.py +0 -0
  52. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/server.py +0 -0
  53. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/session.py +0 -0
  54. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/shell.py +0 -0
  55. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/skill.py +0 -0
  56. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/vcs.py +0 -0
  57. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/voice.py +0 -0
  58. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/websearch.py +0 -0
  59. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/resources/worktree.py +0 -0
  60. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/types/__init__.py +0 -0
  61. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/src/opencode/types/_generated.py +0 -0
  62. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/__init__.py +0 -0
  63. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/conftest.py +0 -0
  64. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_async_client.py +0 -0
  65. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_client.py +0 -0
  66. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_docs_coverage.py +0 -0
  67. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_encoding.py +0 -0
  68. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_models.py +0 -0
  69. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_pagination.py +0 -0
  70. {opencode_python_sdk-0.1.1 → opencode_python_sdk-0.1.2}/tests/test_streaming.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opencode-python-sdk
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Typed Python client for the OpenCode 2.x server API
5
5
  Project-URL: Homepage, https://github.com/shrijayan/opencode-python-sdk
6
6
  Project-URL: Documentation, https://shrijayan.github.io/opencode-python-sdk/
@@ -31,6 +31,8 @@ Requires-Dist: anyio<5,>=4.4
31
31
  Requires-Dist: httpx<1,>=0.27
32
32
  Requires-Dist: pydantic<3,>=2.12
33
33
  Requires-Dist: typing-extensions>=4.12
34
+ Provides-Extra: pty
35
+ Requires-Dist: websockets<17,>=14; extra == 'pty'
34
36
  Description-Content-Type: text/markdown
35
37
 
36
38
  # opencode-python-sdk
@@ -204,6 +206,27 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
204
206
  not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
205
207
  they stay open between events.
206
208
 
209
+ ## Terminals
210
+
211
+ `client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
212
+ `pip install "opencode-python-sdk[pty]"`.
213
+
214
+ ```python
215
+ pty = client.pty.create(command="/bin/sh").data
216
+ with client.pty.attach(pty.id) as term:
217
+ term.resize(120, 40)
218
+ term.send("echo hi\n")
219
+ for chunk in term: # output bytes, until the shell exits
220
+ print(chunk.decode(errors="replace"), end="")
221
+ ```
222
+
223
+ `recv(timeout=...)` reads one chunk and returns `None` once the terminal has ended. `term.cursor` holds the output
224
+ position to pass as `cursor=` when reattaching; `cursor=-1` skips the replay of earlier output. Persistent terminals
225
+ work the same way through `client.experimental.persistent_pty.attach(pty_id, role=..., takeover=...)`. The async
226
+ client returns `AsyncPtyConnection` (asyncio only): `async with await client.pty.attach(...) as term`. A connection
227
+ the server refuses or closes abnormally raises `PtyConnectionError`. See
228
+ [`examples/terminal.py`](examples/terminal.py) for a full TTY bridge.
229
+
207
230
  ## Errors
208
231
 
209
232
  Every error derives from `opencode.OpencodeError`. Errors the server declares are raised as their own class, with a
@@ -169,6 +169,27 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
169
169
  not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
170
170
  they stay open between events.
171
171
 
172
+ ## Terminals
173
+
174
+ `client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
175
+ `pip install "opencode-python-sdk[pty]"`.
176
+
177
+ ```python
178
+ pty = client.pty.create(command="/bin/sh").data
179
+ with client.pty.attach(pty.id) as term:
180
+ term.resize(120, 40)
181
+ term.send("echo hi\n")
182
+ for chunk in term: # output bytes, until the shell exits
183
+ print(chunk.decode(errors="replace"), end="")
184
+ ```
185
+
186
+ `recv(timeout=...)` reads one chunk and returns `None` once the terminal has ended. `term.cursor` holds the output
187
+ position to pass as `cursor=` when reattaching; `cursor=-1` skips the replay of earlier output. Persistent terminals
188
+ work the same way through `client.experimental.persistent_pty.attach(pty_id, role=..., takeover=...)`. The async
189
+ client returns `AsyncPtyConnection` (asyncio only): `async with await client.pty.attach(...) as term`. A connection
190
+ the server refuses or closes abnormally raises `PtyConnectionError`. See
191
+ [`examples/terminal.py`](examples/terminal.py) for a full TTY bridge.
192
+
172
193
  ## Errors
173
194
 
174
195
  Every error derives from `opencode.OpencodeError`. Errors the server declares are raised as their own class, with a
@@ -39,6 +39,11 @@ Repository = "https://github.com/shrijayan/opencode-python-sdk"
39
39
  Issues = "https://github.com/shrijayan/opencode-python-sdk/issues"
40
40
  Changelog = "https://github.com/shrijayan/opencode-python-sdk/blob/main/CHANGELOG.md"
41
41
 
42
+ [project.optional-dependencies]
43
+ pty = [
44
+ "websockets>=14,<17",
45
+ ]
46
+
42
47
  [dependency-groups]
43
48
  dev = [
44
49
  "mypy>=1.18",
@@ -48,6 +53,7 @@ dev = [
48
53
  "respx>=0.22",
49
54
  "ruff>=0.14",
50
55
  "trio>=0.31",
56
+ "websockets>=14",
51
57
  ]
52
58
  docs = [
53
59
  "mkdocs-material[imaging]>=9.6",
@@ -75,6 +75,7 @@ from ._exceptions import (
75
75
  )
76
76
  from ._models import BaseModel
77
77
  from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
78
+ from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
78
79
  from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
79
80
  from ._streaming import AsyncStream, Stream
80
81
  from ._types import NOT_GIVEN, NotGiven
@@ -97,6 +98,7 @@ __all__ = [
97
98
  "AsyncOpencode",
98
99
  "AsyncOpencodeServer",
99
100
  "AsyncPaginator",
101
+ "AsyncPtyConnection",
100
102
  "AsyncStream",
101
103
  "AuthenticationError",
102
104
  "BadRequestError",
@@ -111,6 +113,8 @@ __all__ = [
111
113
  "OpencodeError",
112
114
  "OpencodeServer",
113
115
  "PermissionDeniedError",
116
+ "PtyConnection",
117
+ "PtyConnectionError",
114
118
  "RateLimitError",
115
119
  "ServerStartError",
116
120
  "ServiceUnavailableError",
@@ -0,0 +1,586 @@
1
+ """Interactive terminals: the PTY WebSocket endpoints.
2
+
3
+ The generator skips ``pty.connect`` and ``persistentPty.connect`` because they are WebSockets, so this module
4
+ implements them by hand and the generated ``client.pty`` and ``client.experimental.persistent_pty`` resources
5
+ mix the ``attach`` methods in. It needs the optional ``websockets`` dependency::
6
+
7
+ pip install "opencode-python-sdk[pty]"
8
+
9
+ Protocol (OpenCode 2.1.2):
10
+
11
+ - Opening a socket takes a single-use ticket from ``POST .../connect-token`` (sent with
12
+ ``x-opencode-ticket: 1``), passed as the ``ticket`` query parameter. Tickets expire after 60 seconds.
13
+ - ``GET /api/pty/:ptyID/connect`` takes ``location[directory]`` and an optional ``cursor`` (``-1`` skips the
14
+ replay). The server replays buffered output, then sends one binary control frame, ``0x00`` followed by
15
+ ``{"cursor": n}`` JSON, then live output. Output frames are UTF-8 text. Input frames are UTF-8 text or binary;
16
+ invalid UTF-8 is dropped. Resizing goes through ``PUT /api/pty/:ptyID``.
17
+ - ``GET /api/experimental/persistent-pty/:ptyID/connect`` takes ``cursor`` (byte offset, default ``0``),
18
+ ``role`` (``controller`` or ``observer``), ``attachment_id``, ``takeover`` and ``input_protocol``. Output frames
19
+ are binary; text frames are JSON events: ``attached`` first, then ``replay_complete`` after the replay, and
20
+ later ones such as ``resized``. With ``input_protocol=1`` every input frame is binary: one type byte (``0``
21
+ control, ``1`` input), big-endian ``uint16`` columns and rows, then the input bytes. A control frame claims
22
+ control at that size.
23
+ - Close code ``1000`` means the terminal ended; ``4404`` means it was not found, has exited or is unavailable.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import struct
30
+ import uuid
31
+ from typing import TYPE_CHECKING, Any, Literal, cast
32
+ from urllib.parse import urlencode
33
+
34
+ from typing_extensions import Self
35
+
36
+ from ._exceptions import OpencodeError
37
+ from ._qs import encode_path_segment, encode_query
38
+ from ._resource import AsyncAPIResource, SyncAPIResource
39
+ from ._types import NOT_GIVEN, NotGiven
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
43
+ from types import TracebackType
44
+
45
+ import httpx
46
+ from websockets.asyncio.client import ClientConnection as AsyncWebSocket
47
+ from websockets.sync.client import ClientConnection as SyncWebSocket
48
+
49
+ from .types import Location, PtyID
50
+
51
+ __all__ = [
52
+ "AsyncPersistentPtyAttachMixin",
53
+ "AsyncPtyAttachMixin",
54
+ "AsyncPtyConnection",
55
+ "PersistentPtyAttachMixin",
56
+ "PtyAttachMixin",
57
+ "PtyConnection",
58
+ "PtyConnectionError",
59
+ ]
60
+
61
+ TICKET_HEADER = "x-opencode-ticket"
62
+ CLOSE_NORMAL = 1000
63
+ CLOSE_NOT_FOUND = 4404
64
+ _FRAME_CONTROL = 0
65
+ _FRAME_INPUT = 1
66
+
67
+
68
+ class PtyConnectionError(OpencodeError):
69
+ """The terminal WebSocket could not be opened or closed abnormally.
70
+
71
+ Attributes:
72
+ status: HTTP status of a refused handshake (``403`` for a bad ticket, ``404`` for an unknown PTY).
73
+ code: WebSocket close code (``4404`` when the terminal is not found, exited or unavailable).
74
+ reason: WebSocket close reason.
75
+ """
76
+
77
+ def __init__(self, message: str, *, status: int | None = None, code: int | None = None, reason: str = "") -> None:
78
+ super().__init__(message)
79
+ self.status = status
80
+ self.code = code
81
+ self.reason = reason
82
+
83
+
84
+ def _missing() -> PtyConnectionError:
85
+ return PtyConnectionError(
86
+ 'Terminal support needs the websockets package. Install it with: pip install "opencode-python-sdk[pty]"'
87
+ )
88
+
89
+
90
+ def _ws_url(base_url: httpx.URL, path: str, query: Mapping[str, object]) -> str:
91
+ url = base_url.join(path.lstrip("/"))
92
+ scheme = "wss" if url.scheme == "https" else "ws"
93
+ return str(url.copy_with(scheme=scheme, query=urlencode(encode_query(query)).encode("ascii")))
94
+
95
+
96
+ def _headers(username: str, password: str | None, extra: Mapping[str, str] | None) -> dict[str, str]:
97
+ import base64
98
+
99
+ headers: dict[str, str] = {}
100
+ if password is not None:
101
+ headers["Authorization"] = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
102
+ headers.update(extra or {})
103
+ return headers
104
+
105
+
106
+ def _handshake_error(exc: Exception) -> PtyConnectionError:
107
+ response = getattr(exc, "response", None)
108
+ status = cast("int | None", getattr(response, "status_code", None))
109
+ return PtyConnectionError(f"Could not open the terminal WebSocket: {exc}", status=status)
110
+
111
+
112
+ class _Protocol:
113
+ """Frame handling shared by the sync and async connections."""
114
+
115
+ def __init__(self, *, persistent: bool, cols: int, rows: int) -> None:
116
+ self.persistent = persistent
117
+ self.cols = cols
118
+ self.rows = rows
119
+ self.cursor: int | None = None
120
+ self.attached: dict[str, Any] | None = None
121
+
122
+ def encode_input(self, data: bytes | str) -> bytes | str:
123
+ if not self.persistent:
124
+ return data
125
+ payload = data.encode() if isinstance(data, str) else data
126
+ return struct.pack(">BHH", _FRAME_INPUT, self.cols, self.rows) + payload
127
+
128
+ def encode_control(self, cols: int, rows: int) -> bytes:
129
+ return struct.pack(">BHH", _FRAME_CONTROL, cols, rows)
130
+
131
+ def decode(self, frame: bytes | str) -> tuple[bytes | None, dict[str, Any] | None]:
132
+ """Returns ``(output, event)``; control frames update the connection state and return no output."""
133
+ if self.persistent:
134
+ if isinstance(frame, bytes):
135
+ # Output frames are the daemon's byte range, so the resume offset advances by their length.
136
+ if self.cursor is not None:
137
+ self.cursor += len(frame)
138
+ return frame, None
139
+ event = cast("dict[str, Any]", json.loads(frame))
140
+ self._apply(event)
141
+ return None, event
142
+ if isinstance(frame, str):
143
+ return frame.encode(), None
144
+ if frame[:1] == b"\x00":
145
+ meta = cast("dict[str, Any]", json.loads(frame[1:]))
146
+ self.cursor = int(meta["cursor"])
147
+ return None, meta
148
+ return frame, None
149
+
150
+ def _apply(self, event: dict[str, Any]) -> None:
151
+ kind = event.get("type")
152
+ if kind == "attached":
153
+ self.attached = event
154
+ size = cast("dict[str, int]", event.get("info", {}).get("size") or {})
155
+ self.cols = size.get("cols", self.cols)
156
+ self.rows = size.get("rows", self.rows)
157
+ self.cursor = cast("int | None", event.get("replay", {}).get("endOffset", self.cursor))
158
+ elif kind == "replay_complete":
159
+ self.cursor = int(event["endOffset"])
160
+ elif kind == "resized":
161
+ self.cols = int(event.get("cols", self.cols))
162
+ self.rows = int(event.get("rows", self.rows))
163
+
164
+
165
+ def _closed(code: int | None, reason: str) -> PtyConnectionError | None:
166
+ if code in (CLOSE_NORMAL, None):
167
+ return None
168
+ return PtyConnectionError(
169
+ f"Terminal connection closed with code {code}: {reason or 'no reason'}", code=code, reason=reason
170
+ )
171
+
172
+
173
+ class PtyConnection:
174
+ """A synchronous connection to a remote terminal.
175
+
176
+ Iterate over it, or call :meth:`recv`, for output bytes; it stops when the terminal ends. Control frames
177
+ are handled internally: :attr:`cursor` holds the latest output position the server reported, and
178
+ ``on_event`` (persistent terminals) receives every JSON event.
179
+
180
+ Example::
181
+
182
+ with client.pty.attach(pty.id) as term:
183
+ term.send("echo hi\\n")
184
+ for chunk in term:
185
+ print(chunk.decode(errors="replace"), end="")
186
+ """
187
+
188
+ def __init__(
189
+ self,
190
+ ws: SyncWebSocket,
191
+ protocol: _Protocol,
192
+ resize: Callable[[int, int], object],
193
+ on_event: Callable[[dict[str, Any]], object] | None = None,
194
+ ) -> None:
195
+ self._ws = ws
196
+ self._protocol = protocol
197
+ self._resize = resize
198
+ self._on_event = on_event
199
+
200
+ @property
201
+ def cursor(self) -> int | None:
202
+ """Output position to pass as ``cursor`` when reattaching, once the server has reported one."""
203
+ return self._protocol.cursor
204
+
205
+ @property
206
+ def attached(self) -> dict[str, Any] | None:
207
+ """The ``attached`` event of a persistent terminal (attachment id, role, info, replay offsets)."""
208
+ return self._protocol.attached
209
+
210
+ @property
211
+ def close_code(self) -> int | None:
212
+ """The WebSocket close code once the connection has closed."""
213
+ return self._ws.protocol.close_code
214
+
215
+ def send(self, data: bytes | str) -> None:
216
+ """Writes input to the terminal. ``str`` is sent as UTF-8."""
217
+ self._ws.send(self._protocol.encode_input(data))
218
+
219
+ def resize(self, cols: int, rows: int) -> None:
220
+ """Resizes the terminal."""
221
+ if self._protocol.persistent:
222
+ self._protocol.cols, self._protocol.rows = cols, rows
223
+ self._ws.send(self._protocol.encode_control(cols, rows))
224
+ else:
225
+ self._resize(cols, rows)
226
+
227
+ def recv(self, timeout: float | None = None) -> bytes | None:
228
+ """Returns the next output chunk, or ``None`` once the terminal has ended.
229
+
230
+ Raises:
231
+ TimeoutError: No output within ``timeout`` seconds.
232
+ PtyConnectionError: The server closed the connection abnormally (``4404``: terminal unavailable).
233
+ """
234
+ while (output := self._next(timeout)) == b"":
235
+ pass
236
+ return output
237
+
238
+ def _next(self, timeout: float | None) -> bytes | None:
239
+ """Reads one frame: output bytes, ``b""`` for a control frame, ``None`` after a normal close."""
240
+ from websockets.exceptions import ConnectionClosed
241
+
242
+ try:
243
+ frame = self._ws.recv(timeout)
244
+ except ConnectionClosed as exc:
245
+ error = _closed(exc.rcvd.code if exc.rcvd else None, exc.rcvd.reason if exc.rcvd else "")
246
+ if error is not None:
247
+ raise error from None
248
+ return None
249
+ output, event = self._protocol.decode(frame)
250
+ if event is not None and self._on_event is not None and self._protocol.persistent:
251
+ self._on_event(event)
252
+ return output or b""
253
+
254
+ def wait_attached(self, timeout: float | None) -> None:
255
+ """Reads frames until the ``attached`` event of a persistent terminal; ``attach`` already does this."""
256
+ while self._protocol.attached is None:
257
+ if self._next(timeout) is None:
258
+ raise PtyConnectionError("Terminal connection closed before it was attached", code=self.close_code)
259
+
260
+ def __iter__(self) -> Iterator[bytes]:
261
+ while (chunk := self.recv()) is not None:
262
+ yield chunk
263
+
264
+ def close(self) -> None:
265
+ """Closes the connection. The terminal keeps running on the server."""
266
+ self._ws.close()
267
+
268
+ def __enter__(self) -> Self:
269
+ return self
270
+
271
+ def __exit__(
272
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
273
+ ) -> None:
274
+ self.close()
275
+
276
+
277
+ class AsyncPtyConnection:
278
+ """An asyncio connection to a remote terminal. Same interface as :class:`PtyConnection`, with awaitables.
279
+
280
+ Example::
281
+
282
+ async with await client.pty.attach(pty.id) as term:
283
+ await term.send("echo hi\\n")
284
+ async for chunk in term:
285
+ print(chunk.decode(errors="replace"), end="")
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ ws: AsyncWebSocket,
291
+ protocol: _Protocol,
292
+ resize: Callable[[int, int], Awaitable[object]],
293
+ on_event: Callable[[dict[str, Any]], object] | None = None,
294
+ ) -> None:
295
+ self._ws = ws
296
+ self._protocol = protocol
297
+ self._resize = resize
298
+ self._on_event = on_event
299
+
300
+ @property
301
+ def cursor(self) -> int | None:
302
+ """Output position to pass as ``cursor`` when reattaching, once the server has reported one."""
303
+ return self._protocol.cursor
304
+
305
+ @property
306
+ def attached(self) -> dict[str, Any] | None:
307
+ """The ``attached`` event of a persistent terminal (attachment id, role, info, replay offsets)."""
308
+ return self._protocol.attached
309
+
310
+ @property
311
+ def close_code(self) -> int | None:
312
+ """The WebSocket close code once the connection has closed."""
313
+ return self._ws.protocol.close_code
314
+
315
+ async def send(self, data: bytes | str) -> None:
316
+ """Writes input to the terminal. ``str`` is sent as UTF-8."""
317
+ await self._ws.send(self._protocol.encode_input(data))
318
+
319
+ async def resize(self, cols: int, rows: int) -> None:
320
+ """Resizes the terminal."""
321
+ if self._protocol.persistent:
322
+ self._protocol.cols, self._protocol.rows = cols, rows
323
+ await self._ws.send(self._protocol.encode_control(cols, rows))
324
+ else:
325
+ await self._resize(cols, rows)
326
+
327
+ async def recv(self) -> bytes | None:
328
+ """Returns the next output chunk, or ``None`` once the terminal has ended.
329
+
330
+ Raises:
331
+ PtyConnectionError: The server closed the connection abnormally (``4404``: terminal unavailable).
332
+ """
333
+ while (output := await self._next()) == b"":
334
+ pass
335
+ return output
336
+
337
+ async def _next(self) -> bytes | None:
338
+ """Reads one frame: output bytes, ``b""`` for a control frame, ``None`` after a normal close."""
339
+ from websockets.exceptions import ConnectionClosed
340
+
341
+ try:
342
+ frame = await self._ws.recv()
343
+ except ConnectionClosed as exc:
344
+ error = _closed(exc.rcvd.code if exc.rcvd else None, exc.rcvd.reason if exc.rcvd else "")
345
+ if error is not None:
346
+ raise error from None
347
+ return None
348
+ output, event = self._protocol.decode(frame)
349
+ if event is not None and self._on_event is not None and self._protocol.persistent:
350
+ self._on_event(event)
351
+ return output or b""
352
+
353
+ async def wait_attached(self) -> None:
354
+ """Reads frames until the ``attached`` event of a persistent terminal; ``attach`` already does this."""
355
+ while self._protocol.attached is None:
356
+ if await self._next() is None:
357
+ raise PtyConnectionError("Terminal connection closed before it was attached", code=self.close_code)
358
+
359
+ async def __aiter__(self) -> AsyncIterator[bytes]:
360
+ while (chunk := await self.recv()) is not None:
361
+ yield chunk
362
+
363
+ async def close(self) -> None:
364
+ """Closes the connection. The terminal keeps running on the server."""
365
+ await self._ws.close()
366
+
367
+ async def __aenter__(self) -> Self:
368
+ return self
369
+
370
+ async def __aexit__(
371
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
372
+ ) -> None:
373
+ await self.close()
374
+
375
+
376
+ def _open_sync(url: str, headers: dict[str, str], open_timeout: float | None) -> SyncWebSocket:
377
+ try:
378
+ from websockets.exceptions import InvalidHandshake
379
+ from websockets.sync.client import connect
380
+ except ImportError:
381
+ raise _missing() from None
382
+ try:
383
+ return connect(url, additional_headers=headers, max_size=None, open_timeout=open_timeout)
384
+ except InvalidHandshake as exc:
385
+ raise _handshake_error(exc) from exc
386
+
387
+
388
+ async def _open_async(url: str, headers: dict[str, str], open_timeout: float | None) -> AsyncWebSocket:
389
+ try:
390
+ from websockets.asyncio.client import connect
391
+ from websockets.exceptions import InvalidHandshake
392
+ except ImportError:
393
+ raise _missing() from None
394
+ try:
395
+ return await connect(url, additional_headers=headers, max_size=None, open_timeout=open_timeout)
396
+ except InvalidHandshake as exc:
397
+ raise _handshake_error(exc) from exc
398
+
399
+
400
+ def _require_websockets() -> None:
401
+ try:
402
+ import websockets # noqa: F401 # pyright: ignore[reportUnusedImport]
403
+ except ImportError:
404
+ raise _missing() from None
405
+
406
+
407
+ def _pty_query(location: object, cursor: int | None, ticket: str) -> dict[str, object]:
408
+ return {"location": location, "cursor": NOT_GIVEN if cursor is None else cursor, "ticket": ticket}
409
+
410
+
411
+ def _persistent_query(
412
+ cursor: int, role: Literal["controller", "observer"], attachment_id: str, takeover: bool, ticket: str
413
+ ) -> dict[str, object]:
414
+ return {
415
+ "ticket": ticket,
416
+ "cursor": cursor,
417
+ "role": role,
418
+ "attachment_id": attachment_id,
419
+ "takeover": takeover,
420
+ "input_protocol": 1,
421
+ }
422
+
423
+
424
+ class PtyAttachMixin(SyncAPIResource):
425
+ """Adds :meth:`attach` to ``client.pty``."""
426
+
427
+ def attach(
428
+ self,
429
+ pty_id: PtyID,
430
+ *,
431
+ location: Location | NotGiven | None = NOT_GIVEN,
432
+ cursor: int | None = None,
433
+ open_timeout: float | None = 10,
434
+ extra_headers: Mapping[str, str] | None = None,
435
+ ) -> PtyConnection:
436
+ """Opens an interactive connection to a PTY created with :meth:`create`.
437
+
438
+ Args:
439
+ pty_id: The PTY to attach to.
440
+ location: Project location; defaults to the client's ``directory``.
441
+ cursor: Output position to resume from (:attr:`PtyConnection.cursor` of an earlier connection).
442
+ ``None`` replays the whole buffer, ``-1`` skips the replay.
443
+ open_timeout: Seconds to wait for the WebSocket handshake.
444
+ extra_headers: Additional handshake headers.
445
+
446
+ Raises:
447
+ PtyConnectionError: ``websockets`` is not installed, or the server refused the connection.
448
+
449
+ ``GET /api/pty/:ptyID/connect`` (WebSocket)
450
+ """
451
+ _require_websockets()
452
+ client = self._client
453
+ token = client.pty.connect.token(pty_id, location=location, x_opencode_ticket="1")
454
+ url = _ws_url(
455
+ client.base_url,
456
+ f"/api/pty/{encode_path_segment(pty_id)}/connect",
457
+ _pty_query(self._location(location), cursor, token.data.ticket),
458
+ )
459
+ ws = _open_sync(url, _headers(client.username, client.password, extra_headers), open_timeout)
460
+
461
+ def resize(cols: int, rows: int) -> object:
462
+ from .types import PtyUpdateSize
463
+
464
+ return client.pty.update(pty_id, location=location, size=PtyUpdateSize(cols=cols, rows=rows))
465
+
466
+ return PtyConnection(ws, _Protocol(persistent=False, cols=0, rows=0), resize)
467
+
468
+
469
+ class AsyncPtyAttachMixin(AsyncAPIResource):
470
+ """Adds :meth:`attach` to ``client.pty``."""
471
+
472
+ async def attach(
473
+ self,
474
+ pty_id: PtyID,
475
+ *,
476
+ location: Location | NotGiven | None = NOT_GIVEN,
477
+ cursor: int | None = None,
478
+ open_timeout: float | None = 10,
479
+ extra_headers: Mapping[str, str] | None = None,
480
+ ) -> AsyncPtyConnection:
481
+ """Opens an interactive connection to a PTY. See :meth:`PtyAttachMixin.attach`. Needs asyncio.
482
+
483
+ ``GET /api/pty/:ptyID/connect`` (WebSocket)
484
+ """
485
+ _require_websockets()
486
+ client = self._client
487
+ token = await client.pty.connect.token(pty_id, location=location, x_opencode_ticket="1")
488
+ url = _ws_url(
489
+ client.base_url,
490
+ f"/api/pty/{encode_path_segment(pty_id)}/connect",
491
+ _pty_query(self._location(location), cursor, token.data.ticket),
492
+ )
493
+ ws = await _open_async(url, _headers(client.username, client.password, extra_headers), open_timeout)
494
+
495
+ async def resize(cols: int, rows: int) -> object:
496
+ from .types import PtyUpdateSize
497
+
498
+ return await client.pty.update(pty_id, location=location, size=PtyUpdateSize(cols=cols, rows=rows))
499
+
500
+ return AsyncPtyConnection(ws, _Protocol(persistent=False, cols=0, rows=0), resize)
501
+
502
+
503
+ class PersistentPtyAttachMixin(SyncAPIResource):
504
+ """Adds :meth:`attach` to ``client.experimental.persistent_pty``."""
505
+
506
+ def attach(
507
+ self,
508
+ pty_id: PtyID,
509
+ *,
510
+ cursor: int = 0,
511
+ role: Literal["controller", "observer"] = "controller",
512
+ attachment_id: str | None = None,
513
+ takeover: bool = False,
514
+ on_event: Callable[[dict[str, Any]], object] | None = None,
515
+ open_timeout: float | None = 10,
516
+ extra_headers: Mapping[str, str] | None = None,
517
+ ) -> PtyConnection:
518
+ """Opens an interactive connection to a persistent terminal. *(experimental)*
519
+
520
+ Args:
521
+ pty_id: The terminal to attach to.
522
+ cursor: Byte offset to replay from; ``0`` replays everything the server kept.
523
+ role: ``controller`` sends input and sets the size; ``observer`` only watches.
524
+ attachment_id: Identifies this client across reconnects; a random id by default.
525
+ takeover: Take control from the current controller.
526
+ on_event: Called with each JSON event (``attached``, ``replay_complete``, ``resized``, ...).
527
+ open_timeout: Seconds to wait for the WebSocket handshake.
528
+ extra_headers: Additional handshake headers.
529
+
530
+ Raises:
531
+ PtyConnectionError: ``websockets`` is not installed, or the server refused the connection.
532
+
533
+ ``GET /api/experimental/persistent-pty/:ptyID/connect`` (WebSocket)
534
+ """
535
+ _require_websockets()
536
+ client = self._client
537
+ token = client.experimental.persistent_pty.connect_token(pty_id, x_opencode_ticket="1")
538
+ url = _ws_url(
539
+ client.base_url,
540
+ f"/api/experimental/persistent-pty/{encode_path_segment(pty_id)}/connect",
541
+ _persistent_query(cursor, role, attachment_id or str(uuid.uuid4()), takeover, token.ticket),
542
+ )
543
+ ws = _open_sync(url, _headers(client.username, client.password, extra_headers), open_timeout)
544
+ protocol = _Protocol(persistent=True, cols=0, rows=0)
545
+ connection = PtyConnection(ws, protocol, lambda cols, rows: None, on_event)
546
+ connection.wait_attached(open_timeout)
547
+ return connection
548
+
549
+
550
+ class AsyncPersistentPtyAttachMixin(AsyncAPIResource):
551
+ """Adds :meth:`attach` to ``client.experimental.persistent_pty``."""
552
+
553
+ async def attach(
554
+ self,
555
+ pty_id: PtyID,
556
+ *,
557
+ cursor: int = 0,
558
+ role: Literal["controller", "observer"] = "controller",
559
+ attachment_id: str | None = None,
560
+ takeover: bool = False,
561
+ on_event: Callable[[dict[str, Any]], object] | None = None,
562
+ open_timeout: float | None = 10,
563
+ extra_headers: Mapping[str, str] | None = None,
564
+ ) -> AsyncPtyConnection:
565
+ """Opens an interactive connection to a persistent terminal. See
566
+ :meth:`PersistentPtyAttachMixin.attach`. Needs asyncio. *(experimental)*
567
+
568
+ ``GET /api/experimental/persistent-pty/:ptyID/connect`` (WebSocket)
569
+ """
570
+ _require_websockets()
571
+ client = self._client
572
+ token = await client.experimental.persistent_pty.connect_token(pty_id, x_opencode_ticket="1")
573
+ url = _ws_url(
574
+ client.base_url,
575
+ f"/api/experimental/persistent-pty/{encode_path_segment(pty_id)}/connect",
576
+ _persistent_query(cursor, role, attachment_id or str(uuid.uuid4()), takeover, token.ticket),
577
+ )
578
+ ws = await _open_async(url, _headers(client.username, client.password, extra_headers), open_timeout)
579
+ protocol = _Protocol(persistent=True, cols=0, rows=0)
580
+
581
+ async def resize(cols: int, rows: int) -> None:
582
+ return None
583
+
584
+ connection = AsyncPtyConnection(ws, protocol, resize, on_event)
585
+ await connection.wait_attached()
586
+ return connection
@@ -11,6 +11,7 @@ from functools import cached_property
11
11
  from typing import cast
12
12
 
13
13
  from .._base_client import RequestOptions
14
+ from .._pty import AsyncPersistentPtyAttachMixin, PersistentPtyAttachMixin
14
15
  from .._qs import encode_path_segment
15
16
  from .._resource import AsyncAPIResource, SyncAPIResource
16
17
  from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
@@ -30,7 +31,7 @@ from ..types import (
30
31
  __all__ = ["ExperimentalResource", "AsyncExperimentalResource"]
31
32
 
32
33
 
33
- class ExperimentalPersistentPtyResource(SyncAPIResource):
34
+ class ExperimentalPersistentPtyResource(PersistentPtyAttachMixin):
34
35
  """Operations under ``client.experimental.persistent_pty`` (OpenCode ``experimental.persistentPty``)."""
35
36
 
36
37
  def read(
@@ -396,7 +397,7 @@ class ExperimentalResource(SyncAPIResource):
396
397
  return ExperimentalPersistentPtyResource(self._client)
397
398
 
398
399
 
399
- class AsyncExperimentalPersistentPtyResource(AsyncAPIResource):
400
+ class AsyncExperimentalPersistentPtyResource(AsyncPersistentPtyAttachMixin):
400
401
  """Operations under ``client.experimental.persistent_pty`` (OpenCode ``experimental.persistentPty``)."""
401
402
 
402
403
  async def read(
@@ -10,6 +10,7 @@ from functools import cached_property
10
10
  from typing import cast
11
11
 
12
12
  from .._base_client import RequestOptions
13
+ from .._pty import AsyncPtyAttachMixin, PtyAttachMixin
13
14
  from .._qs import encode_path_segment
14
15
  from .._resource import AsyncAPIResource, SyncAPIResource
15
16
  from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
@@ -72,7 +73,7 @@ class PtyConnectResource(SyncAPIResource):
72
73
  )
73
74
 
74
75
 
75
- class PtyResource(SyncAPIResource):
76
+ class PtyResource(PtyAttachMixin):
76
77
  """Operations under ``client.pty`` (OpenCode ``pty``)."""
77
78
 
78
79
  @cached_property
@@ -318,7 +319,7 @@ class AsyncPtyConnectResource(AsyncAPIResource):
318
319
  )
319
320
 
320
321
 
321
- class AsyncPtyResource(AsyncAPIResource):
322
+ class AsyncPtyResource(AsyncPtyAttachMixin):
322
323
  """Operations under ``client.pty`` (OpenCode ``pty``)."""
323
324
 
324
325
  @cached_property
@@ -87,3 +87,19 @@ async def test_async_server(tmp_path: Path) -> None:
87
87
  info = await client.server.info()
88
88
  assert info.version
89
89
  assert not running.running
90
+
91
+
92
+ def test_pty_attach_echo(client: Opencode) -> None:
93
+ pytest.importorskip("websockets")
94
+ pty = client.pty.create(command="/bin/sh").data
95
+ try:
96
+ with client.pty.attach(pty.id) as term:
97
+ term.resize(100, 30)
98
+ term.send("echo hi\n")
99
+ output = b""
100
+ while b"hi\r\n" not in output.split(b"echo hi", 1)[-1]:
101
+ chunk = term.recv(timeout=15)
102
+ assert chunk is not None
103
+ output += chunk
104
+ finally:
105
+ client.pty.remove(pty.id)
@@ -0,0 +1,236 @@
1
+ """PTY WebSocket attach against an in-process fake server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import struct
7
+ import sys
8
+ import threading
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+ from urllib.parse import parse_qs, urlsplit
13
+
14
+ import httpx
15
+ import pytest
16
+ import respx
17
+ from websockets.datastructures import Headers
18
+ from websockets.http11 import Request, Response
19
+ from websockets.sync.server import Server, ServerConnection, serve
20
+
21
+ from opencode import AsyncOpencode, Opencode, PtyConnectionError
22
+
23
+ PASSWORD = "test-password"
24
+ TICKET = "ticket-1"
25
+
26
+
27
+ @dataclass
28
+ class Fake:
29
+ """Records what the client sent and decides how the fake server behaves."""
30
+
31
+ close_code: int = 1000
32
+ refuse: bool = False
33
+ paths: list[str] = field(default_factory=list)
34
+ queries: list[dict[str, list[str]]] = field(default_factory=list)
35
+ headers: list[Headers] = field(default_factory=list)
36
+ received: list[bytes | str] = field(default_factory=list)
37
+
38
+
39
+ def _handler(fake: Fake) -> Any:
40
+ def handle(ws: ServerConnection) -> None:
41
+ assert ws.request is not None
42
+ path = urlsplit(ws.request.path).path
43
+ if fake.close_code != 1000:
44
+ ws.close(fake.close_code, "session not found")
45
+ return
46
+ if "persistent-pty" in path:
47
+ ws.send(
48
+ json.dumps({"type": "attached", "info": {"size": {"cols": 80, "rows": 24}}, "replay": {"endOffset": 0}})
49
+ )
50
+ ws.send(b"replay")
51
+ ws.send(json.dumps({"type": "replay_complete", "endOffset": 6}))
52
+ else:
53
+ ws.send("replay")
54
+ ws.send(b"\x00" + json.dumps({"cursor": 6}).encode())
55
+ for message in ws:
56
+ fake.received.append(message)
57
+ if "persistent-pty" in path:
58
+ assert isinstance(message, bytes)
59
+ kind, cols, rows = struct.unpack(">BHH", message[:5])
60
+ if kind == 0:
61
+ ws.send(json.dumps({"type": "resized", "cols": cols, "rows": rows}))
62
+ continue
63
+ payload = message[5:]
64
+ else:
65
+ payload = message.encode() if isinstance(message, str) else message
66
+ if payload == b"exit\n":
67
+ ws.close(1000)
68
+ return
69
+ ws.send(payload if "persistent-pty" in path else payload.decode())
70
+
71
+ return handle
72
+
73
+
74
+ def _process_request(fake: Fake) -> Any:
75
+ def process(ws: ServerConnection, request: Request) -> Response | None:
76
+ split = urlsplit(request.path)
77
+ fake.paths.append(split.path)
78
+ fake.queries.append(parse_qs(split.query))
79
+ fake.headers.append(request.headers)
80
+ if fake.refuse:
81
+ return ws.respond(403, "forbidden\n")
82
+ return None
83
+
84
+ return process
85
+
86
+
87
+ @pytest.fixture
88
+ def fake() -> Fake:
89
+ return Fake()
90
+
91
+
92
+ @pytest.fixture
93
+ def ws_server(fake: Fake) -> Iterator[str]:
94
+ server: Server = serve(_handler(fake), "127.0.0.1", 0, process_request=_process_request(fake))
95
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
96
+ thread.start()
97
+ host, port = server.socket.getsockname()[:2]
98
+ try:
99
+ yield f"http://{host}:{port}"
100
+ finally:
101
+ server.shutdown()
102
+ thread.join()
103
+
104
+
105
+ @pytest.fixture
106
+ def api(ws_server: str) -> Iterator[respx.MockRouter]:
107
+ with respx.mock(base_url=ws_server, assert_all_called=False) as router:
108
+ router.post("/api/pty/pty_1/connect-token").respond(
109
+ json={"location": {"directory": "/repo"}, "data": {"ticket": TICKET, "expires_in": 60}}
110
+ )
111
+ router.post("/api/experimental/persistent-pty/pty_1/connect-token").respond(
112
+ json={"data": {"ticket": TICKET, "expires_in": 60}}
113
+ )
114
+ yield router
115
+
116
+
117
+ def test_pty_attach_round_trip(ws_server: str, api: respx.MockRouter, fake: Fake) -> None:
118
+ update = api.put("/api/pty/pty_1").respond(
119
+ json={
120
+ "location": {"directory": "/repo"},
121
+ "data": {
122
+ "id": "pty_1",
123
+ "title": "t",
124
+ "command": "bash",
125
+ "args": [],
126
+ "cwd": "/repo",
127
+ "status": "running",
128
+ "pid": 1,
129
+ },
130
+ }
131
+ )
132
+ with (
133
+ Opencode(ws_server, password=PASSWORD, directory="/repo") as client,
134
+ client.pty.attach("pty_1", cursor=3) as term,
135
+ ):
136
+ assert term.recv(timeout=5) == b"replay"
137
+ term.send("echo hi\n")
138
+ assert term.recv(timeout=5) == b"echo hi\n"
139
+ assert term.cursor == 6
140
+ term.resize(120, 40)
141
+ term.send(b"exit\n")
142
+ assert list(term) == []
143
+ assert term.close_code == 1000
144
+ assert api.calls[0].request.headers["x-opencode-ticket"] == "1"
145
+ assert json.loads(update.calls[0].request.content) == {"size": {"rows": 40, "cols": 120}}
146
+ assert fake.paths == ["/api/pty/pty_1/connect"]
147
+ assert fake.queries[0] == {"location[directory]": ["/repo"], "cursor": ["3"], "ticket": [TICKET]}
148
+ assert fake.headers[0]["Authorization"].startswith("Basic ")
149
+ assert fake.received == ["echo hi\n", b"exit\n"]
150
+
151
+
152
+ def test_persistent_closed_before_attached(ws_server: str, api: respx.MockRouter, fake: Fake) -> None:
153
+ fake.close_code = 4404
154
+ with Opencode(ws_server, password=PASSWORD) as client, pytest.raises(PtyConnectionError) as caught:
155
+ client.experimental.persistent_pty.attach("pty_1")
156
+ assert caught.value.code == 4404
157
+
158
+
159
+ def test_persistent_pty_attach_frames(ws_server: str, api: respx.MockRouter, fake: Fake) -> None:
160
+ events: list[dict[str, Any]] = []
161
+ with Opencode(ws_server, password=PASSWORD) as client:
162
+ term = client.experimental.persistent_pty.attach("pty_1", attachment_id="a1", on_event=events.append)
163
+ with term:
164
+ assert term.recv(timeout=5) == b"replay"
165
+ assert term.attached is not None
166
+ term.send("ls\n")
167
+ assert term.recv(timeout=5) == b"ls\n"
168
+ assert term.cursor == 9
169
+ term.resize(132, 50)
170
+ term.send("exit\n")
171
+ assert list(term) == []
172
+ assert [event["type"] for event in events] == ["attached", "replay_complete", "resized"]
173
+ assert fake.queries[0] == {
174
+ "ticket": [TICKET],
175
+ "cursor": ["0"],
176
+ "role": ["controller"],
177
+ "attachment_id": ["a1"],
178
+ "takeover": ["false"],
179
+ "input_protocol": ["1"],
180
+ }
181
+ # Input frames carry the size from the attached event, then the size set by resize.
182
+ assert fake.received == [
183
+ struct.pack(">BHH", 1, 80, 24) + b"ls\n",
184
+ struct.pack(">BHH", 0, 132, 50),
185
+ struct.pack(">BHH", 1, 132, 50) + b"exit\n",
186
+ ]
187
+
188
+
189
+ def test_not_found_close_raises(ws_server: str, api: respx.MockRouter, fake: Fake) -> None:
190
+ fake.close_code = 4404
191
+ with (
192
+ Opencode(ws_server, password=PASSWORD) as client,
193
+ client.pty.attach("pty_1") as term,
194
+ pytest.raises(PtyConnectionError) as caught,
195
+ ):
196
+ term.recv(timeout=5)
197
+ assert caught.value.code == 4404
198
+ assert caught.value.reason == "session not found"
199
+
200
+
201
+ def test_refused_handshake_raises(ws_server: str, api: respx.MockRouter, fake: Fake) -> None:
202
+ fake.refuse = True
203
+ with Opencode(ws_server, password=PASSWORD) as client, pytest.raises(PtyConnectionError) as caught:
204
+ client.pty.attach("pty_1")
205
+ assert caught.value.status == 403
206
+
207
+
208
+ def test_missing_websockets(monkeypatch: pytest.MonkeyPatch, mock_api: respx.MockRouter, client: Opencode) -> None:
209
+ monkeypatch.setitem(sys.modules, "websockets", None)
210
+ with pytest.raises(PtyConnectionError, match=r"opencode-python-sdk\[pty\]"):
211
+ client.pty.attach("pty_1")
212
+ assert not mock_api.calls
213
+
214
+
215
+ @pytest.mark.anyio
216
+ async def test_async_pty_attach(ws_server: str, api: respx.MockRouter, fake: Fake) -> None:
217
+ async with AsyncOpencode(ws_server, password=PASSWORD) as client:
218
+ async with await client.pty.attach("pty_1") as term:
219
+ assert await term.recv() == b"replay"
220
+ await term.send("echo hi\n")
221
+ assert await term.recv() == b"echo hi\n"
222
+ await term.send("exit\n")
223
+ assert [chunk async for chunk in term] == []
224
+ async with await client.experimental.persistent_pty.attach("pty_1") as persistent:
225
+ assert await persistent.recv() == b"replay"
226
+ await persistent.resize(90, 20)
227
+ await persistent.send(b"exit\n")
228
+ assert [chunk async for chunk in persistent] == []
229
+ assert fake.received[-1] == struct.pack(">BHH", 1, 90, 20) + b"exit\n"
230
+
231
+
232
+ def test_wss_for_https() -> None:
233
+ from opencode._pty import _ws_url
234
+
235
+ url = _ws_url(httpx.URL("https://example.com/base/"), "/api/pty/x/connect", {"ticket": "t"})
236
+ assert url == "wss://example.com/base/api/pty/x/connect?ticket=t"