opencode-python-sdk 0.1.0__py3-none-any.whl → 0.1.2__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.
Files changed (40) hide show
  1. opencode/__init__.py +4 -0
  2. opencode/_exceptions.py +22 -0
  3. opencode/_pty.py +586 -0
  4. opencode/_server.py +8 -0
  5. opencode/resources/agent.py +14 -14
  6. opencode/resources/command.py +6 -6
  7. opencode/resources/config.py +18 -18
  8. opencode/resources/credential.py +22 -22
  9. opencode/resources/debug.py +10 -8
  10. opencode/resources/event.py +4 -4
  11. opencode/resources/experimental.py +77 -74
  12. opencode/resources/file.py +28 -28
  13. opencode/resources/form.py +6 -6
  14. opencode/resources/generate.py +8 -8
  15. opencode/resources/integration.py +102 -94
  16. opencode/resources/location.py +12 -12
  17. opencode/resources/mcp.py +44 -42
  18. opencode/resources/message.py +10 -10
  19. opencode/resources/migration.py +6 -4
  20. opencode/resources/model.py +16 -16
  21. opencode/resources/permission.py +68 -64
  22. opencode/resources/plugin.py +20 -20
  23. opencode/resources/project.py +14 -14
  24. opencode/resources/provider.py +18 -18
  25. opencode/resources/pty.py +51 -48
  26. opencode/resources/reference.py +6 -6
  27. opencode/resources/rpc.py +10 -10
  28. opencode/resources/server.py +12 -12
  29. opencode/resources/session.py +378 -366
  30. opencode/resources/shell.py +34 -34
  31. opencode/resources/skill.py +6 -6
  32. opencode/resources/vcs.py +48 -46
  33. opencode/resources/voice.py +8 -8
  34. opencode/resources/websearch.py +16 -16
  35. opencode/resources/worktree.py +30 -30
  36. {opencode_python_sdk-0.1.0.dist-info → opencode_python_sdk-0.1.2.dist-info}/METADATA +28 -2
  37. opencode_python_sdk-0.1.2.dist-info/RECORD +56 -0
  38. opencode_python_sdk-0.1.0.dist-info/RECORD +0 -55
  39. {opencode_python_sdk-0.1.0.dist-info → opencode_python_sdk-0.1.2.dist-info}/WHEEL +0 -0
  40. {opencode_python_sdk-0.1.0.dist-info → opencode_python_sdk-0.1.2.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py CHANGED
@@ -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",
opencode/_exceptions.py CHANGED
@@ -147,47 +147,69 @@ class APIStatusError(APIError):
147
147
 
148
148
 
149
149
  class BadRequestError(APIStatusError):
150
+ """The server rejected the request as malformed (400)."""
151
+
150
152
  status = 400
151
153
 
152
154
 
153
155
  class AuthenticationError(APIStatusError):
156
+ """The password was missing or wrong (401)."""
157
+
154
158
  status = 401
155
159
 
156
160
 
157
161
  class PermissionDeniedError(APIStatusError):
162
+ """The request is not allowed (403)."""
163
+
158
164
  status = 403
159
165
 
160
166
 
161
167
  class NotFoundError(APIStatusError):
168
+ """The requested resource does not exist (404)."""
169
+
162
170
  status = 404
163
171
 
164
172
 
165
173
  class ConflictError(APIStatusError):
174
+ """The request conflicts with the current state, for example a busy session (409)."""
175
+
166
176
  status = 409
167
177
  tag = "ConflictError"
168
178
 
169
179
 
170
180
  class ContentTooLargeError(APIStatusError):
181
+ """The request body is too large (413)."""
182
+
171
183
  status = 413
172
184
 
173
185
 
174
186
  class UnprocessableEntityError(APIStatusError):
187
+ """The request was well formed but could not be processed (422)."""
188
+
175
189
  status = 422
176
190
 
177
191
 
178
192
  class RateLimitError(APIStatusError):
193
+ """Too many requests (429). Retried automatically, honoring ``Retry-After``."""
194
+
179
195
  status = 429
180
196
 
181
197
 
182
198
  class InternalServerError(APIStatusError):
199
+ """The server failed (500, and the base class for every 5xx)."""
200
+
183
201
  status = 500
184
202
 
185
203
 
186
204
  class NotImplementedServerError(InternalServerError):
205
+ """The server does not implement this endpoint (501)."""
206
+
187
207
  status = 501
188
208
 
189
209
 
190
210
  class ServiceUnavailableError(InternalServerError):
211
+ """The server is temporarily unavailable (503). Retried automatically."""
212
+
191
213
  status = 503
192
214
  tag = "ServiceUnavailableError"
193
215
 
opencode/_pty.py ADDED
@@ -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