opencode-runtime 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ """
2
+ opencode-runtime: runtime infrastructure for multi-user OpenCode deployments.
3
+ """
4
+
5
+ __version__ = "0.4.0"
6
+
7
+ from .event import OpenCodeEvent
8
+ from .runtime import OpenCodeRuntime
9
+ from .response import OpenCodeResponse
10
+ from .session import OpenCodeSession
11
+
12
+ __all__ = [
13
+ "OpenCodeRuntime",
14
+ "OpenCodeSession",
15
+ "OpenCodeEvent",
16
+ "OpenCodeResponse",
17
+ ]
@@ -0,0 +1,299 @@
1
+ """
2
+ CLI entry point for opencode-runtime instance management.
3
+
4
+ Commands:
5
+ serve Start an OpenCode instance (detached)
6
+ ps List all instances tracked in the registry
7
+ stop Stop an instance by key
8
+ stop-all Stop all tracked instances
9
+ health Check health of an instance by key
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import asyncio
16
+ import sys
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ from .server import ServerManager, _compute_runtime_key
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # ANSI
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _R = "\033[0m"
27
+
28
+
29
+ def _green(s: str) -> str:
30
+ return f"\033[32m{s}{_R}"
31
+
32
+
33
+ def _yellow(s: str) -> str:
34
+ return f"\033[33m{s}{_R}"
35
+
36
+
37
+ def _red(s: str) -> str:
38
+ return f"\033[31m{s}{_R}"
39
+
40
+
41
+ def _cyan(s: str) -> str:
42
+ return f"\033[36m{s}{_R}"
43
+
44
+
45
+ def _dim(s: str) -> str:
46
+ return f"\033[2m{s}{_R}"
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # helpers
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def _home(path: str) -> str:
55
+ try:
56
+ return "~/" + str(Path(path).relative_to(Path.home()))
57
+ except ValueError:
58
+ return path
59
+
60
+
61
+ def _uptime(started_at: str, alive: bool) -> str:
62
+ try:
63
+ mins = max(
64
+ 0,
65
+ int(
66
+ (datetime.now(timezone.utc) - datetime.fromisoformat(started_at)).total_seconds()
67
+ // 60
68
+ ),
69
+ )
70
+ except Exception:
71
+ return "?"
72
+ return f"Up {mins}m" if alive else f"Dead {mins}m"
73
+
74
+
75
+ def _row(label: str, value: str) -> None:
76
+ print(f" {_cyan(f'{label:<9}')} {value}")
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # serve
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ async def _serve(args: argparse.Namespace) -> None:
85
+ project_dir = Path(args.project_dir).resolve()
86
+ runtime_dir = Path(args.runtime_dir).resolve() if args.runtime_dir else None
87
+ materials = args.materials or None
88
+
89
+ key = _compute_runtime_key(
90
+ workspace=args.workspace,
91
+ user_id=args.user_id,
92
+ project_dir=project_dir,
93
+ materials=materials,
94
+ config={},
95
+ )
96
+
97
+ manager = ServerManager()
98
+
99
+ existing = manager.find(key)
100
+ if existing is not None:
101
+ if manager.is_alive(key):
102
+ sys.exit(
103
+ _yellow(f"● Server already running id={existing.key} pid={existing.pid}\n")
104
+ + _dim(f" use: opencode-runtime stop {existing.key}")
105
+ )
106
+
107
+ server_dir: Path | None = None
108
+ if runtime_dir is not None:
109
+ server_dir = runtime_dir / "servers" / key
110
+
111
+ print(_yellow("● Starting opencode server..."), flush=True)
112
+
113
+ server = await manager.get_or_start(
114
+ key=key,
115
+ project_dir=project_dir,
116
+ server_dir=server_dir,
117
+ materials=materials,
118
+ config={},
119
+ env={},
120
+ workspace=args.workspace,
121
+ user_id=args.user_id,
122
+ )
123
+
124
+ entry = manager.find(key)
125
+ assert entry is not None
126
+
127
+ print(f"\r{_green('✓ Server started')}\n")
128
+ _row("ID", key)
129
+ if args.workspace:
130
+ _row("Workspace", args.workspace)
131
+ if args.user_id:
132
+ _row("User", args.user_id)
133
+ _row("Status", _green("● alive"))
134
+ _row("URL", server.client.base_url)
135
+ _row("PID", _dim(str(entry.pid)))
136
+ _row("Project", _dim(_home(str(project_dir))))
137
+ print()
138
+ print(_dim(f" opencode-runtime health {key}"))
139
+ print(_dim(f" opencode-runtime stop {key}"))
140
+
141
+
142
+ def cmd_serve(args: argparse.Namespace) -> None:
143
+ try:
144
+ asyncio.run(_serve(args))
145
+ except KeyboardInterrupt:
146
+ pass
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # ps
151
+ # ---------------------------------------------------------------------------
152
+
153
+
154
+ def cmd_ps(_args: argparse.Namespace) -> None:
155
+ entries = ServerManager().list()
156
+ show_workspace = any(e.workspace for e, _ in entries)
157
+ show_user = any(e.user_id for e, _ in entries)
158
+
159
+ cols = [" {:<18}", "{:>6}", "{:>6}", "{:<7}", "{:>8}"]
160
+ headers = ["ID", "PID", "PORT", "STATUS", "UPTIME"]
161
+ if show_workspace:
162
+ cols.append("{:<12}")
163
+ headers.append("WORKSPACE")
164
+ if show_user:
165
+ cols.append("{:<12}")
166
+ headers.append("USER")
167
+ cols.append("{}")
168
+ headers.append("PROJECT")
169
+ fmt = " ".join(cols)
170
+
171
+ print(_cyan(fmt.format(*headers)))
172
+ print(_dim(" " + "─" * (70 + 14 * show_workspace + 14 * show_user)))
173
+
174
+ for e, alive in entries:
175
+ status_plain = "● alive" if alive else "● dead"
176
+ status_coloured = _green(status_plain) if alive else _red(status_plain)
177
+ vals = [e.key, str(e.pid), str(e.port), status_plain, _uptime(e.started_at, alive)]
178
+ if show_workspace:
179
+ vals.append(e.workspace or "-")
180
+ if show_user:
181
+ vals.append(e.user_id or "-")
182
+ vals.append(_home(e.project_dir))
183
+ row = fmt.format(*vals)
184
+ row = row.replace(status_plain, status_coloured, 1)
185
+ print(_dim(row).replace(_dim(status_coloured), status_coloured, 1))
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # stop
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ def cmd_stop(args: argparse.Namespace) -> None:
194
+ manager = ServerManager()
195
+ entry = manager.find(args.key)
196
+ if entry is None:
197
+ sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
198
+
199
+ was_alive = asyncio.run(manager.stop(args.key))
200
+ if not was_alive:
201
+ print(_yellow(f" ● process {entry.pid} was already dead"))
202
+
203
+ print(f"{_green('✓ Server stopped')}\n")
204
+ _row("ID", entry.key)
205
+ _row("PID", _dim(str(entry.pid)))
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # stop-all
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ def cmd_stop_all(_args: argparse.Namespace) -> None:
214
+ manager = ServerManager()
215
+ entries = manager.list()
216
+ if not entries:
217
+ print(_dim(" no servers running"))
218
+ return
219
+
220
+ asyncio.run(manager.stop_all())
221
+
222
+ print(f"{_green(f'✓ Stopped {len(entries)} server(s)')}\n")
223
+ for e, _ in entries:
224
+ print(f" {_dim(e.key)} {_dim(f'pid {e.pid}')}")
225
+
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # health
229
+ # ---------------------------------------------------------------------------
230
+
231
+
232
+ def cmd_health(args: argparse.Namespace) -> None:
233
+ from .exceptions import OpenCodeServerError
234
+
235
+ manager = ServerManager()
236
+ entry = manager.find(args.key)
237
+ if entry is None:
238
+ sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
239
+
240
+ url = f"http://127.0.0.1:{entry.port}"
241
+ try:
242
+ result = asyncio.run(manager.health(args.key))
243
+ version = result.get("version")
244
+ print(_green("✓ healthy") + f" {_dim(f'version {version}')}" + f" {_dim(url)}")
245
+ except OpenCodeServerError as exc:
246
+ sys.exit(_red(f"✗ unreachable {url}\n {exc}"))
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # main
251
+ # ---------------------------------------------------------------------------
252
+
253
+
254
+ def main() -> None:
255
+ parser = argparse.ArgumentParser(
256
+ prog="opencode-runtime", description="Manage OpenCode instance processes."
257
+ )
258
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
259
+ sub.required = True
260
+
261
+ p = sub.add_parser("serve", help="start an opencode server (detached)")
262
+ p.add_argument(
263
+ "--project-dir", default=".", metavar="DIR", help="project directory (default: .)"
264
+ )
265
+ p.add_argument(
266
+ "--runtime-dir", default=None, metavar="DIR", help="isolated runtime directory (optional)"
267
+ )
268
+ p.add_argument(
269
+ "--materials",
270
+ action="append",
271
+ metavar="PATH",
272
+ help="materials path(s) to overlay (repeatable)",
273
+ )
274
+ p.add_argument(
275
+ "--workspace", default=None, metavar="NAME", help="tenant workspace identifier (optional)"
276
+ )
277
+ p.add_argument("--user-id", default=None, metavar="ID", help="user identifier (optional)")
278
+ p.set_defaults(func=cmd_serve)
279
+
280
+ p = sub.add_parser("ps", help="list tracked servers")
281
+ p.set_defaults(func=cmd_ps)
282
+
283
+ p = sub.add_parser("stop", help="stop a server by id")
284
+ p.add_argument("key", help="server id (from ps)")
285
+ p.set_defaults(func=cmd_stop)
286
+
287
+ p = sub.add_parser("stop-all", help="stop all tracked servers")
288
+ p.set_defaults(func=cmd_stop_all)
289
+
290
+ p = sub.add_parser("health", help="check health of a server by id")
291
+ p.add_argument("key", help="server id (from ps)")
292
+ p.set_defaults(func=cmd_health)
293
+
294
+ if len(sys.argv) == 1:
295
+ parser.print_help()
296
+ sys.exit(0)
297
+
298
+ args = parser.parse_args()
299
+ args.func(args)
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import typing as t
6
+
7
+ import httpx
8
+
9
+ from .event import OpenCodeEvent
10
+ from .exceptions import OpenCodeServerError, OpenCodeTimeoutError
11
+
12
+
13
+ class OpenCodeClient:
14
+ """Minimal HTTP/SSE client for the OpenCode server.
15
+
16
+ Covers only what the runtime needs to function:
17
+ - health check
18
+ - fire-and-forget message send
19
+ - SSE event stream
20
+
21
+ For anything else use the ``get()`` / ``post()`` escape hatches directly
22
+ against the OpenCode REST API (see https://opencode.ai/docs/server).
23
+
24
+ Args:
25
+ base_url: Base URL of the running opencode server,
26
+ e.g. ``"http://127.0.0.1:4096"``.
27
+ password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as
28
+ ``Authorization: Bearer <password>`` when set.
29
+ timeout: Default request timeout in seconds.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ base_url: str,
36
+ password: str | None = None,
37
+ timeout: float = 60.0,
38
+ ) -> None:
39
+ self.base_url = base_url.rstrip("/")
40
+ self.password = password
41
+ self.timeout = timeout
42
+
43
+ # ------------------------------------------------------------------
44
+ # Escape hatches — use these for any endpoint not covered below
45
+ # ------------------------------------------------------------------
46
+
47
+ def _headers(self) -> dict[str, str]:
48
+ headers: dict[str, str] = {"Accept": "application/json"}
49
+ if self.password:
50
+ # OpenCode uses HTTP Basic auth: username "opencode", password is the token
51
+ credentials = base64.b64encode(f"opencode:{self.password}".encode()).decode()
52
+ headers["Authorization"] = f"Basic {credentials}"
53
+ return headers
54
+
55
+ def _http(self) -> httpx.AsyncClient:
56
+ return httpx.AsyncClient(
57
+ base_url=self.base_url,
58
+ headers=self._headers(),
59
+ timeout=self.timeout,
60
+ )
61
+
62
+ async def get(self, path: str) -> t.Any:
63
+ """GET any OpenCode server endpoint. Returns parsed JSON."""
64
+ async with self._http() as http:
65
+ try:
66
+ r = await http.get(path)
67
+ r.raise_for_status()
68
+ return r.json()
69
+ except httpx.TimeoutException as exc:
70
+ raise OpenCodeTimeoutError(f"GET {path} timed out") from exc
71
+ except httpx.HTTPStatusError as exc:
72
+ raise OpenCodeServerError(
73
+ f"GET {path} returned {exc.response.status_code}"
74
+ ) from exc
75
+
76
+ async def post(self, path: str, body: dict[str, t.Any]) -> t.Any:
77
+ """POST any OpenCode server endpoint. Returns parsed JSON."""
78
+ async with self._http() as http:
79
+ try:
80
+ r = await http.post(path, json=body)
81
+ r.raise_for_status()
82
+ return r.json()
83
+ except httpx.TimeoutException as exc:
84
+ raise OpenCodeTimeoutError(f"POST {path} timed out") from exc
85
+ except httpx.HTTPStatusError as exc:
86
+ raise OpenCodeServerError(
87
+ f"POST {path} returned {exc.response.status_code}"
88
+ ) from exc
89
+
90
+ # ------------------------------------------------------------------
91
+ # The three things the runtime actually needs
92
+ # ------------------------------------------------------------------
93
+
94
+ async def health(self) -> dict[str, t.Any]:
95
+ """GET /global/health → ``{"healthy": true, "version": "..."}``."""
96
+ return await self.get("/global/health")
97
+
98
+ async def send(
99
+ self,
100
+ session_id: str,
101
+ message: str,
102
+ *,
103
+ model: str | None = None,
104
+ agent: str | None = None,
105
+ tools: dict[str, bool] | None = None,
106
+ system: str | None = None,
107
+ ) -> None:
108
+ """POST /session/:id/prompt_async — non-blocking message send.
109
+
110
+ Returns immediately. Events arrive on :meth:`events`.
111
+
112
+ ``model`` accepts either a ``"providerID/modelID"`` shorthand string
113
+ or a raw ``{"providerID": ..., "modelID": ...}`` dict.
114
+ """
115
+ parts: list[dict[str, t.Any]] = [{"type": "text", "text": message}]
116
+ body: dict[str, t.Any] = {"parts": parts}
117
+
118
+ if model is not None:
119
+ if isinstance(model, str) and "/" in model:
120
+ provider_id, model_id = model.split("/", 1)
121
+ body["model"] = {"providerID": provider_id, "modelID": model_id}
122
+ else:
123
+ body["model"] = model
124
+ if agent is not None:
125
+ body["agent"] = agent
126
+ if tools is not None:
127
+ body["tools"] = tools
128
+ if system is not None:
129
+ body["system"] = system
130
+
131
+ async with self._http() as http:
132
+ try:
133
+ r = await http.post(f"/session/{session_id}/prompt_async", json=body)
134
+ if r.status_code not in (200, 204):
135
+ r.raise_for_status()
136
+ except httpx.TimeoutException as exc:
137
+ raise OpenCodeTimeoutError("send timed out") from exc
138
+ except httpx.HTTPStatusError as exc:
139
+ raise OpenCodeServerError(f"send returned {exc.response.status_code}") from exc
140
+
141
+ async def events(self, session_id: str) -> t.AsyncIterator[OpenCodeEvent]:
142
+ """GET /global/event — SSE bus filtered to ``session_id``.
143
+
144
+ Yields every :class:`~opencode_runtime.event.OpenCodeEvent` that
145
+ OpenCode emits for this session and terminates on a terminal event.
146
+
147
+ The runtime does **no interpretation** — all events are forwarded as-is
148
+ so callers can handle whatever OpenCode emits (text deltas, tool calls,
149
+ thinking, permission requests, status updates, etc.).
150
+
151
+ Common event types emitted by OpenCode:
152
+
153
+ * ``message.part.delta`` — incremental token; ``event.text`` is the
154
+ delta string when ``properties.field == "text"``.
155
+ * ``message.part.updated`` — full part snapshot (text, tool, thinking,
156
+ …); ``event.text`` is the text content when ``part.type == "text"``.
157
+ * ``session.status`` — status change (e.g. ``{type: "running"}``).
158
+ * ``session.idle`` — terminal; model finished.
159
+ * ``session.error`` — terminal; something went wrong.
160
+ * ``permission.asked`` — tool permission request; caller must handle.
161
+
162
+ Only ``sync`` bus-noise events are suppressed.
163
+ """
164
+ async with self._http() as http:
165
+ async with http.stream("GET", "/global/event") as r:
166
+ try:
167
+ r.raise_for_status()
168
+ except httpx.HTTPStatusError as exc:
169
+ raise OpenCodeServerError(
170
+ f"SSE /global/event returned {exc.response.status_code}"
171
+ ) from exc
172
+
173
+ async for line in r.aiter_lines():
174
+ line = line.strip()
175
+
176
+ if not line.startswith("data:"):
177
+ continue
178
+
179
+ raw_data = line[len("data:") :].strip()
180
+ if not raw_data:
181
+ continue
182
+
183
+ try:
184
+ envelope = json.loads(raw_data)
185
+ except json.JSONDecodeError:
186
+ continue
187
+
188
+ # /global/event wraps each event: {"payload": {"type": ..., "properties": ...}}
189
+ payload = envelope.get("payload", {})
190
+ if not isinstance(payload, dict):
191
+ continue
192
+
193
+ event_type = payload.get("type", "")
194
+
195
+ # Suppress internal bus noise — never useful to callers.
196
+ if event_type == "sync":
197
+ continue
198
+
199
+ props = payload.get("properties", {})
200
+ if not isinstance(props, dict):
201
+ props = {}
202
+
203
+ # Filter to this session (events without sessionID are global, pass through)
204
+ sid = props.get("sessionID")
205
+ if sid is not None and sid != session_id:
206
+ continue
207
+
208
+ # Derive a convenience text field where it naturally exists,
209
+ # so callers don't have to dig into raw for the common case.
210
+ text: str | None = None
211
+ if event_type == "message.part.delta" and props.get("field") == "text":
212
+ text = props.get("delta") or None
213
+ elif event_type == "message.part.updated":
214
+ part = props.get("part") or {}
215
+ if isinstance(part, dict) and part.get("type") == "text":
216
+ text = part.get("text") or None
217
+
218
+ yield OpenCodeEvent(type=event_type, text=text, raw=payload)
219
+
220
+ # Terminal events — stop iterating after yielding.
221
+ if event_type == "session.idle":
222
+ return
223
+ if event_type == "session.error":
224
+ return
225
+ if event_type == "session.status":
226
+ status = props.get("status", {})
227
+ if isinstance(status, dict) and status.get("type") == "idle":
228
+ return
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class OpenCodeEvent:
9
+ """A normalized event from the OpenCode server SSE stream.
10
+
11
+ Attributes:
12
+ type: Event type string, e.g. "message.delta", "message.completed",
13
+ "session.error". Mirrors the OpenCode bus event types.
14
+ text: Text content for message.delta events; None for other types.
15
+ raw: The raw event payload from the server. Use this escape hatch
16
+ when you need fields beyond type and text.
17
+ """
18
+
19
+ type: str
20
+ text: str | None = None
21
+ raw: Any = None
22
+
23
+ def to_sse(self) -> str:
24
+ """Format as a Server-Sent Events string suitable for HTTP streaming."""
25
+ data = self.text or ""
26
+ return f"event: {self.type}\ndata: {data}\n\n"
@@ -0,0 +1,14 @@
1
+ class OpenCodeRuntimeError(Exception):
2
+ """Base class for all opencode-runtime errors."""
3
+
4
+
5
+ class OpenCodeNotFoundError(OpenCodeRuntimeError):
6
+ """Raised when the opencode binary cannot be found on PATH."""
7
+
8
+
9
+ class OpenCodeServerError(OpenCodeRuntimeError):
10
+ """Raised when the opencode server fails to start or returns an error."""
11
+
12
+
13
+ class OpenCodeTimeoutError(OpenCodeRuntimeError):
14
+ """Raised when a health check or request exceeds the allowed timeout."""
File without changes
@@ -0,0 +1,84 @@
1
+ """
2
+ Registry for tracking OpenCode instance processes.
3
+
4
+ Each running instance is represented by a JSON file at:
5
+ ~/.opencode-runtime/servers/<key>.json
6
+
7
+ Used by both the CLI (opencode-runtime serve/ps/stop) and the library
8
+ (OpenCodeRuntime) — the registry is the shared source of truth for all
9
+ running instances regardless of how they were started.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from dataclasses import asdict, dataclass
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ REGISTRY_DIR = Path.home() / ".opencode-runtime" / "servers"
21
+
22
+
23
+ @dataclass
24
+ class RegistryEntry:
25
+ key: str
26
+ pid: int
27
+ port: int
28
+ password: str
29
+ project_dir: str
30
+ server_dir: str | None
31
+ started_at: str # ISO-8601
32
+ workspace: str | None = None
33
+ user_id: str | None = None
34
+
35
+
36
+ def write(entry: RegistryEntry) -> None:
37
+ """Write a registry entry to disk. File is chmod 0o600."""
38
+ REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
39
+ path = REGISTRY_DIR / f"{entry.key}.json"
40
+ path.write_text(json.dumps(asdict(entry), indent=2), encoding="utf-8")
41
+ path.chmod(0o600)
42
+
43
+
44
+ def read(key: str) -> RegistryEntry | None:
45
+ """Read a registry entry by key. Returns None if not found."""
46
+ path = REGISTRY_DIR / f"{key}.json"
47
+ if not path.exists():
48
+ return None
49
+ data = json.loads(path.read_text(encoding="utf-8"))
50
+ return RegistryEntry(**data)
51
+
52
+
53
+ def delete(key: str) -> None:
54
+ """Remove a registry entry. No-op if not found."""
55
+ path = REGISTRY_DIR / f"{key}.json"
56
+ path.unlink(missing_ok=True)
57
+
58
+
59
+ def list_all() -> list[RegistryEntry]:
60
+ """Return all registry entries on disk."""
61
+ if not REGISTRY_DIR.exists():
62
+ return []
63
+ entries = []
64
+ for path in REGISTRY_DIR.glob("*.json"):
65
+ try:
66
+ data = json.loads(path.read_text(encoding="utf-8"))
67
+ entries.append(RegistryEntry(**data))
68
+ except Exception:
69
+ pass
70
+ return entries
71
+
72
+
73
+ def is_alive(pid: int) -> bool:
74
+ """Return True if a process with this PID is running."""
75
+ try:
76
+ os.kill(pid, 0)
77
+ return True
78
+ except (ProcessLookupError, PermissionError):
79
+ return False
80
+
81
+
82
+ def now_iso() -> str:
83
+ """Return current UTC time as ISO-8601 string."""
84
+ return datetime.now(timezone.utc).isoformat()
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class OpenCodeResponse:
9
+ """Collected response from a completed ask() call.
10
+
11
+ Attributes:
12
+ text: Full assistant text, concatenated from all message.delta events.
13
+ raw: List of raw event objects received during the session, in order.
14
+ Use this as an escape hatch when you need parts beyond plain text.
15
+ """
16
+
17
+ text: str
18
+ raw: list[Any] = field(default_factory=list)