opencode-runtime 0.4.0__py3-none-any.whl → 0.5.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.
@@ -2,11 +2,11 @@
2
2
  opencode-runtime: runtime infrastructure for multi-user OpenCode deployments.
3
3
  """
4
4
 
5
- __version__ = "0.4.0"
5
+ __version__ = "0.5.0"
6
6
 
7
7
  from .event import OpenCodeEvent
8
- from .runtime import OpenCodeRuntime
9
8
  from .response import OpenCodeResponse
9
+ from .runtime import OpenCodeRuntime
10
10
  from .session import OpenCodeSession
11
11
 
12
12
  __all__ = [
opencode_runtime/cli.py CHANGED
@@ -17,13 +17,14 @@ import sys
17
17
  from datetime import datetime, timezone
18
18
  from pathlib import Path
19
19
 
20
- from .server import ServerManager, _compute_runtime_key
20
+ from .server import DisplayStatus, ServerManager, _compute_runtime_key
21
21
 
22
22
  # ---------------------------------------------------------------------------
23
23
  # ANSI
24
24
  # ---------------------------------------------------------------------------
25
25
 
26
26
  _R = "\033[0m"
27
+ _DIM = "\033[2m"
27
28
 
28
29
 
29
30
  def _green(s: str) -> str:
@@ -43,7 +44,7 @@ def _cyan(s: str) -> str:
43
44
 
44
45
 
45
46
  def _dim(s: str) -> str:
46
- return f"\033[2m{s}{_R}"
47
+ return f"{_DIM}{s}{_R}"
47
48
 
48
49
 
49
50
  # ---------------------------------------------------------------------------
@@ -76,6 +77,29 @@ def _row(label: str, value: str) -> None:
76
77
  print(f" {_cyan(f'{label:<9}')} {value}")
77
78
 
78
79
 
80
+ _STATUS_ICONS: dict[DisplayStatus, str] = {
81
+ DisplayStatus.RUNNING: "●",
82
+ DisplayStatus.STARTING: "◐",
83
+ DisplayStatus.UNHEALTHY: "▲",
84
+ DisplayStatus.STALE: "○",
85
+ DisplayStatus.FAILED: "✗",
86
+ }
87
+ _STATUS_COLORS = {
88
+ DisplayStatus.RUNNING: _green,
89
+ DisplayStatus.STARTING: _yellow,
90
+ DisplayStatus.UNHEALTHY: _red,
91
+ DisplayStatus.STALE: _dim,
92
+ DisplayStatus.FAILED: _red,
93
+ }
94
+
95
+
96
+ def _status_display(status: DisplayStatus) -> str:
97
+ """Render a ServerStatus.display value as a coloured icon + label."""
98
+ icon = _STATUS_ICONS.get(status, "?")
99
+ color = _STATUS_COLORS.get(status, _dim)
100
+ return color(f"{icon} {status.value}")
101
+
102
+
79
103
  # ---------------------------------------------------------------------------
80
104
  # serve
81
105
  # ---------------------------------------------------------------------------
@@ -152,11 +176,11 @@ def cmd_serve(args: argparse.Namespace) -> None:
152
176
 
153
177
 
154
178
  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)
179
+ statuses = asyncio.run(ServerManager().list_statuses())
180
+ show_workspace = any(st.entry.workspace for st in statuses)
181
+ show_user = any(st.entry.user_id for st in statuses)
158
182
 
159
- cols = [" {:<18}", "{:>6}", "{:>6}", "{:<7}", "{:>8}"]
183
+ cols = [" {:<18}", "{:>6}", "{:>6}", "{:<11}", "{:>8}"]
160
184
  headers = ["ID", "PID", "PORT", "STATUS", "UPTIME"]
161
185
  if show_workspace:
162
186
  cols.append("{:<12}")
@@ -171,18 +195,33 @@ def cmd_ps(_args: argparse.Namespace) -> None:
171
195
  print(_cyan(fmt.format(*headers)))
172
196
  print(_dim(" " + "─" * (70 + 14 * show_workspace + 14 * show_user)))
173
197
 
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)]
198
+ for st in statuses:
199
+ e = st.entry
200
+ status_plain = f"{_STATUS_ICONS.get(st.display, '?')} {st.display.value}"
201
+ status_coloured = _status_display(st.display)
202
+
203
+ # Compute uptime
204
+ try:
205
+ started = datetime.fromisoformat(e.started_at)
206
+ uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
207
+ if uptime_secs < 60:
208
+ uptime_str = f"{uptime_secs}s"
209
+ elif uptime_secs < 3600:
210
+ uptime_str = f"{uptime_secs // 60}m"
211
+ else:
212
+ uptime_str = f"{uptime_secs // 3600}h"
213
+ except Exception:
214
+ uptime_str = "?"
215
+
216
+ vals = [e.key, str(e.pid), str(e.port), status_plain, uptime_str]
178
217
  if show_workspace:
179
218
  vals.append(e.workspace or "-")
180
219
  if show_user:
181
220
  vals.append(e.user_id or "-")
182
221
  vals.append(_home(e.project_dir))
183
222
  row = fmt.format(*vals)
184
- row = row.replace(status_plain, status_coloured, 1)
185
- print(_dim(row).replace(_dim(status_coloured), status_coloured, 1))
223
+ dimmed_row = _dim(row).replace(status_plain, f"{status_coloured}{_DIM}", 1)
224
+ print(dimmed_row)
186
225
 
187
226
 
188
227
  # ---------------------------------------------------------------------------
@@ -191,8 +230,10 @@ def cmd_ps(_args: argparse.Namespace) -> None:
191
230
 
192
231
 
193
232
  def cmd_stop(args: argparse.Namespace) -> None:
233
+ from . import registry
234
+
194
235
  manager = ServerManager()
195
- entry = manager.find(args.key)
236
+ entry = registry.read(args.key)
196
237
  if entry is None:
197
238
  sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
198
239
 
@@ -202,7 +243,7 @@ def cmd_stop(args: argparse.Namespace) -> None:
202
243
 
203
244
  print(f"{_green('✓ Server stopped')}\n")
204
245
  _row("ID", entry.key)
205
- _row("PID", _dim(str(entry.pid)))
246
+ _row("PID", _dim(str(entry.pid)) if entry.pid else _dim("(none)"))
206
247
 
207
248
 
208
249
  # ---------------------------------------------------------------------------
@@ -230,20 +271,103 @@ def cmd_stop_all(_args: argparse.Namespace) -> None:
230
271
 
231
272
 
232
273
  def cmd_health(args: argparse.Namespace) -> None:
233
- from .exceptions import OpenCodeServerError
274
+ manager = ServerManager()
275
+ st = asyncio.run(manager.status(args.key))
276
+ if st is None:
277
+ sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
278
+ entry = st.entry
279
+
280
+ if st.display == DisplayStatus.RUNNING:
281
+ try:
282
+ result = asyncio.run(manager.health(args.key))
283
+ version = result.get("version")
284
+ print(
285
+ _green("✓ healthy")
286
+ + f" {_dim(f'version {version}')}"
287
+ + f" {_dim(f'http://127.0.0.1:{entry.port}')}"
288
+ )
289
+ except Exception as exc:
290
+ sys.exit(_red(f"✗ unhealthy: /global/health failed: {exc}"))
291
+ elif st.display == DisplayStatus.STARTING:
292
+ try:
293
+ claimed = datetime.fromisoformat(entry.claimed_at)
294
+ age_secs = int((datetime.now(timezone.utc) - claimed).total_seconds())
295
+ sys.exit(_yellow(f"◐ starting: claimed {age_secs}s ago, health check pending"))
296
+ except Exception:
297
+ sys.exit(_yellow("◐ starting: awaiting health check"))
298
+ elif st.display == DisplayStatus.UNHEALTHY:
299
+ sys.exit(
300
+ _red(
301
+ f"✗ unhealthy: process running (pid {entry.pid}) but /global/health endpoint failed"
302
+ )
303
+ )
304
+ elif st.display == DisplayStatus.STALE:
305
+ sys.exit(_red(f"✗ stale: registry entry exists but pid {entry.pid} is not running"))
306
+ elif st.display == DisplayStatus.FAILED:
307
+ sys.exit(_red("✗ failed: startup failed or lease expired"))
308
+ else:
309
+ sys.exit(_red(f"✗ unknown: {st.display}"))
310
+
234
311
 
312
+ # ---------------------------------------------------------------------------
313
+ # inspect
314
+ # ---------------------------------------------------------------------------
315
+
316
+
317
+ def cmd_inspect(args: argparse.Namespace) -> None:
235
318
  manager = ServerManager()
236
- entry = manager.find(args.key)
237
- if entry is None:
319
+ st = asyncio.run(manager.status(args.key))
320
+ if st is None:
238
321
  sys.exit(_red(f"✗ ID {args.key!r} not found in registry"))
322
+ entry = st.entry
239
323
 
240
- url = f"http://127.0.0.1:{entry.port}"
324
+ # Compute uptime
241
325
  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}"))
326
+ started = datetime.fromisoformat(entry.started_at)
327
+ uptime_secs = int((datetime.now(timezone.utc) - started).total_seconds())
328
+ if uptime_secs < 60:
329
+ uptime = f"{uptime_secs}s"
330
+ elif uptime_secs < 3600:
331
+ uptime = f"{uptime_secs // 60}m {uptime_secs % 60}s"
332
+ else:
333
+ uptime = f"{uptime_secs // 3600}h {(uptime_secs % 3600) // 60}m"
334
+ except Exception:
335
+ uptime = "?"
336
+
337
+ # Compute idle time (time since last use)
338
+ if entry.last_used_at:
339
+ try:
340
+ last_used = datetime.fromisoformat(entry.last_used_at)
341
+ idle_secs = int((datetime.now(timezone.utc) - last_used).total_seconds())
342
+ if idle_secs < 60:
343
+ idle = f"{idle_secs}s ago"
344
+ elif idle_secs < 3600:
345
+ idle = f"{idle_secs // 60}m ago"
346
+ else:
347
+ idle = f"{idle_secs // 3600}h ago"
348
+ except Exception:
349
+ idle = "?"
350
+ else:
351
+ idle = "-"
352
+
353
+ print()
354
+ _row("ID", entry.key)
355
+ _row("Status", _status_display(st.display))
356
+ _row("Project", _home(entry.project_dir))
357
+ if entry.workspace:
358
+ _row("Workspace", entry.workspace)
359
+ if entry.user_id:
360
+ _row("User", entry.user_id)
361
+ _row("PID", _dim(str(entry.pid)) if entry.pid else _dim("(none)"))
362
+ _row("Port", _dim(str(entry.port)))
363
+ _row("Uptime", uptime)
364
+ _row("Last used", idle)
365
+ if entry.runtime_version:
366
+ _row("Runtime", entry.runtime_version)
367
+ if entry.server_dir:
368
+ log_file = _home(str(Path(entry.server_dir) / "opencode.log"))
369
+ _row("Log file", _dim(log_file))
370
+ print()
247
371
 
248
372
 
249
373
  # ---------------------------------------------------------------------------
@@ -291,6 +415,10 @@ def main() -> None:
291
415
  p.add_argument("key", help="server id (from ps)")
292
416
  p.set_defaults(func=cmd_health)
293
417
 
418
+ p = sub.add_parser("inspect", help="show detailed server information")
419
+ p.add_argument("key", help="server id (from ps)")
420
+ p.set_defaults(func=cmd_inspect)
421
+
294
422
  if len(sys.argv) == 1:
295
423
  parser.print_help()
296
424
  sys.exit(0)
@@ -24,8 +24,9 @@ class OpenCodeClient:
24
24
  Args:
25
25
  base_url: Base URL of the running opencode server,
26
26
  e.g. ``"http://127.0.0.1:4096"``.
27
- password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as
28
- ``Authorization: Bearer <password>`` when set.
27
+ password: Value of ``OPENCODE_SERVER_PASSWORD``. Sent as HTTP Basic
28
+ auth (``Authorization: Basic base64("opencode:<password>")``)
29
+ when set.
29
30
  timeout: Default request timeout in seconds.
30
31
  """
31
32
 
@@ -154,7 +155,7 @@ class OpenCodeClient:
154
155
  delta string when ``properties.field == "text"``.
155
156
  * ``message.part.updated`` — full part snapshot (text, tool, thinking,
156
157
  …); ``event.text`` is the text content when ``part.type == "text"``.
157
- * ``session.status`` — status change (e.g. ``{type: "running"}``).
158
+ * ``session.status`` — status change (e.g. ``{type: "busy"}``).
158
159
  * ``session.idle`` — terminal; model finished.
159
160
  * ``session.error`` — terminal; something went wrong.
160
161
  * ``permission.asked`` — tool permission request; caller must handle.
@@ -222,7 +223,3 @@ class OpenCodeClient:
222
223
  return
223
224
  if event_type == "session.error":
224
225
  return
225
- if event_type == "session.status":
226
- status = props.get("status", {})
227
- if isinstance(status, dict) and status.get("type") == "idle":
228
- return
opencode_runtime/event.py CHANGED
@@ -9,9 +9,10 @@ class OpenCodeEvent:
9
9
  """A normalized event from the OpenCode server SSE stream.
10
10
 
11
11
  Attributes:
12
- type: Event type string, e.g. "message.delta", "message.completed",
12
+ type: Event type string, e.g. "message.part.delta", "message.part.updated",
13
13
  "session.error". Mirrors the OpenCode bus event types.
14
- text: Text content for message.delta events; None for other types.
14
+ text: Text content for message.part.delta events (when properties.field == "text")
15
+ and message.part.updated events (when part.type == "text"); None otherwise.
15
16
  raw: The raw event payload from the server. Use this escape hatch
16
17
  when you need fields beyond type and text.
17
18
  """
@@ -19,8 +20,3 @@ class OpenCodeEvent:
19
20
  type: str
20
21
  text: str | None = None
21
22
  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"
@@ -12,3 +12,11 @@ class OpenCodeServerError(OpenCodeRuntimeError):
12
12
 
13
13
  class OpenCodeTimeoutError(OpenCodeRuntimeError):
14
14
  """Raised when a health check or request exceeds the allowed timeout."""
15
+
16
+
17
+ class RegistrySchemaError(OpenCodeRuntimeError):
18
+ """Raised when the registry database's schema version can't be handled.
19
+
20
+ Either it's newer than this version of opencode-runtime understands, or
21
+ no migration is registered to bring it up to the current version.
22
+ """
@@ -1,8 +1,8 @@
1
1
  """
2
2
  Registry for tracking OpenCode instance processes.
3
3
 
4
- Each running instance is represented by a JSON file at:
5
- ~/.opencode-runtime/servers/<key>.json
4
+ Every running instance is a row in a SQLite database at:
5
+ ~/.opencode-runtime/servers/registry.db
6
6
 
7
7
  Used by both the CLI (opencode-runtime serve/ps/stop) and the library
8
8
  (OpenCodeRuntime) — the registry is the shared source of truth for all
@@ -11,67 +11,166 @@ running instances regardless of how they were started.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
- import json
15
14
  import os
15
+ import sqlite3
16
+ from contextlib import contextmanager
16
17
  from dataclasses import asdict, dataclass
17
- from datetime import datetime, timezone
18
+ from datetime import datetime, timedelta, timezone
19
+ from enum import Enum
18
20
  from pathlib import Path
21
+ from typing import Generator
19
22
 
20
- REGISTRY_DIR = Path.home() / ".opencode-runtime" / "servers"
23
+ from .schema import SCHEMA, SCHEMA_VERSION, migrate
24
+
25
+ REGISTRY_DIR = Path(
26
+ os.environ.get("OPENCODE_RUNTIME_REGISTRY_DIR")
27
+ or (Path.home() / ".opencode-runtime" / "servers")
28
+ )
29
+
30
+ # A 'starting' row older than this is treated as abandoned (its starter
31
+ # crashed — SIGKILL, host reboot — before reaching write()/delete()) and is
32
+ # reclaimed by the next claim_starting() call. Comfortably above
33
+ # _wait_healthy's default 60s startup timeout, so a claim only expires once
34
+ # a start attempt has definitely either finished or died without cleaning
35
+ # up after itself.
36
+ _START_LEASE_SECONDS = 90
37
+
38
+
39
+ class ServerState(str, Enum):
40
+ """Server lifecycle state.
41
+
42
+ STARTING: claimed startup slot, awaiting health check.
43
+ RUNNING: process alive, health check passing.
44
+ STOPPING: shutdown initiated (reserved for future use).
45
+ FAILED: startup failed or lease expired (reserved for future use).
46
+ """
47
+
48
+ STARTING = "starting"
49
+ RUNNING = "running"
50
+ STOPPING = "stopping"
51
+ FAILED = "failed"
21
52
 
22
53
 
23
54
  @dataclass
24
55
  class RegistryEntry:
56
+ """A server entry in the registry.
57
+
58
+ state: ServerState enum value. Display status is derived from state + observed health.
59
+ """
60
+
25
61
  key: str
26
- pid: int
62
+ state: ServerState
63
+ pid: int | None
27
64
  port: int
28
65
  password: str
29
66
  project_dir: str
30
67
  server_dir: str | None
31
68
  started_at: str # ISO-8601
69
+ claimed_at: str # ISO-8601; only meaningful while state == "starting"
32
70
  workspace: str | None = None
33
71
  user_id: str | None = None
72
+ last_used_at: str | None = None
73
+ runtime_version: str | None = None
34
74
 
35
75
 
36
- def write(entry: RegistryEntry) -> None:
37
- """Write a registry entry to disk. File is chmod 0o600."""
76
+ _INSERT = """
77
+ INSERT INTO servers (key, state, pid, port, password, project_dir, server_dir,
78
+ started_at, claimed_at, workspace, user_id, last_used_at,
79
+ runtime_version)
80
+ VALUES (:key, :state, :pid, :port, :password, :project_dir, :server_dir,
81
+ :started_at, :claimed_at, :workspace, :user_id, :last_used_at,
82
+ :runtime_version)
83
+ """
84
+
85
+ _UPSERT = _INSERT.replace("INSERT", "INSERT OR REPLACE", 1)
86
+
87
+
88
+ @contextmanager
89
+ def _connect() -> Generator[sqlite3.Connection, None, None]:
90
+ """Open the registry database, creating it on first use.
91
+
92
+ Commits on clean exit, rolls back if the body raises. The 5s timeout is
93
+ SQLite's busy timeout — concurrent writers wait for each other instead
94
+ of failing immediately.
95
+ """
38
96
  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)
97
+ db_path = REGISTRY_DIR / "registry.db"
98
+ is_new = not db_path.exists()
99
+ conn = sqlite3.connect(db_path, timeout=5.0)
100
+ try:
101
+ conn.row_factory = sqlite3.Row
102
+ conn.execute(SCHEMA)
103
+ if is_new:
104
+ db_path.chmod(0o600) # entries hold server passwords
105
+ conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
106
+ else:
107
+ current_version = conn.execute("PRAGMA user_version").fetchone()[0]
108
+ if current_version != SCHEMA_VERSION:
109
+ migrate(conn, current_version)
110
+ yield conn
111
+ conn.commit()
112
+ finally:
113
+ conn.close()
114
+
115
+
116
+ def _entry(row: sqlite3.Row) -> RegistryEntry:
117
+ # Column names match RegistryEntry field names one-to-one.
118
+ return RegistryEntry(**{name: row[name] for name in row.keys()})
119
+
120
+
121
+ def write(entry: RegistryEntry) -> None:
122
+ """Write (insert or replace) a registry entry."""
123
+ with _connect() as conn:
124
+ conn.execute(_UPSERT, asdict(entry))
42
125
 
43
126
 
44
127
  def read(key: str) -> RegistryEntry | None:
45
128
  """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)
129
+ with _connect() as conn:
130
+ row = conn.execute("SELECT * FROM servers WHERE key = ?", (key,)).fetchone()
131
+ return _entry(row) if row is not None else None
51
132
 
52
133
 
53
134
  def delete(key: str) -> None:
54
135
  """Remove a registry entry. No-op if not found."""
55
- path = REGISTRY_DIR / f"{key}.json"
56
- path.unlink(missing_ok=True)
136
+ with _connect() as conn:
137
+ conn.execute("DELETE FROM servers WHERE key = ?", (key,))
57
138
 
58
139
 
59
140
  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."""
141
+ """Return all registry entries."""
142
+ with _connect() as conn:
143
+ rows = conn.execute("SELECT * FROM servers").fetchall()
144
+ return [_entry(row) for row in rows]
145
+
146
+
147
+ def claim_starting(entry: RegistryEntry) -> bool:
148
+ """Atomically insert a 'starting' row for entry.key.
149
+
150
+ Returns True if this call claimed the key, False if a row for the key
151
+ already exists (a live 'starting' claim or a 'ready' server). A
152
+ 'starting' row older than _START_LEASE_SECONDS is treated as abandoned
153
+ and reclaimed within the same transaction.
154
+ """
155
+ cutoff = (datetime.now(timezone.utc) - timedelta(seconds=_START_LEASE_SECONDS)).isoformat(
156
+ timespec="microseconds"
157
+ )
158
+ try:
159
+ with _connect() as conn:
160
+ conn.execute(
161
+ "DELETE FROM servers WHERE key = ? AND state = ? AND claimed_at < ?",
162
+ (entry.key, ServerState.STARTING.value, cutoff),
163
+ )
164
+ conn.execute(_INSERT, asdict(entry))
165
+ return True
166
+ except sqlite3.IntegrityError: # PRIMARY KEY conflict — someone else holds the key
167
+ return False
168
+
169
+
170
+ def is_alive(pid: int | None) -> bool:
171
+ """Return True if pid is set and a process with it is running."""
172
+ if pid is None:
173
+ return False
75
174
  try:
76
175
  os.kill(pid, 0)
77
176
  return True
@@ -80,5 +179,10 @@ def is_alive(pid: int) -> bool:
80
179
 
81
180
 
82
181
  def now_iso() -> str:
83
- """Return current UTC time as ISO-8601 string."""
84
- return datetime.now(timezone.utc).isoformat()
182
+ """Return current UTC time as ISO-8601 string with fixed microsecond precision.
183
+
184
+ Fixed precision (rather than omitting the fraction when it's exactly
185
+ zero, as isoformat() does by default) keeps these strings correctly
186
+ orderable by plain string comparison, e.g. in claim_starting()'s lease check.
187
+ """
188
+ return datetime.now(timezone.utc).isoformat(timespec="microseconds")
@@ -9,7 +9,7 @@ class OpenCodeResponse:
9
9
  """Collected response from a completed ask() call.
10
10
 
11
11
  Attributes:
12
- text: Full assistant text, concatenated from all message.delta events.
12
+ text: Full assistant text, concatenated from all message.part.delta events.
13
13
  raw: List of raw event objects received during the session, in order.
14
14
  Use this as an escape hatch when you need parts beyond plain text.
15
15
  """
@@ -55,19 +55,21 @@ class OpenCodeRuntime:
55
55
  # ------------------------------------------------------------------
56
56
 
57
57
  async def __aenter__(self) -> OpenCodeRuntime:
58
- await self.start()
59
58
  return self
60
59
 
61
60
  async def __aexit__(self, exc_type: t.Any, exc: t.Any, tb: t.Any) -> None:
62
- await self.stop()
61
+ await self.close()
63
62
 
64
- async def start(self) -> None:
65
- """Start the default OpenCode instance eagerly so the runtime is ready to use."""
66
- await self.session()
63
+ async def close(self) -> None:
64
+ """Stop every server this runtime instance itself started.
67
65
 
68
- async def stop(self) -> None:
69
- """Shut down all managed OpenCode instance processes."""
70
- await self._server_manager.stop_all()
66
+ Servers this runtime merely attached to — already running, started
67
+ by another process or the CLI — are left alone; they're shared
68
+ state and exiting this runtime shouldn't terminate them out from
69
+ under whoever else is using them. To stop those explicitly, use the
70
+ CLI (``opencode-runtime stop`` / ``stop-all``).
71
+ """
72
+ await self._server_manager.stop_owned()
71
73
 
72
74
  # ------------------------------------------------------------------
73
75
  # Session factory
@@ -126,6 +128,8 @@ class OpenCodeRuntime:
126
128
  user_id=user_id,
127
129
  )
128
130
 
131
+ self._server_manager.touch(key)
132
+
129
133
  return OpenCodeSession(
130
134
  client=server.client,
131
135
  workspace=workspace,