opencode-python-sdk 0.1.1__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.
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/_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
@@ -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(
opencode/resources/pty.py CHANGED
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  opencode/.generated.json,sha256=QNCFazAYhcwsGf0l4A2EeSpm2HOM7TRBE648Zyoeq_s,1383
2
- opencode/__init__.py,sha256=UGraxBEfh90WBSAQ7ccwxJsXE5AxYDDTyKlcXhqzykc,4106
2
+ opencode/__init__.py,sha256=vr5KU114carpz_eMvR-Pekt0nJz5YgP_iylSdLKeJHg,4251
3
3
  opencode/_base_client.py,sha256=3BwhIXL-cFGoRUuwDzTeFH21O7pihUR5CryOlS1Wylo,20108
4
4
  opencode/_client.py,sha256=ip-UhWyHTViMnqgX3jhUmCkscahFtXnIWM2Jd2zn61I,6723
5
5
  opencode/_constants.py,sha256=-B_D8RHElL4mYN__WVf4bag4D12dJHbL69_EMfi7bXo,1132
@@ -7,6 +7,7 @@ opencode/_errors.py,sha256=dWjsyGV0aDBKsvJrGNKGJn41Yue66yBBwGTT2pTV7E8,16431
7
7
  opencode/_exceptions.py,sha256=kqQiaSM6zbfyiXXddwapIymW7J61ZlAa767Yko5Y9EI,8720
8
8
  opencode/_models.py,sha256=2esvD2CmslOHCbuLNkN0sGDcgMAqeoMmB7hZMPzo1uA,1988
9
9
  opencode/_pagination.py,sha256=uvB-0IwfkGqbYIYiUW_ax_oAE-ldZJSWHtjluVcZlNk,7052
10
+ opencode/_pty.py,sha256=wdqBF7K40ikzas8i203pem2iov8nbgYDC-RqH6ZX41E,23429
10
11
  opencode/_qs.py,sha256=8EWbLzd-iLComngGRyO95f_z76j4tUeBihYJdHI5NP8,3643
11
12
  opencode/_resource.py,sha256=ovuG85mWcuXNTu5m6H9FKPN2rrD5o52oRFAGBHimPyg,1263
12
13
  opencode/_server.py,sha256=O-yVWU-v7HDJuVav1xXTil96BtvvbYMsHm1zLhneWKk,12849
@@ -22,7 +23,7 @@ opencode/resources/config.py,sha256=pnRi7pG8mJqO0Xp6dnh0a8V767Dy5Zvbr8wvsaBqaIc,
22
23
  opencode/resources/credential.py,sha256=6X-IGP8uN4WVVhCFoLPWnMjfQ6dW25wms1R3YIFNggA,12018
23
24
  opencode/resources/debug.py,sha256=gdKIJ1jSsD6mPaI_Qd3pmE5J5URIyTWZAppfulejf-c,5483
24
25
  opencode/resources/event.py,sha256=UntmSEmFRWjSIjWvP-OkXfc-S1cpflwHyp0E2ZYGCBk,2694
25
- opencode/resources/experimental.py,sha256=JdRajS5r4k7AjEX9Xp_Y2iGKndSKdDsJZK1nq-PM0kU,27961
26
+ opencode/resources/experimental.py,sha256=qjaMkBfI7aOwLWeQWlXmVtYwM1VIilry27kyCVcC1B0,28058
26
27
  opencode/resources/file.py,sha256=leXPuTdE44BUrpIqDZ3MAAjSgv8_aKtyDROKsDZuVSE,12943
27
28
  opencode/resources/form.py,sha256=ToPVrHbTlyUZyNrMaH3bkcfqEjvIF1UlL7CQHT2VjyE,2967
28
29
  opencode/resources/generate.py,sha256=koElRVSJtRrP6yok3hsYOJon8MMinku8IB8zCS0MImM,3755
@@ -36,7 +37,7 @@ opencode/resources/permission.py,sha256=4J-eysN90zWIAdrXbzXfR4FvPlsyIgwvXPmRMI46
36
37
  opencode/resources/plugin.py,sha256=CaZCUCdU4iAxgkZhYr6QntPmdAcJuBlip55q3q5rplw,8159
37
38
  opencode/resources/project.py,sha256=BJkgHswUoE2UZkWYQzTg1KyT8L5l5WNlJhlBdVZUMso,6114
38
39
  opencode/resources/provider.py,sha256=H7hdaJZ1ToyEGoSr13qUcAV-Rd1bd-pUSi6GJlY8B7A,6134
39
- opencode/resources/pty.py,sha256=gjXxuppy8R70PaN0x-ixv7X7s-U2XPER6Us4t4Getco,17480
40
+ opencode/resources/pty.py,sha256=ttlB4iwI-QyppsEClPrsdkvEtLFsrxOF5-B1iH-4QY0,17537
40
41
  opencode/resources/reference.py,sha256=7Y9pPZYVoIaN8k3qwYY8-h54vBUdxI6lSBgJYmowq3I,3089
41
42
  opencode/resources/rpc.py,sha256=AExEcrxgJrreOqav4k_edNcrg3Uqa9p7lilnvxVMqfo,3630
42
43
  opencode/resources/server.py,sha256=tNuofw5_BMSCGTalkUvOc5--hn43mSVBU0HANK-L8zo,6932
@@ -49,7 +50,7 @@ opencode/resources/websearch.py,sha256=xr1vaS6dwPsSZJUtVSkRtqtzZuoP9xg_cr1bBpeKo
49
50
  opencode/resources/worktree.py,sha256=mohf9rIm4PPJTMbJmieygQkaC8rc7LgF0GO4u-2D9OU,10950
50
51
  opencode/types/__init__.py,sha256=5JEAe89RPSwJw1yz0kufBxtEJgRsMpZXj7KTbzGX45A,60935
51
52
  opencode/types/_generated.py,sha256=c3zBAfVpZGJ9TX4FqrMZASmoxRntel_QzVuP85BOJwU,235015
52
- opencode_python_sdk-0.1.1.dist-info/METADATA,sha256=i_ELBqCkCafo6tyKdVzcETFFjRDhmDhZi_X8jPJDsQ0,11502
53
- opencode_python_sdk-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
54
- opencode_python_sdk-0.1.1.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
55
- opencode_python_sdk-0.1.1.dist-info/RECORD,,
53
+ opencode_python_sdk-0.1.2.dist-info/METADATA,sha256=pDtlrwdEM6_qXC2rXA5kk2s1LfA43YBen66Y9AiR1MU,12612
54
+ opencode_python_sdk-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
55
+ opencode_python_sdk-0.1.2.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
56
+ opencode_python_sdk-0.1.2.dist-info/RECORD,,