opencode-runtime 0.4.1__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.1"
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
 
opencode_runtime/event.py CHANGED
@@ -20,8 +20,3 @@ class OpenCodeEvent:
20
20
  type: str
21
21
  text: str | None = None
22
22
  raw: Any = None
23
-
24
- def to_sse(self) -> str:
25
- """Format as a Server-Sent Events string suitable for HTTP streaming."""
26
- data = self.text or ""
27
- 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")
@@ -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,
@@ -0,0 +1,79 @@
1
+ """
2
+ SQLite schema and migration mechanism for the registry database.
3
+
4
+ The schema version is stored in the database itself via SQLite's built-in
5
+ `PRAGMA user_version` — no separate metadata table needed. A fresh database
6
+ is stamped with SCHEMA_VERSION directly; an existing one is brought up to it
7
+ by migrate().
8
+
9
+ Bump SCHEMA_VERSION and add a step to _MIGRATIONS whenever the `servers`
10
+ table shape changes. Never edit SCHEMA in place once a version has shipped —
11
+ existing databases on disk still have the old shape and rely on a migration
12
+ step to reach the new one.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sqlite3
18
+ from typing import Callable
19
+
20
+ from .exceptions import RegistrySchemaError
21
+
22
+ SCHEMA = """
23
+ CREATE TABLE IF NOT EXISTS servers (
24
+ key TEXT PRIMARY KEY,
25
+ state TEXT NOT NULL,
26
+ pid INTEGER,
27
+ port INTEGER NOT NULL,
28
+ password TEXT NOT NULL,
29
+ project_dir TEXT NOT NULL,
30
+ server_dir TEXT,
31
+ started_at TEXT NOT NULL,
32
+ claimed_at TEXT NOT NULL,
33
+ workspace TEXT,
34
+ user_id TEXT,
35
+ last_used_at TEXT,
36
+ runtime_version TEXT
37
+ )
38
+ """
39
+
40
+ SCHEMA_VERSION = 1
41
+
42
+ # Migration steps, keyed by the version they upgrade *from*. Each callable
43
+ # receives the open connection (mid-transaction, table already created) and
44
+ # must leave the database in the shape of `from_version + 1` — e.g. an
45
+ # `ALTER TABLE servers ADD COLUMN ...`.
46
+ #
47
+ # Version 0 covers every database that predates this versioning scheme. Its
48
+ # table shape is identical to version 1 (versioning was introduced with no
49
+ # accompanying column change), so that migration is a no-op — it exists so
50
+ # unversioned databases get stamped with a version the first time they're
51
+ # opened under this scheme.
52
+ _MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {
53
+ 0: lambda conn: None,
54
+ }
55
+
56
+
57
+ def migrate(conn: sqlite3.Connection, current_version: int) -> None:
58
+ """Bring conn's schema from current_version up to SCHEMA_VERSION, in place.
59
+
60
+ Raises RegistrySchemaError if current_version is newer than this code
61
+ understands, or if a required migration step isn't registered.
62
+ """
63
+ if current_version > SCHEMA_VERSION:
64
+ raise RegistrySchemaError(
65
+ f"registry database is schema v{current_version}, but this version of "
66
+ f"opencode-runtime only understands up to v{SCHEMA_VERSION}. Upgrade "
67
+ "opencode-runtime to use it."
68
+ )
69
+ version = current_version
70
+ while version < SCHEMA_VERSION:
71
+ step = _MIGRATIONS.get(version)
72
+ if step is None:
73
+ raise RegistrySchemaError(
74
+ f"no migration registered to bring the registry database from schema "
75
+ f"v{version} to v{version + 1}"
76
+ )
77
+ step(conn)
78
+ version += 1
79
+ conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
@@ -13,15 +13,34 @@ import json
13
13
  import os
14
14
  import secrets
15
15
  import shutil
16
+ import signal
16
17
  import socket
17
18
  from dataclasses import dataclass
19
+ from enum import Enum
18
20
  from pathlib import Path
19
- from typing import TYPE_CHECKING, Any
21
+ from typing import Any
20
22
 
21
- if TYPE_CHECKING:
22
- from .client import OpenCodeClient
23
+ try:
24
+ from importlib.metadata import version
25
+ except ImportError:
26
+ from importlib_metadata import version # type: ignore[import-not-found,no-redef]
23
27
 
24
- from .registry import RegistryEntry
28
+ import httpx
29
+
30
+ from . import registry
31
+ from .client import OpenCodeClient
32
+ from .exceptions import (
33
+ OpenCodeNotFoundError,
34
+ OpenCodeRuntimeError,
35
+ OpenCodeServerError,
36
+ OpenCodeTimeoutError,
37
+ )
38
+ from .registry import RegistryEntry, ServerState
39
+
40
+ # One or more paths to overlay into the server dir before startup.
41
+ # Module-level alias: inside ServerManager's class body, `list[...]` in an
42
+ # annotation would resolve to the ServerManager.list method, not the builtin.
43
+ Materials = str | Path | list[str | Path] | None
25
44
 
26
45
 
27
46
  @dataclass
@@ -34,6 +53,58 @@ class _ManagedServer:
34
53
  server_dir: Path | None # None when runtime_dir is not set (no isolation)
35
54
 
36
55
 
56
+ class DisplayStatus(str, Enum):
57
+ """User-facing status derived from registry state, process liveness, and health."""
58
+
59
+ STARTING = "starting"
60
+ RUNNING = "running"
61
+ UNHEALTHY = "unhealthy"
62
+ STALE = "stale"
63
+ FAILED = "failed"
64
+ STOPPING = "stopping"
65
+
66
+
67
+ @dataclass
68
+ class ServerStatus:
69
+ """Computed liveness/health status for a registry entry."""
70
+
71
+ entry: RegistryEntry
72
+ process_alive: bool
73
+ health_ok: bool
74
+ display: DisplayStatus
75
+
76
+
77
+ def _is_process_alive(pid: int | None) -> bool:
78
+ """Return True if pid is set and a process with it is running."""
79
+ return registry.is_alive(pid)
80
+
81
+
82
+ async def _is_health_ok(client: OpenCodeClient, timeout: float = 3.0) -> bool:
83
+ """Return True if /global/health endpoint responds successfully."""
84
+ try:
85
+ await asyncio.wait_for(client.health(), timeout=timeout)
86
+ return True
87
+ except Exception:
88
+ return False
89
+
90
+
91
+ def _compute_display_status(
92
+ state: ServerState, process_alive: bool, health_ok: bool, lease_expired: bool = False
93
+ ) -> DisplayStatus:
94
+ """Derive user-facing display status from state, process liveness, and health."""
95
+ if state == ServerState.STARTING:
96
+ return DisplayStatus.FAILED if lease_expired else DisplayStatus.STARTING
97
+ if state == ServerState.STOPPING:
98
+ return DisplayStatus.STOPPING
99
+ if state == ServerState.FAILED:
100
+ return DisplayStatus.FAILED
101
+ if not process_alive:
102
+ return DisplayStatus.STALE
103
+ if not health_ok:
104
+ return DisplayStatus.UNHEALTHY
105
+ return DisplayStatus.RUNNING
106
+
107
+
37
108
  def _find_free_port(host: str = "127.0.0.1") -> int:
38
109
  """Bind to port 0 and let the OS pick a free ephemeral port."""
39
110
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
@@ -47,8 +118,6 @@ async def _wait_healthy(
47
118
  process: asyncio.subprocess.Process | None = None,
48
119
  ) -> None:
49
120
  """Poll GET /global/health until the server responds or timeout expires."""
50
- from .exceptions import OpenCodeTimeoutError
51
-
52
121
  deadline = asyncio.get_event_loop().time() + timeout
53
122
  last_exc: Exception | None = None
54
123
 
@@ -70,11 +139,9 @@ async def _wait_healthy(
70
139
  def _prepare_dir(
71
140
  server_dir: Path,
72
141
  config: dict[str, Any],
73
- materials: str | Path | list[str | Path] | None,
142
+ materials: Materials,
74
143
  ) -> None:
75
144
  """Write opencode.json and overlay materials into server_dir."""
76
- from .exceptions import OpenCodeRuntimeError
77
-
78
145
  if config:
79
146
  (server_dir / "opencode.json").write_text(
80
147
  json.dumps(config, indent=2),
@@ -119,7 +186,7 @@ def _compute_runtime_key(
119
186
  workspace: str | None,
120
187
  user_id: str | None,
121
188
  project_dir: Path,
122
- materials: str | Path | list[str | Path] | None,
189
+ materials: Materials,
123
190
  config: dict[str, Any],
124
191
  ) -> str:
125
192
  """Compute a stable 16-char key for a unique server configuration.
@@ -152,31 +219,82 @@ class ServerManager:
152
219
  and config gets its own isolated OpenCode instance. Instances are started
153
220
  on demand and reused when the same key is requested again.
154
221
 
155
- The registry is the single source of truth. There is no in-memory cache
156
- every call consults the registry so that external actors (CLI stop-all,
157
- another process) are always reflected immediately.
222
+ The registry is the single source of truth for server state there is
223
+ no in-memory cache of it, so every call consults the registry and
224
+ external actors (CLI stop-all, another process) are always reflected
225
+ immediately. The one piece of instance state this class keeps is
226
+ _owned — which keys *this instance* actually spawned via get_or_start(),
227
+ as opposed to attaching to a server already running elsewhere — so that
228
+ callers like OpenCodeRuntime.close() can stop what they started without
229
+ touching servers owned by someone else.
158
230
  """
159
231
 
160
- def find(self, key: str) -> RegistryEntry | None:
161
- """Return the registry entry for key, or None if not found."""
162
- from .registry import read as registry_read
232
+ def __init__(self) -> None:
233
+ self._owned: set[str] = set()
163
234
 
164
- return registry_read(key)
235
+ def find(self, key: str) -> RegistryEntry | None:
236
+ """Return the registry entry for key, or None if not found or still starting."""
237
+ entry = registry.read(key)
238
+ return entry if entry is not None and entry.state == ServerState.RUNNING else None
165
239
 
166
240
  def is_alive(self, key: str) -> bool:
167
241
  """Return True if the server for key is running."""
168
- from .registry import is_alive
169
- from .registry import read as registry_read
242
+ entry = self.find(key)
243
+ return entry is not None and registry.is_alive(entry.pid)
244
+
245
+ def touch(self, key: str) -> None:
246
+ """Update last_used_at timestamp for a server. Call after session creation."""
247
+ entry = registry.read(key)
248
+ if entry is not None and entry.state == ServerState.RUNNING:
249
+ entry.last_used_at = registry.now_iso()
250
+ registry.write(entry)
251
+
252
+ async def _status_for_entry(
253
+ self, entry: RegistryEntry, *, health_timeout: float = 3.0
254
+ ) -> ServerStatus:
255
+ """Derive a ServerStatus for an already-fetched registry entry."""
256
+ process_alive = (
257
+ _is_process_alive(entry.pid) if entry.state == ServerState.RUNNING else False
258
+ )
259
+ health_ok = False
260
+ if process_alive:
261
+ client = OpenCodeClient(
262
+ base_url=f"http://127.0.0.1:{entry.port}",
263
+ password=entry.password,
264
+ )
265
+ health_ok = await _is_health_ok(client, timeout=health_timeout)
266
+ display = _compute_display_status(entry.state, process_alive, health_ok)
267
+ return ServerStatus(
268
+ entry=entry, process_alive=process_alive, health_ok=health_ok, display=display
269
+ )
170
270
 
171
- entry = registry_read(key)
172
- return entry is not None and is_alive(entry.pid)
271
+ async def status(self, key: str, *, health_timeout: float = 3.0) -> ServerStatus | None:
272
+ """Return the computed status for key, or None if not in the registry."""
273
+ entry = registry.read(key)
274
+ if entry is None:
275
+ return None
276
+ return await self._status_for_entry(entry, health_timeout=health_timeout)
277
+
278
+ # NOTE: list() and list_statuses() must stay defined in this order — once
279
+ # `list` is bound as a class attribute (the method below), any later
280
+ # annotation in this class body that writes the bare name `list[...]`
281
+ # resolves to that method, not the builtin (see the Materials alias note
282
+ # up top for the same issue). list_statuses()'s `-> list[ServerStatus]`
283
+ # return annotation is defined above list() to avoid it.
284
+ async def list_statuses(self, *, health_timeout: float = 1.0) -> list[ServerStatus]:
285
+ """Return computed status for every ready registry entry."""
286
+ return [
287
+ await self._status_for_entry(entry, health_timeout=health_timeout)
288
+ for entry, _ in self.list()
289
+ ]
173
290
 
174
291
  def list(self) -> list[tuple[RegistryEntry, bool]]:
175
- """Return all registry entries with their liveness status."""
176
- from .registry import is_alive
177
- from .registry import list_all
178
-
179
- return [(entry, is_alive(entry.pid)) for entry in list_all()]
292
+ """Return all ready registry entries with their liveness status."""
293
+ return [
294
+ (entry, registry.is_alive(entry.pid))
295
+ for entry in registry.list_all()
296
+ if entry.state == ServerState.RUNNING
297
+ ]
180
298
 
181
299
  async def health(self, key: str) -> dict[str, Any]:
182
300
  """Return health info for the server at key.
@@ -184,13 +302,7 @@ class ServerManager:
184
302
  Raises ``OpenCodeServerError`` if key is not in the registry or the
185
303
  server is unreachable.
186
304
  """
187
- import httpx
188
-
189
- from .client import OpenCodeClient
190
- from .exceptions import OpenCodeServerError
191
- from .registry import read as registry_read
192
-
193
- entry = registry_read(key)
305
+ entry = self.find(key)
194
306
  if entry is None:
195
307
  raise OpenCodeServerError(f"server {key!r} not found in registry")
196
308
 
@@ -209,21 +321,15 @@ class ServerManager:
209
321
  Returns True if the process was alive and killed, False if it was
210
322
  already dead or not found in the registry.
211
323
  """
212
- from .registry import delete as registry_delete
213
- from .registry import is_alive
214
- from .registry import read as registry_read
215
-
216
- entry = registry_read(key)
324
+ entry = registry.read(key)
217
325
  if entry is None:
218
326
  return False
219
327
 
220
- registry_delete(key)
328
+ registry.delete(key)
221
329
 
222
- if not is_alive(entry.pid):
330
+ if entry.pid is None or not registry.is_alive(entry.pid):
223
331
  return False
224
332
 
225
- import signal
226
-
227
333
  try:
228
334
  os.kill(entry.pid, signal.SIGTERM)
229
335
  except ProcessLookupError:
@@ -231,7 +337,7 @@ class ServerManager:
231
337
  # Wait for the process to exit (up to 5s, then SIGKILL).
232
338
  deadline = asyncio.get_event_loop().time() + 5.0
233
339
  while asyncio.get_event_loop().time() < deadline:
234
- if not is_alive(entry.pid):
340
+ if not registry.is_alive(entry.pid):
235
341
  return True
236
342
  await asyncio.sleep(0.1)
237
343
  try:
@@ -242,54 +348,112 @@ class ServerManager:
242
348
 
243
349
  async def stop_all(self) -> None:
244
350
  """Kill all servers tracked in the registry."""
245
- from .registry import list_all
246
-
247
- for entry in list_all():
351
+ for entry in registry.list_all():
248
352
  await self.stop(entry.key)
249
353
 
354
+ async def stop_owned(self) -> None:
355
+ """Stop every server this instance itself spawned via get_or_start().
356
+
357
+ Servers this instance merely attached to — already running, started
358
+ by another process or the CLI — are left alone.
359
+ """
360
+ for key in list(self._owned):
361
+ await self.stop(key)
362
+ self._owned.clear()
363
+
250
364
  async def get_or_start(
251
365
  self,
252
366
  *,
253
367
  key: str,
254
368
  project_dir: Path,
255
369
  server_dir: Path | None,
256
- materials: str | Path | list[str | Path] | None,
370
+ materials: Materials,
257
371
  config: dict[str, Any],
258
372
  env: dict[str, str],
259
373
  workspace: str | None = None,
260
374
  user_id: str | None = None,
261
375
  ) -> _ManagedServer:
262
376
  """Return a client for the running server, starting one if needed."""
263
- from .client import OpenCodeClient
264
- from .registry import delete as registry_delete
265
- from .registry import is_alive
266
- from .registry import read as registry_read
267
-
268
- entry = registry_read(key)
269
- if entry is not None and is_alive(entry.pid):
270
- return _ManagedServer(
377
+ entry = registry.read(key)
378
+ if entry is not None and entry.state == ServerState.RUNNING:
379
+ if registry.is_alive(entry.pid):
380
+ return self._attach(entry)
381
+ # Stale — the process died after finishing startup.
382
+ registry.delete(key)
383
+
384
+ # port/password are generated here (not inside _start) because a
385
+ # 'starting' claim row needs both up front — pid is the only field
386
+ # that isn't known until the subprocess actually exists.
387
+ port = _find_free_port()
388
+ password = secrets.token_urlsafe(32)
389
+ timestamp = registry.now_iso()
390
+
391
+ claimed = registry.claim_starting(
392
+ RegistryEntry(
271
393
  key=key,
272
- process=None,
273
- client=OpenCodeClient(
274
- base_url=f"http://127.0.0.1:{entry.port}",
275
- password=entry.password,
276
- ),
277
- server_dir=Path(entry.server_dir) if entry.server_dir else None,
394
+ state=ServerState.STARTING,
395
+ pid=None,
396
+ port=port,
397
+ password=password,
398
+ project_dir=str(project_dir),
399
+ server_dir=str(server_dir) if server_dir else None,
400
+ started_at=timestamp,
401
+ claimed_at=timestamp,
402
+ workspace=workspace,
403
+ user_id=user_id,
278
404
  )
405
+ )
406
+ if not claimed:
407
+ # Another caller is already starting this key — wait for it
408
+ # instead of racing to spawn a second process for the same key.
409
+ return await self._wait_for_ready(key)
279
410
 
280
- # Not running — clean up stale registry entry and start fresh.
281
- if entry is not None:
282
- registry_delete(key)
411
+ try:
412
+ server = await self._start(
413
+ key=key,
414
+ project_dir=project_dir,
415
+ server_dir=server_dir,
416
+ materials=materials,
417
+ config=config,
418
+ env=env,
419
+ port=port,
420
+ password=password,
421
+ workspace=workspace,
422
+ user_id=user_id,
423
+ )
424
+ except Exception:
425
+ registry.delete(key)
426
+ raise
427
+ self._owned.add(key)
428
+ return server
283
429
 
284
- return await self._start(
285
- key=key,
286
- project_dir=project_dir,
287
- server_dir=server_dir,
288
- materials=materials,
289
- config=config,
290
- env=env,
291
- workspace=workspace,
292
- user_id=user_id,
430
+ def _attach(self, entry: RegistryEntry) -> _ManagedServer:
431
+ """Build a _ManagedServer client for an already-running registry entry."""
432
+ return _ManagedServer(
433
+ key=entry.key,
434
+ process=None,
435
+ client=OpenCodeClient(
436
+ base_url=f"http://127.0.0.1:{entry.port}",
437
+ password=entry.password,
438
+ ),
439
+ server_dir=Path(entry.server_dir) if entry.server_dir else None,
440
+ )
441
+
442
+ async def _wait_for_ready(self, key: str, timeout: float = 60.0) -> _ManagedServer:
443
+ """Poll the registry until the caller holding the start-claim finishes."""
444
+ deadline = asyncio.get_event_loop().time() + timeout
445
+ while asyncio.get_event_loop().time() < deadline:
446
+ entry = registry.read(key)
447
+ if entry is None:
448
+ raise OpenCodeServerError(
449
+ f"the caller starting server {key!r} failed before it became ready"
450
+ )
451
+ if entry.state == ServerState.RUNNING and registry.is_alive(entry.pid):
452
+ return self._attach(entry)
453
+ await asyncio.sleep(0.1)
454
+
455
+ raise OpenCodeTimeoutError(
456
+ f"timed out waiting for another caller to start the server for key {key!r}"
293
457
  )
294
458
 
295
459
  async def _start(
@@ -298,44 +462,33 @@ class ServerManager:
298
462
  key: str,
299
463
  project_dir: Path,
300
464
  server_dir: Path | None,
301
- materials: str | Path | list[str | Path] | None,
465
+ materials: Materials,
302
466
  config: dict[str, Any],
303
467
  env: dict[str, str],
468
+ port: int,
469
+ password: str,
304
470
  workspace: str | None = None,
305
471
  user_id: str | None = None,
306
472
  ) -> _ManagedServer:
307
473
  """Start a new OpenCode instance and return a _ManagedServer."""
308
- from .client import OpenCodeClient
309
- from .exceptions import OpenCodeNotFoundError
310
-
311
474
  if shutil.which("opencode") is None:
312
475
  raise OpenCodeNotFoundError(
313
476
  "opencode binary not found on PATH. Install it with: npm install -g opencode-ai"
314
477
  )
315
478
 
316
- if server_dir is not None:
317
- server_dir.mkdir(parents=True, exist_ok=True)
318
- (server_dir / "tmp").mkdir(exist_ok=True)
319
- _prepare_dir(server_dir, config, materials)
320
-
321
- port = _find_free_port()
322
- password = secrets.token_urlsafe(32)
323
-
324
479
  process_env = {**os.environ, **env}
325
480
  process_env["OPENCODE_SERVER_PASSWORD"] = password
326
481
 
327
482
  if server_dir is not None:
483
+ server_dir.mkdir(parents=True, exist_ok=True)
484
+ (server_dir / "tmp").mkdir(exist_ok=True)
485
+ _prepare_dir(server_dir, config, materials)
328
486
  process_env["HOME"] = str(server_dir)
329
487
  process_env["TMPDIR"] = str(server_dir / "tmp")
330
488
  process_env["OPENCODE_CONFIG"] = str(server_dir / "opencode.json")
331
-
332
- if server_dir is not None:
333
- log_file = open(server_dir / "opencode.log", "ab")
334
- stdout = log_file
335
- stderr = log_file
489
+ output: Any = open(server_dir / "opencode.log", "ab")
336
490
  else:
337
- stdout = asyncio.subprocess.DEVNULL
338
- stderr = asyncio.subprocess.DEVNULL
491
+ output = asyncio.subprocess.DEVNULL
339
492
 
340
493
  process = await asyncio.create_subprocess_exec(
341
494
  "opencode",
@@ -346,8 +499,8 @@ class ServerManager:
346
499
  str(port),
347
500
  cwd=str(project_dir),
348
501
  env=process_env,
349
- stdout=stdout,
350
- stderr=stderr,
502
+ stdout=output,
503
+ stderr=output,
351
504
  )
352
505
 
353
506
  client = OpenCodeClient(
@@ -369,18 +522,20 @@ class ServerManager:
369
522
  await _terminate_process(process)
370
523
  raise
371
524
 
372
- from .registry import RegistryEntry, now_iso
373
- from .registry import write as registry_write
374
-
375
- registry_write(
525
+ timestamp = registry.now_iso()
526
+ registry.write(
376
527
  RegistryEntry(
377
528
  key=key,
529
+ state=ServerState.RUNNING,
378
530
  pid=process.pid,
379
531
  port=port,
380
532
  password=password,
381
533
  project_dir=str(project_dir),
382
534
  server_dir=str(server_dir) if server_dir else None,
383
- started_at=now_iso(),
535
+ started_at=timestamp,
536
+ claimed_at=timestamp,
537
+ last_used_at=timestamp,
538
+ runtime_version=version("opencode-runtime"),
384
539
  workspace=workspace,
385
540
  user_id=user_id,
386
541
  )
@@ -147,6 +147,6 @@ class OpenCodeSession:
147
147
  if self.session_id is not None:
148
148
  await self._client.post(f"/session/{self.session_id}/abort", {})
149
149
 
150
- async def close(self) -> None:
151
- """Release conversation-level resources."""
150
+ async def reset(self) -> None:
151
+ """Forget the current conversation. The next ask()/stream() call starts a new one."""
152
152
  self.session_id = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opencode-runtime
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Embed OpenCode in your Python application.
5
5
  Project-URL: Homepage, https://github.com/ashish16052/opencode-runtime
6
6
  Project-URL: Repository, https://github.com/ashish16052/opencode-runtime
@@ -91,14 +91,15 @@ opencode-runtime ps
91
91
  ```
92
92
 
93
93
  ```
94
- ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
95
- ──────────────────────────────────────────────────────────────────────────────────
96
- 39dce5beb4debfaa 12051 58409 ● alive Up 5m org_a u_1 ~/Developer/myproject
97
- 81fa29acb3e9210f 12088 58411 ● alive Up 3m org_b u_2 ~/Developer/myproject
94
+ ID PID PORT STATUS UPTIME WORKSPACE USER PROJECT
95
+ ───────────────────────────────────────────────────────────────────────────────────
96
+ 39dce5beb4debfaa 12051 58409 ● running 5m org_a u_1 ~/Developer/myproject
97
+ 81fa29acb3e9210f 12088 58411 ● running 3m org_b u_2 ~/Developer/myproject
98
98
  ```
99
99
 
100
100
  ```sh
101
101
  opencode-runtime health 39dce5beb4debfaa
102
+ opencode-runtime inspect 39dce5beb4debfaa
102
103
  opencode-runtime stop-all
103
104
  ```
104
105
 
@@ -0,0 +1,17 @@
1
+ opencode_runtime/__init__.py,sha256=fK91fH2ELtCTVAlMQet6T1N8A4P9phbocbZvKJVXpRI,362
2
+ opencode_runtime/cli.py,sha256=tRrDaolRx_EEByAeWiIHaw7i-HVtCkFR_hTMnQsvIiI,13446
3
+ opencode_runtime/client.py,sha256=EJ9bRsItC8_AdeYLSXTLNllEjlo5xlbLc6jsAuv7EcE,9124
4
+ opencode_runtime/event.py,sha256=6-mIAGEURP-sfj2zGABe1O8h9Phd2ao0iqmDOr1PDGI,763
5
+ opencode_runtime/exceptions.py,sha256=d6X7SFVnzUM_OHNzAuJ_qTkhRID1yNPchIaTepYGD4o,753
6
+ opencode_runtime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ opencode_runtime/registry.py,sha256=-tymxyEM6bd6KHLB5Gao5lav83hnPB5NtUlu8I-q6WQ,6292
8
+ opencode_runtime/response.py,sha256=MmT4qiakWD55J8cFg64VOztusfWkynrucdjbm0srNks,541
9
+ opencode_runtime/runtime.py,sha256=4b4c-cdZyUvTfy-GoWJwvgowm5wQCucP5llAV_dSSts,5488
10
+ opencode_runtime/schema.py,sha256=xPTKUKdgW9Oo0YqNBsO4vqIZ4fwJFBbxnt7iv1u3bSE,2889
11
+ opencode_runtime/server.py,sha256=DKVaJk29peJ0QsqfQBNnzPOVygBwQetM4rmTVCJra4s,19139
12
+ opencode_runtime/session.py,sha256=wUNgh6q12_ZqHitlPVJ-axRxK6XL30ll4nvwqFHRXXw,5819
13
+ opencode_runtime-0.5.0.dist-info/METADATA,sha256=HcCuN-_IT3sRAWE_Gt6XBzjHIgLQyWDa7zvmhLkl9tI,4227
14
+ opencode_runtime-0.5.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
15
+ opencode_runtime-0.5.0.dist-info/entry_points.txt,sha256=4lbdu1KELOg8TD6F7gC-Xrt3VJXdk3RTaH8cQHVy-Tc,63
16
+ opencode_runtime-0.5.0.dist-info/licenses/LICENSE,sha256=EVYfwpsynn_pB9fQ0OhMX-ghTZM10CrFAPv1JbLODi4,11343
17
+ opencode_runtime-0.5.0.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- opencode_runtime/__init__.py,sha256=RNZO_09NXusYD_xhbeJLb2HCp_0JeogNEi6247mPcL4,362
2
- opencode_runtime/cli.py,sha256=byy2ezufQvk5f_e1nwDN8ZIFflIwgvuC9SX7HQGYTkU,8938
3
- opencode_runtime/client.py,sha256=XZw413XiTOaAeKY5WufmMFsDAlullel50i_N5A0wYyM,9069
4
- opencode_runtime/event.py,sha256=7tWTLXTVUB6g3Ni7jiG2VYdHhjU9rpiwrY0MT1RnEMQ,960
5
- opencode_runtime/exceptions.py,sha256=uarBFCh8ovI3w4ZB4oQuffv97-5nwTdkZQrkBcOWtWs,471
6
- opencode_runtime/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- opencode_runtime/registry.py,sha256=rxr3D4iqceskmPBYvRC_lbtME8hoMR4I6rsdYCqllLA,2309
8
- opencode_runtime/response.py,sha256=MmT4qiakWD55J8cFg64VOztusfWkynrucdjbm0srNks,541
9
- opencode_runtime/runtime.py,sha256=magKzC-kN7g9bsttNhSLEif9hD92_qrHgJgvXDOauYU,5255
10
- opencode_runtime/server.py,sha256=eu2JwLwkpICRviZx4OmN2z2_gVWCkfnm4qhcQRW3bSw,12595
11
- opencode_runtime/session.py,sha256=9ES_mVvXWFhLPNnFFY9KwvRi_4zoVNTe8EToU8oe3ng,5777
12
- opencode_runtime-0.4.1.dist-info/METADATA,sha256=IkOu8zi4YGNidowr_OUYpypcvCYsq4R1-z8ul8-ocZc,4179
13
- opencode_runtime-0.4.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
- opencode_runtime-0.4.1.dist-info/entry_points.txt,sha256=4lbdu1KELOg8TD6F7gC-Xrt3VJXdk3RTaH8cQHVy-Tc,63
15
- opencode_runtime-0.4.1.dist-info/licenses/LICENSE,sha256=EVYfwpsynn_pB9fQ0OhMX-ghTZM10CrFAPv1JbLODi4,11343
16
- opencode_runtime-0.4.1.dist-info/RECORD,,